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
- * 납부는 두 단계다. 납부 정보를 제출해도 상태는 납부 전에 머물고, 토스로 송금한 뒤
- * 송금 확인을 요청해야 납부확인중이 된다. 제출만 하고 이탈한 학생을 운영진이
- * 처리 대기로 보지 않기 위해서다.
+ * 납부는 한 번에 요청한다. 학생은 남은 학기 수로 송금 링크를 받아(조회만, 저장하지 않음) 토스에서 송금한 뒤,
+ * 학점이수현황 캡처와 함께 납부를 요청한다. 이때 처음 저장되고 바로 납부확인중이 된다.
+ * 운영진이 확인필요로 돌려보내면 학생은 학기 인증부터 다시 낼 수 있다.
*
* 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다.
*/
@Tag(name = "학생회비", description = "학생 앱 학생회비 납부")
public interface AppFeeApi {
- /** 내 학생회비 납부 현황. 화면은 feeStatus와 studentTransferStatusId로 분기한다. */
+ /** 내 학생회비 납부 현황. 남은 학기 수를 주면 그 값으로 금액과 송금 링크를 계산한다. */
@Operation(summary = "내 학생회비 상태 조회",
- description = "로그인한 학생의 납부 상태와 납부 계좌, 납부 금액과 송금 링크를 조회한다. "
- + "납부 정보를 제출하기 전이거나 확인필요 상태면 금액과 송금 링크는 내려가지 않는다.")
- @ApiErrorCode(type = FeeErrorCode.class, codes = {"FEE_ACCOUNT_NOT_FOUND"})
- ApiResponse
* 검증에 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
- * 페이징과 상태 필터는 회비 쪽에서 한다. 모든 회원이 납부 상태 행을 갖기 때문에(로그인 때 생성,
- * {@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