diff --git a/src/main/java/org/umc/valuedi/domain/asset/controller/AssetController.java b/src/main/java/org/umc/valuedi/domain/asset/controller/AssetController.java index 60f5fca8..3b2d78fe 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/controller/AssetController.java +++ b/src/main/java/org/umc/valuedi/domain/asset/controller/AssetController.java @@ -2,19 +2,19 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.umc.valuedi.domain.asset.dto.res.AssetResDTO; import org.umc.valuedi.domain.asset.dto.res.BankResDTO; import org.umc.valuedi.domain.asset.dto.res.CardResDTO; +import org.umc.valuedi.domain.asset.exception.code.AssetSuccessCode; import org.umc.valuedi.domain.asset.service.query.AssetQueryService; import org.umc.valuedi.domain.connection.service.query.ConnectionQueryService; import org.umc.valuedi.global.apiPayload.ApiResponse; import org.umc.valuedi.global.apiPayload.code.GeneralSuccessCode; import org.umc.valuedi.global.security.annotation.CurrentMember; +import java.time.LocalDate; +import java.time.YearMonth; import java.util.List; @RestController @@ -78,4 +78,36 @@ public ApiResponse getAssetCount( ) { return ApiResponse.onSuccess(GeneralSuccessCode.OK, assetQueryService.getAssetSummaryCount(memberId)); } + + @Override + @GetMapping("/accounts/{accountId}/transactions") + public ApiResponse getAccountTransactions( + @PathVariable Long accountId, + @CurrentMember Long memberId, + @RequestParam(required = false) YearMonth yearMonth, + @RequestParam(required = false) LocalDate date, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess( + AssetSuccessCode.ACCOUNT_TRANSACTIONS_FETCHED, + assetQueryService.getAccountTransactions(memberId, accountId, yearMonth, date, page, size) + ); + } + + @Override + @GetMapping("/cards/{cardId}/transactions") + public ApiResponse getCardTransactions( + @PathVariable Long cardId, + @CurrentMember Long memberId, + @RequestParam(required = false) YearMonth yearMonth, + @RequestParam(required = false) LocalDate date, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess( + AssetSuccessCode.CARD_TRANSACTIONS_FETCHED, + assetQueryService.getCardTransactions(memberId, cardId, yearMonth, date, page, size) + ); + } } diff --git a/src/main/java/org/umc/valuedi/domain/asset/controller/AssetControllerDocs.java b/src/main/java/org/umc/valuedi/domain/asset/controller/AssetControllerDocs.java index b76f3c05..9ec1cf57 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/controller/AssetControllerDocs.java +++ b/src/main/java/org/umc/valuedi/domain/asset/controller/AssetControllerDocs.java @@ -13,6 +13,8 @@ import org.umc.valuedi.global.apiPayload.ApiResponse; import org.umc.valuedi.global.security.annotation.CurrentMember; +import java.time.LocalDate; +import java.time.YearMonth; import java.util.List; @Tag(name = "Asset", description = "자산(계좌/카드) 관련 API") @@ -319,4 +321,157 @@ ApiResponse getAccountsByBank( ) }) ApiResponse getAssetCount(@CurrentMember Long memberId); + + @Operation( + summary = "특정 계좌 거래내역 조회", + description = """ + 계좌의 거래내역을 최신순으로 페이징 조회합니다. + - yearMonth, date 미입력 시 전체 내역을 조회합니다. + - transactionType: INCOME(입금), EXPENSE(출금) + - afterBalance: 해당 거래 직후의 잔액 + - currentBalance: 계좌의 현재 잔액 (가장 최근 동기화 기준) + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공 - 계좌 거래내역 반환", + content = @Content( + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + name = "성공 예시", + value = """ + { + "isSuccess": true, + "code": "ASSET200_1", + "message": "계좌 거래내역 조회에 성공했습니다.", + "result": { + "totalElements": 25, + "page": 0, + "size": 20, + "totalPages": 2, + "organizationCode": "0020", + "assetName": "저축예금", + "assetNumber": "123-456-789012", + "currentBalance": 1250000, + "content": [ + { + "transactionAt": "2026-02-13T14:30:00", + "title": "모바일 OOO 토스뱅크", + "amount": 50000, + "transactionType": "INCOME", + "categoryCode": "TRANSFER", + "categoryName": "이체", + "afterBalance": 1250000 + } + ] + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "계좌를 찾을 수 없음", + content = @Content( + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + name = "실패 예시", + value = """ + { + "isSuccess": false, + "code": "ASSET404_1", + "message": "존재하지 않거나 접근 권한이 없는 계좌입니다.", + "result": null + } + """ + ) + ) + ) + }) + ApiResponse getAccountTransactions( + @Parameter(description = "계좌 ID", required = true, example = "1") Long accountId, + @CurrentMember Long memberId, + @Parameter(description = "조회 년월 (YYYY-MM)", example = "2026-02") YearMonth yearMonth, + @Parameter(description = "특정 일자 필터 (YYYY-MM-DD)", example = "2026-02-13") LocalDate date, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") int page, + @Parameter(description = "페이지 크기", example = "20") int size + ); + + @Operation( + summary = "특정 카드 승인내역 조회", + description = """ + 카드의 승인내역을 최신순으로 페이징 조회합니다. + - yearMonth, date 미입력 시 전체 내역을 조회합니다. + - transactionType: EXPENSE(일반 결제), INCOME(취소/환불) + - currentBalance, afterBalance는 항상 null입니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공 - 카드 승인내역 반환", + content = @Content( + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + name = "성공 예시", + value = """ + { + "isSuccess": true, + "code": "ASSET200_2", + "message": "카드 승인내역 조회에 성공했습니다.", + "result": { + "totalElements": 12, + "page": 0, + "size": 20, + "totalPages": 1, + "organizationCode": "0309", + "assetName": "신한카드 Deep Dream", + "assetNumber": "5336-****-****-5678", + "currentBalance": null, + "content": [ + { + "transactionAt": "2026-02-13T18:20:00", + "title": "스시초밥", + "amount": 16500, + "transactionType": "EXPENSE", + "categoryCode": "FOOD", + "categoryName": "식비", + "afterBalance": null + } + ] + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "카드를 찾을 수 없음", + content = @Content( + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + name = "실패 예시", + value = """ + { + "isSuccess": false, + "code": "ASSET404_2", + "message": "존재하지 않거나 접근 권한이 없는 카드입니다.", + "result": null + } + """ + ) + ) + ) + }) + ApiResponse getCardTransactions( + @Parameter(description = "카드 ID", required = true, example = "1") Long cardId, + @CurrentMember Long memberId, + @Parameter(description = "조회 년월 (YYYY-MM)", example = "2026-02") YearMonth yearMonth, + @Parameter(description = "특정 일자 필터 (YYYY-MM-DD)", example = "2026-02-13") LocalDate date, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") int page, + @Parameter(description = "페이지 크기", example = "20") int size + ); } diff --git a/src/main/java/org/umc/valuedi/domain/asset/converter/AssetConverter.java b/src/main/java/org/umc/valuedi/domain/asset/converter/AssetConverter.java index 376db375..c8805e34 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/converter/AssetConverter.java +++ b/src/main/java/org/umc/valuedi/domain/asset/converter/AssetConverter.java @@ -1,19 +1,29 @@ package org.umc.valuedi.domain.asset.converter; +import org.springframework.data.domain.Page; import org.umc.valuedi.domain.asset.dto.res.AssetResDTO; import org.umc.valuedi.domain.asset.dto.res.BankResDTO; import org.umc.valuedi.domain.asset.dto.res.CardResDTO; import org.umc.valuedi.domain.asset.entity.BankAccount; +import org.umc.valuedi.domain.asset.entity.BankTransaction; import org.umc.valuedi.domain.asset.entity.Card; +import org.umc.valuedi.domain.asset.entity.CardApproval; +import org.umc.valuedi.domain.asset.enums.CancelStatus; +import org.umc.valuedi.domain.asset.enums.TransactionDirection; import org.umc.valuedi.domain.connection.enums.Organization; import org.umc.valuedi.domain.goal.entity.Goal; +import org.umc.valuedi.domain.ledger.entity.Category; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; +import java.util.stream.Stream; public class AssetConverter { + private static final int MAX_TITLE_LENGTH = 50; + // 개별 BankAccount 엔티티 -> BankAccountInfo 변환 public static BankResDTO.BankAccountInfo toBankAccountInfo(BankAccount account) { BankResDTO.GoalInfo goalInfo = null; @@ -51,6 +61,7 @@ public static BankResDTO.BankAccountListDTO toBankAccountListDTO(List CardInfo 변환 public static CardResDTO.CardInfo toCardInfo(Card card) { return CardResDTO.CardInfo.builder() + .cardId(card.getId()) .cardName(card.getCardName()) .cardNoMasked(card.getCardNoMasked()) .cardType(card.getCardType()) @@ -119,4 +130,73 @@ public static BankResDTO.BankAssetResponse toBankAssetResponse(String organizati .goalList(goalList) .build(); } + + public static AssetResDTO.AssetTransactionDetail toBankTransactionDetail(BankTransaction bt, Category category) { + boolean isIncome = bt.getDirection() == TransactionDirection.IN; + long amount = isIncome ? bt.getInAmount() : bt.getOutAmount(); + + String title = Stream.of(bt.getDesc2(), bt.getDesc3(), bt.getDesc4()) + .filter(Objects::nonNull) + .collect(Collectors.joining(" ")).strip(); + if (title.isBlank()) title = "은행 거래"; + if (title.length() > MAX_TITLE_LENGTH) title = title.substring(0, MAX_TITLE_LENGTH); + + return AssetResDTO.AssetTransactionDetail.builder() + .transactionAt(bt.getTrDatetime()) + .title(title) + .amount(amount) + .transactionType(isIncome ? "INCOME" : "EXPENSE") + .categoryCode(category != null ? category.getCode() : "ETC") + .categoryName(category != null ? category.getName() : "기타") + .afterBalance(bt.getAfterBalance()) + .build(); + } + + public static AssetResDTO.AssetTransactionDetail toCardTransactionDetail(CardApproval ca, Category category) { + boolean isExpense = ca.getCancelYn() == CancelStatus.NORMAL; + String merchantName = ca.getMerchantName(); + String title = (merchantName == null || merchantName.isBlank()) ? "카드 승인" : merchantName; + + return AssetResDTO.AssetTransactionDetail.builder() + .transactionAt(ca.getUsedDatetime()) + .title(title) + .amount(ca.getUsedAmount()) + .transactionType(isExpense ? "EXPENSE" : "INCOME") + .categoryCode(category != null ? category.getCode() : "ETC") + .categoryName(category != null ? category.getName() : "기타") + .afterBalance(null) + .build(); + } + + public static AssetResDTO.AssetTransactionResponse toAccountTransactionResponse( + BankAccount account, Page page, List content) { + String orgCode = account.getCodefConnection().getOrganization(); + return AssetResDTO.AssetTransactionResponse.builder() + .totalElements(page.getTotalElements()) + .page(page.getNumber()) + .size(page.getSize()) + .totalPages(page.getTotalPages()) + .organizationCode(orgCode) + .assetName(account.getAccountName()) + .assetNumber(account.getAccountDisplay()) + .currentBalance(account.getBalanceAmount()) + .content(content) + .build(); + } + + public static AssetResDTO.AssetTransactionResponse toCardTransactionResponse( + Card cardEntity, Page page, List content) { + String orgCode = cardEntity.getCodefConnection().getOrganization(); + return AssetResDTO.AssetTransactionResponse.builder() + .totalElements(page.getTotalElements()) + .page(page.getNumber()) + .size(page.getSize()) + .totalPages(page.getTotalPages()) + .organizationCode(orgCode) + .assetName(cardEntity.getCardName()) + .assetNumber(cardEntity.getCardNoMasked()) + .currentBalance(null) + .content(content) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/org/umc/valuedi/domain/asset/dto/res/AssetResDTO.java b/src/main/java/org/umc/valuedi/domain/asset/dto/res/AssetResDTO.java index a2ab1f83..55c66180 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/dto/res/AssetResDTO.java +++ b/src/main/java/org/umc/valuedi/domain/asset/dto/res/AssetResDTO.java @@ -4,6 +4,7 @@ import lombok.*; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -67,4 +68,66 @@ public void addLatestBalance(Long accountId, Long balance) { this.latestBalances.put(accountId, balance); } } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "개별 거래/승인 내역") + public static class AssetTransactionDetail { + @Schema(description = "거래 일시", example = "2026-02-10T14:30:00") + private LocalDateTime transactionAt; + + @Schema(description = "거래내역명", example = "초밥") + private String title; + + @Schema(description = "금액", example = "16500") + private Long amount; + + @Schema(description = "거래유형 (INCOME | EXPENSE)", example = "EXPENSE") + private String transactionType; + + @Schema(description = "카테고리 코드", example = "FOOD") + private String categoryCode; + + @Schema(description = "카테고리명", example = "식비") + private String categoryName; + + @Schema(description = "거래 후 잔액 (카드는 null)", example = "93500") + private Long afterBalance; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "자산 거래내역 페이지 응답") + public static class AssetTransactionResponse { + @Schema(description = "전체 항목 수", example = "42") + private long totalElements; + + @Schema(description = "현재 페이지", example = "0") + private int page; + + @Schema(description = "페이지 크기", example = "20") + private int size; + + @Schema(description = "전체 페이지 수", example = "3") + private int totalPages; + + @Schema(description = "기관 코드", example = "0004") + private String organizationCode; + + @Schema(description = "계좌명 또는 카드명", example = "KB나라사랑우대통장") + private String assetName; + + @Schema(description = "카드번호 또는 계좌번호", example = "123-456-789012") + private String assetNumber; + + @Schema(description = "현재 잔액 (카드는 null)", example = "150000") + private Long currentBalance; + + @Schema(description = "거래내역 목록") + private List content; + } } \ No newline at end of file diff --git a/src/main/java/org/umc/valuedi/domain/asset/dto/res/CardResDTO.java b/src/main/java/org/umc/valuedi/domain/asset/dto/res/CardResDTO.java index 757a60fe..cae8ac34 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/dto/res/CardResDTO.java +++ b/src/main/java/org/umc/valuedi/domain/asset/dto/res/CardResDTO.java @@ -63,6 +63,9 @@ public static class CardIssuerConnection { @AllArgsConstructor @Schema(description = "카드 정보") public static class CardInfo { + @Schema(description = "카드 ID", example = "1") + private Long cardId; + @Schema(description = "카드명", example = "KB국민 나라사랑카드") private String cardName; diff --git a/src/main/java/org/umc/valuedi/domain/asset/entity/BankTransaction.java b/src/main/java/org/umc/valuedi/domain/asset/entity/BankTransaction.java index 8e8181e7..7251f531 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/entity/BankTransaction.java +++ b/src/main/java/org/umc/valuedi/domain/asset/entity/BankTransaction.java @@ -19,6 +19,9 @@ @Getter @Table( name = "bank_transaction", + indexes = { + @Index(name = "idx_bank_tx_account_datetime", columnList = "bank_account_id, tr_datetime DESC") + }, uniqueConstraints = { @UniqueConstraint( name = "uk_bank_transaction_identity", diff --git a/src/main/java/org/umc/valuedi/domain/asset/entity/CardApproval.java b/src/main/java/org/umc/valuedi/domain/asset/entity/CardApproval.java index 6bf50742..cefc107e 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/entity/CardApproval.java +++ b/src/main/java/org/umc/valuedi/domain/asset/entity/CardApproval.java @@ -22,6 +22,9 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) @Table( name = "card_approval", + indexes = { + @Index(name = "idx_card_approval_card_datetime", columnList = "card_id, used_datetime DESC") + }, uniqueConstraints = { @UniqueConstraint( name = "uk_card_approval_identity", diff --git a/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetErrorCode.java b/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetErrorCode.java index 4bae5fa9..d8ef1e0f 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetErrorCode.java +++ b/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetErrorCode.java @@ -10,6 +10,9 @@ public enum AssetErrorCode implements BaseErrorCode { SYNC_COOL_DOWN(HttpStatus.TOO_MANY_REQUESTS, "ASSET429_1", "전체 동기화는 10분에 한 번만 요청할 수 있습니다."), + ACCOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "ASSET404_1", "존재하지 않거나 접근 권한이 없는 계좌입니다."), + CARD_NOT_FOUND(HttpStatus.NOT_FOUND, "ASSET404_2", "존재하지 않거나 접근 권한이 없는 카드입니다."), + ASSET_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "ASSET404_3", "시스템 필수 카테고리가 DB에 존재하지 않습니다. 관리자에게 문의하세요"), ; private final HttpStatus status; diff --git a/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetSuccessCode.java b/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetSuccessCode.java index fe0d70cc..389317dc 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetSuccessCode.java +++ b/src/main/java/org/umc/valuedi/domain/asset/exception/code/AssetSuccessCode.java @@ -10,6 +10,8 @@ @AllArgsConstructor public enum AssetSuccessCode implements BaseSuccessCode { + ACCOUNT_TRANSACTIONS_FETCHED(HttpStatus.OK, "ASSET200_1", "계좌 거래내역 조회에 성공했습니다."), + CARD_TRANSACTIONS_FETCHED(HttpStatus.OK, "ASSET200_2", "카드 승인내역 조회에 성공했습니다."), ; private final HttpStatus status; diff --git a/src/main/java/org/umc/valuedi/domain/asset/repository/AssetTransactionQueryRepository.java b/src/main/java/org/umc/valuedi/domain/asset/repository/AssetTransactionQueryRepository.java new file mode 100644 index 00000000..44d5853f --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/asset/repository/AssetTransactionQueryRepository.java @@ -0,0 +1,165 @@ +package org.umc.valuedi.domain.asset.repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.umc.valuedi.domain.asset.entity.BankAccount; +import org.umc.valuedi.domain.asset.entity.BankTransaction; +import org.umc.valuedi.domain.asset.entity.Card; +import org.umc.valuedi.domain.asset.entity.CardApproval; +import org.umc.valuedi.domain.ledger.entity.LedgerEntry; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.YearMonth; +import java.util.List; +import java.util.Optional; + +import static org.umc.valuedi.domain.asset.entity.QBankAccount.bankAccount; +import static org.umc.valuedi.domain.asset.entity.QBankTransaction.bankTransaction; +import static org.umc.valuedi.domain.asset.entity.QCard.card; +import static org.umc.valuedi.domain.asset.entity.QCardApproval.cardApproval; +import static org.umc.valuedi.domain.connection.entity.QCodefConnection.codefConnection; +import static org.umc.valuedi.domain.ledger.entity.QCategory.category; +import static org.umc.valuedi.domain.ledger.entity.QLedgerEntry.ledgerEntry; + +@Repository +@RequiredArgsConstructor +public class AssetTransactionQueryRepository { + + private final JPAQueryFactory queryFactory; + + public Optional findAccountWithConnection(Long accountId, Long memberId) { + BankAccount result = queryFactory + .selectFrom(bankAccount) + .join(bankAccount.codefConnection, codefConnection).fetchJoin() + .where( + bankAccount.id.eq(accountId), + codefConnection.member.id.eq(memberId) + ) + .fetchOne(); + return Optional.ofNullable(result); + } + + public Optional findCardWithConnection(Long cardId, Long memberId) { + Card result = queryFactory + .selectFrom(card) + .join(card.codefConnection, codefConnection).fetchJoin() + .where( + card.id.eq(cardId), + codefConnection.member.id.eq(memberId) + ) + .fetchOne(); + return Optional.ofNullable(result); + } + + public Page findBankTransactions( + Long accountId, YearMonth yearMonth, LocalDate date, Pageable pageable) { + + List content = queryFactory + .selectFrom(bankTransaction) + .where( + bankTransaction.bankAccount.id.eq(accountId), + bankYearMonthEq(yearMonth), + bankDateEq(date) + ) + .orderBy(bankTransaction.trDatetime.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(bankTransaction.count()) + .from(bankTransaction) + .where( + bankTransaction.bankAccount.id.eq(accountId), + bankYearMonthEq(yearMonth), + bankDateEq(date) + ) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } + + public Page findCardApprovals( + Long cardId, YearMonth yearMonth, LocalDate date, Pageable pageable) { + + List content = queryFactory + .selectFrom(cardApproval) + .where( + cardApproval.card.id.eq(cardId), + cardYearMonthEq(yearMonth), + cardDateEq(date) + ) + .orderBy(cardApproval.usedDatetime.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(cardApproval.count()) + .from(cardApproval) + .where( + cardApproval.card.id.eq(cardId), + cardYearMonthEq(yearMonth), + cardDateEq(date) + ) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } + + public List findLedgerEntriesForBankTransactions(List bankTransactionIds) { + if (bankTransactionIds.isEmpty()) return List.of(); + return queryFactory + .selectFrom(ledgerEntry) + .join(ledgerEntry.category, category).fetchJoin() + .where(ledgerEntry.bankTransaction.id.in(bankTransactionIds)) + .fetch(); + } + + public List findLedgerEntriesForCardApprovals(List cardApprovalIds) { + if (cardApprovalIds.isEmpty()) return List.of(); + return queryFactory + .selectFrom(ledgerEntry) + .join(ledgerEntry.category, category).fetchJoin() + .where(ledgerEntry.cardApproval.id.in(cardApprovalIds)) + .fetch(); + } + + private BooleanExpression bankYearMonthEq(YearMonth ym) { + if (ym == null) return null; + return bankTransaction.trDatetime.between( + ym.atDay(1).atStartOfDay(), + ym.atEndOfMonth().atTime(LocalTime.MAX) + ); + } + + private BooleanExpression bankDateEq(LocalDate date) { + if (date == null) return null; + return bankTransaction.trDatetime.between( + date.atStartOfDay(), + date.atTime(LocalTime.MAX) + ); + } + + private BooleanExpression cardYearMonthEq(YearMonth ym) { + if (ym == null) return null; + return cardApproval.usedDatetime.between( + ym.atDay(1).atStartOfDay(), + ym.atEndOfMonth().atTime(LocalTime.MAX) + ); + } + + private BooleanExpression cardDateEq(LocalDate date) { + if (date == null) return null; + return cardApproval.usedDatetime.between( + date.atStartOfDay(), + date.atTime(LocalTime.MAX) + ); + } +} diff --git a/src/main/java/org/umc/valuedi/domain/asset/service/query/AssetQueryService.java b/src/main/java/org/umc/valuedi/domain/asset/service/query/AssetQueryService.java index fdd5a32d..85775842 100644 --- a/src/main/java/org/umc/valuedi/domain/asset/service/query/AssetQueryService.java +++ b/src/main/java/org/umc/valuedi/domain/asset/service/query/AssetQueryService.java @@ -1,6 +1,8 @@ package org.umc.valuedi.domain.asset.service.query; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.umc.valuedi.domain.asset.converter.AssetConverter; @@ -8,11 +10,26 @@ import org.umc.valuedi.domain.asset.dto.res.BankResDTO; import org.umc.valuedi.domain.asset.dto.res.CardResDTO; import org.umc.valuedi.domain.asset.entity.BankAccount; +import org.umc.valuedi.domain.asset.entity.BankTransaction; import org.umc.valuedi.domain.asset.entity.Card; +import org.umc.valuedi.domain.asset.entity.CardApproval; +import org.umc.valuedi.domain.asset.exception.AssetException; +import org.umc.valuedi.domain.asset.exception.code.AssetErrorCode; +import org.umc.valuedi.domain.asset.repository.AssetTransactionQueryRepository; import org.umc.valuedi.domain.asset.repository.bank.bankAccount.BankAccountRepository; import org.umc.valuedi.domain.asset.repository.card.card.CardRepository; +import org.umc.valuedi.domain.ledger.entity.Category; +import org.umc.valuedi.domain.ledger.entity.LedgerEntry; +import org.umc.valuedi.domain.ledger.repository.CategoryRepository; +import org.umc.valuedi.domain.ledger.service.query.CategoryMatchingService; +import java.time.LocalDate; +import java.time.YearMonth; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; @Service @Transactional(readOnly = true) @@ -21,6 +38,9 @@ public class AssetQueryService { private final BankAccountRepository bankAccountRepository; private final CardRepository cardRepository; + private final AssetTransactionQueryRepository assetTransactionQueryRepository; + private final CategoryMatchingService categoryMatchingService; + private final CategoryRepository categoryRepository; /** * 연동된 전체 계좌 목록 조회 @@ -65,4 +85,101 @@ public AssetResDTO.AssetSummaryCountDTO getAssetSummaryCount(Long memberId) { return AssetConverter.toAssetSummaryCountDTO(accountCount, cardCount); } + + /** + * 특정 계좌의 거래내역 조회 + */ + public AssetResDTO.AssetTransactionResponse getAccountTransactions( + Long memberId, Long accountId, YearMonth yearMonth, LocalDate date, int page, int size) { + + BankAccount account = assetTransactionQueryRepository + .findAccountWithConnection(accountId, memberId) + .orElseThrow(() -> new AssetException(AssetErrorCode.ACCOUNT_NOT_FOUND)); + + YearMonth queryYearMonth = (date != null) ? null : yearMonth; + Page txPage = assetTransactionQueryRepository + .findBankTransactions(accountId, queryYearMonth, date, PageRequest.of(page, size)); + + List ids = txPage.getContent().stream() + .map(BankTransaction::getId).toList(); + Map ledgerCategoryMap = assetTransactionQueryRepository + .findLedgerEntriesForBankTransactions(ids) + .stream() + .filter(le -> le.getBankTransaction() != null) + .collect(Collectors.toMap( + le -> le.getBankTransaction().getId(), + LedgerEntry::getCategory, + (a, b) -> a + )); + + Category defaultCategory = getCategoryOrThrow("ETC"); + Category transferCategory = getCategoryOrThrow("TRANSFER"); + + List content = txPage.getContent().stream() + .map(bt -> { + Category cat = ledgerCategoryMap.get(bt.getId()); + if (cat == null) { + String combinedDesc = Stream.of(bt.getDesc2(), bt.getDesc3(), bt.getDesc4()) + .filter(Objects::nonNull) + .collect(Collectors.joining(" ")); + if (categoryMatchingService.isCardSettlement(combinedDesc)) { + cat = transferCategory; + } else { + cat = categoryMatchingService.mapCategoryByKeyword(combinedDesc, defaultCategory); + } + } + return AssetConverter.toBankTransactionDetail(bt, cat); + }) + .toList(); + + return AssetConverter.toAccountTransactionResponse(account, txPage, content); + } + + /** + * 특정 카드의 승인내역 조회 + */ + public AssetResDTO.AssetTransactionResponse getCardTransactions( + Long memberId, Long cardId, YearMonth yearMonth, LocalDate date, int page, int size) { + + Card cardEntity = assetTransactionQueryRepository + .findCardWithConnection(cardId, memberId) + .orElseThrow(() -> new AssetException(AssetErrorCode.CARD_NOT_FOUND)); + + YearMonth queryYearMonth = (date != null) ? null : yearMonth; + Page approvalPage = assetTransactionQueryRepository + .findCardApprovals(cardId, queryYearMonth, date, PageRequest.of(page, size)); + + List ids = approvalPage.getContent().stream() + .map(CardApproval::getId).toList(); + Map ledgerCategoryMap = assetTransactionQueryRepository + .findLedgerEntriesForCardApprovals(ids) + .stream() + .filter(le -> le.getCardApproval() != null) + .collect(Collectors.toMap( + le -> le.getCardApproval().getId(), + LedgerEntry::getCategory, + (a, b) -> a + )); + + Category defaultCategory = getCategoryOrThrow("ETC"); + + List content = approvalPage.getContent().stream() + .map(ca -> { + Category cat = ledgerCategoryMap.get(ca.getId()); + if (cat == null) { + cat = categoryMatchingService.mapCategoryByKeyword(ca.getMerchantType(), null); + if (cat == null) cat = categoryMatchingService.mapCategoryByKeyword(ca.getMerchantName(), defaultCategory); + if (cat == null) cat = defaultCategory; + } + return AssetConverter.toCardTransactionDetail(ca, cat); + }) + .toList(); + + return AssetConverter.toCardTransactionResponse(cardEntity, approvalPage, content); + } + + private Category getCategoryOrThrow(String code) { + return categoryRepository.findByCode(code) + .orElseThrow(() -> new AssetException(AssetErrorCode.ASSET_CATEGORY_NOT_FOUND)); + } } diff --git a/src/main/java/org/umc/valuedi/domain/ledger/service/command/LedgerSyncService.java b/src/main/java/org/umc/valuedi/domain/ledger/service/command/LedgerSyncService.java index 79f9d64d..f92b48a1 100644 --- a/src/main/java/org/umc/valuedi/domain/ledger/service/command/LedgerSyncService.java +++ b/src/main/java/org/umc/valuedi/domain/ledger/service/command/LedgerSyncService.java @@ -1,6 +1,5 @@ package org.umc.valuedi.domain.ledger.service.command; -import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -14,14 +13,13 @@ import org.umc.valuedi.domain.asset.repository.card.cardApproval.CardApprovalRepository; import org.umc.valuedi.domain.ledger.dto.request.LedgerSyncRequest; import org.umc.valuedi.domain.ledger.entity.Category; -import org.umc.valuedi.domain.ledger.entity.CategoryKeyword; import org.umc.valuedi.domain.ledger.entity.LedgerEntry; import org.umc.valuedi.domain.ledger.enums.TransactionType; import org.umc.valuedi.domain.ledger.exception.LedgerException; import org.umc.valuedi.domain.ledger.exception.code.LedgerErrorCode; -import org.umc.valuedi.domain.ledger.repository.CategoryKeywordRepository; import org.umc.valuedi.domain.ledger.repository.CategoryRepository; import org.umc.valuedi.domain.ledger.repository.LedgerEntryRepository; +import org.umc.valuedi.domain.ledger.service.query.CategoryMatchingService; import org.umc.valuedi.domain.ledger.utill.LedgerCanonicalKeyUtil; import org.umc.valuedi.domain.member.entity.Member; import org.umc.valuedi.domain.member.exception.code.MemberErrorCode; @@ -43,7 +41,7 @@ public class LedgerSyncService { private final BankTransactionRepository bankTransactionRepository; private final CardApprovalRepository cardApprovalRepository; private final CategoryRepository categoryRepository; - private final CategoryKeywordRepository categoryKeywordRepository; + private final CategoryMatchingService categoryMatchingService; private final MemberRepository memberRepository; // --- 키워드 상수 정의 --- @@ -54,23 +52,6 @@ public class LedgerSyncService { "CARD", "DEBIT", "체크", "카드" ); - private static final List CARD_SETTLEMENT_KEYWORDS = List.of( - "카드대금", "신용카드대금", "카드청구", "카드자동이체" - ); - - // 메모리 캐시 (키워드 -> 카테고리) - private volatile Map keywordCache; - - @PostConstruct - public void init() { - refreshKeywordCache(); - } - - public void refreshKeywordCache() { - List keywords = categoryKeywordRepository.findAllWithCategory(); - this.keywordCache = keywords.stream().collect(Collectors.toMap(CategoryKeyword::getKeyword, CategoryKeyword::getCategory, (e, r) -> e)); - } - /** * 기간 내 가계부 데이터를 완전히 재생성(Rebuild)합니다. * 1. 해당 기간 LedgerEntry 삭제 @@ -132,8 +113,8 @@ public void rebuildLedger(Member member, LocalDate from, LocalDate to) { private LedgerEntry createFromCard(Member member, CardApproval ca, Category defaultCategory, String key) { String merchantName = ca.getMerchantName(); - Category category = mapCategoryByKeyword(ca.getMerchantType(), null); - if (category == null) category = mapCategoryByKeyword(merchantName, defaultCategory); + Category category = categoryMatchingService.mapCategoryByKeyword(ca.getMerchantType(), null); + if (category == null) category = categoryMatchingService.mapCategoryByKeyword(merchantName, defaultCategory); if (category == null) category = defaultCategory; TransactionType type = ca.getCancelYn() == CancelStatus.NORMAL ? TransactionType.EXPENSE : TransactionType.INCOME; @@ -157,11 +138,11 @@ private LedgerEntry createFromBank(Member member, BankTransaction bt, Category d Category category; TransactionType type; - if (isCardSettlement(combinedDesc)) { + if (categoryMatchingService.isCardSettlement(combinedDesc)) { category = transferCategory; type = TransactionType.EXPENSE; } else { - category = mapCategoryByKeyword(combinedDesc, defaultCategory); + category = categoryMatchingService.mapCategoryByKeyword(combinedDesc, defaultCategory); type = bt.getDirection() == TransactionDirection.IN ? TransactionType.INCOME : TransactionType.EXPENSE; } @@ -242,7 +223,7 @@ public int rematchCategories(Long memberId, LocalDate from, LocalDate to) { List entries = ledgerEntryRepository.findAllByMemberAndTransactionAtBetween(member, startDateTime, endDateTime); // 키워드 캐시 갱신 (최신 로직 적용) - refreshKeywordCache(); + categoryMatchingService.refreshKeywordCache(); Category defaultCategory = categoryRepository.findByCode("ETC").orElseThrow(); int updatedCount = 0; @@ -262,7 +243,7 @@ public int rematchCategories(Long memberId, LocalDate from, LocalDate to) { } if (textToMatch != null && !textToMatch.isBlank()) { - Category newCategory = mapCategoryByKeyword(textToMatch, defaultCategory); + Category newCategory = categoryMatchingService.mapCategoryByKeyword(textToMatch, defaultCategory); // 카테고리가 변경되었을 경우에만 업데이트 if (!entry.getCategory().equals(newCategory)) { @@ -284,8 +265,8 @@ private void syncCardApprovals(Member member, LocalDate from, LocalDate to, Cate if (ca.getUsedDatetime() == null) continue; String merchantName = ca.getMerchantName(); - Category category = mapCategoryByKeyword(ca.getMerchantType(), null); - if (category == null) category = mapCategoryByKeyword(merchantName, defaultCategory); + Category category = categoryMatchingService.mapCategoryByKeyword(ca.getMerchantType(), null); + if (category == null) category = categoryMatchingService.mapCategoryByKeyword(merchantName, defaultCategory); if (category == null) category = defaultCategory; TransactionType transactionType = ca.getCancelYn() == CancelStatus.NORMAL ? TransactionType.EXPENSE : TransactionType.INCOME; @@ -318,11 +299,11 @@ private void syncBankTransactions(Member member, LocalDate from, LocalDate to, L String title = combinedDesc.isEmpty() ? "은행 거래" : combinedDesc; if (title.length() > 50) title = title.substring(0, 50); - if (isCardSettlement(combinedDesc)) { + if (categoryMatchingService.isCardSettlement(combinedDesc)) { category = transferCategory; transactionType = TransactionType.EXPENSE; } else { - category = mapCategoryByKeyword(combinedDesc, defaultCategory); + category = categoryMatchingService.mapCategoryByKeyword(combinedDesc, defaultCategory); transactionType = bt.getDirection() == TransactionDirection.IN ? TransactionType.INCOME : TransactionType.EXPENSE; } @@ -337,20 +318,8 @@ private void syncBankTransactions(Member member, LocalDate from, LocalDate to, L } } - private boolean isCardSettlement(String text) { - return text != null && CARD_SETTLEMENT_KEYWORDS.stream().anyMatch(text::contains); - } - - private Category mapCategoryByKeyword(String text, Category defaultCategory) { - if (text == null || text.isEmpty()) return defaultCategory; - for (Map.Entry entry : keywordCache.entrySet()) { - if (text.contains(entry.getKey())) return entry.getValue(); - } - return defaultCategory; - } - private boolean isDuplicateOfCardApproval(BankTransaction bt, String combinedDesc, List cards) { - if (bt.getDirection() != TransactionDirection.OUT || isCardSettlement(combinedDesc) || !hasCardPaymentKeyword(combinedDesc)) return false; + if (bt.getDirection() != TransactionDirection.OUT || categoryMatchingService.isCardSettlement(combinedDesc) || !hasCardPaymentKeyword(combinedDesc)) return false; return cards.stream().anyMatch(ca -> isMatch(bt, combinedDesc, ca)); } diff --git a/src/main/java/org/umc/valuedi/domain/ledger/service/query/CategoryMatchingService.java b/src/main/java/org/umc/valuedi/domain/ledger/service/query/CategoryMatchingService.java new file mode 100644 index 00000000..313e7c29 --- /dev/null +++ b/src/main/java/org/umc/valuedi/domain/ledger/service/query/CategoryMatchingService.java @@ -0,0 +1,52 @@ +package org.umc.valuedi.domain.ledger.service.query; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.umc.valuedi.domain.ledger.entity.Category; +import org.umc.valuedi.domain.ledger.entity.CategoryKeyword; +import org.umc.valuedi.domain.ledger.repository.CategoryKeywordRepository; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CategoryMatchingService { + + private final CategoryKeywordRepository categoryKeywordRepository; + + private static final List CARD_SETTLEMENT_KEYWORDS = List.of( + "카드대금", "신용카드대금", "카드청구", "카드자동이체" + ); + + private volatile Map keywordCache; + + @PostConstruct + public void init() { + refreshKeywordCache(); + } + + public void refreshKeywordCache() { + List keywords = categoryKeywordRepository.findAllWithCategory(); + this.keywordCache = keywords.stream() + .collect(Collectors.toMap( + CategoryKeyword::getKeyword, + CategoryKeyword::getCategory, + (e, r) -> e + )); + } + + public Category mapCategoryByKeyword(String text, Category defaultCategory) { + if (text == null || text.isEmpty()) return defaultCategory; + for (Map.Entry entry : keywordCache.entrySet()) { + if (text.contains(entry.getKey())) return entry.getValue(); + } + return defaultCategory; + } + + public boolean isCardSettlement(String text) { + return text != null && CARD_SETTLEMENT_KEYWORDS.stream().anyMatch(text::contains); + } +}