Skip to content

[WTH-404] 회비 온보딩 API 구현#80

Merged
hyxklee merged 26 commits into
devfrom
feat/WTH-404-회비-기능-API-구현
Jun 13, 2026

Hidden character warning

The head ref may contain hidden characters: "feat/WTH-404-\ud68c\ube44-\uae30\ub2a5-API-\uad6c\ud604"
Merged

[WTH-404] 회비 온보딩 API 구현#80
hyxklee merged 26 commits into
devfrom
feat/WTH-404-회비-기능-API-구현

Conversation

@hyxklee

@hyxklee hyxklee commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

📌 Summary

어떤 작업인지 한 줄 요약해 주세요.

총 회비 등록을 위한 온보딩 API를 구현했습니다.
그 외 기능은 다음 PR에서 구현할 계획입니다.

📝 Changes

변경사항을 what, why, how로 구분해 작성해 주세요.

What

회비 온보딩 플로우 구현

Why

회비 기능 확장에 따라 변경된 플로우에 대응할 수 있게 하기 위해

How

  • admin용 컨트롤러 분리, 하단 API 구현
image
  • 이어서 작성 및 초안 삭제, 수정 등 모든 가능한 시나리오에 대응할 수 있도록 구현
  • API 및 DTO 설명 상세 작성
  • 단위 테스트 및 시나리오에 따른 통합 테스트 구현, 실제 시나리오에 따른 API 동작 여부 검증 완료
  • 초안 작성 -> 탈퇴/퇴출자 발생시 엣지 케이스 대응

📸 Screenshots / Logs

필요시 스크린샷 or 로그를 첨부해주세요.

💡 Reviewer 참고사항

리뷰에 참고할 내용을 작성해주세요.

  • 스웨거 명세를 정말 상세하게 작성해뒀습니다. API 연동 전에 한 번 읽어보시고 진행해주시면 감사하겠습니다. (좀 복잡할 수 있으니 모르겠는 부분이 있으면 물어봐주세요)
  • 확장함수 사용해서 Util 성 메서드 분리 해봤습니다.
  • API 개수가 많았고, 테스트 작성이 많아서 FC가 많아졌습니다.. 핵심적인 내용을 중점적으로 봐주시면 감사하겠습니다.

✅ Checklist

  • PR 제목 설정 완료 (WTH-123 인증 필터 설정)
  • 테스트 구현 완료
  • 리뷰어 등록 완료
  • 자체 코드 리뷰 완료

Summary by CodeRabbit

릴리스 노트

  • New Features

    • 회비 장부 등록 프로세스를 단계별로 관리할 수 있습니다(기본 정보, 납부 대상, 이월 설정, 계좌 정보 등록).
    • 납부 대상을 선택 및 제외 대상으로 관리하고 페이징 조회가 가능합니다.
    • 회원 공개 여부를 설정할 수 있습니다.
    • 이전 기수의 이월 정보 및 등록 상태를 조회할 수 있습니다.
  • Bug Fixes

    • 회비 장부 설명 필드가 선택사항으로 변경되었습니다.

@hyxklee hyxklee requested review from JIN921 and soo0711 June 11, 2026 04:44
@hyxklee hyxklee self-assigned this Jun 11, 2026
@hyxklee hyxklee added ✨ Feature 새로운 기능 추가 📬 API 서버 API 통신 labels Jun 11, 2026
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@hyxklee, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 55 minutes and 45 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f3b77b83-f273-423d-a328-865f367af3a6

📥 Commits

Reviewing files that changed from the base of the PR and between 76979be and 2f82d16.

📒 Files selected for processing (1)
  • src/main/resources/db/migration/V3__fix_membership_fee_account_registration_columns.sql
📝 Walkthrough

Walkthrough

회비 장부의 단계별 등록 프로세스를 완벽하게 구현한 대규모 PR입니다. 초안 생성부터 기본정보·납부대상·이월·계좌 정보 저장, 최종 완료까지 전체 워크플로우를 커버하며, DTO 계약부터 도메인 모델, 저장소, 비즈니스 로직, API 엔드포인트, 포괄적인 테스트를 포함합니다.

Changes

