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
@@ -1,11 +1,12 @@
package com.weeth.domain.account.application.dto.request

/**
* 거래 내역 목록 필터 탭.
* - ALL: 전체
* - INCOME: 수동 수입(`INCOME`)만 (납부 `DUES`/이월 `CARRY_OVER` 제외)
* - EXPENSE: 지출 방향 전체(수동 `EXPENSE` + 시스템 `REFUND`)
* - DUES: 회비 납부(`DUES`) + 이월(`CARRY_OVER`)
* 부원 거래 내역 목록 필터 탭.
* 부원 개인 납부(`DUES`)는 어떤 탭에서도 개별 노출하지 않고, 항상 `duesSummary` 집계로만 제공한다.
* - ALL: 수동 `INCOME` + 수동 `EXPENSE` + 이월 `CARRY_OVER` + 내 `REFUND`
* - INCOME: 수동 수입(`INCOME`)만 (이월 `CARRY_OVER`/내 `REFUND` 제외)
* - EXPENSE: 수동 지출(`EXPENSE`) + 내 `REFUND` (`source`는 `"환불"`로 마스킹)
* - DUES: 개별 거래 없는 빈 목록 + `duesSummary` 집계만
*/
enum class AccountTransactionFilter {
ALL,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import io.swagger.v3.oas.annotations.media.Schema
data class SavePaymentTargetsRequest(
@field:Schema(
description =
"납부 대상으로 선택한 동아리 회원 ID 목록(스냅샷). " +
"납부 대상으로 선택한 동아리 회원 ID 목록(스냅샷). 필수 필드입니다. " +
"해당 기수 명부 중 이 목록에 없는 회원은 자동으로 제외 처리됩니다. " +
"빈 목록이면 전원 제외를 의미합니다.",
"빈 목록([])은 전원 제외를 의미하며, 필드 누락은 허용되지 않습니다(전원 제외 오작동 방지).",
requiredMode = Schema.RequiredMode.REQUIRED,
example = "[1, 2, 3]",
)
val targetedClubMemberIds: List<Long> = emptyList(),
val targetedClubMemberIds: List<Long>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.weeth.domain.account.application.dto.response

import io.swagger.v3.oas.annotations.media.Schema

data class AccountCardinalResponse(
@field:Schema(description = "회비 기수", example = "7")
val cardinal: Int,
@field:Schema(description = "회비 장부 이름", example = "7기 회비", nullable = true)
val name: String?,
@field:Schema(description = "선택 가능한 최신 기수 여부", example = "true")
val isLatest: Boolean,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.weeth.domain.account.application.dto.response

import com.weeth.global.common.response.SliceResponse
import io.swagger.v3.oas.annotations.media.Schema

data class MemberAccountTransactionsResponse(
@field:Schema(description = "필터 탭별 부원 공개 거래 건수 요약. 첫 페이지(page=0)에서만 제공", nullable = true)
val counts: TransactionCountsResponse?,
@field:Schema(description = "회비 집계 행. 첫 페이지(page=0)에서만 제공", nullable = true)
val duesSummary: DuesSummaryResponse?,
@field:Schema(description = "부원 공개 거래 내역 (무한 스크롤)")
val transactions: SliceResponse<MemberTransactionResponse>,
) {
data class TransactionCountsResponse(
@field:Schema(description = "전체 공개 거래 수", example = "4")
val all: Int,
@field:Schema(description = "지출 공개 거래 수", example = "3")
val expense: Int,
@field:Schema(description = "수입 공개 거래 수", example = "1")
val income: Int,
@field:Schema(description = "회비 집계 행 수. 집계 금액이 있으면 1", example = "1")
val dues: Int,
)

data class DuesSummaryResponse(
@field:Schema(description = "회비 집계 행 라벨", example = "회비")
val label: String = "회비", // 고정 레이블
@field:Schema(description = "회비 납부 거래 합계", example = "1100000")
val totalAmount: Int,
@field:Schema(description = "회비 집계 행 설명", example = "납부될 때마다 합산돼요")
val description: String = "납부될 때마다 합산돼요", // 고정 설명
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.weeth.domain.account.application.dto.response

import com.weeth.domain.account.domain.enums.AccountTransactionDirection
import com.weeth.domain.account.domain.enums.AccountTransactionType
import com.weeth.domain.file.application.dto.response.FileResponse
import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime

data class MemberTransactionDetailResponse(
@field:Schema(description = "거래 ID", example = "1")
val transactionId: Long,
@field:Schema(description = "거래 유형", example = "EXPENSE")
val type: AccountTransactionType,
@field:Schema(description = "거래 방향", example = "EXPENSE")
val direction: AccountTransactionDirection,
@field:Schema(description = "거래 내용", example = "스터디 지원금")
val title: String,
@field:Schema(description = "거래처. REFUND는 환불로 마스킹", example = "인프런", nullable = true)
val source: String?,
@field:Schema(description = "거래 금액", example = "50000")
val amount: Int,
@field:Schema(description = "거래 일시", example = "2026-07-20T00:00:00")
val transactedAt: LocalDateTime,
@field:Schema(description = "거래 분류", example = "운영비", nullable = true)
val category: String?,
@field:Schema(description = "등록자 이름 스냅샷", example = "운영진 김검도")
val registeredByName: String,
@field:Schema(description = "메모", nullable = true)
val memo: String?,
@field:Schema(description = "영수증 존재 여부", example = "true")
val hasReceipt: Boolean = false,
@field:Schema(description = "영수증 파일 목록. 최대 1개")
val receipts: List<FileResponse> = emptyList(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.weeth.domain.account.application.dto.response

import com.weeth.domain.account.domain.enums.AccountTransactionDirection
import com.weeth.domain.account.domain.enums.AccountTransactionType
import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime

data class MemberTransactionResponse(
@field:Schema(description = "거래 ID", example = "1")
val transactionId: Long,
@field:Schema(description = "거래 유형", example = "EXPENSE")
val type: AccountTransactionType,
@field:Schema(description = "거래 방향", example = "EXPENSE")
val direction: AccountTransactionDirection,
@field:Schema(description = "거래 내용", example = "스터디 지원금")
val title: String,
@field:Schema(description = "거래처. REFUND는 환불로 마스킹", example = "인프런", nullable = true)
val source: String?,
@field:Schema(description = "거래 금액", example = "50000")
val amount: Int,
@field:Schema(description = "거래 일시", example = "2026-07-20T00:00:00")
val transactedAt: LocalDateTime,
@field:Schema(description = "영수증 존재 여부", example = "true")
val hasReceipt: Boolean = false,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.weeth.domain.account.application.dto.response

import com.weeth.domain.account.domain.enums.AccountPaymentStatus
import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime

data class MyAccountResponse(
@field:Schema(description = "회비 장부 ID", example = "12")
val accountId: Long,
@field:Schema(description = "회비 기수", example = "7")
val cardinal: Int,
@field:Schema(description = "회비 장부 이름", example = "7기 회비", nullable = true)
val accountName: String?,
@field:Schema(description = "1인 회비 금액", example = "60000")
val duesAmount: Int,
@field:Schema(description = "나의 납부 상태")
val myPayment: MyPaymentResponse,
@field:Schema(description = "부원에게 계좌 공개 여부", example = "true")
val bankAccountVisible: Boolean,
@field:Schema(description = "입금 계좌 정보. 비공개 또는 미등록이면 null", nullable = true)
val bankAccount: BankAccountResponse?,
@field:Schema(description = "잔액/목표액")
val balance: BalanceResponse,
) {
data class MyPaymentResponse(
@field:Schema(description = "내가 이 회비의 납부 대상인지 여부", example = "true")
val targeted: Boolean,
@field:Schema(description = "나의 납부 상태. 납부 대상이 아니면 null", example = "UNPAID", nullable = true)
val status: AccountPaymentStatus?,
@field:Schema(description = "내 납부 대상 금액", example = "60000")
val dueAmount: Int,
@field:Schema(description = "실제 납부 완료 금액", example = "0")
val paidAmount: Int,
@field:Schema(description = "납부 확인 시각. 미납 또는 대상 아님이면 null", nullable = true)
val paidAt: LocalDateTime?,
)

data class BalanceResponse(
@field:Schema(description = "현재 남은 금액", example = "152129")
val currentBalance: Int,
@field:Schema(description = "목표 총액. 납부 대상 dueAmount 합계", example = "1425000")
val goalAmount: Int,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class AccountTransactionMapper {
fun toEntity(
account: Account,
request: SaveAccountTransactionRequest,
registeredByName: String? = null,
): AccountTransaction =
AccountTransaction.create(
account = account,
Expand All @@ -22,6 +23,7 @@ class AccountTransactionMapper {
amount = Money.of(request.amount),
transactedAt = request.transactedAt.atStartOfDay(),
memo = request.memo,
registeredByName = registeredByName,
)

fun toResponse(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.weeth.domain.account.application.mapper

import com.weeth.domain.account.application.dto.response.MemberTransactionDetailResponse
import com.weeth.domain.account.application.dto.response.MemberTransactionResponse
import com.weeth.domain.account.domain.entity.AccountTransaction
import com.weeth.domain.account.domain.enums.AccountTransactionType
import com.weeth.domain.file.application.dto.response.FileResponse
import org.springframework.stereotype.Component

@Component
class MemberTransactionMapper {
fun toResponse(
transaction: AccountTransaction,
hasReceipt: Boolean,
): MemberTransactionResponse =
MemberTransactionResponse(
transactionId = transaction.id,
type = transaction.type,
direction = transaction.direction,
title = transaction.title,
source = transaction.memberVisibleSource(),
amount = transaction.amount,
transactedAt = transaction.transactedAt,
hasReceipt = hasReceipt,
)

fun toDetailResponse(
transaction: AccountTransaction,
receipts: List<FileResponse>,
): MemberTransactionDetailResponse =
MemberTransactionDetailResponse(
transactionId = transaction.id,
type = transaction.type,
direction = transaction.direction,
title = transaction.title,
source = transaction.memberVisibleSource(),
amount = transaction.amount,
transactedAt = transaction.transactedAt,
category = transaction.category,
registeredByName = transaction.registeredByName ?: DEFAULT_REGISTERED_BY_NAME,
memo = transaction.memo,
hasReceipt = receipts.isNotEmpty(),
receipts = receipts,
)

private fun AccountTransaction.memberVisibleSource(): String? =
if (type == AccountTransactionType.REFUND) REFUND_SOURCE else source

companion object {
private const val REFUND_SOURCE = "환불"
private const val DEFAULT_REGISTERED_BY_NAME = "운영진"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.weeth.domain.account.application.mapper

import com.weeth.domain.account.application.dto.response.AccountCardinalResponse
import com.weeth.domain.account.application.dto.response.BankAccountResponse
import com.weeth.domain.account.application.dto.response.MyAccountResponse
import com.weeth.domain.account.domain.entity.Account
import com.weeth.domain.account.domain.entity.AccountPaymentTarget
import com.weeth.domain.account.domain.enums.AccountTargetStatus
import org.springframework.stereotype.Component

@Component
class MyAccountMapper {
fun toCardinalResponse(
account: Account,
isLatest: Boolean,
): AccountCardinalResponse =
AccountCardinalResponse(
cardinal = account.cardinal,
name = account.name,
isLatest = isLatest,
)

fun toResponse(
account: Account,
target: AccountPaymentTarget?,
goalAmount: Int,
): MyAccountResponse =
MyAccountResponse(
accountId = account.id,
cardinal = account.cardinal,
accountName = account.name,
duesAmount = account.duesAmount,
myPayment = target.toMyPaymentResponse(),
bankAccountVisible = account.bankAccountVisible,
bankAccount = if (account.bankAccountVisible) BankAccountResponse.from(account.bankAccount) else null,
balance =
MyAccountResponse.BalanceResponse(
currentBalance = account.currentBalance,
goalAmount = goalAmount,
),
)

// 제외(EXCLUDED)·미선택 등 TARGETED 가 아니면 납부 대상이 아니므로 상태를 노출하지 않는다.
private fun AccountPaymentTarget?.toMyPaymentResponse(): MyAccountResponse.MyPaymentResponse {
if (this == null || targetStatus != AccountTargetStatus.TARGETED) {
return MyAccountResponse.MyPaymentResponse(
targeted = false,
status = null,
dueAmount = 0,
paidAmount = 0,
paidAt = null,
)
}

return MyAccountResponse.MyPaymentResponse(
targeted = true,
status = paymentStatus,
dueAmount = dueAmount,
paidAmount = paidAmount,
paidAt = paidAt,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.weeth.domain.account.application.usecase

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.club.domain.entity.ClubMember
import com.weeth.domain.club.domain.service.ClubMemberPolicy
import org.springframework.stereotype.Component

/**
* 유저 사이드 회비 화면 접근 게이트.
* 부원용 QueryService 전반에서 동일하게 적용되는 두 단계 검증을 한 곳으로 모은다.
*
* 1. 활성 부원만 통과 — 탈퇴/비활성/비소속이면 [ClubMemberPolicy] 가 예외를 던진다.
* 2. 공개(`memberVisible=true`)·`ACTIVE` 장부만 노출 — 없으면 [AccountNotFoundException].
* 이 한 줄로 (a)없는 기수 (b)초안 상태 (c)미공개 장부를 모두 404로 은닉한다.
*/
@Component
class MemberAccountAccessResolver(
private val accountRepository: AccountRepository,
private val clubMemberPolicy: ClubMemberPolicy,
) {
fun resolve(
clubId: Long,
cardinal: Int,
userId: Long,
): MemberAccountAccess {
val member = clubMemberPolicy.getActiveMember(clubId, userId)
val account =
accountRepository.findByClubIdAndCardinalAndStatusAndMemberVisibleTrue(
clubId,
cardinal,
AccountStatus.ACTIVE,
) ?: throw AccountNotFoundException()
return MemberAccountAccess(account, member)
}
}

data class MemberAccountAccess(
val account: Account,
val member: ClubMember,
)
Loading