Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/frontend-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`는 충전 폴링을 종료하고, 탈퇴 전에는 환불 접수번호와 문의 이메일을 저장할 수 있게 안내합니다.
Expand Down
7 changes: 6 additions & 1 deletion docs/payment-refund-intake.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`는 예약 확정·해제나 결제 상태가 바뀐 뒤 다시 계산한 값이며, 접수 당시 시각을 기준으로 수수료 경계를 판정한다. 표시 문구는 ‘환불 신청 접수’로 하고, 예상액이 있는 경우에도 완료 또는 확정 금액으로 표시하지 않는다.
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down