diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 3033d96..0822963 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -250,10 +250,11 @@ Refresh는 앱 전체에서 한 번씩 실행되도록 묶는다. StrictMode· - PAYMENT_REVIEW 또는 잔액 `spendingBlocked=true`이면 구매/사용을 막고 확인 안내를 표시한다. - `POST /api/v1/payments/payapp/feedback`은 페이앱 서버 전용 FORM 통보이며 프론트에서 호출하지 않는다. - 잔액은 available/reserved/total 및 보류 여부를 구분한다. 최초 온보딩 완료 시 기본3크레딧, 크레딧 무기한. 지급 내역이 없는 기존 완료 계정은0이며 자동 소급하지 않는다. 구매 취소와 생성 실패 예약 해제는 다른 처리다. -- 구매별 환불 접수와 조회는 [환불 접수 계약](payment-refund-intake.md)을 따른다. 페이앱 환불 실행과 정산은 후속 범위다. +- 구매별 환불 접수와 조회는 [환불 접수 계약](payment-refund-intake.md)을 따른다. 페이앱 취소는 판매자 화면에서 진행하고, 확인된 취소 결과를 운영 명령으로 정산한다. -## 환불 접수와 정산 (#132, #134) +## 환불 접수와 정산 (#132, #134, #137) +환불 신청·상세·목록의 최상위 `orderId`로 결제 주문을 연결합니다. 접수가 종료된 뒤에도 같은 값이 제공되며, 개별 접수는 `receiptId`로 구분합니다. 환불 상세·목록의 `processingStatus`로 접수와 최종 처리 결과를 구분합니다. `RECEIVED`는 `currentEstimate`를 표시하고 종료된 건은 `resolution`의 금액·회수 수량·처리 시각을 표시합니다. 주문 `REFUND_SETTLED`는 충전 폴링을 종료하고, 탈퇴 전에는 환불 접수번호와 문의 이메일을 저장할 수 있게 안내합니다. diff --git a/docs/payment-refund-intake.md b/docs/payment-refund-intake.md index a288d10..a5733ab 100644 --- a/docs/payment-refund-intake.md +++ b/docs/payment-refund-intake.md @@ -23,7 +23,12 @@ `orderId`는 결제 API의 공개 주문 UUID다. `requestId`는 버튼을 누를 때 생성하고 같은 요청 재전송에 재사용한다. 일반 환불은 `STANDARD`, 청약철회·서비스 하자 등 판단이 필요한 신청은 `RIGHTS_REVIEW`를 사용한다. -금액·수량·결제사 번호는 클라이언트가 제출하지 않는다. 주문마다 접수 하나가 생성되며 동일 요청은 같은 `receiptId`로 응답한다. +금액·수량·결제사 번호는 클라이언트가 제출하지 않는다. 주문마다 진행 중 또는 완료된 접수는 하나만 허용한다. +반려·철회된 주문은 새 요청 UUID로 다시 신청할 수 있고, 동일 요청은 원래 `receiptId`로 응답한다. + +환불 신청(재전송 포함)·상세·목록의 각 접수에 최상위 `orderId`가 있다. +이 값은 결제 주문 조회의 공개 UUID와 같으며 완료·반려·철회 후 `currentEstimate`가 `null`이어도 유지된다. +구매 주문과 접수는 `orderId`로 연결하고, 반려·철회 후 재신청 이력 등 개별 접수는 `receiptId`로 구분한다. 접수 후 `receiptId`, `receivedAt`, `supportEmail`을 사용자에게 보여주고 저장할 수 있게 한다. `initialStatus`와 `*AtRequest` 필드는 접수 당시 기록이다. `currentEstimate`는 예약 확정·해제나 결제 상태가 바뀐 뒤 다시 계산한 값이며, 접수 당시 시각을 기준으로 수수료 경계를 판정한다. 표시 문구는 ‘환불 신청 접수’로 하고, 예상액이 있는 경우에도 완료 또는 확정 금액으로 표시하지 않는다. diff --git a/src/main/java/com/mongle/backend/domain/payment/RefundReceipt.java b/src/main/java/com/mongle/backend/domain/payment/RefundReceipt.java index abf0060..23cfbeb 100644 --- a/src/main/java/com/mongle/backend/domain/payment/RefundReceipt.java +++ b/src/main/java/com/mongle/backend/domain/payment/RefundReceipt.java @@ -1,9 +1,15 @@ package com.mongle.backend.domain.payment; +import io.swagger.v3.oas.annotations.media.Schema; + import java.time.Instant; public record RefundReceipt( - String receiptId, String requestId, Instant receivedAt, PaymentRefundRequest.Reason reason, + String receiptId, + @Schema(description = "원래 결제 주문의 공개 UUID. 환불 처리 상태와 관계없이 결제 주문 조회의 orderId와 같습니다.", + format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) + String orderId, + String requestId, Instant receivedAt, PaymentRefundRequest.Reason reason, RefundEstimate.Status initialStatus, long unusedAtRequest, long reservedAtRequest, long usedAtRequest, Long estimatedAmountAtRequest, Integer feePercentAtRequest, RefundEstimate currentEstimate, String supportEmail, ProcessingStatus processingStatus, Resolution resolution) { diff --git a/src/main/java/com/mongle/backend/domain/payment/RefundService.java b/src/main/java/com/mongle/backend/domain/payment/RefundService.java index 779e136..db6caa7 100644 --- a/src/main/java/com/mongle/backend/domain/payment/RefundService.java +++ b/src/main/java/com/mongle/backend/domain/payment/RefundService.java @@ -128,14 +128,15 @@ private CreditGrant purchaseGrant(PaymentOrder order) { } private RefundReceipt receipt(PaymentRefundRequest row) { + var order = row.getOrder(); var resolution = row.getResolution(); var estimate = resolution == null - ? RefundEstimate.calculate(row.getOrder(), row.getGrant(), row.getReason(), row.getCreatedAt()) : null; + ? RefundEstimate.calculate(order, row.getGrant(), row.getReason(), row.getCreatedAt()) : null; var status = resolution == null ? RefundReceipt.ProcessingStatus.RECEIVED : RefundReceipt.ProcessingStatus.valueOf(resolution.getStatus().name()); var result = resolution == null ? null : new RefundReceipt.Resolution( resolution.getRefundedAmount(), resolution.getRevokedCredits(), resolution.getResolvedAt()); - return new RefundReceipt(row.getReceiptId(), row.getRequestId(), row.getCreatedAt(), row.getReason(), + return new RefundReceipt(row.getReceiptId(), order.getPublicId(), row.getRequestId(), row.getCreatedAt(), row.getReason(), row.getInitialStatus(), row.getAvailableAtRequest(), row.getReservedAtRequest(), row.getUsedAtRequest(), row.getEstimatedAmount(), row.getFeePercent(), estimate, properties.supportEmail(), status, result); } diff --git a/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java b/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java index 6985d0a..9c2580a 100644 --- a/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java +++ b/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java @@ -45,6 +45,7 @@ @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", + "springdoc.api-docs.enabled=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", @@ -256,9 +257,37 @@ void authenticatedIntakeAndWithdrawalPreserveOpenReceiptsAndIsolateOtherMembers( assertThat(response.headers().firstValue("Cache-Control")).hasValue("no-store"); var data = json.readTree(response.body()).path("data"); String id = data.path("receiptId").asString(); + var orderResponse = http(client, bearer, "GET", "/api/v1/payments/orders/" + paid, null); + assertThat(orderResponse.statusCode()).isEqualTo(200); + assertThat(data.path("orderId")).isEqualTo(json.readTree(orderResponse.body()).path("data").path("orderId")); + assertThat(data.path("orderId").asString()).isEqualTo(paid.toString()); + assertThat(data.path("currentEstimate").path("orderId")).isEqualTo(data.path("orderId")); + assertThat(data.path("processingStatus").asString()).isEqualTo("RECEIVED"); assertThat(data.path("supportEmail").asString()).isEqualTo("help@example.invalid"); assertThat(response.body()).doesNotContain("providerTransaction", "notificationToken", "grantId"); + + var replay = http(client, bearer, "POST", path, body); + assertThat(replay.statusCode()).isEqualTo(200); + assertThat(json.readTree(replay.body()).path("data")).isEqualTo(data); + var detail = http(client, bearer, "GET", path + "/" + id, null); + assertThat(detail.statusCode()).isEqualTo(200); + assertThat(json.readTree(detail.body()).path("data")).isEqualTo(data); + var list = http(client, bearer, "GET", path, null); + assertThat(list.statusCode()).isEqualTo(200); + assertThat(json.readTree(list.body()).path("data").path("items").get(0)).isEqualTo(data); + assertThat(http(client, another, "GET", path + "/" + id, null).statusCode()).isEqualTo(404); + assertThat(http(client, another, "GET", path + "/estimate?orderId=" + paid, null).statusCode()).isEqualTo(404); + var otherList = http(client, another, "GET", path, null); + assertThat(otherList.statusCode()).isEqualTo(200); + assertThat(json.readTree(otherList.body()).path("data").path("items").size()).isZero(); + + var apiDocs = http(client, null, "GET", "/v3/api-docs", null); + assertThat(apiDocs.statusCode()).isEqualTo(200); + var schema = json.readTree(apiDocs.body()).path("components").path("schemas").path("RefundReceipt"); + assertThat(schema.path("properties").path("orderId").path("format").asString()).isEqualTo("uuid"); + assertThat(schema.path("required").valueStream().map(node -> node.asString())).contains("orderId"); + 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(); @@ -370,9 +399,10 @@ void reconcilesDistinctPartialCancellationsOnceAndPreservesOtherPurchaseHold() t assertThat(reclaimedEntries()).isEqualTo(1); var status = refunds.get(user, UUID.fromString(receipt.receiptId())); assertThat(status.processingStatus()).isEqualTo(RefundReceipt.ProcessingStatus.COMPLETED); + assertThat(status.orderId()).isEqualTo(paid.getPublicId()); assertThat(status.currentEstimate()).isNull(); assertThat(status.resolution().refundedAmount()).isEqualTo(693); - assertThat(refunds.page(user, null, 20).items().getFirst().resolution()).isEqualTo(status.resolution()); + assertThat(refunds.page(user, null, 20).items()).containsExactly(status); now = now.plusSeconds(1); partial(paid, "33001", 350, 71); @@ -428,20 +458,29 @@ void closingWithoutProviderCancellationReleasesOnlyThisReceiptAndAllowsReapplica throws Exception { var paid = approvePurchase(); var orderId = UUID.fromString(paid.getPublicId()); - var receipt = refunds.receive(user, request(orderId, STANDARD)); + var originalRequest = request(orderId, STANDARD); + var receipt = refunds.receive(user, originalRequest); String snapshot = readySnapshot(operator("preview", action, receipt, null)); // Changing the same user's balance after preview must make the approval stale. credits.grant(user, CreditSource.PROMOTION, "stale-close:" + user, 1); assertThatThrownBy(() -> operator("apply", action, receipt, snapshot)).hasMessage("SNAPSHOT_CHANGED"); String refreshed = readySnapshot(operator("preview", action, receipt, null)); var closed = operator("apply", action, receipt, refreshed); - assertThat(closed.get("status")).isEqualTo(action.equals("reject") ? "REJECTED" : "WITHDRAWN"); + var expectedStatus = action.equals("reject") + ? RefundReceipt.ProcessingStatus.REJECTED : RefundReceipt.ProcessingStatus.WITHDRAWN; + assertThat(closed.get("status")).isEqualTo(expectedStatus.name()); + var resolved = refunds.get(user, UUID.fromString(receipt.receiptId())); + assertThat(resolved.processingStatus()).isEqualTo(expectedStatus); + assertThat(resolved.orderId()).isEqualTo(paid.getPublicId()); + assertThat(resolved.currentEstimate()).isNull(); + assertThat(refunds.receive(user, originalRequest)).isEqualTo(resolved); var image = reserve("after-close"); reservations.release(user, image.reservationId(), CreditReservation.ReleaseReason.FAILURE); var next = refunds.receive(user, request(orderId, STANDARD)); assertThat(next.receiptId()).isNotEqualTo(receipt.receiptId()); assertError(() -> reserve("after-receiving-again"), CreditErrorCode.INSUFFICIENT); - assertThat(refunds.page(user, null, 20).items()).hasSize(2); + assertThat(next.orderId()).isEqualTo(paid.getPublicId()); + assertThat(refunds.page(user, null, 20).items()).containsExactly(next, resolved); } @Test