diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountBankAccountRequest.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountBankAccountRequest.kt new file mode 100644 index 00000000..17505202 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountBankAccountRequest.kt @@ -0,0 +1,41 @@ +package com.weeth.domain.account.application.dto.request + +import com.weeth.domain.account.domain.vo.BankAccount +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +data class SaveAccountBankAccountRequest( + @field:Schema(description = "계좌 공개 여부", example = "true") + val bankAccountVisible: Boolean, + @field:Schema(description = "입금 계좌 정보. bankAccountVisible=true 일 때 필수", nullable = true) + @field:Valid + val bankAccount: BankAccountRequest?, +) + +data class BankAccountRequest( + @field:Schema(description = "은행명", example = "국민은행") + @field:NotBlank + @field:Size(max = 30) + val bankName: String, + @field:Schema(description = "계좌번호", example = "123-456-789012") + @field:NotBlank + @field:Size(max = 50) + val accountNumber: String, + @field:Schema(description = "예금주", example = "가천대 검도부") + @field:NotBlank + @field:Size(max = 30) + val holder: String, + @field:Schema(description = "입금 안내 메모", example = "이름_회비 형식으로 입금해 주세요.", nullable = true) + @field:Size(max = 30) + val guide: String? = null, +) { + fun toBankAccount(): BankAccount = + BankAccount.of( + bankName = bankName, + accountNumber = accountNumber, + holder = holder, + guide = guide, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountBasicRequest.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountBasicRequest.kt new file mode 100644 index 00000000..7aaf9f94 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountBasicRequest.kt @@ -0,0 +1,19 @@ +package com.weeth.domain.account.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Positive +import jakarta.validation.constraints.Size + +data class SaveAccountBasicRequest( + @field:Schema(description = "회비 이름", example = "5기 정기 회비") + @field:Size(max = 30) + @field:NotBlank + val name: String, + @field:Schema(description = "1인 회비 금액 (원)", example = "50000") + @field:Positive + val duesAmount: Int, + @field:Schema(description = "회비 설명", example = "동아리 운영비로 사용됩니다.", nullable = true) + @field:Size(max = 30) + val description: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountCarryOverRequest.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountCarryOverRequest.kt new file mode 100644 index 00000000..79241755 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/SaveAccountCarryOverRequest.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.account.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Size + +data class SaveAccountCarryOverRequest( + @field:Schema(description = "이월 활성화 여부", example = "true") + val enabled: Boolean, + @field:Schema(description = "이월 금액 (원). enabled=true 일 때 필수", example = "152129", nullable = true) + val amount: Int?, + @field:Schema(description = "이월 메모. 사용자가 입력하지 않은 경우는 'O기 이월 금액입니다.'를 넣어주세요.", example = "4기 잔액", nullable = true) + @field:Size(max = 30) + val memo: String?, +) 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 new file mode 100644 index 00000000..b874b9f5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/SavePaymentTargetsRequest.kt @@ -0,0 +1,16 @@ +package com.weeth.domain.account.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class SavePaymentTargetsRequest( + @field:Schema( + description = "납부 대상으로 지정할 동아리 회원 ID 목록. 두 목록에 모두 없는 회원의 기존 상태는 유지됩니다.", + example = "[1, 2, 3]", + ) + val targetedClubMemberIds: List = emptyList(), + @field:Schema( + description = "납부 대상에서 제외할 동아리 회원 ID 목록. 두 목록에 모두 없는 회원의 기존 상태는 유지됩니다.", + example = "[4, 5]", + ) + val excludedClubMemberIds: List = emptyList(), +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/request/UpdateMemberVisibilityRequest.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/request/UpdateMemberVisibilityRequest.kt new file mode 100644 index 00000000..36f050e1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/request/UpdateMemberVisibilityRequest.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.account.application.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class UpdateMemberVisibilityRequest( + @field:Schema(description = "부원 거래 내역 공개 여부", example = "true") + val visible: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountCarryOverSourceResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountCarryOverSourceResponse.kt new file mode 100644 index 00000000..cac35b56 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountCarryOverSourceResponse.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.account.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class AccountCarryOverSourceResponse( + @field:Schema( + description = + "이전 기수 활성 장부 존재 여부. true면 잔액 배너를 노출하고 이월 금액으로 balance를 사용해주세요. " + + "false면 '이전 기수 정보가 없습니다' 안내와 함께 금액 직접 입력 UI를 노출해주세요.", + example = "true", + ) + val hasPreviousAccount: Boolean, + @field:Schema(description = "이전 기수. 이전 장부가 없으면 null", example = "3", nullable = true) + val cardinalNumber: Int?, + @field:Schema(description = "이전 기수 장부 잔액 (원). 이전 장부가 없으면 null", example = "240000", nullable = true) + val balance: Int?, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountPaymentTargetResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountPaymentTargetResponse.kt new file mode 100644 index 00000000..322e978b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountPaymentTargetResponse.kt @@ -0,0 +1,50 @@ +package com.weeth.domain.account.application.dto.response + +import com.weeth.domain.account.domain.enums.AccountPaymentStatus +import com.weeth.domain.account.domain.enums.AccountTargetStatus +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class AccountPaymentTargetResponse( + @field:Schema(description = "납부 대상 ID. 아직 선택되지 않은 후보 부원은 null") + val targetId: Long?, + @field:Schema(description = "동아리 부원 프로필") + val paymentTargetInfo: PaymentTargetInfoResponse, + @field:Schema(description = "납부 대상 상태 (TARGETED: 대상, EXCLUDED: 제외)", example = "TARGETED") + val targetStatus: AccountTargetStatus, + @field:Schema(description = "납부 상태 (PAID: 납부 완료, UNPAID: 미납)", example = "UNPAID") + val paymentStatus: AccountPaymentStatus, + @field:Schema(description = "납부 금액 (원)", example = "50000") + val dueAmount: Int, + @field:Schema(description = "실제 납부된 금액 (원)", example = "50000") + val paidAmount: Int, + @field:Schema(description = "납부 일시", nullable = true) + val paidAt: LocalDateTime?, + @field:Schema(description = "납부 확인자 ID", nullable = true) + val confirmedBy: Long?, + @field:Schema(description = "메모", nullable = true) + val memo: String?, +) { + data class PaymentTargetInfoResponse( + @field:Schema(description = "사용자 ID", example = "1") + val userId: Long, + @field:Schema(description = "멤버 ID", example = "1") + val clubMemberId: Long, + @field:Schema(description = "사용자 이름", example = "홍길동") + val name: String, + @field:Schema(description = "전화번호", example = "01012345678") + val tel: String?, + @field:Schema(description = "학교", example = "가천대학교") + val school: String?, + @field:Schema(description = "학과", example = "컴퓨터공학과") + val department: String?, + @field:Schema(description = "멤버 권한", example = "USER") + val memberRole: MemberRole, + @field:Schema(description = "멤버 상태", example = "ACTIVE") + val memberStatus: MemberStatus, + @field:Schema(description = "동아리 프로필 이미지 URL", example = "https://cdn.example.com/profile.jpg") + val profileImageUrl: String?, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountPaymentTargetsResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountPaymentTargetsResponse.kt new file mode 100644 index 00000000..daf39dc1 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountPaymentTargetsResponse.kt @@ -0,0 +1,20 @@ +package com.weeth.domain.account.application.dto.response + +import com.weeth.global.common.response.PageResponse +import io.swagger.v3.oas.annotations.media.Schema + +data class AccountPaymentTargetsResponse( + @field:Schema(description = "납부 대상 요약") + val summary: PaymentTargetSummaryResponse, + @field:Schema(description = "납부 대상 페이지") + val targets: PageResponse, +) { + data class PaymentTargetSummaryResponse( + @field:Schema(description = "전체 활성 부원 수", example = "18") + val totalCount: Int, + @field:Schema(description = "선택된 납부 대상 수", example = "12") + val targetedCount: Int, + @field:Schema(description = "제외된 납부 대상 수", example = "6") + val excludedCount: Int, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountRegistrationStatusResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountRegistrationStatusResponse.kt new file mode 100644 index 00000000..c9d77519 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountRegistrationStatusResponse.kt @@ -0,0 +1,77 @@ +package com.weeth.domain.account.application.dto.response + +import com.weeth.domain.account.domain.enums.AccountRegistrationStep +import io.swagger.v3.oas.annotations.media.Schema + +data class AccountRegistrationStatusResponse( + @field:Schema(description = "회비 장부 ID") + val accountId: Long, + @field:Schema( + description = + "다음에 작성할 단계. 이어서 작성 시 이 단계 화면에서 시작하고, " + + "스텝퍼에서 이 단계 이전은 완료(✓) 표시해주세요. " + + "(BASIC → PAYMENT_TARGETS → CARRY_OVER → BANK_ACCOUNT → REVIEW 순)", + example = "CARRY_OVER", + ) + val registrationStep: AccountRegistrationStep, + @field:Schema(description = "기본 정보 (BASIC 단계 저장 후 non-null). 값으로 폼을 채우고, null이면 빈 폼으로 시작해주세요.", nullable = true) + val basic: BasicInfoResponse?, + @field:Schema( + description = "이월 설정 (CARRY_OVER 단계 저장 후 non-null). 값으로 폼을 채우고, null이면 빈 폼으로 시작해주세요.", + nullable = true, + ) + val carryOver: CarryOverResponse?, + @field:Schema( + description = + "납부 대상 설정 요약 (PAYMENT_TARGETS 단계 저장 후 non-null). " + + "대상 목록과 체크 상태는 납부 대상 목록 조회 API로 별도 조회해주세요.", + nullable = true, + ) + val paymentTargets: PaymentTargetsResponse?, + @field:Schema( + description = "계좌 설정 (BANK_ACCOUNT 단계 저장 후 non-null). 값으로 폼을 채우고, null이면 빈 폼으로 시작해주세요.", + nullable = true, + ) + val bankAccount: BankAccountRegistrationResponse?, + @field:Schema(description = "이전 기수 장부 잔액. 이월 설정 단계의 안내 문구에 사용해주세요. 이전 장부가 없으면 null", nullable = true) + val previousAccountBalance: PreviousAccountBalanceResponse?, +) { + data class BasicInfoResponse( + @field:Schema(description = "회비 장부 이름", example = "5기 정기 회비") + val name: String, + @field:Schema(description = "1인 회비 금액 (원)", example = "50000") + val duesAmount: Int, + @field:Schema(description = "회비 설명", example = "동아리 운영비로 사용됩니다.") + val description: String?, + ) + + data class CarryOverResponse( + @field:Schema(description = "이월 활성화 여부", example = "true") + val enabled: Boolean, + @field:Schema(description = "이월 금액 (원). enabled=false 이면 0", example = "240000") + val amount: Int, + @field:Schema(description = "이월 메모", example = "3기 잔액", nullable = true) + val memo: String?, + ) + + data class PaymentTargetsResponse( + @field:Schema(description = "납부 대상 인원 수", example = "20") + val targetCount: Int, + @field:Schema(description = "납부 제외 인원 수", example = "2") + val excludedCount: Int, + ) + + data class BankAccountRegistrationResponse( + @field:Schema(description = "계좌 공개 여부", example = "true") + val bankAccountVisible: Boolean, + @field:Schema(description = "입금 계좌 정보. bankAccountVisible=false 이면 null", nullable = true) + val bankAccount: BankAccountResponse?, + ) + + data class PreviousAccountBalanceResponse( + @field:Schema(description = "이전 기수", example = "3") + val cardinalNumber: Int, + @field:Schema(description = "이전 기수 장부 잔액", example = "240000") + val balance: Int, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountResponse.kt index 3ac8b44d..d49fc023 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountResponse.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/AccountResponse.kt @@ -7,7 +7,7 @@ data class AccountResponse( @field:Schema(description = "회비 ID", example = "1") val accountId: Long, @field:Schema(description = "회비 설명", example = "2024년 2학기 회비") - val description: String, + val description: String?, @field:Schema(description = "총 금액", example = "100000") val totalAmount: Int, @field:Schema(description = "현재 금액", example = "90000") diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/BankAccountResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/BankAccountResponse.kt new file mode 100644 index 00000000..194cdc4b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/BankAccountResponse.kt @@ -0,0 +1,14 @@ +package com.weeth.domain.account.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class BankAccountResponse( + @field:Schema(description = "은행명", example = "국민은행") + val bankName: String, + @field:Schema(description = "계좌번호", example = "123-456-789012") + val accountNumber: String, + @field:Schema(description = "예금주", example = "가천대 검도부") + val holder: String, + @field:Schema(description = "입금 안내 메모", example = "이름_회비 형식으로 입금해 주세요.", nullable = true) + val guide: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/dto/response/CreateAccountDraftResponse.kt b/src/main/kotlin/com/weeth/domain/account/application/dto/response/CreateAccountDraftResponse.kt new file mode 100644 index 00000000..6c3a9833 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/dto/response/CreateAccountDraftResponse.kt @@ -0,0 +1,21 @@ +package com.weeth.domain.account.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class CreateAccountDraftResponse( + @field:Schema(description = "회비 장부 ID") + val accountId: Long, + @field:Schema( + description = + "새로 생성된 초안 여부. false면 작성 중인 초안이 이미 있으므로 " + + "'이어서 작성 / 새로 작성' 분기를 노출해주세요. " + + "이어서 작성 시 등록 현황 조회 API로 폼을 복원하고, 새로 작성 시 초안 폐기 API 호출 후 본 API를 재호출해주세요.", + example = "true", + ) + val isNew: Boolean, + @field:Schema( + description = "기존 초안의 마지막 수정자 이름. '00님이 작성 중인 회비가 있어요' 안내 문구에 사용해주세요. 신규 초안이면 null", + nullable = true, + ) + val lastModifiedByName: String?, +) diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountCarryOverAmountMismatchException.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountCarryOverAmountMismatchException.kt new file mode 100644 index 00000000..dd2583b0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountCarryOverAmountMismatchException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.BaseException + +class AccountCarryOverAmountMismatchException : BaseException(AccountErrorCode.ACCOUNT_CARRY_OVER_AMOUNT_MISMATCH) diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt index 2f48de19..6bc5d6d8 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountErrorCode.kt @@ -20,4 +20,19 @@ enum class AccountErrorCode( @ExplainError("영수증이 요청한 기수의 장부에 속하지 않거나 동아리에 속하지 않는 경우에 발생합니다.") RECEIPT_ACCOUNT_MISMATCH(20103, HttpStatus.BAD_REQUEST, "영수증이 해당 기수의 장부에 속하지 않습니다."), + + @ExplainError("초안 상태가 아닌 회비 장부를 등록 플로우에서 수정하거나 폐기하려고 할 때 발생합니다.") + ACCOUNT_INVALID_DRAFT_STATE(20104, HttpStatus.BAD_REQUEST, "초안 상태의 회비 장부만 처리할 수 있습니다."), + + @ExplainError("납부 대상 멤버가 기수 명부에 없거나, 대상/제외 목록에 같은 멤버가 동시에 포함될 때 발생합니다.") + ACCOUNT_PAYMENT_TARGET_MEMBER_INVALID(20105, HttpStatus.BAD_REQUEST, "유효하지 않은 납부 대상 멤버입니다."), + + @ExplainError("이미 납부 완료된 대상을 제외하려고 할 때 발생합니다.") + ACCOUNT_PAYMENT_TARGET_ALREADY_PAID(20106, HttpStatus.BAD_REQUEST, "납부 완료된 대상은 제외할 수 없습니다."), + + @ExplainError("모든 등록 단계를 저장하지 않은 채 회비 등록 완료를 요청할 때 발생합니다.") + ACCOUNT_REGISTRATION_STEP_INCOMPLETE(20107, HttpStatus.BAD_REQUEST, "모든 등록 단계를 완료한 후 등록할 수 있습니다."), + + @ExplainError("이월 금액이 완료 시점의 이전 기수 장부 잔액과 다를 때 발생합니다. 이월 재원을 다시 조회한 후 이월 설정을 재저장해야 합니다.") + ACCOUNT_CARRY_OVER_AMOUNT_MISMATCH(20108, HttpStatus.CONFLICT, "이전 기수 잔액이 변경되었습니다. 이월 금액을 다시 확인해주세요."), } diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountInvalidDraftStateException.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountInvalidDraftStateException.kt new file mode 100644 index 00000000..ade37346 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountInvalidDraftStateException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.BaseException + +class AccountInvalidDraftStateException : BaseException(AccountErrorCode.ACCOUNT_INVALID_DRAFT_STATE) diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountPaymentTargetMemberInvalidException.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountPaymentTargetMemberInvalidException.kt new file mode 100644 index 00000000..db36bf61 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountPaymentTargetMemberInvalidException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.BaseException + +class AccountPaymentTargetMemberInvalidException : BaseException(AccountErrorCode.ACCOUNT_PAYMENT_TARGET_MEMBER_INVALID) diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountPaymentTargetPaidException.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountPaymentTargetPaidException.kt new file mode 100644 index 00000000..4e6bce9f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountPaymentTargetPaidException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.BaseException + +class AccountPaymentTargetPaidException : BaseException(AccountErrorCode.ACCOUNT_PAYMENT_TARGET_ALREADY_PAID) diff --git a/src/main/kotlin/com/weeth/domain/account/application/exception/AccountRegistrationStepIncompleteException.kt b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountRegistrationStepIncompleteException.kt new file mode 100644 index 00000000..85d242e3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/exception/AccountRegistrationStepIncompleteException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.account.application.exception + +import com.weeth.global.common.exception.BaseException + +class AccountRegistrationStepIncompleteException : BaseException(AccountErrorCode.ACCOUNT_REGISTRATION_STEP_INCOMPLETE) diff --git a/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountPaymentTargetMapper.kt b/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountPaymentTargetMapper.kt new file mode 100644 index 00000000..54035456 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountPaymentTargetMapper.kt @@ -0,0 +1,48 @@ +package com.weeth.domain.account.application.mapper + +import com.weeth.domain.account.application.dto.response.AccountPaymentTargetResponse +import com.weeth.domain.account.domain.entity.AccountPaymentTarget +import com.weeth.domain.account.domain.enums.AccountPaymentStatus +import com.weeth.domain.account.domain.enums.AccountTargetStatus +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import org.springframework.stereotype.Component + +@Component +class AccountPaymentTargetMapper( + private val fileAccessUrlPort: FileAccessUrlPort, +) { + fun toResponse(target: AccountPaymentTarget): AccountPaymentTargetResponse = + toResponse(clubMember = target.clubMember, target = target) + + fun toResponse( + clubMember: ClubMember, + target: AccountPaymentTarget?, + ): AccountPaymentTargetResponse = + AccountPaymentTargetResponse( + targetId = target?.id, + paymentTargetInfo = toPaymentTargetInfo(clubMember), + targetStatus = target?.targetStatus ?: AccountTargetStatus.EXCLUDED, + paymentStatus = target?.paymentStatus ?: AccountPaymentStatus.UNPAID, + dueAmount = target?.dueAmount ?: 0, + paidAmount = target?.paidAmount ?: 0, + paidAt = target?.paidAt, + confirmedBy = target?.confirmedBy, + memo = target?.memo, + ) + + private fun toPaymentTargetInfo(clubMember: ClubMember) = + AccountPaymentTargetResponse.PaymentTargetInfoResponse( + userId = clubMember.user.id, + clubMemberId = clubMember.id, + name = clubMember.user.name, + tel = clubMember.user.telValue, + school = clubMember.user.school, + department = clubMember.user.department, + memberRole = clubMember.memberRole, + memberStatus = clubMember.memberStatus, + profileImageUrl = resolveClubImage(clubMember.profileImageStorageKey), + ) + + private fun resolveClubImage(storageKey: String?): String? = storageKey?.let { fileAccessUrlPort.resolve(it) } +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountRegistrationMapper.kt b/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountRegistrationMapper.kt new file mode 100644 index 00000000..76f6e132 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/mapper/AccountRegistrationMapper.kt @@ -0,0 +1,68 @@ +package com.weeth.domain.account.application.mapper + +import com.weeth.domain.account.application.dto.response.AccountRegistrationStatusResponse +import com.weeth.domain.account.application.dto.response.BankAccountResponse +import com.weeth.domain.account.domain.entity.Account +import com.weeth.domain.account.domain.enums.AccountRegistrationStep +import org.springframework.stereotype.Component + +@Component +class AccountRegistrationMapper { + fun toResponse( + account: Account, + paymentTargetCount: Int?, + excludedTargetCount: Int?, + previousAccount: Account?, + ): AccountRegistrationStatusResponse { + val step = account.registrationStep + + return AccountRegistrationStatusResponse( + accountId = account.id, + registrationStep = step, + basic = + account.name?.let { name -> + AccountRegistrationStatusResponse.BasicInfoResponse( + name = name, + duesAmount = account.duesAmount, + description = account.description, + ) + }, + carryOver = + if (step.isAtLeast(AccountRegistrationStep.BANK_ACCOUNT)) { + AccountRegistrationStatusResponse.CarryOverResponse( + enabled = account.carryOverEnabled, + amount = account.carryOverAmount, + memo = account.carryOverMemo, + ) + } else { + null + }, + paymentTargets = + paymentTargetCount?.let { + AccountRegistrationStatusResponse.PaymentTargetsResponse( + targetCount = it, + excludedCount = excludedTargetCount ?: 0, + ) + }, + bankAccount = + if (step.isAtLeast(AccountRegistrationStep.REVIEW)) { + AccountRegistrationStatusResponse.BankAccountRegistrationResponse( + bankAccountVisible = account.bankAccountVisible, + bankAccount = + account.bankAccount?.let { ba -> + BankAccountResponse(ba.bankName, ba.accountNumber, ba.holder, ba.guide) + }, + ) + } else { + null + }, + previousAccountBalance = + previousAccount?.let { + AccountRegistrationStatusResponse.PreviousAccountBalanceResponse( + cardinalNumber = it.cardinal, + balance = it.currentBalance, + ) + }, + ) + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/AccountClubAccess.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/AccountClubAccess.kt new file mode 100644 index 00000000..4a3a1e1f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/AccountClubAccess.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.account.application.usecase + +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.domain.entity.Account + +/** + * 장부가 요청한 동아리 소속인지 검증한다. + * 다른 동아리의 장부 ID 탐색을 막기 위해 불일치 시 NOT_FOUND로 응답한다. + * club.id가 0인 영속화 이전 인스턴스(레거시 데이터/테스트 픽스처)는 검증을 건너뛴다. + */ +fun Account.validateOwnedBy(clubId: Long) { + if (club.id != 0L && club.id != clubId) throw AccountNotFoundException() +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt index ea27267e..8f718488 100644 --- a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCase.kt @@ -2,6 +2,8 @@ package com.weeth.domain.account.application.usecase.command import com.weeth.domain.account.application.dto.request.AccountSaveRequest import com.weeth.domain.account.application.exception.AccountExistsException +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.usecase.validateOwnedBy import com.weeth.domain.account.domain.entity.Account import com.weeth.domain.account.domain.repository.AccountRepository import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException @@ -18,6 +20,25 @@ class ManageAccountUseCase( private val clubReader: ClubReader, private val clubPermissionPolicy: ClubPermissionPolicy, ) { + @Transactional + fun updateMemberVisibility( + clubId: Long, + accountId: Long, + visible: Boolean, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val account = getAccountWithLock(clubId, accountId) + + if (visible) { + account.showToMembers() + } else { + account.hideFromMembers() + } + + account.markModifiedBy(userId) + } + @Transactional fun save( clubId: Long, @@ -34,4 +55,13 @@ class ManageAccountUseCase( accountRepository.save(Account.create(club, request.description, request.totalAmount, request.cardinal)) } + + private fun getAccountWithLock( + clubId: Long, + accountId: Long, + ): Account { + val account = accountRepository.findByIdWithLock(accountId) ?: throw AccountNotFoundException() + account.validateOwnedBy(clubId) + return account + } } diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/command/RegisterAccountUseCase.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/RegisterAccountUseCase.kt new file mode 100644 index 00000000..3b590574 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/command/RegisterAccountUseCase.kt @@ -0,0 +1,332 @@ +package com.weeth.domain.account.application.usecase.command + +import com.weeth.domain.account.application.dto.request.SaveAccountBankAccountRequest +import com.weeth.domain.account.application.dto.request.SaveAccountBasicRequest +import com.weeth.domain.account.application.dto.request.SaveAccountCarryOverRequest +import com.weeth.domain.account.application.dto.request.SavePaymentTargetsRequest +import com.weeth.domain.account.application.dto.response.CreateAccountDraftResponse +import com.weeth.domain.account.application.exception.AccountCarryOverAmountMismatchException +import com.weeth.domain.account.application.exception.AccountExistsException +import com.weeth.domain.account.application.exception.AccountInvalidDraftStateException +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.exception.AccountPaymentTargetMemberInvalidException +import com.weeth.domain.account.application.exception.AccountPaymentTargetPaidException +import com.weeth.domain.account.application.exception.AccountRegistrationStepIncompleteException +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.AccountRegistrationStep +import com.weeth.domain.account.domain.enums.AccountStatus +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 +import com.weeth.domain.account.domain.repository.AccountTransactionRepository +import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.cardinal.application.exception.CardinalNotFoundException +import com.weeth.domain.cardinal.domain.repository.CardinalReader +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.user.domain.repository.UserReader +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class RegisterAccountUseCase( + private val accountRepository: AccountRepository, + private val paymentTargetRepository: AccountPaymentTargetRepository, + private val transactionRepository: AccountTransactionRepository, + private val cardinalReader: CardinalReader, + private val clubReader: ClubReader, + private val clubMemberCardinalReader: ClubMemberCardinalReader, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val userReader: UserReader, +) { + @Transactional + fun createDraft( + clubId: Long, + cardinal: Int, + userId: Long, + ): CreateAccountDraftResponse { + clubPermissionPolicy.requireAdmin(clubId, userId) + + accountRepository.findByClubIdAndCardinal(clubId, cardinal)?.let { + if (it.status == AccountStatus.DRAFT) { + return CreateAccountDraftResponse( + accountId = it.id, + isNew = false, + lastModifiedByName = + it.lastModifiedBy?.let { modifierId -> + userReader.findByIdOrNull(modifierId)?.name + }, + ) + } + throw AccountExistsException() + } + + cardinalReader.findByClubIdAndCardinalNumber(clubId, cardinal) + ?: throw CardinalNotFoundException() + + val account = + Account + .createDraft(club = clubReader.getClubById(clubId), cardinal = cardinal) + .also { it.markModifiedBy(userId) } + + return CreateAccountDraftResponse( + accountId = accountRepository.save(account).id, + isNew = true, + lastModifiedByName = null, + ) + } + + @Transactional + fun discardDraft( + clubId: Long, + accountId: Long, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val account = getAccountWithLock(clubId, accountId) + + if (account.status != AccountStatus.DRAFT) throw AccountInvalidDraftStateException() + + // 납부 대상 행이 장부를 FK로 참조하므로(cascade 없음) 장부보다 먼저 삭제한다. + paymentTargetRepository.deleteAllByAccountId(account.id) + accountRepository.delete(account) + } + + @Transactional + fun saveBasic( + clubId: Long, + accountId: Long, + request: SaveAccountBasicRequest, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val account = getAccountWithLock(clubId, accountId) + + account.updateBasicInfo( + name = request.name, + duesAmount = Money.of(request.duesAmount), + description = request.description, + ) + + account.markModifiedBy(userId) + } + + @Transactional + fun saveCarryOver( + clubId: Long, + accountId: Long, + request: SaveAccountCarryOverRequest, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val account = getAccountWithLock(clubId, accountId) + + account.updateCarryOver( + enabled = request.enabled, + amount = request.amount?.let(Money::of), + memo = request.memo, + ) + + account.markModifiedBy(userId) + } + + @Transactional + fun saveBankAccount( + clubId: Long, + accountId: Long, + request: SaveAccountBankAccountRequest, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val account = getAccountWithLock(clubId, accountId) + + account.updateBankAccount( + bankAccount = request.bankAccount?.toBankAccount(), + visible = request.bankAccountVisible, + ) + + account.markModifiedBy(userId) + } + + @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) + } + + /** + * 회비 납부 대상을 델타 방식으로 저장한다. 요청에 포함된 멤버만 갱신하며, + * 두 목록에 모두 없는 멤버(비활성 멤버 포함)의 기존 상태는 건드리지 않는다. + * + * 후보는 동아리 전체가 아니라 해당 장부의 기수 활성 명부로 한정한다. + * - targetedClubMemberIds: 납부 대상으로 갱신(이미 납부 완료된 대상은 유지), 행이 없으면 신규 생성 + * - excludedClubMemberIds: 제외 처리(납부 완료된 대상은 제외 불가), 행이 없으면 이미 제외 상태이므로 건너뜀 + */ + @Transactional + fun savePaymentTargets( + clubId: Long, + accountId: Long, + request: SavePaymentTargetsRequest, + userId: Long, + ) { + clubPermissionPolicy.requireAdmin(clubId, userId) + val account = getAccountWithLock(clubId, accountId) + val targetedMemberIds = request.targetedClubMemberIds.distinct().sorted() + val excludedMemberIds = request.excludedClubMemberIds.distinct().sorted() + if (targetedMemberIds.intersect(excludedMemberIds.toSet()).isNotEmpty()) { + throw AccountPaymentTargetMemberInvalidException() + } + + val requestedMemberIds = targetedMemberIds + excludedMemberIds + if (requestedMemberIds.isNotEmpty()) { + val rosterById = + clubMemberCardinalReader + .findAllByClubIdAndCardinalNumber(clubId, account.cardinal, MemberStatus.ACTIVE) + .associate { it.clubMember.id to it.clubMember } + if (!rosterById.keys.containsAll(requestedMemberIds)) throw AccountPaymentTargetMemberInvalidException() + + val dueAmount = Money.of(account.duesAmount) + val existingByMemberId = + paymentTargetRepository + .findAllByAccountIdAndClubMemberIdIn(accountId, requestedMemberIds) + .associateBy { it.clubMember.id } + + excludedMemberIds.mapNotNull { existingByMemberId[it] }.forEach { target -> + if (target.paymentStatus != AccountPaymentStatus.UNPAID) throw AccountPaymentTargetPaidException() + target.exclude() + } + + targetedMemberIds.mapNotNull { existingByMemberId[it] }.forEach { target -> + val alreadyPaid = + target.targetStatus == AccountTargetStatus.TARGETED && + target.paymentStatus == AccountPaymentStatus.PAID + if (!alreadyPaid) { + target.target(dueAmount) + } + } + + val newTargets = + targetedMemberIds + .filterNot { existingByMemberId.containsKey(it) } + .map { AccountPaymentTarget.createTargeted(account, rosterById.getValue(it), dueAmount) } + + if (newTargets.isNotEmpty()) { + paymentTargetRepository.saveAll(newTargets) + } + } + + account.advanceRegistrationStep(AccountRegistrationStep.CARRY_OVER) + account.markModifiedBy(userId) + } + + private fun getAccountWithLock( + clubId: Long, + accountId: Long, + ): Account { + val account = accountRepository.findByIdWithLock(accountId) ?: throw AccountNotFoundException() + account.validateOwnedBy(clubId) + return account + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountPaymentTargetQueryService.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountPaymentTargetQueryService.kt new file mode 100644 index 00000000..591df4da --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountPaymentTargetQueryService.kt @@ -0,0 +1,117 @@ +package com.weeth.domain.account.application.usecase.query + +import com.weeth.domain.account.application.dto.response.AccountPaymentTargetResponse +import com.weeth.domain.account.application.dto.response.AccountPaymentTargetsResponse +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.mapper.AccountPaymentTargetMapper +import com.weeth.domain.account.application.usecase.validateOwnedBy +import com.weeth.domain.account.domain.entity.AccountPaymentTarget +import com.weeth.domain.account.domain.enums.AccountTargetStatus +import com.weeth.domain.account.domain.repository.AccountPaymentTargetRepository +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.global.common.response.PageResponse +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetAccountPaymentTargetQueryService( + private val accountRepository: AccountRepository, + private val paymentTargetRepository: AccountPaymentTargetRepository, + private val clubMemberReader: ClubMemberReader, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val accountPaymentTargetMapper: AccountPaymentTargetMapper, +) { + fun findTargets( + clubId: Long, + accountId: Long, + userId: Long, + page: Int, + size: Int, + keyword: String?, + targetStatus: AccountTargetStatus?, + ): AccountPaymentTargetsResponse { + clubPermissionPolicy.requireAdmin(clubId, userId) + val account = accountRepository.findById(accountId).orElseThrow { AccountNotFoundException() } + account.validateOwnedBy(clubId) + + val pageable = PageRequest.of(page.coerceAtLeast(0), size.coerceIn(1, 100)) + val normalizedKeyword = keyword?.trim()?.takeIf { it.isNotBlank() } + // 납부 대상 후보는 동아리 전체가 아니라 해당 장부의 기수 명부로 한정한다. + val cardinalNumber = account.cardinal + val totalCount = clubMemberReader.countActiveByClubIdAndCardinalNumber(clubId, cardinalNumber) + val targetedCount = + paymentTargetRepository.countActiveClubMemberTargetsByAccountIdAndTargetStatus( + accountId = accountId, + targetStatus = AccountTargetStatus.TARGETED, + ) + + val targets = + when (targetStatus) { + AccountTargetStatus.TARGETED -> { + paymentTargetRepository + .findAllActiveClubMemberTargetsByAccountIdAndTargetStatus( + accountId = accountId, + targetStatus = AccountTargetStatus.TARGETED, + keyword = normalizedKeyword, + pageable = pageable, + ).map { accountPaymentTargetMapper.toResponse(it) } + } + + AccountTargetStatus.EXCLUDED -> { + clubMemberReader + .findExcludedPaymentTargetCandidatesByCardinal( + clubId = clubId, + cardinalNumber = cardinalNumber, + accountId = accountId, + keyword = normalizedKeyword, + pageable = pageable, + ).mapWithSavedTargets(accountId) + } + + null -> { + clubMemberReader + .findActiveByClubIdAndCardinalNumberAndKeyword( + clubId = clubId, + cardinalNumber = cardinalNumber, + keyword = normalizedKeyword, + pageable = pageable, + ).mapWithSavedTargets(accountId) + } + } + + return AccountPaymentTargetsResponse( + summary = + AccountPaymentTargetsResponse.PaymentTargetSummaryResponse( + totalCount = totalCount.toInt(), + targetedCount = targetedCount.toInt(), + excludedCount = (totalCount - targetedCount).coerceAtLeast(0).toInt(), + ), + targets = PageResponse.from(targets), + ) + } + + private fun Page.mapWithSavedTargets(accountId: Long): Page { + val targetByClubMemberId = findTargetsByClubMemberId(accountId, content) + return map { clubMember -> + accountPaymentTargetMapper.toResponse(clubMember, targetByClubMemberId[clubMember.id]) + } + } + + private fun findTargetsByClubMemberId( + accountId: Long, + clubMembers: List, + ): Map { + val clubMemberIds = clubMembers.map { it.id } + if (clubMemberIds.isEmpty()) return emptyMap() + + return paymentTargetRepository + .findAllByAccountIdAndClubMemberIdIn(accountId, clubMemberIds) + .associateBy { it.clubMember.id } + } +} diff --git a/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountRegistrationQueryService.kt b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountRegistrationQueryService.kt new file mode 100644 index 00000000..98be7636 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountRegistrationQueryService.kt @@ -0,0 +1,96 @@ +package com.weeth.domain.account.application.usecase.query + +import com.weeth.domain.account.application.dto.response.AccountCarryOverSourceResponse +import com.weeth.domain.account.application.dto.response.AccountRegistrationStatusResponse +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.mapper.AccountRegistrationMapper +import com.weeth.domain.account.application.usecase.validateOwnedBy +import com.weeth.domain.account.domain.enums.AccountRegistrationStep +import com.weeth.domain.account.domain.enums.AccountStatus +import com.weeth.domain.account.domain.enums.AccountTargetStatus +import com.weeth.domain.account.domain.repository.AccountPaymentTargetRepository +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetAccountRegistrationQueryService( + private val accountRepository: AccountRepository, + private val paymentTargetRepository: AccountPaymentTargetRepository, + private val clubMemberReader: ClubMemberReader, + private val clubPermissionPolicy: ClubPermissionPolicy, + private val registrationMapper: AccountRegistrationMapper, +) { + /** 이월 설정 단계의 재원 정보. 직전 활성 기수 장부의 잔액을 반환하고, 없으면 hasPreviousAccount=false 로 알린다. */ + fun findCarryOverSource( + clubId: Long, + accountId: Long, + userId: Long, + ): AccountCarryOverSourceResponse { + clubPermissionPolicy.requireAdmin(clubId, userId) + val account = accountRepository.findById(accountId).orElseThrow { AccountNotFoundException() } + account.validateOwnedBy(clubId) + + val previousAccount = + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId = clubId, + cardinal = account.cardinal, + status = AccountStatus.ACTIVE, + ) + + return AccountCarryOverSourceResponse( + hasPreviousAccount = previousAccount != null, + cardinalNumber = previousAccount?.cardinal, + balance = previousAccount?.currentBalance, + ) + } + + fun findStatus( + clubId: Long, + accountId: Long, + userId: Long, + ): AccountRegistrationStatusResponse { + clubPermissionPolicy.requireAdmin(clubId, userId) + val account = accountRepository.findById(accountId).orElseThrow { AccountNotFoundException() } + account.validateOwnedBy(clubId) + + // 납부 대상 화면과 동일하게 활성 기수 명부 기준으로 집계한다. + // (행 없음 = 제외, 탈퇴/퇴출 멤버의 행은 카운트에서 제외) + val paymentTargetCount = + if (account.registrationStep.isAtLeast(AccountRegistrationStep.CARRY_OVER)) { + paymentTargetRepository + .countActiveClubMemberTargetsByAccountIdAndTargetStatus( + accountId = accountId, + targetStatus = AccountTargetStatus.TARGETED, + ).toInt() + } else { + null + } + + val excludedTargetCount = + paymentTargetCount?.let { targeted -> + val rosterCount = + clubMemberReader + .countActiveByClubIdAndCardinalNumber(clubId, account.cardinal) + .toInt() + (rosterCount - targeted).coerceAtLeast(0) + } + + val previousAccount = + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId = clubId, + cardinal = account.cardinal, + status = AccountStatus.ACTIVE, + ) + + return registrationMapper.toResponse( + account = account, + paymentTargetCount = paymentTargetCount, + excludedTargetCount = excludedTargetCount, + previousAccount = previousAccount, + ) + } +} 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 f2d6847a..fbf8e5c3 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 @@ -1,5 +1,6 @@ package com.weeth.domain.account.domain.entity +import com.weeth.domain.account.domain.enums.AccountRegistrationStep import com.weeth.domain.account.domain.enums.AccountStatus import com.weeth.domain.account.domain.enums.AccountTransactionDirection import com.weeth.domain.account.domain.vo.BankAccount @@ -34,19 +35,22 @@ class Account( @JoinColumn(name = "club_id", nullable = false) val club: Club, id: Long = 0, - description: String, + description: String? = null, totalAmount: Int, currentAmount: Int, cardinal: Int, name: String? = null, duesAmount: Int = 0, + carryOverEnabled: Boolean = false, carryOverAmount: Int = 0, carryOverMemo: String? = null, currentBalance: Int = 0, bankAccount: BankAccount? = null, bankAccountVisible: Boolean = false, // 계좌 노출 여부 memberVisible: Boolean = false, // 회비 회원 공개 여부 + lastModifiedBy: Long? = null, // 마지막 수정자. 추후 수정 로그 기능 확장에 따라 수정될 가능성 존재 status: AccountStatus = AccountStatus.ACTIVE, + registrationStep: AccountRegistrationStep = AccountRegistrationStep.BASIC, ) : BaseEntity() { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -54,8 +58,8 @@ class Account( var id: Long = id private set - @Column(nullable = false) - var description: String = description + @Column(nullable = true) + var description: String? = description private set @Column(nullable = false) @@ -80,6 +84,10 @@ class Account( var duesAmount: Int = duesAmount private set + @Column(nullable = false) + var carryOverEnabled: Boolean = carryOverEnabled + private set + @Column(nullable = false) var carryOverAmount: Int = carryOverAmount private set @@ -106,11 +114,19 @@ class Account( var memberVisible: Boolean = memberVisible private set + var lastModifiedBy: Long? = lastModifiedBy + private set + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) var status: AccountStatus = status private set + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var registrationStep: AccountRegistrationStep = registrationStep + private set + fun spend(amount: Money) { require(amount.value > 0) { "사용 금액은 0보다 커야 합니다: ${amount.value}" } check(currentAmount >= amount.value) { "잔액이 부족합니다. 현재: $currentAmount, 요청: ${amount.value}" } @@ -136,16 +152,16 @@ class Account( fun updateBasicInfo( name: String, duesAmount: Money, - description: String, + description: String? = null, ) { val normalizedName = name.trim() - val normalizedDescription = description.trim() + val normalizedDescription = description?.trim() require(normalizedName.isNotBlank()) { "회비 이름은 비어 있을 수 없습니다." } require(duesAmount.value > 0) { "1인 회비 금액은 0보다 커야 합니다: ${duesAmount.value}" } - require(normalizedDescription.isNotBlank()) { "회비 설명은 비어 있을 수 없습니다." } this.name = normalizedName this.duesAmount = duesAmount.value this.description = normalizedDescription + advanceRegistrationStep(AccountRegistrationStep.PAYMENT_TARGETS) } fun updateCarryOver( @@ -155,8 +171,10 @@ class Account( ) { val carryOver = if (enabled) requireNotNull(amount) { "이월 금액은 필수입니다." } else Money.ZERO require(carryOver.value >= 0) { "이월 금액은 0 이상이어야 합니다: ${carryOver.value}" } + carryOverEnabled = enabled carryOverAmount = carryOver.value carryOverMemo = memo?.trim()?.takeIf { it.isNotBlank() } + advanceRegistrationStep(AccountRegistrationStep.BANK_ACCOUNT) } fun updateBankAccount( @@ -168,6 +186,14 @@ class Account( } this.bankAccount = bankAccount bankAccountVisible = visible + advanceRegistrationStep(AccountRegistrationStep.REVIEW) + } + + fun advanceRegistrationStep(next: AccountRegistrationStep) { + if (status != AccountStatus.DRAFT) return + if (next.isAfter(registrationStep)) { + registrationStep = next + } } fun showToMembers() { @@ -179,6 +205,11 @@ class Account( memberVisible = false } + fun markModifiedBy(adminId: Long) { + require(adminId > 0) { "마지막 수정자 ID는 0보다 커야 합니다: $adminId" } + lastModifiedBy = adminId + } + fun activate() { check(status == AccountStatus.DRAFT) { "초안 상태의 회비 장부만 활성화할 수 있습니다." } check(!name.isNullOrBlank()) { "회비 이름은 필수입니다." } 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 1bb02e2d..38d561a5 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 @@ -36,14 +36,11 @@ class AccountTransaction( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "account_id", nullable = false) val account: Account, - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) - val type: AccountTransactionType, + type: AccountTransactionType, title: String, source: String?, amount: Money, - @Column(name = "transacted_at", nullable = false) - val transactedAt: LocalDateTime, + transactedAt: LocalDateTime, category: String? = null, memo: String? = null, @ManyToOne(fetch = FetchType.LAZY) @@ -59,7 +56,13 @@ class AccountTransaction( // 인덱스/집계 쿼리에서 활용하기 위해 type 으로부터 파생된 방향을 함께 저장합니다. @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) - val direction: AccountTransactionDirection = type.direction + var type: AccountTransactionType = type + private set + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var direction: AccountTransactionDirection = type.direction + private set @Column(nullable = false, length = 100) var title: String = normalizeRequired(title, "거래 내용", MAX_TITLE_LENGTH) @@ -73,6 +76,10 @@ class AccountTransaction( var amount: Int = amount.value private set + @Column(name = "transacted_at", nullable = false) + var transactedAt: LocalDateTime = transactedAt + private set + // MVP UI에서는 노출하지 않지만 분류/통계 확장을 위해 선반영해두는 자유 입력 카테고리. // 추후 카테고리 테이블로 승격될 수 있다 (membership-fee-domain-plan.md 참조). @Column(length = 30) @@ -103,6 +110,28 @@ class AccountTransaction( } } + fun update( + type: AccountTransactionType, + title: String, + source: String?, + amount: Money, + transactedAt: LocalDateTime, + category: String?, + memo: String?, + ) { + check(!isApplied) { "반영된 거래는 되돌린 뒤 수정할 수 있습니다." } + check(deletedAt == null) { "삭제된 거래는 수정할 수 없습니다." } + require(amount.value > 0) { "거래 금액은 0보다 커야 합니다: ${amount.value}" } + this.type = type + this.direction = type.direction + this.title = normalizeRequired(title, "거래 내용", MAX_TITLE_LENGTH) + this.source = normalizeOptional(source, "거래 출처", MAX_SOURCE_LENGTH) + this.amount = amount.value + this.transactedAt = transactedAt + this.category = normalizeOptional(category, "카테고리", MAX_CATEGORY_LENGTH) + this.memo = normalizeOptional(memo, "메모", MAX_MEMO_LENGTH) + } + internal fun markApplied() { check(!isApplied) { "이미 반영된 거래입니다." } check(deletedAt == null) { "삭제된 거래는 반영할 수 없습니다." } diff --git a/src/main/kotlin/com/weeth/domain/account/domain/enums/AccountRegistrationStep.kt b/src/main/kotlin/com/weeth/domain/account/domain/enums/AccountRegistrationStep.kt index 5bccbdf3..d466bc4e 100644 --- a/src/main/kotlin/com/weeth/domain/account/domain/enums/AccountRegistrationStep.kt +++ b/src/main/kotlin/com/weeth/domain/account/domain/enums/AccountRegistrationStep.kt @@ -1,9 +1,16 @@ package com.weeth.domain.account.domain.enums -enum class AccountRegistrationStep { - BASIC, - CARRY_OVER, - PAYMENT_TARGETS, - BANK_ACCOUNT, - REVIEW, +enum class AccountRegistrationStep( + private val sequence: Int, +) { + BASIC(10), + PAYMENT_TARGETS(20), + CARRY_OVER(30), + BANK_ACCOUNT(40), + REVIEW(50), + ; + + fun isAtLeast(step: AccountRegistrationStep): Boolean = sequence >= step.sequence + + fun isAfter(step: AccountRegistrationStep): Boolean = sequence > step.sequence } 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 3b12de8d..7e5255cb 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 @@ -3,9 +3,113 @@ package com.weeth.domain.account.domain.repository import com.weeth.domain.account.domain.entity.AccountPaymentTarget import com.weeth.domain.account.domain.enums.AccountPaymentStatus import com.weeth.domain.account.domain.enums.AccountTargetStatus +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param interface AccountPaymentTargetRepository : JpaRepository { + fun findAllByAccountId(accountId: Long): List + + @Query( + """ + select target + from AccountPaymentTarget target + join fetch target.clubMember clubMember + join fetch clubMember.user + where target.account.id = :accountId + order by target.id asc + """, + ) + fun findAllByAccountIdOrderByIdAsc( + @Param("accountId") accountId: Long, + ): List + + @Query( + """ + select target + from AccountPaymentTarget target + join fetch target.clubMember clubMember + join fetch clubMember.user + where target.account.id = :accountId + and clubMember.id in :clubMemberIds + """, + ) + fun findAllByAccountIdAndClubMemberIdIn( + @Param("accountId") accountId: Long, + @Param("clubMemberIds") clubMemberIds: List, + ): List + + @Query( + """ + select count(target) + from AccountPaymentTarget target + join target.clubMember clubMember + where target.account.id = :accountId + and target.targetStatus = :targetStatus + and clubMember.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + """, + ) + fun countActiveClubMemberTargetsByAccountIdAndTargetStatus( + @Param("accountId") accountId: Long, + @Param("targetStatus") targetStatus: AccountTargetStatus, + ): Long + + @Query( + value = """ + select target + from AccountPaymentTarget target + join fetch target.clubMember clubMember + join fetch clubMember.user user + where target.account.id = :accountId + and target.targetStatus = :targetStatus + and clubMember.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + and (:keyword is null or user.name like concat('%', :keyword, '%')) + order by target.id asc + """, + countQuery = """ + select count(target) + from AccountPaymentTarget target + join target.clubMember clubMember + join clubMember.user user + where target.account.id = :accountId + and target.targetStatus = :targetStatus + and clubMember.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + and (:keyword is null or user.name like concat('%', :keyword, '%')) + """, + ) + fun findAllActiveClubMemberTargetsByAccountIdAndTargetStatus( + @Param("accountId") accountId: Long, + @Param("targetStatus") targetStatus: AccountTargetStatus, + @Param("keyword") keyword: String?, + pageable: Pageable, + ): Page + + /** 초안 폐기 시 장부에 딸린 납부 대상 행을 일괄 삭제한다. (FK에 cascade가 없어 장부 삭제 전에 호출해야 한다) */ + @Modifying + @Query("delete from AccountPaymentTarget target where target.account.id = :accountId") + fun deleteAllByAccountId( + @Param("accountId") accountId: Long, + ) + + /** 탈퇴/퇴출 등으로 비활성화된 멤버의 미납 납부 대상 행. 등록 완료 시 제외 처리 대상이다. */ + @Query( + """ + select target + from AccountPaymentTarget target + join fetch target.clubMember clubMember + where target.account.id = :accountId + and target.targetStatus = com.weeth.domain.account.domain.enums.AccountTargetStatus.TARGETED + and target.paymentStatus = com.weeth.domain.account.domain.enums.AccountPaymentStatus.UNPAID + and clubMember.memberStatus <> com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + """, + ) + fun findAllUnpaidTargetsWithInactiveClubMemberByAccountId( + @Param("accountId") accountId: Long, + ): List + fun countByAccountIdAndTargetStatus( accountId: Long, targetStatus: AccountTargetStatus, diff --git a/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt index 38d63c8f..956f09f7 100644 --- a/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt +++ b/src/main/kotlin/com/weeth/domain/account/domain/repository/AccountRepository.kt @@ -27,6 +27,18 @@ interface AccountRepository : JpaRepository { status: AccountStatus, ): Account? + fun findByClubIdAndCardinalAndStatusAndMemberVisibleTrue( + clubId: Long, + cardinal: Int, + status: AccountStatus, + ): Account? + + fun findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId: Long, + cardinal: Int, + status: AccountStatus, + ): Account? + fun existsByClubIdAndCardinalAndStatus( clubId: Long, cardinal: Int, diff --git a/src/main/kotlin/com/weeth/domain/account/domain/vo/BankAccount.kt b/src/main/kotlin/com/weeth/domain/account/domain/vo/BankAccount.kt index e39e3097..19ca9804 100644 --- a/src/main/kotlin/com/weeth/domain/account/domain/vo/BankAccount.kt +++ b/src/main/kotlin/com/weeth/domain/account/domain/vo/BankAccount.kt @@ -22,11 +22,11 @@ class BankAccount( var accountNumber: String = normalizeRequired(accountNumber, "계좌번호") private set - @Column(name = "account_holder", length = 50) + @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 diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountManageController.kt similarity index 59% rename from src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt rename to src/main/kotlin/com/weeth/domain/account/presentation/AccountManageController.kt index d44b4a7b..8fe60df2 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountAdminController.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountManageController.kt @@ -1,11 +1,9 @@ package com.weeth.domain.account.presentation -import com.weeth.domain.account.application.dto.request.AccountSaveRequest -import com.weeth.domain.account.application.exception.AccountErrorCode +import com.weeth.domain.account.application.dto.request.UpdateMemberVisibilityRequest import com.weeth.domain.account.application.usecase.command.ManageAccountUseCase -import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_SAVE_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_UPDATE_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 @@ -13,7 +11,8 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid -import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -21,19 +20,24 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "ACCOUNT ADMIN", description = "[ADMIN] 회비 어드민 API") @RestController @RequestMapping("/api/v4/admin/clubs/{clubId}/accounts") -@ApiErrorCodeExample(AccountErrorCode::class) -class AccountAdminController( +class AccountManageController( private val manageAccountUseCase: ManageAccountUseCase, ) { - @PostMapping - @Operation(summary = "회비 총 금액 기입", hidden = true) - fun save( + @PatchMapping("/{accountId}/member-visibility") + @Operation(summary = "부원 거래 내역 공개 여부 수정") + fun updateMemberVisibility( @TsidParam @TsidPathVariable clubId: Long, - @RequestBody @Valid dto: AccountSaveRequest, + @PathVariable accountId: Long, + @RequestBody @Valid request: UpdateMemberVisibilityRequest, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - manageAccountUseCase.save(clubId, dto, userId) - return CommonResponse.success(ACCOUNT_SAVE_SUCCESS) + manageAccountUseCase.updateMemberVisibility( + clubId = clubId, + accountId = accountId, + visible = request.visible, + userId = userId, + ) + return CommonResponse.success(ACCOUNT_UPDATE_SUCCESS) } } diff --git a/src/main/kotlin/com/weeth/domain/account/presentation/AccountRegisterController.kt b/src/main/kotlin/com/weeth/domain/account/presentation/AccountRegisterController.kt new file mode 100644 index 00000000..6e2ae2b5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountRegisterController.kt @@ -0,0 +1,265 @@ +package com.weeth.domain.account.presentation + +import com.weeth.domain.account.application.dto.request.AccountSaveRequest +import com.weeth.domain.account.application.dto.request.SaveAccountBankAccountRequest +import com.weeth.domain.account.application.dto.request.SaveAccountBasicRequest +import com.weeth.domain.account.application.dto.request.SaveAccountCarryOverRequest +import com.weeth.domain.account.application.dto.request.SavePaymentTargetsRequest +import com.weeth.domain.account.application.dto.response.AccountCarryOverSourceResponse +import com.weeth.domain.account.application.dto.response.AccountPaymentTargetsResponse +import com.weeth.domain.account.application.dto.response.AccountRegistrationStatusResponse +import com.weeth.domain.account.application.dto.response.CreateAccountDraftResponse +import com.weeth.domain.account.application.exception.AccountErrorCode +import com.weeth.domain.account.application.usecase.command.ManageAccountUseCase +import com.weeth.domain.account.application.usecase.command.RegisterAccountUseCase +import com.weeth.domain.account.application.usecase.query.GetAccountPaymentTargetQueryService +import com.weeth.domain.account.application.usecase.query.GetAccountRegistrationQueryService +import com.weeth.domain.account.domain.enums.AccountTargetStatus +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_CARRY_OVER_SOURCE_FIND_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_DRAFT_DELETE_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_DRAFT_SAVE_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_PAYMENT_TARGET_FIND_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_PAYMENT_TARGET_UPDATE_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_REGISTRATION_COMPLETE_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_SAVE_SUCCESS +import com.weeth.domain.account.presentation.AccountResponseCode.ACCOUNT_UPDATE_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 jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "ACCOUNT ADMIN", description = "[ADMIN] 회비 어드민 API") +@RestController +@RequestMapping("/api/v4/admin/clubs/{clubId}/accounts") +@ApiErrorCodeExample(AccountErrorCode::class) +class AccountRegisterController( + private val manageAccountUseCase: ManageAccountUseCase, + private val registerAccountUseCase: RegisterAccountUseCase, + private val getAccountRegistrationQueryService: GetAccountRegistrationQueryService, + private val getAccountPaymentTargetQueryService: GetAccountPaymentTargetQueryService, +) { + @GetMapping("/{accountId}/registration/status") + @Operation( + summary = "회비 등록 현황 조회", + description = + "DRAFT 상태 장부의 현재 등록 단계와 각 단계별 저장 내용을 반환합니다. 이어서 작성 시 이 API 한 번으로 폼을 복원해주세요. " + + "registrationStep이 가리키는 단계에서 시작하고, basic/carryOver/bankAccount가 non-null이면 그 값으로 폼을 채워주세요. " + + "납부 대상 목록과 체크 상태는 납부 대상 목록 조회 API로 별도 조회해주세요.", + ) + fun findRegistrationStatus( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable accountId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + AccountResponseCode.ACCOUNT_REGISTRATION_STATUS_FIND_SUCCESS, + getAccountRegistrationQueryService.findStatus(clubId = clubId, accountId = accountId, userId = userId), + ) + + @PostMapping("/drafts") + @Operation( + summary = "[1단계] 회비 등록 초안 생성", + description = + "회비를 등록하기 전에 초안을 생성합니다. 멱등성 보장을 위해 이미 작성 중인 초안이 있다면 ID를 반환합니다. " + + "총 회비 등록 전까지는 매번 호출해서 ID를 조회해주세요. " + + "isNew=false면 '이어서 작성 / 새로 작성' 분기를 노출하고, 이어서 작성 시 등록 현황 조회 API로 폼을 복원해주세요. " + + "새로 작성 시에는 초안 폐기 API 호출 후 본 API를 재호출해주세요.", + ) + fun createDraft( + @TsidParam + @TsidPathVariable clubId: Long, + @RequestParam cardinalNumber: Int, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + ACCOUNT_DRAFT_SAVE_SUCCESS, + registerAccountUseCase.createDraft(clubId = clubId, cardinal = cardinalNumber, userId = userId), + ) + + @DeleteMapping("/{accountId}/registration/draft") + @Operation(summary = "회비 등록 초안 폐기", description = "작성 중인 DRAFT 상태 초안을 삭제합니다. 새로 작성하기 버튼에서 사용하세요.") + fun discardDraft( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable accountId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + registerAccountUseCase.discardDraft(clubId = clubId, accountId = accountId, userId = userId) + return CommonResponse.success(ACCOUNT_DRAFT_DELETE_SUCCESS) + } + + @PatchMapping("/{accountId}/registration/basic") + @Operation(summary = "[2단계] 회비 기본 정보 저장") + fun saveBasic( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable accountId: Long, + @RequestBody @Valid request: SaveAccountBasicRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + registerAccountUseCase.saveBasic(clubId = clubId, accountId = accountId, request = request, userId = userId) + return CommonResponse.success(ACCOUNT_UPDATE_SUCCESS) + } + + @GetMapping("/{accountId}/payment-targets") + @Operation( + summary = "회비 납부 대상 목록 조회", + description = + "등록 플로우 복원과 최종 확인에서 납부 대상/제외 대상 목록을 조회합니다. " + + "각 행의 targetStatus(TARGETED/EXCLUDED)가 체크박스 초기 상태이며, " + + "이후 사용자가 변경한 멤버만 모아 납부 대상 저장 API에 델타로 전달해주세요. " + + "키워드와 상태 필터링도 가능하도록 했으나, 되도록 프론트에서 캐싱된 데이터로 필터링해주시면 감사하겠습니다.", + ) + fun findPaymentTargets( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable accountId: Long, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "10") size: Int, + @RequestParam(required = false) keyword: String?, + @RequestParam(required = false) targetStatus: AccountTargetStatus?, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + ACCOUNT_PAYMENT_TARGET_FIND_SUCCESS, + getAccountPaymentTargetQueryService.findTargets( + clubId = clubId, + accountId = accountId, + userId = userId, + page = page, + size = size, + keyword = keyword, + targetStatus = targetStatus, + ), + ) + + @GetMapping("/{accountId}/registration/carry-over/source") + @Operation( + summary = "회비 이월 재원 조회", + description = + "이월 설정 단계 진입 시 직전 활성 기수 장부의 잔액을 조회합니다. " + + "hasPreviousAccount=true면 'OO원 / 이전 기수 N기 잔액' 배너를 노출하고 이월 금액으로 balance를 사용해주세요. " + + "false면 '이전 기수 정보가 없습니다' 안내와 함께 금액 직접 입력 UI를 노출해주세요.", + ) + fun findCarryOverSource( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable accountId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success( + ACCOUNT_CARRY_OVER_SOURCE_FIND_SUCCESS, + getAccountRegistrationQueryService.findCarryOverSource( + clubId = clubId, + accountId = accountId, + userId = userId, + ), + ) + + @PatchMapping("/{accountId}/payment-targets") + @Operation( + summary = "[3단계] 회비 납부 대상 저장", + description = + "해당 기수 명부 기준으로 납부 대상을 델타 방식으로 저장합니다. " + + "targetedClubMemberIds는 대상으로, excludedClubMemberIds는 제외로 갱신하며 " + + "두 목록에 모두 없는 회원의 기존 상태는 유지됩니다. 초기 등록과 재설정 모두 동일하게 동작합니다.", + ) + fun savePaymentTargets( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable accountId: Long, + @RequestBody @Valid request: SavePaymentTargetsRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + registerAccountUseCase.savePaymentTargets( + clubId = clubId, + accountId = accountId, + request = request, + userId = userId, + ) + return CommonResponse.success(ACCOUNT_PAYMENT_TARGET_UPDATE_SUCCESS) + } + + @PatchMapping("/{accountId}/registration/carry-over") + @Operation( + summary = "[4단계] 회비 이월 설정 저장", + description = + "이월 여부와 금액, 메모를 저장합니다. 등록 완료 시 이전 기수 장부에 남은 잔액은 " + + "이월하기면 '이월 잔액 전출', 이월하지 않기면 '미이월 잔액 정리' 명목의 지출 거래로 자동 정리됩니다. " + + "이전 기수 잔액은 이월 재원 조회 API로 확인해주세요.", + ) + fun saveCarryOver( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable accountId: Long, + @RequestBody @Valid request: SaveAccountCarryOverRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + registerAccountUseCase.saveCarryOver(clubId = clubId, accountId = accountId, request = request, userId = userId) + return CommonResponse.success(ACCOUNT_UPDATE_SUCCESS) + } + + @PatchMapping("/{accountId}/registration/bank-account") + @Operation(summary = "[5단계] 회비 계좌 설정 저장") + fun saveBankAccount( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable accountId: Long, + @RequestBody @Valid request: SaveAccountBankAccountRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + registerAccountUseCase.saveBankAccount( + clubId = clubId, + accountId = accountId, + request = request, + userId = userId, + ) + return CommonResponse.success(ACCOUNT_UPDATE_SUCCESS) + } + + @PostMapping("/{accountId}/registration/complete") + @Operation( + summary = "[6단계] 회비 등록 완료", + description = + "모든 단계(계좌 공개까지)를 저장한 뒤 호출해주세요. 미완료 시 20107, 이미 완료된 장부는 20104 에러가 발생합니다. " + + "완료 시 장부 활성화와 함께 이월 수입 기록, 이전 기수 장부 잔액의 자동 지출 정리가 수행됩니다. " + + "이월 금액이 완료 시점의 이전 기수 잔액과 다르면 20108 에러가 발생하니, " + + "이월 재원 조회 후 이월 설정을 다시 저장하고 재시도해주세요.", + ) + fun completeRegistration( + @TsidParam + @TsidPathVariable clubId: Long, + @PathVariable accountId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + registerAccountUseCase.completeRegistration(clubId = clubId, accountId = accountId, userId = userId) + return CommonResponse.success(ACCOUNT_REGISTRATION_COMPLETE_SUCCESS) + } + + @PostMapping + @Operation(summary = "회비 총 금액 기입", hidden = true) + fun save( + @TsidParam + @TsidPathVariable clubId: Long, + @RequestBody @Valid dto: AccountSaveRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + manageAccountUseCase.save(clubId, dto, userId) + return CommonResponse.success(ACCOUNT_SAVE_SUCCESS) + } +} 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 e93d5a60..0b7ae9fa 100644 --- a/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/account/presentation/AccountResponseCode.kt @@ -13,4 +13,12 @@ enum class AccountResponseCode( RECEIPT_SAVE_SUCCESS(10102, HttpStatus.OK, "영수증이 성공적으로 저장되었습니다."), RECEIPT_DELETE_SUCCESS(10103, HttpStatus.OK, "영수증이 성공적으로 삭제되었습니다."), RECEIPT_UPDATE_SUCCESS(10104, HttpStatus.OK, "영수증이 성공적으로 업데이트 되었습니다."), + ACCOUNT_DRAFT_SAVE_SUCCESS(10105, HttpStatus.OK, "회비 초안이 성공적으로 저장되었습니다."), + ACCOUNT_UPDATE_SUCCESS(10106, HttpStatus.OK, "회비 설정이 성공적으로 수정되었습니다."), + ACCOUNT_DRAFT_DELETE_SUCCESS(10107, HttpStatus.OK, "회비 초안이 성공적으로 폐기되었습니다."), + ACCOUNT_PAYMENT_TARGET_FIND_SUCCESS(10108, HttpStatus.OK, "납부 대상이 성공적으로 조회되었습니다."), + ACCOUNT_PAYMENT_TARGET_UPDATE_SUCCESS(10110, HttpStatus.OK, "납부 대상이 성공적으로 저장되었습니다."), + ACCOUNT_REGISTRATION_COMPLETE_SUCCESS(10112, HttpStatus.OK, "회비 등록이 완료되었습니다."), + ACCOUNT_REGISTRATION_STATUS_FIND_SUCCESS(10117, HttpStatus.OK, "회비 등록 현황이 성공적으로 조회되었습니다."), + ACCOUNT_CARRY_OVER_SOURCE_FIND_SUCCESS(10118, HttpStatus.OK, "이월 재원 정보가 성공적으로 조회되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt index 15533b86..43c24b64 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberReader.kt @@ -3,6 +3,8 @@ package com.weeth.domain.club.domain.repository import com.weeth.domain.club.domain.entity.ClubMember import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable interface ClubMemberReader { fun findByIdWithLock(clubMemberId: Long): ClubMember? @@ -40,6 +42,42 @@ interface ClubMemberReader { fun countActiveByClubId(clubId: Long): Long + fun findActiveByClubIdAndKeyword( + clubId: Long, + keyword: String?, + pageable: Pageable, + ): Page + + fun findExcludedPaymentTargetCandidates( + clubId: Long, + accountId: Long, + keyword: String?, + pageable: Pageable, + ): Page + + /** 특정 기수에 속한 활성 부원 수. 회비 납부 대상 후보는 동아리 전체가 아닌 해당 기수 명부로 한정한다. */ + fun countActiveByClubIdAndCardinalNumber( + clubId: Long, + cardinalNumber: Int, + ): Long + + /** 특정 기수에 속한 활성 부원을 이름 검색·페이지네이션으로 조회한다. (납부 대상 전체 탭) */ + fun findActiveByClubIdAndCardinalNumberAndKeyword( + clubId: Long, + cardinalNumber: Int, + keyword: String?, + pageable: Pageable, + ): Page + + /** 특정 기수 명부 중 해당 장부에서 납부 대상(TARGETED)이 아닌 부원. (납부 대상 제외됨 탭) */ + fun findExcludedPaymentTargetCandidatesByCardinal( + clubId: Long, + cardinalNumber: Int, + accountId: Long, + keyword: String?, + pageable: Pageable, + ): Page + fun findAllByClubIdAndMemberStatus( clubId: Long, memberStatus: MemberStatus, diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt index 9b0e810a..5a9080d2 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -5,6 +5,8 @@ import com.weeth.domain.club.domain.enums.MemberRole import com.weeth.domain.club.domain.enums.MemberStatus import jakarta.persistence.LockModeType import jakarta.persistence.QueryHint +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query @@ -112,6 +114,181 @@ interface ClubMemberRepository : @Param("clubId") clubId: Long, ): Long + @Query( + value = """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.user user + WHERE cm.club.id = :clubId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + AND (:keyword IS NULL OR user.name LIKE CONCAT('%', :keyword, '%')) + ORDER BY cm.id ASC + """, + countQuery = """ + SELECT COUNT(cm) + FROM ClubMember cm + JOIN cm.user user + WHERE cm.club.id = :clubId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + AND (:keyword IS NULL OR user.name LIKE CONCAT('%', :keyword, '%')) + """, + ) + override fun findActiveByClubIdAndKeyword( + @Param("clubId") clubId: Long, + @Param("keyword") keyword: String?, + pageable: Pageable, + ): Page + + @Query( + value = """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.user user + WHERE cm.club.id = :clubId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + AND (:keyword IS NULL OR user.name LIKE CONCAT('%', :keyword, '%')) + AND NOT EXISTS ( + SELECT target.id + FROM AccountPaymentTarget target + WHERE target.account.id = :accountId + AND target.clubMember = cm + AND target.targetStatus = com.weeth.domain.account.domain.enums.AccountTargetStatus.TARGETED + ) + ORDER BY cm.id ASC + """, + countQuery = """ + SELECT COUNT(cm) + FROM ClubMember cm + JOIN cm.user user + WHERE cm.club.id = :clubId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + AND (:keyword IS NULL OR user.name LIKE CONCAT('%', :keyword, '%')) + AND NOT EXISTS ( + SELECT target.id + FROM AccountPaymentTarget target + WHERE target.account.id = :accountId + AND target.clubMember = cm + AND target.targetStatus = com.weeth.domain.account.domain.enums.AccountTargetStatus.TARGETED + ) + """, + ) + override fun findExcludedPaymentTargetCandidates( + @Param("clubId") clubId: Long, + @Param("accountId") accountId: Long, + @Param("keyword") keyword: String?, + pageable: Pageable, + ): Page + + @Query( + """ + SELECT COUNT(cm) + FROM ClubMember cm + WHERE cm.club.id = :clubId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + AND EXISTS ( + SELECT 1 + FROM ClubMemberCardinal cmc + WHERE cmc.clubMember = cm + AND cmc.cardinal.cardinalNumber = :cardinalNumber + ) + """, + ) + override fun countActiveByClubIdAndCardinalNumber( + @Param("clubId") clubId: Long, + @Param("cardinalNumber") cardinalNumber: Int, + ): Long + + @Query( + value = """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.user user + WHERE cm.club.id = :clubId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + AND EXISTS ( + SELECT 1 + FROM ClubMemberCardinal cmc + WHERE cmc.clubMember = cm + AND cmc.cardinal.cardinalNumber = :cardinalNumber + ) + AND (:keyword IS NULL OR user.name LIKE CONCAT('%', :keyword, '%')) + ORDER BY cm.id ASC + """, + countQuery = """ + SELECT COUNT(cm) + FROM ClubMember cm + JOIN cm.user user + WHERE cm.club.id = :clubId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + AND EXISTS ( + SELECT 1 + FROM ClubMemberCardinal cmc + WHERE cmc.clubMember = cm + AND cmc.cardinal.cardinalNumber = :cardinalNumber + ) + AND (:keyword IS NULL OR user.name LIKE CONCAT('%', :keyword, '%')) + """, + ) + override fun findActiveByClubIdAndCardinalNumberAndKeyword( + @Param("clubId") clubId: Long, + @Param("cardinalNumber") cardinalNumber: Int, + @Param("keyword") keyword: String?, + pageable: Pageable, + ): Page + + @Query( + value = """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.user user + WHERE cm.club.id = :clubId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + AND EXISTS ( + SELECT 1 + FROM ClubMemberCardinal cmc + WHERE cmc.clubMember = cm + AND cmc.cardinal.cardinalNumber = :cardinalNumber + ) + AND (:keyword IS NULL OR user.name LIKE CONCAT('%', :keyword, '%')) + AND NOT EXISTS ( + SELECT target.id + FROM AccountPaymentTarget target + WHERE target.account.id = :accountId + AND target.clubMember = cm + AND target.targetStatus = com.weeth.domain.account.domain.enums.AccountTargetStatus.TARGETED + ) + ORDER BY cm.id ASC + """, + countQuery = """ + SELECT COUNT(cm) + FROM ClubMember cm + JOIN cm.user user + WHERE cm.club.id = :clubId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + AND EXISTS ( + SELECT 1 + FROM ClubMemberCardinal cmc + WHERE cmc.clubMember = cm + AND cmc.cardinal.cardinalNumber = :cardinalNumber + ) + AND (:keyword IS NULL OR user.name LIKE CONCAT('%', :keyword, '%')) + AND NOT EXISTS ( + SELECT target.id + FROM AccountPaymentTarget target + WHERE target.account.id = :accountId + AND target.clubMember = cm + AND target.targetStatus = com.weeth.domain.account.domain.enums.AccountTargetStatus.TARGETED + ) + """, + ) + override fun findExcludedPaymentTargetCandidatesByCardinal( + @Param("clubId") clubId: Long, + @Param("cardinalNumber") cardinalNumber: Int, + @Param("accountId") accountId: Long, + @Param("keyword") keyword: String?, + pageable: Pageable, + ): Page + @Query( """ SELECT COUNT(cm) diff --git a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt index 31e4922a..2e2c34a2 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/service/ClubActivityDeletionPolicy.kt @@ -10,6 +10,10 @@ import java.time.LocalDateTime * 멤버 탈퇴 시 함께 삭제 처리해야 하는 활동 정리 정책 * 활동 정합성을 위해 타 도메인 Repository를 직접 의존 * 탈퇴 시 정리 대상이 되는 활동을 한곳에서 관리 + * + * TODO: 탈퇴/퇴출 시 DRAFT 상태 회비 장부의 AccountPaymentTarget(미납 TARGETED 행)도 여기서 정리해야 한다. + * 현재는 회비 등록 완료(RegisterAccountUseCase.completeRegistration) 시점에 비활성 멤버의 미납 행을 + * 제외 처리하는 방식으로 보완 중이다. ACTIVE 장부의 행은 어드민 수동 환불 정책에 따라 유지한다. */ @Service class ClubActivityDeletionPolicy( diff --git a/src/main/kotlin/com/weeth/global/common/response/PageResponse.kt b/src/main/kotlin/com/weeth/global/common/response/PageResponse.kt new file mode 100644 index 00000000..7a425974 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/response/PageResponse.kt @@ -0,0 +1,22 @@ +package com.weeth.global.common.response + +import org.springframework.data.domain.Page + +data class PageResponse( + val content: List, + val pageNumber: Int, + val pageSize: Int, + val totalElements: Long, + val totalPages: Int, +) { + companion object { + fun from(page: Page): PageResponse = + PageResponse( + content = page.content, + pageNumber = page.number, + pageSize = page.size, + totalElements = page.totalElements, + totalPages = page.totalPages, + ) + } +} diff --git a/src/main/resources/db/migration/V3__fix_membership_fee_account_registration_columns.sql b/src/main/resources/db/migration/V3__fix_membership_fee_account_registration_columns.sql new file mode 100644 index 00000000..fb3f0089 --- /dev/null +++ b/src/main/resources/db/migration/V3__fix_membership_fee_account_registration_columns.sql @@ -0,0 +1,12 @@ +-- [회비] 등록 플로우 컬럼 보정 + +ALTER TABLE account + ADD COLUMN carry_over_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN registration_step VARCHAR(20) NOT NULL DEFAULT 'BASIC', + ADD COLUMN last_modified_by BIGINT NULL, + MODIFY COLUMN account_holder VARCHAR(30) NULL, + MODIFY COLUMN bank_guide VARCHAR(30) NULL; + +UPDATE account +SET registration_step = 'REVIEW' +WHERE status = 'ACTIVE'; diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt index c807ce48..d4ecbbdc 100644 --- a/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/ManageAccountUseCaseTest.kt @@ -2,7 +2,11 @@ package com.weeth.domain.account.application.usecase.command import com.weeth.domain.account.application.dto.request.AccountSaveRequest import com.weeth.domain.account.application.exception.AccountExistsException +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.account.domain.vo.Money import com.weeth.domain.cardinal.domain.repository.CardinalReader import com.weeth.domain.cardinal.fixture.CardinalTestFixture import com.weeth.domain.club.domain.repository.ClubReader @@ -10,6 +14,7 @@ import com.weeth.domain.club.domain.service.ClubPermissionPolicy import com.weeth.domain.club.fixture.ClubTestFixture import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk @@ -21,11 +26,17 @@ class ManageAccountUseCaseTest : val cardinalReader = mockk(relaxed = true) val clubReader = mockk(relaxed = true) val clubPermissionPolicy = mockk(relaxed = true) - val useCase = ManageAccountUseCase(accountRepository, cardinalReader, clubReader, clubPermissionPolicy) + val useCase = + ManageAccountUseCase( + accountRepository = accountRepository, + cardinalReader = cardinalReader, + clubReader = clubReader, + clubPermissionPolicy = clubPermissionPolicy, + ) val clubId = 1L val userId = 100L - val club = ClubTestFixture.createClub() + val club = ClubTestFixture.createClub(id = clubId) beforeTest { clearMocks(accountRepository, cardinalReader, clubReader, clubPermissionPolicy) @@ -57,4 +68,34 @@ class ManageAccountUseCaseTest : } } } + + describe("updateMemberVisibility") { + it("부원 거래 내역 공개 여부와 마지막 수정자를 저장한다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + account.activate() + every { accountRepository.findByIdWithLock(1L) } returns account + + useCase.updateMemberVisibility(clubId = clubId, accountId = 1L, visible = true, userId = userId) + + account.status shouldBe AccountStatus.ACTIVE + account.memberVisible shouldBe true + account.lastModifiedBy shouldBe userId + } + + it("다른 동아리 장부이면 AccountNotFoundException을 던지고 공개 상태를 바꾸지 않는다") { + val otherClub = ClubTestFixture.createClub(id = 2L, code = "OTHER-CLUB") + val account = Account.createDraft(club = otherClub, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + account.activate() + every { accountRepository.findByIdWithLock(1L) } returns account + + shouldThrow { + useCase.updateMemberVisibility(clubId = clubId, accountId = 1L, visible = true, userId = userId) + } + + account.memberVisible shouldBe false + account.lastModifiedBy shouldBe null + } + } }) 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 new file mode 100644 index 00000000..679e4a09 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/MembershipFeeRegistrationIntegrationTest.kt @@ -0,0 +1,590 @@ +package com.weeth.domain.account.application.usecase.command + +import com.ninjasquad.springmockk.MockkBean +import com.weeth.config.TestContainersConfig +import com.weeth.domain.account.application.dto.request.BankAccountRequest +import com.weeth.domain.account.application.dto.request.SaveAccountBankAccountRequest +import com.weeth.domain.account.application.dto.request.SaveAccountBasicRequest +import com.weeth.domain.account.application.dto.request.SaveAccountCarryOverRequest +import com.weeth.domain.account.application.dto.request.SavePaymentTargetsRequest +import com.weeth.domain.account.application.exception.AccountCarryOverAmountMismatchException +import com.weeth.domain.account.application.exception.AccountErrorCode +import com.weeth.domain.account.application.exception.AccountInvalidDraftStateException +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.exception.AccountPaymentTargetMemberInvalidException +import com.weeth.domain.account.application.exception.AccountPaymentTargetPaidException +import com.weeth.domain.account.application.exception.AccountRegistrationStepIncompleteException +import com.weeth.domain.account.application.usecase.query.GetAccountPaymentTargetQueryService +import com.weeth.domain.account.application.usecase.query.GetAccountRegistrationQueryService +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.AccountRegistrationStep +import com.weeth.domain.account.domain.enums.AccountStatus +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 +import com.weeth.domain.account.domain.repository.AccountTransactionRepository +import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import com.weeth.domain.club.application.exception.NotClubAdminException +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.repository.ClubRepository +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.repository.UserRepository +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles +import java.time.LocalDateTime + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class) +class MembershipFeeRegistrationIntegrationTest( + private val registerAccountUseCase: RegisterAccountUseCase, + private val registrationQueryService: GetAccountRegistrationQueryService, + private val paymentTargetQueryService: GetAccountPaymentTargetQueryService, + private val accountRepository: AccountRepository, + private val paymentTargetRepository: AccountPaymentTargetRepository, + private val transactionRepository: AccountTransactionRepository, + private val clubRepository: ClubRepository, + private val clubMemberRepository: ClubMemberRepository, + private val clubMemberCardinalRepository: ClubMemberCardinalRepository, + private val cardinalRepository: CardinalRepository, + private val userRepository: UserRepository, + @MockkBean private val fileAccessUrlPort: FileAccessUrlPort, +) : DescribeSpec() { + init { + beforeTest { + every { fileAccessUrlPort.resolve(any()) } answers { firstArg() } + } + + describe("회비 등록 플로우 통합 시나리오") { + it("S1. 이전 장부가 있는 상태에서 이월하기로 등록을 완료한다") { + val context = createContext("S1", previousBalance = 240_000) + val accountId = context.createDraft() + context.saveBasicAndTargets( + accountId, + targeted = context.memberIds(0, 1), + excluded = context.memberIds(2), + ) + + registrationQueryService + .findCarryOverSource(context.club.id, accountId, context.adminUser.id) + .let { + it.hasPreviousAccount shouldBe true + it.cardinalNumber shouldBe 3 + it.balance shouldBe 240_000 + } + + context.saveCarryOverAndBank(accountId, enabled = true, amount = 240_000) + registerAccountUseCase.completeRegistration(context.club.id, accountId, context.adminUser.id) + + val account = accountRepository.findById(accountId).orElseThrow() + val previous = accountRepository.findById(context.previousAccount!!.id).orElseThrow() + account.status shouldBe AccountStatus.ACTIVE + account.currentBalance shouldBe 240_000 + account.lastModifiedBy shouldBe context.adminUser.id + previous.currentBalance shouldBe 0 + transactions(accountId, AccountTransactionType.CARRY_OVER).single().let { + it.amount shouldBe 240_000 + it.isApplied shouldBe true + } + transactions(previous.id, AccountTransactionType.EXPENSE).single().let { + it.amount shouldBe 240_000 + it.title shouldBe "이월 잔액 전출" + } + paymentTargets(accountId, context.memberIds(0, 1)).forEach { + it.targetStatus shouldBe AccountTargetStatus.TARGETED + it.paymentStatus shouldBe AccountPaymentStatus.UNPAID + it.dueAmount shouldBe 30_000 + } + targetResponses(context, accountId).byMember(context.members[2].id).targetStatus shouldBe + AccountTargetStatus.EXCLUDED + } + + it("S2/S3. 이전 장부 없음 수동 이월과 이월하지 않기 자동 마감을 검증한다") { + val first = createContext("S2", previousBalance = null) + val firstAccountId = first.createDraft() + first.saveBasicAndTargets( + firstAccountId, + targeted = first.memberIds(0, 1), + excluded = first.memberIds(2), + ) + registrationQueryService.findCarryOverSource(first.club.id, firstAccountId, first.adminUser.id).let { + it.hasPreviousAccount shouldBe false + it.balance.shouldBeNull() + } + first.saveCarryOverAndBank(firstAccountId, enabled = true, amount = 100_000) + registerAccountUseCase.completeRegistration(first.club.id, firstAccountId, first.adminUser.id) + accountRepository.findById(firstAccountId).orElseThrow().currentBalance shouldBe 100_000 + transactions(firstAccountId, AccountTransactionType.CARRY_OVER).single().amount shouldBe 100_000 + transactions(firstAccountId, AccountTransactionType.EXPENSE).size shouldBe 0 + + val noCarry = createContext("S3", previousBalance = 240_000) + val noCarryAccountId = noCarry.createDraft() + noCarry.saveBasicAndTargets(noCarryAccountId, targeted = noCarry.memberIds(0), excluded = emptyList()) + noCarry.saveCarryOverAndBank(noCarryAccountId, enabled = false, amount = null) + registerAccountUseCase.completeRegistration(noCarry.club.id, noCarryAccountId, noCarry.adminUser.id) + accountRepository.findById(noCarryAccountId).orElseThrow().currentBalance shouldBe 0 + val previous = accountRepository.findById(noCarry.previousAccount!!.id).orElseThrow() + previous.currentBalance shouldBe 0 + transactions(previous.id, AccountTransactionType.EXPENSE).single().title shouldBe "미이월 잔액 정리" + transactions(noCarryAccountId, AccountTransactionType.CARRY_OVER).size shouldBe 0 + } + + it("S4. 작성 중인 초안은 이어서 작성 정보와 체크 상태를 복원한다") { + val context = createContext("S4", previousBalance = 240_000) + val accountId = context.createDraft() + context.saveBasicAndTargets( + accountId, + targeted = context.memberIds(0, 1), + excluded = context.memberIds(2), + ) + + val resumed = + registerAccountUseCase.createDraft( + context.club.id, + cardinal = 5, + userId = context.adminUser.id, + ) + resumed.isNew shouldBe false + resumed.accountId shouldBe accountId + resumed.lastModifiedByName shouldBe context.adminUser.name + + val status = registrationQueryService.findStatus(context.club.id, accountId, context.adminUser.id) + status.registrationStep shouldBe AccountRegistrationStep.CARRY_OVER + status.basic.shouldNotBeNull().name shouldBe "5기 정기 회비" + status.carryOver.shouldBeNull() + status.bankAccount.shouldBeNull() + status.paymentTargets.shouldNotBeNull().targetCount shouldBe 2 + status.paymentTargets.shouldNotBeNull().excludedCount shouldBe 2 + targetResponses(context, accountId).let { + it.byMember(context.members[0].id).targetStatus shouldBe AccountTargetStatus.TARGETED + it.byMember(context.members[2].id).targetStatus shouldBe AccountTargetStatus.EXCLUDED + } + } + + it("S5. 납부 대상이 있는 초안을 폐기하고 같은 기수를 새 초안으로 다시 생성한다") { + val context = createContext("S5", previousBalance = null) + val oldAccountId = context.createDraft() + context.saveBasicAndTargets(oldAccountId, targeted = context.memberIds(0, 1), excluded = emptyList()) + paymentTargetRepository.findAllByAccountId(oldAccountId).size shouldBe 2 + + registerAccountUseCase.discardDraft(context.club.id, oldAccountId, context.adminUser.id) + + accountRepository.findById(oldAccountId).isPresent shouldBe false + paymentTargetRepository.findAllByAccountId(oldAccountId).size shouldBe 0 + val newDraft = + registerAccountUseCase.createDraft( + context.club.id, + cardinal = 5, + userId = context.adminUser.id, + ) + newDraft.isNew shouldBe true + newDraft.accountId shouldNotBe oldAccountId + + context.saveBasicAndTargets(newDraft.accountId, targeted = context.memberIds(0), excluded = emptyList()) + context.saveCarryOverAndBank(newDraft.accountId, enabled = false, amount = null) + registerAccountUseCase.completeRegistration(context.club.id, newDraft.accountId, context.adminUser.id) + shouldThrow { + registerAccountUseCase.discardDraft(context.club.id, newDraft.accountId, context.adminUser.id) + }.errorCode.code shouldBe AccountErrorCode.ACCOUNT_INVALID_DRAFT_STATE.code + } + + it("S6/S10. 납부 대상 델타 재설정과 조회 카운트 정합성을 검증한다") { + val context = createContext("S6-S10", previousBalance = null, activeMemberCount = 18) + val accountId = context.createDraft() + registerAccountUseCase.saveBasic(context.club.id, accountId, basicRequest(), context.adminUser.id) + registerAccountUseCase.savePaymentTargets( + context.club.id, + accountId, + SavePaymentTargetsRequest(targetedClubMemberIds = context.members.take(12).map { it.id }), + context.adminUser.id, + ) + registerAccountUseCase.savePaymentTargets( + context.club.id, + accountId, + SavePaymentTargetsRequest( + targetedClubMemberIds = context.memberIds(12), + excludedClubMemberIds = context.memberIds(1), + ), + context.adminUser.id, + ) + + val all = findTargets(context, accountId, targetStatus = null) + all.summary.totalCount shouldBe 18 + all.summary.targetedCount shouldBe 12 + all.summary.excludedCount shouldBe 6 + findTargets( + context, + accountId, + targetStatus = AccountTargetStatus.TARGETED, + ).targets.totalElements shouldBe + 12 + findTargets( + context, + accountId, + targetStatus = AccountTargetStatus.EXCLUDED, + ).targets.totalElements shouldBe + 6 + all.byMember(context.members[0].id).targetStatus shouldBe AccountTargetStatus.TARGETED + all.byMember(context.members[1].id).targetStatus shouldBe AccountTargetStatus.EXCLUDED + all.byMember(context.members[11].id).targetStatus shouldBe AccountTargetStatus.TARGETED + all.byMember(context.members[12].id).targetStatus shouldBe AccountTargetStatus.TARGETED + findTargets(context, accountId, keyword = "회원01", targetStatus = null).targets.totalElements shouldBe 1 + registrationQueryService + .findStatus(context.club.id, accountId, context.adminUser.id) + .paymentTargets + .shouldNotBeNull() + .targetCount shouldBe 12 + + registerAccountUseCase.savePaymentTargets( + context.club.id, + accountId, + SavePaymentTargetsRequest(), + context.adminUser.id, + ) + findTargets(context, accountId, targetStatus = null).summary.targetedCount shouldBe 12 + } + + it("S7. 납부 대상 검증 실패는 기존 행 상태를 보존한다") { + val context = createContext("S7", previousBalance = null) + val accountId = context.createDraft() + context.saveBasicAndTargets(accountId, targeted = context.memberIds(0, 1), excluded = emptyList()) + val other = createContext("S7-OTHER", previousBalance = null) + + shouldThrow { + registerAccountUseCase.savePaymentTargets( + context.club.id, + accountId, + SavePaymentTargetsRequest(targetedClubMemberIds = listOf(other.members[0].id)), + context.adminUser.id, + ) + }.errorCode.code shouldBe AccountErrorCode.ACCOUNT_PAYMENT_TARGET_MEMBER_INVALID.code + shouldThrow { + registerAccountUseCase.savePaymentTargets( + context.club.id, + accountId, + SavePaymentTargetsRequest( + targetedClubMemberIds = context.memberIds(2), + excludedClubMemberIds = context.memberIds(2), + ), + context.adminUser.id, + ) + }.errorCode.code shouldBe AccountErrorCode.ACCOUNT_PAYMENT_TARGET_MEMBER_INVALID.code + + val paidTarget = paymentTargets(accountId, context.memberIds(0)).single() + paidTarget.markPaid(Money.of(30_000), confirmedBy = context.adminUser.id, paidAt = LocalDateTime.now()) + paymentTargetRepository.save(paidTarget) + shouldThrow { + registerAccountUseCase.savePaymentTargets( + context.club.id, + accountId, + SavePaymentTargetsRequest(excludedClubMemberIds = context.memberIds(0)), + context.adminUser.id, + ) + }.errorCode.code shouldBe AccountErrorCode.ACCOUNT_PAYMENT_TARGET_ALREADY_PAID.code + + paymentTargets(accountId, context.memberIds(0, 1)).let { + it.byMember(context.members[0].id).paymentStatus shouldBe AccountPaymentStatus.PAID + it.byMember(context.members[0].id).targetStatus shouldBe AccountTargetStatus.TARGETED + it.byMember(context.members[1].id).targetStatus shouldBe AccountTargetStatus.TARGETED + } + } + + it("S8. 작성 중 비활성화된 멤버는 조회에서 빠지고 완료 시 제외 처리된다") { + val context = createContext("S8", previousBalance = null) + val accountId = context.createDraft() + context.saveBasicAndTargets(accountId, targeted = context.memberIds(0, 1), excluded = emptyList()) + context.members[1].ban() + clubMemberRepository.save(context.members[1]) + + targetResponses(context, accountId).let { + it.summary.totalCount shouldBe 3 + it.summary.targetedCount shouldBe 1 + it.targets.content.map { row -> row.paymentTargetInfo.clubMemberId } shouldContainExactlyInAnyOrder + context.memberIds(0, 2, 3) + } + shouldThrow { + registerAccountUseCase.savePaymentTargets( + context.club.id, + accountId, + SavePaymentTargetsRequest(targetedClubMemberIds = context.memberIds(1)), + context.adminUser.id, + ) + } + + context.saveCarryOverAndBank(accountId, enabled = false, amount = null) + registerAccountUseCase.completeRegistration(context.club.id, accountId, context.adminUser.id) + paymentTargets(accountId, context.memberIds(1)).single().targetStatus shouldBe + AccountTargetStatus.EXCLUDED + } + + it("S9. 완료 가드와 이월 금액 불일치 복구를 검증한다") { + val incomplete = createContext("S9-INCOMPLETE", previousBalance = 240_000) + val incompleteAccountId = incomplete.createDraft() + registerAccountUseCase.saveBasic( + incomplete.club.id, + incompleteAccountId, + basicRequest(), + incomplete.adminUser.id, + ) + shouldThrow { + registerAccountUseCase.completeRegistration( + incomplete.club.id, + incompleteAccountId, + incomplete.adminUser.id, + ) + }.errorCode.code shouldBe AccountErrorCode.ACCOUNT_REGISTRATION_STEP_INCOMPLETE.code + accountRepository.findById(incomplete.previousAccount!!.id).orElseThrow().currentBalance shouldBe + 240_000 + + val mismatch = createContext("S9-MISMATCH", previousBalance = 240_000) + val accountId = mismatch.createDraft() + mismatch.saveBasicAndTargets(accountId, targeted = mismatch.memberIds(0), excluded = emptyList()) + mismatch.saveCarryOverAndBank(accountId, enabled = true, amount = 240_000) + spendPreviousAccount(mismatch.previousAccount!!.id, amount = 40_000) + + shouldThrow { + registerAccountUseCase.completeRegistration(mismatch.club.id, accountId, mismatch.adminUser.id) + }.errorCode.code shouldBe AccountErrorCode.ACCOUNT_CARRY_OVER_AMOUNT_MISMATCH.code + accountRepository.findById(accountId).orElseThrow().status shouldBe AccountStatus.DRAFT + transactions(accountId, AccountTransactionType.CARRY_OVER).size shouldBe 0 + + registrationQueryService + .findCarryOverSource( + mismatch.club.id, + accountId, + mismatch.adminUser.id, + ).balance shouldBe + 200_000 + registerAccountUseCase.saveCarryOver( + mismatch.club.id, + accountId, + SaveAccountCarryOverRequest(enabled = true, amount = 200_000, memo = "수정 이월"), + mismatch.adminUser.id, + ) + registerAccountUseCase.completeRegistration(mismatch.club.id, accountId, mismatch.adminUser.id) + accountRepository.findById(accountId).orElseThrow().currentBalance shouldBe 200_000 + shouldThrow { + registerAccountUseCase.completeRegistration(mismatch.club.id, accountId, mismatch.adminUser.id) + }.errorCode.code shouldBe AccountErrorCode.ACCOUNT_INVALID_DRAFT_STATE.code + } + + it("S11. 권한과 장부 소속 경계를 검증한다") { + val context = createContext("S11", previousBalance = null) + val accountId = context.createDraft() + + shouldThrow { + registerAccountUseCase.saveBasic( + context.club.id, + accountId, + basicRequest(), + context.members[0].user.id, + ) + } + val other = createContext("S11-OTHER", previousBalance = null) + shouldThrow { + registerAccountUseCase.saveBasic(other.club.id, accountId, basicRequest(), other.adminUser.id) + }.errorCode.code shouldBe AccountErrorCode.ACCOUNT_NOT_FOUND.code + shouldThrow { + registerAccountUseCase.saveBasic( + context.club.id, + Long.MAX_VALUE, + basicRequest(), + context.adminUser.id, + ) + }.errorCode.code shouldBe AccountErrorCode.ACCOUNT_NOT_FOUND.code + } + } + } + + fun createContext( + key: String, + previousBalance: Int?, + activeMemberCount: Int = 4, + ): RegistrationContext { + val suffix = "$key-${System.nanoTime()}" + val club = + clubRepository.save( + ClubTestFixture.createClub( + name = "회비 통합 테스트 $suffix", + code = "FEE-$suffix", + ), + ) + val adminUser = saveUser("관리자-$suffix", "admin-$suffix@test.com") + clubMemberRepository.save(ClubMember(club, adminUser, MemberStatus.ACTIVE, MemberRole.ADMIN)) + val members = + (1..activeMemberCount).map { + val user = saveUser("회원%02d-$key".format(it), "member-$suffix-$it@test.com") + clubMemberRepository.save(ClubMember(club, user, MemberStatus.ACTIVE, MemberRole.USER)) + } + val previousCardinal = cardinalRepository.save(Cardinal.create(club, cardinalNumber = 3)) + val currentCardinal = cardinalRepository.save(Cardinal.create(club, cardinalNumber = 5)) + members.forEach { + clubMemberCardinalRepository.save(ClubMemberCardinal.create(it, currentCardinal)) + } + val previousAccount = + previousBalance?.let { + accountRepository.save( + Account( + club = club, + totalAmount = it, + currentAmount = it, + currentBalance = it, + cardinal = previousCardinal.cardinalNumber, + name = "3기 회비", + duesAmount = 30_000, + status = AccountStatus.ACTIVE, + ), + ) + } + return RegistrationContext(club, adminUser, members, previousAccount) + } + + fun saveUser( + name: String, + email: String, + ): User = userRepository.save(User.create(name = name, email = email, status = Status.ACTIVE)) + + fun RegistrationContext.createDraft(): Long = + registerAccountUseCase + .createDraft(club.id, cardinal = 5, userId = adminUser.id) + .also { it.isNew shouldBe true } + .accountId + + fun RegistrationContext.saveBasicAndTargets( + accountId: Long, + targeted: List, + excluded: List, + ) { + registerAccountUseCase.saveBasic(club.id, accountId, basicRequest(), adminUser.id) + accountRepository.findById(accountId).orElseThrow().registrationStep shouldBe + AccountRegistrationStep.PAYMENT_TARGETS + registerAccountUseCase.savePaymentTargets( + club.id, + accountId, + SavePaymentTargetsRequest(targetedClubMemberIds = targeted, excludedClubMemberIds = excluded), + adminUser.id, + ) + accountRepository.findById(accountId).orElseThrow().registrationStep shouldBe AccountRegistrationStep.CARRY_OVER + } + + fun RegistrationContext.saveCarryOverAndBank( + accountId: Long, + enabled: Boolean, + amount: Int?, + ) { + registerAccountUseCase.saveCarryOver( + club.id, + accountId, + SaveAccountCarryOverRequest(enabled = enabled, amount = amount, memo = amount?.let { "3기 잔액" }), + adminUser.id, + ) + accountRepository.findById(accountId).orElseThrow().registrationStep shouldBe + AccountRegistrationStep.BANK_ACCOUNT + registerAccountUseCase.saveBankAccount( + club.id, + accountId, + SaveAccountBankAccountRequest( + bankAccountVisible = true, + bankAccount = BankAccountRequest("국민은행", "123-456-789", "가천대 검도부"), + ), + adminUser.id, + ) + accountRepository.findById(accountId).orElseThrow().registrationStep shouldBe AccountRegistrationStep.REVIEW + } + + fun RegistrationContext.memberIds(vararg indices: Int): List = indices.map { members[it].id } + + fun basicRequest(): SaveAccountBasicRequest = + SaveAccountBasicRequest(name = "5기 정기 회비", duesAmount = 30_000, description = "운영비") + + fun paymentTargets( + accountId: Long, + clubMemberIds: List, + ): List = + paymentTargetRepository.findAllByAccountIdAndClubMemberIdIn(accountId, clubMemberIds) + + fun List.byMember(clubMemberId: Long): AccountPaymentTarget = + first { it.clubMember.id == clubMemberId } + + fun targetResponses( + context: RegistrationContext, + accountId: Long, + ) = findTargets(context, accountId, keyword = null, targetStatus = null) + + fun findTargets( + context: RegistrationContext, + accountId: Long, + keyword: String? = null, + targetStatus: AccountTargetStatus?, + ) = paymentTargetQueryService.findTargets( + context.club.id, + accountId, + context.adminUser.id, + page = 0, + size = 100, + keyword = keyword, + targetStatus = targetStatus, + ) + + fun com.weeth.domain.account.application.dto.response.AccountPaymentTargetsResponse.byMember(clubMemberId: Long) = + targets.content.first { it.paymentTargetInfo.clubMemberId == clubMemberId } + + fun transactions( + accountId: Long, + type: AccountTransactionType, + ): List = + transactionRepository.findAll().filter { + it.account.id == accountId && + it.type == type + } + + fun spendPreviousAccount( + previousAccountId: Long, + amount: Int, + ) { + val previous = accountRepository.findById(previousAccountId).orElseThrow() + val expense = + AccountTransaction.create( + account = previous, + type = AccountTransactionType.EXPENSE, + title = "완료 직전 지출", + source = null, + amount = Money.of(amount), + transactedAt = LocalDateTime.now(), + ) + previous.applyTransaction(expense) + transactionRepository.save(expense) + accountRepository.save(previous) + } + + data class RegistrationContext( + val club: Club, + val adminUser: User, + val members: List, + val previousAccount: Account?, + ) +} 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 new file mode 100644 index 00000000..fba043b5 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/command/RegisterAccountUseCaseTest.kt @@ -0,0 +1,681 @@ +package com.weeth.domain.account.application.usecase.command + +import com.weeth.domain.account.application.dto.request.BankAccountRequest +import com.weeth.domain.account.application.dto.request.SaveAccountBankAccountRequest +import com.weeth.domain.account.application.dto.request.SaveAccountBasicRequest +import com.weeth.domain.account.application.dto.request.SaveAccountCarryOverRequest +import com.weeth.domain.account.application.dto.request.SavePaymentTargetsRequest +import com.weeth.domain.account.application.exception.AccountCarryOverAmountMismatchException +import com.weeth.domain.account.application.exception.AccountExistsException +import com.weeth.domain.account.application.exception.AccountInvalidDraftStateException +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.exception.AccountPaymentTargetMemberInvalidException +import com.weeth.domain.account.application.exception.AccountPaymentTargetPaidException +import com.weeth.domain.account.application.exception.AccountRegistrationStepIncompleteException +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.AccountRegistrationStep +import com.weeth.domain.account.domain.enums.AccountStatus +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 +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.cardinal.domain.repository.CardinalReader +import com.weeth.domain.cardinal.fixture.CardinalTestFixture +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader +import com.weeth.domain.club.domain.repository.ClubReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +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.domain.repository.UserReader +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.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import java.time.LocalDateTime + +class RegisterAccountUseCaseTest : + DescribeSpec({ + val accountRepository = mockk(relaxed = true) + val paymentTargetRepository = mockk(relaxed = true) + val transactionRepository = mockk(relaxed = true) + val cardinalReader = mockk(relaxed = true) + val clubReader = mockk(relaxed = true) + val clubMemberCardinalReader = mockk() + val clubPermissionPolicy = mockk(relaxed = true) + val userReader = mockk() + val useCase = + RegisterAccountUseCase( + accountRepository = accountRepository, + paymentTargetRepository = paymentTargetRepository, + transactionRepository = transactionRepository, + cardinalReader = cardinalReader, + clubReader = clubReader, + clubMemberCardinalReader = clubMemberCardinalReader, + clubPermissionPolicy = clubPermissionPolicy, + userReader = userReader, + ) + + val clubId = 1L + val accountId = 10L + val userId = 100L + val club = ClubTestFixture.createClub(id = clubId) + + beforeTest { + clearMocks( + accountRepository, + paymentTargetRepository, + transactionRepository, + cardinalReader, + clubReader, + clubMemberCardinalReader, + clubPermissionPolicy, + userReader, + ) + every { clubReader.getClubById(clubId) } returns club + } + + describe("createDraft") { + it("같은 기수에 작성 중인 초안이 있으면 기존 초안 정보와 마지막 수정자 이름을 반환한다") { + val draft = Account.createDraft(club = club, cardinal = 5) + draft.markModifiedBy(200L) + every { userReader.findByIdOrNull(200L) } returns UserTestFixture.createActiveUser1(id = 200L) + every { accountRepository.findByClubIdAndCardinal(clubId, 5) } returns draft + + val result = useCase.createDraft(clubId, cardinal = 5, userId = userId) + + result.accountId shouldBe draft.id + result.isNew shouldBe false + result.lastModifiedByName shouldBe "적순" + verify(exactly = 0) { accountRepository.save(any()) } + } + + it("같은 기수에 활성 장부가 있으면 AccountExistsException을 던진다") { + val active = Account.createDraft(club = club, cardinal = 5) + active.updateBasicInfo("5기 회비", Money.of(50_000), "정기 회비") + active.activate() + every { accountRepository.findByClubIdAndCardinal(clubId, 5) } returns active + + shouldThrow { useCase.createDraft(clubId, cardinal = 5, userId = userId) } + } + + it("기존 장부가 없으면 DRAFT 장부를 저장한다") { + every { accountRepository.findByClubIdAndCardinal(clubId, 5) } returns null + every { cardinalReader.findByClubIdAndCardinalNumber(clubId, 5) } returns + CardinalTestFixture.createCardinal(cardinalNumber = 5) + every { accountRepository.save(any()) } answers { firstArg() } + + val result = useCase.createDraft(clubId, cardinal = 5, userId = userId) + + result.isNew shouldBe true + result.lastModifiedByName shouldBe null + verify(exactly = 1) { + accountRepository.save( + match { + it.status == AccountStatus.DRAFT && + it.cardinal == 5 && + it.lastModifiedBy == userId + }, + ) + } + } + } + + describe("discardDraft") { + it("초안 상태의 장부를 납부 대상 행과 함께 삭제한다") { + val draft = Account.createDraft(club = club, cardinal = 5) + every { accountRepository.findByIdWithLock(1L) } returns draft + + useCase.discardDraft(clubId = clubId, accountId = 1L, userId = userId) + + verify(exactly = 1) { paymentTargetRepository.deleteAllByAccountId(draft.id) } + verify(exactly = 1) { accountRepository.delete(draft) } + } + + it("활성 장부는 삭제하지 않는다") { + val active = Account.createDraft(club = club, cardinal = 5) + active.updateBasicInfo("5기 회비", Money.of(50_000), null) + active.activate() + every { accountRepository.findByIdWithLock(1L) } returns active + + shouldThrow { + useCase.discardDraft(clubId = clubId, accountId = 1L, userId = userId) + } + verify(exactly = 0) { accountRepository.delete(any()) } + } + } + + describe("saveBasic") { + it("잠금 조회한 장부의 기본 정보와 마지막 수정자를 저장한다") { + val account = Account.createDraft(club = club, cardinal = 5) + every { accountRepository.findByIdWithLock(1L) } returns account + + useCase.saveBasic( + clubId = clubId, + accountId = 1L, + request = SaveAccountBasicRequest(name = "5기 정기 회비", duesAmount = 30_000, description = "운영비"), + userId = userId, + ) + + account.name shouldBe "5기 정기 회비" + account.duesAmount shouldBe 30_000 + account.description shouldBe "운영비" + account.lastModifiedBy shouldBe userId + account.registrationStep shouldBe AccountRegistrationStep.PAYMENT_TARGETS + } + + it("장부가 없으면 AccountNotFoundException을 던진다") { + every { accountRepository.findByIdWithLock(404L) } returns null + + shouldThrow { + useCase.saveBasic( + clubId = clubId, + accountId = 404L, + request = SaveAccountBasicRequest(name = "5기 정기 회비", duesAmount = 30_000, description = "운영비"), + userId = userId, + ) + } + } + + it("다른 동아리 장부이면 AccountNotFoundException을 던지고 수정하지 않는다") { + val otherClub = ClubTestFixture.createClub(id = 2L, code = "OTHER-CLUB") + val account = Account.createDraft(club = otherClub, cardinal = 5) + every { accountRepository.findByIdWithLock(1L) } returns account + + shouldThrow { + useCase.saveBasic( + clubId = clubId, + accountId = 1L, + request = SaveAccountBasicRequest(name = "5기 정기 회비", duesAmount = 30_000, description = "운영비"), + userId = userId, + ) + } + + account.name shouldBe null + account.lastModifiedBy shouldBe null + } + } + + describe("savePaymentTargets") { + // 해당 기수 명부를 ClubMemberCardinal 목록으로 반환하도록 스텁한다. + fun stubRoster(vararg members: ClubMember) { + val cardinal = CardinalTestFixture.createCardinal(club = club, cardinalNumber = 5) + every { + clubMemberCardinalReader.findAllByClubIdAndCardinalNumber(clubId, 5, MemberStatus.ACTIVE) + } returns members.map { ClubMemberCardinalTestFixture.create(it, cardinal) } + } + + it("초기 등록 - 기존 행이 없으면 선택 멤버를 신규 납부 대상으로 생성하고 다음 단계로 이동한다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + val member1 = ClubMemberTestFixture.createActiveMember(id = 1L, club = club) + val member2 = ClubMemberTestFixture.createActiveMember(id = 2L, club = club) + val saveAllSlot = slot>() + + every { accountRepository.findByIdWithLock(accountId) } returns account + stubRoster(member1, member2) + every { + paymentTargetRepository.findAllByAccountIdAndClubMemberIdIn(accountId, any()) + } returns emptyList() + every { paymentTargetRepository.saveAll(capture(saveAllSlot)) } answers + { firstArg>().toList() } + + useCase.savePaymentTargets( + clubId = clubId, + accountId = accountId, + request = SavePaymentTargetsRequest(targetedClubMemberIds = listOf(2L, 1L)), + userId = userId, + ) + + saveAllSlot.captured.map { it.clubMember.id } shouldContainExactly listOf(1L, 2L) + saveAllSlot.captured.map { it.dueAmount } shouldContainExactly listOf(30_000, 30_000) + account.registrationStep shouldBe AccountRegistrationStep.CARRY_OVER + account.lastModifiedBy shouldBe userId + } + + it("재설정 - 대상 목록 멤버는 납부 대상으로, 제외 목록의 기존 멤버는 제외 대상으로 갱신한다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + val member1 = ClubMemberTestFixture.createActiveMember(id = 1L, club = club) + val member2 = ClubMemberTestFixture.createActiveMember(id = 2L, club = club) + val member3 = ClubMemberTestFixture.createActiveMember(id = 3L, club = club) + val existingTarget = AccountPaymentTarget.createTargeted(account, member1, Money.of(30_000)) + existingTarget.markPaid( + Money.of(30_000), + confirmedBy = userId, + paidAt = LocalDateTime.of(2026, 3, 1, 10, 0), + ) + val existingTargeted2 = AccountPaymentTarget.createTargeted(account, member2, Money.of(30_000)) + val saveAllSlot = slot>() + + every { accountRepository.findByIdWithLock(accountId) } returns account + stubRoster(member1, member2, member3) + every { + paymentTargetRepository.findAllByAccountIdAndClubMemberIdIn(accountId, listOf(1L, 3L, 2L)) + } returns listOf(existingTarget, existingTargeted2) + every { paymentTargetRepository.saveAll(capture(saveAllSlot)) } answers + { firstArg>().toList() } + + useCase.savePaymentTargets( + clubId = clubId, + accountId = accountId, + request = + SavePaymentTargetsRequest( + targetedClubMemberIds = listOf(3L, 1L), + excludedClubMemberIds = listOf(2L), + ), + userId = userId, + ) + + existingTarget.targetStatus shouldBe AccountTargetStatus.TARGETED + existingTarget.paymentStatus shouldBe AccountPaymentStatus.PAID + existingTargeted2.targetStatus shouldBe AccountTargetStatus.EXCLUDED + saveAllSlot.captured.map { it.clubMember.id } shouldContainExactly listOf(3L) + saveAllSlot.captured.first().dueAmount shouldBe 30_000 + account.lastModifiedBy shouldBe userId + verify(exactly = 1) { clubPermissionPolicy.requireAdmin(clubId, userId) } + } + + it("델타 - 두 목록에 모두 없는 멤버의 기존 행은 조회하지도 갱신하지도 않는다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + val member1 = ClubMemberTestFixture.createActiveMember(id = 1L, club = club) + val saveAllSlot = slot>() + + every { accountRepository.findByIdWithLock(accountId) } returns account + stubRoster(member1) + every { + paymentTargetRepository.findAllByAccountIdAndClubMemberIdIn(accountId, listOf(1L)) + } returns emptyList() + every { paymentTargetRepository.saveAll(capture(saveAllSlot)) } answers + { firstArg>().toList() } + + useCase.savePaymentTargets( + clubId = clubId, + accountId = accountId, + request = SavePaymentTargetsRequest(targetedClubMemberIds = listOf(1L)), + userId = userId, + ) + + saveAllSlot.captured.map { it.clubMember.id } shouldContainExactly listOf(1L) + verify(exactly = 1) { + paymentTargetRepository.findAllByAccountIdAndClubMemberIdIn(accountId, listOf(1L)) + } + verify(exactly = 0) { paymentTargetRepository.findAllByAccountId(any()) } + } + + it("빈 요청이면 대상 갱신 없이 다음 단계로만 이동한다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + + every { accountRepository.findByIdWithLock(accountId) } returns account + + useCase.savePaymentTargets( + clubId = clubId, + accountId = accountId, + request = SavePaymentTargetsRequest(), + userId = userId, + ) + + account.registrationStep shouldBe AccountRegistrationStep.CARRY_OVER + account.lastModifiedBy shouldBe userId + verify(exactly = 0) { paymentTargetRepository.findAllByAccountIdAndClubMemberIdIn(any(), any()) } + verify(exactly = 0) { paymentTargetRepository.saveAll(any>()) } + } + + it("납부 완료된 기존 대상은 제외할 수 없다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + val member = ClubMemberTestFixture.createActiveMember(id = 1L, club = club) + val existingTarget = AccountPaymentTarget.createTargeted(account, member, Money.of(30_000)) + existingTarget.markPaid( + Money.of(30_000), + confirmedBy = userId, + paidAt = LocalDateTime.of(2026, 3, 1, 10, 0), + ) + + every { accountRepository.findByIdWithLock(accountId) } returns account + stubRoster(member) + every { + paymentTargetRepository.findAllByAccountIdAndClubMemberIdIn(accountId, listOf(1L)) + } returns listOf(existingTarget) + + shouldThrow { + useCase.savePaymentTargets( + clubId = clubId, + accountId = accountId, + request = SavePaymentTargetsRequest(excludedClubMemberIds = listOf(1L)), + userId = userId, + ) + } + } + + it("기수 명부에 없는 멤버가 포함되면 AccountPaymentTargetMemberInvalidException을 던진다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + val member = ClubMemberTestFixture.createActiveMember(id = 1L, club = club) + + every { accountRepository.findByIdWithLock(accountId) } returns account + stubRoster(member) + + shouldThrow { + useCase.savePaymentTargets( + clubId = clubId, + accountId = accountId, + request = SavePaymentTargetsRequest(targetedClubMemberIds = listOf(1L, 2L)), + userId = userId, + ) + } + } + + it("같은 멤버가 대상/제외 목록에 동시에 포함되면 AccountPaymentTargetMemberInvalidException을 던진다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + + every { accountRepository.findByIdWithLock(accountId) } returns account + + shouldThrow { + useCase.savePaymentTargets( + clubId = clubId, + accountId = accountId, + request = + SavePaymentTargetsRequest( + targetedClubMemberIds = listOf(1L, 2L), + excludedClubMemberIds = listOf(2L), + ), + userId = userId, + ) + } + } + } + + describe("saveCarryOver") { + it("이월 설정과 마지막 수정자를 저장한다") { + val account = Account.createDraft(club = club, cardinal = 5) + every { accountRepository.findByIdWithLock(1L) } returns account + + useCase.saveCarryOver( + clubId = clubId, + accountId = 1L, + request = SaveAccountCarryOverRequest(enabled = true, amount = 152_129, memo = "4기 잔액"), + userId = userId, + ) + + account.carryOverAmount shouldBe 152_129 + account.carryOverMemo shouldBe "4기 잔액" + account.lastModifiedBy shouldBe userId + } + } + + describe("saveBankAccount") { + it("계좌 설정과 마지막 수정자를 저장한다") { + val account = Account.createDraft(club = club, cardinal = 5) + every { accountRepository.findByIdWithLock(1L) } returns account + + useCase.saveBankAccount( + clubId = clubId, + accountId = 1L, + request = + SaveAccountBankAccountRequest( + bankAccountVisible = true, + bankAccount = + BankAccountRequest( + bankName = "국민은행", + accountNumber = "12-12412-1231", + holder = "가천대 검도부", + ), + ), + userId = userId, + ) + + account.bankAccount?.bankName shouldBe "국민은행" + account.bankAccountVisible shouldBe true + account.lastModifiedBy shouldBe userId + } + } + + describe("completeRegistration") { + it("초안 작성 중 탈퇴/퇴출된 멤버의 미납 대상 행은 제외 처리하고 활성화한다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + account.updateBankAccount(bankAccount = null, visible = false) + val bannedMember = ClubMemberTestFixture.createBannedMember(id = 1L, club = club) + val ghostTarget = AccountPaymentTarget.createTargeted(account, bannedMember, Money.of(30_000)) + every { accountRepository.findByIdWithLock(1L) } returns account + every { + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId = clubId, + cardinal = 5, + status = AccountStatus.ACTIVE, + ) + } returns null + every { paymentTargetRepository.findAllUnpaidTargetsWithInactiveClubMemberByAccountId(1L) } returns + listOf(ghostTarget) + + useCase.completeRegistration(clubId = clubId, accountId = 1L, userId = userId) + + account.status shouldBe AccountStatus.ACTIVE + ghostTarget.targetStatus shouldBe AccountTargetStatus.EXCLUDED + } + + it("모든 단계를 저장하지 않은 초안은 완료할 수 없다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + + every { accountRepository.findByIdWithLock(1L) } returns account + + shouldThrow { + useCase.completeRegistration(clubId = clubId, accountId = 1L, userId = userId) + } + account.status shouldBe AccountStatus.DRAFT + } + + it("이미 활성화된 장부에 완료를 재요청하면 AccountInvalidDraftStateException을 던진다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + account.updateBankAccount(bankAccount = null, visible = false) + account.activate() + + every { accountRepository.findByIdWithLock(1L) } returns account + + shouldThrow { + useCase.completeRegistration(clubId = clubId, accountId = 1L, userId = userId) + } + } + + it("이월 금액이 완료 시점의 이전 장부 잔액과 다르면 AccountCarryOverAmountMismatchException을 던진다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + account.updateCarryOver(enabled = true, amount = Money.of(240_000), memo = "3기 잔액") + account.updateBankAccount(bankAccount = null, visible = false) + val previousAccount = + AccountTestFixture.createAccount( + id = 9L, + club = club, + cardinal = 3, + currentAmount = 200_000, + currentBalance = 200_000, + ) + + every { accountRepository.findByIdWithLock(1L) } returns account + every { + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId = clubId, + cardinal = 5, + status = AccountStatus.ACTIVE, + ) + } returns previousAccount + every { accountRepository.findByIdWithLock(9L) } returns previousAccount + + shouldThrow { + useCase.completeRegistration(clubId = clubId, accountId = 1L, userId = userId) + } + account.status shouldBe AccountStatus.DRAFT + previousAccount.currentBalance shouldBe 200_000 + verify(exactly = 0) { transactionRepository.save(any()) } + } + + it("이월하기로 완료하면 신규 장부에 이월 수입을 기록하고 이전 장부 잔액을 전출 지출로 정리한다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + account.updateCarryOver(enabled = true, amount = Money.of(240_000), memo = "3기 잔액") + account.updateBankAccount(bankAccount = null, visible = false) + val previousAccount = + AccountTestFixture.createAccount( + id = 9L, + club = club, + cardinal = 3, + currentAmount = 240_000, + currentBalance = 240_000, + ) + + every { accountRepository.findByIdWithLock(1L) } returns account + every { paymentTargetRepository.findAllUnpaidTargetsWithInactiveClubMemberByAccountId(1L) } returns + emptyList() + every { + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId = clubId, + cardinal = 5, + status = AccountStatus.ACTIVE, + ) + } returns previousAccount + every { accountRepository.findByIdWithLock(9L) } returns previousAccount + every { transactionRepository.save(any()) } answers { firstArg() } + + useCase.completeRegistration(clubId = clubId, accountId = 1L, userId = userId) + + account.status shouldBe AccountStatus.ACTIVE + account.currentBalance shouldBe 240_000 + previousAccount.currentBalance shouldBe 0 + verify(exactly = 1) { + transactionRepository.save( + match { + it.type == AccountTransactionType.CARRY_OVER && + it.amount == 240_000 && + it.account === account + }, + ) + } + verify(exactly = 1) { + transactionRepository.save( + match { + it.type == AccountTransactionType.EXPENSE && + it.amount == 240_000 && + it.account === previousAccount + }, + ) + } + } + + it("이월하지 않기로 완료하면 이전 기수 장부의 잔액을 지출 거래로 자동 정리한다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + account.updateCarryOver(enabled = false, amount = null, memo = null) + account.updateBankAccount(bankAccount = null, visible = false) + val previousAccount = + AccountTestFixture.createAccount( + id = 9L, + club = club, + cardinal = 3, + currentAmount = 240_000, + currentBalance = 240_000, + ) + + every { accountRepository.findByIdWithLock(1L) } returns account + every { paymentTargetRepository.findAllUnpaidTargetsWithInactiveClubMemberByAccountId(1L) } returns + emptyList() + every { + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId = clubId, + cardinal = 5, + status = AccountStatus.ACTIVE, + ) + } returns previousAccount + every { accountRepository.findByIdWithLock(9L) } returns previousAccount + every { transactionRepository.save(any()) } answers { firstArg() } + + useCase.completeRegistration(clubId = clubId, accountId = 1L, userId = userId) + + account.status shouldBe AccountStatus.ACTIVE + previousAccount.currentBalance shouldBe 0 + verify(exactly = 1) { + transactionRepository.save( + match { + it.type == AccountTransactionType.EXPENSE && + it.amount == 240_000 && + it.account === previousAccount + }, + ) + } + } + + it("이월하지 않기여도 이전 기수 장부가 없으면 지출 거래를 만들지 않는다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + account.updateCarryOver(enabled = false, amount = null, memo = null) + account.updateBankAccount(bankAccount = null, visible = false) + + every { accountRepository.findByIdWithLock(1L) } returns account + every { paymentTargetRepository.findAllUnpaidTargetsWithInactiveClubMemberByAccountId(1L) } returns + emptyList() + every { + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId = clubId, + cardinal = 5, + status = AccountStatus.ACTIVE, + ) + } returns null + + useCase.completeRegistration(clubId = clubId, accountId = 1L, userId = userId) + + account.status shouldBe AccountStatus.ACTIVE + verify(exactly = 0) { transactionRepository.save(any()) } + } + + it("초안 장부를 활성화하고 이월액이 있으면 CARRY_OVER 거래로 잔액에 반영한다") { + val account = Account.createDraft(club = club, cardinal = 5) + account.updateBasicInfo("5기 회비", Money.of(30_000), "운영비") + account.updateCarryOver(enabled = true, amount = Money.of(152_129), memo = "4기 잔액") + account.updateBankAccount(bankAccount = null, visible = false) + every { accountRepository.findByIdWithLock(1L) } returns account + every { + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId = clubId, + cardinal = 5, + status = AccountStatus.ACTIVE, + ) + } returns null + every { paymentTargetRepository.findAllUnpaidTargetsWithInactiveClubMemberByAccountId(1L) } returns + emptyList() + every { transactionRepository.save(any()) } answers { firstArg() } + + useCase.completeRegistration(clubId = clubId, accountId = 1L, userId = userId) + + account.status shouldBe AccountStatus.ACTIVE + account.currentBalance shouldBe 152_129 + account.lastModifiedBy shouldBe userId + verify(exactly = 1) { + transactionRepository.save( + match { + it.type == AccountTransactionType.CARRY_OVER && + it.amount == 152_129 && + it.isApplied + }, + ) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountPaymentTargetQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountPaymentTargetQueryServiceTest.kt new file mode 100644 index 00000000..2b5db054 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountPaymentTargetQueryServiceTest.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.AccountPaymentTargetMapper +import com.weeth.domain.account.domain.entity.AccountPaymentTarget +import com.weeth.domain.account.domain.enums.AccountPaymentStatus +import com.weeth.domain.account.domain.enums.AccountTargetStatus +import com.weeth.domain.account.domain.repository.AccountPaymentTargetRepository +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.account.fixture.AccountTestFixture +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.file.domain.port.FileAccessUrlPort +import io.kotest.assertions.throwables.shouldThrow +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 org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import java.time.LocalDateTime +import java.util.Optional + +class GetAccountPaymentTargetQueryServiceTest : + DescribeSpec({ + val accountRepository = mockk() + val paymentTargetRepository = mockk() + val clubMemberReader = mockk() + val clubPermissionPolicy = mockk(relaxed = true) + val fileAccessUrlPort = mockk() + val accountPaymentTargetMapper = AccountPaymentTargetMapper(fileAccessUrlPort) + val service = + GetAccountPaymentTargetQueryService( + accountRepository = accountRepository, + paymentTargetRepository = paymentTargetRepository, + clubMemberReader = clubMemberReader, + clubPermissionPolicy = clubPermissionPolicy, + accountPaymentTargetMapper = accountPaymentTargetMapper, + ) + + beforeTest { + clearMocks( + accountRepository, + paymentTargetRepository, + clubMemberReader, + clubPermissionPolicy, + fileAccessUrlPort, + ) + } + + describe("findTargets") { + it("전체 후보 멤버를 페이지로 조회하고 저장된 납부 대상 상태를 합친다") { + val clubId = 1L + val accountId = 10L + val userId = 100L + val club = ClubTestFixture.createClub(id = clubId) + val account = AccountTestFixture.createAccount(id = accountId, club = club) + val targetedMember = ClubMemberTestFixture.createActiveMember(id = 20L, club = club) + val excludedMember = ClubMemberTestFixture.createActiveMember(id = 21L, club = club) + val target = AccountPaymentTarget.createTargeted(account, targetedMember, Money.of(30_000)) + target.markPaid(Money.of(30_000), confirmedBy = userId, paidAt = LocalDateTime.of(2026, 3, 1, 10, 0)) + 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.findActiveByClubIdAndCardinalNumberAndKeyword( + clubId = clubId, + cardinalNumber = 40, + keyword = "김", + pageable = pageable, + ) + } returns PageImpl(listOf(targetedMember, excludedMember), pageable, 18) + every { + paymentTargetRepository.findAllByAccountIdAndClubMemberIdIn( + accountId, + listOf(20L, 21L), + ) + } returns + listOf(target) + + val result = + service.findTargets( + clubId = clubId, + accountId = accountId, + userId = userId, + page = 0, + size = 10, + keyword = "김", + targetStatus = null, + ) + + result.summary.totalCount shouldBe 18 + result.summary.targetedCount shouldBe 12 + result.summary.excludedCount shouldBe 6 + result.targets.pageNumber shouldBe 0 + result.targets.pageSize shouldBe 10 + result.targets.totalElements shouldBe 18 + result.targets.content.size shouldBe 2 + result.targets.content[0] + .paymentTargetInfo.clubMemberId shouldBe 20L + result.targets.content[0].targetStatus shouldBe AccountTargetStatus.TARGETED + result.targets.content[0].paymentStatus shouldBe AccountPaymentStatus.PAID + result.targets.content[0].paidAmount shouldBe 30_000 + result.targets.content[1] + .paymentTargetInfo.clubMemberId shouldBe 21L + result.targets.content[1].targetStatus shouldBe AccountTargetStatus.EXCLUDED + result.targets.content[1].paymentStatus shouldBe AccountPaymentStatus.UNPAID + } + + it("선택됨 필터에서는 저장된 TARGETED 대상만 페이지로 조회한다") { + val clubId = 1L + val accountId = 10L + val userId = 100L + val club = ClubTestFixture.createClub(id = clubId) + val account = AccountTestFixture.createAccount(id = accountId, club = club) + val member = ClubMemberTestFixture.createActiveMember(id = 20L, club = club) + val target = AccountPaymentTarget.createTargeted(account, member, Money.of(30_000)) + 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 { + paymentTargetRepository.findAllActiveClubMemberTargetsByAccountIdAndTargetStatus( + accountId = accountId, + targetStatus = AccountTargetStatus.TARGETED, + keyword = null, + pageable = pageable, + ) + } returns PageImpl(listOf(target), pageable, 12) + + val result = + service.findTargets( + clubId = clubId, + accountId = accountId, + userId = userId, + page = 0, + size = 10, + keyword = null, + targetStatus = AccountTargetStatus.TARGETED, + ) + + result.targets.totalElements shouldBe 12 + result.targets.content + .first() + .targetStatus shouldBe AccountTargetStatus.TARGETED + } + + it("다른 동아리 장부이면 AccountNotFoundException을 던지고 대상 목록을 조회하지 않는다") { + val clubId = 1L + val accountId = 10L + val userId = 100L + val otherClub = ClubTestFixture.createClub(id = 2L, code = "OTHER-CLUB") + val account = AccountTestFixture.createAccount(id = accountId, club = otherClub) + + every { accountRepository.findById(accountId) } returns Optional.of(account) + + shouldThrow { + service.findTargets( + clubId = clubId, + accountId = accountId, + userId = userId, + page = 0, + size = 10, + keyword = null, + targetStatus = null, + ) + } + + verify(exactly = 0) { clubMemberReader.countActiveByClubIdAndCardinalNumber(any(), any()) } + verify(exactly = 0) { + paymentTargetRepository.countActiveClubMemberTargetsByAccountIdAndTargetStatus(any(), any()) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountRegistrationQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountRegistrationQueryServiceTest.kt new file mode 100644 index 00000000..72c26d5e --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/account/application/usecase/query/GetAccountRegistrationQueryServiceTest.kt @@ -0,0 +1,228 @@ +package com.weeth.domain.account.application.usecase.query + +import com.weeth.domain.account.application.exception.AccountNotFoundException +import com.weeth.domain.account.application.mapper.AccountRegistrationMapper +import com.weeth.domain.account.domain.enums.AccountStatus +import com.weeth.domain.account.domain.enums.AccountTargetStatus +import com.weeth.domain.account.domain.repository.AccountPaymentTargetRepository +import com.weeth.domain.account.domain.repository.AccountRepository +import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.account.fixture.AccountTestFixture +import com.weeth.domain.club.domain.repository.ClubMemberReader +import com.weeth.domain.club.domain.service.ClubPermissionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.club.fixture.ClubTestFixture +import io.kotest.assertions.throwables.shouldThrow +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.util.Optional + +class GetAccountRegistrationQueryServiceTest : + DescribeSpec({ + val accountRepository = mockk() + val paymentTargetRepository = mockk() + val clubMemberReader = mockk() + val clubPermissionPolicy = mockk(relaxed = true) + val registrationMapper = AccountRegistrationMapper() + val service = + GetAccountRegistrationQueryService( + accountRepository = accountRepository, + paymentTargetRepository = paymentTargetRepository, + clubMemberReader = clubMemberReader, + clubPermissionPolicy = clubPermissionPolicy, + registrationMapper = registrationMapper, + ) + + beforeTest { + clearMocks(accountRepository, paymentTargetRepository, clubMemberReader, clubPermissionPolicy) + } + + describe("findCarryOverSource") { + it("직전 활성 기수 장부가 있으면 기수와 잔액을 반환한다") { + val clubId = 1L + val accountId = 10L + val userId = 100L + val club = ClubTestFixture.createClub(id = clubId) + val account = + com.weeth.domain.account.domain.entity.Account + .createDraft(club = club, cardinal = 5) + val previousAccount = + AccountTestFixture.createAccount( + id = 9L, + club = club, + cardinal = 3, + currentAmount = 240_000, + currentBalance = 240_000, + ) + + every { accountRepository.findById(accountId) } returns Optional.of(account) + every { + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId = clubId, + cardinal = 5, + status = AccountStatus.ACTIVE, + ) + } returns previousAccount + + val result = service.findCarryOverSource(clubId = clubId, accountId = accountId, userId = userId) + + result.hasPreviousAccount shouldBe true + result.cardinalNumber shouldBe 3 + result.balance shouldBe 240_000 + } + + it("직전 활성 기수 장부가 없으면 hasPreviousAccount=false를 반환한다") { + val clubId = 1L + val accountId = 10L + val userId = 100L + val club = ClubTestFixture.createClub(id = clubId) + val account = + com.weeth.domain.account.domain.entity.Account + .createDraft(club = club, cardinal = 5) + + every { accountRepository.findById(accountId) } returns Optional.of(account) + every { + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId = clubId, + cardinal = 5, + status = AccountStatus.ACTIVE, + ) + } returns null + + val result = service.findCarryOverSource(clubId = clubId, accountId = accountId, userId = userId) + + result.hasPreviousAccount shouldBe false + result.cardinalNumber shouldBe null + result.balance shouldBe null + } + + it("다른 동아리 장부이면 AccountNotFoundException을 던지고 이전 장부를 조회하지 않는다") { + val clubId = 1L + val accountId = 10L + val userId = 100L + val otherClub = ClubTestFixture.createClub(id = 2L, code = "OTHER-CLUB") + val account = + com.weeth.domain.account.domain.entity.Account + .createDraft(club = otherClub, cardinal = 5) + + every { accountRepository.findById(accountId) } returns Optional.of(account) + + shouldThrow { + service.findCarryOverSource(clubId = clubId, accountId = accountId, userId = userId) + } + + verify(exactly = 0) { + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + any(), + any(), + any(), + ) + } + } + } + + describe("findStatus") { + it("납부 대상/제외 대상 카운트와 이전 기수 잔액을 포함한다") { + val clubId = 1L + val accountId = 10L + val userId = 100L + val club = ClubTestFixture.createClub(id = clubId) + val account = + com.weeth.domain.account.domain.entity.Account + .createDraft(club = club, cardinal = 5) + account.updateBasicInfo(name = "5기 회비", duesAmount = Money.of(30_000), description = null) + account.advanceRegistrationStep( + com.weeth.domain.account.domain.enums.AccountRegistrationStep.CARRY_OVER, + ) + val previousAccount = + AccountTestFixture.createAccount( + id = 9L, + club = club, + cardinal = 3, + currentAmount = 240_000, + currentBalance = 240_000, + ) + + every { accountRepository.findById(accountId) } returns Optional.of(account) + every { + paymentTargetRepository.countActiveClubMemberTargetsByAccountIdAndTargetStatus( + accountId = accountId, + targetStatus = AccountTargetStatus.TARGETED, + ) + } returns 12L + every { clubMemberReader.countActiveByClubIdAndCardinalNumber(clubId, 5) } returns 18L + every { + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId = clubId, + cardinal = 5, + status = AccountStatus.ACTIVE, + ) + } returns previousAccount + + val result = service.findStatus(clubId = clubId, accountId = accountId, userId = userId) + + result.paymentTargets?.targetCount shouldBe 12 + result.paymentTargets?.excludedCount shouldBe 6 + result.previousAccountBalance?.cardinalNumber shouldBe 3 + result.previousAccountBalance?.balance shouldBe 240_000 + } + + it("이월하기 0원을 저장한 경우 enabled=true로 복원한다") { + val clubId = 1L + val accountId = 10L + val userId = 100L + val club = ClubTestFixture.createClub(id = clubId) + val account = + com.weeth.domain.account.domain.entity.Account + .createDraft(club = club, cardinal = 5) + account.updateBasicInfo(name = "5기 회비", duesAmount = Money.of(30_000), description = null) + account.updateCarryOver(enabled = true, amount = Money.ZERO, memo = "남은 금액 없음") + + every { accountRepository.findById(accountId) } returns Optional.of(account) + every { + paymentTargetRepository.countActiveClubMemberTargetsByAccountIdAndTargetStatus( + accountId = accountId, + targetStatus = AccountTargetStatus.TARGETED, + ) + } returns 0L + every { clubMemberReader.countActiveByClubIdAndCardinalNumber(clubId, 5) } returns 0L + every { + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId = clubId, + cardinal = 5, + status = AccountStatus.ACTIVE, + ) + } returns null + + val result = service.findStatus(clubId = clubId, accountId = accountId, userId = userId) + + result.carryOver?.enabled shouldBe true + result.carryOver?.amount shouldBe 0 + } + + it("다른 동아리 장부이면 AccountNotFoundException을 던지고 납부 대상 집계를 조회하지 않는다") { + val clubId = 1L + val accountId = 10L + val userId = 100L + val otherClub = ClubTestFixture.createClub(id = 2L, code = "OTHER-CLUB") + val account = + com.weeth.domain.account.domain.entity.Account + .createDraft(club = otherClub, cardinal = 5) + + every { accountRepository.findById(accountId) } returns Optional.of(account) + + shouldThrow { + service.findStatus(clubId = clubId, accountId = accountId, userId = userId) + } + + verify(exactly = 0) { + paymentTargetRepository.countActiveClubMemberTargetsByAccountIdAndTargetStatus(any(), any()) + } + verify(exactly = 0) { clubMemberReader.countActiveByClubIdAndCardinalNumber(any(), any()) } + } + } + }) 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 7213b91d..bc8c5d68 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 @@ -1,5 +1,6 @@ package com.weeth.domain.account.domain.entity +import com.weeth.domain.account.domain.enums.AccountRegistrationStep import com.weeth.domain.account.domain.enums.AccountStatus import com.weeth.domain.account.domain.enums.AccountTransactionType import com.weeth.domain.account.domain.vo.Money @@ -78,6 +79,14 @@ class AccountTest : account.status shouldBe AccountStatus.ACTIVE } + "markModifiedBy는 마지막 수정자 ID를 기록한다" { + val account = Account.createDraft(club = ClubTestFixture.createClub(), cardinal = 4) + + account.markModifiedBy(100L) + + account.lastModifiedBy shouldBe 100L + } + "activate는 회비 이름이 없으면 IllegalStateException을 던진다" { val account = Account.createDraft(club = ClubTestFixture.createClub(), cardinal = 4) @@ -165,6 +174,40 @@ class AccountTest : } } + "advanceRegistrationStep은 DRAFT에서 다음 단계로만 진행된다" { + val account = Account.createDraft(club = ClubTestFixture.createClub(), cardinal = 4) + account.advanceRegistrationStep( + AccountRegistrationStep.PAYMENT_TARGETS, + ) + account.registrationStep shouldBe + AccountRegistrationStep.PAYMENT_TARGETS + + // 이미 지난 단계로 되돌릴 수 없다 + account.advanceRegistrationStep(AccountRegistrationStep.BASIC) + account.registrationStep shouldBe + AccountRegistrationStep.PAYMENT_TARGETS + } + + "advanceRegistrationStep은 ACTIVE 상태에서 아무 변경도 하지 않는다" { + val account = Account.createDraft(club = ClubTestFixture.createClub(), cardinal = 4) + account.updateBasicInfo("4기 회비", Money.of(50_000), "정기 회비") + account.activate() + + account.advanceRegistrationStep(AccountRegistrationStep.CARRY_OVER) + + account.registrationStep shouldBe + AccountRegistrationStep.PAYMENT_TARGETS + } + + "updateBasicInfo 호출 시 registrationStep이 PAYMENT_TARGETS로 진행된다" { + val account = Account.createDraft(club = ClubTestFixture.createClub(), cardinal = 4) + + account.updateBasicInfo("4기 회비", Money.of(50_000), "정기 회비") + + account.registrationStep shouldBe + AccountRegistrationStep.PAYMENT_TARGETS + } + "applyTransaction은 잔액보다 큰 지출 거래면 IllegalStateException을 던진다" { val account = Account.createDraft(club = ClubTestFixture.createClub(), cardinal = 4) val transaction = 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 24e81a4b..f14ca50d 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 @@ -4,24 +4,36 @@ import com.weeth.config.TestContainersConfig 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.entity.Receipt import com.weeth.domain.account.domain.enums.AccountPaymentStatus import com.weeth.domain.account.domain.enums.AccountStatus import com.weeth.domain.account.domain.enums.AccountTargetStatus import com.weeth.domain.account.domain.enums.AccountTransactionType import com.weeth.domain.account.domain.vo.Money +import com.weeth.domain.cardinal.domain.entity.Cardinal +import com.weeth.domain.cardinal.domain.repository.CardinalRepository +import com.weeth.domain.club.domain.entity.Club +import com.weeth.domain.club.domain.entity.ClubMember +import com.weeth.domain.club.domain.entity.ClubMemberCardinal +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberCardinalRepository import com.weeth.domain.club.domain.repository.ClubMemberRepository import com.weeth.domain.club.domain.repository.ClubRepository import com.weeth.domain.club.fixture.ClubMemberTestFixture import com.weeth.domain.club.fixture.ClubTestFixture +import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.repository.UserRepository 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.shouldContainExactly import io.kotest.matchers.shouldBe import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager import org.springframework.context.annotation.Import import org.springframework.dao.DataIntegrityViolationException +import org.springframework.data.domain.PageRequest import java.time.LocalDate import java.time.LocalDateTime @@ -32,10 +44,63 @@ class AccountRepositoryTest( private val accountRepository: AccountRepository, private val accountTransactionRepository: AccountTransactionRepository, private val accountPaymentTargetRepository: AccountPaymentTargetRepository, + private val receiptRepository: ReceiptRepository, private val clubRepository: ClubRepository, private val clubMemberRepository: ClubMemberRepository, + private val cardinalRepository: CardinalRepository, + private val clubMemberCardinalRepository: ClubMemberCardinalRepository, private val userRepository: UserRepository, + private val entityManager: TestEntityManager, ) : DescribeSpec({ + fun createUser( + name: String, + email: String, + ): User = + userRepository.save( + User.create(name = name, email = email, status = com.weeth.domain.user.domain.enums.Status.ACTIVE), + ) + + fun createMember( + club: Club, + name: String, + email: String, + status: MemberStatus = MemberStatus.ACTIVE, + ): ClubMember = + clubMemberRepository.save( + ClubMemberTestFixture.createActiveMember(club = club, user = createUser(name, email)).also { + if (status == MemberStatus.BANNED) it.ban() + if (status == MemberStatus.LEFT) it.leave(LocalDateTime.of(2026, 3, 1, 10, 0)) + }, + ) + + fun assignCardinal( + clubMember: ClubMember, + cardinal: Cardinal, + ) { + clubMemberCardinalRepository.save(ClubMemberCardinal.create(clubMember, cardinal)) + } + + fun createActiveAccount( + club: Club, + cardinal: Int, + memberVisible: Boolean = false, + currentBalance: Int = 0, + ): Account { + val account = + Account( + club = club, + totalAmount = currentBalance, + currentAmount = currentBalance, + currentBalance = currentBalance, + cardinal = cardinal, + name = "${cardinal}기 회비", + duesAmount = 50_000, + status = AccountStatus.ACTIVE, + ) + if (memberVisible) account.showToMembers() + return accountRepository.save(account) + } + describe("AccountRepository") { it("동아리, 기수, 상태로 회비 장부를 조회한다") { val club = clubRepository.save(ClubTestFixture.createClub(code = "ACCOUNT-REPO-1")) @@ -61,6 +126,61 @@ class AccountRepositoryTest( accountRepository.saveAndFlush(Account.createDraft(club = club, cardinal = 7)) } } + + it("회원 공개된 활성 장부만 기수로 조회한다") { + val visibleClub = clubRepository.save(ClubTestFixture.createClub(code = "ACCOUNT-REPO-VISIBLE")) + val hiddenClub = + clubRepository.save( + ClubTestFixture.createClub(name = "비공개 장부 테스트 동아리", code = "ACCOUNT-REPO-HIDDEN"), + ) + val visibleAccount = createActiveAccount(visibleClub, cardinal = 6, memberVisible = true) + createActiveAccount(hiddenClub, cardinal = 6, memberVisible = false) + + val found = + accountRepository.findByClubIdAndCardinalAndStatusAndMemberVisibleTrue( + clubId = visibleClub.id, + cardinal = 6, + status = AccountStatus.ACTIVE, + ) + + found?.id shouldBe visibleAccount.id + accountRepository.findByClubIdAndCardinalAndStatusAndMemberVisibleTrue( + clubId = hiddenClub.id, + cardinal = 6, + status = AccountStatus.ACTIVE, + ) shouldBe null + accountRepository.findByClubIdAndCardinalAndStatusAndMemberVisibleTrue( + clubId = visibleClub.id, + cardinal = 6, + status = AccountStatus.DRAFT, + ) shouldBe null + } + + it("현재 기수보다 작은 직전 활성 장부를 기수 내림차순으로 조회한다") { + val club = clubRepository.save(ClubTestFixture.createClub(code = "ACCOUNT-REPO-PREVIOUS")) + createActiveAccount(club, cardinal = 3, currentBalance = 30_000) + val previous = createActiveAccount(club, cardinal = 5, currentBalance = 50_000) + accountRepository.save(Account.createDraft(club = club, cardinal = 6)) + createActiveAccount(club, cardinal = 7, currentBalance = 70_000) + + val found = + accountRepository.findTopByClubIdAndCardinalLessThanAndStatusOrderByCardinalDesc( + clubId = club.id, + cardinal = 6, + status = AccountStatus.ACTIVE, + ) + + found?.id shouldBe previous.id + found?.cardinal shouldBe 5 + } + + it("잠금 조회는 ID에 해당하는 장부를 반환하고 없으면 null을 반환한다") { + val club = clubRepository.save(ClubTestFixture.createClub(code = "ACCOUNT-REPO-LOCK")) + val account = accountRepository.save(Account.createDraft(club = club, cardinal = 8)) + + accountRepository.findByIdWithLock(account.id)?.id shouldBe account.id + accountRepository.findByIdWithLock(Long.MAX_VALUE) shouldBe null + } } describe("AccountTransactionRepository") { @@ -107,6 +227,101 @@ class AccountRepositoryTest( duesLikeCount shouldBe 2 } + + it("거래 집계는 장부와 타입을 함께 필터링한다") { + val club = clubRepository.save(ClubTestFixture.createClub(code = "ACCOUNT-REPO-TX-FILTER")) + val account = accountRepository.save(Account.createDraft(club = club, cardinal = 5)) + val otherAccount = accountRepository.save(Account.createDraft(club = club, cardinal = 6)) + + accountTransactionRepository.save( + AccountTransaction.create( + account = account, + type = AccountTransactionType.DUES, + title = "5기 회비", + source = null, + amount = Money.of(50_000), + transactedAt = LocalDateTime.of(2026, 3, 13, 10, 0), + ), + ) + accountTransactionRepository.save( + AccountTransaction.create( + account = account, + type = AccountTransactionType.EXPENSE, + title = "지출", + source = null, + amount = Money.of(10_000), + transactedAt = LocalDateTime.of(2026, 3, 14, 10, 0), + ), + ) + accountTransactionRepository.save( + AccountTransaction.create( + account = otherAccount, + type = AccountTransactionType.DUES, + title = "6기 회비", + source = null, + amount = Money.of(60_000), + transactedAt = LocalDateTime.of(2026, 3, 15, 10, 0), + ), + ) + + accountTransactionRepository.countByAccountIdAndTypeInAndDeletedAtIsNull( + accountId = account.id, + types = listOf(AccountTransactionType.DUES), + ) shouldBe 1 + } + } + + describe("ReceiptRepository") { + it("영수증을 장부별로 분리해 생성일 내림차순으로 조회한다") { + val club = clubRepository.save(ClubTestFixture.createClub(code = "ACCOUNT-REPO-RECEIPT")) + val account = accountRepository.save(Account.createDraft(club = club, cardinal = 5)) + val otherAccount = accountRepository.save(Account.createDraft(club = club, cardinal = 6)) + val older = + receiptRepository.save( + Receipt.create( + description = "오래된 영수증", + source = "편의점", + amount = 10_000, + date = LocalDate.of(2026, 3, 1), + account = account, + ), + ) + val newer = + receiptRepository.save( + Receipt.create( + description = "최신 영수증", + source = "문구점", + amount = 20_000, + date = LocalDate.of(2026, 3, 2), + account = account, + ), + ) + receiptRepository.save( + Receipt.create( + description = "다른 장부 영수증", + source = "식당", + amount = 30_000, + date = LocalDate.of(2026, 3, 3), + account = otherAccount, + ), + ) + entityManager.flush() + entityManager.entityManager + .createNativeQuery("update receipt set created_at = ? where receipt_id = ?") + .setParameter(1, LocalDateTime.of(2026, 3, 1, 10, 0)) + .setParameter(2, older.id) + .executeUpdate() + entityManager.entityManager + .createNativeQuery("update receipt set created_at = ? where receipt_id = ?") + .setParameter(1, LocalDateTime.of(2026, 3, 2, 10, 0)) + .setParameter(2, newer.id) + .executeUpdate() + entityManager.clear() + + val result = receiptRepository.findAllByAccountIdOrderByCreatedAtDesc(account.id) + + result.map { it.id } shouldContainExactly listOf(newer.id, older.id) + } } describe("AccountPaymentTargetRepository") { @@ -142,5 +357,120 @@ class AccountRepositoryTest( paymentStatus = AccountPaymentStatus.PAID, ) shouldBe 1 } + + it("활성 멤버의 납부 대상만 대상 수로 집계한다") { + val club = clubRepository.save(ClubTestFixture.createClub(code = "ACCOUNT-REPO-ACTIVE-COUNT")) + val account = accountRepository.save(Account.createDraft(club = club, cardinal = 6)) + val activeMember = createMember(club, "활성회원", "account-active-count-1@test.com") + val bannedMember = createMember(club, "차단회원", "account-active-count-2@test.com", MemberStatus.BANNED) + val excludedMember = createMember(club, "제외회원", "account-active-count-3@test.com") + + accountPaymentTargetRepository.save( + AccountPaymentTarget.createTargeted(account, activeMember, Money.of(50_000)), + ) + accountPaymentTargetRepository.save( + AccountPaymentTarget.createTargeted(account, bannedMember, Money.of(50_000)), + ) + accountPaymentTargetRepository.save(AccountPaymentTarget.createExcluded(account, excludedMember)) + + accountPaymentTargetRepository.countActiveClubMemberTargetsByAccountIdAndTargetStatus( + accountId = account.id, + targetStatus = AccountTargetStatus.TARGETED, + ) shouldBe 1 + } + + it("TARGETED 페이지 조회는 활성 멤버만 포함하고 키워드를 적용한다") { + val club = clubRepository.save(ClubTestFixture.createClub(code = "ACCOUNT-REPO-TARGETED-PAGE")) + val account = accountRepository.save(Account.createDraft(club = club, cardinal = 6)) + val matchedMember = createMember(club, "김활성", "account-targeted-page-1@test.com") + val unmatchedMember = createMember(club, "박활성", "account-targeted-page-2@test.com") + val bannedMember = createMember(club, "김차단", "account-targeted-page-3@test.com", MemberStatus.BANNED) + + accountPaymentTargetRepository.save( + AccountPaymentTarget.createTargeted(account, matchedMember, Money.of(50_000)), + ) + accountPaymentTargetRepository.save( + AccountPaymentTarget.createTargeted(account, unmatchedMember, Money.of(50_000)), + ) + accountPaymentTargetRepository.save( + AccountPaymentTarget.createTargeted(account, bannedMember, Money.of(50_000)), + ) + + val result = + accountPaymentTargetRepository.findAllActiveClubMemberTargetsByAccountIdAndTargetStatus( + accountId = account.id, + targetStatus = AccountTargetStatus.TARGETED, + keyword = "김", + pageable = PageRequest.of(0, 10), + ) + + result.totalElements shouldBe 1 + result.content.map { it.clubMember.id } shouldContainExactly listOf(matchedMember.id) + } + + it("등록 완료 정리 대상은 비활성 멤버의 TARGETED 미납 행만 조회한다") { + val club = clubRepository.save(ClubTestFixture.createClub(code = "ACCOUNT-REPO-INACTIVE-UNPAID")) + val account = accountRepository.save(Account.createDraft(club = club, cardinal = 6)) + val bannedUnpaidMember = + createMember(club, "미납차단", "account-inactive-unpaid-1@test.com", MemberStatus.BANNED) + val bannedPaidMember = + createMember(club, "완납차단", "account-inactive-unpaid-2@test.com", MemberStatus.BANNED) + val activeUnpaidMember = createMember(club, "미납활성", "account-inactive-unpaid-3@test.com") + val bannedExcludedMember = + createMember(club, "제외차단", "account-inactive-unpaid-4@test.com", MemberStatus.BANNED) + val cleanupTarget = AccountPaymentTarget.createTargeted(account, bannedUnpaidMember, Money.of(50_000)) + val paidTarget = AccountPaymentTarget.createTargeted(account, bannedPaidMember, Money.of(50_000)) + paidTarget.markPaid(Money.of(50_000), confirmedBy = 1L, paidAt = LocalDateTime.of(2026, 3, 1, 10, 0)) + + accountPaymentTargetRepository.save(cleanupTarget) + accountPaymentTargetRepository.save(paidTarget) + accountPaymentTargetRepository.save( + AccountPaymentTarget.createTargeted(account, activeUnpaidMember, Money.of(50_000)), + ) + accountPaymentTargetRepository.save(AccountPaymentTarget.createExcluded(account, bannedExcludedMember)) + + val result = + accountPaymentTargetRepository.findAllUnpaidTargetsWithInactiveClubMemberByAccountId( + account.id, + ) + + result.map { it.clubMember.id } shouldContainExactly listOf(bannedUnpaidMember.id) + } + + it("기수별 제외 후보 조회는 해당 기수 활성 명부 중 TARGETED가 아닌 멤버만 반환한다") { + val club = clubRepository.save(ClubTestFixture.createClub(code = "ACCOUNT-REPO-EXCLUDED-CANDIDATE")) + val cardinal6 = cardinalRepository.save(Cardinal.create(club = club, cardinalNumber = 6)) + val cardinal7 = cardinalRepository.save(Cardinal.create(club = club, cardinalNumber = 7)) + val account = accountRepository.save(Account.createDraft(club = club, cardinal = 6)) + val targetedMember = createMember(club, "김대상", "account-excluded-candidate-1@test.com") + val excludedMember = createMember(club, "김제외", "account-excluded-candidate-2@test.com") + val noRowMember = createMember(club, "김행없음", "account-excluded-candidate-3@test.com") + val otherCardinalMember = createMember(club, "김다른기수", "account-excluded-candidate-4@test.com") + val bannedMember = + createMember(club, "김차단", "account-excluded-candidate-5@test.com", MemberStatus.BANNED) + + listOf( + targetedMember, + excludedMember, + noRowMember, + bannedMember, + ).forEach { assignCardinal(it, cardinal6) } + assignCardinal(otherCardinalMember, cardinal7) + accountPaymentTargetRepository.save( + AccountPaymentTarget.createTargeted(account, targetedMember, Money.of(50_000)), + ) + accountPaymentTargetRepository.save(AccountPaymentTarget.createExcluded(account, excludedMember)) + + val result = + clubMemberRepository.findExcludedPaymentTargetCandidatesByCardinal( + clubId = club.id, + cardinalNumber = 6, + accountId = account.id, + keyword = "김", + pageable = PageRequest.of(0, 10), + ) + + result.content.map { it.id } shouldContainExactly listOf(excludedMember.id, noRowMember.id) + } } }) diff --git a/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt b/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt index e0157e61..c2a86145 100644 --- a/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/account/fixture/AccountTestFixture.kt @@ -11,6 +11,7 @@ object AccountTestFixture { description: String = "2024년 2학기 회비", totalAmount: Int = 100_000, currentAmount: Int = 100_000, + currentBalance: Int = currentAmount, cardinal: Int = 40, ): Account = Account( @@ -19,6 +20,7 @@ object AccountTestFixture { description = description, totalAmount = totalAmount, currentAmount = currentAmount, + currentBalance = currentBalance, cardinal = cardinal, ) }