diff --git a/docs/credits.md b/docs/credits.md index d84b93e..bcb6cdc 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -74,7 +74,7 @@ expiresAt은 해당 생성 시도의 lease 기한이다. DB DATETIME(6) 정밀 유상/무료 모두 무기한 정책으로 확정해 기본값은 NO_EXPIRY다. 이전 UNDECIDED를 명시한 운영 설정은 활성화 전 수정해야 한다. 상품 유효기간과 예약 lease는 다른 값이며, 생성 시도의 예약 기한/복구는 유지한다. -이미지 사용량은 설정된 경우 양수여야 하고, 가입 지급량 설정은 음수일 수 없으며 신규 온보딩 지급에는 양수가 필요하다(기본3, 0/null은503). 미설정 기능은503으로 실패한다. 운영 비용 환경변수를 지정하면 기본 비용을 덮어쓴다. 무료 1회는 고정 재생성 단가를 0으로 설정하는 방식이 아니며 별도 이력으로 처리한다. 새 사용을 비활성화해도 기존 예약의 확정·실패 해제·만료 복구는 계속 가능하다. 결제 상품 가격은 페이앱 상품 설정으로 관리한다. #62의 외부 취소 통보는 PAYMENT_HOLD 원장/계정 사용 보류로 보호한다. 구매 환불 신청·자동 회수/보류 해제는 구현하지 않았다. +이미지 사용량은 설정된 경우 양수여야 하고, 가입 지급량 설정은 음수일 수 없으며 신규 온보딩 지급에는 양수가 필요하다(기본3, 0/null은503). 미설정 기능은503으로 실패한다. 운영 비용 환경변수를 지정하면 기본 비용을 덮어쓴다. 무료 1회는 고정 재생성 단가를 0으로 설정하는 방식이 아니며 별도 이력으로 처리한다. 새 사용을 비활성화해도 기존 예약의 확정·실패 해제·만료 복구는 계속 가능하다. 결제 상품 가격은 페이앱 상품 설정으로 관리한다. #62의 외부 취소 통보는 PAYMENT_HOLD 원장/계정 사용 보류로 보호한다. [구매별 환불 접수](payment-refund-intake.md)는 해당 지급분만 새 이미지 예약에서 제외한다. PG 환불 실행과 정산·보류 해제는 후속 범위다. ## 오류와 DB 적용 diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index cabaa0d..3db08af 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -250,7 +250,7 @@ Refresh는 앱 전체에서 한 번씩 실행되도록 묶는다. StrictMode· - PAYMENT_REVIEW 또는 잔액 `spendingBlocked=true`이면 구매/사용을 막고 확인 안내를 표시한다. - `POST /api/v1/payments/payapp/feedback`은 페이앱 서버 전용 FORM 통보이며 프론트에서 호출하지 않는다. - 잔액은 available/reserved/total 및 보류 여부를 구분한다. 최초 온보딩 완료 시 기본3크레딧, 크레딧 무기한. 지급 내역이 없는 기존 완료 계정은0이며 자동 소급하지 않는다. 구매 취소와 생성 실패 예약 해제는 다른 처리다. -- 구매 환불 신청·자동 회수/보류 해제 API는 아직 없다. 법정 환불 접수·운영 취소 처리 경로는 별도 준비 중이며 일괄 환불 불가를 확정한 것은 아니다. +- 구매별 환불 접수와 조회는 [환불 접수 계약](payment-refund-intake.md)을 따른다. 페이앱 환불 실행과 정산은 후속 범위다. ## 무료 크레딧 우선 차감 (#130) @@ -280,7 +280,7 @@ onboardingCompleted는 기존 닉네임 온보딩 상태로 유지합니다. 기 - `USER_409_3`: 실제 결제 승인 여부 확인 필요. 단순 결제창 진입만으로는 제한하지 않습니다. - 잔액 조회에는 paidAvailable/paidReserved/freeAvailable/freeReserved가 추가됐으며 기존 잔액 필드는 유지합니다. - 예약분과 유료 크레딧 수량은 환불 가능 금액을 뜻하지 않습니다. 프론트가 환불액·접수 여부를 계산하거나 탈퇴 요청에 전달하지 않습니다. -- 환불 접수 기능은 후속 이슈입니다. 서버의 기본 접수 판정은 유료 잔액 탈퇴를 차단하며 유료 출시는 환불 연동 완료 이후입니다. +- 남은 유료 구매분을 모두 [환불 접수](payment-refund-intake.md)한 뒤 탈퇴를 재시도합니다. 탈퇴 전에 접수번호와 문의 이메일을 저장할 수 있도록 안내합니다. - 재가입은 허용하지만 이전 데이터·잔액을 복원하지 않고 같은 카카오 계정에 최초 가입 보상을 다시 지급하지 않습니다. 탈퇴는 복구 불가능하며 이미지 파일·카카오 연결은 후속 정리될 수 있습니다. @@ -292,7 +292,7 @@ onboardingCompleted는 기존 닉네임 온보딩 상태로 유지합니다. 기 | 항목 | 현재 상태 | |---|---| | 이미지 크레딧 과금 | 최초3·첫 재생성 성공 무료·이후1 구현. 가격 미설정 시 무료 요청도 거절하며 실제 운영 활성화는 별도 | -| 판매 상품·기간 이용권·환불 | 판매 조건·상품 설정 확정과 실결제 검증 별도. 기간 이용권·환불 신청 API 없음 | +| 판매 상품·기간 이용권·환불 | 실결제 운영 검증, 기간 이용권 및 PG 환불 실행은 별도. 유료분 환불 접수는 #132로 추가 | | 스타일4개 고정 카탈로그·프롬프트 | #86 구현·머지. 키·표시 이름은 docs/image-generation-policy.md 참고, 실제 생성 품질 검증 별도 | | 세계관 전체 이미지 | #77 구현·통합. 정확한 SQL 적용과 운영 활성화·실검증은 별도 | | 무료 종합 해석 생성/저장/갱신 | #84 구현. 기본 비활성, LINER 실검증 및 프론트 연동 필요 | diff --git a/docs/payapp-payments.md b/docs/payapp-payments.md index bfc39e3..3a17f49 100644 --- a/docs/payapp-payments.md +++ b/docs/payapp-payments.md @@ -4,7 +4,7 @@ 개인사업자 계정의 상품·실결제 승인은 팀에서 확인했으며 실제 청구·통보·정산 종단 검증은 별도다. 기본은 신규 결제/Provider/크레딧 지급 모두 비활성이다. [실결제 설정·프론트 복구·운영 검증 안내](payapp-live-readiness.md)를 함께 확인한다. 유상/무료 크레딧 모두 무기한으로 사용하는 정책을 반영해 expiry-policy 기본은 NO_EXPIRY로 변경했다. -결제 모듈은 구매 환불 신청·PG 자동 환불·실물 상품을 포함하지 않는다. 가입 지급과 이미지 과금은 별도 크레딧 기능을 재사용한다. +[구매별 환불 접수](payment-refund-intake.md)는 #132에서 추가했다. PG 환불 실행·실물 상품은 별도 범위다. 가입 지급과 이미지 과금은 별도 크레딧 기능을 재사용한다. 신규 이미지 예약은 무료 지급분을 먼저 사용하고 부족한 수량만 구매 지급분에서 사용한다(#130). 같은 그룹 안에서는 지급 ID 순서를 유지하며 기존 예약·사용 원장은 소급 변경하지 않는다. 구매별 사용량은 계정 전체 잔액이 아니라 저장된 출처별 예약·확정 배분으로 확인한다. [크레딧 예약 계약](credits.md)을 따른다. ## 공개 계약 @@ -135,7 +135,7 @@ mongle: ## 환불 기능과 정책의 구분 -서비스 내 구매 환불 신청·자동 회수/보류 해제 API는 아직 없다. 이는 미사용 유료 크레딧을 일괄 환불 불가로 정한 정책이 아니다. 법정 청약철회·환불을 접수하고 실제 거래와 지급 출처·예약·사용량을 대조해 처리하는 별도 운영 경로가 필요하다. 페이앱 관리자 취소만으로 몽글 크레딧 회수가 끝나지는 않는다. 외부 취소 수신 시 현재 구현은 사용 보류를 설정한다. +[환불 접수 API](payment-refund-intake.md)는 구매별 예상 금액과 신청을 기록하고 대상 구매분의 사용을 보류한다. 페이앱에서의 승인 취소, 취소 결과 확인 및 회수·보류 해제는 후속 정산 경로에서 처리한다. 결제사에서 먼저 취소 통보가 오면 기존 보류가 적용되고, 접수 내역의 현재 산정 상태는 검토 대상으로 바뀐다. #64 이미지 생성 실패의 예약 해제는 구매 대금 환불과 다르다. 사용하지 않은 예약을 원래 잔액으로 돌려주며 이미 성공 확정한 생성 비용을 임의로 반환하지 않는다. 무기한 크레딧 정책을 환불 권리 포기로 해석하지 않는다. diff --git a/docs/payment-reclamation-command.md b/docs/payment-reclamation-command.md index 5d5594e..586eca6 100644 --- a/docs/payment-reclamation-command.md +++ b/docs/payment-reclamation-command.md @@ -93,7 +93,7 @@ sudo /usr/bin/systemd-run --quiet --wait --pipe --collect \ - REQUEST_CONFLICT / RECLAMATION_WRITE_CONFLICT: 키·기존 처리·동시 쓰기 충돌. 새 키로 우회하지 않는다. - RECLAMATION_RECORD_INCONSISTENT / POSTCONDITION_FAILED: 기존 이력 또는 적용 후 검증 불일치. 새 변경은 롤백하며 운영 검토로 넘긴다. -부분 사용·부분취소·실제 환불 신청/PG 취소 API·임의 보류 해제는 후속 범위다. 모든 보류를 한꺼번에 지우는 명령은 제공하지 않는다. +환불 신청이 접수된 주문은 REFUND_INTAKE_EXISTS로 거절한다. 부분 사용·부분취소·PG 취소 및 접수 건 정산은 별도 범위다. 모든 보류를 한꺼번에 지우는 명령은 제공하지 않는다. ## 프론트 반영 사항 diff --git a/docs/payment-refund-intake.md b/docs/payment-refund-intake.md new file mode 100644 index 0000000..10ea86c --- /dev/null +++ b/docs/payment-refund-intake.md @@ -0,0 +1,67 @@ +# 유료 크레딧 환불 접수 (#132) + +## 프론트 연동 + +모든 경로는 온보딩을 완료한 회원의 Bearer 인증과 기존 `ApiResponse`를 사용한다. 응답에는 `Cache-Control: no-store`를 설정한다. 최신 약관 동의가 필요한 기존 회원도 환불 경로를 이용할 수 있다. + +| API | 사용 | +| --- | --- | +| `GET /api/v1/payments/refunds/estimate?orderId={UUID}&reason=STANDARD` | 본인 주문의 구매·사용·예약·미사용 수량과 예상 원화 금액 확인 | +| `POST /api/v1/payments/refunds` | 해당 주문의 남은 유료분에 대한 접수 및 보류 | +| `GET /api/v1/payments/refunds/{receiptId}` | 접수 당시 산정 근거와 현재 산정 상태 확인 | +| `GET /api/v1/payments/refunds?beforeId=&size=20` | 내 접수 목록. 크기 1~50, `hasNext`이면 `nextBeforeId`를 다음 요청에 사용 | + +요청 예시 + +```json +{ + "requestId": "6bf6a5ea-22a6-43ea-88dd-28780b42a3a1", + "orderId": "5f5654b2-716c-4a25-944a-ae512ccf434b", + "reason": "STANDARD" +} +``` + +`orderId`는 결제 API의 공개 주문 UUID다. `requestId`는 버튼을 누를 때 생성하고 같은 요청 재전송에 재사용한다. +일반 환불은 `STANDARD`, 청약철회·서비스 하자 등 판단이 필요한 신청은 `RIGHTS_REVIEW`를 사용한다. +금액·수량·결제사 번호는 클라이언트가 제출하지 않는다. 주문마다 접수 하나가 생성되며 동일 요청은 같은 `receiptId`로 응답한다. + +접수 후 `receiptId`, `receivedAt`, `supportEmail`을 사용자에게 보여주고 저장할 수 있게 한다. +`initialStatus`와 `*AtRequest` 필드는 접수 당시 기록이다. `currentEstimate`는 예약 확정·해제나 결제 상태가 바뀐 뒤 다시 계산한 값이며, 접수 당시 시각을 기준으로 수수료 경계를 판정한다. 표시 문구는 ‘환불 신청 접수’로 하고, 예상액이 있는 경우에도 완료 또는 확정 금액으로 표시하지 않는다. + +| 산정 상태 | 화면 처리 | +| --- | --- | +| `ESTIMATED` | 예상 금액과 공제율 표시 | +| `WAITING_RESERVATION` | 진행 중인 이미지 사용량을 확인한 뒤 예상액 갱신. 접수는 가능 | +| `REVIEW_REQUIRED` | 승인 시각 미확인, 별도 권리 검토 또는 기존 결제 취소 확인. 금액은 null | +| `NO_REFUNDABLE_BALANCE` | 일반 환불에 사용할 미사용분 없음. 권리 검토 접수는 별도 안내 | + +| 오류 | 처리 | +| --- | --- | +| `REFUND_404_1` | 없는 주문·접수 또는 타인 소유 | +| `REFUND_409_1` | 이미 접수된 주문 또는 요청 UUID 충돌. 접수 목록 확인 | +| `REFUND_409_2` | 주문·지급 내역이 신청 조건을 충족하지 않음 | +| `REFUND_503_1` | 접수 기능 준비 전 | +| 400 | 요청 형식·페이지 값 확인 | + +크레딧 잔액 API는 원장 합계를 반환한다. 환불 접수된 구매분의 미사용량은 위 접수 응답에서 확인한다. +보류분은 새 이미지 예약에서 빠지므로 원장 잔액만 충분하고 실제 차감 가능한 금액이 부족하면 `CREDIT_409_1`을 반환한다. +기존의 결제 취소 확인 보류는 `CREDIT_409_5`로 처리된다. + +## 산정과 탈퇴 + +- 일반 예상액은 원주문 결제액 × (미사용 수량 ÷ 구매 수량)이다. 검증한 승인 시각부터 정확히 168시간까지 공제율은 0%, 그 이후 일반 환불 공제율은 10%다. 공제를 포함한 전체 산식의 결과를 마지막에 한 번 원 단위로 올림한다. +- [페이앱 결제통보 문서](https://docs.payapp.kr/dev_center01.html)의 `pay_date`를 KST 기준으로 해석해 신규 지급의 최초 승인 시각에 저장한다. 지원 형식은 `yyyyMMddHHmmss`, `yyyy-MM-dd HH:mm:ss`다. 저장 시 초 단위 반올림에 따른 최대 1초의 차이를 허용한다. 이 범위를 벗어나 주문 생성보다 이르거나 통보 시각보다 늦은 값, 누락·파싱 실패와 기존 주문의 승인 시각은 검토 대상으로 남긴다. `responsedate`는 기존 통보 인증·유효성 확인에 사용한다. +- 실제 지급과 동일한 사용자 행 잠금에서 주문·구매 지급분·예약·소유자를 다시 확인한다. 환불 신청은 기존 결제 취소 보류와 별개이며, 해당 구매분의 차감만 막는다. 기존 이미지 예약의 성공·실패 처리는 계속 가능하다. +- 모든 남은 유료 구매분이 접수 범위에 포함되면 탈퇴를 진행한다. 기존 탈퇴 처리에서 진행 중 예약을 해제하므로 접수된 유료분은 그 범위 내에서 다시 미사용분으로 돌아간다. 접수 이후 추가로 지급된 다른 구매분이 있으면 그 구매분도 확인한다. +- 탈퇴 후 로그인 세션은 해제된다. 사용자는 탈퇴 전에 저장한 접수번호로 고객센터에 문의한다. 접수번호는 검색 식별자이므로 고객센터는 거래와 신청자 확인을 거쳐 상태를 안내한다. +- 미완료 환불은 거래 보존기간이 지났어도 파기하지 않는다. 페이앱에서 이미 취소된 주문을 회수하는 기존 명령도 접수된 건을 거절하므로 동일 구매분을 이중 처리하지 않는다. + +## 배포와 공개 + +1. 기존 CD로 `V9__payment_refund_intake.sql`을 적용한다. 기존 결제의 승인 시각과 환불 접수 건은 백필하지 않는다. +2. `MONGLE_PAYMENT_REFUND_ENABLED` 기본값은 false다. 활성화 시 공개 문의 주소 `MONGLE_PAYMENT_REFUND_SUPPORT_EMAIL`이 필요하다. +3. 실제 페이앱 취소와 크레딧 회수, 접수 종료·보류 해제 처리 경로를 준비한 뒤 공개한다. 이 배포의 상태는 접수 및 검토 대기다. +4. 신규 접수를 끌 때도 이미 접수된 건의 조회·보류·탈퇴 확인·보존은 계속 동작한다. 문의 주소는 유지한다. 접수가 생성된 운영 DB에서 V9 이전 앱은 새 구매분 보류를 읽을 수 없으므로 신규 접수를 중지할 때는 V9 앱의 설정을 사용한다. + +운영자는 접수번호로 `payment_refund_requests`와 원주문·지급 원장을 대조한다. +서버의 예상액은 실제 카드 취소 증빙을 대신하지 않으므로 페이앱 거래 확인 후 후속 정산 경로에서 처리한다. diff --git a/docs/user-withdrawal.md b/docs/user-withdrawal.md index a8e3f91..275532b 100644 --- a/docs/user-withdrawal.md +++ b/docs/user-withdrawal.md @@ -18,13 +18,11 @@ SIGNUP/ATTENDANCE/PROMOTION은 무료다. 지급 출처를 한 번에 SUM 집계 ## 범위와 후속 환불 기능 -회원탈퇴 기능은 기본 비활성이다. 환불 신청 UI/API, 금액 계산, PG 환불 실행, 안내 경로는 후속 이슈다. -현재 기본 WithdrawalRefundPolicy는 유료 잔액을 승인하지 않는다. -후속 환불 서비스는 이 포트를 대체해 같은 사용자 잠금 안에서 실제 DB 접수 내역이 -모든 남은 유료 구매분을 커버하는지 확인해야 한다. 클라이언트의 boolean이나 -이메일 발송만으로 접수됐다고 처리하면 안 된다. -hasOpenRefunds는 보존기간 종료 시 미완료 접수 건의 삭제를 막아야 한다. -이 연결 전에는 유료 기능을 출시하지 않는다. +회원탈퇴와 환불 접수는 기본 비활성이다. [환불 접수 계약](payment-refund-intake.md)에 따라 +같은 사용자 잠금 아래 실제 DB 접수 내역이 모든 남은 유료 구매분(사용 가능분과 진행 중 예약분)을 커버하는지 확인한다. +접수 완료 후 탈퇴하면 진행 중인 이미지 예약은 기존 절차로 해제하고 유료분과 접수는 별도로 보존한다. +탈퇴 전 접수번호와 문의 이메일을 안내하며, 미완료 접수 건은 보존기간 종료 시에도 파기하지 않는다. +페이앱 승인 취소 실행과 취소 결과에 따른 정산은 후속 작업이다. ## 삭제·보존 표 diff --git a/scripts/mysql84-test.py b/scripts/mysql84-test.py index 4b96085..aaee386 100644 --- a/scripts/mysql84-test.py +++ b/scripts/mysql84-test.py @@ -94,7 +94,7 @@ def stop_on_signal(signum, frame): migration_password = secrets.token_hex(24) sql(f"CREATE USER 'mongle_migrator'@'127.0.0.1' IDENTIFIED BY '{migration_password}' REQUIRE SSL;") migration_databases = ("flyway_fresh", "flyway_existing", "flyway_drift", "flyway_failed", - "flyway_checksum", "flyway_privilege", "flyway_jpa", "flyway_v1", "flyway_cli", "flyway_check", "flyway_summary", "flyway_reclamation", "flyway_legal") + "flyway_checksum", "flyway_privilege", "flyway_jpa", "flyway_v1", "flyway_cli", "flyway_check", "flyway_summary", "flyway_reclamation", "flyway_legal", "flyway_refund") schema = (repo / "src/main/resources/db/schema-initial.sql").read_text() for database in migration_databases: sql(f"CREATE DATABASE {database} CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;") diff --git a/src/main/java/com/mongle/backend/domain/credit/CreditAccountRepository.java b/src/main/java/com/mongle/backend/domain/credit/CreditAccountRepository.java index e9c4cbb..92abd1f 100644 --- a/src/main/java/com/mongle/backend/domain/credit/CreditAccountRepository.java +++ b/src/main/java/com/mongle/backend/domain/credit/CreditAccountRepository.java @@ -1,3 +1,11 @@ package com.mongle.backend.domain.credit; import org.springframework.data.jpa.repository.JpaRepository; -public interface CreditAccountRepository extends JpaRepository {} +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CreditAccountRepository extends JpaRepository { + @Modifying + @Query("update CreditAccount a set a.version = a.version + 1 where a.userId = :user") + int invalidateSpendingSnapshot(@Param("user") Long userId); +} diff --git a/src/main/java/com/mongle/backend/domain/credit/CreditGrantRepository.java b/src/main/java/com/mongle/backend/domain/credit/CreditGrantRepository.java index 1b526ea..078be90 100644 --- a/src/main/java/com/mongle/backend/domain/credit/CreditGrantRepository.java +++ b/src/main/java/com/mongle/backend/domain/credit/CreditGrantRepository.java @@ -7,8 +7,16 @@ public interface CreditGrantRepository extends JpaRepository { Optional findFirstByUserIdAndSourceTypeOrderByIdAsc(Long userId, CreditSource sourceType); Optional findBySourceTypeAndSourceHash(CreditSource type, String hash); + String SPENDABLE_GRANTS = """ + from credit_grants g where g.user_id = :userId and g.available > 0 + and not exists (select 1 from payment_refund_requests r where r.grant_id = g.id) + """; + + @Query(value = "select coalesce(sum(g.available), 0) " + SPENDABLE_GRANTS, nativeQuery = true) + long spendable(@Param("userId") Long userId); + // Apply priority before pagination; retain grant order within free and paid groups. - @Query("select g from CreditGrant g where g.userId=:userId and g.available>0 " - + "order by case when g.sourceType=com.mongle.backend.domain.credit.CreditSource.PAYMENT then 1 else 0 end,g.id") + @Query(value = "select g.* " + SPENDABLE_GRANTS + + "order by case when g.source_type = 'PAYMENT' then 1 else 0 end, g.id", nativeQuery = true) List available(@Param("userId") Long userId, Pageable page); } diff --git a/src/main/java/com/mongle/backend/domain/credit/CreditReservationService.java b/src/main/java/com/mongle/backend/domain/credit/CreditReservationService.java index 6daded8..900b456 100644 --- a/src/main/java/com/mongle/backend/domain/credit/CreditReservationService.java +++ b/src/main/java/com/mongle/backend/domain/credit/CreditReservationService.java @@ -41,6 +41,7 @@ public CreditReservationReceipt reserve(Long userId, String requestKey, CreditUs throw new BusinessException(CreditErrorCode.REQUEST_CONFLICT); credits.requireSpendingAllowed(userId); long amount = properties.cost(usage); + if (grants.spendable(userId) < amount) throw new BusinessException(CreditErrorCode.INSUFFICIENT); var account = credits.account(userId); account.reserve(amount); var reservation = reservations.saveAndFlush(CreditReservation.create(userId, requestKey, usage, targetKey, amount, expiresAt)); diff --git a/src/main/java/com/mongle/backend/domain/payment/PayAppFeedback.java b/src/main/java/com/mongle/backend/domain/payment/PayAppFeedback.java index db18c26..2ed8a06 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PayAppFeedback.java +++ b/src/main/java/com/mongle/backend/domain/payment/PayAppFeedback.java @@ -2,7 +2,7 @@ import java.time.Instant; /** 인증 필드·전화번호·원문 통보는 영속하거나 로그에 남기지 않는다. */ record PayAppFeedback(String orderId,String token,String transactionId,int state,long amount, - String originalTransaction,Long originalAmount,Instant respondedAt,String eventHash) { + String originalTransaction,Long originalAmount,Instant respondedAt,Instant approvedAt,String eventHash) { boolean partialCancellation() { return state==70 || state==71; } boolean cancellation() { return partialCancellation() || state==8 || state==32 || state==9 || state==64; } @Override public String toString() { return "PayAppFeedback[redacted]"; } diff --git a/src/main/java/com/mongle/backend/domain/payment/PayAppFeedbackVerifier.java b/src/main/java/com/mongle/backend/domain/payment/PayAppFeedbackVerifier.java index f36f691..051b42f 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PayAppFeedbackVerifier.java +++ b/src/main/java/com/mongle/backend/domain/payment/PayAppFeedbackVerifier.java @@ -12,6 +12,10 @@ public class PayAppFeedbackVerifier { private final PayAppProperties properties; private final Clock authClock; private static final DateTimeFormatter DATE=DateTimeFormatter.ofPattern("uuuuMMddHHmmss").withResolverStyle(ResolverStyle.STRICT); + private static final DateTimeFormatter APPROVAL_DATE = + DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT); + private static final ZoneId PAYAPP_ZONE = ZoneId.of("Asia/Seoul"); + PayAppFeedback verify(Map values) { if(!properties.enabled()) throw new BusinessException(PaymentErrorCode.UNAVAILABLE); try { @@ -21,7 +25,7 @@ PayAppFeedback verify(Map values) { || !PaymentHashes.equal(value,properties.linkValue()) || !hash.matches("[0-9a-fA-F]{64}") || !PaymentHashes.equal(hash.toLowerCase(Locale.ROOT),PaymentHashes.sha256(seller+date+properties.requestKey()))) invalid(); if(!date.matches("[0-9]{14}")) invalid(); - Instant responded=LocalDateTime.parse(date,DATE).atZone(ZoneId.of("Asia/Seoul")).toInstant(); + Instant responded=LocalDateTime.parse(date,DATE).atZone(PAYAPP_ZONE).toInstant(); String order=field(values,"var1",36),token=field(values,"var2",36),transaction=id(values,"mul_no"); if(!UUID.fromString(order).toString().equals(order) || !UUID.fromString(token).toString().equals(token)) invalid(); int state=Integer.parseInt(field(values,"pay_state",2)); @@ -31,9 +35,21 @@ PayAppFeedback verify(Map values) { if(state==70 || state==71) { original=id(values,"orig_mul_no"); originalAmount=amount(values,"orig_price"); } String canonical=String.join(":",order,token,transaction,Integer.toString(state),Long.toString(amount), original==null?"":original,originalAmount==null?"":originalAmount.toString(),date); - return new PayAppFeedback(order,token,transaction,state,amount,original,originalAmount,responded,PaymentHashes.sha256(canonical)); + Instant approved = state == 4 ? approvalDate(values.get("pay_date"), responded) : null; + return new PayAppFeedback(order,token,transaction,state,amount,original,originalAmount,responded,approved,PaymentHashes.sha256(canonical)); } catch(RuntimeException e) { throw new BusinessException(PaymentErrorCode.INVALID_FEEDBACK); } } + private static Instant approvalDate(String value, Instant responded) { + if (value == null || (value.length() != 14 && value.length() != 19)) return null; + try { + var approved = LocalDateTime.parse(value, value.length() == 14 ? DATE : APPROVAL_DATE) + .atZone(PAYAPP_ZONE).toInstant(); + return approved.isAfter(responded) ? null : approved; + } catch (DateTimeParseException ignored) { + return null; + } + } + void requireFresh(PayAppFeedback event) { Instant now=authClock.instant(); if(event.respondedAt().isBefore(now.minus(Duration.ofMinutes(15))) || event.respondedAt().isAfter(now.plusSeconds(120))) invalid(); diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentConfiguration.java b/src/main/java/com/mongle/backend/domain/payment/PaymentConfiguration.java index 519894f..87f8d85 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentConfiguration.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentConfiguration.java @@ -5,7 +5,7 @@ import java.time.Clock; import java.util.concurrent.CompletableFuture; @Configuration(proxyBeanMethods = false) -@EnableConfigurationProperties({PaymentProperties.class,PayAppProperties.class}) +@EnableConfigurationProperties({PaymentProperties.class,PayAppProperties.class, RefundProperties.class}) public class PaymentConfiguration { @Bean @ConditionalOnMissingBean(PaymentGateway.class) PaymentGateway paymentGateway(PayAppProperties properties,Clock authClock) { diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentFeedbackService.java b/src/main/java/com/mongle/backend/domain/payment/PaymentFeedbackService.java index e83ba77..2e91a5d 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentFeedbackService.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentFeedbackService.java @@ -47,7 +47,7 @@ public void accept(PayAppFeedback event) { order.review(); } else if(order.getCreditGrantId()==null) { var grant=credits.grant(user,CreditSource.PAYMENT,sourceKey(order),order.getCredits()); - order.paid(grant.grantId()); + order.paid(grant.grantId(), event.approvedAt()); } } case 8,32,9,64,70,71 -> { diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentOrder.java b/src/main/java/com/mongle/backend/domain/payment/PaymentOrder.java index 120252c..40d0629 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentOrder.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentOrder.java @@ -4,6 +4,8 @@ import jakarta.persistence.*; import lombok.*; import java.time.Instant; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; import java.util.UUID; @Getter @Entity @@ -29,6 +31,8 @@ public enum State { REQUESTING, READY, REQUEST_REJECTED, UNKNOWN, PENDING, PAID, @Column(name="provider_transaction",length=40) private String providerTransaction; @Column(name="payment_url",length=2048) private String paymentUrl; @Column(name="credit_grant_id") private Long creditGrantId; + @org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.TIMESTAMP) + @Column(name="approved_at") private Instant approvedAt; @Column(name="request_deadline",nullable=false) private Instant requestDeadline; @Version @Column(nullable=false) private long version; @@ -54,7 +58,17 @@ void requestFinished(PaymentGateway.Result result) { } } void pending() { if(state==State.REQUESTING || state==State.READY || state==State.UNKNOWN) state=State.PENDING; } - void paid(Long grantId) { state=State.PAID; creditGrantId=grantId; paymentUrl=null; } + void paid(Long grantId, Instant approved) { + state = State.PAID; + creditGrantId = grantId; + paymentUrl = null; + // DATETIME(0) can round the order creation time up to the next second in MySQL. + Instant earliestApproval = getCreatedAt().toInstant(ZoneOffset.UTC) + .truncatedTo(ChronoUnit.SECONDS).minusSeconds(1); + if (approved != null && !approved.isBefore(earliestApproval)) { + approvedAt = approved; + } + } void cancelled() { state=creditGrantId==null ? State.CANCELLED : State.PAYMENT_REVIEW; paymentUrl=null; } void review() { state=State.PAYMENT_REVIEW; paymentUrl=null; } } diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRepository.java b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRepository.java new file mode 100644 index 0000000..f59a3ba --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRepository.java @@ -0,0 +1,35 @@ +package com.mongle.backend.domain.payment; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +interface PaymentRefundRepository extends JpaRepository { + Optional findByUserIdAndRequestId(Long userId, String requestId); + Optional findByUserIdAndReceiptId(Long userId, String receiptId); + boolean existsByGrantId(Long grantId); + boolean existsByUserId(Long userId); + + @Query(""" + select r from PaymentRefundRequest r join fetch r.order join fetch r.grant + where r.userId = :user and (:before is null or r.id < :before) + order by r.id desc + """) + List page(@Param("user") Long userId, @Param("before") Long before, Pageable page); + + @Query(value = """ + select count(*) from credit_grants g + where g.user_id = :user and g.source_type = 'PAYMENT' and (g.available > 0 or g.reserved > 0) + and not exists ( + select 1 from payment_refund_requests r join payment_orders o on o.id = r.order_id + where r.user_id = g.user_id and r.grant_id = g.id and o.user_id = g.user_id + and o.credit_grant_id = g.id and o.state in ('PAID', 'PAYMENT_REVIEW') + and r.available_at_request + r.reserved_at_request >= g.available + g.reserved + ) + """, nativeQuery = true) + long uncoveredPaidGrants(@Param("user") Long userId); +} diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRequest.java b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRequest.java new file mode 100644 index 0000000..c6eff02 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRequest.java @@ -0,0 +1,68 @@ +package com.mongle.backend.domain.payment; + +import com.mongle.backend.domain.credit.CreditGrant; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "payment_refund_requests") +public class PaymentRefundRequest { + public enum Reason { STANDARD, RIGHTS_REVIEW } + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false, length = 36) + private String receiptId; + @Column(nullable = false, length = 36) + private String requestId; + @Column(nullable = false) + private Long userId; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "order_id", nullable = false) + private PaymentOrder order; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "grant_id", nullable = false) + private CreditGrant grant; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 24) + private Reason reason; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 24) + private RefundEstimate.Status initialStatus; + @Column(nullable = false) + private long availableAtRequest; + @Column(nullable = false) + private long reservedAtRequest; + @Column(nullable = false) + private long usedAtRequest; + private Long estimatedAmount; + private Integer feePercent; + @JdbcTypeCode(SqlTypes.TIMESTAMP) @Column(nullable = false) + private Instant createdAt; + + static PaymentRefundRequest receive(PaymentOrder order, CreditGrant grant, UUID requestId, + Reason reason, RefundEstimate estimate, Instant now) { + var refund = new PaymentRefundRequest(); + refund.receiptId = UUID.randomUUID().toString(); + refund.requestId = requestId.toString(); + refund.userId = order.getUserId(); + refund.order = order; + refund.grant = grant; + refund.reason = reason; + refund.initialStatus = estimate.status(); + refund.availableAtRequest = estimate.unusedCredits(); + refund.reservedAtRequest = estimate.reservedCredits(); + refund.usedAtRequest = estimate.usedCredits(); + refund.estimatedAmount = estimate.estimatedAmount(); + refund.feePercent = estimate.feePercent(); + refund.createdAt = now; + return refund; + } +} diff --git a/src/main/java/com/mongle/backend/domain/payment/RefundController.java b/src/main/java/com/mongle/backend/domain/payment/RefundController.java new file mode 100644 index 0000000..797e090 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/RefundController.java @@ -0,0 +1,66 @@ +package com.mongle.backend.domain.payment; + +import com.mongle.backend.global.config.SwaggerConfig; +import com.mongle.backend.global.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/payments/refunds") +@Tag(name = "Refund", description = "본인 유료 크레딧 환불 접수 및 조회") +@SecurityRequirement(name = SwaggerConfig.BEARER_AUTH) +public class RefundController { + private final RefundService refunds; + + @GetMapping("/estimate") + @Operation(summary = "본인 주문의 예상 환불액 조회", + description = "구매별 미사용·예약 수량을 조회합니다. 예약 중이거나 별도 검토가 필요하면 금액은 null입니다.") + public ResponseEntity> estimate( + @Parameter(hidden = true) @AuthenticationPrincipal Jwt jwt, @RequestParam UUID orderId, + @RequestParam(defaultValue = "STANDARD") PaymentRefundRequest.Reason reason) { + return respond(refunds.estimate(userId(jwt), orderId, reason)); + } + + @PostMapping(consumes = "application/json") + @Operation(summary = "본인 주문의 환불 신청 접수", + description = "같은 요청 UUID와 주문·사유를 재전송하면 같은 접수번호를 반환합니다. 대상 구매분을 보류하고 문의 이메일을 반환합니다.") + public ResponseEntity> receive( + @Parameter(hidden = true) @AuthenticationPrincipal Jwt jwt, @Valid @RequestBody RefundRequest request) { + return respond(refunds.receive(userId(jwt), request)); + } + + @GetMapping("/{receiptId}") + @Operation(summary = "본인 환불 접수 상세 조회") + public ResponseEntity> get( + @Parameter(hidden = true) @AuthenticationPrincipal Jwt jwt, @PathVariable UUID receiptId) { + return respond(refunds.get(userId(jwt), receiptId)); + } + + @GetMapping + @Operation(summary = "본인 환불 접수 목록 조회", description = "기본 20개, 최대 50개입니다.") + public ResponseEntity> page( + @Parameter(hidden = true) @AuthenticationPrincipal Jwt jwt, + @RequestParam(required = false) Long beforeId, @RequestParam(defaultValue = "20") int size) { + return respond(refunds.page(userId(jwt), beforeId, size)); + } + + private static long userId(Jwt jwt) { + return Long.parseLong(jwt.getSubject()); + } + + private static ResponseEntity> respond(T data) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(ApiResponse.success(data)); + } +} diff --git a/src/main/java/com/mongle/backend/domain/payment/RefundError.java b/src/main/java/com/mongle/backend/domain/payment/RefundError.java new file mode 100644 index 0000000..c625838 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/RefundError.java @@ -0,0 +1,20 @@ +package com.mongle.backend.domain.payment; + +import com.mongle.backend.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +enum RefundError implements ErrorCode { + INVALID(HttpStatus.BAD_REQUEST, "REFUND_400_1", "환불 신청 정보를 확인해주세요."), + NOT_FOUND(HttpStatus.NOT_FOUND, "REFUND_404_1", "환불 대상이나 접수 내역을 찾을 수 없습니다."), + CONFLICT(HttpStatus.CONFLICT, "REFUND_409_1", "이미 접수된 환불이나 다른 요청에 사용된 식별자입니다."), + INELIGIBLE(HttpStatus.CONFLICT, "REFUND_409_2", "현재 이 주문의 환불 신청을 접수할 수 없습니다."), + UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "REFUND_503_1", "환불 신청 서비스가 준비되지 않았습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/mongle/backend/domain/payment/RefundEstimate.java b/src/main/java/com/mongle/backend/domain/payment/RefundEstimate.java new file mode 100644 index 0000000..88f8f21 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/RefundEstimate.java @@ -0,0 +1,43 @@ +package com.mongle.backend.domain.payment; + +import java.math.BigInteger; +import java.time.Duration; +import java.time.Instant; + +public record RefundEstimate( + String orderId, long purchaseAmount, String currency, long purchasedCredits, + long usedCredits, long reservedCredits, long unusedCredits, Instant approvedAt, + boolean requestable, Status status, Long estimatedAmount, Integer feePercent) { + + public enum Status { ESTIMATED, WAITING_RESERVATION, REVIEW_REQUIRED, NO_REFUNDABLE_BALANCE } + + static RefundEstimate calculate(PaymentOrder order, com.mongle.backend.domain.credit.CreditGrant grant, + PaymentRefundRequest.Reason reason, Instant evaluatedAt) { + long available = grant.getAvailable(); + long reserved = grant.getReserved(); + boolean review = reason == PaymentRefundRequest.Reason.RIGHTS_REVIEW + || order.getState() == PaymentOrder.State.PAYMENT_REVIEW + || order.getApprovedAt() == null || order.getApprovedAt().isAfter(evaluatedAt); + boolean requestable = available > 0 || reserved > 0 || reason == PaymentRefundRequest.Reason.RIGHTS_REVIEW; + Status status; + Long amount = null; + Integer fee = null; + if (!requestable) { + status = Status.NO_REFUNDABLE_BALANCE; + } else if (reserved > 0) { + status = Status.WAITING_RESERVATION; + } else if (review) { + status = Status.REVIEW_REQUIRED; + } else { + status = Status.ESTIMATED; + fee = evaluatedAt.isAfter(order.getApprovedAt().plus(Duration.ofDays(7))) ? 10 : 0; + // Round once at the end, up to a whole won. BigInteger also supports large credit quantities. + var numerator = BigInteger.valueOf(order.getPrice()).multiply(BigInteger.valueOf(available)) + .multiply(BigInteger.valueOf(100 - fee)); + var divisor = BigInteger.valueOf(order.getCredits()).multiply(BigInteger.valueOf(100)); + amount = numerator.add(divisor).subtract(BigInteger.ONE).divide(divisor).longValueExact(); + } + return new RefundEstimate(order.getPublicId(), order.getPrice(), order.getCurrency(), order.getCredits(), + grant.getConsumed(), reserved, available, order.getApprovedAt(), requestable, status, amount, fee); + } +} diff --git a/src/main/java/com/mongle/backend/domain/payment/RefundPage.java b/src/main/java/com/mongle/backend/domain/payment/RefundPage.java new file mode 100644 index 0000000..4fb52ad --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/RefundPage.java @@ -0,0 +1,6 @@ +package com.mongle.backend.domain.payment; + +import java.util.List; + +public record RefundPage(List items, boolean hasNext, Long nextBeforeId) { +} diff --git a/src/main/java/com/mongle/backend/domain/payment/RefundProperties.java b/src/main/java/com/mongle/backend/domain/payment/RefundProperties.java new file mode 100644 index 0000000..5fee9a3 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/RefundProperties.java @@ -0,0 +1,17 @@ +package com.mongle.backend.domain.payment; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("mongle.payment.refund") +public record RefundProperties(boolean enabled, String supportEmail) { + public RefundProperties { + supportEmail = supportEmail == null ? "" : supportEmail.strip(); + if (!supportEmail.isEmpty() && (supportEmail.length() > 254 + || !supportEmail.matches("[A-Za-z0-9._+%-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"))) { + throw new IllegalArgumentException("환불 문의 이메일 형식을 확인해주세요."); + } + if (enabled && supportEmail.isEmpty()) { + throw new IllegalArgumentException("환불 접수 공개 전에 문의 이메일을 설정해주세요."); + } + } +} diff --git a/src/main/java/com/mongle/backend/domain/payment/RefundReceipt.java b/src/main/java/com/mongle/backend/domain/payment/RefundReceipt.java new file mode 100644 index 0000000..eef2ab3 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/RefundReceipt.java @@ -0,0 +1,10 @@ +package com.mongle.backend.domain.payment; + +import java.time.Instant; + +public record RefundReceipt( + String receiptId, String requestId, Instant receivedAt, PaymentRefundRequest.Reason reason, + RefundEstimate.Status initialStatus, long unusedAtRequest, long reservedAtRequest, long usedAtRequest, + Long estimatedAmountAtRequest, Integer feePercentAtRequest, RefundEstimate currentEstimate, + String supportEmail) { +} diff --git a/src/main/java/com/mongle/backend/domain/payment/RefundRequest.java b/src/main/java/com/mongle/backend/domain/payment/RefundRequest.java new file mode 100644 index 0000000..4222108 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/RefundRequest.java @@ -0,0 +1,7 @@ +package com.mongle.backend.domain.payment; + +import jakarta.validation.constraints.NotNull; +import java.util.UUID; + +public record RefundRequest(@NotNull UUID requestId, @NotNull UUID orderId, @NotNull PaymentRefundRequest.Reason reason) { +} diff --git a/src/main/java/com/mongle/backend/domain/payment/RefundService.java b/src/main/java/com/mongle/backend/domain/payment/RefundService.java new file mode 100644 index 0000000..92ba4e7 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/RefundService.java @@ -0,0 +1,146 @@ +package com.mongle.backend.domain.payment; + +import com.mongle.backend.domain.credit.CreditAccountRepository; +import com.mongle.backend.domain.credit.CreditGrant; +import com.mongle.backend.domain.credit.CreditGrantRepository; +import com.mongle.backend.domain.credit.CreditSource; +import com.mongle.backend.domain.user.exception.UserErrorCode; +import com.mongle.backend.domain.user.repository.UserRepository; +import com.mongle.backend.domain.user.withdrawal.WithdrawalRefundPolicy; +import com.mongle.backend.global.error.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) +public class RefundService implements WithdrawalRefundPolicy { + private final PaymentRefundRepository refunds; + private final PaymentOrderRepository orders; + private final CreditGrantRepository grants; + private final CreditAccountRepository accounts; + private final PaymentTransactions transactions; + private final UserRepository users; + private final RefundProperties properties; + private final Clock authClock; + + public RefundEstimate estimate(Long userId, UUID orderId, PaymentRefundRequest.Reason reason) { + requireActive(userId); + requireEnabled(); + var order = ownedOrder(userId, orderId); + var grant = purchaseGrant(order); + if (refunds.existsByGrantId(grant.getId())) throw new BusinessException(RefundError.CONFLICT); + return RefundEstimate.calculate(order, grant, reason, authClock.instant()); + } + + @Transactional(isolation = Isolation.READ_COMMITTED) + public RefundReceipt receive(Long userId, RefundRequest request) { + // Shared lock with credit reservations, verified payment feedback, reclaim and withdrawal. + transactions.requireUser(userId); + var existing = refunds.findByUserIdAndRequestId(userId, request.requestId().toString()); + if (existing.isPresent()) { + var receipt = existing.get(); + if (!receipt.getOrder().getPublicId().equals(request.orderId().toString()) + || receipt.getReason() != request.reason()) { + throw new BusinessException(RefundError.CONFLICT); + } + return receipt(receipt); + } + + requireEnabled(); + var order = ownedOrder(userId, request.orderId()); + var grant = purchaseGrant(order); + if (refunds.existsByGrantId(grant.getId())) throw new BusinessException(RefundError.CONFLICT); + + var now = authClock.instant().truncatedTo(ChronoUnit.MICROS); + var estimate = RefundEstimate.calculate(order, grant, request.reason(), now); + if (!estimate.requestable()) throw new BusinessException(RefundError.INELIGIBLE); + var refund = refunds.saveAndFlush(PaymentRefundRequest.receive( + order, grant, request.requestId(), request.reason(), estimate, now)); + // Invalidate older repeatable-read snapshots that might still see this grant as spendable. + if (accounts.invalidateSpendingSnapshot(userId) != 1) { + throw new BusinessException(RefundError.INELIGIBLE); + } + return receipt(refund); + } + + public RefundReceipt get(Long userId, UUID receiptId) { + requireActive(userId); + return refunds.findByUserIdAndReceiptId(userId, receiptId.toString()) + .map(this::receipt).orElseThrow(() -> new BusinessException(RefundError.NOT_FOUND)); + } + + public RefundPage page(Long userId, Long beforeId, int size) { + requireActive(userId); + if (size < 1 || size > 50 || (beforeId != null && beforeId < 1)) { + throw new BusinessException(RefundError.INVALID); + } + var rows = refunds.page(userId, beforeId, PageRequest.of(0, size + 1)); + boolean hasNext = rows.size() > size; + return new RefundPage(rows.stream().limit(size).map(this::receipt).toList(), + hasNext, hasNext ? rows.get(size - 1).getId() : null); + } + + @Override + @Transactional(readOnly = true, propagation = Propagation.MANDATORY) + public boolean coversRemainingPurchases(Long userId) { + return refunds.uncoveredPaidGrants(userId) == 0; + } + + @Override + @Transactional(readOnly = true, propagation = Propagation.MANDATORY) + public boolean hasOpenRefunds(Long userId) { + return refunds.existsByUserId(userId); + } + + private PaymentOrder ownedOrder(Long userId, UUID orderId) { + return orders.findByPublicIdAndUserId(orderId.toString(), userId) + .orElseThrow(() -> new BusinessException(RefundError.NOT_FOUND)); + } + + private CreditGrant purchaseGrant(PaymentOrder order) { + if ((order.getState() != PaymentOrder.State.PAID && order.getState() != PaymentOrder.State.PAYMENT_REVIEW) + || order.getCreditGrantId() == null || order.getPrice() <= 0 || order.getCredits() <= 0 + || !"KRW".equals(order.getCurrency())) { + throw new BusinessException(RefundError.INELIGIBLE); + } + var grant = grants.findById(order.getCreditGrantId()) + .orElseThrow(() -> new BusinessException(RefundError.INELIGIBLE)); + long available = grant.getAvailable(); + long reserved = grant.getReserved(); + if (!grant.getUserId().equals(order.getUserId()) || grant.getSourceType() != CreditSource.PAYMENT + || !grant.getSourceKey().equals(PaymentFeedbackService.sourceKey(order)) + || grant.getAmount() != order.getCredits() || grant.getForfeited() != 0 + || available < 0 || reserved < 0 || available > grant.getAmount() + || reserved > grant.getAmount() - available + || grant.getConsumed() != grant.getAmount() - available - reserved) { + throw new BusinessException(RefundError.INELIGIBLE); + } + return grant; + } + + private RefundReceipt receipt(PaymentRefundRequest row) { + var estimate = RefundEstimate.calculate(row.getOrder(), row.getGrant(), row.getReason(), row.getCreatedAt()); + return new RefundReceipt(row.getReceiptId(), row.getRequestId(), row.getCreatedAt(), row.getReason(), + row.getInitialStatus(), row.getAvailableAtRequest(), row.getReservedAtRequest(), + row.getUsedAtRequest(), row.getEstimatedAmount(), row.getFeePercent(), estimate, properties.supportEmail()); + } + + private void requireActive(Long userId) { + var user = users.findById(userId).filter(u -> !u.isWithdrawn()) + .orElseThrow(() -> new BusinessException(RefundError.NOT_FOUND)); + if (!user.isOnboardingCompleted()) throw new BusinessException(UserErrorCode.ONBOARDING_REQUIRED); + } + + private void requireEnabled() { + if (!properties.enabled()) throw new BusinessException(RefundError.UNAVAILABLE); + } +} diff --git a/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalConfiguration.java b/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalConfiguration.java index 1750e1d..be7cd0d 100644 --- a/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalConfiguration.java +++ b/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalConfiguration.java @@ -2,18 +2,10 @@ import org.springframework.context.annotation.*; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @Configuration(proxyBeanMethods=false) @EnableConfigurationProperties(WithdrawalProperties.class) public class WithdrawalConfiguration { - @Bean @ConditionalOnMissingBean(WithdrawalRefundPolicy.class) - WithdrawalRefundPolicy withdrawalRefundPolicy() { - return new WithdrawalRefundPolicy() { - public boolean coversRemainingPurchases(Long user) { return false; } - public boolean hasOpenRefunds(Long user) { return false; } - }; - } @Bean(destroyMethod="close") KakaoUnlinkGateway kakaoUnlinkGateway(WithdrawalProperties properties) { return new KakaoUnlinkGateway(properties); } @Bean(destroyMethod="close") diff --git a/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalData.java b/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalData.java index cbe2770..deb3cf4 100644 --- a/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalData.java +++ b/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalData.java @@ -28,6 +28,7 @@ public LocalDateTime financialRetention(Long user, LocalDateTime now) { select max(event_at) from ( select max(updated_at) event_at from payment_orders where user_id=:user union all select max(created_at) event_at from payment_credit_reclamations where user_id=:user + union all select max(created_at) event_at from payment_refund_requests where user_id=:user union all select max(created_at) event_at from credit_ledger where user_id=:user and source_type='PAYMENT' ) accounting_dates """) @@ -135,6 +136,7 @@ public void deleteAccounting(Long user, LocalDateTime now) { em.flush(); for(String sql:List.of( "delete from legal_consents where user_id=:user", + "delete from payment_refund_requests where user_id=:user", "delete from payment_credit_reclamations where user_id=:user", "delete from credit_payment_holds where user_id=:user", "delete from payment_notifications where order_id in (select id from payment_orders where user_id=:user)", diff --git a/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalRefundPolicy.java b/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalRefundPolicy.java index 22c7b9e..673de5f 100644 --- a/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalRefundPolicy.java +++ b/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalRefundPolicy.java @@ -1,7 +1,6 @@ package com.mongle.backend.domain.user.withdrawal; -/** 후속 환불 기능은 사용자 잠금 안에서 DB의 실제 접수 내역으로 구매별 미사용량을 검증해야 한다. - * 클라이언트 입력/이메일 발송만으로 접수됐다고 간주하지 않는다. 기본 구현은 유료 탈퇴를 허용하지 않는다. */ +/** 회원 행 잠금 안에서 구매별 실제 접수 내역과 미완료 접수 여부를 확인한다. */ public interface WithdrawalRefundPolicy { boolean coversRemainingPurchases(Long userId); boolean hasOpenRefunds(Long userId); diff --git a/src/main/java/com/mongle/backend/operations/payment/PaymentReclaimer.java b/src/main/java/com/mongle/backend/operations/payment/PaymentReclaimer.java index 62b295e..7144e98 100644 --- a/src/main/java/com/mongle/backend/operations/payment/PaymentReclaimer.java +++ b/src/main/java/com/mongle/backend/operations/payment/PaymentReclaimer.java @@ -168,6 +168,10 @@ select count(*) as total, metadata.grant, user); var reasons = new ArrayList(); + long refundRequests = db.query( + "select count(*) from payment_refund_requests where order_id=? or grant_id=?", + row -> row.getLong(1), metadata.id, metadata.grant).getFirst(); + if (refundRequests != 0) reasons.add("REFUND_INTAKE_EXISTS"); if (!((List) report.get("findings")).isEmpty()) reasons.add("INTERNAL_RECORDS_INCONSISTENT"); if (!metadata.state.equals("PAYMENT_REVIEW")) reasons.add("ORDER_NOT_IN_REVIEW"); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1981612..886e056 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -72,6 +72,9 @@ mongle: enabled: ${MONGLE_PAYMENT_ENABLED:false} # 상품 목록은 운영 설정으로 공급한다. 임의 가격/지급량을 기본 제공하지 않는다. products: [] + refund: + enabled: ${MONGLE_PAYMENT_REFUND_ENABLED:false} + support-email: ${MONGLE_PAYMENT_REFUND_SUPPORT_EMAIL:} payapp: enabled: ${MONGLE_PAYAPP_ENABLED:false} seller-id: ${PAYAPP_SELLER_ID:} diff --git a/src/main/resources/db/migration/V9__payment_refund_intake.sql b/src/main/resources/db/migration/V9__payment_refund_intake.sql new file mode 100644 index 0000000..6b9a7df --- /dev/null +++ b/src/main/resources/db/migration/V9__payment_refund_intake.sql @@ -0,0 +1,26 @@ +ALTER TABLE payment_orders ADD COLUMN approved_at DATETIME(6) NULL; + +CREATE TABLE payment_refund_requests ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + receipt_id VARCHAR(36) NOT NULL, + request_id VARCHAR(36) NOT NULL, + user_id BIGINT NOT NULL, + order_id BIGINT NOT NULL, + grant_id BIGINT NOT NULL, + reason VARCHAR(24) NOT NULL, + initial_status VARCHAR(24) NOT NULL, + available_at_request BIGINT NOT NULL, + reserved_at_request BIGINT NOT NULL, + used_at_request BIGINT NOT NULL, + estimated_amount BIGINT NULL, + fee_percent INT NULL, + created_at DATETIME(6) NOT NULL, + CONSTRAINT uk_refund_receipt UNIQUE (receipt_id), + CONSTRAINT uk_refund_user_request UNIQUE (user_id, request_id), + CONSTRAINT uk_refund_order UNIQUE (order_id), + CONSTRAINT uk_refund_grant UNIQUE (grant_id), + CONSTRAINT fk_refund_user FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT fk_refund_order FOREIGN KEY (order_id) REFERENCES payment_orders(id), + CONSTRAINT fk_refund_grant FOREIGN KEY (grant_id) REFERENCES credit_grants(id) +); +CREATE INDEX idx_refund_user_page ON payment_refund_requests(user_id, id); diff --git a/src/mysqlTest/java/com/mongle/backend/domain/payment/Mysql84RefundIntakeIntegrationTest.java b/src/mysqlTest/java/com/mongle/backend/domain/payment/Mysql84RefundIntakeIntegrationTest.java new file mode 100644 index 0000000..4f23b4a --- /dev/null +++ b/src/mysqlTest/java/com/mongle/backend/domain/payment/Mysql84RefundIntakeIntegrationTest.java @@ -0,0 +1,14 @@ +package com.mongle.backend.domain.payment; + +import com.mongle.backend.support.NativeMysqlSupport; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +@ActiveProfiles(value = "mysql", inheritProfiles = false) +class Mysql84RefundIntakeIntegrationTest extends RefundIntakeIntegrationTest { + @DynamicPropertySource + static void mysql(DynamicPropertyRegistry registry) { + NativeMysqlSupport.register(registry, "payment_test"); + } +} diff --git a/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java b/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java index 936a338..f64bfb3 100644 --- a/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java +++ b/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java @@ -83,7 +83,7 @@ String location() throws Exception { assertThat(new MigrationEngine(source, "flyway_existing", "filesystem:" + priorRelease).execute("migrate")) .containsEntry("targetVersion", "3"); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='recent_interpretations'", Integer.class)).isZero(); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 5).containsEntry("targetVersion", "8"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 6).containsEntry("targetVersion", "9"); assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 0); assertThatThrownBy(() -> engine.execute("baseline")).hasMessage("HISTORY_ALREADY_EXISTS"); assertThat(jdbc.queryForObject("SELECT nickname FROM users WHERE email='preserved@test.invalid'", String.class)).isEqualTo("keep"); @@ -102,7 +102,7 @@ String location() throws Exception { jdbc.update("INSERT INTO users(email,nickname,created_at,updated_at) VALUES('summary-preserved@test.invalid','keep-summary',NOW(),NOW())"); var history=jdbc.queryForList("select version,checksum from flyway_schema_history order by installed_rank"); var engine=new MigrationEngine(source,"flyway_summary"); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",4).containsEntry("targetVersion","8"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",5).containsEntry("targetVersion","9"); assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",0); assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version in ('1','3','4') order by installed_rank")).isEqualTo(history); assertThat(jdbc.queryForObject("select nickname from users where email='summary-preserved@test.invalid'",String.class)).isEqualTo("keep-summary"); @@ -124,9 +124,9 @@ String location() throws Exception { var history=jdbc.queryForList("select version,checksum from flyway_schema_history order by installed_rank"); var accounts=jdbc.queryForList("select * from credit_accounts");var grants=jdbc.queryForList("select * from credit_grants"); var engine=new MigrationEngine(ds,"flyway_reclamation"); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",3).containsEntry("targetVersion","8"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",4).containsEntry("targetVersion","9"); assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",0); - assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version not in ('6','7','8') order by installed_rank")).isEqualTo(history); + assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version not in ('6','7','8','9') order by installed_rank")).isEqualTo(history); assertThat(jdbc.queryForList("select * from credit_accounts")).isEqualTo(accounts); var upgradedGrants=jdbc.queryForList("select * from credit_grants"); upgradedGrants.forEach(row -> assertThat(row.remove("forfeited")).isEqualTo(0L)); @@ -146,17 +146,52 @@ String location() throws Exception { var before=jdbc.queryForMap("select * from users where email='legal-existing@test.invalid'"); var history=jdbc.queryForList("select version,checksum from flyway_schema_history order by installed_rank"); var engine=new MigrationEngine(ds,"flyway_legal"); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",1).containsEntry("targetVersion","8"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",2).containsEntry("targetVersion","9"); assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",0); var after=jdbc.queryForMap("select * from users where email='legal-existing@test.invalid'"); for(String column:java.util.List.of("terms_version","terms_digest","privacy_version","privacy_digest")) assertThat(after.remove(column)).isNull(); assertThat(after).isEqualTo(before); - assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version<>'8' order by installed_rank")).isEqualTo(history); + assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version not in ('8','9') order by installed_rank")).isEqualTo(history); assertThat(jdbc.queryForObject("select count(*) from legal_consents",Long.class)).isZero(); assertThat(jdbc.queryForObject("select count(*) from legal_document_versions where document_digest is null",Long.class)).isEqualTo(2); } + @Test void v9UpgradesV8WithoutGuessingLegacyApprovalOrCreatingReceipts() throws Exception { + var prior = Files.createDirectory(directory.resolve("prior-refund-release")); + for (String name : java.util.List.of("V1__mongle_baseline.sql", "V3__image_regeneration_policy.sql", + "V4__recent_interpretations.sql", "V5__world_summaries.sql", + "V6__payment_credit_reclamations.sql", "V7__user_withdrawal.sql", "V8__legal_consents.sql")) { + try (var sql = new ClassPathResource("db/migration/" + name).getInputStream()) { + Files.copy(sql, prior.resolve(name)); + } + } + var source = source("flyway_refund"); + var jdbc = new JdbcTemplate(source); + assertThat(new MigrationEngine(source, "flyway_refund", "filesystem:" + prior).execute("migrate")) + .containsEntry("targetVersion", "8"); + jdbc.update("insert into users(email,nickname,created_at,updated_at) values('refund-legacy@test.invalid','keep',NOW(),NOW())"); + Long user = jdbc.queryForObject("select id from users where email='refund-legacy@test.invalid'", Long.class); + jdbc.update(""" + insert into payment_orders(public_id,user_id,request_id,input_hash,notification_token, + product_id,product_name,price,currency,credits,state,request_deadline,created_at,updated_at) + values(UUID(),?,UUID(),REPEAT('a',64),UUID(),'pack','credit pack',1100,'KRW',10,'PAID',NOW(),NOW(),NOW()) + """, user); + var order = jdbc.queryForMap("select * from payment_orders where user_id=?", user); + var history = jdbc.queryForList("select version,checksum from flyway_schema_history order by installed_rank"); + + var engine = new MigrationEngine(source, "flyway_refund"); + assertThat(engine.execute("migrate")).containsEntry("targetVersion", "9").containsEntry("migrationsExecuted", 1); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 0); + var after = jdbc.queryForMap("select * from payment_orders where user_id=?", user); + assertThat(after).containsKey("approved_at"); + assertThat(after.remove("approved_at")).isNull(); + assertThat(after).isEqualTo(order); + assertThat(jdbc.queryForObject("select count(*) from payment_refund_requests", Long.class)).isZero(); + assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version<>'9' order by installed_rank")) + .isEqualTo(history); + } + @Test void baselineRejectsColumnIndexAndForeignKeyDriftEvenWith33Tables() throws Exception { var source = source("flyway_drift"); var jdbc = new JdbcTemplate(source); var engine = new MigrationEngine(source, "flyway_drift"); diff --git a/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java b/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java index 7015503..9ec7455 100644 --- a/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java +++ b/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java @@ -11,7 +11,7 @@ @SpringBootTest(properties = { "spring.datasource.url=jdbc:h2:mem:mongle-schema;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "spring.sql.init.mode=always", - "spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql", + "spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql,classpath:db/migration/V9__payment_refund_intake.sql", "spring.jpa.hibernate.ddl-auto=validate" }) @ActiveProfiles("test") @@ -41,19 +41,19 @@ class InitialSchemaValidationTest { @Test void creditMigrationIsRepeatableAndKeepsReservationTrace() { - for (String table : java.util.List.of("credit_ledger", "credit_allocations", "credit_reservations", "credit_grants", "credit_accounts")) { - jdbcTemplate.execute("drop table " + table); - } - jdbcTemplate.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { + var legacy = new JdbcTemplate(new org.springframework.jdbc.datasource.DriverManagerDataSource( + "jdbc:h2:mem:legacy-credit;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "sa", "")); + legacy.execute("create table users(id bigint not null primary key)"); + legacy.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { var script = new org.springframework.core.io.ClassPathResource("db/migrations/20261026-credit-ledger.sql"); org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(connection, script); org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(connection, script); return null; }); for (String table : java.util.List.of("credit_accounts", "credit_grants", "credit_reservations", "credit_allocations", "credit_ledger")) { - assertThat(jdbcTemplate.queryForObject("select count(*) from information_schema.tables where table_schema='public' and table_name=?", Long.class, table)).isEqualTo(1); + assertThat(legacy.queryForObject("select count(*) from information_schema.tables where table_schema='public' and table_name=?", Long.class, table)).isEqualTo(1); } - assertThat(jdbcTemplate.queryForList("select column_name from information_schema.columns where table_schema='public' and table_name='credit_grants'", String.class)) + assertThat(legacy.queryForList("select column_name from information_schema.columns where table_schema='public' and table_name='credit_grants'", String.class)) .contains("source_type", "source_key", "source_hash", "available", "reserved", "consumed", "version"); } diff --git a/src/test/java/com/mongle/backend/domain/ai/liner/AiGenerationLoggingIntegrationTest.java b/src/test/java/com/mongle/backend/domain/ai/liner/AiGenerationLoggingIntegrationTest.java index 976f7b3..204213a 100644 --- a/src/test/java/com/mongle/backend/domain/ai/liner/AiGenerationLoggingIntegrationTest.java +++ b/src/test/java/com/mongle/backend/domain/ai/liner/AiGenerationLoggingIntegrationTest.java @@ -47,7 +47,7 @@ @SpringBootTest(properties = { "spring.datasource.url=jdbc:h2:mem:mongle-ai-logging;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "spring.sql.init.mode=always", - "spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql", + "spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql,classpath:db/migration/V9__payment_refund_intake.sql", "spring.jpa.hibernate.ddl-auto=validate", "mongle.ai.liner.api-key=logging-test-key", "mongle.ai.liner.connect-timeout=300ms", diff --git a/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java b/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java index 58b3fd3..c5dc47c 100644 --- a/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java +++ b/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java @@ -1,17 +1,17 @@ package com.mongle.backend.domain.payment; import static org.assertj.core.api.Assertions.*; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.jdbc.core.JdbcTemplate; @SpringBootTest(properties={"spring.datasource.url=jdbc:h2:mem:payment-schema;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", - "spring.sql.init.mode=always","spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql","spring.jpa.hibernate.ddl-auto=validate"}) + "spring.sql.init.mode=always","spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql,classpath:db/migration/V9__payment_refund_intake.sql","spring.jpa.hibernate.ddl-auto=validate"}) @ActiveProfiles("test") class PaymentMigrationTest { - @Autowired JdbcTemplate jdbc; @Test void initialSchemaMatchesHibernateAndMigrationCanBeAppliedTwice() { - for(String table:java.util.List.of("payment_notifications","payment_transaction_bindings","payment_orders","credit_payment_holds")) jdbc.execute("drop table "+table); + var jdbc = new JdbcTemplate(new org.springframework.jdbc.datasource.DriverManagerDataSource( + "jdbc:h2:mem:legacy-payment;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "sa", "")); + jdbc.execute("create table users(id bigint not null primary key)"); jdbc.execute((org.springframework.jdbc.core.ConnectionCallback)c -> { var script=new org.springframework.core.io.ClassPathResource("db/migrations/20261026-payapp-payments.sql"); org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(c,script); diff --git a/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java b/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java new file mode 100644 index 0000000..ae8ccbd --- /dev/null +++ b/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java @@ -0,0 +1,366 @@ +package com.mongle.backend.domain.payment; + +import com.mongle.backend.domain.auth.service.TokenService; +import com.mongle.backend.domain.credit.*; +import com.mongle.backend.domain.user.entity.User; +import com.mongle.backend.domain.user.exception.UserErrorCode; +import com.mongle.backend.domain.user.repository.UserRepository; +import com.mongle.backend.domain.user.withdrawal.WithdrawalRetention; +import com.mongle.backend.domain.user.withdrawal.WithdrawalService; +import com.mongle.backend.global.error.BusinessException; +import com.mongle.backend.global.error.ErrorCode; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import tools.jackson.databind.json.JsonMapper; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.concurrent.*; + +import static com.mongle.backend.domain.payment.PaymentRefundRequest.Reason.*; +import static com.mongle.backend.domain.payment.RefundEstimate.Status.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.when; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "spring.datasource.url=jdbc:h2:mem:refund-intake;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "mongle.credit.enabled=true", "mongle.credit.dream-image-generation=3", + "mongle.payment.refund.enabled=true", "mongle.payment.refund.support-email=help@example.invalid", + "mongle.legal.published=true", "mongle.legal.enforce=true", + "mongle.legal.terms-digest=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "mongle.legal.privacy-digest=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "mongle.withdrawal.enabled=true", "mongle.withdrawal.worker-enabled=false", + "mongle.withdrawal.identity-key=withdrawal-test-identity-key-at-least-32-characters", + "mongle.withdrawal.encryption-key=withdrawal-test-encryption-key-at-least-32-characters", + "mongle.payment.payapp.enabled=true", "mongle.payment.payapp.seller-id=seller", + "mongle.payment.payapp.link-key=test-link", "mongle.payment.payapp.link-value=test-value", + "mongle.payment.payapp.request-key=test-secret", + "mongle.payment.payapp.feedback-url=https://api.mongle.test/api/v1/payments/payapp/feedback", + "mongle.payment.payapp.return-url=https://mongle.test/payment" +}) +@ActiveProfiles("test") +class RefundIntakeIntegrationTest { + @Autowired UserRepository users; + @Autowired PaymentOrderRepository orders; + @Autowired RefundService refunds; + @Autowired PaymentFeedbackService feedback; + @Autowired PayAppFeedbackVerifier verifier; + @Autowired CreditService credits; + @Autowired CreditReservationService reservations; + @Autowired TokenService tokens; + @Autowired WithdrawalService withdrawals; + @Autowired WithdrawalRetention retention; + @Autowired JdbcTemplate jdbc; + @Autowired PlatformTransactionManager transactions; + @MockitoBean(name = "authClock") Clock clock; + @Value("${local.server.port}") int port; + + private final JsonMapper json = JsonMapper.builder().build(); + private Instant now; + private long user; + + @BeforeAll + static void initAccountingFixture(@Autowired JdbcTemplate jdbc) { + jdbc.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { + try (var tables = connection.getMetaData().getTables(connection.getCatalog(), null, + "payment_credit_reclamations", null)) { + if (tables.next()) return null; + } + new ResourceDatabasePopulator(new ClassPathResource("db/migration/V6__payment_credit_reclamations.sql")) + .populate(connection); + return null; + }); + } + + @BeforeEach + void setup() { + now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + when(clock.instant()).thenAnswer(ignored -> now); + when(clock.getZone()).thenReturn(ZoneOffset.UTC); + user = member(); + } + + @Test + void calculatesPartialAmountWithSingleRoundingAndExactSevenDayBoundary() { + var order = purchase(7, now.minus(Duration.ofDays(7))); + assertThat(refunds.estimate(user, order, STANDARD).estimatedAmount()).isEqualTo(1100); + reservations.settle(user, reserve("used").reservationId(), "saved-image"); + credits.grant(user, CreditSource.PROMOTION, "free-after:" + user, 10); + + var atBoundary = refunds.estimate(user, order, STANDARD); + assertThat(atBoundary).satisfies(e -> { + assertThat(e.usedCredits()).isEqualTo(3); + assertThat(e.unusedCredits()).isEqualTo(4); + assertThat(e.feePercent()).isZero(); + assertThat(e.estimatedAmount()).isEqualTo(629); + }); + + now = now.plus(1, ChronoUnit.MICROS); + var receipt = refunds.receive(user, request(order, STANDARD)); + assertThat(receipt.feePercentAtRequest()).isEqualTo(10); + assertThat(receipt.estimatedAmountAtRequest()).isEqualTo(566); + assertThat(receipt.currentEstimate().status()).isEqualTo(ESTIMATED); + } + + @Test + void missingApprovalAndRightsClaimsRemainReviewable() { + var historical = purchase(10, null); + var old = refunds.receive(user, request(historical, STANDARD)); + assertThat(old.initialStatus()).isEqualTo(REVIEW_REQUIRED); + assertThat(old.estimatedAmountAtRequest()).isNull(); + assertThat(old.feePercentAtRequest()).isNull(); + + var consumed = purchase(3, now.minus(Duration.ofDays(14))); + reservations.settle(user, reserve("fully-used").reservationId(), "saved-image"); + assertThat(refunds.estimate(user, consumed, STANDARD).status()).isEqualTo(NO_REFUNDABLE_BALANCE); + assertError(() -> refunds.receive(user, request(consumed, STANDARD)), RefundError.INELIGIBLE); + var rights = refunds.receive(user, request(consumed, RIGHTS_REVIEW)); + assertThat(rights.currentEstimate().status()).isEqualTo(REVIEW_REQUIRED); + assertThat(rights.currentEstimate().estimatedAmount()).isNull(); + } + + @Test + void holdBlocksOnlyTheRequestedPurchaseAndExistingReleaseRestoresItForRecalculation() { + var heldOrder = purchase(10, now); + var inFlight = reserve("in-flight"); + var otherOrder = purchase(3, now); + var request = request(heldOrder, STANDARD); + var initial = refunds.receive(user, request); + assertThat(initial.initialStatus()).isEqualTo(WAITING_RESERVATION); + assertThat(initial.unusedAtRequest()).isEqualTo(7); + assertThat(initial.reservedAtRequest()).isEqualTo(3); + assertThat(initial.estimatedAmountAtRequest()).isNull(); + assertError(() -> withdrawals.withdraw(user), UserErrorCode.REFUND_REQUIRED); + + reservations.settle(user, reserve("other-lot").reservationId(), "other-image"); + assertThat(refunds.estimate(user, otherOrder, STANDARD).usedCredits()).isEqualTo(3); + assertError(() -> reserve("held-only"), CreditErrorCode.INSUFFICIENT); + credits.grant(user, CreditSource.ATTENDANCE, "free-unheld:" + user, 3); + reservations.settle(user, reserve("free-unheld").reservationId(), "free-image"); + + now = now.plus(Duration.ofDays(8)); + reservations.release(user, inFlight.reservationId(), CreditReservation.ReleaseReason.FAILURE); + var refreshed = refunds.receive(user, request); + assertThat(refreshed.receiptId()).isEqualTo(initial.receiptId()); + assertThat(refreshed.reservedAtRequest()).isEqualTo(3); + assertThat(refreshed.currentEstimate().unusedCredits()).isEqualTo(10); + assertThat(refreshed.currentEstimate().estimatedAmount()).isEqualTo(1100); + assertThat(refreshed.currentEstimate().feePercent()).isZero(); + assertError(() -> reserve("released-but-held"), CreditErrorCode.INSUFFICIENT); + } + + @Test + void existingReservationCanSettleAndVerifiedCancellationMovesReceiptToReview() { + var paid = purchase(10, now); + var reservation = reserve("before-intake"); + var receipt = refunds.receive(user, request(paid, STANDARD)); + reservations.settle(user, reservation.reservationId(), "completed-image"); + var id = UUID.fromString(receipt.receiptId()); + assertThat(refunds.get(user, id).currentEstimate().estimatedAmount()).isEqualTo(770); + + var order = orders.findByPublicId(paid.toString()).orElseThrow(); + feedback.accept(verifier.verify(notification(order, 9, now))); + var reviewed = refunds.get(user, id); + assertThat(reviewed.currentEstimate().status()).isEqualTo(REVIEW_REQUIRED); + assertThat(reviewed.currentEstimate().estimatedAmount()).isNull(); + assertThat(reviewed.currentEstimate().unusedCredits()).isEqualTo(7); + assertThat(credits.balance(user).spendingBlocked()).isTrue(); + assertThat(refunds.page(user, null, 20).items()).hasSize(1); + } + + @Test + void concurrentDuplicateRequestsCommitOnlyOneHoldAndRejectChangedInputs() throws Exception { + var paid = purchase(10, now); + var request = request(paid, STANDARD); + var beforeVersion = accountVersion(); + var gate = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + Callable task = () -> { + if (!gate.await(10, TimeUnit.SECONDS)) throw new AssertionError("request start timeout"); + return refunds.receive(user, request); + }; + var first = pool.submit(task); + var second = pool.submit(task); + gate.countDown(); + assertThat(second.get(15, TimeUnit.SECONDS)).isEqualTo(first.get(15, TimeUnit.SECONDS)); + } + assertThat(accountVersion()).isEqualTo(beforeVersion + 1); + assertThat(refunds.page(user, null, 20).items()).hasSize(1); + assertError(() -> refunds.receive(user, request(paid, STANDARD)), RefundError.CONFLICT); + assertError(() -> refunds.receive(user, new RefundRequest(request.requestId(), paid, RIGHTS_REVIEW)), + RefundError.CONFLICT); + } + + @Test + void staleOuterSnapshotCannotReserveAfterRefundIntake() { + var paid = purchase(10, now); + var outer = new TransactionTemplate(transactions); + outer.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); + assertThatThrownBy(() -> outer.execute(status -> { + assertThat(credits.balance(user).available()).isEqualTo(10); + try (var pool = Executors.newSingleThreadExecutor()) { + pool.submit(() -> refunds.receive(user, request(paid, STANDARD))).get(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } catch (ExecutionException | TimeoutException e) { + throw new AssertionError(e); + } + return reserve("stale-snapshot"); + })).satisfiesAnyOf( + e -> assertThat(e).isInstanceOf(org.springframework.dao.ConcurrencyFailureException.class), + e -> assertThat(e).isInstanceOfSatisfying(BusinessException.class, + problem -> assertThat(problem.getErrorCode()).isEqualTo(CreditErrorCode.INSUFFICIENT))); + assertThat(credits.balance(user).reserved()).isZero(); + assertThat(refunds.page(user, null, 20).items()).hasSize(1); + } + + @Test + void authenticatedIntakeAndWithdrawalPreserveOpenReceiptsAndIsolateOtherMembers() throws Exception { + var paid = purchase(10, now); + var second = purchase(10, now); + var pending = reserve("cancel-on-withdrawal"); + var request = request(paid, STANDARD); + String body = json.writeValueAsString(request); + String bearer = tokens.login(user).response().accessToken(); + String another = tokens.login(member()).response().accessToken(); + String path = "/api/v1/payments/refunds"; + try (var client = HttpClient.newHttpClient()) { + assertThat(http(client, null, "POST", path, body).statusCode()).isEqualTo(401); + assertThat(http(client, another, "POST", path, body).statusCode()).isEqualTo(404); + assertThat(http(client, bearer, "GET", path + "/estimate?orderId=" + paid, null).statusCode()).isEqualTo(200); + var response = http(client, bearer, "POST", path, body); + assertThat(response.statusCode()).as(response.body()).isEqualTo(200); + assertThat(response.headers().firstValue("Cache-Control")).hasValue("no-store"); + var data = json.readTree(response.body()).path("data"); + String id = data.path("receiptId").asString(); + assertThat(data.path("supportEmail").asString()).isEqualTo("help@example.invalid"); + assertThat(response.body()).doesNotContain("providerTransaction", "notificationToken", "grantId"); + assertThat(http(client, another, "GET", path + "/" + id, null).statusCode()).isEqualTo(404); + assertThat(http(client, bearer, "DELETE", "/api/v1/users/me", null).body()).contains("USER_409_2"); + refunds.receive(user, request(second, STANDARD)); + assertThat(refunds.page(user, null, 1).hasNext()).isTrue(); + assertThat(http(client, bearer, "DELETE", "/api/v1/users/me", null).statusCode()).isEqualTo(200); + assertThat(http(client, bearer, "GET", path + "/" + id, null).statusCode()).isEqualTo(401); + } + + assertThat(jdbc.queryForObject("select release_reason from credit_reservations where id=?", + String.class, pending.reservationId())).isEqualTo("CANCELLED"); + now = now.plus(Duration.ofDays(6 * 365)); + assertThat(retention.purge(user)).isFalse(); + assertThat(jdbc.queryForObject("select count(*) from payment_refund_requests where user_id=?", + Long.class, user)).isEqualTo(2); + assertThat(users.findById(user).orElseThrow().isWithdrawn()).isTrue(); + } + + @Test + void storesOnlyVerifiedApprovalTimeAndNeverReplacesItOnReplay() { + var order = readyOrder(10); + var storedCreation = orders.findById(order.getId()).orElseThrow().getCreatedAt() + .toInstant(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS); + // Exercise a provider approval that falls in the second before the stored, rounded creation time. + var approved = storedCreation.minusSeconds(1); + now = storedCreation.plusSeconds(10); + var form = notification(order, 4, now); + form.put("pay_date", DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Seoul")) + .format(approved)); + var verified = verifier.verify(form); + assertThat(verified.approvedAt()).isEqualTo(approved); + feedback.accept(verified); + var current = orders.findById(order.getId()).orElseThrow(); + assertThat(current.getApprovedAt()).isEqualTo(approved); + form.put("pay_date", "malformed"); + feedback.accept(verifier.verify(form)); + assertThat(orders.findById(order.getId()).orElseThrow().getApprovedAt()).isEqualTo(approved); + assertThat(credits.balance(user).available()).isEqualTo(10); + + var other = readyOrder(10); + var invalidDate = notification(other, 4, now); + invalidDate.put("pay_date", "20991231235959"); + assertThat(verifier.verify(invalidDate).approvedAt()).isNull(); + invalidDate.put("pay_date", DateTimeFormatter.ofPattern("uuuuMMddHHmmss").withZone(ZoneId.of("Asia/Seoul")) + .format(storedCreation.minusSeconds(60))); + feedback.accept(verifier.verify(invalidDate)); + assertThat(orders.findById(other.getId()).orElseThrow().getState()).isEqualTo(PaymentOrder.State.PAID); + assertThat(refunds.estimate(user, UUID.fromString(other.getPublicId()), STANDARD).status()) + .isEqualTo(REVIEW_REQUIRED); + } + + private long member() { + return users.saveAndFlush(User.create(UUID.randomUUID() + "@refund.test", "몽글")).getId(); + } + + private UUID purchase(long quantity, Instant approvedAt) { + var order = readyOrder(quantity); + var grant = credits.grant(user, CreditSource.PAYMENT, PaymentFeedbackService.sourceKey(order), quantity); + jdbc.update("update payment_orders set state='PAID', credit_grant_id=?, approved_at=? where id=?", + grant.grantId(), approvedAt == null ? null : LocalDateTime.ofInstant(approvedAt, ZoneOffset.UTC), + order.getId()); + return UUID.fromString(order.getPublicId()); + } + + private PaymentOrder readyOrder(long quantity) { + var order = orders.saveAndFlush(PaymentOrder.create(user, + new PaymentOrderRequest(UUID.randomUUID(), "pack", "01012345678"), + new CreditProduct("pack", "크레딧", 1100, "KRW", quantity), now.plusSeconds(60))); + jdbc.update("update payment_orders set state='READY', provider_transaction=? where id=?", + providerId(order), order.getId()); + return order; + } + + private String providerId(PaymentOrder order) { + return Long.toString(8_000_000 + order.getId()); + } + + private Map notification(PaymentOrder order, int state, Instant date) { + String stamp = DateTimeFormatter.ofPattern("uuuuMMddHHmmss").withZone(ZoneId.of("Asia/Seoul")).format(date); + return new HashMap<>(Map.of("userid", "seller", "linkkey", "test-link", "linkval", "test-value", + "responsedate", stamp, "hash", PaymentHashes.sha256("seller" + stamp + "test-secret"), + "var1", order.getPublicId(), "var2", order.getNotificationToken(), "mul_no", providerId(order), + "pay_state", Integer.toString(state), "price", "1100")); + } + + private RefundRequest request(UUID order, PaymentRefundRequest.Reason reason) { + return new RefundRequest(UUID.randomUUID(), order, reason); + } + + private CreditReservationReceipt reserve(String key) { + return reservations.reserve(user, key, CreditUsage.DREAM_IMAGE_GENERATION, "image:" + key, now.plusSeconds(60)); + } + + private long accountVersion() { + return jdbc.queryForObject("select version from credit_accounts where user_id=?", Long.class, user); + } + + private void assertError(Runnable operation, ErrorCode expected) { + assertThatThrownBy(operation::run).isInstanceOfSatisfying(BusinessException.class, + error -> assertThat(error.getErrorCode()).isEqualTo(expected)); + } + + private HttpResponse http(HttpClient client, String token, String method, String path, String body) + throws Exception { + var request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)); + if (token != null) request.header("Authorization", "Bearer " + token); + if (body != null) request.header("Content-Type", "application/json"); + return client.send(request.build(), HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/src/test/java/com/mongle/backend/domain/user/UserWithdrawalIntegrationTest.java b/src/test/java/com/mongle/backend/domain/user/UserWithdrawalIntegrationTest.java index 49e7d1f..af6162d 100644 --- a/src/test/java/com/mongle/backend/domain/user/UserWithdrawalIntegrationTest.java +++ b/src/test/java/com/mongle/backend/domain/user/UserWithdrawalIntegrationTest.java @@ -59,7 +59,7 @@ public class UserWithdrawalIntegrationTest { @Autowired EntityManagerFactory emf; @Autowired PlatformTransactionManager manager; @Autowired JdbcTemplate jdbc; - @MockitoBean WithdrawalRefundPolicy refunds; + @MockitoBean com.mongle.backend.domain.payment.RefundService refunds; @org.springframework.test.context.bean.override.mockito.MockitoSpyBean WithdrawalProperties withdrawalProperties; @Value("${local.server.port}") int port; static final AtomicLong IDS=new AtomicLong(100000); diff --git a/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java b/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java index ac4c455..9272c85 100644 --- a/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java +++ b/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java @@ -41,7 +41,7 @@ DataSource dataSource() { "jdbc:h2:mem:payment-inspect;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "sa", ""); - new ResourceDatabasePopulator(new ClassPathResource("db/schema-initial.sql"),new ClassPathResource("db/migration/V6__payment_credit_reclamations.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql"), new ClassPathResource("db/migration/V8__legal_consents.sql")).execute(ds); + new ResourceDatabasePopulator(new ClassPathResource("db/schema-initial.sql"),new ClassPathResource("db/migration/V6__payment_credit_reclamations.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql"), new ClassPathResource("db/migration/V8__legal_consents.sql"), new ClassPathResource("db/migration/V9__payment_refund_intake.sql")).execute(ds); return ds; } diff --git a/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java b/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java index 5572bdb..473d844 100644 --- a/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java +++ b/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java @@ -53,7 +53,7 @@ DataSource dataSource() { ""); new ResourceDatabasePopulator( new ClassPathResource("db/schema-initial.sql"), - new ClassPathResource("db/migration/V6__payment_credit_reclamations.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql"), new ClassPathResource("db/migration/V8__legal_consents.sql")) + new ClassPathResource("db/migration/V6__payment_credit_reclamations.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql"), new ClassPathResource("db/migration/V8__legal_consents.sql"), new ClassPathResource("db/migration/V9__payment_refund_intake.sql")) .execute(ds); return ds; } @@ -127,6 +127,25 @@ void refused(String o) throws Exception { assertThat(f.snapshot()).isEqualTo(before); } + @Test + void anIntakeCommittedAfterPreviewPreventsLegacyReclaim() throws Exception { + String order = cancelled(); + String key = "before-intake-" + order; + var plan = preview(order, key); + assertThat(plan.path("status").asString()).isEqualTo("WOULD_RECLAIM"); + jdbc.update(""" + insert into payment_refund_requests(receipt_id,request_id,user_id,order_id,grant_id, + reason,initial_status,available_at_request,reserved_at_request,used_at_request,created_at) + values(?,?,?,?,?,'STANDARD','REVIEW_REQUIRED',10,0,0,?) + """, UUID.randomUUID().toString(), UUID.randomUUID().toString(), + f.user, f.id(order), grant(order), LocalDateTime.ofInstant(PaymentInspectorContract.NOW, ZoneOffset.UTC)); + assertThat(preview(order, key).toString()).contains("REFUND_INTAKE_EXISTS", "REFUSED"); + assertThatThrownBy(() -> apply(order, key, plan.path("snapshot").asString())) + .hasMessage("RECLAMATION_NOT_ALLOWED"); + assertThat(jdbc.queryForObject("select available from credit_grants where id=?", + Long.class, grant(order))).isEqualTo(10); + } + @Test void unusedWholePurchaseIsReclaimedAtomicallyWithoutTouchingPromotionOrOriginalAmount() throws Exception {