From 38f61fd0cf56eed5cc5b996cba11921bdafeac8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:31:59 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=82=A9=EB=B6=80=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=97=90=20=EA=B8=88=EC=95=A1=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../welfare/domain/fee/domain/StudentTransferStatus.java | 9 ++++++--- .../db/welfare/StudentTransferStatusJpaEntity.java | 6 +++++- .../V24__add_amount_to_student_transfer_statuses.sql | 5 +++++ 3 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 infrastructure/db/src/main/resources/db/migration/V24__add_amount_to_student_transfer_statuses.sql 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 b89ebfb2..3661ffa0 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 @@ -18,6 +18,8 @@ public class StudentTransferStatus { // 학생이 입력한 남은 학기 수와, 그 근거로 올린 학점이수현황 캡처. 제출 전에는 둘 다 null이다 private Integer remainingSemesters; private String proofImageKey; + // 요청 시점에 안내한 납부 금액(남은 학기 수 × 그때의 학기당 회비). 운영진이 학기당 회비를 바꿔도 그대로다 + private Long amount; // 관리자가 확인필요로 처리하며 남긴 안내 문구. REJECTED일 때만 값이 있다 private String adminNote; private LocalDateTime requestedAt; @@ -28,14 +30,14 @@ public class StudentTransferStatus { // 납부 정보를 처음 제출하는 회원. 상태는 납부 전에서 시작하고, 값은 submit이 채운다 public static StudentTransferStatus create(Long memberId) { return new StudentTransferStatus( - null, memberId, TransferStatus.UNPAID, null, null, null, null, null, null, null); + null, memberId, TransferStatus.UNPAID, null, null, null, null, null, null, null, null); } // 아직 아무것도 제출하지 않아 DB에 row가 없는 회원을 위한 가상의 기본값 (저장하지 않는다). // create와 상태가 같아졌으므로 둘의 차이는 "저장되는가" 하나뿐이다. public static StudentTransferStatus notRequested(Long memberId) { return new StudentTransferStatus( - null, memberId, TransferStatus.UNPAID, null, null, null, null, null, null, null); + null, memberId, TransferStatus.UNPAID, null, null, null, null, null, null, null, null); } public static StudentTransferStatus of( @@ -44,6 +46,7 @@ public static StudentTransferStatus of( TransferStatus status, Integer remainingSemesters, String proofImageKey, + Long amount, String adminNote, LocalDateTime requestedAt, LocalDateTime reviewedAt, @@ -51,7 +54,7 @@ public static StudentTransferStatus of( LocalDateTime updatedAt ) { return new StudentTransferStatus(id, memberId, status, remainingSemesters, proofImageKey, - adminNote, requestedAt, reviewedAt, createdAt, updatedAt); + amount, adminNote, requestedAt, reviewedAt, createdAt, updatedAt); } /** diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java index acfd69ea..99a9b0e1 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/StudentTransferStatusJpaEntity.java @@ -46,6 +46,9 @@ public class StudentTransferStatusJpaEntity extends BaseTimeEntity { @Column(name = "proof_image_key", length = 255) private String proofImageKey; + @Column(name = "amount") + private Long amount; + @Column(name = "admin_note", length = 255) private String adminNote; @@ -61,6 +64,7 @@ private StudentTransferStatusJpaEntity(StudentTransferStatus request) { this.status = request.getStatus(); this.remainingSemesters = request.getRemainingSemesters(); this.proofImageKey = request.getProofImageKey(); + this.amount = request.getAmount(); this.adminNote = request.getAdminNote(); this.requestedAt = request.getRequestedAt(); this.reviewedAt = request.getReviewedAt(); @@ -72,6 +76,6 @@ public static StudentTransferStatusJpaEntity from(StudentTransferStatus request) public StudentTransferStatus toDomain() { return StudentTransferStatus.of(id, memberId, status, remainingSemesters, proofImageKey, - adminNote, requestedAt, reviewedAt, getCreatedAt(), getUpdatedAt()); + amount, adminNote, requestedAt, reviewedAt, getCreatedAt(), getUpdatedAt()); } } diff --git a/infrastructure/db/src/main/resources/db/migration/V24__add_amount_to_student_transfer_statuses.sql b/infrastructure/db/src/main/resources/db/migration/V24__add_amount_to_student_transfer_statuses.sql new file mode 100644 index 00000000..a4a9cfd1 --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V24__add_amount_to_student_transfer_statuses.sql @@ -0,0 +1,5 @@ +-- 학생에게 안내한 납부 금액을 요청 시점 값으로 남긴다(남은 학기 수 × 그때의 학기당 회비). +-- 운영진이 학기당 회비를 바꿔도 이미 들어온 요청의 금액은 바뀌지 않게 하려는 것이다. +-- 이 컬럼이 생기기 전의 행은 금액을 모르므로 NULL을 허용한다. 계산 결과가 long이라 BIGINT로 둔다(ddl-auto validate). +ALTER TABLE student_transfer_statuses + ADD COLUMN amount BIGINT NULL AFTER remaining_semesters; From ca3f0c1ef0c568972ffbad2e12904ae7a5bb6f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:32:19 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=ED=95=A0=20=EB=95=8C=20=EB=82=A9=EB=B6=80=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=ED=96=89=EC=9D=84=20=EB=A7=8C=EB=93=A4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/app/core/auth/usecase/OAuthLoginUseCase.java | 6 ------ .../domain/fee/service/StudentTransferStatusService.java | 8 -------- .../service/impl/StudentTransferStatusServiceImpl.java | 9 --------- 3 files changed, 23 deletions(-) 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 e403b29a..a530c56f 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,7 +8,6 @@ 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; @@ -25,16 +24,11 @@ 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/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 525f48ac..95bd0c77 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 @@ -11,14 +11,6 @@ public interface StudentTransferStatusService { StudentTransferStatus getByMemberId(Long memberId); - /** - * 회원의 납부 상태 행을 만든다. 이미 있으면 아무것도 하지 않는다. - *

- * 모든 회원이 행을 갖게 해서 관리자 목록이 납부 전 회원까지 보여줄 수 있게 한다. 행이 없는 회원을 - * 목록에 넣으려면 회원 기준으로 조회해야 하고, 그러면 member 도메인이 회비를 알아야 한다. - */ - void createIfAbsent(Long memberId); - /** * 납부 정보(남은 학기 수·학점이수현황 캡처)를 저장한다. 상태는 납부 전 그대로다. * 행이 없으면 만들고, 있으면 이전 입력값을 덮어쓴다. 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 f6cfa6c6..1a48bd1c 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 @@ -40,15 +40,6 @@ public Map getMapByMemberIds(List memberIds) .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) { From c816202118960c4462aca07effc81dca6914207d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:34:44 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EB=82=A9=EB=B6=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A0=9C=EC=B6=9C=EA=B3=BC=20=EC=86=A1=EA=B8=88=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88=EC=97=90=20=EB=B0=9B=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/admin/welfare/fee/AdminFeeApi.java | 2 +- .../stream/api/app/welfare/fee/AppFeeApi.java | 39 +++++------ .../api/app/welfare/fee/AppFeeController.java | 30 +++++---- .../welfare/fee/request/FeeSubmitRequest.java | 2 +- .../fee/response/FeeConfirmResponse.java | 16 ----- .../app/welfare/fee/response/FeeResponse.java | 67 ++++++++++++------- .../fee/response/FeeSubmitResponse.java | 20 ------ .../welfare/fee/usecase/FeeSubmitUseCase.java | 43 +++++------- .../domain/fee/domain/FeeErrorCode.java | 2 +- .../fee/domain/StudentTransferStatus.java | 58 +++++++--------- .../domain/fee/domain/TransferStatus.java | 2 +- .../fee/service/FeeAmountCalculator.java | 18 +++++ .../service/StudentTransferStatusService.java | 15 +++-- .../StudentTransferStatusServiceImpl.java | 27 +++----- .../V25__remove_unpaid_transfer_statuses.sql | 10 +++ 15 files changed, 166 insertions(+), 185 deletions(-) delete mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeConfirmResponse.java delete mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java create mode 100644 infrastructure/db/src/main/resources/db/migration/V25__remove_unpaid_transfer_statuses.sql 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 fd6a1fe3..961cd640 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 @@ -41,7 +41,7 @@ ApiResponse> getRequests( @Operation(summary = "학생회비 납부 상태 수정", description = "납부 상태를 바꾼다. 잘못 처리한 경우를 되돌릴 수 있도록 이미 처리한 요청도 다시 바꿀 수 있다. " + "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 안내 문구가 필요하다. " - + "확인필요 학생은 직접 재제출할 수 없으므로, 다시 내게 하려면 납부 전(UNPAID)으로 되돌린다.") + + "확인필요로 돌려받은 학생은 안내 문구를 보고 학기 인증부터 다시 낼 수 있다.") @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_TRANSFER_STATUS", "INVALID_ADMIN_NOTE", "FEE_REQUEST_NOT_FOUND"}) ApiResponse updateStatus( diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java index 9316b183..c68ed261 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/AppFeeApi.java @@ -4,9 +4,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest; -import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeConfirmResponse; import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeResponse; -import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeSubmitResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; @@ -14,36 +12,31 @@ /** * 학생 앱 학생회비 API의 문서 명세. 구현은 {@link AppFeeController}가 맡는다. *

- * 납부는 두 단계다. 납부 정보를 제출해도 상태는 납부 전에 머물고, 토스로 송금한 뒤 - * 송금 확인을 요청해야 납부확인중이 된다. 제출만 하고 이탈한 학생을 운영진이 - * 처리 대기로 보지 않기 위해서다. + * 납부는 한 번에 요청한다. 학생은 남은 학기 수로 송금 링크를 받아(조회만, 저장하지 않음) 토스에서 송금한 뒤, + * 학점이수현황 캡처와 함께 납부를 요청한다. 이때 처음 저장되고 바로 납부확인중이 된다. + * 운영진이 확인필요로 돌려보내면 학생은 학기 인증부터 다시 낼 수 있다. *

* 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. */ @Tag(name = "학생회비", description = "학생 앱 학생회비 납부") public interface AppFeeApi { - /** 내 학생회비 납부 현황. 화면은 feeStatus와 studentTransferStatusId로 분기한다. */ + /** 내 학생회비 납부 현황. 남은 학기 수를 주면 그 값으로 금액과 송금 링크를 계산한다. */ @Operation(summary = "내 학생회비 상태 조회", - description = "로그인한 학생의 납부 상태와 납부 계좌, 납부 금액과 송금 링크를 조회한다. " - + "납부 정보를 제출하기 전이거나 확인필요 상태면 금액과 송금 링크는 내려가지 않는다.") - @ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_ACCOUNT_NOT_FOUND"}) - ApiResponse getFeeByMemberId(AppApiUser apiUser); + description = "로그인한 학생의 납부 상태와 납부 계좌를 조회한다. 아무것도 저장하지 않는다. " + + "납부 전(UNPAID)이거나 확인필요(REJECTED)인 학생이 remainingSemesters(남은 학기 수, 1~8)를 보내면 " + + "그 값으로 납부 금액과 송금 링크를 계산해 준다. 납부확인중·납부완료면 요청할 때 남긴 금액만 보여주고 " + + "송금 링크는 주지 않는다.") + @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_REMAINING_SEMESTERS", "FEE_ACCOUNT_NOT_FOUND"}) + ApiResponse getFeeByMemberId(AppApiUser apiUser, Integer remainingSemesters); - /** 납부 정보 제출. 상태는 납부 전 그대로이고, 돌려받은 송금 링크로 토스에서 송금한다. */ - @Operation(summary = "학생회비 납부 정보 제출", - description = "남은 학기 수와 학점이수현황 캡처를 제출하고, 납부 금액이 붙은 토스 송금 링크를 받는다. " - + "상태는 납부 전 그대로이며 이전에 제출한 값과 이미지는 덮어쓴다. " - + "확인필요로 처리된 학생은 직접 다시 제출할 수 없고 운영진이 납부 전으로 되돌려야 한다.") + /** 학생회비 납부 요청. 학기 인증과 송금 완료를 한 번에 받아 바로 납부확인중이 된다. */ + @Operation(summary = "학생회비 납부 요청", + description = "토스로 송금을 마친 학생이 남은 학기 수와 학점이수현황 캡처를 함께 보내 운영진의 확인을 요청한다. " + + "요청 시점의 금액(남은 학기 수 × 학기당 회비)을 함께 남긴다. " + + "처음 내는 학생과 확인필요로 돌려받은 학생만 요청할 수 있고, 돌려받은 학생은 이전 요청을 덮어쓴다.") @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_REMAINING_SEMESTERS", "INVALID_PROOF_IMAGE", "FEE_ACCOUNT_NOT_FOUND", "FEE_REQUEST_ALREADY_REQUESTED"}) - ApiResponse submit(AppApiUser apiUser, FeeSubmitRequest request); - - /** 송금 확인 요청. 여기서 비로소 납부확인중으로 바뀐다. */ - @Operation(summary = "학생회비 송금 확인 요청", - description = "송금을 마친 학생이 운영진의 확인을 요청한다. 납부 정보를 제출한 학생만 요청할 수 있다.") - @ApiErrorCode(type = FeeErrorCode.class, - codes = {"FEE_REQUEST_NOT_FOUND", "FEE_REQUEST_ALREADY_REQUESTED"}) - ApiResponse requestConfirmation(AppApiUser apiUser); + ApiResponse submit(AppApiUser apiUser, FeeSubmitRequest request); } 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 882ab145..80ea794b 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 @@ -2,18 +2,17 @@ import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.welfare.fee.request.FeeSubmitRequest; -import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeConfirmResponse; import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeResponse; -import kr.ac.kookmin.stream.api.app.welfare.fee.response.FeeSubmitResponse; import kr.ac.kookmin.stream.api.app.welfare.fee.usecase.FeeSubmitUseCase; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; +import kr.ac.kookmin.stream.welfare.domain.fee.service.FeeAmountCalculator; import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -25,24 +24,27 @@ public class AppFeeController implements AppFeeApi { private final FeeAccountProvider feeAccountProvider; private final FeeSubmitUseCase feeSubmitUseCase; + // 남은 학기 수는 금액과 송금 링크를 계산할 때만 쓴다. 이 조회는 아무것도 저장하지 않는다 @Override @GetMapping("/me") - public ApiResponse getFeeByMemberId(AppApiUser apiUser) { - StudentTransferStatus transferStatus = studentTransferStatusService.getByMemberId(apiUser.userId()); + public ApiResponse getFeeByMemberId( + AppApiUser apiUser, + @RequestParam(required = false) Integer remainingSemesters + ) { + Integer semesters = remainingSemesters == null + ? null + : FeeAmountCalculator.validateRemainingSemesters(remainingSemesters); + FeeAccount account = feeAccountProvider.getAccount(); - return ApiResponse.success(FeeResponse.of(transferStatus, feeAccountProvider.getAccount())); + FeeResponse response = studentTransferStatusService.findByMemberId(apiUser.userId()) + .map(transferStatus -> FeeResponse.of(transferStatus, account, semesters)) + .orElseGet(() -> FeeResponse.notRequested(account, semesters)); + return ApiResponse.success(response); } @Override @PostMapping - public ApiResponse submit(AppApiUser apiUser, @RequestBody FeeSubmitRequest request) { + public ApiResponse submit(AppApiUser apiUser, @RequestBody FeeSubmitRequest request) { return ApiResponse.success(feeSubmitUseCase.submit(apiUser.userId(), request)); } - - @Override - @PostMapping("/requests/confirm") - public ApiResponse requestConfirmation(AppApiUser apiUser) { - return ApiResponse.success( - FeeConfirmResponse.from(studentTransferStatusService.requestConfirmation(apiUser.userId()))); - } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java index a702d993..da4f9db8 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/request/FeeSubmitRequest.java @@ -3,7 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; /** - * 납부 정보 제출. 남은 학기 수와 그 근거가 되는 학점이수현황 캡처를 받는다. + * 학생회비 납부 요청. 송금을 마친 뒤 남은 학기 수와 그 근거가 되는 학점이수현황 캡처를 함께 보낸다. *

