diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 0822963..4b75609 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -252,12 +252,14 @@ Refresh는 앱 전체에서 한 번씩 실행되도록 묶는다. StrictMode· - 잔액은 available/reserved/total 및 보류 여부를 구분한다. 최초 온보딩 완료 시 기본3크레딧, 크레딧 무기한. 지급 내역이 없는 기존 완료 계정은0이며 자동 소급하지 않는다. 구매 취소와 생성 실패 예약 해제는 다른 처리다. - 구매별 환불 접수와 조회는 [환불 접수 계약](payment-refund-intake.md)을 따른다. 페이앱 취소는 판매자 화면에서 진행하고, 확인된 취소 결과를 운영 명령으로 정산한다. -## 환불 접수와 정산 (#132, #134, #137) +## 환불 접수와 정산 (#132, #134, #137, #139) 환불 신청·상세·목록의 최상위 `orderId`로 결제 주문을 연결합니다. 접수가 종료된 뒤에도 같은 값이 제공되며, 개별 접수는 `receiptId`로 구분합니다. 환불 상세·목록의 `processingStatus`로 접수와 최종 처리 결과를 구분합니다. `RECEIVED`는 `currentEstimate`를 표시하고 종료된 건은 `resolution`의 금액·회수 수량·처리 시각을 표시합니다. 주문 `REFUND_SETTLED`는 충전 폴링을 종료하고, 탈퇴 전에는 환불 접수번호와 문의 이메일을 저장할 수 있게 안내합니다. +주문과 환불 조회의 `confirmedCancellationAmount`는 확인이 끝난 주문 누적 취소액이며, 정산 전에는 `null`입니다. +접수 건의 `resolution.refundedAmount`는 최초 정산 결과로 유지됩니다. 이후 추가 통보가 재검토 중이면 환불 접수가 완료 상태여도 `orderState=PAYMENT_REVIEW`가 함께 반환됩니다. [환불 접수 API](payment-refund-intake.md)와 [환불 상태 계약](payment-refund-settlement.md)을 참고합니다. ## 무료 크레딧 우선 차감 (#130) diff --git a/docs/payment-refund-settlement.md b/docs/payment-refund-settlement.md index 171d956..ffdc58e 100644 --- a/docs/payment-refund-settlement.md +++ b/docs/payment-refund-settlement.md @@ -69,3 +69,45 @@ - V10을 CD로 적용한다. V9 접수와 원장은 보존된다. 정산이 열린 동안은 탈퇴 기록을 파기하지 않으며, 완료된 환불의 보관 기한에는 최종 처리 시각을 반영한다. - 환불 접수 공개 전 대상 판매자 계정의 전액·부분취소, 정산 후 반환, 실제 취소 통보, 탈퇴 후 문의를 운영자와 검증한다. 현재 공개 플래그 기본값은 false다. - 운영 정산이 시작되면 `REFUND_SETTLED`와 정산 기록을 읽는 버전으로 운영한다. 일시 중지는 신규 접수 플래그를 내려 처리한다. + +## 정산 후 추가 취소 대조 (#139) + +V11은 최초 정산과 증빙을 보존하고 추가 취소 확인 이력을 분리한다. +기존 확인 취소와 다른 확정 통보가 수신되어 보류된 완료 접수만 대상으로 한다. + +1. 보류된 완료 접수를 조회한다. 한 번에 최대 20건이며 다음 페이지는 반환된 + `nextAfterReceiptId`를 `--after-receipt-id`에 넣어 조회한다. + + ```bash + java -jar "$MONGLE_JAR" --payment-refund=list-follow-ups + java -jar "$MONGLE_JAR" --payment-refund=inspect-follow-up --receipt-id="$RECEIPT_ID" + ``` + +2. 페이앱 판매자 화면에서 원주문과 추가 취소가 확정됐는지 대조한다. + 조회 결과에서 최초 정산액, 이전 추가 확인 이력, 이미 확인한 누적액, 새 통보 금액을 구분한다. + 새 통보 금액은 판단 전 참고값이다. 금액·귀속이 다르거나 통보가 충돌하면 보류한다. +3. 새로 확인한 금액만 `--amount`에 넣는다. 아래 예시는 최초 정산 900원, 추가 확정 취소 200원이다. + 운영 참조번호는 같은 접수의 추가 대조 건마다 새로 부여한다. + + ```bash + java -jar "$MONGLE_JAR" --payment-refund=preview --receipt-id="$RECEIPT_ID" \ + --action=follow-up --amount=200 --operator-reference=case-139-001 + java -jar "$MONGLE_JAR" --payment-refund=apply --receipt-id="$RECEIPT_ID" \ + --action=follow-up --amount=200 --operator-reference=case-139-001 \ + --expected-snapshot="$REFUND_SNAPSHOT" + ``` + +4. `READY`인 미리보기만 적용한다. 이후 통보나 상태가 달라졌으면 다시 대조한다. + 적용 후 같은 주문만 `REFUND_SETTLED`로 돌아간다. 다른 주문의 보류가 남으면 계정의 + `spendingBlocked`도 계속 true다. + +추가 대조는 최초 접수의 `resolution.refundedAmount`와 크레딧 회수 원장을 변경하지 않는다. +새 확인 이력과 증빙 저장, 해당 보류 해제, 주문 상태 변경은 사용자 잠금 아래 함께 커밋한다. +같은 참조번호·금액·스냅샷으로 재실행하면 당시 처리 결과를 반환하며, 더 최근에 잡힌 보류에는 손대지 않는다. +한 주문에서 확정 통보의 거래 식별자가 32건 이상이면 증빙을 일부만 처리하지 않고 검토 상태를 유지한다. +추가 대조가 적용된 거래의 보존 기한에는 최종 확인 시각을 반영한다. 재검토 중에는 파기하지 않는다. + +주문·환불 조회의 `confirmedCancellationAmount`는 최초 정산과 적용한 추가 취소를 합한 주문 단위 금액이다. +확인된 정산이 없으면 null이며, 새 통보가 보류 중이면 확인을 끝낸 금액까지만 포함한다. +환불 조회의 `orderState`가 `PAYMENT_REVIEW`여도 원래 접수의 `processingStatus=COMPLETED`는 유지된다. +V11을 적용한 릴리스의 운영 명령을 사용한다. diff --git a/scripts/payment_reclamation_smoke.py b/scripts/payment_reclamation_smoke.py index bfe0caf..024cf4a 100644 --- a/scripts/payment_reclamation_smoke.py +++ b/scripts/payment_reclamation_smoke.py @@ -16,9 +16,17 @@ def verify(repo, jar, credit_env, sql): owner = sql("INSERT INTO users(email,nickname,created_at,updated_at) VALUES('reclamation-cli@test.invalid','Reclaim',NOW(),NOW()); SELECT LAST_INSERT_ID();", "credit_test").strip() sql(f"INSERT INTO credit_accounts(user_id,available,reserved,version,created_at,updated_at) VALUES({owner},0,0,0,NOW(),NOW());", "credit_test") - def cancelled(): + def cancelled(partial_amount=None): order = str(uuid.uuid4()) tx = str(uuid.uuid4().int % (10**30) + 1) + cancel_tx = str(uuid.uuid4().int % (10**30) + 1) if partial_amount else tx + cancel_state = 70 if partial_amount else 9 + cancel_amount = partial_amount or 1100 + partial_binding = ( + f"INSERT INTO payment_transaction_bindings(transaction_id,order_id) " + f"SELECT '{cancel_tx}',id FROM payment_orders WHERE public_id='{order}';" + if partial_amount else "" + ) key = "payapp:" + order digest = hashlib.sha256(key.encode()).hexdigest() grant = sql(f"""INSERT INTO credit_grants(user_id,source_type,source_key,source_hash,amount,available,reserved,consumed,version,created_at) @@ -31,8 +39,9 @@ def cancelled(): INSERT INTO payment_transaction_bindings(transaction_id,order_id) SELECT '{tx}',id FROM payment_orders WHERE public_id='{order}'; INSERT INTO payment_notifications(event_hash,order_id,provider_transaction,state_code,amount,responded_at,created_at) SELECT SHA2('{uuid.uuid4()}',256),id,'{tx}',4,1100,UTC_TIMESTAMP()-INTERVAL 30 SECOND,NOW() FROM payment_orders WHERE public_id='{order}'; + {partial_binding} INSERT INTO payment_notifications(event_hash,order_id,provider_transaction,state_code,amount,responded_at,created_at) - SELECT SHA2('{uuid.uuid4()}',256),id,'{tx}',9,1100,UTC_TIMESTAMP(),NOW() FROM payment_orders WHERE public_id='{order}'; + SELECT SHA2('{uuid.uuid4()}',256),id,'{cancel_tx}',{cancel_state},{cancel_amount},UTC_TIMESTAMP(),NOW() FROM payment_orders WHERE public_id='{order}'; INSERT INTO credit_payment_holds(grant_id,user_id,created_at) VALUES({grant},{owner},NOW()); INSERT INTO credit_ledger(user_id,entry_key,kind,source_type,grant_id,available_delta,reserved_delta,available_after,reserved_after,created_at) SELECT {owner},'PAYMENT_HOLD:{grant}','PAYMENT_HOLD','PAYMENT',{grant},0,0,available,reserved,NOW() FROM credit_accounts WHERE user_id={owner};""", "credit_test") @@ -91,7 +100,7 @@ def command(order, mode, key, token=None, expected_exit=0, overrides=None, no_ch raise RuntimeError("Packaged reclamation final balance mismatch.") print("Packaged JAR: reclaim preview SELECT-only/apply/replay/safe rejection/concurrent JVMs passed without Boot or provider calls.", flush=True) - order, grant = cancelled() + order, grant = cancelled(partial_amount=900) verify_refund(repo, jar, credit_env, sql, order, grant) @@ -105,11 +114,14 @@ def verify_refund(repo, jar, credit_env, sql, order, grant): SELECT '{receipt}','{uuid.uuid4()}',user_id,id,{grant},'RIGHTS_REVIEW','REVIEW_REQUIRED',10,0,0,NOW() FROM payment_orders WHERE public_id='{order}';""", "credit_test") - def command(mode, token=None, enabled=True, expected_exit=0): - args = ["java", "-Xmx128m", "-jar", str(jar), "--payment-refund=" + mode, "--receipt-id=" + receipt] - if mode != "inspect": - args.extend(["--action=settle", "--amount=1100", "--credits=10", "--basis=REVIEW", - "--operator-reference=fixture-134"]) + def command(mode, token=None, enabled=True, expected_exit=0, follow_up=False): + args = ["java", "-Xmx128m", "-jar", str(jar), "--payment-refund=" + mode] + if mode != "list-follow-ups": + args.append("--receipt-id=" + receipt) + if mode in {"preview", "apply"}: + args.extend(["--action=follow-up", "--amount=200", "--operator-reference=fixture-139"] if follow_up + else ["--action=settle", "--amount=900", "--credits=10", "--basis=REVIEW", + "--operator-reference=fixture-134"]) if token: args.append("--expected-snapshot=" + token) outcome = subprocess.run(args, cwd=repo, env={ @@ -131,3 +143,34 @@ def command(mode, token=None, enabled=True, expected_exit=0): if sql(f"SELECT available FROM credit_grants WHERE id={grant};", "credit_test").strip() != "0": raise RuntimeError("Packaged refund did not reclaim the target grant.") print("Packaged JAR: refund inspect/preview/flag and concurrent apply passed without Boot or provider calls.", flush=True) + + extra_tx = str(uuid.uuid4().int % (10**30) + 1) + sql(f"""UPDATE payment_orders SET state='PAYMENT_REVIEW',version=version+1 WHERE public_id='{order}'; + INSERT INTO payment_transaction_bindings(transaction_id,order_id) + SELECT '{extra_tx}',id FROM payment_orders WHERE public_id='{order}'; + INSERT INTO payment_notifications(event_hash,order_id,provider_transaction,state_code,amount,responded_at,created_at) + SELECT SHA2('{uuid.uuid4()}',256),id,'{extra_tx}',71,200,UTC_TIMESTAMP(),NOW() + FROM payment_orders WHERE public_id='{order}'; + INSERT INTO credit_payment_holds(grant_id,user_id,created_at) + SELECT {grant},user_id,NOW() FROM payment_orders WHERE public_id='{order}'; + INSERT INTO credit_ledger(user_id,entry_key,kind,source_type,grant_id, + available_delta,reserved_delta,available_after,reserved_after,created_at) + SELECT a.user_id,'PAYMENT_HOLD:{grant}:1','PAYMENT_HOLD','PAYMENT',{grant},0,0,a.available,a.reserved,NOW() + FROM payment_orders o JOIN credit_accounts a ON a.user_id=o.user_id WHERE o.public_id='{order}';""", "credit_test") + sql("REVOKE INSERT,UPDATE,DELETE ON credit_test.* FROM 'mongle_app'@'127.0.0.1';") + try: + if command("list-follow-ups").get("items") != [{"receiptId": receipt, "orderId": order}]: + raise RuntimeError("Packaged follow-up discovery mismatch.") + if command("inspect-follow-up").get("unreviewedCancellationAmount") != 200: + raise RuntimeError("Packaged follow-up inspection mismatch.") + follow_up = command("preview", follow_up=True) + if follow_up.get("decision") != "READY": + raise RuntimeError("Packaged follow-up preview mismatch.") + finally: + sql("GRANT SELECT,INSERT,UPDATE,DELETE ON credit_test.* TO 'mongle_app'@'127.0.0.1';") + with ThreadPoolExecutor(max_workers=2) as pool: + outcomes = list(pool.map(lambda _: command("apply", follow_up["snapshot"], follow_up=True), range(2))) + if (outcomes[0] != outcomes[1] or outcomes[0].get("confirmedCancellationAmount") != 1100 + or command("list-follow-ups").get("items") != []): + raise RuntimeError("Independent follow-up JVMs did not clear the target hold exactly once.") + print("Packaged JAR: follow-up discovery/inspection/preview SELECT-only and concurrent apply passed.", flush=True) diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentOrderResponse.java b/src/main/java/com/mongle/backend/domain/payment/PaymentOrderResponse.java index 3372722..e7efc2d 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentOrderResponse.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentOrderResponse.java @@ -4,12 +4,18 @@ @Schema(description="본인 주문. 결제창 URL 발급은 결제 완료가 아닙니다. PAID와 creditGranted를 확인하세요. CREDIT_RECLAIMED는 전액 승인취소된 미사용 구매분의 내부 회수 완료입니다. REFUND_SETTLED는 접수 건의 PG 취소와 내부 정산이 완료된 상태입니다. creditGranted는 과거 지급 연결 여부이며 현재 잔액이 아닙니다. UNKNOWN은 결과 미확정이며 같은 주문으로 결제를 자동 재요청하지 않습니다.") public record PaymentOrderResponse(String orderId, String productId, String productName, long price, String currency, long credits, PaymentOrder.State state, String paymentUrl, boolean creditGranted, - LocalDateTime createdAt) { + LocalDateTime createdAt, + @Schema(description = "환불 정산 및 이후 재대조에서 확인한 주문 누적 취소액. 확인된 정산이 없으면 null.") + Long confirmedCancellationAmount) { static PaymentOrderResponse from(PaymentOrder o,Instant now) { + return from(o, now, null); + } + + static PaymentOrderResponse from(PaymentOrder o,Instant now,Long confirmedCancellationAmount) { var state=o.getState(); if(state==PaymentOrder.State.REQUESTING && !o.getRequestDeadline().isAfter(now)) state=PaymentOrder.State.UNKNOWN; return new PaymentOrderResponse(o.getPublicId(),o.getProductId(),o.getProductName(),o.getPrice(), o.getCurrency(),o.getCredits(),state,state==PaymentOrder.State.READY?o.getPaymentUrl():null, - o.getCreditGrantId()!=null,o.getCreatedAt()); + o.getCreditGrantId()!=null,o.getCreatedAt(),confirmedCancellationAmount); } } diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentQueryService.java b/src/main/java/com/mongle/backend/domain/payment/PaymentQueryService.java index d4f726d..c740a62 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentQueryService.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentQueryService.java @@ -5,13 +5,15 @@ import com.mongle.backend.global.error.BusinessException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import java.time.Clock; -@Service @RequiredArgsConstructor @Transactional(readOnly=true) +@Service @RequiredArgsConstructor @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) public class PaymentQueryService { private final PaymentOrderRepository orders; private final UserRepository users; + private final PaymentRefundRepository refunds; private final Clock authClock; public PaymentOrderResponse byRequestId(Long user,String requestId) { requireUser(user); @@ -22,13 +24,13 @@ public PaymentOrderResponse byRequestId(Long user,String requestId) { } catch(IllegalArgumentException | NullPointerException ex) { throw new BusinessException(PaymentErrorCode.INVALID_INPUT); } - return PaymentOrderResponse.from(orders.findByUserIdAndRequestId(user,canonical) - .orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND)),authClock.instant()); + return response(orders.findByUserIdAndRequestId(user,canonical) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND))); } public PaymentOrderResponse get(Long user,String publicId) { requireUser(user); - return PaymentOrderResponse.from(orders.findByPublicIdAndUserId(publicId,user) - .orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND)),authClock.instant()); + return response(orders.findByPublicIdAndUserId(publicId,user) + .orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND))); } public PaymentOrderPage page(Long user,Long beforeId,int size) { if(size<1 || size>50 || beforeId!=null && beforeId<1) throw new BusinessException(PaymentErrorCode.INVALID_PAGE); @@ -36,9 +38,15 @@ public PaymentOrderPage page(Long user,Long beforeId,int size) { var rows=orders.page(user,beforeId,org.springframework.data.domain.PageRequest.of(0,size+1)); boolean hasNext=rows.size()>size; var now=authClock.instant(); - return new PaymentOrderPage(rows.stream().limit(size).map(o -> PaymentOrderResponse.from(o,now)).toList(), + var page = rows.subList(0, Math.min(size, rows.size())); + var totals = refunds.confirmedCancellationAmounts(page.stream().map(PaymentOrder::getId).toList()); + return new PaymentOrderPage(page.stream().map(o -> PaymentOrderResponse.from(o,now,totals.get(o.getId()))).toList(), hasNext,hasNext?rows.get(size-1).getId():null); } + + private PaymentOrderResponse response(PaymentOrder order) { + return PaymentOrderResponse.from(order, authClock.instant(), refunds.confirmedCancellationAmount(order.getId())); + } private void requireUser(Long id) { if(id==null || id<1) throw new BusinessException(PaymentErrorCode.INVALID_INPUT); var u=users.findById(id).orElseThrow(() -> new BusinessException(PaymentErrorCode.NOT_FOUND)); diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentRefundEvidence.java b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundEvidence.java index 3276657..27c0e86 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentRefundEvidence.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundEvidence.java @@ -12,6 +12,7 @@ class PaymentRefundEvidence { private Long id; @Column(nullable = false) private Long refundId; + private Long followUpId; @Column(nullable = false, length = 40, unique = true) private String providerTransaction; @Column(nullable = false) diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentRefundFollowUp.java b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundFollowUp.java new file mode 100644 index 0000000..b172e5a --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundFollowUp.java @@ -0,0 +1,33 @@ +package com.mongle.backend.domain.payment; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "payment_refund_follow_ups", uniqueConstraints = { + @UniqueConstraint(columnNames = {"refund_id", "operator_reference"}), + @UniqueConstraint(columnNames = {"id", "refund_id"}) +}) +class PaymentRefundFollowUp { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false) + private Long refundId; + @Column(nullable = false) + private long additionalAmount; + @Column(nullable = false) + private long confirmedTotal; + @Column(nullable = false, length = 80) + private String operatorReference; + @Column(nullable = false, length = 64) + private String snapshotHash; + @JdbcTypeCode(SqlTypes.TIMESTAMP) + @Column(nullable = false) + private Instant confirmedAt; +} diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRepository.java b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRepository.java index 1ccca85..347208b 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRepository.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRepository.java @@ -6,6 +6,8 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import java.util.Optional; interface PaymentRefundRepository extends JpaRepository { @@ -35,6 +37,29 @@ select count(*) from payment_refund_evidence e long countMatchedCancellation(@Param("orderId") Long orderId, @Param("transaction") String transaction, @Param("amount") long amount, @Param("full") boolean full); + interface ConfirmedCancellation { + Long getOrderId(); + Long getAmount(); + } + + @Query(""" + select r.order.id as orderId, sum(e.amount) as amount + from PaymentRefundRequest r join PaymentRefundEvidence e on e.refundId = r.id + where r.order.id in :orderIds and r.resolution.status = COMPLETED + group by r.order.id + """) + List sumConfirmedByOrderIds(@Param("orderIds") List orderIds); + + default Map confirmedCancellationAmounts(List orderIds) { + if (orderIds.isEmpty()) return Map.of(); + return sumConfirmedByOrderIds(orderIds).stream() + .collect(Collectors.toMap(ConfirmedCancellation::getOrderId, ConfirmedCancellation::getAmount)); + } + + default Long confirmedCancellationAmount(Long orderId) { + return confirmedCancellationAmounts(List.of(orderId)).get(orderId); + } + @Query(""" select r from PaymentRefundRequest r join fetch r.order join fetch r.grant left join fetch r.resolution where r.userId = :user and (:before is null or r.id < :before) diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentTransactions.java b/src/main/java/com/mongle/backend/domain/payment/PaymentTransactions.java index c3faaa5..5a1e1e5 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentTransactions.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentTransactions.java @@ -13,6 +13,7 @@ public class PaymentTransactions { private final UserRepository users; private final PaymentOrderRepository orders; + private final PaymentRefundRepository refunds; private final PaymentTransactionRepository bindings; private final PaymentCatalog catalog; private final PaymentProperties properties; @@ -32,7 +33,9 @@ public Begin begin(Long user,PaymentOrderRequest request,boolean providerAvailab if(prior.isPresent()) { if(!PaymentHashes.equal(prior.get().getInputHash(),PaymentOrder.inputHash(request))) throw new BusinessException(PaymentErrorCode.REQUEST_CONFLICT); - return new Begin(PaymentOrderResponse.from(prior.get(),authClock.instant()),null); + var order = prior.get(); + return new Begin(PaymentOrderResponse.from(order, authClock.instant(), + refunds.confirmedCancellationAmount(order.getId())), null); } if(!properties.enabled() || !creditProperties.enabled() || !providerAvailable) throw new BusinessException(PaymentErrorCode.UNAVAILABLE); @@ -53,7 +56,7 @@ public PaymentOrderResponse finish(Long user,String publicId,PaymentGateway.Resu order.requestFinished(result); orders.flush(); retainAccountingRecords(user); - return PaymentOrderResponse.from(order,authClock.instant()); + return PaymentOrderResponse.from(order, authClock.instant(), refunds.confirmedCancellationAmount(order.getId())); } void bind(PaymentOrder order,String transactionId) { if(transactionId==null || !transactionId.matches("[1-9][0-9]{0,39}")) 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 23cfbeb..643aa1e 100644 --- a/src/main/java/com/mongle/backend/domain/payment/RefundReceipt.java +++ b/src/main/java/com/mongle/backend/domain/payment/RefundReceipt.java @@ -12,7 +12,11 @@ public record RefundReceipt( 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) { + String supportEmail, ProcessingStatus processingStatus, Resolution resolution, + @Schema(description = "원주문의 현재 결제 상태. 환불 정산 후 새 취소를 확인 중이면 PAYMENT_REVIEW.") + PaymentOrder.State orderState, + @Schema(description = "원주문에서 확인한 누적 취소액. 해당 접수의 최초 정산액은 resolution.refundedAmount로 별도 제공.") + Long confirmedCancellationAmount) { public enum ProcessingStatus { RECEIVED, COMPLETED, REJECTED, WITHDRAWN } 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 db6caa7..7d726f0 100644 --- a/src/main/java/com/mongle/backend/domain/payment/RefundService.java +++ b/src/main/java/com/mongle/backend/domain/payment/RefundService.java @@ -85,7 +85,10 @@ public RefundPage page(Long userId, Long beforeId, int size) { } 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(), + var page = rows.subList(0, Math.min(size, rows.size())); + var totals = refunds.confirmedCancellationAmounts(page.stream() + .map(row -> row.getOrder().getId()).distinct().toList()); + return new RefundPage(page.stream().map(row -> receipt(row, totals.get(row.getOrder().getId()))).toList(), hasNext, hasNext ? rows.get(size - 1).getId() : null); } @@ -128,6 +131,10 @@ private CreditGrant purchaseGrant(PaymentOrder order) { } private RefundReceipt receipt(PaymentRefundRequest row) { + return receipt(row, refunds.confirmedCancellationAmount(row.getOrder().getId())); + } + + private RefundReceipt receipt(PaymentRefundRequest row, Long confirmedCancellationAmount) { var order = row.getOrder(); var resolution = row.getResolution(); var estimate = resolution == null @@ -138,7 +145,8 @@ private RefundReceipt receipt(PaymentRefundRequest row) { resolution.getRefundedAmount(), resolution.getRevokedCredits(), resolution.getResolvedAt()); 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); + row.getUsedAtRequest(), row.getEstimatedAmount(), row.getFeePercent(), estimate, properties.supportEmail(), + status, result, order.getState(), confirmedCancellationAmount); } private void requireActive(Long userId) { 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 7b01556..b8c2b55 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 @@ -31,6 +31,8 @@ union all select max(created_at) event_at from payment_credit_reclamations where union all select max(created_at) event_at from payment_refund_requests where user_id=:user union all select max(s.resolved_at) event_at from payment_refund_resolutions s join payment_refund_requests r on r.id=s.refund_id where r.user_id=:user + union all select max(f.confirmed_at) event_at from payment_refund_follow_ups f + join payment_refund_requests r on r.id=f.refund_id where r.user_id=:user union all select max(created_at) event_at from credit_ledger where user_id=:user and source_type='PAYMENT' ) accounting_dates """) @@ -139,6 +141,7 @@ public void deleteAccounting(Long user, LocalDateTime now) { for(String sql:List.of( "delete from legal_consents where user_id=:user", "delete from payment_refund_evidence where refund_id in (select id from payment_refund_requests where user_id=:user)", + "delete from payment_refund_follow_ups where refund_id in (select id from payment_refund_requests where user_id=:user)", "delete from payment_refund_resolutions where refund_id in (select id from payment_refund_requests where user_id=:user)", "delete from payment_refund_requests where user_id=:user", "delete from payment_credit_reclamations where user_id=:user", diff --git a/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java b/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java index 5c1007e..24e1ca2 100644 --- a/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java +++ b/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java @@ -252,20 +252,23 @@ select coalesce(sum(case when user_id=? then available_delta else 0 end),0) as a """ select coalesce(sum(case when user_id=? then 1 else 0 end),0) as account_holds, coalesce(sum(case when grant_id=? then 1 else 0 end),0) as purchase_holds, - coalesce(sum(case when grant_id=? and user_id<>? then 1 else 0 end),0) as invalid_owner + coalesce(sum(case when grant_id=? and user_id<>? then 1 else 0 end),0) as invalid_owner, + (select count(*) from payment_refund_follow_ups where refund_id=?) as follow_up_releases from credit_payment_holds where user_id=? or grant_id=? """, - r -> values(r, "account_holds", "purchase_holds", "invalid_owner"), + r -> values(r, "account_holds", "purchase_holds", "invalid_owner", "follow_up_releases"), order.user, grantId, grantId, order.user, + refund == null ? -1L : refund.id, order.user, grantId) .getFirst(); if (holds.get(2).signum() != 0 || !holds.get(1) .add(reclamation != null || (refund != null && refund.releasedHold) ? BigInteger.ONE : BigInteger.ZERO) + .add(holds.get(3)) .equals(ledger.get(5))) findings.add("PAYMENT_HOLD_LEDGER_MISMATCH"); if (holds.get(1).signum() > 0 && !storedState.equals("PAYMENT_REVIEW")) findings.add("HOLD_WITHOUT_REVIEW_STATE"); diff --git a/src/main/java/com/mongle/backend/operations/payment/RefundFollowUp.java b/src/main/java/com/mongle/backend/operations/payment/RefundFollowUp.java new file mode 100644 index 0000000..59899d6 --- /dev/null +++ b/src/main/java/com/mongle/backend/operations/payment/RefundFollowUp.java @@ -0,0 +1,208 @@ +package com.mongle.backend.operations.payment; + +import tools.jackson.databind.json.JsonMapper; + +import java.math.BigInteger; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.*; + +import static com.mongle.backend.operations.payment.RefundResolver.*; + +final class RefundFollowUp { + private static final int PAGE_SIZE = 20; + private static final int EVIDENCE_LIMIT = 32; + private static final JsonMapper JSON = JsonMapper.builder().build(); + + private RefundFollowUp() {} + + static Map list(PaymentInspector.Session db, String afterReceiptId) throws SQLException { + var rows = db.query(""" + select r.receipt_id,o.public_id from payment_refund_requests r + join payment_refund_resolutions s on s.refund_id=r.id and s.status='COMPLETED' + join payment_orders o on o.id=r.order_id and o.user_id=r.user_id + and o.credit_grant_id=r.grant_id and o.state='PAYMENT_REVIEW' + join credit_payment_holds h on h.grant_id=r.grant_id and h.user_id=r.user_id + where r.receipt_id>? + order by r.receipt_id limit ? + """, row -> new Candidate(row.getString("receipt_id"), row.getString("public_id")), + afterReceiptId, PAGE_SIZE + 1); + var result = new LinkedHashMap(); + result.put("event", "refund-follow-ups"); + result.put("items", rows.stream().limit(PAGE_SIZE).toList()); + result.put("nextAfterReceiptId", rows.size() > PAGE_SIZE ? rows.get(PAGE_SIZE - 1).receiptId() : null); + return result; + } + + static Map execute(PaymentInspector.Session db, Clock clock, RefundResolutionRequest request, + Receipt receipt, Resolution original, Map report, PaymentInspector inspector) + throws SQLException { + if (original == null || !"COMPLETED".equals(original.status())) throw fail("REFUND_NOT_COMPLETED"); + if (request.reference() != null) { + var previous = db.query(""" + select id,additional_amount,confirmed_total,operator_reference,snapshot_hash,confirmed_at + from payment_refund_follow_ups where refund_id=? and operator_reference=? + """, RefundFollowUp::round, receipt.id(), request.reference()); + if (!previous.isEmpty()) { + var found = previous.getFirst(); + if (!request.apply() || found.amount() != request.amount() + || !found.reference().equals(request.reference()) + || !found.snapshot().equals(request.expectedSnapshot())) { + throw fail("FOLLOW_UP_REFERENCE_USED"); + } + return applied(request, receipt, found); + } + } + + var verified = evidence(db, receipt); + var recorded = db.query(""" + select provider_transaction,amount,full_cancellation,follow_up_id + from payment_refund_evidence where refund_id=? order by provider_transaction + """, row -> new Recorded(row.getString("provider_transaction"), row.getLong("amount"), + row.getBoolean("full_cancellation"), row.getLong("follow_up_id")), receipt.id()); + var rounds = db.query(""" + select id,additional_amount,confirmed_total,operator_reference,snapshot_hash,confirmed_at + from payment_refund_follow_ups where refund_id=? order by id + """, RefundFollowUp::round, receipt.id()); + var byTransaction = new HashMap(); + recorded.forEach(item -> byTransaction.put(item.transaction(), item)); + var pending = verified.items().stream() + .filter(item -> !byTransaction.containsKey(item.transaction())).toList(); + BigInteger approvedTotal = sum(recorded); + BigInteger pendingTotal = pending.stream().map(item -> BigInteger.valueOf(item.minimum())) + .reduce(BigInteger.ZERO, BigInteger::add); + var findings = new ArrayList(); + + if (!((List) report.get("findings")).isEmpty() || receipt.linkedGrant() != receipt.grant() + || receipt.available() != 0 || receipt.reserved() != 0 || receipt.forfeited() != 0 + || !validHistory(recorded, rounds, original.refunded())) { + findings.add("REFUND_RECORDS_INCONSISTENT"); + } + if (verified.items().size() >= EVIDENCE_LIMIT || !verified.approvalMatches() + || verified.items().stream().anyMatch(item -> !item.matches(receipt)) + || verified.items().size() - pending.size() != recorded.size() + || verified.items().stream().anyMatch(item -> { + var existing = byTransaction.get(item.transaction()); + return existing != null && (existing.amount() != item.minimum() + || existing.full() != (item.minFull() == 1)); + })) { + findings.add("CANCELLATION_EVIDENCE_CONFLICT"); + } + if (verified.total().compareTo(BigInteger.valueOf(receipt.price())) > 0) { + findings.add("CANCELLATION_EXCEEDS_ORDER_AMOUNT"); + } + if (pending.isEmpty()) findings.add("NO_NEW_CONFIRMED_CANCELLATION"); + if (!receipt.hold() || !"PAYMENT_REVIEW".equals(receipt.orderState())) { + findings.add("FOLLOW_UP_HOLD_REQUIRED"); + } + + var response = summary(request, receipt); + response.put("orderState", receipt.orderState()); + response.put("initialRefundedAmount", original.refunded()); + response.put("confirmedCancellationAmount", approvedTotal); + response.put("unreviewedCancellationAmount", pendingTotal); + response.put("unreviewedCancellationCount", pending.size()); + response.put("previousFollowUps", rounds.stream() + .map(round -> new RoundSummary(round.amount(), round.total(), round.confirmedAt())).toList()); + if (request.mode() == RefundResolutionRequest.Mode.INSPECT_FOLLOW_UP) { + response.put("findings", findings); + return response; + } + + if (!pendingTotal.equals(BigInteger.valueOf(request.amount()))) { + findings.add("CANCELLATION_DOES_NOT_MATCH_APPROVAL"); + } + var state = new LinkedHashMap<>(report); + state.remove("observedAt"); + String snapshot = PaymentInspector.hash(JSON.writeValueAsString(List.of(request.receiptId(), + request.amount(), request.reference(), receipt.orderVersion(), receipt.accountVersion(), + receipt.grantVersion(), state, verified.items(), recorded, rounds.size()))); + response.put("decision", findings.isEmpty() ? "READY" : "REVIEW_REQUIRED"); + response.put("snapshot", snapshot); + response.put("findings", findings); + if (!request.apply()) return response; + if (!findings.isEmpty()) throw fail(findings.getFirst()); + if (!snapshot.equals(request.expectedSnapshot())) throw fail("SNAPSHOT_CHANGED"); + + var now = LocalDateTime.ofInstant(clock.instant().truncatedTo(ChronoUnit.MICROS), ZoneOffset.UTC); + long total = approvedTotal.add(pendingTotal).longValueExact(); + update(db, """ + insert into payment_refund_follow_ups(refund_id,additional_amount,confirmed_total, + operator_reference,snapshot_hash,confirmed_at) values(?,?,?,?,?,?) + """, receipt.id(), request.amount(), total, request.reference(), snapshot, now); + var round = db.query(""" + select id,additional_amount,confirmed_total,operator_reference,snapshot_hash,confirmed_at + from payment_refund_follow_ups where refund_id=? and operator_reference=? + """, RefundFollowUp::round, receipt.id(), request.reference()).getFirst(); + for (var item : pending) { + update(db, """ + insert into payment_refund_evidence(refund_id,provider_transaction,amount,full_cancellation,follow_up_id) + values(?,?,?,?,?) + """, receipt.id(), item.transaction(), item.minimum(), item.minFull() == 1, round.id()); + } + update(db, """ + update credit_accounts set version=version+1,updated_at=? where user_id=? and version=? + """, now, receipt.user(), receipt.accountVersion()); + update(db, "delete from credit_payment_holds where grant_id=? and user_id=?", receipt.grant(), receipt.user()); + update(db, """ + update payment_orders set state='REFUND_SETTLED',payment_url=null,version=version+1,updated_at=? + where id=? and user_id=? and version=? and state='PAYMENT_REVIEW' + """, now, receipt.orderKey(), receipt.user(), receipt.orderVersion()); + var after = inspector.inspect(db.connection(), receipt.orderId(), db.deadline()); + if (!((List) after.get("findings")).isEmpty()) throw fail("POSTCONDITION_FAILED"); + return applied(request, receipt, round); + } + + private static boolean validHistory(List recorded, List rounds, long original) { + if (recorded.size() >= EVIDENCE_LIMIT || rounds.size() >= EVIDENCE_LIMIT) return false; + var totals = new HashMap(); + for (var item : recorded) { + totals.merge(item.roundId(), BigInteger.valueOf(item.amount()), BigInteger::add); + } + BigInteger cumulative = totals.remove(0L); + if (!BigInteger.valueOf(original).equals(cumulative)) return false; + for (var round : rounds) { + var amount = totals.remove(round.id()); + if (!BigInteger.valueOf(round.amount()).equals(amount)) return false; + cumulative = cumulative.add(amount); + if (!BigInteger.valueOf(round.total()).equals(cumulative)) return false; + } + return totals.isEmpty(); + } + + private static Round round(ResultSet row) throws SQLException { + return new Round(row.getLong("id"), row.getLong("additional_amount"), row.getLong("confirmed_total"), + row.getString("operator_reference"), row.getString("snapshot_hash"), instant(row, "confirmed_at")); + } + + private static BigInteger sum(List items) { + return items.stream().map(item -> BigInteger.valueOf(item.amount())).reduce(BigInteger.ZERO, BigInteger::add); + } + + private static Map summary(RefundResolutionRequest request, Receipt receipt) { + var result = new LinkedHashMap(); + result.put("event", "refund-follow-up"); + result.put("receiptId", request.receiptId()); + result.put("orderId", receipt.orderId()); + return result; + } + + private static Map applied(RefundResolutionRequest request, Receipt receipt, Round round) { + var result = summary(request, receipt); + result.put("status", "APPLIED"); + result.put("additionalAmount", round.amount()); + result.put("confirmedCancellationAmount", round.total()); + result.put("confirmedAt", round.confirmedAt()); + return result; + } + + private record Candidate(String receiptId, String orderId) {} + private record Recorded(String transaction, long amount, boolean full, long roundId) {} + private record Round(long id, long amount, long total, String reference, String snapshot, Instant confirmedAt) {} + private record RoundSummary(long additionalAmount, long confirmedCancellationAmount, Instant confirmedAt) {} +} diff --git a/src/main/java/com/mongle/backend/operations/payment/RefundResolutionRequest.java b/src/main/java/com/mongle/backend/operations/payment/RefundResolutionRequest.java index 2edf96c..53970ae 100644 --- a/src/main/java/com/mongle/backend/operations/payment/RefundResolutionRequest.java +++ b/src/main/java/com/mongle/backend/operations/payment/RefundResolutionRequest.java @@ -4,8 +4,8 @@ record RefundResolutionRequest(Mode mode, String receiptId, Action action, long amount, long credits, String basis, String reference, String expectedSnapshot) { - enum Mode { INSPECT, PREVIEW, APPLY } - enum Action { SETTLE, REJECT, WITHDRAW } + enum Mode { INSPECT, LIST_FOLLOW_UPS, INSPECT_FOLLOW_UP, PREVIEW, APPLY } + enum Action { SETTLE, REJECT, WITHDRAW, FOLLOW_UP } static RefundResolutionRequest parse(String[] args) { try { @@ -16,14 +16,18 @@ static RefundResolutionRequest parse(String[] args) { throw new IllegalArgumentException(); } } - Mode mode = Mode.valueOf(values.remove("--payment-refund").toUpperCase(Locale.ROOT)); - String id = values.remove("--receipt-id"); - if (!UUID.fromString(id).toString().equals(id)) throw new IllegalArgumentException(); - if (mode == Mode.INSPECT && values.isEmpty()) { + Mode mode = Mode.valueOf(values.remove("--payment-refund").toUpperCase(Locale.ROOT).replace('-', '_')); + if (mode == Mode.LIST_FOLLOW_UPS) { + String cursor = values.remove("--after-receipt-id"); + if (!values.isEmpty()) throw new IllegalArgumentException(); + return new RefundResolutionRequest(mode, cursor == null ? "" : canonicalId(cursor), null, 0, 0, null, null, null); + } + String id = canonicalId(values.remove("--receipt-id")); + if (mode == Mode.INSPECT || mode == Mode.INSPECT_FOLLOW_UP) { + if (!values.isEmpty()) throw new IllegalArgumentException(); return new RefundResolutionRequest(mode, id, null, 0, 0, null, null, null); } - if (mode == Mode.INSPECT) throw new IllegalArgumentException(); - Action action = Action.valueOf(values.remove("--action").toUpperCase(Locale.ROOT)); + Action action = Action.valueOf(values.remove("--action").toUpperCase(Locale.ROOT).replace('-', '_')); String reference = values.remove("--operator-reference"); if (!reference.matches("[a-zA-Z0-9][a-zA-Z0-9._:-]{0,79}")) throw new IllegalArgumentException(); String token = mode == Mode.APPLY ? values.remove("--expected-snapshot") : null; @@ -37,6 +41,10 @@ static RefundResolutionRequest parse(String[] args) { basis = values.remove("--basis"); if (amount == 0 || !Set.of("STANDARD", "REVIEW").contains(basis)) throw new IllegalArgumentException(); } + if (action == Action.FOLLOW_UP) { + amount = nonnegative(values.remove("--amount")); + if (amount == 0) throw new IllegalArgumentException(); + } if (!values.isEmpty()) throw new IllegalArgumentException(); return new RefundResolutionRequest(mode, id, action, amount, credits, basis, reference, token); } catch (RuntimeException failure) { @@ -44,6 +52,13 @@ static RefundResolutionRequest parse(String[] args) { } } + private static String canonicalId(String id) { + if (!UUID.fromString(id).toString().equals(id)) throw new IllegalArgumentException(); + return id; + } + + boolean followUp() { return mode == Mode.INSPECT_FOLLOW_UP || action == Action.FOLLOW_UP; } + private static long nonnegative(String value) { if (!value.matches("0|[1-9][0-9]{0,18}")) throw new IllegalArgumentException(); return Long.parseLong(value); @@ -56,6 +71,7 @@ String status() { case SETTLE -> "COMPLETED"; case REJECT -> "REJECTED"; case WITHDRAW -> "WITHDRAWN"; + case FOLLOW_UP -> throw new IllegalStateException(); }; } diff --git a/src/main/java/com/mongle/backend/operations/payment/RefundResolver.java b/src/main/java/com/mongle/backend/operations/payment/RefundResolver.java index 68b9bc6..0c2d71c 100644 --- a/src/main/java/com/mongle/backend/operations/payment/RefundResolver.java +++ b/src/main/java/com/mongle/backend/operations/payment/RefundResolver.java @@ -40,6 +40,9 @@ Map execute(RefundResolutionRequest request) throws SQLException connection.setAutoCommit(false); try { var db = new PaymentInspector.Session(connection, deadline); + if (request.mode() == RefundResolutionRequest.Mode.LIST_FOLLOW_UPS) { + return RefundFollowUp.list(db, request.receiptId()); + } long user = one(db.query("select user_id from payment_refund_requests where receipt_id=?", row -> row.getLong(1), request.receiptId()), "REFUND_NOT_FOUND"); if (request.apply()) { @@ -48,7 +51,13 @@ Map execute(RefundResolutionRequest request) throws SQLException } var receipt = load(db, request.receiptId(), user); var prior = resolution(db, receipt.id); - var report = new PaymentInspector(source, clock).inspect(connection, receipt.orderId, deadline); + var inspector = new PaymentInspector(source, clock); + var report = inspector.inspect(connection, receipt.orderId, deadline); + if (request.followUp()) { + var result = RefundFollowUp.execute(db, clock, request, receipt, prior, report, inspector); + if (request.apply()) connection.commit(); + return result; + } var response = summary(request, receipt, report, prior); if (prior != null) { if (request.apply()) verifyReplay(request, prior); @@ -131,7 +140,7 @@ private static Resolution resolution(PaymentInspector.Session db, long id) throw return rows.isEmpty() ? null : rows.getFirst(); } - private static Evidence evidence(PaymentInspector.Session db, Receipt receipt) throws SQLException { + static Evidence evidence(PaymentInspector.Session db, Receipt receipt) throws SQLException { var items = db.query(""" select n.provider_transaction,min(n.amount) as minimum,max(n.amount) as maximum, min(case when n.state_code in (9,64) then 1 else 0 end) as min_full, @@ -299,7 +308,7 @@ private static String snapshot(RefundResolutionRequest request, Receipt r, r.grantVersion, state, evidence.requestCancellation, evidence.approvalMatches, evidence.items))); } - private static void update(PaymentInspector.Session db, String sql, Object... values) throws SQLException { + static void update(PaymentInspector.Session db, String sql, Object... values) throws SQLException { long left = db.deadline() - System.nanoTime(); if (left <= 0) throw new SQLTimeoutException(); try (var statement = db.connection().prepareStatement(sql)) { @@ -309,7 +318,7 @@ private static void update(PaymentInspector.Session db, String sql, Object... va } } - private static Instant instant(ResultSet row, String field) throws SQLException { + static Instant instant(ResultSet row, String field) throws SQLException { var value = row.getTimestamp(field, Calendar.getInstance(TimeZone.getTimeZone("UTC"))); return value == null ? null : value.toInstant(); } @@ -319,23 +328,23 @@ private static T one(List rows, String code) { return rows.getFirst(); } - private static PaymentInspectCommand.Failure fail(String code) { + static PaymentInspectCommand.Failure fail(String code) { return new PaymentInspectCommand.Failure(code); } - private record Receipt(long id, long user, long orderKey, long grant, String reason, Instant received, + record Receipt(long id, long user, long orderKey, long grant, String reason, Instant received, long initialAvailable, long initialReserved, String orderId, String originalTransaction, long linkedGrant, long price, long purchased, String orderState, long orderVersion, Instant approved, long available, long reserved, long consumed, long forfeited, long grantVersion, long accountAvailable, long accountReserved, long accountVersion, boolean hold) {} - private record Resolution(String status, long refunded, long revoked, String basis, String reference, + record Resolution(String status, long refunded, long revoked, String basis, String reference, String snapshot, Instant resolvedAt) {} - private record Evidence(List items, BigInteger total, boolean requestCancellation, + record Evidence(List items, BigInteger total, boolean requestCancellation, boolean approvalMatches) {} - private record Cancellation(String transaction, long minimum, long maximum, int minFull, + record Cancellation(String transaction, long minimum, long maximum, int minFull, int maxFull, boolean badBinding, Instant firstNotice) { boolean matches(Receipt receipt) { if (receipt.originalTransaction == null || transaction == null || !transaction.matches("[1-9][0-9]{0,39}") diff --git a/src/main/resources/db/migration/V11__payment_refund_follow_up.sql b/src/main/resources/db/migration/V11__payment_refund_follow_up.sql new file mode 100644 index 0000000..627c7d5 --- /dev/null +++ b/src/main/resources/db/migration/V11__payment_refund_follow_up.sql @@ -0,0 +1,17 @@ +CREATE TABLE payment_refund_follow_ups ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + refund_id BIGINT NOT NULL, + additional_amount BIGINT NOT NULL, + confirmed_total BIGINT NOT NULL, + operator_reference VARCHAR(80) NOT NULL, + snapshot_hash VARCHAR(64) NOT NULL, + confirmed_at DATETIME(6) NOT NULL, + CONSTRAINT fk_refund_follow_up_resolution FOREIGN KEY (refund_id) REFERENCES payment_refund_resolutions(refund_id), + CONSTRAINT uk_refund_follow_up_reference UNIQUE (refund_id, operator_reference), + CONSTRAINT uk_refund_follow_up_id_refund UNIQUE (id, refund_id), + CONSTRAINT ck_refund_follow_up_amount CHECK (additional_amount > 0 AND confirmed_total > additional_amount) +); + +ALTER TABLE payment_refund_evidence ADD COLUMN follow_up_id BIGINT NULL; +ALTER TABLE payment_refund_evidence ADD CONSTRAINT fk_refund_evidence_follow_up + FOREIGN KEY (follow_up_id, refund_id) REFERENCES payment_refund_follow_ups(id, refund_id); diff --git a/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java b/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java index a1f3322..871051c 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", 7).containsEntry("targetVersion", "10"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 8).containsEntry("targetVersion", "11"); 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", 6).containsEntry("targetVersion", "10"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 7).containsEntry("targetVersion", "11"); 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", 5).containsEntry("targetVersion", "10"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 6).containsEntry("targetVersion", "11"); assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",0); - assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version not in ('6','7','8','9','10') order by installed_rank")).isEqualTo(history); + assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version not in ('6','7','8','9','10','11') 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,13 +146,13 @@ 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", 3).containsEntry("targetVersion", "10"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 4).containsEntry("targetVersion", "11"); 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 not in ('8','9','10') order by installed_rank")).isEqualTo(history); + assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version not in ('8','9','10','11') 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); } @@ -210,9 +210,12 @@ select UUID(),UUID(),user_id,id,credit_grant_id,'STANDARD','REVIEW_REQUIRED',10, jdbc.update(insert, user); var originalReceipt = jdbc.queryForMap("select * from payment_refund_requests where user_id=?", user); var v9History = 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", "10").containsEntry("migrationsExecuted", 1); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 0); + String settlementMigration = "V10__payment_refund_settlement.sql"; + try (var sql = new ClassPathResource("db/migration/" + settlementMigration).getInputStream()) { + Files.copy(sql, prior.resolve(settlementMigration)); + } + assertThat(new MigrationEngine(source, "flyway_refund", "filesystem:" + prior).execute("migrate")) + .containsEntry("targetVersion", "10").containsEntry("migrationsExecuted", 1); assertThat(jdbc.queryForMap("select * from payment_refund_requests where user_id=?", user)).isEqualTo(originalReceipt); assertThat(jdbc.queryForObject("select count(*) from payment_refund_resolutions", Long.class)).isZero(); assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version<>'10' order by installed_rank")) @@ -220,6 +223,25 @@ select UUID(),UUID(),user_id,id,credit_grant_id,'STANDARD','REVIEW_REQUIRED',10, jdbc.update(insert, user); assertThat(jdbc.queryForObject("select count(*) from payment_refund_requests where user_id=?", Long.class, user)) .isEqualTo(2); + Long refund = ((Number) originalReceipt.get("id")).longValue(); + jdbc.update(""" + insert into payment_refund_resolutions(refund_id,status,refunded_amount,revoked_credits, + released_payment_hold,basis,operator_reference,snapshot_hash,resolved_at) + values(?,'COMPLETED',1100,10,true,'STANDARD','original',REPEAT('a',64),NOW()) + """, refund); + jdbc.update(""" + insert into payment_refund_evidence(refund_id,provider_transaction,amount,full_cancellation) + values(?,'555010',1100,true) + """, refund); + var v10History = 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", "11").containsEntry("migrationsExecuted", 1); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 0); + assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version<>'11' order by installed_rank")) + .isEqualTo(v10History); + assertThat(jdbc.queryForMap("select amount,full_cancellation,follow_up_id from payment_refund_evidence where refund_id=?", refund)) + .containsEntry("amount", 1100L).containsEntry("full_cancellation", true).containsEntry("follow_up_id", null); + assertThat(jdbc.queryForObject("select count(*) from payment_refund_follow_ups", Long.class)).isZero(); } @Test void baselineRejectsColumnIndexAndForeignKeyDriftEvenWith33Tables() throws Exception { diff --git a/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java b/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java index 71b324b..88385bf 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,classpath:db/migration/V9__payment_refund_intake.sql,classpath:db/migration/V10__payment_refund_settlement.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,classpath:db/migration/V10__payment_refund_settlement.sql,classpath:db/migration/V11__payment_refund_follow_up.sql", "spring.jpa.hibernate.ddl-auto=validate" }) @ActiveProfiles("test") 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 af64555..d1b8f6e 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,classpath:db/migration/V9__payment_refund_intake.sql,classpath:db/migration/V10__payment_refund_settlement.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,classpath:db/migration/V10__payment_refund_settlement.sql,classpath:db/migration/V11__payment_refund_follow_up.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 9a87449..6eaab22 100644 --- a/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java +++ b/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java @@ -5,7 +5,7 @@ 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,classpath:db/migration/V9__payment_refund_intake.sql,classpath:db/migration/V10__payment_refund_settlement.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,classpath:db/migration/V10__payment_refund_settlement.sql,classpath:db/migration/V11__payment_refund_follow_up.sql","spring.jpa.hibernate.ddl-auto=validate"}) @ActiveProfiles("test") class PaymentMigrationTest { @Test void initialSchemaMatchesHibernateAndMigrationCanBeAppliedTwice() { 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 9c2580a..e4475dd 100644 --- a/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java +++ b/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java @@ -414,6 +414,139 @@ void reconcilesDistinctPartialCancellationsOnceAndPreservesOtherPurchaseHold() t assertThat(reclaimedEntries()).isEqualTo(1); } + @Test + void reconcilesMultipleFollowUpsWithOneCreditReclaimAndKeepsOtherHolds() throws Exception { + var paid = approvePurchase(); + var receipt = completePartialRefund(paid); + var other = approvePurchase(); + feedback.accept(verifier.verify(notification(other, 9, now))); + assertThat(json.writeValueAsString(listFollowUps())).doesNotContain(receipt.receiptId()); + + now = now.plusSeconds(1); + partial(paid, "62001", 100, 70); + partial(paid, "62001", 100, 71); + var queue = listFollowUps(); + assertThat(json.writeValueAsString(queue)).contains(receipt.receiptId(), paid.getPublicId()); + var later = RefundResolutionTestDriver.run(dataSource, clock, "--payment-refund=list-follow-ups", + "--after-receipt-id=" + receipt.receiptId()); + assertThat(json.writeValueAsString(later)).doesNotContain(receipt.receiptId()); + assertThat(followUp("inspect-follow-up", receipt, null, 0, null)) + .containsEntry("unreviewedCancellationCount", 1) + .containsEntry("confirmedCancellationAmount", java.math.BigInteger.valueOf(770)); + var stale = readySnapshot(followUp("preview", receipt, "first", 100, null)); + now = now.plusSeconds(1); + partial(paid, "62002", 50, 70); + assertThatThrownBy(() -> followUp("apply", receipt, "first", 100, stale)) + .hasMessage("CANCELLATION_DOES_NOT_MATCH_APPROVAL"); + + var snapshot = readySnapshot(followUp("preview", receipt, "first", 150, null)); + var start = new CountDownLatch(1); + Map firstResult; + try (var pool = Executors.newFixedThreadPool(2)) { + Callable> apply = () -> { + if (!start.await(10, TimeUnit.SECONDS)) throw new AssertionError("apply start timeout"); + return followUp("apply", receipt, "first", 150, snapshot); + }; + var first = pool.submit(apply); + var second = pool.submit(apply); + start.countDown(); + firstResult = first.get(15, TimeUnit.SECONDS); + assertThat(second.get(15, TimeUnit.SECONDS)).isEqualTo(firstResult); + } + assertThat(firstResult).containsEntry("confirmedCancellationAmount", 920L); + assertThatThrownBy(() -> followUp("apply", receipt, "first", 149, snapshot)) + .hasMessage("FOLLOW_UP_REFERENCE_USED"); + assertThat(json.writeValueAsString(listFollowUps())).doesNotContain(receipt.receiptId()); + assertThat(credits.balance(user).spendingBlocked()).isTrue(); + assertThat(grantAvailable(other)).isEqualTo(10); + + now = now.plusSeconds(1); + partial(paid, "62002", 50, 71); + assertThat(orders.findById(paid.getId()).orElseThrow().getState()).isEqualTo(PaymentOrder.State.REFUND_SETTLED); + partial(paid, "62003", 180, 71); + assertThat(followUp("apply", receipt, "first", 150, snapshot)).isEqualTo(firstResult); + assertThat(refunds.get(user, UUID.fromString(receipt.receiptId())).orderState()) + .isEqualTo(PaymentOrder.State.PAYMENT_REVIEW); + var second = readySnapshot(followUp("preview", receipt, "second", 180, null)); + assertThat(followUp("apply", receipt, "second", 180, second)) + .containsEntry("confirmedCancellationAmount", 1100L); + assertThat(reclaimedEntries()).isEqualTo(1); + assertThat(grantAvailable(paid)).isZero(); + assertThat(jdbc.queryForObject("select count(*) from credit_payment_holds where user_id=?", Long.class, user)) + .isEqualTo(1); + assertThat(refunds.page(user, null, 20).items()).singleElement().satisfies(result -> { + assertThat(result.resolution().refundedAmount()).isEqualTo(770); + assertThat(result.confirmedCancellationAmount()).isEqualTo(1100); + assertThat(result.orderState()).isEqualTo(PaymentOrder.State.REFUND_SETTLED); + }); + + String bearer = tokens.login(user).response().accessToken(); + String another = tokens.login(member()).response().accessToken(); + try (var client = HttpClient.newHttpClient()) { + for (String path : List.of("/api/v1/payments/orders/" + paid.getPublicId(), + "/api/v1/payments/orders/by-request-id/" + paid.getRequestId(), + "/api/v1/payments/refunds/" + receipt.receiptId())) { + var response = http(client, bearer, "GET", path, null); + assertThat(response.statusCode()).as(response.body()).isEqualTo(200); + assertThat(json.readTree(response.body()).path("data").path("confirmedCancellationAmount").asLong()) + .isEqualTo(1100); + assertThat(http(client, another, "GET", path, null).statusCode()).isEqualTo(404); + } + var page = http(client, bearer, "GET", "/api/v1/payments/orders", null); + assertThat(page.statusCode()).isEqualTo(200); + var pageItems = json.readTree(page.body()).path("data").path("items"); + assertThat(pageItems.get(0).path("confirmedCancellationAmount").isNull()).isTrue(); + assertThat(pageItems.get(1).path("confirmedCancellationAmount").asLong()).isEqualTo(1100); + } + } + + @Test + void leavesConflictingOrExcessFollowUpOnHold() throws Exception { + var paid = approvePurchase(); + var receipt = completePartialRefund(paid); + now = now.plusSeconds(1); + partial(paid, "63001", 400, 70); + partial(paid, "63001", 401, 71); + var preview = followUp("preview", receipt, "excess", 400, null); + assertThat(preview).containsEntry("decision", "REVIEW_REQUIRED"); + assertThat(preview.get("findings")).asList() + .contains("CANCELLATION_EVIDENCE_CONFLICT", "CANCELLATION_EXCEEDS_ORDER_AMOUNT"); + assertThatThrownBy(() -> followUp("apply", receipt, "excess", 400, preview.get("snapshot").toString())) + .hasMessage("CANCELLATION_EVIDENCE_CONFLICT"); + assertThat(credits.balance(user).spendingBlocked()).isTrue(); + assertThat(reclaimedEntries()).isEqualTo(1); + assertThat(refunds.get(user, UUID.fromString(receipt.receiptId())).confirmedCancellationAmount()) + .isEqualTo(770); + } + + @Test + void followsUpAfterWithdrawalAndPreservesRecordsUntilTheNewResolutionAgesOut() throws Exception { + var paid = approvePurchase(); + var receipt = completePartialRefund(paid); + withdrawals.withdraw(user); + now = now.plus(Duration.ofDays(5)); + partial(paid, "64001", 330, 71); + var preview = readySnapshot(followUp("preview", receipt, "after-withdrawal", 330, null)); + + // A new audit event invalidates the preview, even if it leaves the cancellation amount unchanged. + now = now.plusSeconds(1); + partial(paid, "64001", 330, 70); + assertThatThrownBy(() -> followUp("apply", receipt, "after-withdrawal", 330, preview)) + .hasMessage("SNAPSHOT_CHANGED"); + now = now.plus(Duration.ofDays(5)); + var latest = readySnapshot(followUp("preview", receipt, "after-withdrawal", 330, null)); + followUp("apply", receipt, "after-withdrawal", 330, latest); + var resolvedAt = now; + now = resolvedAt.atOffset(ZoneOffset.UTC).plusYears(5).minusDays(1).toInstant(); + assertThat(retention.purge(user)).isFalse(); + now = resolvedAt.atOffset(ZoneOffset.UTC).plusYears(5).plusDays(1).toInstant(); + assertThat(retention.purge(user)).isTrue(); + assertThat(jdbc.queryForObject("select count(*) from payment_refund_requests where user_id=?", Long.class, user)) + .isZero(); + assertThat(jdbc.queryForObject("select count(*) from payment_refund_evidence where provider_transaction=?", + Long.class, "64001" + paid.getId())).isZero(); + } + @Test void refusesUnverifiedOrConflictingCancellationAmountsWithoutReleasingFunds() throws Exception { var paid = approvePurchase(); @@ -531,6 +664,29 @@ private Map settlement(String mode, RefundReceipt receipt, long return RefundResolutionTestDriver.run(dataSource, clock, args.toArray(String[]::new)); } + private RefundReceipt completePartialRefund(PaymentOrder order) throws Exception { + reservations.settle(user, reserve("used-before-follow-up").reservationId(), "saved-image"); + var receipt = refunds.receive(user, request(UUID.fromString(order.getPublicId()), STANDARD)); + partial(order, "61001", 770, 70); + var snapshot = readySnapshot(settlement("preview", receipt, 770, 7, "STANDARD", null)); + settlement("apply", receipt, 770, 7, "STANDARD", snapshot); + return receipt; + } + + private Map listFollowUps() throws Exception { + return RefundResolutionTestDriver.run(dataSource, clock, "--payment-refund=list-follow-ups"); + } + + private Map followUp(String mode, RefundReceipt receipt, String reference, long amount, + String snapshot) throws Exception { + var args = new ArrayList<>(List.of("--payment-refund=" + mode, "--receipt-id=" + receipt.receiptId())); + if (reference != null) { + args.addAll(List.of("--action=follow-up", "--operator-reference=" + reference, "--amount=" + amount)); + } + if (snapshot != null) args.add("--expected-snapshot=" + snapshot); + return RefundResolutionTestDriver.run(dataSource, clock, args.toArray(String[]::new)); + } + private Map operator(String mode, String action, RefundReceipt receipt, String snapshot) throws Exception { var args = new ArrayList<>(List.of("--payment-refund=" + mode, "--receipt-id=" + receipt.receiptId(), "--action=" + action, "--operator-reference=case-134")); 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 f0b065b..1f2e21c 100644 --- a/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java +++ b/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java @@ -42,7 +42,8 @@ DataSource dataSource() { "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"), new ClassPathResource("db/migration/V9__payment_refund_intake.sql"), - new ClassPathResource("db/migration/V10__payment_refund_settlement.sql")).execute(ds); + new ClassPathResource("db/migration/V10__payment_refund_settlement.sql"), + new ClassPathResource("db/migration/V11__payment_refund_follow_up.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 17843bd..d1231c4 100644 --- a/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java +++ b/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java @@ -54,7 +54,8 @@ 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/V9__payment_refund_intake.sql"), - new ClassPathResource("db/migration/V10__payment_refund_settlement.sql")) + new ClassPathResource("db/migration/V10__payment_refund_settlement.sql"), + new ClassPathResource("db/migration/V11__payment_refund_follow_up.sql")) .execute(ds); return ds; }