diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountTransactionSort.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountTransactionSort.kt index a4bd6421..5207b26a 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountTransactionSort.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/AccountTransactionSort.kt @@ -3,7 +3,8 @@ package com.weeth.domain.account.application.dto.request import org.springframework.data.domain.Sort /** - * 거래 내역 목록 정렬 옵션. id 를 보조 정렬키로 두어 동일 값에서도 안정적으로 정렬한다. + * 거래 내역 목록 정렬 옵션. 날짜가 같은 거래는 생성 순서로 안정적으로 정렬한다. + * 거래 데이터가 일정 규모를 넘어서면 transactedAt + createdAt 정렬에 맞춘 복합 인덱스를 검토한다. */ enum class AccountTransactionSort { LATEST, @@ -14,8 +15,8 @@ enum class AccountTransactionSort { fun toSort(): Sort = when (this) { - LATEST -> Sort.by(Sort.Direction.DESC, "transactedAt", "id") - OLDEST -> Sort.by(Sort.Direction.ASC, "transactedAt", "id") + LATEST -> Sort.by(Sort.Direction.DESC, "transactedAt", "createdAt") + OLDEST -> Sort.by(Sort.Direction.ASC, "transactedAt", "createdAt") AMOUNT_DESC -> Sort.by(Sort.Order.desc("amount"), Sort.Order.desc("id")) AMOUNT_ASC -> Sort.by(Sort.Order.asc("amount"), Sort.Order.desc("id")) } diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/ExcludePaymentTargetsRequest.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/ExcludePaymentTargetsRequest.kt new file mode 100644 index 00000000..07a62379 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/ExcludePaymentTargetsRequest.kt @@ -0,0 +1,10 @@ +package com.weeth.domain.account.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty + +data class ExcludePaymentTargetsRequest( + @field:Schema(description = "납부 대상에서 제외할 납부 대상 ID 목록 (단건은 길이 1)", example = "[1, 2, 3]") + @field:NotEmpty + val targetIds: List, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountTransactionRequest.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountTransactionRequest.kt index 1d69e632..86e48356 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountTransactionRequest.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountTransactionRequest.kt @@ -8,7 +8,7 @@ import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Positive import jakarta.validation.constraints.Size -import java.time.LocalDate +import java.time.LocalDateTime data class SaveAccountTransactionRequest( @field:Schema( @@ -28,7 +28,7 @@ data class SaveAccountTransactionRequest( @field:Size(max = 30) val source: String, @field:Schema(description = "거래 일자", example = "2026-07-20") - val transactedAt: LocalDate, + val transactedAt: LocalDateTime, @field:Schema(description = "메모", nullable = true) @field:Size(max = 200) val memo: String? = null, diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountDashboardResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountDashboardResponse.kt index 2b21ebad..39dbb94b 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountDashboardResponse.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountDashboardResponse.kt @@ -10,6 +10,8 @@ data class AccountDashboardResponse( val summary: SummaryResponse, @field:Schema(description = "납부 현황 요약 (예: 3 / 24)") val paymentSummary: PaymentSummaryResponse, + @field:Schema(description = "부원에게 회비 공개 여부", example = "true") + val memberVisible: Boolean, @field:Schema(description = "부원에게 계좌 공개 여부", example = "true") val bankAccountPublic: Boolean, @field:Schema(description = "입금 계좌 정보. 등록되지 않았으면 null", nullable = true) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountTransactionResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountTransactionResponse.kt index c1539576..a0ae62f8 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountTransactionResponse.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountTransactionResponse.kt @@ -4,7 +4,7 @@ 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 +import java.time.LocalDate data class AccountTransactionResponse( @field:Schema(description = "거래 ID", example = "1") @@ -19,8 +19,13 @@ data class AccountTransactionResponse( val source: String?, @field:Schema(description = "거래 금액 (원, 부호 없는 양수). 부호는 direction 으로 표기", example = "50000") val amount: Int, - @field:Schema(description = "거래 일시", example = "2026-07-20T00:00:00") - val transactedAt: LocalDateTime, + @field:Schema(description = "거래 일자", example = "2026-07-20") + val transactedAt: LocalDate, + @field:Schema( + description = "해당 거래 직후의 실제 통장 잔액 (원)", + example = "1520000", + ) + val balanceAfter: Int, @field:Schema(description = "메모", nullable = true) val memo: String?, @field:Schema(description = "영수증 존재 여부", example = "true") diff --git a/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountDashboardMapper.kt b/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountDashboardMapper.kt index 6884775b..970b9cc1 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountDashboardMapper.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountDashboardMapper.kt @@ -36,6 +36,7 @@ class AccountDashboardMapper( paidCount = paidCount, totalTargetCount = totalTargetCount, ), + memberVisible = account.memberVisible, bankAccountPublic = account.bankAccountVisible, bankAccount = BankAccountResponse.from(account.bankAccount), lastModified = 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 379c8272..9fd441e9 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 @@ -21,7 +21,7 @@ class AccountTransactionMapper { title = request.title, source = request.source, amount = Money.of(request.amount), - transactedAt = request.transactedAt.atStartOfDay(), + transactedAt = request.transactedAt, memo = request.memo, registeredByName = registeredByName, ) @@ -38,7 +38,8 @@ class AccountTransactionMapper { title = transaction.title, source = transaction.source, amount = transaction.amount, - transactedAt = transaction.transactedAt, + transactedAt = transaction.transactedAt.toLocalDate(), + balanceAfter = transaction.balanceAfter, memo = transaction.memo, hasReceipt = hasReceipt, receipts = receipts, 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 index 04789034..4b5c03b8 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/MemberAccountAccessResolver.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/MemberAccountAccessResolver.kt @@ -5,21 +5,25 @@ 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.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import org.springframework.stereotype.Component /** * 유저 사이드 회비 화면 접근 게이트. - * 부원용 QueryService 전반에서 동일하게 적용되는 두 단계 검증을 한 곳으로 모은다. + * 부원용 QueryService 전반에서 동일하게 적용되는 세 단계 검증을 한 곳으로 모은다. * * 1. 활성 부원만 통과 — 탈퇴/비활성/비소속이면 [ClubMemberPolicy] 가 예외를 던진다. * 2. 공개(`memberVisible=true`)·`ACTIVE` 장부만 노출 — 없으면 [AccountNotFoundException]. * 이 한 줄로 (a)없는 기수 (b)초안 상태 (c)미공개 장부를 모두 404로 은닉한다. + * 3. 내가 참여한 기수의 장부만 노출 — 참여하지 않은 기수는 [AccountNotFoundException] 으로 은닉한다. + * 목록 필터를 우회한 기수 번호 직접 접근을 여기서 차단한다. */ @Component class MemberAccountAccessResolver( private val accountRepository: AccountRepository, private val clubMemberPolicy: ClubMemberPolicy, + private val clubMemberCardinalReader: ClubMemberCardinalReader, ) { fun resolve( clubId: Long, @@ -33,6 +37,13 @@ class MemberAccountAccessResolver( cardinal, AccountStatus.ACTIVE, ) ?: throw AccountNotFoundException() + + val participated = + clubMemberCardinalReader + .findAllByClubMember(member) + .any { it.cardinal.cardinalNumber == cardinal } + if (!participated) throw AccountNotFoundException() + return MemberAccountAccess(account, member) } } 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 093e2f0f..0b53eb56 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 @@ -1,5 +1,6 @@ package com.weeth.domain.account.application.usecase.command +import com.weeth.domain.account.application.dto.request.ExcludePaymentTargetsRequest import com.weeth.domain.account.application.dto.request.MarkPaymentPaidRequest import com.weeth.domain.account.application.dto.request.MarkPaymentUnpaidRequest import com.weeth.domain.account.application.dto.request.RefundPaymentRequest @@ -7,11 +8,13 @@ import com.weeth.domain.account.application.exception.AccountNotActiveException import com.weeth.domain.account.application.exception.AccountNotFoundException import com.weeth.domain.account.application.exception.AccountPaymentNotRefundableException import com.weeth.domain.account.application.exception.AccountPaymentTargetNotFoundException +import com.weeth.domain.account.application.exception.AccountPaymentTargetPaidException import com.weeth.domain.account.application.usecase.validateOwnedBy 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.AccountPaymentStatus +import com.weeth.domain.account.domain.enums.AccountTargetStatus import com.weeth.domain.account.domain.enums.AccountTransactionType import com.weeth.domain.account.domain.repository.AccountPaymentTargetRepository import com.weeth.domain.account.domain.repository.AccountRepository @@ -89,6 +92,35 @@ class ManageAccountPaymentUseCase( account.markModifiedBy(userId) } + /** + * 납부 대상 제외(벌크): 선택된 대상들을 EXCLUDED 로 전이해 납부 대상에서 빼낸다. + * 미납(UNPAID) 대상만 제외할 수 있다 — 납부(PAID)/환불(REFUNDED) 이력이 있으면 거래·잔액 정합성이 깨지므로 + * 하나라도 포함되면 아무것도 변경하지 않고 예외를 던진다(먼저 납부 정정 후 제외하도록 유도). + */ + @Transactional + fun exclude( + clubId: Long, + accountId: Long, + request: ExcludePaymentTargetsRequest, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val account = getActiveAccountWithLock(clubId, accountId) + val targets = getTargets(accountId, request.targetIds) + + // 전부-또는-전무: 부분 적용을 막기 위해 전이 전에 먼저 전량 검증한다. + if (targets.any { + it.targetStatus != AccountTargetStatus.TARGETED || + it.paymentStatus != AccountPaymentStatus.UNPAID + } + ) { + throw AccountPaymentTargetPaidException() + } + + targets.forEach { it.exclude() } + account.markModifiedBy(userId) + } + /** 환불(벌크): DUES는 보존하고 REFUND 지출 거래를 생성해 잔액을 차감하며 상태를 REFUNDED로 전이한다. */ @Transactional fun refund( 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 index b390758d..f6ee1b25 100644 --- 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 @@ -7,6 +7,7 @@ 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.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.service.ClubMemberPolicy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -17,6 +18,7 @@ class GetMyAccountQueryService( private val accountRepository: AccountRepository, private val paymentTargetRepository: AccountPaymentTargetRepository, private val clubMemberPolicy: ClubMemberPolicy, + private val clubMemberCardinalReader: ClubMemberCardinalReader, private val memberAccountAccessResolver: MemberAccountAccessResolver, private val myAccountMapper: MyAccountMapper, ) { @@ -24,12 +26,18 @@ class GetMyAccountQueryService( clubId: Long, userId: Long, ): List { - clubMemberPolicy.getActiveMember(clubId, userId) + val member = clubMemberPolicy.getActiveMember(clubId, userId) + val participatedCardinals = + clubMemberCardinalReader + .findAllByClubMember(member) + .map { it.cardinal.cardinalNumber } + .toSet() val accounts = - accountRepository.findAllByClubIdAndStatusAndMemberVisibleTrueOrderByCardinalDesc( - clubId, - AccountStatus.ACTIVE, - ) + accountRepository + .findAllByClubIdAndStatusAndMemberVisibleTrueOrderByCardinalDesc( + clubId, + AccountStatus.ACTIVE, + ).filter { it.cardinal in participatedCardinals } val latestCardinal = accounts.firstOrNull()?.cardinal return accounts.map { myAccountMapper.toCardinalResponse(it, isLatest = it.cardinal == latestCardinal) } diff --git a/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt b/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt index 460959fe..f341e474 100644 --- a/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt +++ b/src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt @@ -205,6 +205,8 @@ class Account( currentBalance -= transaction.amount } } + // 은행 거래내역처럼 적용 시점의 총잔액을 거래에 스냅샷으로 남긴다. + transaction.recordBalanceAfter(currentBalance) transaction.markApplied() } 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 9d33d2f2..3ee63f01 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 @@ -81,6 +81,12 @@ class AccountTransaction( var transactedAt: LocalDateTime = transactedAt private set + // 거래가 적용된 시점의 장부 총잔액 스냅샷(은행 거래내역의 잔액 열). + // Account.applyTransaction 이 잔액 갱신 직후 기록한다. 이후 거래의 수정/삭제로는 재계산하지 않는다. + @Column(name = "balance_after", nullable = false) + var balanceAfter: Int = 0 + private set + // MVP UI에서는 노출하지 않지만 분류/통계 확장을 위해 선반영해두는 자유 입력 카테고리. // 추후 카테고리 테이블로 승격될 수 있다 (membership-fee-domain-plan.md 참조). @Column(length = 30) @@ -147,6 +153,11 @@ class AccountTransaction( isApplied = true } + /** 거래 적용 시점의 장부 총잔액을 기록한다. [Account.applyTransaction] 에서만 호출한다. */ + internal fun recordBalanceAfter(balance: Int) { + balanceAfter = balance + } + internal fun markReverted() { check(isApplied) { "반영되지 않은 거래는 되돌릴 수 없습니다." } check(deletedAt == null) { "삭제된 거래는 되돌릴 수 없습니다." } diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountManageController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountManageController.kt index 980376c5..52c9200f 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountManageController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountManageController.kt @@ -3,6 +3,7 @@ package com.weeth.domain.account.presentation import com.weeth.domain.account.application.dto.request.AccountPaymentStatusFilter 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.request.ExcludePaymentTargetsRequest import com.weeth.domain.account.application.dto.request.MarkPaymentPaidRequest import com.weeth.domain.account.application.dto.request.MarkPaymentUnpaidRequest import com.weeth.domain.account.application.dto.request.RefundPaymentRequest @@ -21,6 +22,7 @@ import com.weeth.domain.account.application.usecase.query.GetAccountDashboardQue import com.weeth.domain.account.application.usecase.query.GetAccountPaymentTargetQueryService import com.weeth.domain.account.application.usecase.query.GetAccountTransactionQueryService import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_DASHBOARD_FIND_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_PAYMENT_EXCLUDE_SUCCESS import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_PAYMENT_MARK_PAID_SUCCESS import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_PAYMENT_MARK_UNPAID_SUCCESS import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_PAYMENT_REFUND_SUCCESS @@ -228,6 +230,22 @@ class AccountManageController( return CommonResponse.success(ACCOUNT_PAYMENT_MARK_UNPAID_SUCCESS) } + @PatchMapping("/{accountId}/payment-targets/excluded") + @Operation( + summary = "납부 대상 제외(벌크)", + description = "선택한 부원들을 납부 대상에서 제외합니다. 미납 대상만 제외할 수 있으며, 납부 완료·환불 이력이 있으면 제외할 수 없습니다.", + ) + fun excludePaymentTargets( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable accountId: Long, + @RequestBody @Valid request: ExcludePaymentTargetsRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageAccountPaymentUseCase.exclude(clubId, accountId, request, userId) + return CommonResponse.success(ACCOUNT_PAYMENT_EXCLUDE_SUCCESS) + } + @PatchMapping("/{accountId}/payment-targets/refund") @Operation(summary = "환불(벌크)", description = "납부 완료 대상을 환불 처리하고 시스템 환불 지출 거래를 생성합니다. 납부 이력은 보존됩니다.") fun refundPayment( 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 5c5dfd5b..32dca933 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt @@ -28,4 +28,5 @@ enum class AccountResponseCode( ACCOUNT_PAYMENT_STATUS_FIND_SUCCESS(10128, HttpStatus.OK, "부원별 납부현황이 성공적으로 조회되었습니다."), ACCOUNT_MY_SUMMARY_FIND_SUCCESS(10129, HttpStatus.OK, "나의 회비 정보가 성공적으로 조회되었습니다."), ACCOUNT_CARDINAL_LIST_FIND_SUCCESS(10130, HttpStatus.OK, "회비 기수 목록이 성공적으로 조회되었습니다."), + ACCOUNT_PAYMENT_EXCLUDE_SUCCESS(10131, HttpStatus.OK, "선택한 부원이 납부 대상에서 제외되었습니다."), } diff --git a/src/main/resources/db/migration/V9__add_account_transaction_balance_after.sql b/src/main/resources/db/migration/V9__add_account_transaction_balance_after.sql new file mode 100644 index 00000000..734af270 --- /dev/null +++ b/src/main/resources/db/migration/V9__add_account_transaction_balance_after.sql @@ -0,0 +1,32 @@ +ALTER TABLE account_transaction + ADD COLUMN balance_after INT NULL AFTER transacted_at; + +-- 적용 순서를 복원할 별도 컬럼이 없어 백필은 거래일(transacted_at)을 source of truth 로 삼는다. +-- 소급 등록으로 실제 적용 순서와 거래일 순서가 달랐던 과거 데이터는 거래일 순 누적 잔액으로 보정된다. +UPDATE account_transaction target +JOIN ( + SELECT + account_transaction_id, + SUM( + CASE direction + WHEN 'INCOME' THEN amount + WHEN 'EXPENSE' THEN -amount + ELSE 0 + END + ) OVER ( + PARTITION BY account_id + ORDER BY transacted_at ASC, account_transaction_id ASC + ) AS calculated_balance_after + FROM account_transaction + WHERE deleted_at IS NULL + AND is_applied = TRUE +) running_balance + ON target.account_transaction_id = running_balance.account_transaction_id +SET target.balance_after = running_balance.calculated_balance_after; + +UPDATE account_transaction +SET balance_after = 0 +WHERE balance_after IS NULL; + +ALTER TABLE account_transaction + MODIFY COLUMN balance_after INT NOT NULL; diff --git a/src/test/kotlin/com/weeth/domain/account/application/dto/request/AccountTransactionRequestValidationTest.kt b/src/test/kotlin/com/weeth/domain/account/application/dto/request/AccountTransactionRequestValidationTest.kt index 2394879d..d74bad03 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/dto/request/AccountTransactionRequestValidationTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/dto/request/AccountTransactionRequestValidationTest.kt @@ -5,7 +5,7 @@ import com.weeth.domain.file.application.dto.request.FileSaveRequest import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.booleans.shouldBeTrue import jakarta.validation.Validation -import java.time.LocalDate +import java.time.LocalDateTime class AccountTransactionRequestValidationTest : StringSpec({ @@ -32,7 +32,7 @@ class AccountTransactionRequestValidationTest : amount = 10_000, title = "스터디 지원금", source = "인프런", - transactedAt = LocalDate.of(2026, 7, 20), + transactedAt = LocalDateTime.of(2026, 7, 20, 0, 0), files = listOf(receipt1, receipt2), ) 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 7e6b307e..d5f97ef9 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 @@ -1,14 +1,17 @@ package com.weeth.domain.account.application.usecase.command +import com.weeth.domain.account.application.dto.request.ExcludePaymentTargetsRequest import com.weeth.domain.account.application.dto.request.MarkPaymentPaidRequest import com.weeth.domain.account.application.dto.request.MarkPaymentUnpaidRequest import com.weeth.domain.account.application.dto.request.RefundPaymentRequest import com.weeth.domain.account.application.exception.AccountPaymentNotRefundableException import com.weeth.domain.account.application.exception.AccountPaymentTargetNotFoundException +import com.weeth.domain.account.application.exception.AccountPaymentTargetPaidException 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.AccountPaymentStatus +import com.weeth.domain.account.domain.enums.AccountTargetStatus import com.weeth.domain.account.domain.enums.AccountTransactionType import com.weeth.domain.account.domain.repository.AccountPaymentTargetRepository import com.weeth.domain.account.domain.repository.AccountRepository @@ -176,4 +179,87 @@ class ManageAccountPaymentUseCaseTest : } } } + + describe("exclude") { + it("선택한 미납 대상들을 EXCLUDED로 전이하고 마지막 수정자를 기록한다") { + val account = AccountTestFixture.createAccount(id = accountId, currentBalance = 100_000) + val target1 = target(account, id = 100L, due = 30_000, paid = false) + val target2 = target(account, id = 200L, due = 30_000, paid = false) + every { accountRepository.findByIdWithLock(accountId) } returns account + every { paymentTargetRepository.findAllByAccountIdAndIdIn(accountId, listOf(100L, 200L)) } returns + listOf(target1, target2) + + useCase.exclude(account.club.id, accountId, ExcludePaymentTargetsRequest(listOf(100L, 200L)), userId) + + target1.targetStatus shouldBe AccountTargetStatus.EXCLUDED + target2.targetStatus shouldBe AccountTargetStatus.EXCLUDED + target1.dueAmount shouldBe 0 + account.lastModifiedBy shouldBe userId + } + + it("납부 완료(PAID) 대상이 포함되면 예외를 던지고 아무 대상도 변경하지 않는다") { + val account = AccountTestFixture.createAccount(id = accountId, currentBalance = 100_000) + val unpaid = target(account, id = 100L, due = 30_000, paid = false) + val paid = target(account, id = 200L, due = 30_000, paid = true) + every { accountRepository.findByIdWithLock(accountId) } returns account + every { paymentTargetRepository.findAllByAccountIdAndIdIn(accountId, listOf(100L, 200L)) } returns + listOf(unpaid, paid) + + shouldThrow { + useCase.exclude( + account.club.id, + accountId, + ExcludePaymentTargetsRequest(listOf(100L, 200L)), + userId, + ) + } + + unpaid.targetStatus shouldBe AccountTargetStatus.TARGETED + paid.targetStatus shouldBe AccountTargetStatus.TARGETED + } + + it("이미 제외된 대상이 포함되면 예외를 던지고 아무 대상도 변경하지 않는다") { + val account = AccountTestFixture.createAccount(id = accountId, currentBalance = 100_000) + val unpaid = target(account, id = 100L, due = 30_000, paid = false) + val excluded = + AccountPaymentTarget + .createExcluded( + account = account, + clubMember = ClubMemberTestFixture.createActiveMember(club = account.club), + ).also { + ReflectionTestUtils.setField(it, "id", 200L) + } + every { accountRepository.findByIdWithLock(accountId) } returns account + every { paymentTargetRepository.findAllByAccountIdAndIdIn(accountId, listOf(100L, 200L)) } returns + listOf(unpaid, excluded) + + shouldThrow { + useCase.exclude( + account.club.id, + accountId, + ExcludePaymentTargetsRequest(listOf(100L, 200L)), + userId, + ) + } + + unpaid.targetStatus shouldBe AccountTargetStatus.TARGETED + excluded.targetStatus shouldBe AccountTargetStatus.EXCLUDED + } + + it("존재하지 않는 대상이 포함되면 NotFound를 던진다") { + val account = AccountTestFixture.createAccount(id = accountId) + every { accountRepository.findByIdWithLock(accountId) } returns account + every { paymentTargetRepository.findAllByAccountIdAndIdIn(accountId, listOf(100L, 200L)) } returns + listOf(target(account, id = 100L, due = 30_000, paid = false)) + + shouldThrow { + useCase.exclude( + account.club.id, + accountId, + ExcludePaymentTargetsRequest(listOf(100L, 200L)), + userId, + ) + } + } + } }) 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 5ba753b8..7aba4a89 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 @@ -39,7 +39,7 @@ import io.mockk.mockk import io.mockk.runs import io.mockk.verify import org.springframework.test.util.ReflectionTestUtils -import java.time.LocalDate +import java.time.LocalDateTime class ManageAccountTransactionUseCaseTest : DescribeSpec({ @@ -63,7 +63,7 @@ class ManageAccountTransactionUseCaseTest : val userId = 10L val accountId = 1L val transactionId = 100L - val date = LocalDate.of(2026, 7, 20) + val date = LocalDateTime.of(2026, 7, 20, 0, 0) val adminMember = ClubMemberTestFixture.createAdminMember(user = UserTestFixture.createAdmin(id = userId)) val receiptRequest = FileSaveRequest( @@ -132,7 +132,7 @@ class ManageAccountTransactionUseCaseTest : title = "기존 거래", source = null, amount = Money.of(amount), - transactedAt = date.atStartOfDay(), + transactedAt = date, ).also { account.applyTransaction(it) } describe("save") { @@ -221,7 +221,14 @@ class ManageAccountTransactionUseCaseTest : every { accountRepository.findByIdWithLock(accountId) } returns account every { transactionRepository.findByIdAndDeletedAtIsNull(transactionId) } returns transaction val request = - UpdateAccountTransactionRequest(AccountTransactionType.EXPENSE, 50_000, "수정", null, date, null) + UpdateAccountTransactionRequest( + AccountTransactionType.EXPENSE, + 50_000, + "수정", + null, + date.toLocalDate(), + null, + ) val response = useCase.update(account.club.id, accountId, transactionId, request, userId) @@ -317,7 +324,14 @@ class ManageAccountTransactionUseCaseTest : every { accountRepository.findByIdWithLock(accountId) } returns account every { transactionRepository.findByIdAndDeletedAtIsNull(transactionId) } returns transaction val request = - UpdateAccountTransactionRequest(AccountTransactionType.EXPENSE, 1_000, "수정", null, date, null) + UpdateAccountTransactionRequest( + AccountTransactionType.EXPENSE, + 1_000, + "수정", + null, + date.toLocalDate(), + null, + ) shouldThrow { useCase.update(account.club.id, accountId, transactionId, request, userId) @@ -329,7 +343,14 @@ class ManageAccountTransactionUseCaseTest : every { accountRepository.findByIdWithLock(accountId) } returns account every { transactionRepository.findByIdAndDeletedAtIsNull(transactionId) } returns null val request = - UpdateAccountTransactionRequest(AccountTransactionType.EXPENSE, 1_000, "수정", null, date, null) + UpdateAccountTransactionRequest( + AccountTransactionType.EXPENSE, + 1_000, + "수정", + null, + date.toLocalDate(), + null, + ) shouldThrow { useCase.update(account.club.id, accountId, transactionId, request, userId) diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountTransactionQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountTransactionQueryServiceTest.kt index 9e5bef0e..282f590e 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountTransactionQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountTransactionQueryServiceTest.kt @@ -31,6 +31,7 @@ import io.mockk.mockk import io.mockk.verify import org.springframework.data.domain.PageImpl import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort import org.springframework.test.util.ReflectionTestUtils import java.time.LocalDate import java.util.Optional @@ -79,6 +80,7 @@ class GetAccountTransactionQueryServiceTest : id: Long, type: AccountTransactionType, amount: Int, + balanceAfter: Int = 0, ): AccountTransaction = AccountTransaction .create( @@ -88,7 +90,10 @@ class GetAccountTransactionQueryServiceTest : source = null, amount = Money.of(amount), transactedAt = LocalDate.of(2026, 7, 20).atStartOfDay(), - ).also { ReflectionTestUtils.setField(it, "id", id) } + ).also { + ReflectionTestUtils.setField(it, "id", id) + ReflectionTestUtils.setField(it, "balanceAfter", balanceAfter) + } beforeTest { clearMocks(accountRepository, transactionRepository, clubPermissionPolicy, fileReader, fileMapper) @@ -183,6 +188,81 @@ class GetAccountTransactionQueryServiceTest : verify(exactly = 0) { fileReader.findAll(FileOwnerType.ACCOUNT_TRANSACTION, 101L, any()) } } + it("각 거래에 저장된 시점 총잔액(balanceAfter)을 그대로 반환한다") { + val account = AccountTestFixture.createAccount(id = accountId) + val transactions = + listOf( + transaction(account, 100L, AccountTransactionType.EXPENSE, 5_000, balanceAfter = 15_000), + transaction(account, 101L, AccountTransactionType.INCOME, 10_000, balanceAfter = 25_000), + ) + every { accountRepository.findById(accountId) } returns Optional.of(account) + every { transactionRepository.findByAccountIdAndDeletedAtIsNull(accountId, any()) } returns + PageImpl(transactions) + + val result = + queryService.findTransactions( + account.club.id, + accountId, + AccountTransactionFilter.ALL, + AccountTransactionSort.LATEST, + 0, + 20, + userId, + ) + + result.transactions.content[0].balanceAfter shouldBe 15_000 + result.transactions.content[1].balanceAfter shouldBe 25_000 + } + + it("거래 일시는 날짜만 반환한다") { + val account = AccountTestFixture.createAccount(id = accountId) + val tx = transaction(account, 100L, AccountTransactionType.EXPENSE, 5_000) + every { accountRepository.findById(accountId) } returns Optional.of(account) + every { transactionRepository.findByAccountIdAndDeletedAtIsNull(accountId, any()) } returns + PageImpl(listOf(tx)) + + val result = + queryService.findTransactions( + account.club.id, + accountId, + AccountTransactionFilter.ALL, + AccountTransactionSort.LATEST, + 0, + 20, + userId, + ) + + result.transactions.content + .first() + .transactedAt shouldBe LocalDate.of(2026, 7, 20) + } + + it("LATEST 정렬은 거래 일자가 같을 때 생성일 최신순으로 조회한다") { + val account = AccountTestFixture.createAccount(id = accountId) + every { accountRepository.findById(accountId) } returns Optional.of(account) + every { transactionRepository.findByAccountIdAndDeletedAtIsNull(accountId, any()) } answers { + val pageable = secondArg() + val orders = pageable.sort.toList() + + orders[0].property shouldBe "transactedAt" + orders[0].direction shouldBe Sort.Direction.DESC + orders[1].property shouldBe "createdAt" + orders[1].direction shouldBe Sort.Direction.DESC + + PageImpl(emptyList(), pageable, 0) + } + + queryService.findTransactions( + account.club.id, + accountId, + AccountTransactionFilter.ALL, + AccountTransactionSort.LATEST, + 0, + 20, + userId, + ) + } + it("DRAFT 장부면 AccountNotActiveException 을 던진다") { val account = AccountTestFixture.createAccount(id = accountId, status = AccountStatus.DRAFT) every { accountRepository.findById(accountId) } returns Optional.of(account) @@ -236,12 +316,13 @@ class GetAccountTransactionQueryServiceTest : val account = AccountTestFixture.createAccount(id = accountId) every { accountRepository.findById(accountId) } returns Optional.of(account) every { transactionRepository.findByIdAndDeletedAtIsNull(100L) } returns - transaction(account, 100L, AccountTransactionType.EXPENSE, 5_000) + transaction(account, 100L, AccountTransactionType.EXPENSE, 5_000, balanceAfter = 15_000) val result = queryService.findTransaction(account.club.id, accountId, 100L, userId) result.transactionId shouldBe 100L result.amount shouldBe 5_000 + result.balanceAfter shouldBe 15_000 } it("단건 상세에 영수증 파일 응답을 포함한다") { 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 index 7d1c8629..b7b70d9c 100644 --- 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 @@ -11,13 +11,17 @@ 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.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberCardinalTestFixture 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.collections.shouldBeEmpty import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.mockk.clearMocks @@ -31,12 +35,15 @@ class GetMyAccountQueryServiceTest : val accountRepository = mockk() val paymentTargetRepository = mockk() val clubMemberPolicy = mockk() + val clubMemberCardinalReader = mockk() val queryService = GetMyAccountQueryService( accountRepository = accountRepository, paymentTargetRepository = paymentTargetRepository, clubMemberPolicy = clubMemberPolicy, - memberAccountAccessResolver = MemberAccountAccessResolver(accountRepository, clubMemberPolicy), + clubMemberCardinalReader = clubMemberCardinalReader, + memberAccountAccessResolver = + MemberAccountAccessResolver(accountRepository, clubMemberPolicy, clubMemberCardinalReader), myAccountMapper = MyAccountMapper(), ) @@ -86,9 +93,18 @@ class GetMyAccountQueryServiceTest : } } + fun participatedIn(vararg cardinalNumbers: Int) = + cardinalNumbers.map { + ClubMemberCardinalTestFixture.create( + clubMember = member, + cardinal = CardinalTestFixture.createCardinal(cardinalNumber = it), + ) + } + beforeTest { - clearMocks(accountRepository, paymentTargetRepository, clubMemberPolicy) + clearMocks(accountRepository, paymentTargetRepository, clubMemberPolicy, clubMemberCardinalReader) every { clubMemberPolicy.getActiveMember(club.id, userId) } returns member + every { clubMemberCardinalReader.findAllByClubMember(member) } returns participatedIn(6, 7) } describe("findCardinals") { @@ -107,6 +123,33 @@ class GetMyAccountQueryServiceTest : result[1].isLatest shouldBe false verify(exactly = 1) { clubMemberPolicy.getActiveMember(club.id, userId) } } + + it("내가 참여하지 않은 기수의 장부는 목록에서 제외한다") { + every { clubMemberCardinalReader.findAllByClubMember(member) } returns participatedIn(7) + 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) + result[0].isLatest shouldBe true + } + + it("참여한 기수가 없으면 빈 목록을 반환한다") { + every { clubMemberCardinalReader.findAllByClubMember(member) } returns emptyList() + every { + accountRepository.findAllByClubIdAndStatusAndMemberVisibleTrueOrderByCardinalDesc( + club.id, + AccountStatus.ACTIVE, + ) + } returns listOf(account(id = 7L, cardinal = 7), account(id = 6L, cardinal = 6)) + + queryService.findCardinals(club.id, userId).shouldBeEmpty() + } } describe("findMyAccount") { @@ -189,5 +232,20 @@ class GetMyAccountQueryServiceTest : queryService.findMyAccount(club.id, cardinal = 7, userId = userId) } } + + it("내가 참여하지 않은 기수의 장부는 AccountNotFound로 은닉한다") { + every { clubMemberCardinalReader.findAllByClubMember(member) } returns participatedIn(6) + every { + accountRepository.findByClubIdAndCardinalAndStatusAndMemberVisibleTrue( + club.id, + 7, + AccountStatus.ACTIVE, + ) + } returns account(id = 12L, cardinal = 7) + + 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 index 2da3f49c..616c5818 100644 --- 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 @@ -13,8 +13,11 @@ 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.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader import com.weeth.domain.club.domain.service.ClubMemberPolicy +import com.weeth.domain.club.fixture.ClubMemberCardinalTestFixture import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.club.fixture.ClubTestFixture import com.weeth.domain.file.application.dto.response.FileResponse @@ -44,12 +47,14 @@ class GetMyAccountTransactionQueryServiceTest : val accountRepository = mockk() val transactionRepository = mockk() val clubMemberPolicy = mockk() + val clubMemberCardinalReader = mockk() val fileReader = mockk() val fileMapper = mockk() val queryService = GetMyAccountTransactionQueryService( transactionRepository = transactionRepository, - memberAccountAccessResolver = MemberAccountAccessResolver(accountRepository, clubMemberPolicy), + memberAccountAccessResolver = + MemberAccountAccessResolver(accountRepository, clubMemberPolicy, clubMemberCardinalReader), fileReader = fileReader, fileMapper = fileMapper, memberTransactionMapper = MemberTransactionMapper(), @@ -130,8 +135,22 @@ class GetMyAccountTransactionQueryServiceTest : ) beforeTest { - clearMocks(accountRepository, transactionRepository, clubMemberPolicy, fileReader, fileMapper) + clearMocks( + accountRepository, + transactionRepository, + clubMemberPolicy, + clubMemberCardinalReader, + fileReader, + fileMapper, + ) every { clubMemberPolicy.getActiveMember(club.id, userId) } returns member + every { clubMemberCardinalReader.findAllByClubMember(member) } returns + listOf( + ClubMemberCardinalTestFixture.create( + clubMember = member, + cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 7), + ), + ) every { accountRepository.findByClubIdAndCardinalAndStatusAndMemberVisibleTrue( club.id, diff --git a/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt b/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt index b037c373..47561494 100644 --- a/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt @@ -66,6 +66,8 @@ class AccountTest : account.currentBalance shouldBe 23 transaction.isApplied shouldBe true + // 적용 시점의 총잔액이 거래에 스냅샷으로 기록된다. + transaction.balanceAfter shouldBe 23 } "applyTransaction은 같은 거래를 중복 반영할 수 없다" { diff --git a/src/test/kotlin/com/weeth/domain/account/domain/repository/AccountRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/account/domain/repository/AccountRepositoryTest.kt index c549cc29..d7e4690c 100644 --- a/src/test/kotlin/com/weeth/domain/account/domain/repository/AccountRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/domain/repository/AccountRepositoryTest.kt @@ -265,6 +265,37 @@ class AccountRepositoryTest( types = listOf(AccountTransactionType.DUES), ) shouldBe 1 } + + it("적용된 거래에 그 시점의 총잔액(balanceAfter)이 저장된다") { + val club = clubRepository.save(ClubTestFixture.createClub(code = "ACCOUNT-REPO-BALANCE-AFTER")) + val account = accountRepository.save(Account.createDraft(club = club, cardinal = 9)) + + fun applyAndSave( + type: AccountTransactionType, + amount: Int, + at: LocalDateTime, + ): AccountTransaction { + val tx = + AccountTransaction.create( + account = account, + type = type, + title = "거래", + source = null, + amount = Money.of(amount), + transactedAt = at, + ) + account.applyTransaction(tx) + return accountTransactionRepository.save(tx) + } + + val tx1 = applyAndSave(AccountTransactionType.INCOME, 100_000, LocalDateTime.of(2026, 3, 1, 10, 0)) + val tx2 = applyAndSave(AccountTransactionType.EXPENSE, 30_000, LocalDateTime.of(2026, 3, 2, 10, 0)) + accountRepository.save(account) + + accountTransactionRepository.findByIdAndDeletedAtIsNull(tx1.id)?.balanceAfter shouldBe 100_000 + accountTransactionRepository.findByIdAndDeletedAtIsNull(tx2.id)?.balanceAfter shouldBe 70_000 + account.currentBalance shouldBe 70_000 + } } describe("AccountPaymentTargetRepository") {