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 + );