diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeApi.java
index 57bfb9fb..1b4f29e0 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeApi.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeApi.java
@@ -2,36 +2,32 @@
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
-import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
-import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
-import kr.ac.kookmin.stream.api.common.dto.PageParams;
-import kr.ac.kookmin.stream.api.common.dto.PageResponse;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
-import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAmountUpdateRequest;
-import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeLinkUpdateRequest;
+import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAccountUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeStatusUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.AdminFeeSearchResponse;
-import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeAmountUpdateResponse;
-import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeLinkUpdateResponse;
+import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeAccountResponse;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeStatusUpdateResponse;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.api.common.dto.PageParams;
+import kr.ac.kookmin.stream.api.common.dto.PageResponse;
+import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
import kr.ac.kookmin.stream.common.CommonErrorCode;
-import kr.ac.kookmin.stream.member.domain.member.domain.MemberErrorCode;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode;
import org.springdoc.core.annotations.ParameterObject;
/**
* 운영진 학생회비 API의 문서 명세. 구현은 {@link AdminFeeController}가 맡는다.
*
- * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과
- * 파라미터 바인딩(@{@code ModelAttribute}, @{@code RequestBody} 등), 부서 권한(@{@code RequireDepartment})은
- * 구현체에 둔다.
+ * 계좌와 학기당 회비는 한 화면에서 같이 저장하므로 하나의 엔드포인트(`/account`)로 묶여 있다.
*/
-@Tag(name = "학생회비 관리", description = "운영진 학생회비 납부 확인·설정")
+@Tag(name = "학생회비 (운영진)", description = "학생회비 납부 관리")
public interface AdminFeeApi {
- /** 납부 확인 요청 목록. 상태·키워드로 필터링하고 페이지네이션한다. */
- @Operation(summary = "납부 확인 요청 목록 조회",
- description = "학생회비 납부 확인 요청을 상태(status)와 이름·학번 키워드(keyword)로 필터링해 페이지 단위로 조회한다.")
+ /** 납부자 목록. 회원 기준이라 납부 정보를 내지 않은 "납부 전" 회원도 포함된다. */
+ @Operation(summary = "학생회비 납부자 목록 조회",
+ description = "이름·학번 검색과 납부 상태 필터로 회원을 조회한다. 납부 정보를 내지 않은 회원도 "
+ + "납부 전으로 함께 나오며, 그런 행은 studentTransferStatusId를 비롯한 납부 값이 모두 null이다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_TRANSFER_STATUS"})
ApiResponse> getRequests(
@@ -41,36 +37,30 @@ ApiResponse> getRequests(
@ParameterObject PageParams pageParams
);
- /** 납부 확인 요청 처리. 승인/반려로 상태를 바꾸고 납부자 명부에 반영한다. */
- @Operation(summary = "납부 확인 요청 처리",
- description = "납부 확인 요청을 승인 또는 반려 처리하고 납부자 명부를 갱신한다.")
- @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
- @ApiErrorCode(
- type = FeeErrorCode.class,
- codes = {"INVALID_TRANSFER_STATUS", "FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_ALREADY_REVIEWED"}
- )
- @ApiErrorCode(type = MemberErrorCode.class, codes = {"MEMBER_NOT_FOUND"})
+ /** 납부 상태 처리. 이미 처리한 요청도 다시 바꿀 수 있다. */
+ @Operation(summary = "학생회비 납부 상태 수정",
+ description = "납부 상태를 바꾼다. 잘못 처리한 경우를 되돌릴 수 있도록 이미 처리한 요청도 다시 바꿀 수 있다. "
+ + "확인필요(NEEDS_CHECK)로 바꿀 때는 학생에게 보여줄 안내 문구가 필요하다. "
+ + "확인필요 학생은 직접 재제출할 수 없으므로, 다시 내게 하려면 납부 전(UNPAID)으로 되돌린다.")
+ @ApiErrorCode(type = FeeErrorCode.class,
+ codes = {"INVALID_TRANSFER_STATUS", "INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND"})
ApiResponse updateStatus(
AdminApiUser apiUser,
- Long transferStatusId,
+ Long studentTransferStatusId,
FeeStatusUpdateRequest request
);
- /** 송금 링크(은행·계좌번호) 설정. */
- @Operation(summary = "송금 링크 설정",
- description = "학생회비 송금에 쓰이는 은행과 계좌번호를 설정한다.")
- @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
- ApiResponse updateLink(
- AdminApiUser apiUser,
- FeeLinkUpdateRequest request
- );
+ /** 회비 납부 설정 조회. 수정 폼의 초기값으로 쓴다. */
+ @Operation(summary = "회비 납부 계좌 조회",
+ description = "설정된 은행·계좌번호·학기당 회비를 조회한다. 아직 설정하지 않았으면 은행과 계좌번호는 null이고 "
+ + "학기당 회비는 기본값이 내려간다.")
+ ApiResponse getAccount(AdminApiUser apiUser);
- /** 학생회비 금액 설정. */
- @Operation(summary = "학생회비 금액 설정",
- description = "학생회비 송금 금액을 설정한다.")
- @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
- ApiResponse updateAmount(
- AdminApiUser apiUser,
- FeeAmountUpdateRequest request
- );
+ /** 회비 납부 설정 변경. 계좌와 학기당 회비를 함께 받는다. */
+ @Operation(summary = "회비 납부 계좌 변경",
+ description = "은행·계좌번호·학기당 회비를 한 번에 저장한다. 은행은 허용 목록 중 하나여야 하고 "
+ + "계좌번호는 하이픈을 제외한 숫자 10~14자리다. 송금 링크는 저장된 값으로 조회 시점에 만들어진다.")
+ @ApiErrorCode(type = FeeErrorCode.class,
+ codes = {"INVALID_BANK", "INVALID_ACCOUNT_NO", "INVALID_TRANSFER_AMOUNT"})
+ ApiResponse updateAccount(AdminApiUser apiUser, FeeAccountUpdateRequest request);
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeController.java
index 735ea965..d65b8ced 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeController.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/AdminFeeController.java
@@ -1,26 +1,20 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee;
import jakarta.validation.Valid;
-import java.util.Map;
-import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
-import kr.ac.kookmin.stream.api.common.dto.PageParams;
-import kr.ac.kookmin.stream.api.common.dto.PageResponse;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.security.RequireDepartment;
-import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAmountUpdateRequest;
-import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeLinkUpdateRequest;
+import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAccountUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeStatusUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.AdminFeeSearchResponse;
-import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeAmountUpdateResponse;
-import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeLinkUpdateResponse;
+import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeAccountResponse;
import kr.ac.kookmin.stream.api.admin.welfare.fee.response.FeeStatusUpdateResponse;
import kr.ac.kookmin.stream.api.admin.welfare.fee.usecase.AdminFeeReviewUseCase;
import kr.ac.kookmin.stream.api.admin.welfare.fee.usecase.AdminFeeSearchUseCase;
-import kr.ac.kookmin.stream.api.admin.welfare.fee.usecase.MemberFeeStatus;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.api.common.dto.PageParams;
+import kr.ac.kookmin.stream.api.common.dto.PageResponse;
import kr.ac.kookmin.stream.common.CouncilDepartment;
import kr.ac.kookmin.stream.common.PageResult;
-import kr.ac.kookmin.stream.internal.domain.config.service.AdminConfigValueService;
-import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeConfigKeys;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus;
import lombok.RequiredArgsConstructor;
@@ -39,9 +33,9 @@
@RequiredArgsConstructor
public class AdminFeeController implements AdminFeeApi {
- private final AdminConfigValueService adminConfigValueService;
private final AdminFeeSearchUseCase adminFeeSearchUseCase;
private final AdminFeeReviewUseCase adminFeeReviewUseCase;
+ private final FeeAccountProvider feeAccountProvider;
@Override
@GetMapping("/requests")
@@ -52,51 +46,43 @@ public ApiResponse> getRequests(
@RequestParam(required = false) String keyword,
@Valid @ModelAttribute PageParams pageParams
) {
- TransferStatus transferStatus = TransferStatus.from(status);
- PageResult result = adminFeeSearchUseCase.search(transferStatus, keyword, pageParams.toOffset());
+ PageResult result = adminFeeSearchUseCase.search(
+ TransferStatus.from(status), keyword, pageParams.toOffset());
- return ApiResponse.success(
- PageResponse.from(result, (vo) -> AdminFeeSearchResponse.of(vo.status(), vo.member()))
- );
+ return ApiResponse.success(PageResponse.from(result, response -> response));
}
@Override
- @PatchMapping("/requests/{transferStatusId}")
+ @PatchMapping("/requests/{studentTransferStatusId}")
@RequireDepartment(CouncilDepartment.GENERAL_AFFAIRS)
public ApiResponse updateStatus(
AdminApiUser apiUser,
- @PathVariable Long transferStatusId,
- @Valid @RequestBody FeeStatusUpdateRequest request
+ @PathVariable Long studentTransferStatusId,
+ @RequestBody FeeStatusUpdateRequest request
) {
- TransferStatus status = TransferStatus.from(request.status());
- StudentTransferStatus reviewed = adminFeeReviewUseCase.review(transferStatusId, status);
+ StudentTransferStatus reviewed = adminFeeReviewUseCase.review(
+ studentTransferStatusId, TransferStatus.from(request.status()), request.adminNote());
return ApiResponse.success(FeeStatusUpdateResponse.from(reviewed));
}
@Override
- @PutMapping("/link")
+ @GetMapping("/account")
@RequireDepartment(CouncilDepartment.GENERAL_AFFAIRS)
- public ApiResponse updateLink(
- AdminApiUser apiUser,
- @Valid @RequestBody FeeLinkUpdateRequest request
- ) {
- adminConfigValueService.upsertValues(Map.of(
- FeeConfigKeys.TRANSFER_BANK, request.bank(),
- FeeConfigKeys.TRANSFER_ACCOUNT_NO, request.accountNo()
- ));
-
- return ApiResponse.success(FeeLinkUpdateResponse.of(request.bank(), request.accountNo()));
+ public ApiResponse getAccount(AdminApiUser apiUser) {
+ return ApiResponse.success(FeeAccountResponse.from(feeAccountProvider.getAccount()));
}
@Override
- @PutMapping("/amount")
+ @PutMapping("/account")
@RequireDepartment(CouncilDepartment.GENERAL_AFFAIRS)
- public ApiResponse updateAmount(
+ public ApiResponse updateAccount(
AdminApiUser apiUser,
- @Valid @RequestBody FeeAmountUpdateRequest request
+ @RequestBody FeeAccountUpdateRequest request
) {
- adminConfigValueService.upsertValue(FeeConfigKeys.TRANSFER_AMOUNT, String.valueOf(request.amount()));
- return ApiResponse.success(FeeAmountUpdateResponse.of(request.amount()));
+ FeeAccountValidator.validate(request);
+ FeeAccount saved = feeAccountProvider.save(request.bank(), request.accountNo(), request.amount());
+
+ return ApiResponse.success(FeeAccountResponse.from(saved));
}
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java
new file mode 100644
index 00000000..29a9f047
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccount.java
@@ -0,0 +1,32 @@
+package kr.ac.kookmin.stream.api.admin.welfare.fee;
+
+import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator;
+import kr.ac.kookmin.stream.welfare.domain.fee.service.TossTransferLinkGenerator;
+
+/**
+ * 관리자가 설정한 회비 납부 설정. 계좌는 전역 하나이고 학년도별로 나누지 않는다.
+ *
+ * {@code api:app-api}에도 같은 모양의 record가 있다. 공유하지 않는 이유는 둘을 담을 자리가 없어서다 —
+ * {@code api:common-api}는 welfare·internal 모듈을 모르고, 그 의존을 추가하면 "role 무관 공통 인프라"라는
+ * 그 모듈의 역할이 깨진다. 설정 키는 {@code FeeConfigKeys} 하나를 같이 쓰므로 값이 어긋나지는 않는다.
+ *
+ * @param bank 설정 전이면 null
+ * @param accountNo 설정 전이면 null
+ * @param amount 학기당 회비. 설정 전이면 기본값
+ */
+public record FeeAccount(String bank, String accountNo, long amount) {
+
+ public boolean isConfigured() {
+ return bank != null && accountNo != null;
+ }
+
+ /** 금액이 빠진 송금 기본 링크. 계좌가 설정되기 전이면 null이다. */
+ public String generateTransferLinkUrl() {
+ return isConfigured() ? TossTransferLinkGenerator.generate(bank, accountNo, null) : null;
+ }
+
+ /** 남은 학기 수를 낸 학생의 납부 금액. 아직 내지 않았으면 null이다. */
+ public Long calculateTransferAmount(Integer remainingSemesters) {
+ return remainingSemesters == null ? null : FeeAmountCalculator.calculate(remainingSemesters, amount);
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountProvider.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountProvider.java
new file mode 100644
index 00000000..f6bf9dc7
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountProvider.java
@@ -0,0 +1,41 @@
+package kr.ac.kookmin.stream.api.admin.welfare.fee;
+
+import java.util.Map;
+import kr.ac.kookmin.stream.internal.domain.config.service.AdminConfigValueService;
+import kr.ac.kookmin.stream.welfare.WelfareConstants;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeConfigKeys;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+/**
+ * 회비 납부 설정을 읽고 쓴다. welfare 도메인은 internal 모듈에 의존할 수 없어 이 조합을 api에 둔다.
+ *
+ * 학생 쪽과 달리 계좌가 없어도 예외를 던지지 않는다 — 관리자 화면은 "아직 설정하지 않음"을 보여주고
+ * 그 자리에서 설정하게 해야 하기 때문이다.
+ */
+@Component
+@RequiredArgsConstructor
+public class FeeAccountProvider {
+
+ private final AdminConfigValueService adminConfigValueService;
+
+ public FeeAccount getAccount() {
+ return new FeeAccount(
+ adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_BANK).orElse(null),
+ adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_ACCOUNT_NO).orElse(null),
+ adminConfigValueService.getValue(FeeConfigKeys.TRANSFER_AMOUNT)
+ .map(Long::parseLong)
+ .orElse(WelfareConstants.DEFAULT_TRANSFER_AMOUNT)
+ );
+ }
+
+ // 계좌와 금액은 한 화면에서 같이 저장하므로 세 키를 한 번에 쓴다
+ public FeeAccount save(String bank, String accountNo, long amount) {
+ adminConfigValueService.upsertValues(Map.of(
+ FeeConfigKeys.TRANSFER_BANK, bank,
+ FeeConfigKeys.TRANSFER_ACCOUNT_NO, accountNo,
+ FeeConfigKeys.TRANSFER_AMOUNT, String.valueOf(amount)
+ ));
+ return new FeeAccount(bank, accountNo, amount);
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountValidator.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountValidator.java
new file mode 100644
index 00000000..caac6de9
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/FeeAccountValidator.java
@@ -0,0 +1,39 @@
+package kr.ac.kookmin.stream.api.admin.welfare.fee;
+
+import kr.ac.kookmin.stream.api.admin.welfare.fee.request.FeeAccountUpdateRequest;
+import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeBanks;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode;
+
+/**
+ * 회비 납부 설정 입력 검증. 항목마다 전용 에러 코드를 내려줘야 해서 Bean Validation 대신 직접 본다
+ * (fee-domain-study 08번 문서 — 애노테이션으로는 공통 INVALID_INPUT이 된다).
+ */
+final class FeeAccountValidator {
+
+ private static final int MIN_ACCOUNT_NO_LENGTH = 10;
+ private static final int MAX_ACCOUNT_NO_LENGTH = 14;
+
+ private FeeAccountValidator() {}
+
+ static void validate(FeeAccountUpdateRequest request) {
+ FeeBanks.validate(request.bank());
+ validateAccountNo(request.accountNo());
+ validateAmount(request.amount());
+ }
+
+ private static void validateAccountNo(String accountNo) {
+ if (accountNo == null
+ || !accountNo.matches("^[0-9]+$")
+ || accountNo.length() < MIN_ACCOUNT_NO_LENGTH
+ || accountNo.length() > MAX_ACCOUNT_NO_LENGTH) {
+ throw new BusinessException(FeeErrorCode.INVALID_ACCOUNT_NO);
+ }
+ }
+
+ private static void validateAmount(Integer amount) {
+ if (amount == null || amount < 1) {
+ throw new BusinessException(FeeErrorCode.INVALID_TRANSFER_AMOUNT);
+ }
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAccountUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAccountUpdateRequest.java
new file mode 100644
index 00000000..e46367bc
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAccountUpdateRequest.java
@@ -0,0 +1,21 @@
+package kr.ac.kookmin.stream.api.admin.welfare.fee.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+/**
+ * 회비 납부 설정 변경. 계좌와 학기당 회비를 한 화면에서 같이 저장하므로 함께 받는다.
+ *
+ * 검증에 Bean Validation을 쓰지 않는다 — 항목마다 전용 에러 코드를 내려줘야 하는데
+ * 애노테이션으로는 공통 INVALID_INPUT이 되기 때문이다(fee-domain-study 08번 문서).
+ */
+public record FeeAccountUpdateRequest(
+ @Schema(description = "은행명 (허용 목록 중 하나)", example = "카카오뱅크")
+ String bank,
+
+ @Schema(description = "계좌번호 (하이픈 제외, 10~14자리)", example = "3333011234567")
+ String accountNo,
+
+ @Schema(description = "학기당 학생회비", example = "20000")
+ Integer amount
+) {
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAmountUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAmountUpdateRequest.java
deleted file mode 100644
index b6234203..00000000
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeAmountUpdateRequest.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package kr.ac.kookmin.stream.api.admin.welfare.fee.request;
-
-import jakarta.validation.constraints.NotNull;
-import jakarta.validation.constraints.Positive;
-
-public record FeeAmountUpdateRequest(
-
- @NotNull(message = "유효하지 않은 금액입니다.")
- @Positive(message = "유효하지 않은 금액입니다.")
- Integer amount
-) {
-}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeLinkUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeLinkUpdateRequest.java
deleted file mode 100644
index 861e8967..00000000
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeLinkUpdateRequest.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package kr.ac.kookmin.stream.api.admin.welfare.fee.request;
-
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.Pattern;
-
-public record FeeLinkUpdateRequest(
- @NotBlank(message = "은행명을 입력해 주세요.")
- String bank,
-
- @NotBlank(message = "계좌번호를 입력해 주세요.")
- @Pattern(regexp = "^[0-9]+$", message = "계좌번호는 숫자만 입력할 수 있습니다.")
- String accountNo
-) {
-}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeStatusUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeStatusUpdateRequest.java
index 195441cf..30400142 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeStatusUpdateRequest.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/request/FeeStatusUpdateRequest.java
@@ -1,9 +1,17 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee.request;
-import jakarta.validation.constraints.NotBlank;
+import io.swagger.v3.oas.annotations.media.Schema;
+/**
+ * 납부 상태 변경. 상태 문자열 파싱은 {@code TransferStatus.from}이 하고,
+ * 안내 문구는 확인필요일 때만 필수라 Bean Validation 대신 UseCase에서 검증한다.
+ */
public record FeeStatusUpdateRequest(
- @NotBlank(message = "처리 상태를 입력해 주세요.")
- String status
+ @Schema(description = "변경할 납부 상태", example = "NEEDS_CHECK")
+ String status,
+
+ @Schema(description = "관리자 안내 문구 (최대 255자). 확인필요일 때 필수",
+ example = "입금자 정보를 확인하지 못했어요. 문제가 있을 경우 학생회에 문의해 주세요.")
+ String adminNote
) {
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java
index 6f6fdab6..3a833d28 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java
@@ -1,31 +1,57 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee.response;
import java.time.LocalDateTime;
+import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccount;
import kr.ac.kookmin.stream.api.common.ApiConstants;
+import kr.ac.kookmin.stream.api.common.StorageUrlBuilder;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus;
+/**
+ * 납부자 목록의 한 줄. 모든 회원이 납부 상태 행을 가지므로 아직 아무것도 내지 않은 회원도 들어온다 —
+ * 그런 줄은 상태가 납부 전이고 제출 관련 값이 모두 null이다.
+ */
public record AdminFeeSearchResponse(
Long studentTransferStatusId,
Long memberId,
String name,
String studentId,
String department,
- String status,
+ boolean isStreamMember,
+ String feeStatus,
+ Integer remainingSemesters,
+ Long transferAmount,
+ String proofImageUrl,
LocalDateTime requestedAt,
- LocalDateTime reviewedAt
+ LocalDateTime confirmedAt,
+ String adminNote
) {
- // member가 null이면 탈퇴(소프트 삭제)한 회원의 과거 요청이다 — 이름/학번/학과를 알 수 없으니 표시용 placeholder를 쓴다.
- public static AdminFeeSearchResponse of(StudentTransferStatus status, Member member) {
+ /**
+ * @param member 탈퇴(소프트 삭제)한 회원의 행이면 null — 이름·학번·학과를 알 수 없어 표시용 placeholder를 쓴다
+ * @param signedUp 회원가입(전화번호 + 필수 약관 동의)을 마쳤는지
+ */
+ public static AdminFeeSearchResponse of(
+ StudentTransferStatus transferStatus,
+ Member member,
+ boolean signedUp,
+ FeeAccount account
+ ) {
return new AdminFeeSearchResponse(
- status.getId(),
- status.getMemberId(),
+ transferStatus.getId(),
+ transferStatus.getMemberId(),
member != null ? member.getName() : ApiConstants.WITHDRAWN_MEMBER_LABEL,
member != null ? member.getStudentId() : null,
member != null ? member.getDepartment().displayName() : null,
- status.getStatus().name(),
- status.getCreatedAt(),
- status.getReviewedAt()
+ signedUp,
+ transferStatus.getStatus().name(),
+ transferStatus.getRemainingSemesters(),
+ account.calculateTransferAmount(transferStatus.getRemainingSemesters()),
+ StorageUrlBuilder.build(transferStatus.getProofImageKey()),
+ transferStatus.getRequestedAt(),
+ // 납부 확인 시각은 납부완료일 때의 처리 시각이다
+ transferStatus.getStatus() == TransferStatus.PAID ? transferStatus.getReviewedAt() : null,
+ transferStatus.getAdminNote()
);
}
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAccountResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAccountResponse.java
new file mode 100644
index 00000000..be558e2e
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAccountResponse.java
@@ -0,0 +1,16 @@
+package kr.ac.kookmin.stream.api.admin.welfare.fee.response;
+
+import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccount;
+
+/** 회비 납부 설정. 조회와 변경이 같은 모양을 쓴다. */
+public record FeeAccountResponse(
+ String bank,
+ String accountNo,
+ long amount,
+ String transferLinkUrl
+) {
+ public static FeeAccountResponse from(FeeAccount account) {
+ return new FeeAccountResponse(
+ account.bank(), account.accountNo(), account.amount(), account.generateTransferLinkUrl());
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAmountUpdateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAmountUpdateResponse.java
deleted file mode 100644
index b84eb0eb..00000000
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeAmountUpdateResponse.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package kr.ac.kookmin.stream.api.admin.welfare.fee.response;
-
-public record FeeAmountUpdateResponse(Integer amount) {
- public static FeeAmountUpdateResponse of(Integer amount) {
- return new FeeAmountUpdateResponse(amount);
- }
-}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeLinkUpdateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeLinkUpdateResponse.java
deleted file mode 100644
index 6e36681e..00000000
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeLinkUpdateResponse.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package kr.ac.kookmin.stream.api.admin.welfare.fee.response;
-
-public record FeeLinkUpdateResponse(String bank, String accountNo) {
- public static FeeLinkUpdateResponse of(String bank, String accountNo) {
- return new FeeLinkUpdateResponse(bank, accountNo);
- }
-}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeStatusUpdateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeStatusUpdateResponse.java
index bb0c92c9..10e02653 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeStatusUpdateResponse.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/FeeStatusUpdateResponse.java
@@ -2,14 +2,25 @@
import java.time.LocalDateTime;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus;
public record FeeStatusUpdateResponse(
Long studentTransferStatusId,
Long memberId,
String status,
- LocalDateTime reviewedAt
+ String adminNote,
+ LocalDateTime reviewedAt,
+ LocalDateTime confirmedAt
) {
- public static FeeStatusUpdateResponse from(StudentTransferStatus request) {
- return new FeeStatusUpdateResponse(request.getId(), request.getMemberId(), request.getStatus().name(), request.getReviewedAt());
+ public static FeeStatusUpdateResponse from(StudentTransferStatus transferStatus) {
+ return new FeeStatusUpdateResponse(
+ transferStatus.getId(),
+ transferStatus.getMemberId(),
+ transferStatus.getStatus().name(),
+ transferStatus.getAdminNote(),
+ transferStatus.getReviewedAt(),
+ // 납부 확인 시각은 납부완료일 때의 처리 시각이다
+ transferStatus.getStatus() == TransferStatus.PAID ? transferStatus.getReviewedAt() : null
+ );
}
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java
index 40d49e03..a173414f 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java
@@ -1,7 +1,9 @@
package kr.ac.kookmin.stream.api.admin.welfare.fee.usecase;
+import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;
import kr.ac.kookmin.stream.member.domain.member.service.MemberService;
+import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus;
import kr.ac.kookmin.stream.welfare.domain.fee.service.PayerService;
@@ -10,22 +12,38 @@
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
+/**
+ * 납부 상태 처리. 상태 변경과 납부자 명부 반영이 어긋나면 안 되므로 한 트랜잭션으로 묶는다.
+ */
@Component
@RequiredArgsConstructor
public class AdminFeeReviewUseCase {
+ private static final int MAX_ADMIN_NOTE_LENGTH = 255;
+
private final StudentTransferStatusService studentTransferStatusService;
private final PayerService payerService;
private final MemberService memberService;
- // 확인요청 처리 + 납부자 명부(Payer) 반영을 하나의 트랜잭션으로 묶는다.
@Transactional
- public StudentTransferStatus review(Long transferStatusId, TransferStatus status) {
- StudentTransferStatus transferStatus = studentTransferStatusService.review(transferStatusId, status);
- Member member = memberService.getById(transferStatus.getMemberId());
+ public StudentTransferStatus review(Long transferStatusId, TransferStatus status, String adminNote) {
+ validateAdminNote(status, adminNote);
+
+ StudentTransferStatus reviewed = studentTransferStatusService.review(transferStatusId, status, adminNote);
+ Member member = memberService.getById(reviewed.getMemberId());
- payerService.sync(member.getId(), member.getName(), member.getStudentId(), transferStatus.getStatus());
+ payerService.sync(member.getId(), member.getName(), member.getStudentId(), reviewed.getStatus());
+
+ return reviewed;
+ }
- return transferStatus;
+ // 확인필요는 학생에게 보여줄 안내 문구가 있어야 의미가 있다. 그 외 상태에서는 문구를 받아도 버린다
+ private void validateAdminNote(TransferStatus status, String adminNote) {
+ if (status != TransferStatus.NEEDS_CHECK) {
+ return;
+ }
+ if (adminNote == null || adminNote.isBlank() || adminNote.length() > MAX_ADMIN_NOTE_LENGTH) {
+ throw new BusinessException(FeeErrorCode.INVALID_ADMIN_NOTE);
+ }
}
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java
index b97ea4c9..a0b70ced 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeSearchUseCase.java
@@ -2,6 +2,9 @@
import java.util.List;
import java.util.Map;
+import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccount;
+import kr.ac.kookmin.stream.api.admin.welfare.fee.FeeAccountProvider;
+import kr.ac.kookmin.stream.api.admin.welfare.fee.response.AdminFeeSearchResponse;
import kr.ac.kookmin.stream.common.PageOffset;
import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;
@@ -12,34 +15,49 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
+/**
+ * 납부자 목록. {@code welfare}와 {@code member} 두 도메인을 조합한다.
+ *
+ * 페이징과 상태 필터는 회비 쪽에서 한다. 모든 회원이 납부 상태 행을 갖기 때문에(로그인 때 생성,
+ * {@code V21}로 기존 회원 백필) 납부 전 회원도 그대로 걸린다. 회원 기준으로 뒤집으면 member 도메인이
+ * 회비 상태를 알아야 하는데, 회비 때문에 남의 도메인을 고치지 않으려고 방향을 이렇게 잡았다.
+ *
+ * member 쪽은 이미 공개된 조회 메서드만 쓴다 — 검색은 {@code searchIdsByKeyword}, 회원 정보는 {@code getMapByIds}.
+ *
+ * 트랜잭션을 걸지 않는다. 쓰기가 없고, 조회 사이가 어긋나도 한 줄의 정보가 잠깐 최신이 아닐 뿐이다.
+ */
@Component
@RequiredArgsConstructor
public class AdminFeeSearchUseCase {
private final StudentTransferStatusService studentTransferStatusService;
private final MemberService memberService;
+ private final FeeAccountProvider feeAccountProvider;
- public PageResult search(TransferStatus status, String keyword, PageOffset pageOffset) {
+ public PageResult search(TransferStatus status, String keyword, PageOffset pageOffset) {
List memberIds = null;
-
if (keyword != null && !keyword.isBlank()) {
memberIds = memberService.searchIdsByKeyword(keyword);
-
if (memberIds.isEmpty()) {
return PageResult.empty(pageOffset.page(), pageOffset.size());
}
}
- PageResult result = studentTransferStatusService.search(status, memberIds, pageOffset);
- List resultMemberIds = result.content()
- .stream()
- .map(StudentTransferStatus::getMemberId)
- .toList();
-
- // 탈퇴(소프트 삭제)한 회원은 findAllByIds에서 빠지므로, 그 회원의 과거 요청은 member가 null로 남는다.
- // (탈퇴 회원의 요청을 조회 자체에서 제외하는 대신, 표시는 Controller/DTO에서 처리한다)
- Map memberMap = memberService.getMapByIds(resultMemberIds);
-
- return result.map(transferStatus -> new MemberFeeStatus(transferStatus, memberMap.getOrDefault(transferStatus.getMemberId(), null)));
+ PageResult page = studentTransferStatusService.search(status, memberIds, pageOffset);
+ List pagedMemberIds = page.content().stream().map(StudentTransferStatus::getMemberId).toList();
+
+ // 탈퇴(소프트 삭제)한 회원은 빠지므로 그 회원의 행은 member가 null로 남는다
+ Map membersById = memberService.getMapByIds(pagedMemberIds);
+ // 학기당 금액은 줄마다 같으므로 목록당 한 번만 읽는다
+ FeeAccount account = feeAccountProvider.getAccount();
+
+ return page.map(transferStatus -> AdminFeeSearchResponse.of(
+ transferStatus,
+ membersById.get(transferStatus.getMemberId()),
+ // 가입 여부는 member가 아는 사실이라 member의 공개 메서드에 묻는다.
+ // 줄마다 한 번씩 부르므로 목록이 커지면 벌크 조회를 member 쪽에 요청할 자리다
+ memberService.isSignedUp(transferStatus.getMemberId()),
+ account
+ ));
}
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/MemberFeeStatus.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/MemberFeeStatus.java
deleted file mode 100644
index 5c86a6b3..00000000
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/MemberFeeStatus.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package kr.ac.kookmin.stream.api.admin.welfare.fee.usecase;
-
-import kr.ac.kookmin.stream.member.domain.member.domain.Member;
-import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
-
-// AdminFeeSearchUseCase가 조합한 결과. DTO(AdminFeeSearchResponse) 변환은 Controller가 한다
-// (PageResult<도메인>까지만 UseCase가 만들고, api DTO 조립은 경계에서 하기 위함).
-// member는 탈퇴(소프트 삭제)한 회원의 과거 요청이면 null일 수 있다.
-public record MemberFeeStatus(StudentTransferStatus status, Member member) {
-}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java
index a530c56f..e403b29a 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/core/auth/usecase/OAuthLoginUseCase.java
@@ -8,6 +8,7 @@
import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand;
import kr.ac.kookmin.stream.member.domain.member.service.MemberService;
import kr.ac.kookmin.stream.security.AuthTokenIssuer;
+import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@@ -24,11 +25,16 @@ public class OAuthLoginUseCase {
private final OAuthService oauthService;
private final MemberService memberService;
private final AuthTokenIssuer authTokenIssuer;
+ private final StudentTransferStatusService studentTransferStatusService;
public OAuthLoginResponse login(OAuthLoginCommand command) {
OAuthUserInfo userInfo = oauthService.authenticate(command);
Member member = findOrRegister(userInfo);
+ // 모든 회원이 납부 상태 행을 갖게 해서, 관리자 납부자 목록이 납부 전 회원까지 보여줄 수 있게 한다.
+ // 회비가 자기 테이블에서 전원을 다루므로 member 도메인은 회비를 몰라도 된다.
+ studentTransferStatusService.createIfAbsent(member.getId());
+
String accessToken = authTokenIssuer.issue(member.getId(), member.getRole(), member.getCouncilDepartment());
// 회원을 이번에 만들었는지가 아니라 저장된 전화번호·동의 기록으로 판단한다. 회원가입 화면에서 이탈한 회원도 다시 가입 화면을 본다
boolean signUpRequired = !memberService.isSignedUp(member.getId());
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java
index 56c9e2cb..882ab145 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeController.java
@@ -22,7 +22,7 @@
public class AppFeeController implements AppFeeApi {
private final StudentTransferStatusService studentTransferStatusService;
- private final FeeTransferUrlProvider feeTransferUrlProvider;
+ private final FeeAccountProvider feeAccountProvider;
private final FeeSubmitUseCase feeSubmitUseCase;
@Override
@@ -30,7 +30,7 @@ public class AppFeeController implements AppFeeApi {
public ApiResponse getFeeByMemberId(AppApiUser apiUser) {
StudentTransferStatus transferStatus = studentTransferStatusService.getByMemberId(apiUser.userId());
- return ApiResponse.success(FeeResponse.of(transferStatus, feeTransferUrlProvider.getAccount()));
+ return ApiResponse.success(FeeResponse.of(transferStatus, feeAccountProvider.getAccount()));
}
@Override
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java
index a2812b66..5d7a2f3e 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccount.java
@@ -12,12 +12,12 @@
public record FeeAccount(String bank, String accountNo, long amountPerSemester) {
/** 이 학생이 보낼 금액. 남은 학기 수 × 학기당 회비. */
- public long transferAmountFor(int remainingSemesters) {
+ public long calculateTransferAmount(int remainingSemesters) {
return FeeAmountCalculator.calculate(remainingSemesters, amountPerSemester);
}
/** 금액이 붙은 토스 송금 링크. */
- public String transferLinkFor(long transferAmount) {
+ public String generateTransferLinkUrl(long transferAmount) {
return TossTransferLinkGenerator.generate(bank, accountNo, transferAmount);
}
}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccountProvider.java
similarity index 97%
rename from api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java
rename to api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccountProvider.java
index ea3493ed..85c2f6c8 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeTransferUrlProvider.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/FeeAccountProvider.java
@@ -17,7 +17,7 @@
*/
@Component
@RequiredArgsConstructor
-public class FeeTransferUrlProvider {
+public class FeeAccountProvider {
private final AdminConfigValueService adminConfigValueService;
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java
index b7c09924..9d69afa3 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeResponse.java
@@ -30,7 +30,7 @@ public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount ac
Integer remainingSemesters = transferStatus.getRemainingSemesters();
boolean payable = remainingSemesters != null && transferStatus.getStatus() != TransferStatus.NEEDS_CHECK;
- Long transferAmount = payable ? account.transferAmountFor(remainingSemesters) : null;
+ Long transferAmount = payable ? account.calculateTransferAmount(remainingSemesters) : null;
return new FeeResponse(
transferStatus.getId(),
@@ -39,7 +39,7 @@ public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount ac
account.accountNo(),
remainingSemesters,
transferAmount,
- transferAmount == null ? null : account.transferLinkFor(transferAmount),
+ transferAmount == null ? null : account.generateTransferLinkUrl(transferAmount),
transferStatus.getRequestedAt(),
// 납부 확인 시각은 PAID일 때의 처리 시각이다. 그 외 상태에서는 보여주지 않는다
transferStatus.getStatus() == TransferStatus.PAID ? transferStatus.getReviewedAt() : null,
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java
index 73eca1fb..bc37cf02 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java
@@ -11,10 +11,10 @@ public record FeeSubmitResponse(
String transferLinkUrl
) {
public static FeeSubmitResponse of(int remainingSemesters, FeeAccount account) {
- long transferAmount = account.transferAmountFor(remainingSemesters);
+ long transferAmount = account.calculateTransferAmount(remainingSemesters);
return new FeeSubmitResponse(
account.bank(), account.accountNo(), remainingSemesters,
- transferAmount, account.transferLinkFor(transferAmount));
+ transferAmount, account.generateTransferLinkUrl(transferAmount));
}
}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java
index 2a3861de..3d9d55bd 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/usecase/FeeSubmitUseCase.java
@@ -2,7 +2,7 @@
import java.util.Objects;
import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount;
-import kr.ac.kookmin.stream.api.app.welfare.fee.FeeTransferUrlProvider;
+import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccountProvider;
import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest;
import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeSubmitResponse;
import kr.ac.kookmin.stream.common.BusinessException;
@@ -34,7 +34,7 @@ public class FeeSubmitUseCase {
private static final int MAX_REMAINING_SEMESTERS = 8;
private final StudentTransferStatusService studentTransferStatusService;
- private final FeeTransferUrlProvider feeTransferUrlProvider;
+ private final FeeAccountProvider feeAccountProvider;
private final FileService fileService;
public FeeSubmitResponse submit(Long memberId, FeeSubmitRequest request) {
@@ -42,7 +42,7 @@ public FeeSubmitResponse submit(Long memberId, FeeSubmitRequest request) {
validateProofImage(request.proofImageKey(), memberId);
// 계좌가 없으면 송금 자체가 불가능하므로, 저장하기 전에 먼저 막는다
- FeeAccount account = feeTransferUrlProvider.getAccount();
+ FeeAccount account = feeAccountProvider.getAccount();
// 교체될 이전 키를 알려고 한 번 더 읽는다. submit이 돌려주게 하면 반환 타입에 값을 하나 더 얹어야 해서,
// 유니크 인덱스 조회 한 번을 받아들였다. 같은 학생의 동시 제출이 겹치면 낡은 키를 볼 수 있는데,
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeBanks.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeBanks.java
new file mode 100644
index 00000000..9f8aad94
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeBanks.java
@@ -0,0 +1,26 @@
+package kr.ac.kookmin.stream.welfare.domain.fee.domain;
+
+import java.util.Set;
+import kr.ac.kookmin.stream.common.BusinessException;
+
+/**
+ * 회비 납부 계좌로 쓸 수 있는 은행. 관리자 화면의 은행 선택 목록과 같은 22개다.
+ *
+ * enum이 아니라 문자열 집합인 이유는, 이 값이 토스 송금 링크의 {@code bank} 파라미터로 그대로 나가고
+ * 화면에도 그대로 표시되기 때문이다. 중간에 코드로 바꿨다가 되돌리면 한쪽만 고쳤을 때 조용히 어긋난다.
+ */
+public final class FeeBanks {
+
+ private static final Set ALLOWED = Set.of(
+ "경남", "광주", "기업", "국민", "대구", "부산", "산림", "새마을", "SC제일", "신한", "신협",
+ "수협", "케이뱅크", "우리", "우체국", "저축은행", "전북", "제주", "카카오뱅크", "토스뱅크", "하나", "농협"
+ );
+
+ private FeeBanks() {}
+
+ public static void validate(String bank) {
+ if (bank == null || !ALLOWED.contains(bank)) {
+ throw new BusinessException(FeeErrorCode.INVALID_BANK);
+ }
+ }
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java
index d3f0ab5e..763ce85a 100644
--- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java
@@ -15,10 +15,13 @@ public enum FeeErrorCode implements ErrorCode {
INVALID_TRANSFER_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 납부 상태입니다."),
INVALID_REMAINING_SEMESTERS(ErrorStatus.BAD_REQUEST, "유효하지 않은 남은 학기 수입니다."),
INVALID_PROOF_IMAGE(ErrorStatus.BAD_REQUEST, "유효하지 않은 인증 이미지입니다."),
+ INVALID_BANK(ErrorStatus.BAD_REQUEST, "지원하지 않는 은행입니다."),
+ INVALID_ACCOUNT_NO(ErrorStatus.BAD_REQUEST, "유효하지 않은 계좌번호입니다."),
+ INVALID_TRANSFER_AMOUNT(ErrorStatus.BAD_REQUEST, "유효하지 않은 학생회비 금액입니다."),
+ INVALID_ADMIN_NOTE(ErrorStatus.BAD_REQUEST, "유효하지 않은 관리자 안내 문구입니다."),
FEE_ACCOUNT_NOT_FOUND(ErrorStatus.NOT_FOUND, "학생회비 납부 계좌가 등록되지 않았습니다."),
FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "제출된 납부 정보가 없습니다."),
FEE_REQUEST_ALREADY_REQUESTED(ErrorStatus.CONFLICT, "이미 납부 확인을 요청했거나 납부가 완료되었습니다."),
- FEE_REQUEST_ALREADY_REVIEWED(ErrorStatus.CONFLICT, "이미 처리된 납부 확인 요청입니다."),
MEMBER_IS_NOT_PAYER(ErrorStatus.BAD_REQUEST, "회비를 납부한 회원만 이용할 수 있습니다.");
private final int status;
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java
index 66a4c858..12f35814 100644
--- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/StudentTransferStatus.java
@@ -80,9 +80,16 @@ public void requestConfirmation(LocalDateTime now) {
this.reviewedAt = null;
}
- public void review(TransferStatus reviewedStatus, LocalDateTime now) {
+ /**
+ * 관리자가 납부 상태를 바꾼다. 이미 처리한 요청도 다시 바꿀 수 있다 — 잘못 처리한 경우를 되돌리기 위해서다.
+ * 그래서 납부완료도 종결 상태가 아니다.
+ *
+ * 안내 문구는 확인필요일 때만 남긴다. 납부확인중으로 되돌리면 아직 처리 전이라는 뜻이므로 처리 시각도 지운다.
+ */
+ public void review(TransferStatus reviewedStatus, String adminNote, LocalDateTime now) {
this.status = reviewedStatus;
- this.reviewedAt = now;
+ this.adminNote = reviewedStatus == TransferStatus.NEEDS_CHECK ? adminNote : null;
+ this.reviewedAt = reviewedStatus == TransferStatus.PENDING ? null : now;
}
private void requireUnpaid() {
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/repository/StudentTransferStatusRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/repository/StudentTransferStatusRepository.java
index d4596599..6264a79f 100644
--- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/repository/StudentTransferStatusRepository.java
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/repository/StudentTransferStatusRepository.java
@@ -10,6 +10,7 @@
public interface StudentTransferStatusRepository {
Optional findByMemberId(Long memberId);
Optional findById(Long id);
+ List findAllByMemberIds(List memberIds);
// memberIds가 null이면 전체 대상, 빈 리스트면 결과 없음을 의도한 것이므로 호출 전에 걸러낸다(빈 리스트로 호출하지 않는다)
PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset);
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java
index ab49881a..525f48ac 100644
--- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java
@@ -1,6 +1,7 @@
package kr.ac.kookmin.stream.welfare.domain.fee.service;
import java.util.List;
+import java.util.Map;
import kr.ac.kookmin.stream.common.PageOffset;
import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus;
@@ -10,6 +11,14 @@ public interface StudentTransferStatusService {
StudentTransferStatus getByMemberId(Long memberId);
+ /**
+ * 회원의 납부 상태 행을 만든다. 이미 있으면 아무것도 하지 않는다.
+ *
+ * 모든 회원이 행을 갖게 해서 관리자 목록이 납부 전 회원까지 보여줄 수 있게 한다. 행이 없는 회원을
+ * 목록에 넣으려면 회원 기준으로 조회해야 하고, 그러면 member 도메인이 회비를 알아야 한다.
+ */
+ void createIfAbsent(Long memberId);
+
/**
* 납부 정보(남은 학기 수·학점이수현황 캡처)를 저장한다. 상태는 납부 전 그대로다.
* 행이 없으면 만들고, 있으면 이전 입력값을 덮어쓴다.
@@ -19,8 +28,12 @@ public interface StudentTransferStatusService {
/** 송금 확인을 요청한다. 납부 정보를 제출하지 않았으면 {@code FEE_REQUEST_NOT_FOUND}다. */
StudentTransferStatus requestConfirmation(Long memberId);
+ /** 회원별 납부 상태. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 제출 전 회원은 빠진다. */
+ Map getMapByMemberIds(List memberIds);
+
// status가 null이면 전체 상태, memberIds가 null이면 회원 필터 없음
PageResult search(TransferStatus status, List memberIds, PageOffset pageOffset);
- StudentTransferStatus review(Long transferStatusId, TransferStatus status);
+ /** 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남는다. */
+ StudentTransferStatus review(Long transferStatusId, TransferStatus status, String adminNote);
}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java
index 37a2df49..f6cfa6c6 100644
--- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java
@@ -2,6 +2,9 @@
import java.time.LocalDateTime;
import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.common.DateUtil;
import kr.ac.kookmin.stream.common.PageOffset;
@@ -28,6 +31,24 @@ public StudentTransferStatus getByMemberId(Long memberId) {
.orElseGet(() -> StudentTransferStatus.notRequested(memberId));
}
+ @Override
+ public Map getMapByMemberIds(List memberIds) {
+ if (memberIds.isEmpty()) {
+ return Map.of();
+ }
+ return studentTransferStatusRepository.findAllByMemberIds(memberIds).stream()
+ .collect(Collectors.toMap(StudentTransferStatus::getMemberId, Function.identity()));
+ }
+
+ @Override
+ @Transactional
+ public void createIfAbsent(Long memberId) {
+ if (studentTransferStatusRepository.findByMemberId(memberId).isPresent()) {
+ return;
+ }
+ studentTransferStatusRepository.save(StudentTransferStatus.create(memberId));
+ }
+
@Override
@Transactional
public StudentTransferStatus submit(Long memberId, int remainingSemesters, String proofImageKey) {
@@ -58,18 +79,11 @@ public PageResult search(TransferStatus status, List new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_FOUND));
- if (transferStatus.getStatus() != TransferStatus.PENDING) {
- throw new BusinessException(FeeErrorCode.FEE_REQUEST_ALREADY_REVIEWED);
- }
- transferStatus.review(status, LocalDateTime.now());
+ transferStatus.review(status, adminNote, LocalDateTime.now(DateUtil.KST));
return studentTransferStatusRepository.save(transferStatus);
}
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaRepository.java
index a6856b84..e5e492ba 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaRepository.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaRepository.java
@@ -13,6 +13,8 @@ public interface StudentTransferStatusJpaRepository extends JpaRepository findByMemberId(Long memberId);
+ List findAllByMemberIdIn(List memberIds);
+
@Query(value = """
SELECT r FROM StudentTransferStatusJpaEntity r
WHERE (:status IS NULL OR r.status = :status)
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusRepositoryImpl.java
index da849279..1d8e325b 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusRepositoryImpl.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusRepositoryImpl.java
@@ -23,6 +23,13 @@ public Optional findByMemberId(Long memberId) {
return studentTransferStatusJpaRepository.findByMemberId(memberId).map(StudentTransferStatusJpaEntity::toDomain);
}
+ @Override
+ public List findAllByMemberIds(List memberIds) {
+ return studentTransferStatusJpaRepository.findAllByMemberIdIn(memberIds).stream()
+ .map(StudentTransferStatusJpaEntity::toDomain)
+ .toList();
+ }
+
@Override
public Optional findById(Long id) {
return studentTransferStatusJpaRepository.findById(id).map(StudentTransferStatusJpaEntity::toDomain);
diff --git a/infrastructure/db/src/main/resources/db/migration/V21__insert_default_student_transfer_statuses.sql b/infrastructure/db/src/main/resources/db/migration/V21__insert_default_student_transfer_statuses.sql
new file mode 100644
index 00000000..fc6b107c
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V21__insert_default_student_transfer_statuses.sql
@@ -0,0 +1,12 @@
+-- 납부 상태 행은 회원이 만들어질 때(첫 로그인) 함께 만든다. 그 전에 들어온 회원에게도 납부 전 행을 만들어 둔다.
+-- 관리자 납부자 목록이 "납부 전" 회원까지 보여주려면 모든 회원에게 행이 있어야 한다.
+-- 행이 없는 회원을 목록에 넣으려면 회원 기준으로 조회해야 하는데, 그러면 member 도메인이 회비를 알아야 한다.
+INSERT INTO student_transfer_statuses (member_id, status)
+SELECT m.member_id, 'UNPAID'
+FROM members m
+WHERE m.is_deleted = 0
+ AND NOT EXISTS (
+ SELECT 1
+ FROM student_transfer_statuses s
+ WHERE s.member_id = m.member_id
+ );