* 검증에 Bean Validation을 쓰지 않는다 — 명세가 항목마다 전용 에러 코드를 요구하는데 * 애노테이션으로는 공통 INVALID_INPUT이 되기 때문이다(coding-style 검증 규칙, fee-domain-study 08번 문서). diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeConfirmResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeConfirmResponse.java deleted file mode 100644 index d8dc0c1e..00000000 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeConfirmResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -package kr.ac.kookmin.stream.api.app.welfare.fee.response; - -import java.time.LocalDateTime; -import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; - -/** 송금 확인 요청 결과. */ -public record FeeConfirmResponse( - Long studentTransferStatusId, - String feeStatus, - LocalDateTime requestedAt -) { - public static FeeConfirmResponse from(StudentTransferStatus transferStatus) { - return new FeeConfirmResponse( - transferStatus.getId(), transferStatus.getStatus().name(), transferStatus.getRequestedAt()); - } -} 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 5bb11ca7..7f035dfc 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 @@ -6,8 +6,11 @@ import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; /** - * 학생회비 납부 현황. 화면은 feeStatus와 studentTransferStatusId로 분기한다 — - * 납부 전이면서 id가 null이면 남은 학기 입력, id가 있으면 송금 완료 여부 확인 화면이다. + * 학생회비 납부 현황. 화면은 feeStatus로 분기한다 — 납부 전(UNPAID)·확인필요(REJECTED)면 남은 학기 입력부터, + * 납부확인중·납부완료면 요청 내용 확인 화면이다. + *

+ * 금액과 송금 링크는 지금 낼 수 있을 때만 계산해 준다. 납부 전·확인필요인 학생이 남은 학기 수를 보내면 그 값으로 + * 계산하고, 납부확인중·납부완료면 요청할 때 남긴 금액만 보여주고 링크는 주지 않는다("또 보내라"로 읽히지 않게). */ public record FeeResponse( Long studentTransferStatusId, @@ -21,29 +24,43 @@ public record FeeResponse( LocalDateTime confirmedAt, String adminNote ) { - /** - * 금액과 송금 링크는 보낼 금액을 정할 수 있을 때만 내려준다. 제출 전이면 남은 학기 수를 모르고, - * 확인필요면 아직 납부가 확인되지 않아 화면이 납부금액을 "-"로 둔다. 확인필요에서 송금 링크를 - * 다시 주면 "또 보내라"로 읽히는 것도 이유다. 재제출은 관리자가 납부 전으로 되돌린 뒤에 열린다. - */ - public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount account) { - Integer remainingSemesters = transferStatus.getRemainingSemesters(); - boolean payable = remainingSemesters != null && transferStatus.getStatus() != TransferStatus.REJECTED; - - Long transferAmount = payable ? account.calculateTransferAmount(remainingSemesters) : null; - - return new FeeResponse( - transferStatus.getId(), - transferStatus.getStatus().name(), - account.bank(), - account.accountNo(), - remainingSemesters, - transferAmount, - transferAmount == null ? null : account.generateTransferLinkUrl(transferAmount), + + // 한 번도 요청하지 않은 회원의 상태. 도메인 상태에는 없고(행이 없다) 응답에서만 쓰는 이름이다 + private static final String NOT_REQUESTED_STATUS = "UNPAID"; + + /** 한 번도 요청하지 않은 학생. 남은 학기 수를 받았으면 그 값으로 금액과 송금 링크를 계산한다. */ + public static FeeResponse notRequested(FeeAccount account, Integer remainingSemesters) { + Long transferAmount = amountOf(account, remainingSemesters); + + return new FeeResponse(null, NOT_REQUESTED_STATUS, account.bank(), account.accountNo(), + remainingSemesters, transferAmount, linkOf(account, transferAmount), null, null, null); + } + + /** 요청이 있는 학생. remainingSemesters는 확인필요(다시 낼 수 있는 상태)일 때만 쓴다. */ + public static FeeResponse of(StudentTransferStatus transferStatus, FeeAccount account, Integer remainingSemesters) { + TransferStatus status = transferStatus.getStatus(); + + // 반려면 다시 낼 수 있으므로 미납부와 같이 다룬다 — 이전 요청 값 대신 이번에 받은 남은 학기 수로 계산한다 + if (status == TransferStatus.REJECTED) { + Long transferAmount = amountOf(account, remainingSemesters); + return new FeeResponse(transferStatus.getId(), status.name(), account.bank(), account.accountNo(), + remainingSemesters, transferAmount, linkOf(account, transferAmount), + transferStatus.getRequestedAt(), null, transferStatus.getAdminNote()); + } + + return new FeeResponse(transferStatus.getId(), status.name(), account.bank(), account.accountNo(), + transferStatus.getRemainingSemesters(), transferStatus.getAmount(), null, transferStatus.getRequestedAt(), - // 납부 확인 시각은 PAID일 때의 처리 시각이다. 그 외 상태에서는 보여주지 않는다 - transferStatus.getStatus() == TransferStatus.PAID ? transferStatus.getReviewedAt() : null, - transferStatus.getStatus() == TransferStatus.REJECTED ? transferStatus.getAdminNote() : null - ); + // 납부 확인 시각은 PAID일 때의 처리 시각이다 + status == TransferStatus.PAID ? transferStatus.getReviewedAt() : null, + null); + } + + private static Long amountOf(FeeAccount account, Integer remainingSemesters) { + return remainingSemesters == null ? null : account.calculateTransferAmount(remainingSemesters); + } + + private static String linkOf(FeeAccount account, Long transferAmount) { + return transferAmount == null ? null : account.generateTransferLinkUrl(transferAmount); } } 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 deleted file mode 100644 index bc37cf02..00000000 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/fee/response/FeeSubmitResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package kr.ac.kookmin.stream.api.app.welfare.fee.response; - -import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount; - -/** 납부 정보 제출 결과. 학생은 이 계좌와 금액으로 토스에서 송금한다. */ -public record FeeSubmitResponse( - String bank, - String accountNo, - Integer remainingSemesters, - Long transferAmount, - String transferLinkUrl -) { - public static FeeSubmitResponse of(int remainingSemesters, FeeAccount account) { - long transferAmount = account.calculateTransferAmount(remainingSemesters); - - return new FeeSubmitResponse( - account.bank(), account.accountNo(), remainingSemesters, - 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 3d9d55bd..dcfeff74 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 @@ -4,13 +4,14 @@ import kr.ac.kookmin.stream.api.app.welfare.fee.FeeAccount; 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.api.app.welfare.fee.response.FeeResponse; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.file.domain.FileCategory; import kr.ac.kookmin.stream.file.service.FileService; 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.service.FeeAmountCalculator; import kr.ac.kookmin.stream.welfare.domain.fee.service.StudentTransferStatusService; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; @@ -18,9 +19,10 @@ import org.springframework.stereotype.Component; /** - * 납부 정보 제출. {@code welfare}(납부 상태)와 {@code file}(인증 이미지) 두 도메인을 조합한다. + * 학생회비 납부 요청. 학기 인증(남은 학기 수·학점이수현황 캡처)과 송금 완료를 한 번에 받는다. + * {@code welfare}(납부 요청)와 {@code file}(인증 이미지) 두 도메인을 조합한다. *

- * 메서드에 {@code @Transactional}을 붙이지 않는다. 저장은 {@code StudentTransferStatusService.submit}이 + * 메서드에 {@code @Transactional}을 붙이지 않는다. 저장은 {@code StudentTransferStatusService.request}가 * 자기 트랜잭션에서 끝내고, 이전 이미지 삭제는 R2 호출이라 롤백되지 않아 그 커밋 뒤에 해야 하기 때문이다 * ({@code architecture.md} 6-1절 "롤백 불가한 외부 부수효과"). */ @@ -30,39 +32,30 @@ public class FeeSubmitUseCase { private static final Logger log = LoggerFactory.getLogger(FeeSubmitUseCase.class); - private static final int MIN_REMAINING_SEMESTERS = 1; - private static final int MAX_REMAINING_SEMESTERS = 8; - private final StudentTransferStatusService studentTransferStatusService; private final FeeAccountProvider feeAccountProvider; private final FileService fileService; - public FeeSubmitResponse submit(Long memberId, FeeSubmitRequest request) { - int remainingSemesters = validateRemainingSemesters(request.remainingSemesters()); + public FeeResponse submit(Long memberId, FeeSubmitRequest request) { + int remainingSemesters = FeeAmountCalculator.validateRemainingSemesters(request.remainingSemesters()); validateProofImage(request.proofImageKey(), memberId); // 계좌가 없으면 송금 자체가 불가능하므로, 저장하기 전에 먼저 막는다 FeeAccount account = feeAccountProvider.getAccount(); + long amount = account.calculateTransferAmount(remainingSemesters); - // 교체될 이전 키를 알려고 한 번 더 읽는다. submit이 돌려주게 하면 반환 타입에 값을 하나 더 얹어야 해서, - // 유니크 인덱스 조회 한 번을 받아들였다. 같은 학생의 동시 제출이 겹치면 낡은 키를 볼 수 있는데, - // 그때 생기는 건 고아 파일 하나뿐이라 파일 정리 배치가 걷어간다. - String previousProofImageKey = studentTransferStatusService.getByMemberId(memberId).getProofImageKey(); + // 반려 후 다시 내면 이전 요청을 덮어쓰므로, 교체될 이전 키를 알려고 한 번 더 읽는다. request가 돌려주게 하면 + // 반환 타입에 값을 하나 더 얹어야 해서, 유니크 인덱스 조회 한 번을 받아들였다. 같은 학생의 동시 요청이 겹치면 + // 낡은 키를 볼 수 있는데, 그때 생기는 건 고아 파일 하나뿐이라 파일 정리 배치가 걷어간다. + String previousProofImageKey = studentTransferStatusService.findByMemberId(memberId) + .map(StudentTransferStatus::getProofImageKey) + .orElse(null); StudentTransferStatus saved = - studentTransferStatusService.submit(memberId, remainingSemesters, request.proofImageKey()); + studentTransferStatusService.request(memberId, remainingSemesters, request.proofImageKey(), amount); deletePreviousProofImage(previousProofImageKey, saved.getProofImageKey()); - return FeeSubmitResponse.of(remainingSemesters, account); - } - - private int validateRemainingSemesters(Integer remainingSemesters) { - if (remainingSemesters == null - || remainingSemesters < MIN_REMAINING_SEMESTERS - || remainingSemesters > MAX_REMAINING_SEMESTERS) { - throw new BusinessException(FeeErrorCode.INVALID_REMAINING_SEMESTERS); - } - return remainingSemesters; + return FeeResponse.of(saved, account, null); } // 실재하는 키인지, 본인이 올린 것인지, 학점이수현황 캡처로 올린 것인지 본다. @@ -82,9 +75,9 @@ private void validateProofImage(String proofImageKey, Long memberId) { } /** - * 재제출로 교체된 이전 이미지를 지운다. 아무도 가리키지 않게 된 데다 학번·이름이 찍혀 있어 남겨둘 이유가 없다. + * 다시 내면서 교체된 이전 이미지를 지운다. 아무도 가리키지 않게 된 데다 학번·이름이 찍혀 있어 남겨둘 이유가 없다. *

- * 삭제가 실패해도 제출은 성공으로 둔다 — 고아 파일 하나가 남는 편이 제출 실패보다 낫다. + * 삭제가 실패해도 요청은 성공으로 둔다 — 고아 파일 하나가 남는 편이 요청 실패보다 낫다. * 남은 것은 파일 정리 배치가 걷어간다. */ private void deletePreviousProofImage(String previousKey, String currentKey) { 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 9850480b..d1afd9c4 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 @@ -11,7 +11,7 @@ @AllArgsConstructor public enum FeeErrorCode implements ErrorCode { - // 상태 문자열이 UNPAID/PENDING/PAID/REJECTED가 아닌 경우에 쓴다 + // 상태 문자열이 PENDING/PAID/REJECTED가 아닌 경우에 쓴다 INVALID_TRANSFER_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 납부 상태입니다."), INVALID_REMAINING_SEMESTERS(ErrorStatus.BAD_REQUEST, "유효하지 않은 남은 학기 수입니다."), INVALID_PROOF_IMAGE(ErrorStatus.BAD_REQUEST, "유효하지 않은 인증 이미지입니다."), 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 3661ffa0..60c6b4ca 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 @@ -15,7 +15,7 @@ public class StudentTransferStatus { private Long id; private Long memberId; private TransferStatus status; - // 학생이 입력한 남은 학기 수와, 그 근거로 올린 학점이수현황 캡처. 제출 전에는 둘 다 null이다 + // 학생이 입력한 남은 학기 수와, 그 근거로 올린 학점이수현황 캡처 private Integer remainingSemesters; private String proofImageKey; // 요청 시점에 안내한 납부 금액(남은 학기 수 × 그때의 학기당 회비). 운영진이 학기당 회비를 바꿔도 그대로다 @@ -27,17 +27,19 @@ public class StudentTransferStatus { private LocalDateTime createdAt; private LocalDateTime updatedAt; - // 납부 정보를 처음 제출하는 회원. 상태는 납부 전에서 시작하고, 값은 submit이 채운다 - public static StudentTransferStatus create(Long memberId) { - return new StudentTransferStatus( - null, memberId, TransferStatus.UNPAID, null, null, null, null, null, null, null, null); - } - - // 아직 아무것도 제출하지 않아 DB에 row가 없는 회원을 위한 가상의 기본값 (저장하지 않는다). - // create와 상태가 같아졌으므로 둘의 차이는 "저장되는가" 하나뿐이다. - public static StudentTransferStatus notRequested(Long memberId) { - return new StudentTransferStatus( - null, memberId, TransferStatus.UNPAID, null, null, null, null, null, null, null, null); + /** + * 송금을 마친 학생의 첫 납부 요청. 학기 인증(남은 학기 수·학점이수현황 캡처)과 송금 완료를 + * 한 번에 받으므로 바로 납부확인중이 된다. + */ + public static StudentTransferStatus request( + Long memberId, + int remainingSemesters, + String proofImageKey, + long amount, + LocalDateTime now + ) { + return new StudentTransferStatus(null, memberId, TransferStatus.PENDING, remainingSemesters, proofImageKey, + amount, null, now, null, null, null); } public static StudentTransferStatus of( @@ -58,28 +60,22 @@ public static StudentTransferStatus of( } /** - * 납부 정보(남은 학기 수·학점이수현황 캡처)를 제출한다. 상태는 납부 전 그대로다 — - * 송금 확인 요청({@link #requestConfirmation()})을 해야 납부확인중으로 넘어간다. + * 반려된 학생이 학기 인증부터 다시 납부한다. 이전 요청의 입력값과 처리 흔적(안내 문구·처리 시각)을 + * 지우고 새 요청으로 만든다. *

- * 납부 전에서만 가능하다. 확인필요로 처리된 회원은 직접 다시 제출하지 못하고, - * 관리자가 납부 전으로 되돌려야 한다(화면의 진입점이 "문의하기" 하나뿐이다). + * 반려에서만 가능하다. 납부확인중이면 이미 운영진에게 요청이 가 있고, 납부완료면 더 낼 것이 없다. */ - public void submit(int remainingSemesters, String proofImageKey) { - requireUnpaid(); + public void resubmit(int remainingSemesters, String proofImageKey, long amount, LocalDateTime now) { + if (status != TransferStatus.REJECTED) { + throw new BusinessException(FeeErrorCode.FEE_REQUEST_ALREADY_REQUESTED); + } + this.status = TransferStatus.PENDING; this.remainingSemesters = remainingSemesters; this.proofImageKey = proofImageKey; - } - - /** - * 송금 확인을 요청한다. 이전 처리 흔적(관리자 안내 문구·처리 시각)은 지워 새 요청으로 만든다. - */ - public void requestConfirmation(LocalDateTime now) { - requireUnpaid(); - - this.status = TransferStatus.PENDING; - this.requestedAt = now; + this.amount = amount; this.adminNote = null; + this.requestedAt = now; this.reviewedAt = null; } @@ -94,10 +90,4 @@ public void review(TransferStatus reviewedStatus, String adminNote, LocalDateTim this.adminNote = reviewedStatus == TransferStatus.REJECTED ? adminNote : null; this.reviewedAt = reviewedStatus == TransferStatus.PENDING ? null : now; } - - private void requireUnpaid() { - if (status != TransferStatus.UNPAID) { - throw new BusinessException(FeeErrorCode.FEE_REQUEST_ALREADY_REQUESTED); - } - } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java index bea8025c..62cafa66 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/TransferStatus.java @@ -2,8 +2,8 @@ import kr.ac.kookmin.stream.common.BusinessException; +// 납부 요청의 상태. 한 번도 요청하지 않은 회원(미납부)은 행이 없으므로 여기에 상태가 없다 public enum TransferStatus { - UNPAID, PENDING, PAID, // 운영진이 납부를 확인하지 못해 돌려보낸 상태. 화면에서는 "확인필요"로 보인다 diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java index b61d941c..88def836 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/FeeAmountCalculator.java @@ -1,11 +1,29 @@ package kr.ac.kookmin.stream.welfare.domain.fee.service; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; + // 남은 학기 수만큼만 회비를 걷는다 — 남은 학기 수 × 학기당 금액. // 남은 학기 수는 학생이 직접 입력하고 학점이수현황 캡처로 증빙한다(예전에는 학년·학기로 계산했다). public final class FeeAmountCalculator { + private static final int MIN_REMAINING_SEMESTERS = 1; + private static final int MAX_REMAINING_SEMESTERS = 8; + private FeeAmountCalculator() {} + /** + * 남은 학기 수가 1~8인지 본다. 송금 링크를 받을 때와 납부를 요청할 때 같은 범위를 쓰도록 여기에 둔다. + */ + public static int validateRemainingSemesters(Integer remainingSemesters) { + if (remainingSemesters == null + || remainingSemesters < MIN_REMAINING_SEMESTERS + || remainingSemesters > MAX_REMAINING_SEMESTERS) { + throw new BusinessException(FeeErrorCode.INVALID_REMAINING_SEMESTERS); + } + return remainingSemesters; + } + public static long calculate(int remainingSemesters, long amountPerSemester) { return (long) remainingSemesters * amountPerSemester; } 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 95bd0c77..3bdbf75e 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 @@ -2,6 +2,7 @@ import java.util.List; import java.util.Map; +import java.util.Optional; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; @@ -9,16 +10,16 @@ public interface StudentTransferStatusService { - StudentTransferStatus getByMemberId(Long memberId); + /** 회원의 납부 요청. 한 번도 요청하지 않은 회원(미납부)이면 비어 있다. */ + Optional findByMemberId(Long memberId); /** - * 납부 정보(남은 학기 수·학점이수현황 캡처)를 저장한다. 상태는 납부 전 그대로다. - * 행이 없으면 만들고, 있으면 이전 입력값을 덮어쓴다. + * 학기 인증과 송금 완료를 한 번에 받아 납부확인중으로 만든다. 처음이면 새로 만들고, 반려된 요청이면 다시 낸다. + * 납부확인중·납부완료면 {@code FEE_REQUEST_ALREADY_REQUESTED}다. + * + * @param amount 요청 시점에 안내한 납부 금액 */ - StudentTransferStatus submit(Long memberId, int remainingSemesters, String proofImageKey); - - /** 송금 확인을 요청한다. 납부 정보를 제출하지 않았으면 {@code FEE_REQUEST_NOT_FOUND}다. */ - StudentTransferStatus requestConfirmation(Long memberId); + StudentTransferStatus request(Long memberId, int remainingSemesters, String proofImageKey, long amount); /** 회원별 납부 상태. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 제출 전 회원은 빠진다. */ Map getMapByMemberIds(List memberIds); 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 1a48bd1c..ff961934 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 @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; @@ -26,9 +27,8 @@ class StudentTransferStatusServiceImpl implements StudentTransferStatusService { // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다(PR#67/#69 원칙 — 조회니까 붙이는 게 아니라 실제 이득이 있을 때만 붙인다). @Override - public StudentTransferStatus getByMemberId(Long memberId) { - return studentTransferStatusRepository.findByMemberId(memberId) - .orElseGet(() -> StudentTransferStatus.notRequested(memberId)); + public Optional findByMemberId(Long memberId) { + return studentTransferStatusRepository.findByMemberId(memberId); } @Override @@ -42,21 +42,14 @@ public Map getMapByMemberIds(List memberIds) @Override @Transactional - public StudentTransferStatus submit(Long memberId, int remainingSemesters, String proofImageKey) { + public StudentTransferStatus request(Long memberId, int remainingSemesters, String proofImageKey, long amount) { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); StudentTransferStatus transferStatus = studentTransferStatusRepository.findByMemberId(memberId) - .orElseGet(() -> StudentTransferStatus.create(memberId)); - transferStatus.submit(remainingSemesters, proofImageKey); - return studentTransferStatusRepository.save(transferStatus); - } - - // 행이 있어도 남은 학기 수가 없으면 제출 전이다(옛 흐름에서 만들어진 행이 여기 해당한다). - @Override - @Transactional - public StudentTransferStatus requestConfirmation(Long memberId) { - StudentTransferStatus transferStatus = studentTransferStatusRepository.findByMemberId(memberId) - .filter(existing -> existing.getRemainingSemesters() != null) - .orElseThrow(() -> new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_FOUND)); - transferStatus.requestConfirmation(LocalDateTime.now(DateUtil.KST)); + .map(existing -> { + existing.resubmit(remainingSemesters, proofImageKey, amount, now); + return existing; + }) + .orElseGet(() -> StudentTransferStatus.request(memberId, remainingSemesters, proofImageKey, amount, now)); return studentTransferStatusRepository.save(transferStatus); } diff --git a/infrastructure/db/src/main/resources/db/migration/V25__remove_unpaid_transfer_statuses.sql b/infrastructure/db/src/main/resources/db/migration/V25__remove_unpaid_transfer_statuses.sql new file mode 100644 index 00000000..8b4c67aa --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V25__remove_unpaid_transfer_statuses.sql @@ -0,0 +1,10 @@ +-- 미납부는 이제 행이 없는 상태다. 납부 정보 제출과 송금 확인 요청이 한 번으로 합쳐져 "제출만 한" 상태가 없어졌다. +-- UNPAID 행 중 운영진이 처리한 적 있는 행(reviewed_at이 있음)은 돌려보낸 요청이었으므로 REJECTED로 남기고, +-- 처리된 적 없는 행(V21이 전 회원에게 만든 행, 제출만 하고 확인 요청을 안 한 행)은 지운다. +UPDATE student_transfer_statuses +SET status = 'REJECTED' +WHERE status = 'UNPAID' + AND reviewed_at IS NOT NULL; + +DELETE FROM student_transfer_statuses +WHERE status = 'UNPAID'; From d977fcba747318816ebe3b324cba7c242dab9436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:36:11 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EA=B8=88=EC=95=A1=EC=9D=84=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=8B=9C=EC=A0=90=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=97=AC=EC=A4=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/admin/welfare/fee/AdminFeeApi.java | 6 +++--- .../stream/api/admin/welfare/fee/FeeAccount.java | 6 ------ .../fee/response/AdminFeeSearchResponse.java | 10 ++++------ .../welfare/fee/usecase/AdminFeeSearchUseCase.java | 13 +++---------- .../fee/service/StudentTransferStatusService.java | 2 +- 5 files changed, 11 insertions(+), 26 deletions(-) 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 961cd640..f94ee34c 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 @@ -24,10 +24,10 @@ @Tag(name = "학생회비 (운영진)", description = "학생회비 납부 관리") public interface AdminFeeApi { - /** 납부자 목록. 회원 기준이라 납부 정보를 내지 않은 "납부 전" 회원도 포함된다. */ + /** 납부 요청 목록. 납부를 요청한 회원만 나온다. */ @Operation(summary = "학생회비 납부자 목록 조회", - description = "이름·학번 검색과 납부 상태 필터로 회원을 조회한다. 납부 정보를 내지 않은 회원도 " - + "납부 전으로 함께 나오며, 그런 행은 studentTransferStatusId를 비롯한 납부 값이 모두 null이다.") + description = "이름·학번 검색과 납부 상태 필터로 납부를 요청한 회원을 조회한다. " + + "한 번도 요청하지 않은 회원은 나오지 않는다. 금액은 요청 시점에 학생에게 안내한 값이다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = FeeErrorCode.class, codes = {"INVALID_TRANSFER_STATUS"}) ApiResponse> getRequests( 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 index 29a9f047..2f4439e6 100644 --- 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 @@ -1,6 +1,5 @@ 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; /** @@ -24,9 +23,4 @@ public boolean isConfigured() { 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/response/AdminFeeSearchResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/response/AdminFeeSearchResponse.java index 3a833d28..cc8f6874 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,7 +1,6 @@ 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; @@ -9,8 +8,7 @@ import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; /** - * 납부자 목록의 한 줄. 모든 회원이 납부 상태 행을 가지므로 아직 아무것도 내지 않은 회원도 들어온다 — - * 그런 줄은 상태가 납부 전이고 제출 관련 값이 모두 null이다. + * 납부자 목록의 한 줄. 납부를 요청한 회원만 들어온다 — 한 번도 요청하지 않은 회원은 행이 없다. */ public record AdminFeeSearchResponse( Long studentTransferStatusId, @@ -34,8 +32,7 @@ public record AdminFeeSearchResponse( public static AdminFeeSearchResponse of( StudentTransferStatus transferStatus, Member member, - boolean signedUp, - FeeAccount account + boolean signedUp ) { return new AdminFeeSearchResponse( transferStatus.getId(), @@ -46,7 +43,8 @@ public static AdminFeeSearchResponse of( signedUp, transferStatus.getStatus().name(), transferStatus.getRemainingSemesters(), - account.calculateTransferAmount(transferStatus.getRemainingSemesters()), + // 요청 시점에 남긴 금액이다. 운영진이 학기당 회비를 바꿔도 이미 들어온 요청의 금액은 그대로 보인다 + transferStatus.getAmount(), StorageUrlBuilder.build(transferStatus.getProofImageKey()), transferStatus.getRequestedAt(), // 납부 확인 시각은 납부완료일 때의 처리 시각이다 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 a0b70ced..cc4edc12 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,8 +2,6 @@ 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; @@ -18,9 +16,8 @@ /** * 납부자 목록. {@code welfare}와 {@code member} 두 도메인을 조합한다. *

- * 페이징과 상태 필터는 회비 쪽에서 한다. 모든 회원이 납부 상태 행을 갖기 때문에(로그인 때 생성, - * {@code V21}로 기존 회원 백필) 납부 전 회원도 그대로 걸린다. 회원 기준으로 뒤집으면 member 도메인이 - * 회비 상태를 알아야 하는데, 회비 때문에 남의 도메인을 고치지 않으려고 방향을 이렇게 잡았다. + * 페이징과 상태 필터는 회비 쪽에서 한다. 납부를 요청한 회원만 행이 있으므로 목록도 요청한 회원만 나온다 + * (한 번도 요청하지 않은 미납부 회원은 관리자 화면에서 다루지 않는다). *

* member 쪽은 이미 공개된 조회 메서드만 쓴다 — 검색은 {@code searchIdsByKeyword}, 회원 정보는 {@code getMapByIds}. *

@@ -32,7 +29,6 @@ public class AdminFeeSearchUseCase { private final StudentTransferStatusService studentTransferStatusService; private final MemberService memberService; - private final FeeAccountProvider feeAccountProvider; public PageResult search(TransferStatus status, String keyword, PageOffset pageOffset) { List memberIds = null; @@ -48,16 +44,13 @@ public PageResult search(TransferStatus status, String k // 탈퇴(소프트 삭제)한 회원은 빠지므로 그 회원의 행은 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 + memberService.isSignedUp(transferStatus.getMemberId()) )); } } 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 3bdbf75e..dbe830b0 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 @@ -21,7 +21,7 @@ public interface StudentTransferStatusService { */ StudentTransferStatus request(Long memberId, int remainingSemesters, String proofImageKey, long amount); - /** 회원별 납부 상태. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 제출 전 회원은 빠진다. */ + /** 회원별 납부 상태. 목록이 회원 기준이라 회원 한 페이지만큼 한 번에 가져온다. 한 번도 요청하지 않은 회원은 빠진다. */ Map getMapByMemberIds(List memberIds); // status가 null이면 전체 상태, memberIds가 null이면 회원 필터 없음