회비 등록 플로우 종합 구현

Layer / File(s) Summary
데이터 계약 & 에러 처리
src/main/kotlin/com/weeth/domain/account/application/dto/request/*, src/main/kotlin/com/weeth/domain/account/application/dto/response/*, src/main/kotlin/com/weeth/domain/account/application/exception/*, src/main/kotlin/com/weeth/global/common/response/PageResponse.kt
요청 DTO (SaveAccountBasicRequest, SavePaymentTargetsRequest, SaveAccountCarryOverRequest, SaveAccountBankAccountRequest, UpdateMemberVisibilityRequest), 응답 DTO (AccountRegistrationStatusResponse, AccountPaymentTargetResponse, AccountPaymentTargetsResponse 등), 5개 신규 에러 코드, 5개 신규 예외 클래스, 8개 응답 코드, 제네릭 PageResponse를 정의하여 회비 등록 API 계약 전체를 구성합니다.
도메인 모델 확장
src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt, src/main/kotlin/com/weeth/domain/account/domain/entity/AccountTransaction.kt, src/main/kotlin/com/weeth/domain/account/domain/enums/AccountRegistrationStep.kt, src/main/kotlin/com/weeth/domain/account/domain/vo/BankAccount.kt
Accountdescription nullable 전환, carryOverEnabled/lastModifiedBy/registrationStep 필드 추가, markModifiedBy()/advanceRegistrationStep() 메서드 추가. AccountTransactiontype/direction/transactedAtvar로 변경하고 update() 메서드 추가. AccountRegistrationStepsequence 기반 비교 메서드 도입. 컬럼 길이 제약 조정.
데이터 접근 계층
src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt, src/main/kotlin/com/weeth/domain/account/domain/repository/AccountPaymentTargetRepository.kt, src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt, src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt
멤버 공개/기수 필터링 조회, 페이징 기반 납부 대상 조회, 활성 멤버/제외 후보 조회, 키워드 검색, 비활성 멤버 미납 대상 조회, 집계 메서드 등 총 20개 이상의 신규 쿼리 메서드를 추가합니다.
DTO 매핑
src/main/kotlin/com/weeth/domain/account/application/mapper/AccountPaymentTargetMapper.kt, src/main/kotlin/com/weeth/domain/account/application/mapper/AccountRegistrationMapper.kt
엔티티를 응답 DTO로 변환하는 매퍼 구현. AccountPaymentTargetMapper는 프로필 이미지 URL 해석 포함. AccountRegistrationMapper는 등록 단계별 조건부 필드 포함을 처리합니다.
접근 제어 & 유틸
src/main/kotlin/com/weeth/domain/account/application/usecase/AccountClubAccess.kt
Account.validateOwnedBy(clubId) 확장 함수로 클럽 소유 여부를 검증하여 cross-club 접근을 방지합니다.
계정 관리 커맨드
src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt
회원 노출 상태(showToMembers/hideFromMembers) 변경 및 수정자 추적을 구현합니다.
회비 등록 커맨드
src/main/kotlin/com/weeth/domain/account/application/usecase/command/RegisterAccountUseCase.kt
초안 생성·폐기(삭제 시 납부 대상 함께 삭제), 기본정보·납부대상·이월·계좌 저장, 등록 완료(이월 금액 불일치 검증, 비활성 멤버 자동 제외, 이전 계정 거래 정리)의 전체 등록 플로우를 구현합니다.
등록 상태 조회 서비스
src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountRegistrationQueryService.kt
이월 재원 조회(hasPreviousAccount, 이전 기수 번호/잔액) 및 등록 상태 조회(단계별 필드 포함, 납부 대상/제외 대상 카운트)를 권한/소유권 검증하에 제공합니다.
납부 대상 조회 서비스
src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountPaymentTargetQueryService.kt
특정 계정의 납부 대상을 targetStatus별로 필터링하여 페이징·키워드 검색으로 조회하며, 전체/타깃/제외 카운트를 요약으로 반환합니다.
REST API 엔드포인트
src/main/kotlin/com/weeth/domain/account/presentation/AccountManageController.kt, src/main/kotlin/com/weeth/domain/account/presentation/AccountRegisterController.kt
계정 관리(회원 가시성 PATCH), 회비 등록 전 단계(초안 생성/폐기, 기본정보·납부대상·이월·계좌 저장, 등록 완료 조회)의 총 12개 엔드포인트를 제공합니다.
단위 및 통합 테스트
src/test/kotlin/com/weeth/domain/account/*, src/test/kotlin/com/weeth/domain/account/application/usecase/command/MembershipFeeRegistrationIntegrationTest.kt
엔티티, 저장소, 유스케이스 메서드별 단위 테스트와 S1~S11 통합 시나리오(이전 장부 유무, 초안 재작성, 납부 대상 델타 갱신, 예외 처리, 비활성 멤버 제외, 이월 금액 검증, 권한 경계 등)로 전체 플로우 정합성을 검증합니다.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Possibly related PRs

  • Team-Weeth/weeth-server#78: 회비 장부의 핵심 도메인 모델(Account 상태 필드, AccountPaymentTarget, AccountTransaction)을 확장하는 PR로서, 본 PR의 새로운 DTO/유스케이스/API가 이를 기반으로 구축됩니다.

Suggested labels

🔨 Refactor


Suggested reviewers

  • soo0711

Poem

🐰 초안부터 완료까지, 회비의 여정,
담기는 정보들, 단계 하나하나,
납부 대상 꼼꼼히, 이월도 예쁘게,
검증과 거래로 일관성 지키며,
회비 등록 플로우, 완성되었네! 🎉

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/WTH-404-회비-기능-API-구현

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountPaymentTargetQueryServiceTest.kt (1)

56-191: ⚡ Quick win

EXCLUDED 필터 분기에 대한 테스트 케이스 누락

서비스의 targetStatus == AccountTargetStatus.EXCLUDED 분기(서비스 Line 66-75)가 findExcludedPaymentTargetCandidatesByCardinal을 호출하는데, 이 경로에 대한 테스트가 없습니다. TARGETED와 null(전체) 케이스는 검증되지만, EXCLUDED 필터 시나리오도 커버하면 좋겠습니다.

💡 EXCLUDED 필터 테스트 케이스 예시
it("제외됨 필터에서는 EXCLUDED 후보만 페이지로 조회한다") {
    val clubId = 1L
    val accountId = 10L
    val userId = 100L
    val club = ClubTestFixture.createClub(id = clubId)
    val account = AccountTestFixture.createAccount(id = accountId, club = club)
    val excludedMember = ClubMemberTestFixture.createActiveMember(id = 21L, club = club)
    val pageable = PageRequest.of(0, 10)

    every { accountRepository.findById(accountId) } returns Optional.of(account)
    every { clubMemberReader.countActiveByClubIdAndCardinalNumber(clubId, 40) } returns 18L
    every {
        paymentTargetRepository.countActiveClubMemberTargetsByAccountIdAndTargetStatus(
            accountId,
            AccountTargetStatus.TARGETED,
        )
    } returns 12L
    every {
        clubMemberReader.findExcludedPaymentTargetCandidatesByCardinal(
            clubId = clubId,
            cardinalNumber = 40,
            accountId = accountId,
            keyword = null,
            pageable = pageable,
        )
    } returns PageImpl(listOf(excludedMember), pageable, 6)
    every {
        paymentTargetRepository.findAllByAccountIdAndClubMemberIdIn(accountId, listOf(21L))
    } returns emptyList()

    val result = service.findTargets(
        clubId = clubId,
        accountId = accountId,
        userId = userId,
        page = 0,
        size = 10,
        keyword = null,
        targetStatus = AccountTargetStatus.EXCLUDED,
    )

    result.targets.content.first().targetStatus shouldBe AccountTargetStatus.EXCLUDED
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountPaymentTargetQueryServiceTest.kt`
around lines 56 - 191, The EXCLUDED branch of service.findTargets (lines
handling targetStatus == AccountTargetStatus.EXCLUDED) is not covered by tests;
add a new test that mocks accountRepository.findById(accountId) to return the
account, clubMemberReader.countActiveByClubIdAndCardinalNumber(...) to return
the total,
paymentTargetRepository.countActiveClubMemberTargetsByAccountIdAndTargetStatus(accountId,
AccountTargetStatus.TARGETED) to return targetedCount, and
clubMemberReader.findExcludedPaymentTargetCandidatesByCardinal(...) to return a
PageImpl with an excluded member; also mock
paymentTargetRepository.findAllByAccountIdAndClubMemberIdIn(accountId,
listOf(excludedId)) to return emptyList(); invoke service.findTargets with
targetStatus = AccountTargetStatus.EXCLUDED and assert the returned
targets.content.first().targetStatus is AccountTargetStatus.EXCLUDED and the
page/summary counts match expectations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/kotlin/com/weeth/domain/account/application/usecase/command/RegisterAccountUseCase.kt`:
- Around line 159-257: The UseCase completeRegistration currently contains
business rules (registration-step validation, carry-over mismatch check,
activating the account, excluding inactive member payment targets,
creating/applying carry-over transaction, and settling the previous account) —
move these rules into the domain layer: add domain methods on Account or a
domain service such as Account.completeRegistration(userId, previousAccount,
now) or finer-grained methods like
Account.ensureRegistrationStepAtLeast(AccountRegistrationStep),
Account.verifyCarryOverMatches(previousAccount),
Account.activateAndApplyCarryOver(carryOverAmount, carryOverMemo, now), and
Account.settlePreviousBalance(previousAccount, now); update
findPreviousAccountWithLock to remain in the UseCase for locking/orchestration,
keep permission checks and repository calls (getAccountWithLock,
findPreviousAccountWithLock, transactionRepository.save,
paymentTargetRepository.findAllUnpaidTargetsWithInactiveClubMemberByAccountId)
in RegisterAccountUseCase, and replace inline logic with calls to the new domain
methods, persisting returned/modified entities as needed (e.g., via
transactionRepository.save and accountRepository.save or letting ORM flush).

In `@src/main/kotlin/com/weeth/domain/account/domain/vo/BankAccount.kt`:
- Around line 25-31: Entity column lengths in BankAccount (properties holder and
guide) don't match the migration V1__add_membership_fee_account_models.sql
(entity uses length=30 while migration sets VARCHAR(50) and VARCHAR(200])—align
them and add input validation: update `@Column`(length=...) on BankAccount.holder
and BankAccount.guide to match the migration (50 and 200 respectively) and/or
adjust the migration to match desired lengths, and update normalizeRequired and
normalizeOptional to enforce the same max lengths (trim + validate length, throw
or return a clear validation error) so runtime/data layer and Hibernate ddl-auto
validate/update won't fail. Ensure the change touches the BankAccount class,
normalizeRequired/normalizeOptional implementations, and coordinate with the
migration V1__add_membership_fee_account_models.sql or yml ddl-auto settings so
schema and entity mapping remain consistent.

---

Nitpick comments:
In
`@src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountPaymentTargetQueryServiceTest.kt`:
- Around line 56-191: The EXCLUDED branch of service.findTargets (lines handling
targetStatus == AccountTargetStatus.EXCLUDED) is not covered by tests; add a new
test that mocks accountRepository.findById(accountId) to return the account,
clubMemberReader.countActiveByClubIdAndCardinalNumber(...) to return the total,
paymentTargetRepository.countActiveClubMemberTargetsByAccountIdAndTargetStatus(accountId,
AccountTargetStatus.TARGETED) to return targetedCount, and
clubMemberReader.findExcludedPaymentTargetCandidatesByCardinal(...) to return a
PageImpl with an excluded member; also mock
paymentTargetRepository.findAllByAccountIdAndClubMemberIdIn(accountId,
listOf(excludedId)) to return emptyList(); invoke service.findTargets with
targetStatus = AccountTargetStatus.EXCLUDED and assert the returned
targets.content.first().targetStatus is AccountTargetStatus.EXCLUDED and the
page/summary counts match expectations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 99154c61-d888-4127-8128-f2d1e63cd96d

📥 Commits

Reviewing files that changed from the base of the PR and between db4eff3 and 76979be.

📒 Files selected for processing (46)
  • src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountBankAccountRequest.kt
  • src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountBasicRequest.kt
  • src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountCarryOverRequest.kt
  • src/main/kotlin/com/weeth/domain/account/application/dto/request/SavePaymentTargetsRequest.kt
  • src/main/kotlin/com/weeth/domain/account/application/dto/request/UpdateMemberVisibilityRequest.kt
  • src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountCarryOverSourceResponse.kt
  • src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountPaymentTargetResponse.kt
  • src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountPaymentTargetsResponse.kt
  • src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountRegistrationStatusResponse.kt
  • src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountResponse.kt
  • src/main/kotlin/com/weeth/domain/account/application/dto/response/BankAccountResponse.kt
  • src/main/kotlin/com/weeth/domain/account/application/dto/response/CreateAccountDraftResponse.kt
  • src/main/kotlin/com/weeth/domain/account/application/exception/AccountCarryOverAmountMismatchException.kt
  • src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt
  • src/main/kotlin/com/weeth/domain/account/application/exception/AccountInvalidDraftStateException.kt
  • src/main/kotlin/com/weeth/domain/account/application/exception/AccountPaymentTargetMemberInvalidException.kt
  • src/main/kotlin/com/weeth/domain/account/application/exception/AccountPaymentTargetPaidException.kt
  • src/main/kotlin/com/weeth/domain/account/application/exception/AccountRegistrationStepIncompleteException.kt
  • src/main/kotlin/com/weeth/domain/account/application/mapper/AccountPaymentTargetMapper.kt
  • src/main/kotlin/com/weeth/domain/account/application/mapper/AccountRegistrationMapper.kt
  • src/main/kotlin/com/weeth/domain/account/application/usecase/AccountClubAccess.kt
  • src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt
  • src/main/kotlin/com/weeth/domain/account/application/usecase/command/RegisterAccountUseCase.kt
  • src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountPaymentTargetQueryService.kt
  • src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountRegistrationQueryService.kt
  • src/main/kotlin/com/weeth/domain/account/domain/entity/Account.kt
  • src/main/kotlin/com/weeth/domain/account/domain/entity/AccountTransaction.kt
  • src/main/kotlin/com/weeth/domain/account/domain/enums/AccountRegistrationStep.kt
  • src/main/kotlin/com/weeth/domain/account/domain/repository/AccountPaymentTargetRepository.kt
  • src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt
  • src/main/kotlin/com/weeth/domain/account/domain/vo/BankAccount.kt
  • src/main/kotlin/com/weeth/domain/account/presentation/AccountManageController.kt
  • src/main/kotlin/com/weeth/domain/account/presentation/AccountRegisterController.kt
  • src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt
  • src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt
  • src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt
  • src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt
  • src/main/kotlin/com/weeth/global/common/response/PageResponse.kt
  • src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt
  • src/test/kotlin/com/weeth/domain/account/application/usecase/command/MembershipFeeRegistrationIntegrationTest.kt
  • src/test/kotlin/com/weeth/domain/account/application/usecase/command/RegisterAccountUseCaseTest.kt
  • src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountPaymentTargetQueryServiceTest.kt
  • src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountRegistrationQueryServiceTest.kt
  • src/test/kotlin/com/weeth/domain/account/domain/entity/AccountTest.kt
  • src/test/kotlin/com/weeth/domain/account/domain/repository/AccountRepositoryTest.kt
  • src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt

Comment on lines +159 to +257
@Transactional
fun completeRegistration(
clubId: Long,
accountId: Long,
userId: Long,
) {
clubPermissionPolicy.requireAdmin(clubId, userId)
val account = getAccountWithLock(clubId, accountId)
if (account.status != AccountStatus.DRAFT) throw AccountInvalidDraftStateException()
// 이월/계좌 단계를 건너뛴 채 완료하면 이월 결정 없이 이전 장부가 마감되는 부수효과가 생기므로 모든 단계 저장을 강제한다.
if (!account.registrationStep.isAtLeast(AccountRegistrationStep.REVIEW)) {
throw AccountRegistrationStepIncompleteException()
}

// 이월 재원 조회와 완료 사이에 이전 장부 잔액이 변했을 수 있으므로 잠금 조회 후 이월 금액과 대조한다.
val previousAccount = findPreviousAccountWithLock(clubId, account)
if (account.carryOverAmount > 0 &&
previousAccount != null &&
previousAccount.currentBalance != account.carryOverAmount
) {
throw AccountCarryOverAmountMismatchException()
}

account.activate()

// 초안 작성 중 탈퇴/퇴출된 멤버의 미납 대상 행은 조회 화면에서 보이지 않아 갱신할 방법이 없으므로
// 활성 장부로 넘기지 않고 여기서 제외 처리한다. 활성화 이후의 탈퇴는 어드민 수동 환불 정책에 따라 행을 유지한다.
paymentTargetRepository
.findAllUnpaidTargetsWithInactiveClubMemberByAccountId(accountId)
.forEach { it.exclude() }

if (account.carryOverAmount > 0) {
val transaction =
AccountTransaction.create(
account = account,
type = AccountTransactionType.CARRY_OVER,
title = "이월 금액",
source = null,
amount = Money.of(account.carryOverAmount),
transactedAt = LocalDateTime.now(),
memo = account.carryOverMemo,
)
transactionRepository.save(transaction)
account.applyTransaction(transaction)
}

// 이월 여부와 무관하게 이전 기수 장부에 남은 잔액을 지출로 자동 정리해 장부를 마감한다.
settlePreviousAccountBalance(previousAccount, account)

account.markModifiedBy(userId)
}

/** 직전 활성 기수 장부를 잠금 조회한다. 잔액 검증과 마감에 같은 잠금 인스턴스를 재사용한다. */
private fun findPreviousAccountWithLock(
clubId: Long,
account: Account,
): Account? {
val previousAccount =
accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc(
clubId = clubId,
cardinal = account.cardinal,
status = AccountStatus.ACTIVE,
) ?: return null
return accountRepository.findByIdWithLock(previousAccount.id)
}

