diff --git a/backend/src/main/java/com/dday/domain/auth/controller/AuthController.java b/backend/src/main/java/com/dday/domain/auth/controller/AuthController.java index 1b25e16..99ea4b8 100644 --- a/backend/src/main/java/com/dday/domain/auth/controller/AuthController.java +++ b/backend/src/main/java/com/dday/domain/auth/controller/AuthController.java @@ -21,7 +21,7 @@ public class AuthController { private final AuthService authService; @Operation(summary = "휴대폰 인증번호 발송", description = """ - 실제 SMS는 보내지 않는다. 인증번호는 항상 `000000`이고 3분 뒤 만료된다. + 실제 SMS는 보내지 않는다. 인증번호는 항상 `371829`이고 3분 뒤 만료된다. """) @PostMapping("/phone/send") public ResponseEntity> sendPhoneCode( diff --git a/backend/src/main/java/com/dday/domain/auth/service/AuthService.java b/backend/src/main/java/com/dday/domain/auth/service/AuthService.java index 992b5bc..fb53b45 100644 --- a/backend/src/main/java/com/dday/domain/auth/service/AuthService.java +++ b/backend/src/main/java/com/dday/domain/auth/service/AuthService.java @@ -34,7 +34,7 @@ public class AuthService { /** 목 인증번호. 실 발송을 붙이는 순간 함께 제거한다. */ - private static final String MOCK_CODE = "000000"; + private static final String MOCK_CODE = "371829"; /** 인증번호 유효시간(초). 응답의 expiresIn으로도 나간다. */ private static final int CODE_EXPIRES_IN_SECONDS = 180; diff --git a/backend/src/main/java/com/dday/domain/mydata/dto/response/MydataConnectResponse.java b/backend/src/main/java/com/dday/domain/mydata/dto/response/MydataConnectResponse.java index 2ab483b..89fa79d 100644 --- a/backend/src/main/java/com/dday/domain/mydata/dto/response/MydataConnectResponse.java +++ b/backend/src/main/java/com/dday/domain/mydata/dto/response/MydataConnectResponse.java @@ -1,40 +1,61 @@ package com.dday.domain.mydata.dto.response; +import com.dday.domain.mydata.entity.AccountType; import com.dday.domain.mydata.entity.Institution; import com.dday.domain.mydata.entity.UserAccount; +import com.dday.domain.mydata.entity.UserCard; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * 마이데이터 연결 결과. 연동 완료 화면과 "모아둔 돈" 화면이 함께 쓴다. * - *

연결과 첫 동기화가 한 번에 끝나므로 불러온 계좌를 그대로 담아 준다 — - * 프론트가 연결 직후 계좌 목록을 또 부를 필요가 없다. + *

연결과 첫 동기화가 한 번에 끝나므로 불러온 계좌·카드를 그대로 담아 준다 — + * 프론트가 연결 직후 목록을 또 부를 필요가 없다. */ @Getter @Builder @AllArgsConstructor public class MydataConnectResponse { - /** 연결된 계좌 수. 완료 화면이 "N개의 금융기관이 연결되었습니다"에 쓴다. */ + /** + * 연결된 기관 수 — 계좌·카드 행 수가 아니다. 완료 화면이 + * "N개의 금융기관이 연결되었습니다"에 쓰는데, 국민은행 계좌가 3개라고 3개 기관은 + * 아니다. {@link #institutions}의 크기와 같다. + */ private final int connectedCount; private final List accounts; + /** 연결된 기관 목록(계좌·카드 통틀어 중복 제거). 완료 화면의 체크리스트가 이걸 그린다. */ + private final List institutions; + /** 이번에 새로 저장된 거래 수. 재연결이면 0이다(이미 있는 거래는 건너뛴다). */ private final int insertedTransactionCount; - public static MydataConnectResponse of(List userAccounts, int insertedTransactionCount) { + public static MydataConnectResponse of(List userAccounts, List userCards, + int insertedTransactionCount) { List accounts = userAccounts.stream() .map(ConnectedAccount::from) .toList(); + // orgCode로 중복을 걸러내되, 처음 등장한 순서(계좌 먼저, 카드는 그 다음)를 유지한다. + Map institutionsByOrgCode = new LinkedHashMap<>(); + userAccounts.forEach(account -> institutionsByOrgCode + .computeIfAbsent(account.getOrgCode(), ConnectedInstitution::of)); + userCards.forEach(card -> institutionsByOrgCode + .computeIfAbsent(card.getOrgCode(), ConnectedInstitution::of)); + List institutions = List.copyOf(institutionsByOrgCode.values()); + return MydataConnectResponse.builder() - .connectedCount(accounts.size()) + .connectedCount(institutions.size()) .accounts(accounts) + .institutions(institutions) .insertedTransactionCount(insertedTransactionCount) .build(); } @@ -61,12 +82,19 @@ public static class ConnectedAccount { private final Long balance; + /** + * DEPOSIT · SAVINGS · LOAN · INVESTMENT · ETC. "모아둔 자산" 화면이 대출 잔액을 + * 자산으로 합산하지 않으려면 이 값으로 걸러야 한다 — 대출은 빚이지 자산이 아니다. + */ + private final AccountType accountType; + public static ConnectedAccount from(UserAccount account) { return ConnectedAccount.builder() .institutionId(account.getOrgCode()) .institutionName(Institution.nameOf(account.getOrgCode())) .accountNumber(mask(account.getAccountNum())) .balance(account.getBalance()) + .accountType(account.getAccountType()) .build(); } @@ -81,4 +109,21 @@ private static String mask(String accountNum) { return "****" + accountNum.substring(accountNum.length() - 4); } } + + /** 연동된 기관 하나(계좌든 카드든). 완료 화면이 "무엇을 연결했는지"만 보여줄 때 쓴다. */ + @Getter + @Builder + @AllArgsConstructor + public static class ConnectedInstitution { + + private final String institutionId; + private final String institutionName; + + static ConnectedInstitution of(String orgCode) { + return ConnectedInstitution.builder() + .institutionId(orgCode) + .institutionName(Institution.nameOf(orgCode)) + .build(); + } + } } diff --git a/backend/src/main/java/com/dday/domain/mydata/entity/Institution.java b/backend/src/main/java/com/dday/domain/mydata/entity/Institution.java index 518147b..2ab70f2 100644 --- a/backend/src/main/java/com/dday/domain/mydata/entity/Institution.java +++ b/backend/src/main/java/com/dday/domain/mydata/entity/Institution.java @@ -32,7 +32,7 @@ public enum Institution { HYUNDAI_CARD("0302", "현대카드", FinancialSector.NON_BANK), SAMSUNG_CARD("0303", "삼성카드", FinancialSector.NON_BANK), - KB_CARD("0301", "KB국민카드", FinancialSector.NON_BANK), + KB_CARD("0301", "국민카드", FinancialSector.NON_BANK), SHINHAN_CARD("0306", "신한카드", FinancialSector.NON_BANK), WOORI_CARD("0313", "우리카드", FinancialSector.NON_BANK), diff --git a/backend/src/main/java/com/dday/domain/mydata/repository/UserAccountRepository.java b/backend/src/main/java/com/dday/domain/mydata/repository/UserAccountRepository.java index 8216264..701491e 100644 --- a/backend/src/main/java/com/dday/domain/mydata/repository/UserAccountRepository.java +++ b/backend/src/main/java/com/dday/domain/mydata/repository/UserAccountRepository.java @@ -15,6 +15,13 @@ Optional findByUserUserIdAndOrgCodeAndAccountNum( /** 표시 순서가 화면에서 바뀌면 안 되니 등록 순서(id)로 고정한다. */ List findAllByUserUserIdOrderByAccountIdAsc(Long userId); + /** + * 연동 직후 응답(연결된 기관·계좌 수)에 쓴다. 비활성 계좌를 뺀다 — 안 그러면 + * 기관을 정리해도(예: 신한 계좌 삭제) 예전에 연동했던 회원은 죽은 계좌까지 세어 + * "연결된 기관 수"가 실제보다 많게 나온다. + */ + List findAllByUserUserIdAndActiveTrueOrderByAccountIdAsc(Long userId); + /** * id만으로 찾지 않고 소유자를 함께 건다. 마이페이지에서 계좌 선택여부를 바꿀 때 * 이 메서드를 쓰면 남의 계좌를 건드리는 요청이 조회 단계에서 빈 값이 되어 애초에 진행되지 않는다. diff --git a/backend/src/main/java/com/dday/domain/mydata/repository/UserCardRepository.java b/backend/src/main/java/com/dday/domain/mydata/repository/UserCardRepository.java index 359bb08..d11bc7a 100644 --- a/backend/src/main/java/com/dday/domain/mydata/repository/UserCardRepository.java +++ b/backend/src/main/java/com/dday/domain/mydata/repository/UserCardRepository.java @@ -17,6 +17,9 @@ Optional findByUserUserIdAndOrgCodeAndCardIdentifier( /** 표시 순서가 화면에서 바뀌면 안 되니 등록 순서(id)로 고정한다. */ List findAllByUserUserIdOrderByCardIdAsc(Long userId); + /** 연동 직후 응답에 쓴다. 비활성 카드를 빼는 이유는 {@link UserAccountRepository}와 같다. */ + List findAllByUserUserIdAndActiveTrueOrderByCardIdAsc(Long userId); + /** 기관 연결 해제 대상을 찾는다. 같은 기관 카드가 여러 개일 수 있어 리스트다. */ List findAllByUserUserIdAndOrgCode(Long userId, String orgCode); diff --git a/backend/src/main/java/com/dday/domain/mydata/service/MydataConnectService.java b/backend/src/main/java/com/dday/domain/mydata/service/MydataConnectService.java index 432dca6..6a5aed7 100644 --- a/backend/src/main/java/com/dday/domain/mydata/service/MydataConnectService.java +++ b/backend/src/main/java/com/dday/domain/mydata/service/MydataConnectService.java @@ -3,6 +3,7 @@ import com.dday.domain.mockmydata.service.MockMydataProvisioner; import com.dday.domain.mydata.dto.response.MydataConnectResponse; import com.dday.domain.mydata.repository.UserAccountRepository; +import com.dday.domain.mydata.repository.UserCardRepository; import com.dday.domain.pocket.service.PocketService; import com.dday.domain.mydata.dto.response.MydataSyncResponse; import lombok.RequiredArgsConstructor; @@ -49,6 +50,7 @@ public class MydataConnectService { private final MockMydataProvisioner mockMydataProvisioner; private final MydataService mydataService; private final UserAccountRepository userAccountRepository; + private final UserCardRepository userCardRepository; private final PocketService pocketService; /** @@ -70,8 +72,11 @@ public MydataConnectResponse connect(Long userId) { log.info("마이데이터 연결: userId={}, 목데이터 복제={}", userId, provisioned); // 동기화가 끝난 뒤 읽어야 이번에 새로 붙은 계좌까지 응답에 담긴다. + // 비활성 계좌·카드는 뺀다 — 기관을 정리한 뒤에도 예전에 연동했던 회원은 죽은 + // 행까지 세어 "연결된 기관 수"가 실제보다 많게 나오면 안 된다. return MydataConnectResponse.of( - userAccountRepository.findAllByUserUserIdOrderByAccountIdAsc(userId), + userAccountRepository.findAllByUserUserIdAndActiveTrueOrderByAccountIdAsc(userId), + userCardRepository.findAllByUserUserIdAndActiveTrueOrderByCardIdAsc(userId), sync.getInsertedTransactionCount()); } } diff --git a/frontend/src/api/mydata.ts b/frontend/src/api/mydata.ts index 239eb6b..da1c220 100644 --- a/frontend/src/api/mydata.ts +++ b/frontend/src/api/mydata.ts @@ -19,8 +19,13 @@ import logoKakaobank from '@/assets/logos/kakaobank.svg' import logoTossbank from '@/assets/logos/tossbank.svg' import logoHyundaiCard from '@/assets/logos/hyundai-card.svg' import logoSamsungCard from '@/assets/logos/samsung-card.png' +import logoHyundaiCapital from '@/assets/logos/hyundai-capital.svg' +import logoLotteCapital from '@/assets/logos/lotte-capital.svg' +import logoWooriCapital from '@/assets/logos/woori-capital.svg' +import logoKbCapital from '@/assets/logos/kb-capital.svg' +import logoHanaCapital from '@/assets/logos/hana-capital.svg' -export type InstitutionCategory = 'BANK' | 'CARD' | 'SECURITIES' +export type InstitutionCategory = 'BANK' | 'CARD' | 'CAPITAL' | 'SECURITIES' export interface Institution { /** 기관 코드(백엔드 `Institution` enum의 code). 연동·해제 요청 때 이 값을 보낸다 */ @@ -47,14 +52,24 @@ export interface ConnectedAccount { /** 마스킹된 계좌번호 (예: 110-***-4567) */ accountNumber: string balance: number + /** DEPOSIT · SAVINGS · LOAN · INVESTMENT · ETC. 대출은 자산이 아니라 빚이라 합산에서 뺀다 */ + accountType: AccountType +} + +/** 연동된 기관 하나(계좌든 카드든, 중복 제거됨). 완료 화면의 체크리스트가 이걸 그린다 */ +export interface ConnectedInstitution { + institutionId: string + institutionName: string } export interface ConnectResponse { + /** 연결된 "기관" 수다 — accounts 배열 길이가 아니다. institutions.length와 같다 */ connectedCount: number accounts: ConnectedAccount[] + institutions: ConnectedInstitution[] } -export type AccountType = 'DEPOSIT' | 'SAVINGS' | 'LOAN' +export type AccountType = 'DEPOSIT' | 'SAVINGS' | 'LOAN' | 'INVESTMENT' | 'ETC' /** 연동 계좌 한 건. 기관 이름은 안 내려온다 — orgCode만으로 표시해야 하면 서버에 필드 추가가 필요하다 */ export interface UserAccount { @@ -87,10 +102,12 @@ export interface UserCard { const delay = (ms = 400) => new Promise((resolve) => setTimeout(resolve, ms)) /** - * 은행 7개 + 카드사 5개 — 백엔드 `Institution` enum과 코드를 맞췄다(대출 전용 기관인 - * 현대캐피탈·SBI저축은행은 연동 화면에서 고를 대상이 아니라 뺐다). + * 은행 7개 + 카드사 5개 + 캐피탈 5개 — 백엔드 `Institution` enum과 코드를 맞췄다. * - * 대표 3개(`popular`)는 국내 이용자 수 기준 상위권 은행/카드사로 골랐다. + * **분류마다 우리가 실제로 데모 데이터를 붙여둔 기관(국민은행·국민카드·우리카드· + * 현대캐피탈)을 배열 맨 앞, `popular`로 둔다.** 무엇을 고르든 서버는 이 4곳만 + * 연동해주기 때문에, 목록 첫 화면에서부터 실제로 연동될 기관이 먼저 보여야 + * "왜 고른 게 아니라 딴 게 연결됐지"라는 혼란이 없다. */ const MOCK_INSTITUTIONS: Institution[] = [ { institutionId: '004', name: '국민은행', category: 'BANK', logoUrl: logoKookmin, popular: true }, @@ -101,11 +118,17 @@ const MOCK_INSTITUTIONS: Institution[] = [ { institutionId: '011', name: '농협은행', category: 'BANK', logoUrl: logoNonghyup }, { institutionId: '092', name: '토스뱅크', category: 'BANK', logoUrl: logoTossbank }, + { institutionId: '0301', name: '국민카드', category: 'CARD', logoUrl: logoKookmin, popular: true }, + { institutionId: '0313', name: '우리카드', category: 'CARD', logoUrl: logoWoori, popular: true }, { institutionId: '0306', name: '신한카드', category: 'CARD', logoUrl: logoShinhan, popular: true }, - { institutionId: '0301', name: 'KB국민카드', category: 'CARD', logoUrl: logoKookmin, popular: true }, - { institutionId: '0303', name: '삼성카드', category: 'CARD', logoUrl: logoSamsungCard, popular: true }, + { institutionId: '0303', name: '삼성카드', category: 'CARD', logoUrl: logoSamsungCard }, { institutionId: '0302', name: '현대카드', category: 'CARD', logoUrl: logoHyundaiCard }, - { institutionId: '0313', name: '우리카드', category: 'CARD', logoUrl: logoWoori }, + + { institutionId: '0602', name: '현대캐피탈', category: 'CAPITAL', logoUrl: logoHyundaiCapital, popular: true }, + { institutionId: '0603', name: '롯데캐피탈', category: 'CAPITAL', logoUrl: logoLotteCapital, popular: true }, + { institutionId: '0604', name: 'KB캐피탈', category: 'CAPITAL', logoUrl: logoKbCapital, popular: true }, + { institutionId: '0605', name: '우리금융캐피탈', category: 'CAPITAL', logoUrl: logoWooriCapital }, + { institutionId: '0606', name: '하나캐피탈', category: 'CAPITAL', logoUrl: logoHanaCapital }, ] export const mydataApi = { diff --git a/frontend/src/assets/logos/hana-capital.svg b/frontend/src/assets/logos/hana-capital.svg new file mode 100644 index 0000000..0ce9f21 --- /dev/null +++ b/frontend/src/assets/logos/hana-capital.svg @@ -0,0 +1,4 @@ + + + 하나캐피탈 + diff --git a/frontend/src/assets/logos/hyundai-capital.svg b/frontend/src/assets/logos/hyundai-capital.svg new file mode 100644 index 0000000..2aa55bc --- /dev/null +++ b/frontend/src/assets/logos/hyundai-capital.svg @@ -0,0 +1,4 @@ + + + 현대캐피탈 + diff --git a/frontend/src/assets/logos/kb-capital.svg b/frontend/src/assets/logos/kb-capital.svg new file mode 100644 index 0000000..267b4f3 --- /dev/null +++ b/frontend/src/assets/logos/kb-capital.svg @@ -0,0 +1,4 @@ + + + KB캐피탈 + diff --git a/frontend/src/assets/logos/lotte-capital.svg b/frontend/src/assets/logos/lotte-capital.svg new file mode 100644 index 0000000..f30169f --- /dev/null +++ b/frontend/src/assets/logos/lotte-capital.svg @@ -0,0 +1,4 @@ + + + 롯데캐피탈 + diff --git a/frontend/src/assets/logos/woori-capital.svg b/frontend/src/assets/logos/woori-capital.svg new file mode 100644 index 0000000..3c6e137 --- /dev/null +++ b/frontend/src/assets/logos/woori-capital.svg @@ -0,0 +1,4 @@ + + + 우리금융캐피탈 + diff --git a/frontend/src/views/onboarding/AssetsSavedView.vue b/frontend/src/views/onboarding/AssetsSavedView.vue index ad2291f..12f3ac0 100644 --- a/frontend/src/views/onboarding/AssetsSavedView.vue +++ b/frontend/src/views/onboarding/AssetsSavedView.vue @@ -35,9 +35,11 @@ async function connectMydata() { const result = await mydataApi.connect({ institutionIds: bankInstitutions.map((i) => i.institutionId), }) + // 대출 계좌는 뺀다 — 대출 잔액은 갚아야 할 빚이지 "모아둔 자산"이 아니다. // 기관 이름은 서버가 내려준 값을 그대로 쓴다. 프론트에서 코드를 이름으로 바꾸지 않는다. - accounts.value = result.accounts - onboarding.totalSaved = result.accounts.reduce((sum, account) => sum + account.balance, 0) + const savedAccounts = result.accounts.filter((account) => account.accountType !== 'LOAN') + accounts.value = savedAccounts + onboarding.totalSaved = savedAccounts.reduce((sum, account) => sum + account.balance, 0) connected.value = true } catch (e) { accounts.value = [] diff --git a/frontend/src/views/signup/MydataDoneView.vue b/frontend/src/views/signup/MydataDoneView.vue index c5a9aef..6dae0d2 100644 --- a/frontend/src/views/signup/MydataDoneView.vue +++ b/frontend/src/views/signup/MydataDoneView.vue @@ -1,7 +1,6 @@