diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountTransactionFilter.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountTransactionFilter.kt index 07ae03df..996d7748 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountTransactionFilter.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountTransactionFilter.kt @@ -1,11 +1,12 @@ package com.weeth.domain.account.application.dto.request /** - * 거래 내역 목록 필터 탭. - * - ALL: 전체 - * - INCOME: 수동 수입(`INCOME`)만 (납부 `DUES`/이월 `CARRY_OVER` 제외) - * - EXPENSE: 지출 방향 전체(수동 `EXPENSE` + 시스템 `REFUND`) - * - DUES: 회비 납부(`DUES`) + 이월(`CARRY_OVER`) + * 부원 거래 내역 목록 필터 탭. + * 부원 개인 납부(`DUES`)는 어떤 탭에서도 개별 노출하지 않고, 항상 `duesSummary` 집계로만 제공한다. + * - ALL: 수동 `INCOME` + 수동 `EXPENSE` + 이월 `CARRY_OVER` + 내 `REFUND` + * - INCOME: 수동 수입(`INCOME`)만 (이월 `CARRY_OVER`/내 `REFUND` 제외) + * - EXPENSE: 수동 지출(`EXPENSE`) + 내 `REFUND` (`source`는 `"환불"`로 마스킹) + * - DUES: 개별 거래 없는 빈 목록 + `duesSummary` 집계만 */ enum class AccountTransactionFilter { ALL, diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/SavePaymentTargetsRequest.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/SavePaymentTargetsRequest.kt index 54468d9a..1221d07b 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/dto/request/SavePaymentTargetsRequest.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/SavePaymentTargetsRequest.kt @@ -5,10 +5,11 @@ import io.swagger.v3.oas.annotations.media.Schema data class SavePaymentTargetsRequest( @field:Schema( description = - "납부 대상으로 선택한 동아리 회원 ID 목록(스냅샷). " + + "납부 대상으로 선택한 동아리 회원 ID 목록(스냅샷). 필수 필드입니다. " + "해당 기수 명부 중 이 목록에 없는 회원은 자동으로 제외 처리됩니다. " + - "빈 목록이면 전원 제외를 의미합니다.", + "빈 목록([])은 전원 제외를 의미하며, 필드 누락은 허용되지 않습니다(전원 제외 오작동 방지).", + requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2, 3]", ) - val targetedClubMemberIds: List = emptyList(), + val targetedClubMemberIds: List, ) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountCardinalResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountCardinalResponse.kt new file mode 100644 index 00000000..6834dde8 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountCardinalResponse.kt @@ -0,0 +1,12 @@ +package com.weeth.domain.account.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class AccountCardinalResponse( + @field:Schema(description = "회비 기수", example = "7") + val cardinal: Int, + @field:Schema(description = "회비 장부 이름", example = "7기 회비", nullable = true) + val name: String?, + @field:Schema(description = "선택 가능한 최신 기수 여부", example = "true") + val isLatest: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/MemberAccountTransactionsResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/MemberAccountTransactionsResponse.kt new file mode 100644 index 00000000..77fef4a1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/MemberAccountTransactionsResponse.kt @@ -0,0 +1,33 @@ +package com.weeth.domain.account.application.dto.response + +import com.weeth.global.common.response.SliceResponse +import io.swagger.v3.oas.annotations.media.Schema + +data class MemberAccountTransactionsResponse( + @field:Schema(description = "필터 탭별 부원 공개 거래 건수 요약. 첫 페이지(page=0)에서만 제공", nullable = true) + val counts: TransactionCountsResponse?, + @field:Schema(description = "회비 집계 행. 첫 페이지(page=0)에서만 제공", nullable = true) + val duesSummary: DuesSummaryResponse?, + @field:Schema(description = "부원 공개 거래 내역 (무한 스크롤)") + val transactions: SliceResponse, +) { + data class TransactionCountsResponse( + @field:Schema(description = "전체 공개 거래 수", example = "4") + val all: Int, + @field:Schema(description = "지출 공개 거래 수", example = "3") + val expense: Int, + @field:Schema(description = "수입 공개 거래 수", example = "1") + val income: Int, + @field:Schema(description = "회비 집계 행 수. 집계 금액이 있으면 1", example = "1") + val dues: Int, + ) + + data class DuesSummaryResponse( + @field:Schema(description = "회비 집계 행 라벨", example = "회비") + val label: String = "회비", // 고정 레이블 + @field:Schema(description = "회비 납부 거래 합계", example = "1100000") + val totalAmount: Int, + @field:Schema(description = "회비 집계 행 설명", example = "납부될 때마다 합산돼요") + val description: String = "납부될 때마다 합산돼요", // 고정 설명 + ) +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/MemberTransactionDetailResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/MemberTransactionDetailResponse.kt new file mode 100644 index 00000000..79f0ee16 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/MemberTransactionDetailResponse.kt @@ -0,0 +1,34 @@ +package com.weeth.domain.account.application.dto.response + +import com.weeth.domain.account.domain.enums.AccountTransactionDirection +import com.weeth.domain.account.domain.enums.AccountTransactionType +import com.weeth.domain.file.application.dto.response.FileResponse +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class MemberTransactionDetailResponse( + @field:Schema(description = "거래 ID", example = "1") + val transactionId: Long, + @field:Schema(description = "거래 유형", example = "EXPENSE") + val type: AccountTransactionType, + @field:Schema(description = "거래 방향", example = "EXPENSE") + val direction: AccountTransactionDirection, + @field:Schema(description = "거래 내용", example = "스터디 지원금") + val title: String, + @field:Schema(description = "거래처. REFUND는 환불로 마스킹", example = "인프런", nullable = true) + val source: String?, + @field:Schema(description = "거래 금액", example = "50000") + val amount: Int, + @field:Schema(description = "거래 일시", example = "2026-07-20T00:00:00") + val transactedAt: LocalDateTime, + @field:Schema(description = "거래 분류", example = "운영비", nullable = true) + val category: String?, + @field:Schema(description = "등록자 이름 스냅샷", example = "운영진 김검도") + val registeredByName: String, + @field:Schema(description = "메모", nullable = true) + val memo: String?, + @field:Schema(description = "영수증 존재 여부", example = "true") + val hasReceipt: Boolean = false, + @field:Schema(description = "영수증 파일 목록. 최대 1개") + val receipts: List = emptyList(), +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/MemberTransactionResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/MemberTransactionResponse.kt new file mode 100644 index 00000000..136124f5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/MemberTransactionResponse.kt @@ -0,0 +1,25 @@ +package com.weeth.domain.account.application.dto.response + +import com.weeth.domain.account.domain.enums.AccountTransactionDirection +import com.weeth.domain.account.domain.enums.AccountTransactionType +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class MemberTransactionResponse( + @field:Schema(description = "거래 ID", example = "1") + val transactionId: Long, + @field:Schema(description = "거래 유형", example = "EXPENSE") + val type: AccountTransactionType, + @field:Schema(description = "거래 방향", example = "EXPENSE") + val direction: AccountTransactionDirection, + @field:Schema(description = "거래 내용", example = "스터디 지원금") + val title: String, + @field:Schema(description = "거래처. REFUND는 환불로 마스킹", example = "인프런", nullable = true) + val source: String?, + @field:Schema(description = "거래 금액", example = "50000") + val amount: Int, + @field:Schema(description = "거래 일시", example = "2026-07-20T00:00:00") + val transactedAt: LocalDateTime, + @field:Schema(description = "영수증 존재 여부", example = "true") + val hasReceipt: Boolean = false, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/MyAccountResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/MyAccountResponse.kt new file mode 100644 index 00000000..ca595443 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/MyAccountResponse.kt @@ -0,0 +1,44 @@ +package com.weeth.domain.account.application.dto.response + +import com.weeth.domain.account.domain.enums.AccountPaymentStatus +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class MyAccountResponse( + @field:Schema(description = "회비 장부 ID", example = "12") + val accountId: Long, + @field:Schema(description = "회비 기수", example = "7") + val cardinal: Int, + @field:Schema(description = "회비 장부 이름", example = "7기 회비", nullable = true) + val accountName: String?, + @field:Schema(description = "1인 회비 금액", example = "60000") + val duesAmount: Int, + @field:Schema(description = "나의 납부 상태") + val myPayment: MyPaymentResponse, + @field:Schema(description = "부원에게 계좌 공개 여부", example = "true") + val bankAccountVisible: Boolean, + @field:Schema(description = "입금 계좌 정보. 비공개 또는 미등록이면 null", nullable = true) + val bankAccount: BankAccountResponse?, + @field:Schema(description = "잔액/목표액") + val balance: BalanceResponse, +) { + data class MyPaymentResponse( + @field:Schema(description = "내가 이 회비의 납부 대상인지 여부", example = "true") + val targeted: Boolean, + @field:Schema(description = "나의 납부 상태. 납부 대상이 아니면 null", example = "UNPAID", nullable = true) + val status: AccountPaymentStatus?, + @field:Schema(description = "내 납부 대상 금액", example = "60000") + val dueAmount: Int, + @field:Schema(description = "실제 납부 완료 금액", example = "0") + val paidAmount: Int, + @field:Schema(description = "납부 확인 시각. 미납 또는 대상 아님이면 null", nullable = true) + val paidAt: LocalDateTime?, + ) + + data class BalanceResponse( + @field:Schema(description = "현재 남은 금액", example = "152129") + val currentBalance: Int, + @field:Schema(description = "목표 총액. 납부 대상 dueAmount 합계", example = "1425000") + val goalAmount: Int, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountTransactionMapper.kt b/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountTransactionMapper.kt index b60faeb0..379c8272 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountTransactionMapper.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountTransactionMapper.kt @@ -13,6 +13,7 @@ class AccountTransactionMapper { fun toEntity( account: Account, request: SaveAccountTransactionRequest, + registeredByName: String? = null, ): AccountTransaction = AccountTransaction.create( account = account, @@ -22,6 +23,7 @@ class AccountTransactionMapper { amount = Money.of(request.amount), transactedAt = request.transactedAt.atStartOfDay(), memo = request.memo, + registeredByName = registeredByName, ) fun toResponse( diff --git a/src/main/kotlin/com/weeth/domain/account/application/mapper/MemberTransactionMapper.kt b/src/main/kotlin/com/weeth/domain/account/application/mapper/MemberTransactionMapper.kt new file mode 100644 index 00000000..4a9e0803 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/mapper/MemberTransactionMapper.kt @@ -0,0 +1,53 @@ +package com.weeth.domain.account.application.mapper + +import com.weeth.domain.account.application.dto.response.MemberTransactionDetailResponse +import com.weeth.domain.account.application.dto.response.MemberTransactionResponse +import com.weeth.domain.account.domain.entity.AccountTransaction +import com.weeth.domain.account.domain.enums.AccountTransactionType +import com.weeth.domain.file.application.dto.response.FileResponse +import org.springframework.stereotype.Component + +@Component +class MemberTransactionMapper { + fun toResponse( + transaction: AccountTransaction, + hasReceipt: Boolean, + ): MemberTransactionResponse = + MemberTransactionResponse( + transactionId = transaction.id, + type = transaction.type, + direction = transaction.direction, + title = transaction.title, + source = transaction.memberVisibleSource(), + amount = transaction.amount, + transactedAt = transaction.transactedAt, + hasReceipt = hasReceipt, + ) + + fun toDetailResponse( + transaction: AccountTransaction, + receipts: List, + ): MemberTransactionDetailResponse = + MemberTransactionDetailResponse( + transactionId = transaction.id, + type = transaction.type, + direction = transaction.direction, + title = transaction.title, + source = transaction.memberVisibleSource(), + amount = transaction.amount, + transactedAt = transaction.transactedAt, + category = transaction.category, + registeredByName = transaction.registeredByName ?: DEFAULT_REGISTERED_BY_NAME, + memo = transaction.memo, + hasReceipt = receipts.isNotEmpty(), + receipts = receipts, + ) + + private fun AccountTransaction.memberVisibleSource(): String? = + if (type == AccountTransactionType.REFUND) REFUND_SOURCE else source + + companion object { + private const val REFUND_SOURCE = "환불" + private const val DEFAULT_REGISTERED_BY_NAME = "운영진" + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/mapper/MyAccountMapper.kt b/src/main/kotlin/com/weeth/domain/account/application/mapper/MyAccountMapper.kt new file mode 100644 index 00000000..dd3505a6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/mapper/MyAccountMapper.kt @@ -0,0 +1,63 @@ +package com.weeth.domain.account.application.mapper + +import com.weeth.domain.account.application.dto.response.AccountCardinalResponse +import com.weeth.domain.account.application.dto.response.BankAccountResponse +import com.weeth.domain.account.application.dto.response.MyAccountResponse +import com.weeth.domain.account.domain.entity.Account +import com.weeth.domain.account.domain.entity.AccountPaymentTarget +import com.weeth.domain.account.domain.enums.AccountTargetStatus +import org.springframework.stereotype.Component + +@Component +class MyAccountMapper { + fun toCardinalResponse( + account: Account, + isLatest: Boolean, + ): AccountCardinalResponse = + AccountCardinalResponse( + cardinal = account.cardinal, + name = account.name, + isLatest = isLatest, + ) + + fun toResponse( + account: Account, + target: AccountPaymentTarget?, + goalAmount: Int, + ): MyAccountResponse = + MyAccountResponse( + accountId = account.id, + cardinal = account.cardinal, + accountName = account.name, + duesAmount = account.duesAmount, + myPayment = target.toMyPaymentResponse(), + bankAccountVisible = account.bankAccountVisible, + bankAccount = if (account.bankAccountVisible) BankAccountResponse.from(account.bankAccount) else null, + balance = + MyAccountResponse.BalanceResponse( + currentBalance = account.currentBalance, + goalAmount = goalAmount, + ), + ) + + // 제외(EXCLUDED)·미선택 등 TARGETED 가 아니면 납부 대상이 아니므로 상태를 노출하지 않는다. + private fun AccountPaymentTarget?.toMyPaymentResponse(): MyAccountResponse.MyPaymentResponse { + if (this == null || targetStatus != AccountTargetStatus.TARGETED) { + return MyAccountResponse.MyPaymentResponse( + targeted = false, + status = null, + dueAmount = 0, + paidAmount = 0, + paidAt = null, + ) + } + + return MyAccountResponse.MyPaymentResponse( + targeted = true, + status = paymentStatus, + dueAmount = dueAmount, + paidAmount = paidAmount, + paidAt = paidAt, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/MemberAccountAccessResolver.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/MemberAccountAccessResolver.kt new file mode 100644 index 00000000..04789034 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/MemberAccountAccessResolver.kt @@ -0,0 +1,43 @@ +package com.weeth.domain.account.application.usecase + +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.domain.entity.Account +import com.weeth.domain.account.domain.enums.AccountStatus +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import org.springframework.stereotype.Component + +/** + * 유저 사이드 회비 화면 접근 게이트. + * 부원용 QueryService 전반에서 동일하게 적용되는 두 단계 검증을 한 곳으로 모은다. + * + * 1. 활성 부원만 통과 — 탈퇴/비활성/비소속이면 [ClubMemberPolicy] 가 예외를 던진다. + * 2. 공개(`memberVisible=true`)·`ACTIVE` 장부만 노출 — 없으면 [AccountNotFoundException]. + * 이 한 줄로 (a)없는 기수 (b)초안 상태 (c)미공개 장부를 모두 404로 은닉한다. + */ +@Component +class MemberAccountAccessResolver( + private val accountRepository: AccountRepository, + private val clubMemberPolicy: ClubMemberPolicy, +) { + fun resolve( + clubId: Long, + cardinal: Int, + userId: Long, + ): MemberAccountAccess { + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val account = + accountRepository.findByClubIdAndCardinalAndStatusAndMemberVisibleTrue( + clubId, + cardinal, + AccountStatus.ACTIVE, + ) ?: throw AccountNotFoundException() + return MemberAccountAccess(account, member) + } +} + +data class MemberAccountAccess( + val account: Account, + val member: ClubMember, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountPaymentUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountPaymentUseCase.kt index a157cbab..f981aaa5 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountPaymentUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountPaymentUseCase.kt @@ -24,13 +24,6 @@ import org.springframework.transaction.annotation.Transactional import java.time.Clock import java.time.LocalDateTime -/** - * TODO: 관리자가 납부 현황을 관리하는 경우 해당 거래 내역을 자동으로 생성한다. -> 환불/납부는 돈이 실제로 거래가 되므로 대시보드에서 확인이 가능해야함 - * TODO: 대신 환불/납부 거래내역은 관리자만 볼 수 있어야한다. - * TODO: 유저 사이드에서 납부 거래내역은 하나의 "회비" 거래내역에 누적이 되어야한다. - * 즉 현황을 업데이트할 때 해당 인원만큼 개별로 거래 내역이 생성되며, 이는 관리자만 확인이 가능하고, 유저사이드에서는 하나의 거래 내역에 금액이 +- 되는 방식으로 동작한다. - * 환불의 경우는 유저사이드에서도 확인이 가능하되, 이름은 가린다. - */ @Service class ManageAccountPaymentUseCase( private val accountRepository: AccountRepository, @@ -47,7 +40,8 @@ class ManageAccountPaymentUseCase( request: MarkPaymentPaidRequest, userId: Long, ) { - val account = getActiveAccountWithLock(clubId, accountId, userId) + val admin = clubPermissionPolicy.requireAdmin(clubId, userId) + val account = getActiveAccountWithLock(clubId, accountId) val targets = getTargets(accountId, request.targetIds) val paidAt = request.paidAt ?: LocalDateTime.now(clock) @@ -64,6 +58,7 @@ class ManageAccountPaymentUseCase( amount = Money.of(target.dueAmount), transactedAt = paidAt, memo = request.memo, + registeredByName = admin.user.name, paymentTarget = target, ).also { account.applyTransaction(it) } } @@ -79,7 +74,8 @@ class ManageAccountPaymentUseCase( request: MarkPaymentUnpaidRequest, userId: Long, ) { - val account = getActiveAccountWithLock(clubId, accountId, userId) + clubPermissionPolicy.requireAdmin(clubId, userId) + val account = getActiveAccountWithLock(clubId, accountId) val targets = getTargets(accountId, request.targetIds) targets.forEach { target -> @@ -102,7 +98,8 @@ class ManageAccountPaymentUseCase( request: RefundPaymentRequest, userId: Long, ) { - val account = getActiveAccountWithLock(clubId, accountId, userId) + val admin = clubPermissionPolicy.requireAdmin(clubId, userId) + val account = getActiveAccountWithLock(clubId, accountId) val targets = getTargets(accountId, request.targetIds) val refundedAt = LocalDateTime.now(clock) @@ -118,6 +115,7 @@ class ManageAccountPaymentUseCase( amount = Money.of(target.paidAmount), transactedAt = refundedAt, memo = request.memo, + registeredByName = admin.user.name, paymentTarget = target, ).also { account.applyTransaction(it) @@ -131,9 +129,7 @@ class ManageAccountPaymentUseCase( private fun getActiveAccountWithLock( clubId: Long, accountId: Long, - userId: Long, ): Account { - clubPermissionPolicy.requireAdmin(clubId, userId) val account = accountRepository.findByIdWithLock(accountId) ?: throw AccountNotFoundException() account.validateOwnedBy(clubId) if (account.status != AccountStatus.ACTIVE) throw AccountNotActiveException() diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCase.kt index 2b4fbde0..6e10344a 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCase.kt @@ -43,11 +43,11 @@ class ManageAccountTransactionUseCase( request: SaveAccountTransactionRequest, userId: Long, ): AccountTransactionResponse { - clubPermissionPolicy.requireAdmin(clubId, userId) + val admin = clubPermissionPolicy.requireAdmin(clubId, userId) val account = getActiveAccountWithLock(clubId, accountId) requireManualType(request.type) - val transaction = accountTransactionMapper.toEntity(account, request) + val transaction = accountTransactionMapper.toEntity(account, request, registeredByName = admin.user.name) val savedTransaction = transactionRepository.save(transaction) account.applyTransaction(savedTransaction) diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetMyAccountQueryService.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetMyAccountQueryService.kt new file mode 100644 index 00000000..b390758d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetMyAccountQueryService.kt @@ -0,0 +1,49 @@ +package com.weeth.domain.account.application.usecase.query + +import com.weeth.domain.account.application.dto.response.AccountCardinalResponse +import com.weeth.domain.account.application.dto.response.MyAccountResponse +import com.weeth.domain.account.application.mapper.MyAccountMapper +import com.weeth.domain.account.application.usecase.MemberAccountAccessResolver +import com.weeth.domain.account.domain.enums.AccountStatus +import com.weeth.domain.account.domain.repository.AccountPaymentTargetRepository +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetMyAccountQueryService( + private val accountRepository: AccountRepository, + private val paymentTargetRepository: AccountPaymentTargetRepository, + private val clubMemberPolicy: ClubMemberPolicy, + private val memberAccountAccessResolver: MemberAccountAccessResolver, + private val myAccountMapper: MyAccountMapper, +) { + fun findCardinals( + clubId: Long, + userId: Long, + ): List { + clubMemberPolicy.getActiveMember(clubId, userId) + val accounts = + accountRepository.findAllByClubIdAndStatusAndMemberVisibleTrueOrderByCardinalDesc( + clubId, + AccountStatus.ACTIVE, + ) + val latestCardinal = accounts.firstOrNull()?.cardinal + + return accounts.map { myAccountMapper.toCardinalResponse(it, isLatest = it.cardinal == latestCardinal) } + } + + fun findMyAccount( + clubId: Long, + cardinal: Int, + userId: Long, + ): MyAccountResponse { + val (account, member) = memberAccountAccessResolver.resolve(clubId, cardinal, userId) + val target = paymentTargetRepository.findByAccountIdAndClubMemberId(account.id, member.id) + val goalAmount = paymentTargetRepository.sumDueAmountByAccountId(account.id).toInt() + + return myAccountMapper.toResponse(account, target, goalAmount) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetMyAccountTransactionQueryService.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetMyAccountTransactionQueryService.kt new file mode 100644 index 00000000..2ec0d84e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetMyAccountTransactionQueryService.kt @@ -0,0 +1,173 @@ +package com.weeth.domain.account.application.usecase.query + +import com.weeth.domain.account.application.dto.request.AccountTransactionFilter +import com.weeth.domain.account.application.dto.request.AccountTransactionSort +import com.weeth.domain.account.application.dto.response.MemberAccountTransactionsResponse +import com.weeth.domain.account.application.dto.response.MemberTransactionDetailResponse +import com.weeth.domain.account.application.exception.AccountTransactionNotFoundException +import com.weeth.domain.account.application.mapper.MemberTransactionMapper +import com.weeth.domain.account.application.usecase.MemberAccountAccessResolver +import com.weeth.domain.account.domain.entity.AccountTransaction +import com.weeth.domain.account.domain.enums.AccountTransactionType +import com.weeth.domain.account.domain.repository.AccountTransactionRepository +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.global.common.response.SliceResponse +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.SliceImpl +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetMyAccountTransactionQueryService( + private val transactionRepository: AccountTransactionRepository, + private val memberAccountAccessResolver: MemberAccountAccessResolver, + private val fileReader: FileReader, + private val fileMapper: FileMapper, + private val memberTransactionMapper: MemberTransactionMapper, +) { + fun findTransactions( + clubId: Long, + cardinal: Int, + filter: AccountTransactionFilter, + sort: AccountTransactionSort, + page: Int, + size: Int, + userId: Long, + ): MemberAccountTransactionsResponse { + val (account, member) = memberAccountAccessResolver.resolve(clubId, cardinal, userId) + val pageable = PageRequest.of(page.coerceAtLeast(0), size.coerceIn(1, MAX_PAGE_SIZE), sort.toSort()) + + val transactions = + if (filter == AccountTransactionFilter.DUES) { + SliceImpl(emptyList(), pageable, false) + } else { + val visibility = filter.toVisibility() + transactionRepository.findMemberVisibleTransactions( + accountId = account.id, + clubMemberId = member.id, + publicTypes = visibility.publicTypes, + includeRefund = visibility.includeRefund, + pageable = pageable, + ) + } + + val transactionIds = transactions.content.map { it.id } + val transactionIdsWithReceipt = + fileReader + .findAll(FileOwnerType.ACCOUNT_TRANSACTION, transactionIds) + .map { it.ownerId } + .toSet() + + // 카운트·회비 집계는 페이지마다 변하지 않는 헤더 데이터이므로 첫 페이지에서만 계산한다. + // 무한 스크롤 후속 페이지에서는 count 쿼리 3회 + 합계 쿼리 1회를 생략한다. + val (counts, duesSummary) = + if (pageable.pageNumber == 0) { + val duesTotalAmount = transactionRepository.sumNetDuesAmountByAccountId(account.id).toInt() + countByFilter(account.id, member.id, duesTotalAmount) to + MemberAccountTransactionsResponse.DuesSummaryResponse(totalAmount = duesTotalAmount) + } else { + null to null + } + + return MemberAccountTransactionsResponse( + counts = counts, + duesSummary = duesSummary, + transactions = + SliceResponse.from( + transactions.map { + memberTransactionMapper.toResponse( + transaction = it, + hasReceipt = it.id in transactionIdsWithReceipt, + ) + }, + ), + ) + } + + fun findTransaction( + clubId: Long, + cardinal: Int, + transactionId: Long, + userId: Long, + ): MemberTransactionDetailResponse { + val (account, member) = memberAccountAccessResolver.resolve(clubId, cardinal, userId) + val transaction = + transactionRepository.findByIdAndDeletedAtIsNull(transactionId) + ?: throw AccountTransactionNotFoundException() + + if (transaction.account.id != account.id) throw AccountTransactionNotFoundException() + if (!transaction.isVisibleToMember(member.id)) throw AccountTransactionNotFoundException() + + val receipts = + fileReader + .findAll(FileOwnerType.ACCOUNT_TRANSACTION, transaction.id) + .map(fileMapper::toFileResponse) + + return memberTransactionMapper.toDetailResponse(transaction, receipts) + } + + private fun countByFilter( + accountId: Long, + clubMemberId: Long, + duesTotalAmount: Int, + ): MemberAccountTransactionsResponse.TransactionCountsResponse = + MemberAccountTransactionsResponse.TransactionCountsResponse( + all = count(accountId, clubMemberId, ALL_PUBLIC_TYPES, includeRefund = true), + income = count(accountId, clubMemberId, listOf(AccountTransactionType.INCOME), includeRefund = false), + expense = count(accountId, clubMemberId, listOf(AccountTransactionType.EXPENSE), includeRefund = true), + dues = if (duesTotalAmount > 0) 1 else 0, + ) + + private fun count( + accountId: Long, + clubMemberId: Long, + publicTypes: List, + includeRefund: Boolean, + ): Int = + transactionRepository + .countMemberVisibleTransactions( + accountId = accountId, + clubMemberId = clubMemberId, + publicTypes = publicTypes, + includeRefund = includeRefund, + ).toInt() + + private fun AccountTransactionFilter.toVisibility(): Visibility = + when (this) { + AccountTransactionFilter.ALL -> Visibility(ALL_PUBLIC_TYPES, includeRefund = true) + AccountTransactionFilter.INCOME -> Visibility(listOf(AccountTransactionType.INCOME), includeRefund = false) + AccountTransactionFilter.EXPENSE -> Visibility(listOf(AccountTransactionType.EXPENSE), includeRefund = true) + AccountTransactionFilter.DUES -> Visibility(emptyList(), includeRefund = false) + } + + private fun AccountTransaction.isVisibleToMember(clubMemberId: Long): Boolean = + when (type) { + AccountTransactionType.INCOME, + AccountTransactionType.EXPENSE, + AccountTransactionType.CARRY_OVER, + -> true + + AccountTransactionType.REFUND -> paymentTarget?.clubMember?.id == clubMemberId + + AccountTransactionType.DUES -> false + } + + private data class Visibility( + val publicTypes: List, + val includeRefund: Boolean, + ) + + companion object { + private const val MAX_PAGE_SIZE = 100 + + private val ALL_PUBLIC_TYPES = + listOf( + AccountTransactionType.INCOME, + AccountTransactionType.EXPENSE, + AccountTransactionType.CARRY_OVER, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/domain/entity/AccountTransaction.kt b/src/main/kotlin/com/weeth/domain/account/domain/entity/AccountTransaction.kt index 7c3297bb..9d33d2f2 100644 --- a/src/main/kotlin/com/weeth/domain/account/domain/entity/AccountTransaction.kt +++ b/src/main/kotlin/com/weeth/domain/account/domain/entity/AccountTransaction.kt @@ -43,6 +43,7 @@ class AccountTransaction( transactedAt: LocalDateTime, category: String? = null, memo: String? = null, + registeredByName: String? = null, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "account_payment_target_id") val paymentTarget: AccountPaymentTarget? = null, @@ -90,6 +91,10 @@ class AccountTransaction( var memo: String? = normalizeOptional(memo, "메모", MAX_MEMO_LENGTH) private set + @Column(name = "registered_by_name", length = 50) + var registeredByName: String? = normalizeOptional(registeredByName, "등록자 이름", MAX_REGISTERED_BY_NAME_LENGTH) + private set + var deletedAt: LocalDateTime? = null private set @@ -153,6 +158,7 @@ class AccountTransaction( private const val MAX_SOURCE_LENGTH = 50 private const val MAX_CATEGORY_LENGTH = 30 private const val MAX_MEMO_LENGTH = 200 + private const val MAX_REGISTERED_BY_NAME_LENGTH = 50 fun create( account: Account, @@ -163,6 +169,7 @@ class AccountTransaction( transactedAt: LocalDateTime, category: String? = null, memo: String? = null, + registeredByName: String? = null, paymentTarget: AccountPaymentTarget? = null, ): AccountTransaction = AccountTransaction( @@ -174,6 +181,7 @@ class AccountTransaction( transactedAt = transactedAt, category = category, memo = memo, + registeredByName = registeredByName, paymentTarget = paymentTarget, ) diff --git a/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountPaymentTargetRepository.kt b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountPaymentTargetRepository.kt index 112dd220..e00c7458 100644 --- a/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountPaymentTargetRepository.kt +++ b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountPaymentTargetRepository.kt @@ -13,6 +13,11 @@ import org.springframework.data.repository.query.Param interface AccountPaymentTargetRepository : JpaRepository { fun findAllByAccountId(accountId: Long): List + fun findByAccountIdAndClubMemberId( + accountId: Long, + clubMemberId: Long, + ): AccountPaymentTarget? + /** * 납부 대상 스냅샷 저장에 필요한 행만 조회한다: 현재 TARGETED이거나 이번에 선택된 멤버의 행. * 선택되지 않은 EXCLUDED/REFUNDED 행은 스냅샷 계산에 쓰이지 않으므로 로드하지 않는다. @@ -217,7 +222,7 @@ interface AccountPaymentTargetRepository : JpaRepository { status: AccountStatus, ): Account? + fun findAllByClubIdAndStatusAndMemberVisibleTrueOrderByCardinalDesc( + clubId: Long, + status: AccountStatus, + ): List + fun findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( clubId: Long, cardinal: Int, diff --git a/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountTransactionRepository.kt b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountTransactionRepository.kt index e8a7558e..20ec668f 100644 --- a/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountTransactionRepository.kt +++ b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountTransactionRepository.kt @@ -5,7 +5,10 @@ import com.weeth.domain.account.domain.enums.AccountTransactionDirection import com.weeth.domain.account.domain.enums.AccountTransactionType import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param interface AccountTransactionRepository : JpaRepository { fun countByAccountIdAndTypeInAndDeletedAtIsNull( @@ -51,6 +54,33 @@ interface AccountTransactionRepository : JpaRepository pageable: Pageable, ): Page + // 무한 스크롤: 전체 개수를 세지 않고 size+1 조회로 다음 페이지 존재 여부만 판별하므로 countQuery 가 없다. + @Query( + """ + select transaction + from AccountTransaction transaction + left join transaction.paymentTarget paymentTarget + left join paymentTarget.clubMember clubMember + where transaction.account.id = :accountId + and transaction.deletedAt is null + and ( + transaction.type in :publicTypes + or ( + :includeRefund = true + and transaction.type = com.weeth.domain.account.domain.enums.AccountTransactionType.REFUND + and clubMember.id = :clubMemberId + ) + ) + """, + ) + fun findMemberVisibleTransactions( + @Param("accountId") accountId: Long, + @Param("clubMemberId") clubMemberId: Long, + @Param("publicTypes") publicTypes: Collection, + @Param("includeRefund") includeRefund: Boolean, + pageable: Pageable, + ): Slice + // 필터 탭 카운트 fun countByAccountIdAndDeletedAtIsNull(accountId: Long): Long @@ -63,4 +93,58 @@ interface AccountTransactionRepository : JpaRepository accountId: Long, direction: AccountTransactionDirection, ): Long + + @Query( + """ + select count(transaction) + from AccountTransaction transaction + left join transaction.paymentTarget paymentTarget + left join paymentTarget.clubMember clubMember + where transaction.account.id = :accountId + and transaction.deletedAt is null + and ( + transaction.type in :publicTypes + or ( + :includeRefund = true + and transaction.type = com.weeth.domain.account.domain.enums.AccountTransactionType.REFUND + and clubMember.id = :clubMemberId + ) + ) + """, + ) + fun countMemberVisibleTransactions( + @Param("accountId") accountId: Long, + @Param("clubMemberId") clubMemberId: Long, + @Param("publicTypes") publicTypes: Collection, + @Param("includeRefund") includeRefund: Boolean, + ): Long + + // 회비 집계는 환불을 净차감한다: DUES 는 보존하고 REFUND 지출 거래를 별도로 쌓는 모델이라 + // DUES 합만 보면 환불된 금액까지 포함되므로, REFUND 금액을 빼서 실제 순납부액을 반환한다. + @Query( + """ + select coalesce( + sum( + case + when transaction.type = com.weeth.domain.account.domain.enums.AccountTransactionType.DUES + then transaction.amount + when transaction.type = com.weeth.domain.account.domain.enums.AccountTransactionType.REFUND + then -transaction.amount + else 0 + end + ), + 0 + ) + from AccountTransaction transaction + where transaction.account.id = :accountId + and transaction.deletedAt is null + and transaction.type in ( + com.weeth.domain.account.domain.enums.AccountTransactionType.DUES, + com.weeth.domain.account.domain.enums.AccountTransactionType.REFUND + ) + """, + ) + fun sumNetDuesAmountByAccountId( + @Param("accountId") accountId: Long, + ): Long } diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt new file mode 100644 index 00000000..d3a92225 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountController.kt @@ -0,0 +1,106 @@ +package com.weeth.domain.account.presentation + +import com.weeth.domain.account.application.dto.request.AccountTransactionFilter +import com.weeth.domain.account.application.dto.request.AccountTransactionSort +import com.weeth.domain.account.application.dto.response.AccountCardinalResponse +import com.weeth.domain.account.application.dto.response.MemberAccountTransactionsResponse +import com.weeth.domain.account.application.dto.response.MemberTransactionDetailResponse +import com.weeth.domain.account.application.dto.response.MyAccountResponse +import com.weeth.domain.account.application.exception.AccountErrorCode +import com.weeth.domain.account.application.usecase.query.GetMyAccountQueryService +import com.weeth.domain.account.application.usecase.query.GetMyAccountTransactionQueryService +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_CARDINAL_LIST_FIND_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_MY_SUMMARY_FIND_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_TRANSACTION_DETAIL_FIND_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_TRANSACTION_FIND_SUCCESS +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.common.web.TsidParam +import com.weeth.global.common.web.TsidPathVariable +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +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.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "ACCOUNT", description = "회비 API") +@RestController +@RequestMapping("/api/v4/clubs/{clubId}/accounts") +@ApiErrorCodeExample(AccountErrorCode::class) +class AccountController( + private val getMyAccountQueryService: GetMyAccountQueryService, + private val getMyAccountTransactionQueryService: GetMyAccountTransactionQueryService, +) { + @GetMapping("/cardinals") + @Operation(summary = "부원 회비 기수 목록 조회") + fun findCardinals( + @TsidParam + @TsidPathVariable clubId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse> = + CommonResponse.success( + ACCOUNT_CARDINAL_LIST_FIND_SUCCESS, + getMyAccountQueryService.findCardinals(clubId = clubId, userId = userId), + ) + + @GetMapping("/{cardinal}/me") + @Operation(summary = "나의 회비 정보 조회") + fun findMyAccount( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable cardinal: Int, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + ACCOUNT_MY_SUMMARY_FIND_SUCCESS, + getMyAccountQueryService.findMyAccount(clubId = clubId, cardinal = cardinal, userId = userId), + ) + + @GetMapping("/{cardinal}/transactions") + @Operation(summary = "부원 회비 거래 내역 목록 조회") + fun findTransactions( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable cardinal: Int, + @RequestParam(defaultValue = "ALL") filter: AccountTransactionFilter, + @RequestParam(defaultValue = "LATEST") sort: AccountTransactionSort, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + ACCOUNT_TRANSACTION_FIND_SUCCESS, + getMyAccountTransactionQueryService.findTransactions( + clubId = clubId, + cardinal = cardinal, + filter = filter, + sort = sort, + page = page, + size = size, + userId = userId, + ), + ) + + @GetMapping("/{cardinal}/transactions/{transactionId}") + @Operation(summary = "부원 회비 거래 내역 상세 조회") + fun findTransaction( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable cardinal: Int, + @PathVariable transactionId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + ACCOUNT_TRANSACTION_DETAIL_FIND_SUCCESS, + getMyAccountTransactionQueryService.findTransaction( + clubId = clubId, + cardinal = cardinal, + transactionId = transactionId, + userId = userId, + ), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountRegisterController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountRegisterController.kt index b9ea62fd..57258a91 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountRegisterController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountRegisterController.kt @@ -119,7 +119,8 @@ class AccountRegisterController( description = "등록 플로우 복원과 최종 확인에서 납부 대상/제외 대상 목록을 조회합니다. " + "각 행의 targetStatus(TARGETED/EXCLUDED)가 체크박스 초기 상태이며, " + - "이후 사용자가 변경한 멤버만 모아 납부 대상 저장 API에 델타로 전달해주세요. " + + "이후 사용자가 최종 선택한 대상 전체를 납부 대상 저장 API에 스냅샷으로 전달해주세요" + + "(전송 목록에 없는 명부 회원은 자동 제외 처리됩니다). " + "키워드와 상태 필터링도 가능하도록 했으나, 되도록 프론트에서 캐싱된 데이터로 필터링해주시면 감사하겠습니다.", ) fun findPaymentTargets( diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt index 8c6fd5f6..5c5dfd5b 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt @@ -26,4 +26,6 @@ enum class AccountResponseCode( ACCOUNT_PAYMENT_MARK_UNPAID_SUCCESS(10126, HttpStatus.OK, "납부가 취소 처리되었습니다."), ACCOUNT_PAYMENT_REFUND_SUCCESS(10127, HttpStatus.OK, "환불이 처리되었습니다."), ACCOUNT_PAYMENT_STATUS_FIND_SUCCESS(10128, HttpStatus.OK, "부원별 납부현황이 성공적으로 조회되었습니다."), + ACCOUNT_MY_SUMMARY_FIND_SUCCESS(10129, HttpStatus.OK, "나의 회비 정보가 성공적으로 조회되었습니다."), + ACCOUNT_CARDINAL_LIST_FIND_SUCCESS(10130, HttpStatus.OK, "회비 기수 목록이 성공적으로 조회되었습니다."), } diff --git a/src/main/kotlin/com/weeth/global/common/response/SliceResponse.kt b/src/main/kotlin/com/weeth/global/common/response/SliceResponse.kt new file mode 100644 index 00000000..2c687f9c --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/response/SliceResponse.kt @@ -0,0 +1,27 @@ +package com.weeth.global.common.response + +import org.springframework.data.domain.Slice + +/** + * 무한 스크롤 목록 응답 래퍼. + * 전체 개수(totalElements/totalPages)를 계산하지 않는 대신 다음 페이지 존재 여부(`hasNext`)만 제공한다. + * 전체 개수가 필요한 화면은 [PageResponse] 를 사용한다. + */ +data class SliceResponse( + val content: List, + val pageNumber: Int, + val pageSize: Int, + val numberOfElements: Int, + val hasNext: Boolean, +) { + companion object { + fun from(slice: Slice): SliceResponse = + SliceResponse( + content = slice.content, + pageNumber = slice.number, + pageSize = slice.size, + numberOfElements = slice.numberOfElements, + hasNext = slice.hasNext(), + ) + } +} diff --git a/src/main/resources/db/migration/V7__add_account_transaction_registered_by_name.sql b/src/main/resources/db/migration/V7__add_account_transaction_registered_by_name.sql new file mode 100644 index 00000000..add341f4 --- /dev/null +++ b/src/main/resources/db/migration/V7__add_account_transaction_registered_by_name.sql @@ -0,0 +1,4 @@ +-- 부원 거래 상세의 "등록자" 표기를 위한 이름 스냅샷. +-- 기존 거래는 값이 없을 수 있으므로 nullable 로 추가하고 응답에서 "운영진"으로 fallback 한다. +ALTER TABLE account_transaction + ADD COLUMN registered_by_name VARCHAR(50) NULL; diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountPaymentUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountPaymentUseCaseTest.kt index 34ffaf4c..7e6b307e 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountPaymentUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountPaymentUseCaseTest.kt @@ -17,6 +17,7 @@ import com.weeth.domain.account.domain.vo.Money import com.weeth.domain.account.fixture.AccountTestFixture import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.nulls.shouldNotBeNull @@ -49,9 +50,11 @@ class ManageAccountPaymentUseCaseTest : val userId = 10L val accountId = 1L + val adminMember = ClubMemberTestFixture.createAdminMember(user = UserTestFixture.createAdmin(id = userId)) beforeTest { clearMocks(accountRepository, paymentTargetRepository, transactionRepository, clubPermissionPolicy) + every { clubPermissionPolicy.requireAdmin(any(), userId) } returns adminMember } fun target( @@ -84,7 +87,8 @@ class ManageAccountPaymentUseCaseTest : transactionRepository.saveAll( match> { it.size == 1 && it.first().type == AccountTransactionType.DUES && - it.first().amount == 30_000 + it.first().amount == 30_000 && + it.first().registeredByName == "적순" }, ) } @@ -153,7 +157,8 @@ class ManageAccountPaymentUseCaseTest : transactionRepository.saveAll( match> { it.size == 1 && it.first().type == AccountTransactionType.REFUND && - it.first().amount == 30_000 + it.first().amount == 30_000 && + it.first().registeredByName == "적순" }, ) } diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCaseTest.kt index 4d74c0d7..e185a4b0 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountTransactionUseCaseTest.kt @@ -16,6 +16,7 @@ import com.weeth.domain.account.domain.repository.AccountTransactionRepository import com.weeth.domain.account.domain.vo.Money import com.weeth.domain.account.fixture.AccountTestFixture import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.file.application.dto.request.FileSaveRequest import com.weeth.domain.file.application.dto.response.FileResponse import com.weeth.domain.file.application.mapper.FileMapper @@ -26,6 +27,7 @@ import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.file.domain.vo.StorageKey import com.weeth.domain.file.fixture.FileTestFixture +import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.nulls.shouldNotBeNull @@ -62,6 +64,7 @@ class ManageAccountTransactionUseCaseTest : val accountId = 1L val transactionId = 100L val date = LocalDate.of(2026, 7, 20) + val adminMember = ClubMemberTestFixture.createAdminMember(user = UserTestFixture.createAdmin(id = userId)) val receiptRequest = FileSaveRequest( fileName = "receipt.png", @@ -115,6 +118,7 @@ class ManageAccountTransactionUseCaseTest : every { fileRepository.deleteAll(any>()) } just runs every { fileRepository.flush() } just runs every { fileReader.findAll(any(), any(), any()) } returns emptyList() + every { clubPermissionPolicy.requireAdmin(any(), userId) } returns adminMember } fun appliedTransaction( @@ -145,7 +149,7 @@ class ManageAccountTransactionUseCaseTest : response.type shouldBe AccountTransactionType.EXPENSE account.currentBalance shouldBe 70_000 account.lastModifiedBy shouldBe userId - verify(exactly = 1) { transactionRepository.save(any()) } + verify(exactly = 1) { transactionRepository.save(match { it.registeredByName == "적순" }) } } it("영수증 파일이 있으면 거래 ID를 ownerId로 파일 메타데이터를 저장하고 응답에 포함한다") { diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/MembershipFeeRegistrationIntegrationTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/MembershipFeeRegistrationIntegrationTest.kt index e589241a..e33b5d64 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/MembershipFeeRegistrationIntegrationTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/MembershipFeeRegistrationIntegrationTest.kt @@ -260,7 +260,7 @@ class MembershipFeeRegistrationIntegrationTest( registerAccountUseCase.savePaymentTargets( context.club.id, accountId, - SavePaymentTargetsRequest(), + SavePaymentTargetsRequest(targetedClubMemberIds = emptyList()), context.adminUser.id, ) findTargets(context, accountId, targetStatus = null).summary.targetedCount shouldBe 0 @@ -405,6 +405,33 @@ class MembershipFeeRegistrationIntegrationTest( ) }.errorCode.code shouldBe AccountErrorCode.ACCOUNT_NOT_FOUND.code } + + it("S12. 회비 순합계는 환불을 차감하고 회비 외 거래는 무시한다") { + val context = createContext("S12", previousBalance = 0) + val account = context.previousAccount.shouldNotBeNull() + + fun save( + type: AccountTransactionType, + amount: Int, + ) = transactionRepository.save( + AccountTransaction.create( + account = account, + type = type, + title = "거래", + source = null, + amount = Money.of(amount), + transactedAt = LocalDateTime.now(), + ), + ) + + save(AccountTransactionType.DUES, 35_000) + save(AccountTransactionType.DUES, 35_000) + save(AccountTransactionType.REFUND, 35_000) + save(AccountTransactionType.EXPENSE, 10_000) + + // 70_000(DUES) - 35_000(REFUND) = 35_000, EXPENSE 는 집계 제외 + transactionRepository.sumNetDuesAmountByAccountId(account.id) shouldBe 35_000L + } } } diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/RegisterAccountUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/RegisterAccountUseCaseTest.kt index 6275aedb..715501a8 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/RegisterAccountUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/RegisterAccountUseCaseTest.kt @@ -324,7 +324,7 @@ class RegisterAccountUseCaseTest : useCase.savePaymentTargets( clubId = clubId, accountId = accountId, - request = SavePaymentTargetsRequest(), + request = SavePaymentTargetsRequest(targetedClubMemberIds = emptyList()), userId = userId, ) @@ -357,7 +357,7 @@ class RegisterAccountUseCaseTest : useCase.savePaymentTargets( clubId = clubId, accountId = accountId, - request = SavePaymentTargetsRequest(), + request = SavePaymentTargetsRequest(targetedClubMemberIds = emptyList()), userId = userId, ) } diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetMyAccountQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetMyAccountQueryServiceTest.kt new file mode 100644 index 00000000..7d1c8629 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetMyAccountQueryServiceTest.kt @@ -0,0 +1,193 @@ +package com.weeth.domain.account.application.usecase.query + +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.mapper.MyAccountMapper +import com.weeth.domain.account.application.usecase.MemberAccountAccessResolver +import com.weeth.domain.account.domain.entity.Account +import com.weeth.domain.account.domain.entity.AccountPaymentTarget +import com.weeth.domain.account.domain.enums.AccountPaymentStatus +import com.weeth.domain.account.domain.enums.AccountStatus +import com.weeth.domain.account.domain.repository.AccountPaymentTargetRepository +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.account.domain.vo.BankAccount +import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDateTime + +class GetMyAccountQueryServiceTest : + DescribeSpec({ + val accountRepository = mockk() + val paymentTargetRepository = mockk() + val clubMemberPolicy = mockk() + val queryService = + GetMyAccountQueryService( + accountRepository = accountRepository, + paymentTargetRepository = paymentTargetRepository, + clubMemberPolicy = clubMemberPolicy, + memberAccountAccessResolver = MemberAccountAccessResolver(accountRepository, clubMemberPolicy), + myAccountMapper = MyAccountMapper(), + ) + + val club = ClubTestFixture.createClub(id = 1L) + val userId = 10L + val member = + ClubMemberTestFixture.createActiveMember( + id = 50L, + club = club, + user = UserTestFixture.createActiveUser1(id = userId), + ) + + fun account( + id: Long, + cardinal: Int, + name: String = "${cardinal}기 회비", + memberVisible: Boolean = true, + bankAccountVisible: Boolean = true, + ): Account = + Account( + id = id, + club = club, + cardinal = cardinal, + name = name, + duesAmount = 60_000, + currentBalance = 152_129, + bankAccount = BankAccount.of("국민은행", "12-12412-1231", "가천대 검도부", "이름_회비"), + bankAccountVisible = bankAccountVisible, + memberVisible = memberVisible, + status = AccountStatus.ACTIVE, + ) + + fun targeted( + account: Account, + member: ClubMember, + paid: Boolean = false, + ): AccountPaymentTarget = + AccountPaymentTarget + .createTargeted(account = account, clubMember = member, dueAmount = Money.of(60_000)) + .also { + if (paid) { + it.markPaid( + amount = Money.of(60_000), + confirmedBy = 99L, + paidAt = LocalDateTime.of(2026, 7, 20, 12, 0), + ) + } + } + + beforeTest { + clearMocks(accountRepository, paymentTargetRepository, clubMemberPolicy) + every { clubMemberPolicy.getActiveMember(club.id, userId) } returns member + } + + describe("findCardinals") { + it("공개 ACTIVE 장부 기수만 최신순으로 반환하고 가장 높은 기수를 latest로 표시한다") { + every { + accountRepository.findAllByClubIdAndStatusAndMemberVisibleTrueOrderByCardinalDesc( + club.id, + AccountStatus.ACTIVE, + ) + } returns listOf(account(id = 7L, cardinal = 7), account(id = 6L, cardinal = 6)) + + val result = queryService.findCardinals(club.id, userId) + + result.map { it.cardinal } shouldBe listOf(7, 6) + result[0].isLatest shouldBe true + result[1].isLatest shouldBe false + verify(exactly = 1) { clubMemberPolicy.getActiveMember(club.id, userId) } + } + } + + describe("findMyAccount") { + it("나의 납부 상태, 공개 계좌, 잔액과 목표액을 반환한다") { + val account = account(id = 12L, cardinal = 7) + val target = targeted(account, member) + every { + accountRepository.findByClubIdAndCardinalAndStatusAndMemberVisibleTrue( + club.id, + 7, + AccountStatus.ACTIVE, + ) + } returns account + every { paymentTargetRepository.findByAccountIdAndClubMemberId(12L, 50L) } returns target + every { paymentTargetRepository.sumDueAmountByAccountId(12L) } returns 1_425_000L + + val result = queryService.findMyAccount(club.id, cardinal = 7, userId = userId) + + result.accountId shouldBe 12L + result.cardinal shouldBe 7 + result.duesAmount shouldBe 60_000 + result.myPayment.targeted shouldBe true + result.myPayment.status shouldBe AccountPaymentStatus.UNPAID + result.myPayment.dueAmount shouldBe 60_000 + result.bankAccountVisible shouldBe true + result.bankAccount.shouldNotBeNull().accountNumber shouldBe "12-12412-1231" + result.balance.currentBalance shouldBe 152_129 + result.balance.goalAmount shouldBe 1_425_000 + } + + it("계좌 비공개 장부는 bankAccountVisible=true가 아니면 계좌 정보를 숨긴다") { + val account = account(id = 12L, cardinal = 7, bankAccountVisible = false) + every { + accountRepository.findByClubIdAndCardinalAndStatusAndMemberVisibleTrue( + club.id, + 7, + AccountStatus.ACTIVE, + ) + } returns account + every { paymentTargetRepository.findByAccountIdAndClubMemberId(12L, 50L) } returns + targeted(account, member) + every { paymentTargetRepository.sumDueAmountByAccountId(12L) } returns 1_425_000L + + val result = queryService.findMyAccount(club.id, cardinal = 7, userId = userId) + + result.bankAccountVisible shouldBe false + result.bankAccount shouldBe null + } + + it("납부 대상 행이 없으면 targeted=false로 반환한다") { + val account = account(id = 12L, cardinal = 7) + every { + accountRepository.findByClubIdAndCardinalAndStatusAndMemberVisibleTrue( + club.id, + 7, + AccountStatus.ACTIVE, + ) + } returns account + every { paymentTargetRepository.findByAccountIdAndClubMemberId(12L, 50L) } returns null + every { paymentTargetRepository.sumDueAmountByAccountId(12L) } returns 1_425_000L + + val result = queryService.findMyAccount(club.id, cardinal = 7, userId = userId) + + result.myPayment.targeted shouldBe false + result.myPayment.status shouldBe null + result.myPayment.dueAmount shouldBe 0 + result.myPayment.paidAmount shouldBe 0 + } + + it("미공개 또는 없는 장부는 AccountNotFound로 은닉한다") { + every { + accountRepository.findByClubIdAndCardinalAndStatusAndMemberVisibleTrue( + club.id, + 7, + AccountStatus.ACTIVE, + ) + } returns null + + shouldThrow { + queryService.findMyAccount(club.id, cardinal = 7, userId = userId) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetMyAccountTransactionQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetMyAccountTransactionQueryServiceTest.kt new file mode 100644 index 00000000..2da3f49c --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetMyAccountTransactionQueryServiceTest.kt @@ -0,0 +1,363 @@ +package com.weeth.domain.account.application.usecase.query + +import com.weeth.domain.account.application.dto.request.AccountTransactionFilter +import com.weeth.domain.account.application.dto.request.AccountTransactionSort +import com.weeth.domain.account.application.exception.AccountTransactionNotFoundException +import com.weeth.domain.account.application.mapper.MemberTransactionMapper +import com.weeth.domain.account.application.usecase.MemberAccountAccessResolver +import com.weeth.domain.account.domain.entity.Account +import com.weeth.domain.account.domain.entity.AccountPaymentTarget +import com.weeth.domain.account.domain.entity.AccountTransaction +import com.weeth.domain.account.domain.enums.AccountStatus +import com.weeth.domain.account.domain.enums.AccountTransactionType +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.account.domain.repository.AccountTransactionRepository +import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.enums.FileOwnerType +import com.weeth.domain.file.domain.enums.FileStatus +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.vo.StorageKey +import com.weeth.domain.file.fixture.FileTestFixture +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.data.domain.PageImpl +import org.springframework.test.util.ReflectionTestUtils +import java.time.LocalDateTime + +class GetMyAccountTransactionQueryServiceTest : + DescribeSpec({ + val accountRepository = mockk() + val transactionRepository = mockk() + val clubMemberPolicy = mockk() + val fileReader = mockk() + val fileMapper = mockk() + val queryService = + GetMyAccountTransactionQueryService( + transactionRepository = transactionRepository, + memberAccountAccessResolver = MemberAccountAccessResolver(accountRepository, clubMemberPolicy), + fileReader = fileReader, + fileMapper = fileMapper, + memberTransactionMapper = MemberTransactionMapper(), + ) + + val club = ClubTestFixture.createClub(id = 1L) + val userId = 10L + val accountId = 12L + val member = + ClubMemberTestFixture.createActiveMember( + id = 50L, + club = club, + user = UserTestFixture.createActiveUser1(id = userId), + ) + val otherMember = + ClubMemberTestFixture.createActiveMember( + id = 51L, + club = club, + user = UserTestFixture.createActiveUser2(id = 11L), + ) + val account = + Account( + id = accountId, + club = club, + cardinal = 7, + name = "7기 회비", + duesAmount = 60_000, + currentBalance = 152_129, + memberVisible = true, + status = AccountStatus.ACTIVE, + ) + val receiptResponse = + FileResponse( + fileId = 200L, + fileName = "receipt.png", + fileUrl = "https://cdn.weeth/receipt.png", + storageKey = "ACCOUNT_TRANSACTION/2026-07/550e8400-e29b-41d4-a716-446655440200_receipt.png", + fileSize = 1024, + contentType = "image/png", + status = FileStatus.UPLOADED, + ) + + fun target( + member: ClubMember, + id: Long, + ): AccountPaymentTarget = + AccountPaymentTarget + .createTargeted(account = account, clubMember = member, dueAmount = Money.of(60_000)) + .also { ReflectionTestUtils.setField(it, "id", id) } + + fun transaction( + id: Long, + type: AccountTransactionType, + source: String? = "출처", + paymentTarget: AccountPaymentTarget? = null, + registeredByName: String? = "운영진 김검도", + ): AccountTransaction = + AccountTransaction + .create( + account = account, + type = type, + title = "거래", + source = source, + amount = Money.of(30_000), + transactedAt = LocalDateTime.of(2026, 7, 20, 0, 0), + memo = "메모", + paymentTarget = paymentTarget, + registeredByName = registeredByName, + ).also { ReflectionTestUtils.setField(it, "id", id) } + + fun receiptFile(ownerId: Long) = + FileTestFixture.createFile( + id = 200L, + fileName = "receipt.png", + storageKey = StorageKey(receiptResponse.storageKey), + ownerType = FileOwnerType.ACCOUNT_TRANSACTION, + ownerId = ownerId, + ) + + beforeTest { + clearMocks(accountRepository, transactionRepository, clubMemberPolicy, fileReader, fileMapper) + every { clubMemberPolicy.getActiveMember(club.id, userId) } returns member + every { + accountRepository.findByClubIdAndCardinalAndStatusAndMemberVisibleTrue( + club.id, + 7, + AccountStatus.ACTIVE, + ) + } returns account + every { transactionRepository.sumNetDuesAmountByAccountId(accountId) } returns 110_000L + every { + transactionRepository.countMemberVisibleTransactions( + accountId = accountId, + clubMemberId = 50L, + publicTypes = + listOf( + AccountTransactionType.INCOME, + AccountTransactionType.EXPENSE, + AccountTransactionType.CARRY_OVER, + ), + includeRefund = true, + ) + } returns 4 + every { + transactionRepository.countMemberVisibleTransactions( + accountId = accountId, + clubMemberId = 50L, + publicTypes = listOf(AccountTransactionType.INCOME), + includeRefund = false, + ) + } returns 1 + every { + transactionRepository.countMemberVisibleTransactions( + accountId = accountId, + clubMemberId = 50L, + publicTypes = listOf(AccountTransactionType.EXPENSE), + includeRefund = true, + ) + } returns 3 + every { fileReader.findAll(FileOwnerType.ACCOUNT_TRANSACTION, any>(), any()) } returns + emptyList() + every { fileReader.findAll(FileOwnerType.ACCOUNT_TRANSACTION, any(), any()) } returns emptyList() + every { fileMapper.toFileResponse(any()) } returns receiptResponse + } + + describe("findTransactions") { + it("ALL 목록은 DUES를 제외하고 공개 거래와 내 REFUND만 반환하며 REFUND source를 마스킹한다") { + val ownRefund = transaction(101L, AccountTransactionType.REFUND, "홍길동", target(member, 1L)) + val manualIncome = transaction(102L, AccountTransactionType.INCOME, "후원") + every { + transactionRepository.findMemberVisibleTransactions( + accountId = accountId, + clubMemberId = 50L, + publicTypes = + listOf( + AccountTransactionType.INCOME, + AccountTransactionType.EXPENSE, + AccountTransactionType.CARRY_OVER, + ), + includeRefund = true, + pageable = any(), + ) + } returns PageImpl(listOf(ownRefund, manualIncome)) + + val result = + queryService.findTransactions( + clubId = club.id, + cardinal = 7, + filter = AccountTransactionFilter.ALL, + sort = AccountTransactionSort.LATEST, + page = 0, + size = 20, + userId = userId, + ) + + result.duesSummary.shouldNotBeNull().totalAmount shouldBe 110_000 + result.counts.shouldNotBeNull().all shouldBe 4 + result.counts.shouldNotBeNull().dues shouldBe 1 + result.transactions.content.map { it.transactionId } shouldBe listOf(101L, 102L) + result.transactions.content + .first() + .source shouldBe "환불" + } + + it("DUES 필터는 개별 거래 없이 duesSummary만 반환한다") { + val result = + queryService.findTransactions( + clubId = club.id, + cardinal = 7, + filter = AccountTransactionFilter.DUES, + sort = AccountTransactionSort.LATEST, + page = 0, + size = 20, + userId = userId, + ) + + result.duesSummary.shouldNotBeNull().totalAmount shouldBe 110_000 + result.transactions.content.shouldBeEmpty() + verify(exactly = 0) { + transactionRepository.findMemberVisibleTransactions(any(), any(), any(), any(), any()) + } + } + + it("첫 페이지가 아니면 counts·duesSummary를 생략하고 집계 쿼리를 호출하지 않는다") { + every { + transactionRepository.findMemberVisibleTransactions( + accountId = accountId, + clubMemberId = 50L, + publicTypes = + listOf( + AccountTransactionType.INCOME, + AccountTransactionType.EXPENSE, + AccountTransactionType.CARRY_OVER, + ), + includeRefund = true, + pageable = any(), + ) + } returns PageImpl(listOf(transaction(103L, AccountTransactionType.EXPENSE))) + + val result = + queryService.findTransactions( + clubId = club.id, + cardinal = 7, + filter = AccountTransactionFilter.ALL, + sort = AccountTransactionSort.LATEST, + page = 1, + size = 20, + userId = userId, + ) + + result.counts.shouldBeNull() + result.duesSummary.shouldBeNull() + result.transactions.content.map { it.transactionId } shouldBe listOf(103L) + verify(exactly = 0) { transactionRepository.sumNetDuesAmountByAccountId(any()) } + verify(exactly = 0) { + transactionRepository.countMemberVisibleTransactions(any(), any(), any(), any()) + } + } + + it("목록의 hasReceipt는 거래 ID 목록으로 배치 조회한다") { + val expense = transaction(101L, AccountTransactionType.EXPENSE) + val income = transaction(102L, AccountTransactionType.INCOME) + every { + transactionRepository.findMemberVisibleTransactions( + accountId = accountId, + clubMemberId = 50L, + publicTypes = + listOf( + AccountTransactionType.INCOME, + AccountTransactionType.EXPENSE, + AccountTransactionType.CARRY_OVER, + ), + includeRefund = true, + pageable = any(), + ) + } returns PageImpl(listOf(expense, income)) + every { + fileReader.findAll(FileOwnerType.ACCOUNT_TRANSACTION, listOf(101L, 102L), any()) + } returns listOf(receiptFile(ownerId = 101L)) + + val result = + queryService.findTransactions( + clubId = club.id, + cardinal = 7, + filter = AccountTransactionFilter.ALL, + sort = AccountTransactionSort.LATEST, + page = 0, + size = 20, + userId = userId, + ) + + result.transactions.content[0].hasReceipt shouldBe true + result.transactions.content[1].hasReceipt shouldBe false + verify(exactly = 1) { + fileReader.findAll(FileOwnerType.ACCOUNT_TRANSACTION, listOf(101L, 102L), any()) + } + } + } + + describe("findTransaction") { + it("수동 거래 상세는 영수증과 등록자 이름 스냅샷을 반환한다") { + val expense = transaction(101L, AccountTransactionType.EXPENSE, registeredByName = "운영진 김검도") + every { transactionRepository.findByIdAndDeletedAtIsNull(101L) } returns expense + every { fileReader.findAll(FileOwnerType.ACCOUNT_TRANSACTION, 101L, any()) } returns + listOf(receiptFile(ownerId = 101L)) + + val result = queryService.findTransaction(club.id, cardinal = 7, transactionId = 101L, userId = userId) + + result.transactionId shouldBe 101L + result.registeredByName shouldBe "운영진 김검도" + result.hasReceipt shouldBe true + result.receipts shouldBe listOf(receiptResponse) + } + + it("등록자 이름이 없으면 운영진으로 fallback한다") { + val expense = transaction(101L, AccountTransactionType.EXPENSE, registeredByName = null) + every { transactionRepository.findByIdAndDeletedAtIsNull(101L) } returns expense + + val result = queryService.findTransaction(club.id, cardinal = 7, transactionId = 101L, userId = userId) + + result.registeredByName shouldBe "운영진" + } + + it("내 REFUND 상세는 source를 환불로 마스킹한다") { + val ownRefund = transaction(101L, AccountTransactionType.REFUND, "홍길동", target(member, 1L)) + every { transactionRepository.findByIdAndDeletedAtIsNull(101L) } returns ownRefund + + val result = queryService.findTransaction(club.id, cardinal = 7, transactionId = 101L, userId = userId) + + result.source shouldBe "환불" + } + + it("DUES 상세 요청은 NotFound로 은닉한다") { + every { transactionRepository.findByIdAndDeletedAtIsNull(101L) } returns + transaction(101L, AccountTransactionType.DUES, "홍길동", target(member, 1L)) + + shouldThrow { + queryService.findTransaction(club.id, cardinal = 7, transactionId = 101L, userId = userId) + } + } + + it("다른 부원의 REFUND 상세 요청은 NotFound로 은닉한다") { + every { transactionRepository.findByIdAndDeletedAtIsNull(101L) } returns + transaction(101L, AccountTransactionType.REFUND, "다른부원", target(otherMember, 2L)) + + shouldThrow { + queryService.findTransaction(club.id, cardinal = 7, transactionId = 101L, userId = userId) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/presentation/AccountControllerTest.kt b/src/test/kotlin/com/weeth/domain/account/presentation/AccountControllerTest.kt new file mode 100644 index 00000000..709c5b7f --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/presentation/AccountControllerTest.kt @@ -0,0 +1,156 @@ +package com.weeth.domain.account.presentation + +import com.weeth.domain.account.application.dto.request.AccountTransactionFilter +import com.weeth.domain.account.application.dto.request.AccountTransactionSort +import com.weeth.domain.account.application.dto.response.AccountCardinalResponse +import com.weeth.domain.account.application.dto.response.MemberAccountTransactionsResponse +import com.weeth.domain.account.application.dto.response.MemberTransactionDetailResponse +import com.weeth.domain.account.application.dto.response.MyAccountResponse +import com.weeth.domain.account.application.usecase.query.GetMyAccountQueryService +import com.weeth.domain.account.application.usecase.query.GetMyAccountTransactionQueryService +import com.weeth.domain.account.domain.enums.AccountTransactionDirection +import com.weeth.domain.account.domain.enums.AccountTransactionType +import com.weeth.global.common.response.SliceResponse +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.time.LocalDateTime + +class AccountControllerTest : + DescribeSpec({ + val getMyAccountQueryService = mockk() + val getMyAccountTransactionQueryService = mockk() + val controller = + AccountController( + getMyAccountQueryService = getMyAccountQueryService, + getMyAccountTransactionQueryService = getMyAccountTransactionQueryService, + ) + + val clubId = 1L + val userId = 10L + + beforeTest { + clearMocks(getMyAccountQueryService, getMyAccountTransactionQueryService) + } + + describe("findCardinals") { + it("회비 기수 목록 성공 코드를 반환한다") { + val data = listOf(AccountCardinalResponse(cardinal = 7, name = "7기 회비", isLatest = true)) + every { getMyAccountQueryService.findCardinals(clubId, userId) } returns data + + val response = controller.findCardinals(clubId, userId) + + response.code shouldBe AccountResponseCode.ACCOUNT_CARDINAL_LIST_FIND_SUCCESS.code + response.data shouldBe data + } + } + + describe("findMyAccount") { + it("나의 회비 정보 성공 코드를 반환한다") { + val data = + MyAccountResponse( + accountId = 12L, + cardinal = 7, + accountName = "7기 회비", + duesAmount = 60_000, + myPayment = + MyAccountResponse.MyPaymentResponse( + targeted = false, + status = null, + dueAmount = 0, + paidAmount = 0, + paidAt = null, + ), + bankAccountVisible = false, + bankAccount = null, + balance = MyAccountResponse.BalanceResponse(currentBalance = 152_129, goalAmount = 1_425_000), + ) + every { getMyAccountQueryService.findMyAccount(clubId, 7, userId) } returns data + + val response = controller.findMyAccount(clubId, cardinal = 7, userId = userId) + + response.code shouldBe AccountResponseCode.ACCOUNT_MY_SUMMARY_FIND_SUCCESS.code + response.data shouldBe data + } + } + + describe("findTransactions") { + it("부원 거래 목록 성공 코드를 반환하고 필터/정렬을 전달한다") { + val data = + MemberAccountTransactionsResponse( + counts = + MemberAccountTransactionsResponse.TransactionCountsResponse( + all = 0, + expense = 0, + income = 0, + dues = 0, + ), + duesSummary = MemberAccountTransactionsResponse.DuesSummaryResponse(totalAmount = 0), + transactions = SliceResponse(emptyList(), 0, 20, 0, false), + ) + every { + getMyAccountTransactionQueryService.findTransactions( + clubId, + 7, + AccountTransactionFilter.ALL, + AccountTransactionSort.LATEST, + 0, + 20, + userId, + ) + } returns data + + val response = + controller.findTransactions( + clubId = clubId, + cardinal = 7, + filter = AccountTransactionFilter.ALL, + sort = AccountTransactionSort.LATEST, + page = 0, + size = 20, + userId = userId, + ) + + response.code shouldBe AccountResponseCode.ACCOUNT_TRANSACTION_FIND_SUCCESS.code + response.data shouldBe data + verify(exactly = 1) { + getMyAccountTransactionQueryService.findTransactions( + clubId, + 7, + AccountTransactionFilter.ALL, + AccountTransactionSort.LATEST, + 0, + 20, + userId, + ) + } + } + } + + describe("findTransaction") { + it("부원 거래 상세 성공 코드를 반환한다") { + val data = + MemberTransactionDetailResponse( + transactionId = 100L, + type = AccountTransactionType.EXPENSE, + direction = AccountTransactionDirection.EXPENSE, + title = "스터디 지원금", + source = "인프런", + amount = 50_000, + transactedAt = LocalDateTime.of(2026, 7, 20, 0, 0), + category = null, + registeredByName = "운영진", + memo = null, + ) + every { getMyAccountTransactionQueryService.findTransaction(clubId, 7, 100L, userId) } returns data + + val response = controller.findTransaction(clubId, cardinal = 7, transactionId = 100L, userId = userId) + + response.code shouldBe AccountResponseCode.ACCOUNT_TRANSACTION_DETAIL_FIND_SUCCESS.code + response.data shouldBe data + } + } + })