/**
* 등록 완료 시 직전 활성 기수 장부에 남은 잔액을 지출 거래로 정리해 0원으로 만든다.
* 실제 이월된 금액이 있으면 신규 장부로의 전출, 없으면 미이월 잔액 정리 명목으로 기록해
* 같은 돈이 두 장부에 중복 집계되지 않도록 한다.
* (CARRY_OVER 수입 거래와 같은 조건이므로 전출 기록이 있을 때만 대응하는 이월 수입이 존재한다)
*/
private fun settlePreviousAccountBalance(
previousAccount: Account?,
account: Account,
) {
if (previousAccount == null || previousAccount.currentBalance <= 0) return

val (title, memo) =
if (account.carryOverAmount > 0) {
"이월 잔액 전출" to "${account.cardinal}기 회비로 이월되어 자동 지출 처리되었습니다."
} else {
"미이월 잔액 정리" to "${account.cardinal}기 회비 등록 시 이월하지 않기를 선택하여 자동 지출 처리되었습니다."
}

val expense =
AccountTransaction.create(
account = previousAccount,
type = AccountTransactionType.EXPENSE,
title = title,
source = null,
amount = Money.of(previousAccount.currentBalance),
transactedAt = LocalDateTime.now(),
memo = memo,
)

transactionRepository.save(expense)
previousAccount.applyTransaction(expense)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

UseCase에 도메인 규칙이 과도하게 집약되어 계층 경계가 흐려집니다.

completeRegistration/savePaymentTargets에서 단계 전이, 납부 대상 상태 전이, 이전 기수 마감 정책까지 직접 판단하고 있습니다. 이 규칙은 Account 엔티티/도메인 서비스로 이동시키고, UseCase는 권한 확인·락 조회·영속화 오케스트레이션 중심으로 축소하는 것이 좋습니다.
As per coding guidelines, **/application/usecase/**/*.kt: "UseCase classes contain orchestration only — business logic must live in Entities (Rich Domain Model)" 및 src/main/**/application/**/*UseCase.kt: "UseCase classes orchestrate only; business rules belong in entities or domain services".

Also applies to: 267-322

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/kotlin/com/weeth/domain/account/application/usecase/command/RegisterAccountUseCase.kt`
around lines 159 - 257, The UseCase completeRegistration currently contains
business rules (registration-step validation, carry-over mismatch check,
activating the account, excluding inactive member payment targets,
creating/applying carry-over transaction, and settling the previous account) —
move these rules into the domain layer: add domain methods on Account or a
domain service such as Account.completeRegistration(userId, previousAccount,
now) or finer-grained methods like
Account.ensureRegistrationStepAtLeast(AccountRegistrationStep),
Account.verifyCarryOverMatches(previousAccount),
Account.activateAndApplyCarryOver(carryOverAmount, carryOverMemo, now), and
Account.settlePreviousBalance(previousAccount, now); update
findPreviousAccountWithLock to remain in the UseCase for locking/orchestration,
keep permission checks and repository calls (getAccountWithLock,
findPreviousAccountWithLock, transactionRepository.save,
paymentTargetRepository.findAllUnpaidTargetsWithInactiveClubMemberByAccountId)
in RegisterAccountUseCase, and replace inline logic with calls to the new domain
methods, persisting returned/modified entities as needed (e.g., via
transactionRepository.save and accountRepository.save or letting ORM flush).

Source: Coding guidelines

Comment on lines +25 to 31
@Column(name = "account_holder", length = 30)
var holder: String = normalizeRequired(holder, "예금주")
private set

@Column(name = "bank_guide", length = 200)
@Column(name = "bank_guide", length = 30)
var guide: String? = normalizeOptional(guide)
private set

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 기존 BankAccount 길이 제약 확인 및 마이그레이션 스크립트 존재 여부 확인

# Flyway/Liquibase 마이그레이션 파일에서 bank_account 관련 ALTER 검색
fd -e sql -e xml -e yaml . | xargs rg -l -i 'account_holder|bank_guide' 2>/dev/null || echo "No migration files found"

# 기존 컬럼 정의 확인
rg -n 'account_holder|bank_guide' --type=kotlin

Repository: Team-Weeth/weeth-server

Length of output: 375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) DB 마이그레이션에서 관련 컬럼/테이블 변경 여부 확인
rg -n -i 'account_holder|bank_guide|bank_account' src/main/resources/db/migration || true

# 2) BankAccount.kt 정규화 함수 호출부/정의 확인(파일 전체는 아니고 필요한 범위)
FILE="src/main/kotlin/com/weeth/domain/account/domain/vo/BankAccount.kt"
if [ -f "$FILE" ]; then
  echo "---- $FILE (relevant lines) ----"
  sed -n '1,120p' "$FILE" | nl -ba | sed -n '1,120p' || true
fi

# 3) normalizeRequired/normalizeOptional이 어디 정의돼 있는지 역추적
rg -n 'fun normalizeRequired|fun normalizeOptional|normalizeRequired\(|normalizeOptional\(' src/main/kotlin || true

Repository: Team-Weeth/weeth-server

Length of output: 3358


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) DB 마이그레이션에서 관련 컬럼/테이블 변경 여부 확인
rg -n -i 'account_holder|bank_guide|bank_account' src/main/resources/db/migration || true

# 2) BankAccount.kt 정규화 함수 호출부/정의 확인(파일 전체는 아니고 필요한 범위)
FILE="src/main/kotlin/com/weeth/domain/account/domain/vo/BankAccount.kt"
if [ -f "$FILE" ]; then
  echo "---- $FILE (relevant lines) ----"
  nl -ba "$FILE" | sed -n '1,160p' || true
fi

# 3) normalizeRequired/normalizeOptional이 어디 정의돼 있는지 역추적
rg -n 'fun normalizeRequired|fun normalizeOptional|normalizeRequired\(|normalizeOptional\(' src/main/kotlin || true

Repository: Team-Weeth/weeth-server

Length of output: 3358


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) BankAccount.kt: 컬럼 length 및 normalize 함수 구현 확인(불필요하게 전체 출력하지 않음)
FILE="src/main/kotlin/com/weeth/domain/account/domain/vo/BankAccount.kt"
echo "---- $FILE (column + normalize impl) ----"
sed -n '1,120p' "$FILE" | cat -n | sed -n '1,120p'

# 2) 마이그레이션에서 account_holder/bank_guide 변경(추가/ALTER) 이력 전체 확인
echo "---- migrations: account_holder/bank_guide/account 관련 ----"
rg -n -i 'account_holder|bank_guide|bank_account' src/main/resources/db/migration || true

# 3) 혹시 VARCHAR(30) 등으로 별도 변경이 있는지(오탐 줄이기 위해 컬럼명과 함께)
rg -n -i 'account_holder\s+varchar\(30\)|bank_guide\s+varchar\(30\)|varchar\(30\).*account_holder|varchar\(30\).*bank_guide' src/main/resources/db/migration || true

Repository: Team-Weeth/weeth-server

Length of output: 2781


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n --hidden -S 'ddl-auto|hbm2ddl|hibernate\.hbm2ddl|schema-validation|hibernate\.ddl' src/main/resources . || true

Repository: Team-Weeth/weeth-server

Length of output: 933


@Column(length)(30) vs 마이그레이션(DB VARCHAR(50/200)) 길이 불일치로 스키마 검증/업데이트 영향 점검 필요

BankAccount.kt에서 account_holder/bank_guide 길이를 각각 length = 30으로 매핑했지만, 마이그레이션 V1__add_membership_fee_account_models.sql에서는 account_holder VARCHAR(50), bank_guide VARCHAR(200)로 생성됩니다. application-dev.yml/application-prod.ymlddl-auto: validate라서 Hibernate 스키마 검증 시 컬럼 타입/길이 불일치로 시작 실패가 날 수 있고, application-local.ymlddl-auto: update에서는 컬럼 길이를 30으로 변경하려다 기존 데이터가 있으면 잘림/오류가 발생할 수 있습니다.

또한 normalizeRequired/normalizeOptional은 trim/공백 검증만 하고 길이 제한을 걸지 않으므로(30자 제한 미적용), 길이가 실제로 30으로 내려가는 경우 DB 제약 위반 가능성도 함께 고려해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/kotlin/com/weeth/domain/account/domain/vo/BankAccount.kt` around
lines 25 - 31, Entity column lengths in BankAccount (properties holder and
guide) don't match the migration V1__add_membership_fee_account_models.sql
(entity uses length=30 while migration sets VARCHAR(50) and VARCHAR(200])—align
them and add input validation: update `@Column`(length=...) on BankAccount.holder
and BankAccount.guide to match the migration (50 and 200 respectively) and/or
adjust the migration to match desired lengths, and update normalizeRequired and
normalizeOptional to enforce the same max lengths (trim + validate length, throw
or return a clear validation error) so runtime/data layer and Hibernate ddl-auto
validate/update won't fail. Ensure the change touches the BankAccount class,
normalizeRequired/normalizeOptional implementations, and coordinate with the
migration V1__add_membership_fee_account_models.sql or yml ddl-auto settings so
schema and entity mapping remain consistent.

@soo0711 soo0711 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고하셨습니다!! 👍
스웨거 설명이 정말 자세하게 작성되어 있어서 전체 플로우를 이해하기 편한 것 같아요!!

Comment on lines +75 to +78
@Operation(
summary = "[1단계] 회비 등록 초안 생성",
description =
"회비를 등록하기 전에 초안을 생성합니다. 멱등성 보장을 위해 이미 작성 중인 초안이 있다면 ID를 반환합니다. " +

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

피그마에서는 아래 순서로 정의되어 있는데
[1] 기본 정보
[2] 납부 대상
[3] 이월 설정
[4] 계좌 설정
[5] 계좌 등록

현재 API는 초안 생성이 [1단계]로 포함되어 있어 단계가 하나씩 밀려 보이는 것 같습니다!

초안 생성을 등록 플로우의 한 단계로 보고 의도적으로 분리해두신 걸까용??

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵넵 디자인과 맞춘 단계보다는 개발하는 입장에서 연동할 단계에 집중해서 작성해뒀어용

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아항 넵 감사합니다!

@hyxklee hyxklee merged commit 9cc2364 into dev Jun 13, 2026
2 checks passed
@hyxklee hyxklee deleted the feat/WTH-404-회비-기능-API-구현 branch June 13, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📬 API 서버 API 통신 ✨ Feature 새로운 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants