Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Long>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Comment thread
hyxklee marked this conversation as resolved.
Comment thread
hyxklee marked this conversation as resolved.
@field:Schema(description = "메모", nullable = true)
@field:Size(max = 200)
val memo: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class AccountDashboardMapper(
paidCount = paidCount,
totalTargetCount = totalTargetCount,
),
memberVisible = account.memberVisible,
bankAccountPublic = account.bankAccountVisible,
bankAccount = BankAccountResponse.from(account.bankAccount),
lastModified =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
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.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
Expand Down Expand Up @@ -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()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

targets.forEach { it.exclude() }
account.markModifiedBy(userId)
}

/** 환불(벌크): DUES는 보존하고 REFUND 지출 거래를 생성해 잔액을 차감하며 상태를 REFUNDED로 전이한다. */
@Transactional
fun refund(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,19 +18,26 @@ 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,
) {
fun findCardinals(
clubId: Long,
userId: Long,
): List<AccountCardinalResponse> {
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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ class Account(
currentBalance -= transaction.amount
}
}
// 은행 거래내역처럼 적용 시점의 총잔액을 거래에 스냅샷으로 남긴다.
transaction.recordBalanceAfter(currentBalance)
transaction.markApplied()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) { "삭제된 거래는 되돌릴 수 없습니다." }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Void> {
manageAccountPaymentUseCase.exclude(clubId, accountId, request, userId)
return CommonResponse.success(ACCOUNT_PAYMENT_EXCLUDE_SUCCESS)
}

@PatchMapping("/{accountId}/payment-targets/refund")
@Operation(summary = "환불(벌크)", description = "납부 완료 대상을 환불 처리하고 시스템 환불 지출 거래를 생성합니다. 납부 이력은 보존됩니다.")
fun refundPayment(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "선택한 부원이 납부 대상에서 제외되었습니다."),
}
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

UPDATE account_transaction
SET balance_after = 0
WHERE balance_after IS NULL;

ALTER TABLE account_transaction
MODIFY COLUMN balance_after INT NOT NULL;
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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),
)

Expand Down
Loading