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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime

data class AccountDashboardResponse(
@field:Schema(description = "회비 장부 ID. 다른 회비 어드민 API 호출에 사용", example = "1")
val accountId: Long,
@field:Schema(description = "잔액/총액 요약")
val summary: SummaryResponse,
@field:Schema(description = "납부 현황 요약 (예: 3 / 24)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class AccountDashboardMapper(
modifier: ClubMember?,
): AccountDashboardResponse =
AccountDashboardResponse(
accountId = account.id,
summary =
AccountDashboardResponse.SummaryResponse(
// 총 회비(목표액)는 레거시 account.totalAmount 가 아니라 납부 대상 dueAmount 합으로 live 계산한다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ 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.AccountStatus
import com.weeth.domain.account.domain.enums.AccountTransactionType
import com.weeth.domain.account.domain.repository.AccountPaymentTargetRepository
import com.weeth.domain.account.domain.repository.AccountRepository
Expand Down Expand Up @@ -132,7 +131,7 @@ class ManageAccountPaymentUseCase(
): Account {
val account = accountRepository.findByIdWithLock(accountId) ?: throw AccountNotFoundException()
account.validateOwnedBy(clubId)
if (account.status != AccountStatus.ACTIVE) throw AccountNotActiveException()
if (!account.isActive) throw AccountNotActiveException()
return account
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import com.weeth.domain.account.application.mapper.AccountTransactionMapper
import com.weeth.domain.account.application.usecase.validateOwnedBy
import com.weeth.domain.account.domain.entity.Account
import com.weeth.domain.account.domain.entity.AccountTransaction
import com.weeth.domain.account.domain.enums.AccountStatus
import com.weeth.domain.account.domain.enums.AccountTransactionType
import com.weeth.domain.account.domain.repository.AccountRepository
import com.weeth.domain.account.domain.repository.AccountTransactionRepository
Expand Down Expand Up @@ -112,7 +111,7 @@ class ManageAccountTransactionUseCase(
val account = accountRepository.findByIdWithLock(accountId) ?: throw AccountNotFoundException()
account.validateOwnedBy(clubId)

if (account.status != AccountStatus.ACTIVE) throw AccountNotActiveException()
if (!account.isActive) throw AccountNotActiveException()

return account
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.weeth.domain.account.application.usecase.command

import com.weeth.domain.account.application.exception.AccountNotActiveException
import com.weeth.domain.account.application.exception.AccountNotFoundException
import com.weeth.domain.account.application.usecase.validateOwnedBy
import com.weeth.domain.account.domain.entity.Account
Expand All @@ -22,6 +23,7 @@ class ManageAccountUseCase(
) {
clubPermissionPolicy.requireAdmin(clubId, userId)
val account = getAccountWithLock(clubId, accountId)
if (!account.isActive) throw AccountNotActiveException()

if (visible) {
account.showToMembers()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.weeth.domain.account.application.usecase.query

import com.weeth.domain.account.application.dto.response.AccountDashboardResponse
import com.weeth.domain.account.application.dto.response.MonthlyBalanceResponse
import com.weeth.domain.account.application.exception.AccountNotActiveException
import com.weeth.domain.account.application.exception.AccountNotFoundException
import com.weeth.domain.account.application.mapper.AccountDashboardMapper
import com.weeth.domain.account.application.usecase.validateOwnedBy
Expand Down Expand Up @@ -34,12 +35,16 @@ class GetAccountDashboardQueryService(
) {
fun getDashboard(
clubId: Long,
accountId: Long,
cardinal: Int,
userId: Long,
): AccountDashboardResponse {
clubPermissionPolicy.requireAdmin(clubId, userId)
val account = accountRepository.findById(accountId).orElseThrow { AccountNotFoundException() }
val account =
accountRepository.findByClubIdAndCardinal(clubId, cardinal)
?: throw AccountNotFoundException()
account.validateOwnedBy(clubId)
if (!account.isActive) throw AccountNotActiveException()
val accountId = account.id

// 총 회비(목표액)는 레거시 account.totalAmount 가 아니라 납부 대상 dueAmount 합으로 live 계산한다.
val totalAmount = paymentTargetRepository.sumDueAmountByAccountId(accountId).toInt()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.weeth.domain.account.application.dto.response.AccountPaymentStatusRes
import com.weeth.domain.account.application.dto.response.AccountPaymentTargetResponse
import com.weeth.domain.account.application.dto.response.AccountPaymentTargetsResponse
import com.weeth.domain.account.application.dto.response.BankAccountResponse
import com.weeth.domain.account.application.exception.AccountNotActiveException
import com.weeth.domain.account.application.exception.AccountNotFoundException
import com.weeth.domain.account.application.mapper.AccountPaymentTargetMapper
import com.weeth.domain.account.application.usecase.validateOwnedBy
Expand Down Expand Up @@ -113,6 +114,7 @@ class GetAccountPaymentTargetQueryService(
size: Int,
): AccountPaymentStatusResponse {
val account = requireAdminAndGetAccount(clubId, accountId, userId)
if (!account.isActive) throw AccountNotActiveException()

val pageable = PageRequest.of(page.coerceAtLeast(0), size.coerceIn(1, 100))
val normalizedKeyword = keyword?.trim()?.takeIf { it.isNotBlank() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.weeth.domain.account.application.dto.request.AccountTransactionFilter
import com.weeth.domain.account.application.dto.request.AccountTransactionSort
import com.weeth.domain.account.application.dto.response.AccountTransactionResponse
import com.weeth.domain.account.application.dto.response.AccountTransactionsResponse
import com.weeth.domain.account.application.exception.AccountNotActiveException
import com.weeth.domain.account.application.exception.AccountNotFoundException
import com.weeth.domain.account.application.exception.AccountTransactionNotFoundException
import com.weeth.domain.account.application.mapper.AccountTransactionMapper
Expand Down Expand Up @@ -128,6 +129,7 @@ class GetAccountTransactionQueryService(

val account = accountRepository.findById(accountId).orElseThrow { AccountNotFoundException() }
account.validateOwnedBy(clubId)
if (!account.isActive) throw AccountNotActiveException()
}

private fun countByFilter(accountId: Long): AccountTransactionsResponse.TransactionCountsResponse =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ class Account(
var status: AccountStatus = status
private set

val isActive: Boolean
get() = status == AccountStatus.ACTIVE

@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
var registrationStep: AccountRegistrationStep = registrationStep
Expand Down Expand Up @@ -162,7 +165,7 @@ class Account(
}

fun showToMembers() {
check(status == AccountStatus.ACTIVE) { "활성화된 회비 장부만 회원에게 공개할 수 있습니다." }
check(isActive) { "활성화된 회비 장부만 회원에게 공개할 수 있습니다." }
memberVisible = true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,17 @@ class AccountManageController(
private val getAccountDashboardQueryService: GetAccountDashboardQueryService,
private val getAccountPaymentTargetQueryService: GetAccountPaymentTargetQueryService,
) {
@GetMapping("/{accountId}/dashboard")
@GetMapping("/{cardinal}/dashboard")
@Operation(summary = "회비 대시보드 조회", description = "잔액/총액, 납부 현황, 계좌, 월별 잔액 추이, 마지막 수정 정보를 집계해 반환합니다.")
fun getDashboard(
@TsidParam
@TsidPathVariable clubId: Long,
@PathVariable accountId: Long,
@PathVariable cardinal: Int,
@Parameter(hidden = true) @CurrentUser userId: Long,
): CommonResponse<AccountDashboardResponse> =
CommonResponse.success(
ACCOUNT_DASHBOARD_FIND_SUCCESS,
getAccountDashboardQueryService.getDashboard(clubId, accountId, userId),
getAccountDashboardQueryService.getDashboard(clubId, cardinal, userId),
)

@GetMapping("/{accountId}/payment-status")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.weeth.domain.account.application.usecase.command

import com.weeth.domain.account.application.exception.AccountNotActiveException
import com.weeth.domain.account.application.exception.AccountNotFoundException
import com.weeth.domain.account.domain.entity.Account
import com.weeth.domain.account.domain.enums.AccountStatus
Expand Down Expand Up @@ -46,6 +47,18 @@ class ManageAccountUseCaseTest :
account.lastModifiedBy shouldBe userId
}

it("DRAFT 장부면 AccountNotActiveException을 던지고 공개 상태를 바꾸지 않는다") {
val account = Account.createDraft(club = club, cardinal = 5)
every { accountRepository.findByIdWithLock(1L) } returns account

shouldThrow<AccountNotActiveException> {
useCase.updateMemberVisibility(clubId = clubId, accountId = 1L, visible = true, userId = userId)
}

account.memberVisible shouldBe false
account.lastModifiedBy shouldBe null
}

it("다른 동아리 장부이면 AccountNotFoundException을 던지고 공개 상태를 바꾸지 않는다") {
val otherClub = ClubTestFixture.createClub(id = 2L, code = "OTHER-CLUB")
val account = Account.createDraft(club = otherClub, cardinal = 5)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.weeth.domain.account.application.usecase.query

import com.weeth.domain.account.application.exception.AccountNotActiveException
import com.weeth.domain.account.application.exception.AccountNotFoundException
import com.weeth.domain.account.application.mapper.AccountDashboardMapper
import com.weeth.domain.account.domain.entity.Account
import com.weeth.domain.account.domain.entity.AccountTransaction
Expand All @@ -17,6 +19,7 @@ import com.weeth.domain.club.domain.service.ClubPermissionPolicy
import com.weeth.domain.club.fixture.ClubTestFixture
import com.weeth.domain.file.domain.port.FileAccessUrlPort
import com.weeth.domain.user.fixture.UserTestFixture
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
Expand All @@ -27,7 +30,6 @@ import java.time.Clock
import java.time.LocalDate
import java.time.YearMonth
import java.time.ZoneId
import java.util.Optional

class GetAccountDashboardQueryServiceTest :
DescribeSpec({
Expand All @@ -51,6 +53,7 @@ class GetAccountDashboardQueryServiceTest :

val userId = 10L
val accountId = 1L
val cardinal = 40

fun transaction(
account: Account,
Expand Down Expand Up @@ -91,9 +94,14 @@ class GetAccountDashboardQueryServiceTest :
describe("getDashboard") {
it("다음 기수 장부가 있으면 종료월을 그 장부 시작월의 직전 월로 고정하고 월별 잔액을 누적 집계한다") {
// currentBalance(저장값) = 거래 순증감(100_000 - 40_000) 으로, 차트 마지막 endingBalance 와 일치하는 정상 상태.
val account = AccountTestFixture.createAccount(id = accountId, cardinal = 40, currentBalance = 60_000)
val account =
AccountTestFixture.createAccount(
id = accountId,
cardinal = cardinal,
currentBalance = 60_000,
)
account.markModifiedBy(5L)
every { accountRepository.findById(accountId) } returns Optional.of(account)
every { accountRepository.findByClubIdAndCardinal(account.club.id, cardinal) } returns account

val transactions =
listOf(
Expand All @@ -108,7 +116,7 @@ class GetAccountDashboardQueryServiceTest :
every {
accountRepository.findTopByClubIdAndCardinalGreaterThanAndStatusOrderByCardinalAsc(
account.club.id,
40,
cardinal,
AccountStatus.ACTIVE,
)
} returns nextAccount
Expand All @@ -125,8 +133,9 @@ class GetAccountDashboardQueryServiceTest :
every { clubMemberReader.findByClubIdAndUserId(account.club.id, 5L) } returns modifierMember
every { fileAccessUrlPort.resolve("profiles/5.png") } returns "https://cdn.test/profiles/5.png"

val result = queryService.getDashboard(account.club.id, accountId, userId)
val result = queryService.getDashboard(account.club.id, cardinal, userId)

result.accountId shouldBe accountId
result.period.startYearMonth shouldBe "2026-03"
result.period.endYearMonth shouldBe "2026-05"
result.monthlyBalances.map { it.yearMonth } shouldBe listOf("2026-03", "2026-04", "2026-05")
Expand All @@ -147,8 +156,8 @@ class GetAccountDashboardQueryServiceTest :
}

it("다음 기수 장부가 없으면 종료월이 현재 월이고 시작월은 가장 이른 거래의 월이다") {
val account = AccountTestFixture.createAccount(id = accountId, cardinal = 40)
every { accountRepository.findById(accountId) } returns Optional.of(account)
val account = AccountTestFixture.createAccount(id = accountId, cardinal = cardinal)
every { accountRepository.findByClubIdAndCardinal(account.club.id, cardinal) } returns account
every {
transactionRepository.findByAccountIdAndDeletedAtIsNullOrderByTransactedAtAsc(accountId)
} returns
Expand All @@ -158,17 +167,40 @@ class GetAccountDashboardQueryServiceTest :
every {
accountRepository.findTopByClubIdAndCardinalGreaterThanAndStatusOrderByCardinalAsc(
account.club.id,
40,
cardinal,
AccountStatus.ACTIVE,
)
} returns null

val result = queryService.getDashboard(account.club.id, accountId, userId)
val result = queryService.getDashboard(account.club.id, cardinal, userId)

result.period.startYearMonth shouldBe "2026-04"
result.period.endYearMonth shouldBe YearMonth.now(clock).toString()
result.lastModified.modifiedBy shouldBe null
verify(exactly = 0) { clubMemberReader.findByClubIdAndUserId(any(), any()) }
}

it("해당 기수의 장부가 없으면 AccountNotFoundException 을 던진다") {
val clubId = 100L
every { accountRepository.findByClubIdAndCardinal(clubId, cardinal) } returns null

shouldThrow<AccountNotFoundException> {
queryService.getDashboard(clubId, cardinal, userId)
}
}

it("장부가 DRAFT 상태면 AccountNotActiveException 을 던진다") {
val account =
AccountTestFixture.createAccount(
id = accountId,
cardinal = cardinal,
status = AccountStatus.DRAFT,
)
every { accountRepository.findByClubIdAndCardinal(account.club.id, cardinal) } returns account

shouldThrow<AccountNotActiveException> {
queryService.getDashboard(account.club.id, cardinal, userId)
}
}
}
})
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.weeth.domain.account.application.usecase.query

import com.weeth.domain.account.application.dto.request.AccountPaymentStatusFilter
import com.weeth.domain.account.application.exception.AccountNotActiveException
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.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
Expand Down Expand Up @@ -234,6 +236,33 @@ class GetAccountPaymentTargetQueryServiceTest :
every { clubMemberReader.countActiveByClubIdAndCardinalNumber(clubId, 40) } returns 5L
}

it("DRAFT 장부면 AccountNotActiveException 을 던지고 목록을 조회하지 않는다") {
val club = ClubTestFixture.createClub(id = clubId)
val account =
AccountTestFixture.createAccount(id = accountId, club = club, status = AccountStatus.DRAFT)
every { accountRepository.findById(accountId) } returns Optional.of(account)

shouldThrow<AccountNotActiveException> {
service.findPaymentStatus(
clubId = clubId,
accountId = accountId,
userId = userId,
paymentStatusFilter = AccountPaymentStatusFilter.ALL,
keyword = null,
page = 0,
size = 20,
)
}
verify(exactly = 0) {
paymentTargetRepository.findActiveTargetsByPaymentStatusOrderByUnpaidFirst(
any(),
any(),
any(),
any(),
)
}
}

it("납부 대상 목록과 상단 요약(수납액/목표/납부율/카운트)을 함께 반환한다") {
val club = ClubTestFixture.createClub(id = clubId)
val account = AccountTestFixture.createAccount(id = accountId, club = club)
Expand Down
Loading