From fa16d763e9d659baf1ad6792457036d252040489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=ED=98=84=EC=84=B1?= Date: Sun, 8 Nov 2026 19:48:10 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EC=95=B1=20?= =?UTF-8?q?=ED=99=98=EB=B6=88=20=EA=B2=B0=EA=B3=BC=20=EB=8C=80=EC=A1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B5=AC=EB=A7=A4=20=ED=81=AC=EB=A0=88=EB=94=A7=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/MongleBackendApplication.java | 5 + .../domain/credit/CreditGrantRepository.java | 4 +- .../backend/domain/credit/CreditLedger.java | 6 + .../domain/credit/CreditLedgerRepository.java | 2 + .../backend/domain/credit/CreditService.java | 3 +- .../payment/PaymentFeedbackService.java | 10 + .../backend/domain/payment/PaymentOrder.java | 2 +- .../domain/payment/PaymentOrderResponse.java | 2 +- .../domain/payment/PaymentRefundEvidence.java | 21 ++ .../payment/PaymentRefundRepository.java | 28 +- .../domain/payment/PaymentRefundRequest.java | 4 + .../payment/PaymentRefundResolution.java | 39 ++ .../domain/payment/RefundEstimate.java | 20 +- .../backend/domain/payment/RefundReceipt.java | 6 +- .../backend/domain/payment/RefundService.java | 16 +- .../user/withdrawal/WithdrawalData.java | 4 + .../operations/payment/PaymentInspector.java | 63 ++-- .../payment/PaymentRefundCommand.java | 45 +++ .../payment/RefundResolutionRequest.java | 64 ++++ .../operations/payment/RefundResolver.java | 347 ++++++++++++++++++ .../V10__payment_refund_settlement.sql | 35 ++ .../migration/Mysql84MigrationTest.java | 52 ++- .../domain/InitialSchemaValidationTest.java | 2 +- .../AiGenerationLoggingIntegrationTest.java | 2 +- .../domain/payment/PaymentMigrationTest.java | 2 +- .../payment/PaymentInspectorContract.java | 3 +- .../payment/PaymentReclaimerContract.java | 3 +- 27 files changed, 732 insertions(+), 58 deletions(-) create mode 100644 src/main/java/com/mongle/backend/domain/payment/PaymentRefundEvidence.java create mode 100644 src/main/java/com/mongle/backend/domain/payment/PaymentRefundResolution.java create mode 100644 src/main/java/com/mongle/backend/operations/payment/PaymentRefundCommand.java create mode 100644 src/main/java/com/mongle/backend/operations/payment/RefundResolutionRequest.java create mode 100644 src/main/java/com/mongle/backend/operations/payment/RefundResolver.java create mode 100644 src/main/resources/db/migration/V10__payment_refund_settlement.sql diff --git a/src/main/java/com/mongle/backend/MongleBackendApplication.java b/src/main/java/com/mongle/backend/MongleBackendApplication.java index 0a864e4..68c59d5 100644 --- a/src/main/java/com/mongle/backend/MongleBackendApplication.java +++ b/src/main/java/com/mongle/backend/MongleBackendApplication.java @@ -4,6 +4,7 @@ import com.mongle.backend.operations.credit.TestCreditCommand; import com.mongle.backend.operations.payment.PaymentInspectCommand; import com.mongle.backend.operations.payment.PaymentReclaimCommand; +import com.mongle.backend.operations.payment.PaymentRefundCommand; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -11,6 +12,10 @@ public class MongleBackendApplication { public static void main(String[] args) { + if (PaymentRefundCommand.requested(args)) { + System.exit(PaymentRefundCommand.run(args, System.getenv(), System.out)); + return; + } if (PaymentReclaimCommand.requested(args)) { System.exit(PaymentReclaimCommand.run(args, System.getenv(), System.out)); return; diff --git a/src/main/java/com/mongle/backend/domain/credit/CreditGrantRepository.java b/src/main/java/com/mongle/backend/domain/credit/CreditGrantRepository.java index 078be90..3414599 100644 --- a/src/main/java/com/mongle/backend/domain/credit/CreditGrantRepository.java +++ b/src/main/java/com/mongle/backend/domain/credit/CreditGrantRepository.java @@ -9,7 +9,9 @@ public interface CreditGrantRepository extends JpaRepository Optional findBySourceTypeAndSourceHash(CreditSource type, String hash); String SPENDABLE_GRANTS = """ from credit_grants g where g.user_id = :userId and g.available > 0 - and not exists (select 1 from payment_refund_requests r where r.grant_id = g.id) + and not exists (select 1 from payment_refund_requests r + left join payment_refund_resolutions s on s.refund_id = r.id + where r.grant_id = g.id and s.refund_id is null) """; @Query(value = "select coalesce(sum(g.available), 0) " + SPENDABLE_GRANTS, nativeQuery = true) diff --git a/src/main/java/com/mongle/backend/domain/credit/CreditLedger.java b/src/main/java/com/mongle/backend/domain/credit/CreditLedger.java index 913a795..af3bd9d 100644 --- a/src/main/java/com/mongle/backend/domain/credit/CreditLedger.java +++ b/src/main/java/com/mongle/backend/domain/credit/CreditLedger.java @@ -24,6 +24,12 @@ public enum Kind { GRANT, RESERVE, SETTLE, RELEASE, PAYMENT_HOLD, RECLAIM, FORFE @Column(name = "available_after", nullable = false) private long availableAfter; @Column(name = "reserved_after", nullable = false) private long reservedAfter; + static CreditLedger paymentHold(CreditAccount account, Long grantId, long previousHolds) { + var entry = entry(account, Kind.PAYMENT_HOLD, grantId, null, CreditSource.PAYMENT, null, 0, 0); + if (previousHolds > 0) entry.entryKey += ":" + (previousHolds + 1); + return entry; + } + static CreditLedger entry(CreditAccount account, Kind kind, Long grantId, Long reservationId, CreditSource source, CreditUsage usage, long availableDelta, long reservedDelta) { var l = new CreditLedger(); l.userId = account.getUserId(); l.kind = kind; diff --git a/src/main/java/com/mongle/backend/domain/credit/CreditLedgerRepository.java b/src/main/java/com/mongle/backend/domain/credit/CreditLedgerRepository.java index 849151b..c300727 100644 --- a/src/main/java/com/mongle/backend/domain/credit/CreditLedgerRepository.java +++ b/src/main/java/com/mongle/backend/domain/credit/CreditLedgerRepository.java @@ -5,6 +5,8 @@ import org.springframework.data.domain.Pageable; import java.util.List; public interface CreditLedgerRepository extends JpaRepository { + long countByGrantIdAndKind(Long grantId, CreditLedger.Kind kind); + @Query("select l from CreditLedger l where l.userId=:userId and (:beforeId is null or l.id<:beforeId) order by l.id desc") List page(@Param("userId") Long userId, @Param("beforeId") Long beforeId, Pageable page); } diff --git a/src/main/java/com/mongle/backend/domain/credit/CreditService.java b/src/main/java/com/mongle/backend/domain/credit/CreditService.java index 8fbc371..b2bc78e 100644 --- a/src/main/java/com/mongle/backend/domain/credit/CreditService.java +++ b/src/main/java/com/mongle/backend/domain/credit/CreditService.java @@ -84,7 +84,8 @@ public void holdPayment(Long userId,String sourceKey) { throw new BusinessException(CreditErrorCode.REQUEST_CONFLICT); if(paymentHolds.existsById(grant.getId())) return; paymentHolds.saveAndFlush(CreditPaymentHold.create(grant.getId(),userId)); - ledger.saveAndFlush(CreditLedger.entry(account(userId),CreditLedger.Kind.PAYMENT_HOLD,grant.getId(),null,CreditSource.PAYMENT,null,0,0)); + ledger.saveAndFlush(CreditLedger.paymentHold(account(userId), grant.getId(), + ledger.countByGrantIdAndKind(grant.getId(), CreditLedger.Kind.PAYMENT_HOLD))); } void requireSpendingAllowed(Long userId) { if(paymentHolds.existsByUserId(userId)) throw new BusinessException(CreditErrorCode.PAYMENT_REVIEW); diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentFeedbackService.java b/src/main/java/com/mongle/backend/domain/payment/PaymentFeedbackService.java index 2e91a5d..9dac7fb 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentFeedbackService.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentFeedbackService.java @@ -13,6 +13,7 @@ public class PaymentFeedbackService { private final PaymentTransactions transactions; private final PayAppFeedbackVerifier verifier; private final CreditService credits; + private final PaymentRefundRepository refunds; @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public void accept(PayAppFeedback event) { @@ -40,6 +41,15 @@ public void accept(PayAppFeedback event) { transactions.retainAccountingRecords(user); return; } + // A fresh replay can have a different event hash while referring to the same settled cancellation. + boolean confirmedCancellation = event.state() == 9 || event.state() == 64 || event.partialCancellation(); + if ((confirmedCancellation && refunds.countMatchedCancellation(order.getId(), event.transactionId(), + event.amount(), !event.partialCancellation()) > 0) + || (order.getState() == PaymentOrder.State.REFUND_SETTLED && !confirmedCancellation)) { + notifications.saveAndFlush(PaymentNotification.create(event, order.getId())); + transactions.retainAccountingRecords(user); + return; + } switch(event.state()) { case 1,10 -> order.pending(); case 4 -> { diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentOrder.java b/src/main/java/com/mongle/backend/domain/payment/PaymentOrder.java index 40d0629..6791a2a 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentOrder.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentOrder.java @@ -15,7 +15,7 @@ indexes=@Index(name="idx_payment_order_user",columnList="user_id,id")) @NoArgsConstructor(access=AccessLevel.PROTECTED) public class PaymentOrder extends BaseEntity { - public enum State { REQUESTING, READY, REQUEST_REJECTED, UNKNOWN, PENDING, PAID, CANCELLED, PAYMENT_REVIEW, CREDIT_RECLAIMED } + public enum State { REQUESTING, READY, REQUEST_REJECTED, UNKNOWN, PENDING, PAID, CANCELLED, PAYMENT_REVIEW, CREDIT_RECLAIMED, REFUND_SETTLED } @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @Column(name="public_id",nullable=false,length=36) private String publicId; @Column(name="user_id",nullable=false) private Long userId; 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 7d5e68c..3372722 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentOrderResponse.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentOrderResponse.java @@ -1,7 +1,7 @@ package com.mongle.backend.domain.payment; import io.swagger.v3.oas.annotations.media.Schema; import java.time.*; -@Schema(description="본인 주문. 결제창 URL 발급은 결제 완료가 아닙니다. PAID와 creditGranted를 확인하세요. CREDIT_RECLAIMED는 전액 승인취소된 미사용 구매분의 내부 회수 완료입니다. creditGranted는 과거 지급 연결 여부이며 현재 잔액이 아닙니다. UNKNOWN은 결과 미확정이며 같은 주문으로 결제를 자동 재요청하지 않습니다.") +@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) { diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentRefundEvidence.java b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundEvidence.java new file mode 100644 index 0000000..3276657 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundEvidence.java @@ -0,0 +1,21 @@ +package com.mongle.backend.domain.payment; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "payment_refund_evidence") +class PaymentRefundEvidence { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false) + private Long refundId; + @Column(nullable = false, length = 40, unique = true) + private String providerTransaction; + @Column(nullable = false) + private long amount; + @Column(nullable = false) + private boolean fullCancellation; +} 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 f59a3ba..1ccca85 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRepository.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRepository.java @@ -11,11 +11,32 @@ interface PaymentRefundRepository extends JpaRepository { Optional findByUserIdAndRequestId(Long userId, String requestId); Optional findByUserIdAndReceiptId(Long userId, String receiptId); - boolean existsByGrantId(Long grantId); - boolean existsByUserId(Long userId); + @Query(value = """ + select count(*) from payment_refund_requests r + left join payment_refund_resolutions s on s.refund_id = r.id + where r.grant_id = :grantId and (s.refund_id is null or s.status = 'COMPLETED') + """, nativeQuery = true) + long countOpenOrCompletedForGrant(@Param("grantId") Long grantId); + @Query(value = """ + select count(*) from payment_refund_requests r + left join payment_refund_resolutions s on s.refund_id = r.id + where r.user_id = :userId and (s.refund_id is null or exists ( + select 1 from credit_payment_holds h where h.grant_id = r.grant_id and h.user_id = r.user_id + )) + """, nativeQuery = true) + long countOpen(@Param("userId") Long userId); + + @Query(value = """ + select count(*) from payment_refund_evidence e + join payment_refund_requests r on r.id = e.refund_id + where r.order_id = :orderId and e.provider_transaction = :transaction + and e.amount = :amount and e.full_cancellation = :full + """, nativeQuery = true) + long countMatchedCancellation(@Param("orderId") Long orderId, @Param("transaction") String transaction, + @Param("amount") long amount, @Param("full") boolean full); @Query(""" - select r from PaymentRefundRequest r join fetch r.order join fetch r.grant + 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) order by r.id desc """) @@ -28,6 +49,7 @@ and not exists ( select 1 from payment_refund_requests r join payment_orders o on o.id = r.order_id where r.user_id = g.user_id and r.grant_id = g.id and o.user_id = g.user_id and o.credit_grant_id = g.id and o.state in ('PAID', 'PAYMENT_REVIEW') + and not exists (select 1 from payment_refund_resolutions s where s.refund_id = r.id) and r.available_at_request + r.reserved_at_request >= g.available + g.reserved ) """, nativeQuery = true) diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRequest.java b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRequest.java index c6eff02..56726fe 100644 --- a/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRequest.java +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundRequest.java @@ -44,6 +44,10 @@ public enum Reason { STANDARD, RIGHTS_REVIEW } private long usedAtRequest; private Long estimatedAmount; private Integer feePercent; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "id", referencedColumnName = "refund_id", insertable = false, updatable = false, + foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private PaymentRefundResolution resolution; @JdbcTypeCode(SqlTypes.TIMESTAMP) @Column(nullable = false) private Instant createdAt; diff --git a/src/main/java/com/mongle/backend/domain/payment/PaymentRefundResolution.java b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundResolution.java new file mode 100644 index 0000000..1045e16 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/payment/PaymentRefundResolution.java @@ -0,0 +1,39 @@ +package com.mongle.backend.domain.payment; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "payment_refund_resolutions") +class PaymentRefundResolution { + public enum Status { COMPLETED, REJECTED, WITHDRAWN } + + @Id @Column(name = "refund_id") + private Long refundId; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Status status; + @Column(nullable = false) + private long refundedAmount; + @Column(nullable = false) + private long revokedCredits; + @Column(nullable = false) + private boolean releasedPaymentHold; + @Column(nullable = false, length = 12) + private String basis; + @Column(nullable = false, length = 80) + private String operatorReference; + @Column(nullable = false, length = 64) + private String snapshotHash; + @JdbcTypeCode(SqlTypes.TIMESTAMP) + @Column(nullable = false) + private Instant resolvedAt; +} diff --git a/src/main/java/com/mongle/backend/domain/payment/RefundEstimate.java b/src/main/java/com/mongle/backend/domain/payment/RefundEstimate.java index 88f8f21..632485a 100644 --- a/src/main/java/com/mongle/backend/domain/payment/RefundEstimate.java +++ b/src/main/java/com/mongle/backend/domain/payment/RefundEstimate.java @@ -30,14 +30,22 @@ static RefundEstimate calculate(PaymentOrder order, com.mongle.backend.domain.cr status = Status.REVIEW_REQUIRED; } else { status = Status.ESTIMATED; - fee = evaluatedAt.isAfter(order.getApprovedAt().plus(Duration.ofDays(7))) ? 10 : 0; - // Round once at the end, up to a whole won. BigInteger also supports large credit quantities. - var numerator = BigInteger.valueOf(order.getPrice()).multiply(BigInteger.valueOf(available)) - .multiply(BigInteger.valueOf(100 - fee)); - var divisor = BigInteger.valueOf(order.getCredits()).multiply(BigInteger.valueOf(100)); - amount = numerator.add(divisor).subtract(BigInteger.ONE).divide(divisor).longValueExact(); + fee = standardFeePercent(order.getApprovedAt(), evaluatedAt); + amount = standardAmount(order.getPrice(), order.getCredits(), available, fee); } return new RefundEstimate(order.getPublicId(), order.getPrice(), order.getCurrency(), order.getCredits(), grant.getConsumed(), reserved, available, order.getApprovedAt(), requestable, status, amount, fee); } + + public static int standardFeePercent(Instant approvedAt, Instant requestedAt) { + return requestedAt.isAfter(approvedAt.plus(Duration.ofDays(7))) ? 10 : 0; + } + + public static long standardAmount(long price, long purchased, long unused, int feePercent) { + // Round once at the end, up to a whole won. + var numerator = BigInteger.valueOf(price).multiply(BigInteger.valueOf(unused)) + .multiply(BigInteger.valueOf(100 - feePercent)); + var divisor = BigInteger.valueOf(purchased).multiply(BigInteger.valueOf(100)); + return numerator.add(divisor).subtract(BigInteger.ONE).divide(divisor).longValueExact(); + } } 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 eef2ab3..abf0060 100644 --- a/src/main/java/com/mongle/backend/domain/payment/RefundReceipt.java +++ b/src/main/java/com/mongle/backend/domain/payment/RefundReceipt.java @@ -6,5 +6,9 @@ public record RefundReceipt( String receiptId, String requestId, Instant receivedAt, PaymentRefundRequest.Reason reason, RefundEstimate.Status initialStatus, long unusedAtRequest, long reservedAtRequest, long usedAtRequest, Long estimatedAmountAtRequest, Integer feePercentAtRequest, RefundEstimate currentEstimate, - String supportEmail) { + String supportEmail, ProcessingStatus processingStatus, Resolution resolution) { + + public enum ProcessingStatus { RECEIVED, COMPLETED, REJECTED, WITHDRAWN } + + public record Resolution(long refundedAmount, long revokedCredits, Instant resolvedAt) {} } 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 92ba4e7..779e136 100644 --- a/src/main/java/com/mongle/backend/domain/payment/RefundService.java +++ b/src/main/java/com/mongle/backend/domain/payment/RefundService.java @@ -37,7 +37,7 @@ public RefundEstimate estimate(Long userId, UUID orderId, PaymentRefundRequest.R requireEnabled(); var order = ownedOrder(userId, orderId); var grant = purchaseGrant(order); - if (refunds.existsByGrantId(grant.getId())) throw new BusinessException(RefundError.CONFLICT); + if (refunds.countOpenOrCompletedForGrant(grant.getId()) > 0) throw new BusinessException(RefundError.CONFLICT); return RefundEstimate.calculate(order, grant, reason, authClock.instant()); } @@ -58,7 +58,7 @@ public RefundReceipt receive(Long userId, RefundRequest request) { requireEnabled(); var order = ownedOrder(userId, request.orderId()); var grant = purchaseGrant(order); - if (refunds.existsByGrantId(grant.getId())) throw new BusinessException(RefundError.CONFLICT); + if (refunds.countOpenOrCompletedForGrant(grant.getId()) > 0) throw new BusinessException(RefundError.CONFLICT); var now = authClock.instant().truncatedTo(ChronoUnit.MICROS); var estimate = RefundEstimate.calculate(order, grant, request.reason(), now); @@ -98,7 +98,7 @@ public boolean coversRemainingPurchases(Long userId) { @Override @Transactional(readOnly = true, propagation = Propagation.MANDATORY) public boolean hasOpenRefunds(Long userId) { - return refunds.existsByUserId(userId); + return refunds.countOpen(userId) > 0; } private PaymentOrder ownedOrder(Long userId, UUID orderId) { @@ -128,10 +128,16 @@ private CreditGrant purchaseGrant(PaymentOrder order) { } private RefundReceipt receipt(PaymentRefundRequest row) { - var estimate = RefundEstimate.calculate(row.getOrder(), row.getGrant(), row.getReason(), row.getCreatedAt()); + var resolution = row.getResolution(); + var estimate = resolution == null + ? RefundEstimate.calculate(row.getOrder(), 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(), row.getInitialStatus(), row.getAvailableAtRequest(), row.getReservedAtRequest(), - row.getUsedAtRequest(), row.getEstimatedAmount(), row.getFeePercent(), estimate, properties.supportEmail()); + row.getUsedAtRequest(), row.getEstimatedAmount(), row.getFeePercent(), estimate, properties.supportEmail(), status, result); } 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 deb3cf4..7b01556 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 @@ -29,6 +29,8 @@ select max(event_at) from ( select max(updated_at) event_at from payment_orders where user_id=:user union all select max(created_at) event_at from payment_credit_reclamations where user_id=:user union all select max(created_at) event_at from payment_refund_requests where user_id=:user + union all select max(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(created_at) event_at from credit_ledger where user_id=:user and source_type='PAYMENT' ) accounting_dates """) @@ -136,6 +138,8 @@ public void deleteAccounting(Long user, LocalDateTime now) { em.flush(); for(String sql:List.of( "delete from legal_consents where user_id=:user", + "delete from payment_refund_evidence 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", "delete from credit_payment_holds 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 07b04ed..5c1007e 100644 --- a/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java +++ b/src/main/java/com/mongle/backend/operations/payment/PaymentInspector.java @@ -133,26 +133,35 @@ private Map snapshot(Session db, String orderId) throws SQLExcep if (storedState.equals("PAID") && grant == null) findings.add("PAID_WITHOUT_MATCHING_GRANT"); if (grant != null - && !Set.of("PAID", "PAYMENT_REVIEW", "CREDIT_RECLAIMED").contains(storedState)) + && !Set.of("PAID", "PAYMENT_REVIEW", "CREDIT_RECLAIMED", "REFUND_SETTLED").contains(storedState)) findings.add("GRANT_WITHOUT_CONFIRMED_ORDER_STATE"); long grantId = grant == null ? -1 : grant.id; - var reclaimedRows = - db.query( - "select id,user_id,grant_id,amount from payment_credit_reclamations where" - + " order_id=?", - r -> values(r, "id", "user_id", "grant_id", "amount"), - order.id); - var reclamation = reclaimedRows.isEmpty() ? null : reclaimedRows.getFirst(); - BigInteger reclaimed = reclamation == null ? BigInteger.ZERO : reclamation.get(3); - if (reclamation != null - && (grant == null - || !reclamation.get(1).equals(BigInteger.valueOf(order.user)) - || !reclamation.get(2).equals(BigInteger.valueOf(grantId)) - || !reclaimed.equals(order.credits) - || !storedState.equals("CREDIT_RECLAIMED"))) + var adjustments = db.query(""" + select id,user_id,grant_id,amount,'LEGACY' as source,false as released_hold + from payment_credit_reclamations where order_id=? + union all + select s.refund_id,r.user_id,r.grant_id,s.revoked_credits,'REFUND',s.released_payment_hold + from payment_refund_resolutions s join payment_refund_requests r on r.id=s.refund_id + where r.order_id=? and s.status='COMPLETED' + """, row -> new Adjustment(row.getLong("id"), row.getLong("user_id"), row.getLong("grant_id"), + number(row, "amount"), "LEGACY".equals(row.getString("source")), row.getBoolean("released_hold")), + order.id, order.id); + var adjustment = adjustments.isEmpty() ? null : adjustments.getFirst(); + var reclamation = adjustment != null && adjustment.legacy ? adjustment : null; + var refund = adjustment != null && !adjustment.legacy ? adjustment : null; + BigInteger reclaimed = adjustment == null ? BigInteger.ZERO : adjustment.amount; + if (adjustments.size() > 1 || (adjustment != null + && (grant == null || adjustment.user != order.user || adjustment.grant != grantId + || reclaimed.signum() < 0 + || (adjustment.legacy + ? !reclaimed.equals(order.credits) || !storedState.equals("CREDIT_RECLAIMED") + : !Set.of("REFUND_SETTLED", "PAYMENT_REVIEW").contains(storedState))))) { findings.add("RECLAMATION_STATE_MISMATCH"); - if (storedState.equals("CREDIT_RECLAIMED") && reclamation == null) + } + if ((storedState.equals("CREDIT_RECLAIMED") && reclamation == null) + || (storedState.equals("REFUND_SETTLED") && refund == null)) { findings.add("RECLAMATION_STATE_MISMATCH"); + } if (grant != null && (!grant.amount.equals(order.credits) || !grant.amount.equals( @@ -179,11 +188,16 @@ private Map snapshot(Session db, String orderId) throws SQLExcep db.query( """ select count(*) as grants,coalesce(sum(g.available),0) as available,coalesce(sum(g.reserved),0) as reserved, - coalesce(sum(case when g.amount<>g.available+g.reserved+g.consumed+g.forfeited+coalesce(c.amount,0) then 1 else 0 end),0) as invalid_grants - from credit_grants g left join payment_credit_reclamations c on c.grant_id=g.id where g.user_id=? + coalesce(sum(case when g.amount<>g.available+g.reserved+g.consumed+g.forfeited+coalesce(c.amount,0)+coalesce(f.amount,0) then 1 else 0 end),0) as invalid_grants + from credit_grants g left join payment_credit_reclamations c on c.grant_id=g.id + left join ( + select r.grant_id, sum(s.revoked_credits) as amount + from payment_refund_resolutions s join payment_refund_requests r on r.id=s.refund_id + where r.user_id=? and s.status='COMPLETED' group by r.grant_id + ) f on f.grant_id=g.id where g.user_id=? """, r -> values(r, "grants", "available", "reserved", "invalid_grants"), - order.user) + order.user, order.user) .getFirst(); if (!account.get(0).equals(totals.get(1)) || !account.get(1).equals(totals.get(2)) @@ -251,7 +265,7 @@ select coalesce(sum(case when user_id=? then 1 else 0 end),0) as account_holds, .getFirst(); if (holds.get(2).signum() != 0 || !holds.get(1) - .add(reclamation == null ? BigInteger.ZERO : BigInteger.ONE) + .add(reclamation != null || (refund != null && refund.releasedHold) ? BigInteger.ONE : BigInteger.ZERO) .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"); @@ -269,7 +283,7 @@ select count(*) as entries,coalesce(sum(available_delta),0) as reclaimed, order.user, grantId) .getFirst(); - if (!reclaimLedger.get(0).equals(reclamation == null ? BigInteger.ZERO : BigInteger.ONE) + if (!reclaimLedger.get(0).equals(reclaimed.signum() == 0 ? BigInteger.ZERO : BigInteger.ONE) || !reclaimLedger.get(1).equals(reclaimed.negate()) || reclaimLedger.get(2).signum() != 0) findings.add("RECLAMATION_LEDGER_MISMATCH"); @@ -331,7 +345,7 @@ else if (Set.of(70, 71).contains(notice.stateCode) if (storedState.equals("PAID") && completions.signum() == 0) findings.add("COMPLETION_NOTIFICATION_MISSING"); if (grant != null - && reclamation == null + && adjustment == null && cancellations.signum() > 0 && holds.get(1).signum() == 0) findings.add("CANCELLATION_HOLD_MISSING"); String status = @@ -347,7 +361,8 @@ else if (Set.of(70, 71).contains(notice.stateCode) result.put("scope", "INTERNAL_DATABASE_ONLY"); result.put( "reclamation", - reclamation == null ? null : map("id", reclamation.get(0), "amount", reclaimed)); + reclamation == null ? null : map("id", reclamation.id, "amount", reclaimed)); + result.put("refundResolution", refund == null ? null : map("revokedCredits", reclaimed)); result.put("status", status); result.put("observedAt", observed); result.put( @@ -506,6 +521,8 @@ private record Grant( BigInteger reserved, BigInteger consumed) {} + private record Adjustment(long id, long user, long grant, BigInteger amount, boolean legacy, boolean releasedHold) {} + private record Notice( int stateCode, BigInteger count, BigInteger minimum, BigInteger maximum) {} diff --git a/src/main/java/com/mongle/backend/operations/payment/PaymentRefundCommand.java b/src/main/java/com/mongle/backend/operations/payment/PaymentRefundCommand.java new file mode 100644 index 0000000..6b2611a --- /dev/null +++ b/src/main/java/com/mongle/backend/operations/payment/PaymentRefundCommand.java @@ -0,0 +1,45 @@ +package com.mongle.backend.operations.payment; + +import tools.jackson.databind.json.JsonMapper; + +import java.io.PrintStream; +import java.time.Clock; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +public final class PaymentRefundCommand { + private PaymentRefundCommand() {} + + public static boolean requested(String[] args) { + return Arrays.stream(args).anyMatch(arg -> arg.startsWith("--payment-refund")); + } + + public static int run(String[] args, Map environment, PrintStream output) { + var json = JsonMapper.builder().build(); + try (var ignored = new PaymentInspectLogging()) { + var request = RefundResolutionRequest.parse(args); + if (!"true".equals(environment.get("MONGLE_PAYMENT_REFUND_OPS_ENABLED"))) { + throw new PaymentInspectCommand.Failure("OPERATOR_COMMAND_DISABLED"); + } + var env = new HashMap<>(environment); + env.put("MONGLE_PAYMENT_INSPECT_ENABLED", "true"); + env.put("MONGLE_PAYMENT_INSPECT_EXPECTED_HOST", + environment.getOrDefault("MONGLE_PAYMENT_REFUND_OPS_EXPECTED_HOST", "")); + env.put("MONGLE_PAYMENT_INSPECT_EXPECTED_DATABASE", + environment.getOrDefault("MONGLE_PAYMENT_REFUND_OPS_EXPECTED_DATABASE", "")); + var settings = PaymentInspectSettings.fromEnvironment(env); + output.println(json.writeValueAsString( + new RefundResolver(settings.dataSource(), Clock.systemUTC()).execute(request))); + return 0; + } catch (Exception failure) { + String code = switch (failure) { + case PaymentInspectCommand.Failure known -> known.getMessage(); + case java.sql.SQLTimeoutException ignored -> "REFUND_RESOLUTION_TIMEOUT"; + default -> "REFUND_RESOLUTION_FAILED"; + }; + output.println(json.writeValueAsString(Map.of("event", "refund-resolution-failed", "code", code))); + return 1; + } + } +} diff --git a/src/main/java/com/mongle/backend/operations/payment/RefundResolutionRequest.java b/src/main/java/com/mongle/backend/operations/payment/RefundResolutionRequest.java new file mode 100644 index 0000000..2edf96c --- /dev/null +++ b/src/main/java/com/mongle/backend/operations/payment/RefundResolutionRequest.java @@ -0,0 +1,64 @@ +package com.mongle.backend.operations.payment; + +import java.util.*; + +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 } + + static RefundResolutionRequest parse(String[] args) { + try { + var values = new HashMap(); + for (String arg : args) { + int at = arg.indexOf('='); + if (at < 1 || values.putIfAbsent(arg.substring(0, at), arg.substring(at + 1)) != null) { + 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()) { + 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)); + 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; + if (mode == Mode.APPLY && !token.matches("[0-9a-f]{64}")) throw new IllegalArgumentException(); + long amount = 0; + long credits = 0; + String basis = "NONE"; + if (action == Action.SETTLE) { + amount = nonnegative(values.remove("--amount")); + credits = nonnegative(values.remove("--credits")); + basis = values.remove("--basis"); + if (amount == 0 || !Set.of("STANDARD", "REVIEW").contains(basis)) throw new IllegalArgumentException(); + } + if (!values.isEmpty()) throw new IllegalArgumentException(); + return new RefundResolutionRequest(mode, id, action, amount, credits, basis, reference, token); + } catch (RuntimeException failure) { + throw new PaymentInspectCommand.Failure("INVALID_ARGUMENTS"); + } + } + + private static long nonnegative(String value) { + if (!value.matches("0|[1-9][0-9]{0,18}")) throw new IllegalArgumentException(); + return Long.parseLong(value); + } + + boolean apply() { return mode == Mode.APPLY; } + + String status() { + return switch (action) { + case SETTLE -> "COMPLETED"; + case REJECT -> "REJECTED"; + case WITHDRAW -> "WITHDRAWN"; + }; + } + + @Override + public String toString() { return "RefundResolutionRequest[redacted]"; } +} diff --git a/src/main/java/com/mongle/backend/operations/payment/RefundResolver.java b/src/main/java/com/mongle/backend/operations/payment/RefundResolver.java new file mode 100644 index 0000000..68b9bc6 --- /dev/null +++ b/src/main/java/com/mongle/backend/operations/payment/RefundResolver.java @@ -0,0 +1,347 @@ +package com.mongle.backend.operations.payment; + +import com.mongle.backend.domain.payment.RefundEstimate; +import tools.jackson.databind.json.JsonMapper; + +import javax.sql.DataSource; +import java.math.BigInteger; +import java.sql.*; +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 java.util.concurrent.TimeUnit; + +/** Reconciles externally verified PayApp cancellations using the same user lock as the application. */ +final class RefundResolver { + private static final JsonMapper JSON = JsonMapper.builder().build(); + private final DataSource source; + private final Clock clock; + + RefundResolver(DataSource source, Clock clock) { + this.source = source; + this.clock = clock; + } + + Map execute(RefundResolutionRequest request) throws SQLException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + try (Connection connection = connect()) { + connection.setReadOnly(!request.apply()); + connection.setTransactionIsolation(request.apply() + ? Connection.TRANSACTION_READ_COMMITTED : Connection.TRANSACTION_REPEATABLE_READ); + if (request.apply() && "MySQL".equals(connection.getMetaData().getDatabaseProductName())) { + try (var statement = connection.createStatement()) { + statement.setQueryTimeout(5); + statement.execute("SET SESSION innodb_lock_wait_timeout=5"); + } + } + connection.setAutoCommit(false); + try { + var db = new PaymentInspector.Session(connection, deadline); + 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()) { + one(db.query("select id from users where id=? for update", + row -> row.getLong(1), user), "REFUND_NOT_FOUND"); + } + 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 response = summary(request, receipt, report, prior); + if (prior != null) { + if (request.apply()) verifyReplay(request, prior); + return response; + } + + var evidence = evidence(db, receipt); + var findings = findings(receipt, report, evidence); + response.put("confirmedCancellationAmount", evidence.total); + response.put("cancellationCount", evidence.items.size()); + response.put("findings", findings); + if (request.mode() == RefundResolutionRequest.Mode.INSPECT) return response; + + var reasons = new ArrayList<>(findings); + validateAction(request, receipt, evidence, reasons); + var snapshot = snapshot(request, receipt, report, evidence); + response.put("snapshot", snapshot); + response.put("action", request.action()); + response.put("approvedAmount", request.amount()); + response.put("revokedCredits", request.credits()); + response.put("decision", reasons.isEmpty() ? "READY" : "REVIEW_REQUIRED"); + response.put("findings", List.copyOf(reasons)); + if (!request.apply()) return response; + if (!reasons.isEmpty()) throw fail(reasons.getFirst()); + if (!snapshot.equals(request.expectedSnapshot())) throw fail("SNAPSHOT_CHANGED"); + + apply(db, request, receipt, evidence, snapshot); + var after = new PaymentInspector(source, clock).inspect(connection, receipt.orderId, deadline); + if (!((List) after.get("findings")).isEmpty()) throw fail("POSTCONDITION_FAILED"); + var resolved = resolution(db, receipt.id); + var result = summary(request, receipt, after, resolved); + connection.commit(); + return result; + } finally { + connection.rollback(); + } + } + } + + private Connection connect() { + try { + return source.getConnection(); + } catch (SQLException failure) { + throw fail("DB_CONNECTION_FAILED"); + } + } + + private static Receipt load(PaymentInspector.Session db, String receiptId, long user) throws SQLException { + return one(db.query(""" + select r.id,r.user_id,r.order_id,r.grant_id,r.reason,r.created_at, + r.available_at_request,r.reserved_at_request,o.public_id,o.provider_transaction, + o.credit_grant_id,o.price,o.credits,o.state,o.version as order_version,o.approved_at, + g.available,g.reserved,g.consumed,g.forfeited,g.version as grant_version, + a.available as account_available,a.reserved as account_reserved,a.version as account_version, + h.grant_id as hold_id + from payment_refund_requests r + join payment_orders o on o.id=r.order_id and o.user_id=r.user_id + join credit_grants g on g.id=r.grant_id and g.user_id=r.user_id + join credit_accounts a on a.user_id=r.user_id + left join credit_payment_holds h on h.grant_id=g.id and h.user_id=r.user_id + where r.receipt_id=? and r.user_id=? + """, row -> new Receipt(row.getLong("id"), row.getLong("user_id"), row.getLong("order_id"), + row.getLong("grant_id"), row.getString("reason"), instant(row, "created_at"), + row.getLong("available_at_request"), row.getLong("reserved_at_request"), + row.getString("public_id"), row.getString("provider_transaction"), row.getLong("credit_grant_id"), + row.getLong("price"), row.getLong("credits"), row.getString("state"), row.getLong("order_version"), + instant(row, "approved_at"), row.getLong("available"), row.getLong("reserved"), + row.getLong("consumed"), row.getLong("forfeited"), row.getLong("grant_version"), + row.getLong("account_available"), row.getLong("account_reserved"), row.getLong("account_version"), + row.getObject("hold_id") != null), receiptId, user), "REFUND_RECORDS_INCONSISTENT"); + } + + private static Resolution resolution(PaymentInspector.Session db, long id) throws SQLException { + var rows = db.query(""" + select status,refunded_amount,revoked_credits,basis,operator_reference,snapshot_hash,resolved_at + from payment_refund_resolutions where refund_id=? + """, row -> new Resolution(row.getString("status"), row.getLong("refunded_amount"), + row.getLong("revoked_credits"), row.getString("basis"), row.getString("operator_reference"), + row.getString("snapshot_hash"), instant(row, "resolved_at")), id); + return rows.isEmpty() ? null : rows.getFirst(); + } + + private 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, + max(case when n.state_code in (9,64) then 1 else 0 end) as max_full, + max(case when b.order_id=n.order_id then 0 else 1 end) as bad_binding, + min(n.responded_at) as first_notice + from payment_notifications n + left join payment_transaction_bindings b on b.transaction_id=n.provider_transaction + where n.order_id=? and n.state_code in (9,64,70,71) + group by n.provider_transaction order by n.provider_transaction + """, row -> new Cancellation(row.getString("provider_transaction"), row.getLong("minimum"), + row.getLong("maximum"), row.getInt("min_full"), row.getInt("max_full"), + row.getInt("bad_binding") != 0, instant(row, "first_notice")), receipt.orderKey); + var total = items.stream().map(item -> BigInteger.valueOf(item.minimum)) + .reduce(BigInteger.ZERO, BigInteger::add); + boolean earlierCancellation = one(db.query(""" + select count(*) from payment_notifications n where n.order_id=? and n.state_code in (8,32) + """, row -> row.getLong(1), receipt.orderKey), "REFUND_RECORDS_INCONSISTENT") != 0; + var approval = db.query(""" + select n.responded_at from payment_notifications n + join payment_transaction_bindings b on b.transaction_id=n.provider_transaction and b.order_id=n.order_id + where n.order_id=? and n.state_code=4 and n.amount=? and n.provider_transaction=? + order by n.responded_at limit 1 + """, row -> instant(row, "responded_at"), receipt.orderKey, receipt.price, receipt.originalTransaction); + boolean approvalMatches = !approval.isEmpty() + && items.stream().noneMatch(item -> item.firstNotice.isBefore(approval.getFirst())); + return new Evidence(items, total, earlierCancellation, approvalMatches); + } + + private static List findings(Receipt receipt, Map report, Evidence evidence) { + var result = new ArrayList(); + if (!((List) report.get("findings")).isEmpty() || receipt.linkedGrant != receipt.grant + || receipt.forfeited != 0 || receipt.available > receipt.accountAvailable + || !Set.of("PAID", "PAYMENT_REVIEW").contains(receipt.orderState) + || BigInteger.valueOf(receipt.available).add(BigInteger.valueOf(receipt.reserved)) + .compareTo(BigInteger.valueOf(receipt.initialAvailable) + .add(BigInteger.valueOf(receipt.initialReserved))) > 0) { + result.add("REFUND_RECORDS_INCONSISTENT"); + } + // Session caps rows at 32. Treat reaching the cap as incomplete evidence. + if (evidence.items.size() >= 32 || evidence.items.stream().anyMatch(item -> !item.matches(receipt))) { + result.add("CANCELLATION_EVIDENCE_CONFLICT"); + } + if (evidence.total.compareTo(BigInteger.valueOf(receipt.price)) > 0) { + result.add("CANCELLATION_EXCEEDS_ORDER_AMOUNT"); + } + return result; + } + + private static void validateAction(RefundResolutionRequest request, Receipt receipt, + Evidence evidence, List reasons) { + if (request.action() != RefundResolutionRequest.Action.SETTLE) { + if (!evidence.items.isEmpty() || evidence.requestCancellation || receipt.hold + || !"PAID".equals(receipt.orderState)) { + reasons.add("PROVIDER_STATUS_REQUIRES_REVIEW"); + } + return; + } + + if (receipt.reserved != 0) reasons.add("RESERVATION_STILL_OPEN"); + if (request.credits() != receipt.available || request.amount() > receipt.price) { + reasons.add("APPROVAL_OUTSIDE_PURCHASE_BALANCE"); + } + if ("STANDARD".equals(request.basis())) { + Long estimate = standardEstimate(receipt); + if (estimate == null || estimate != request.amount()) reasons.add("APPROVAL_REQUIRES_REVIEW"); + } + if (evidence.items.isEmpty()) { + reasons.add("AWAITING_CANCEL_CONFIRMATION"); + } else if (!evidence.approvalMatches || !evidence.total.equals(BigInteger.valueOf(request.amount())) || !receipt.hold + || !"PAYMENT_REVIEW".equals(receipt.orderState)) { + reasons.add("CANCELLATION_DOES_NOT_MATCH_APPROVAL"); + } + } + + private static Long standardEstimate(Receipt receipt) { + if (!"STANDARD".equals(receipt.reason) || receipt.reserved != 0 || receipt.approved == null + || receipt.approved.isAfter(receipt.received) || receipt.purchased <= 0 || receipt.price <= 0 + || receipt.available < 0 || receipt.available > receipt.purchased) return null; + return RefundEstimate.standardAmount(receipt.price, receipt.purchased, receipt.available, + RefundEstimate.standardFeePercent(receipt.approved, receipt.received)); + } + + private static void verifyReplay(RefundResolutionRequest request, Resolution prior) { + if (!prior.status.equals(request.status()) || prior.refunded != request.amount() + || prior.revoked != request.credits() || !prior.basis.equals(request.basis()) + || !prior.reference.equals(request.reference()) || !prior.snapshot.equals(request.expectedSnapshot())) { + throw fail("REFUND_ALREADY_RESOLVED"); + } + } + + private void apply(PaymentInspector.Session db, RefundResolutionRequest request, Receipt receipt, + Evidence evidence, String snapshot) throws SQLException { + var now = LocalDateTime.ofInstant(clock.instant().truncatedTo(ChronoUnit.MICROS), ZoneOffset.UTC); + boolean settled = request.action() == RefundResolutionRequest.Action.SETTLE; + boolean clearHold = settled && receipt.hold; + update(db, """ + insert into payment_refund_resolutions(refund_id,status,refunded_amount,revoked_credits, + released_payment_hold,basis,operator_reference,snapshot_hash,resolved_at) + values(?,?,?,?,?,?,?,?,?) + """, receipt.id, request.status(), request.amount(), request.credits(), clearHold, + request.basis(), request.reference(), snapshot, now); + // Account version invalidates older image transactions when releasing a hold, even with zero debit. + update(db, """ + update credit_accounts set available=available-?,version=version+1,updated_at=? + where user_id=? and version=? and available>=? + """, request.credits(), now, receipt.user, receipt.accountVersion, request.credits()); + + if (!settled) return; + if (request.credits() > 0) { + update(db, """ + update credit_grants set available=available-?,version=version+1 + where id=? and user_id=? and version=? and available=? and reserved=0 + """, request.credits(), receipt.grant, receipt.user, receipt.grantVersion, request.credits()); + update(db, """ + insert into credit_ledger(user_id,entry_key,kind,source_type,grant_id, + available_delta,reserved_delta,available_after,reserved_after,created_at) + values(?,?,'RECLAIM','PAYMENT',?,?,0,?,?,?) + """, receipt.user, "RECLAIM:" + receipt.grant, receipt.grant, -request.credits(), + receipt.accountAvailable - request.credits(), receipt.accountReserved, now); + } + for (var item : evidence.items) { + update(db, """ + insert into payment_refund_evidence(refund_id,provider_transaction,amount,full_cancellation) + values(?,?,?,?) + """, receipt.id, item.transaction, item.minimum, item.minFull == 1); + } + 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); + } + + private static Map summary(RefundResolutionRequest request, Receipt receipt, + Map report, Resolution prior) { + var response = new LinkedHashMap(); + response.put("event", "refund-resolution"); + response.put("receiptId", request.receiptId()); + response.put("orderId", receipt.orderId); + response.put("orderState", report.get("order") instanceof Map order ? order.get("storedState") : null); + response.put("status", prior == null ? "RECEIVED" : prior.status); + response.put("reason", receipt.reason); + response.put("purchaseAmount", receipt.price); + response.put("unusedCredits", report.get("purchase") instanceof Map purchase ? purchase.get("available") : null); + response.put("reservedCredits", receipt.reserved); + response.put("usedCredits", receipt.consumed); + response.put("standardEstimatedAmount", prior == null ? standardEstimate(receipt) : null); + if (prior != null) { + response.put("refundedAmount", prior.refunded); + response.put("revokedCredits", prior.revoked); + response.put("resolvedAt", prior.resolvedAt); + } + return response; + } + + private static String snapshot(RefundResolutionRequest request, Receipt r, + Map report, Evidence evidence) { + var state = new LinkedHashMap<>(report); + state.remove("observedAt"); + var approved = List.of(request.receiptId(), request.action(), request.amount(), request.credits(), + request.basis(), request.reference()); + // Include stable cancellation IDs privately. Only the hash is emitted. + return PaymentInspector.hash(JSON.writeValueAsString(List.of(approved, r.orderVersion, r.accountVersion, + r.grantVersion, state, evidence.requestCancellation, evidence.approvalMatches, evidence.items))); + } + + private 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)) { + statement.setQueryTimeout((int) Math.min(5, Math.max(1, TimeUnit.NANOSECONDS.toSeconds(left)))); + for (int i = 0; i < values.length; i++) statement.setObject(i + 1, values[i]); + if (statement.executeUpdate() != 1) throw fail("REFUND_WRITE_CONFLICT"); + } + } + + private 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(); + } + + private static T one(List rows, String code) { + if (rows.size() != 1) throw fail(code); + return rows.getFirst(); + } + + private 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, + 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, + String snapshot, Instant resolvedAt) {} + + private record Evidence(List items, BigInteger total, boolean requestCancellation, + boolean approvalMatches) {} + + private 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}") + || badBinding || minimum <= 0 || minimum != maximum || minFull != maxFull) return false; + return minFull == 1 ? transaction.equals(receipt.originalTransaction) && minimum == receipt.price + : !transaction.equals(receipt.originalTransaction) && minimum < receipt.price; + } + } +} diff --git a/src/main/resources/db/migration/V10__payment_refund_settlement.sql b/src/main/resources/db/migration/V10__payment_refund_settlement.sql new file mode 100644 index 0000000..ba7b3be --- /dev/null +++ b/src/main/resources/db/migration/V10__payment_refund_settlement.sql @@ -0,0 +1,35 @@ +-- Keep historical rejected/withdrawn receipts and allow a fresh request for the purchase. +-- Intake and resolution serialize on the same users row. +CREATE INDEX idx_refund_order ON payment_refund_requests(order_id); +CREATE INDEX idx_refund_grant ON payment_refund_requests(grant_id); +ALTER TABLE payment_refund_requests DROP INDEX uk_refund_order; +ALTER TABLE payment_refund_requests DROP INDEX uk_refund_grant; + +CREATE TABLE payment_refund_resolutions ( + refund_id BIGINT NOT NULL PRIMARY KEY, + status VARCHAR(20) NOT NULL, + refunded_amount BIGINT NOT NULL, + revoked_credits BIGINT NOT NULL, + released_payment_hold BOOLEAN NOT NULL, + basis VARCHAR(12) NOT NULL, + operator_reference VARCHAR(80) NOT NULL, + snapshot_hash VARCHAR(64) NOT NULL, + resolved_at DATETIME(6) NOT NULL, + CONSTRAINT fk_refund_resolution_request FOREIGN KEY (refund_id) REFERENCES payment_refund_requests(id), + CONSTRAINT ck_refund_resolution CHECK ( + (status = 'COMPLETED' AND refunded_amount > 0 AND revoked_credits >= 0) + OR (status IN ('REJECTED', 'WITHDRAWN') AND refunded_amount = 0 AND revoked_credits = 0 + AND released_payment_hold = FALSE) + ) +); + +CREATE TABLE payment_refund_evidence ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + refund_id BIGINT NOT NULL, + provider_transaction VARCHAR(40) NOT NULL, + amount BIGINT NOT NULL, + full_cancellation BOOLEAN NOT NULL, + CONSTRAINT uk_refund_evidence_transaction UNIQUE (provider_transaction), + CONSTRAINT fk_refund_evidence_resolution FOREIGN KEY (refund_id) REFERENCES payment_refund_resolutions(refund_id), + CONSTRAINT ck_refund_evidence_amount CHECK (amount > 0) +); diff --git a/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java b/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java index f64bfb3..a1f3322 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", 6).containsEntry("targetVersion", "9"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 7).containsEntry("targetVersion", "10"); 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",5).containsEntry("targetVersion","9"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 6).containsEntry("targetVersion", "10"); 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",4).containsEntry("targetVersion","9"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 5).containsEntry("targetVersion", "10"); 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') 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') 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,18 +146,18 @@ 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",2).containsEntry("targetVersion","9"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 3).containsEntry("targetVersion", "10"); 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') order by installed_rank")).isEqualTo(history); + 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.queryForObject("select count(*) from legal_consents",Long.class)).isZero(); assertThat(jdbc.queryForObject("select count(*) from legal_document_versions where document_digest is null",Long.class)).isEqualTo(2); } - @Test void v9UpgradesV8WithoutGuessingLegacyApprovalOrCreatingReceipts() throws Exception { + @Test void v10PreservesOpenV9ReceiptsAndAllowsHistoricalReceiptsPerPurchase() throws Exception { var prior = Files.createDirectory(directory.resolve("prior-refund-release")); for (String name : java.util.List.of("V1__mongle_baseline.sql", "V3__image_regeneration_policy.sql", "V4__recent_interpretations.sql", "V5__world_summaries.sql", @@ -180,16 +180,46 @@ insert into payment_orders(public_id,user_id,request_id,input_hash,notification_ var order = jdbc.queryForMap("select * from payment_orders where user_id=?", user); var history = jdbc.queryForList("select version,checksum from flyway_schema_history order by installed_rank"); - var engine = new MigrationEngine(source, "flyway_refund"); - assertThat(engine.execute("migrate")).containsEntry("targetVersion", "9").containsEntry("migrationsExecuted", 1); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 0); + String intakeMigration = "V9__payment_refund_intake.sql"; + try (var sql = new ClassPathResource("db/migration/" + intakeMigration).getInputStream()) { + Files.copy(sql, prior.resolve(intakeMigration)); + } + assertThat(new MigrationEngine(source, "flyway_refund", "filesystem:" + prior).execute("migrate")) + .containsEntry("targetVersion", "9").containsEntry("migrationsExecuted", 1); var after = jdbc.queryForMap("select * from payment_orders where user_id=?", user); assertThat(after).containsKey("approved_at"); assertThat(after.remove("approved_at")).isNull(); assertThat(after).isEqualTo(order); - assertThat(jdbc.queryForObject("select count(*) from payment_refund_requests", Long.class)).isZero(); assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version<>'9' order by installed_rank")) .isEqualTo(history); + assertThat(jdbc.queryForObject("select count(*) from payment_refund_requests", Long.class)).isZero(); + + jdbc.update(""" + insert into credit_grants(user_id,source_type,source_key,source_hash,amount,available, + reserved,consumed,version,created_at) + values(?,'PAYMENT','legacy-refund',REPEAT('b',64),10,10,0,0,0,NOW()) + """, user); + Long grant = jdbc.queryForObject("select id from credit_grants where user_id=?", Long.class, user); + jdbc.update("update payment_orders set credit_grant_id=? where user_id=?", grant, user); + String insert = """ + insert into payment_refund_requests(receipt_id,request_id,user_id,order_id,grant_id,reason, + initial_status,available_at_request,reserved_at_request,used_at_request,created_at) + select UUID(),UUID(),user_id,id,credit_grant_id,'STANDARD','REVIEW_REQUIRED',10,0,0,NOW() + from payment_orders where user_id=? + """; + 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); + 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")) + .isEqualTo(v9History); + jdbc.update(insert, user); + assertThat(jdbc.queryForObject("select count(*) from payment_refund_requests where user_id=?", Long.class, user)) + .isEqualTo(2); } @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 9ec7455..71b324b 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", + "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" }) @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 204213a..af64555 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", + "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", "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 c5dc47c..9a87449 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","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","spring.jpa.hibernate.ddl-auto=validate"}) @ActiveProfiles("test") class PaymentMigrationTest { @Test void initialSchemaMatchesHibernateAndMigrationCanBeAppliedTwice() { 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 9272c85..f0b065b 100644 --- a/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java +++ b/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java @@ -41,7 +41,8 @@ DataSource dataSource() { "jdbc:h2:mem:payment-inspect;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "sa", ""); - new ResourceDatabasePopulator(new ClassPathResource("db/schema-initial.sql"),new ClassPathResource("db/migration/V6__payment_credit_reclamations.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql"), new ClassPathResource("db/migration/V8__legal_consents.sql"), new ClassPathResource("db/migration/V9__payment_refund_intake.sql")).execute(ds); + new ResourceDatabasePopulator(new ClassPathResource("db/schema-initial.sql"),new ClassPathResource("db/migration/V6__payment_credit_reclamations.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql"), new ClassPathResource("db/migration/V8__legal_consents.sql"), new ClassPathResource("db/migration/V9__payment_refund_intake.sql"), + new ClassPathResource("db/migration/V10__payment_refund_settlement.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 473d844..17843bd 100644 --- a/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java +++ b/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java @@ -53,7 +53,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/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); return ds; } From 40501469bdf88db2fdc7ea4bbc2c89f6fbefeb5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=ED=98=84=EC=84=B1?= Date: Sun, 8 Nov 2026 19:48:10 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=ED=99=98=EB=B6=88=20=EC=A0=95?= =?UTF-8?q?=EC=82=B0=EA=B3=BC=20=EC=A4=91=EB=B3=B5=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/payment_reclamation_smoke.py | 41 ++++ .../payment/RefundIntakeIntegrationTest.java | 208 ++++++++++++++++++ .../payment/RefundResolutionTestDriver.java | 14 ++ 3 files changed, 263 insertions(+) create mode 100644 src/test/java/com/mongle/backend/operations/payment/RefundResolutionTestDriver.java diff --git a/scripts/payment_reclamation_smoke.py b/scripts/payment_reclamation_smoke.py index 4ae97d1..bfe0caf 100644 --- a/scripts/payment_reclamation_smoke.py +++ b/scripts/payment_reclamation_smoke.py @@ -90,3 +90,44 @@ def command(order, mode, key, token=None, expected_exit=0, overrides=None, no_ch if sql(f"SELECT available FROM credit_accounts WHERE user_id={owner};", "credit_test").strip() != "0": 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() + verify_refund(repo, jar, credit_env, sql, order, grant) + + +def verify_refund(repo, jar, credit_env, sql, order, grant): + env = {**credit_env, "MONGLE_PAYMENT_REFUND_OPS_ENABLED": "true", + "MONGLE_PAYMENT_REFUND_OPS_EXPECTED_HOST": "127.0.0.1", + "MONGLE_PAYMENT_REFUND_OPS_EXPECTED_DATABASE": "credit_test"} + receipt = str(uuid.uuid4()) + sql(f"""INSERT INTO payment_refund_requests(receipt_id,request_id,user_id,order_id,grant_id, + reason,initial_status,available_at_request,reserved_at_request,used_at_request,created_at) + 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"]) + if token: + args.append("--expected-snapshot=" + token) + outcome = subprocess.run(args, cwd=repo, env={ + **env, "MONGLE_PAYMENT_REFUND_OPS_ENABLED": str(enabled).lower() + }, capture_output=True, text=True, timeout=90) + if outcome.returncode != expected_exit or outcome.stderr.strip() or len(outcome.stdout.splitlines()) != 1: + raise RuntimeError("Packaged refund resolution failed; raw output suppressed.") + return json.loads(outcome.stdout) + + if command("inspect", enabled=False, expected_exit=1).get("code") != "OPERATOR_COMMAND_DISABLED": + raise RuntimeError("Packaged refund command bypassed its operator flag.") + plan = command("preview") + if plan.get("decision") != "READY" or command("inspect").get("status") != "RECEIVED": + raise RuntimeError("Packaged refund preview mismatch.") + with ThreadPoolExecutor(max_workers=2) as pool: + outcomes = list(pool.map(lambda _: command("apply", plan["snapshot"]), range(2))) + if outcomes[0] != outcomes[1] or outcomes[0].get("status") != "COMPLETED": + raise RuntimeError("Packaged refund settlement was not idempotent.") + 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) 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 ae8ccbd..6985d0a 100644 --- a/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java +++ b/src/test/java/com/mongle/backend/domain/payment/RefundIntakeIntegrationTest.java @@ -9,9 +9,12 @@ import com.mongle.backend.domain.user.withdrawal.WithdrawalService; import com.mongle.backend.global.error.BusinessException; import com.mongle.backend.global.error.ErrorCode; +import com.mongle.backend.operations.payment.RefundResolutionTestDriver; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.SpringBootTest; @@ -69,6 +72,7 @@ class RefundIntakeIntegrationTest { @Autowired WithdrawalService withdrawals; @Autowired WithdrawalRetention retention; @Autowired JdbcTemplate jdbc; + @Autowired javax.sql.DataSource dataSource; @Autowired PlatformTransactionManager transactions; @MockitoBean(name = "authClock") Clock clock; @Value("${local.server.port}") int port; @@ -304,6 +308,210 @@ void storesOnlyVerifiedApprovalTimeAndNeverReplacesItOnReplay() { .isEqualTo(REVIEW_REQUIRED); } + @Test + void confirmsWholeRefundAfterWithdrawalAndRetainsRecordsUntilResolutionAgesOut() throws Exception { + var paid = approvePurchase(); + var receipt = refunds.receive(user, request(UUID.fromString(paid.getPublicId()), STANDARD)); + withdrawals.withdraw(user); + + var pending = settlement("preview", receipt, 1100, 10, "STANDARD", null); + assertThat(pending.get("findings")).asList().contains("AWAITING_CANCEL_CONFIRMATION"); + assertThat(settlement("inspect", receipt, 0, 0, null, null).get("status")).isEqualTo("RECEIVED"); + now = now.plus(Duration.ofDays(10)); + feedback.accept(verifier.verify(notification(paid, 9, now))); + var preview = settlement("preview", receipt, 1100, 10, "STANDARD", null); + var snapshot = readySnapshot(preview); + var result = settlement("apply", receipt, 1100, 10, "STANDARD", snapshot); + assertThat(result).containsEntry("status", "COMPLETED").containsEntry("refundedAmount", 1100L) + .containsEntry("revokedCredits", 10L).containsEntry("orderState", "REFUND_SETTLED"); + assertThat(settlement("apply", receipt, 1100, 10, "STANDARD", snapshot)).isEqualTo(result); + assertThat(grantAvailable(paid)).isZero(); + assertThat(reclaimedEntries()).isEqualTo(1); + + Instant resolved = now; + now = resolved.atOffset(ZoneOffset.UTC).plusYears(5).minusDays(1).toInstant(); + assertThat(retention.purge(user)).isFalse(); + now = resolved.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(); + } + + @Test + void reconcilesDistinctPartialCancellationsOnceAndPreservesOtherPurchaseHold() throws Exception { + var paid = approvePurchase(); + reservations.settle(user, reserve("partly-used").reservationId(), "completed-image"); + var other = approvePurchase(); + now = now.plus(Duration.ofDays(8)); + var receipt = refunds.receive(user, request(UUID.fromString(paid.getPublicId()), STANDARD)); + assertThat(receipt.estimatedAmountAtRequest()).isEqualTo(693); + + partial(paid, "33001", 350, 70); + partial(paid, "33001", 350, 71); + partial(paid, "33002", 343, 71); + feedback.accept(verifier.verify(notification(other, 9, now))); + var preview = settlement("preview", receipt, 693, 7, "STANDARD", null); + assertThat(preview).containsEntry("cancellationCount", 2); + String snapshot = readySnapshot(preview); + var start = new CountDownLatch(1); + try (var pool = Executors.newFixedThreadPool(2)) { + Callable> apply = () -> { + if (!start.await(10, TimeUnit.SECONDS)) throw new AssertionError("apply start timeout"); + return settlement("apply", receipt, 693, 7, "STANDARD", snapshot); + }; + var first = pool.submit(apply); + var second = pool.submit(apply); + start.countDown(); + assertThat(first.get(15, TimeUnit.SECONDS)).isEqualTo(second.get(15, TimeUnit.SECONDS)); + } + assertThat(grantAvailable(paid)).isZero(); + assertThat(grantAvailable(other)).isEqualTo(10); + assertThat(credits.balance(user).spendingBlocked()).isTrue(); + assertThat(reclaimedEntries()).isEqualTo(1); + var status = refunds.get(user, UUID.fromString(receipt.receiptId())); + assertThat(status.processingStatus()).isEqualTo(RefundReceipt.ProcessingStatus.COMPLETED); + assertThat(status.currentEstimate()).isNull(); + assertThat(status.resolution().refundedAmount()).isEqualTo(693); + assertThat(refunds.page(user, null, 20).items().getFirst().resolution()).isEqualTo(status.resolution()); + + now = now.plusSeconds(1); + partial(paid, "33001", 350, 71); + assertThat(orders.findById(paid.getId()).orElseThrow().getState()).isEqualTo(PaymentOrder.State.REFUND_SETTLED); + partial(paid, "33003", 10, 71); + assertThat(orders.findById(paid.getId()).orElseThrow().getState()).isEqualTo(PaymentOrder.State.PAYMENT_REVIEW); + assertThat(jdbc.queryForObject("select count(*) from credit_payment_holds where user_id=?", + Long.class, user)).isEqualTo(2); + assertThat(reclaimedEntries()).isEqualTo(1); + } + + @Test + void refusesUnverifiedOrConflictingCancellationAmountsWithoutReleasingFunds() throws Exception { + var paid = approvePurchase(); + var receipt = refunds.receive(user, request(UUID.fromString(paid.getPublicId()), STANDARD)); + partial(paid, "44001", 700, 70); + var mismatch = settlement("preview", receipt, 1100, 10, "STANDARD", null); + assertThat(mismatch.get("findings")).asList().contains("CANCELLATION_DOES_NOT_MATCH_APPROVAL"); + partial(paid, "44001", 701, 71); + partial(paid, "44002", 500, 71); + var conflict = settlement("preview", receipt, 1100, 10, "STANDARD", null); + assertThat(conflict.get("findings")).asList() + .contains("CANCELLATION_EVIDENCE_CONFLICT", "CANCELLATION_EXCEEDS_ORDER_AMOUNT"); + assertThatThrownBy(() -> settlement("apply", receipt, 1100, 10, "STANDARD", + conflict.get("snapshot").toString())).hasMessage("CANCELLATION_EVIDENCE_CONFLICT"); + assertThat(grantAvailable(paid)).isEqualTo(10); + assertThat(credits.balance(user).spendingBlocked()).isTrue(); + assertThat(reclaimedEntries()).isZero(); + } + + @Test + void reviewCanRefundMoneyForAConsumedPurchaseWithoutRecreatingItsCredits() throws Exception { + var paid = approvePurchase(); + for (int i = 0; i < 3; i++) reservations.settle(user, reserve("rights-used-" + i).reservationId(), "image-" + i); + reservations.settle(user, reservations.reserve(user, "rights-last", CreditUsage.DREAM_IMAGE_REGENERATION, + "last-image", now.plusSeconds(60)).reservationId(), "last-result"); + var receipt = refunds.receive(user, request(UUID.fromString(paid.getPublicId()), RIGHTS_REVIEW)); + feedback.accept(verifier.verify(notification(paid, 9, now))); + var standard = settlement("preview", receipt, 1100, 0, "STANDARD", null); + assertThat(standard.get("findings")).asList().contains("APPROVAL_REQUIRES_REVIEW"); + var snapshot = readySnapshot(settlement("preview", receipt, 1100, 0, "REVIEW", null)); + settlement("apply", receipt, 1100, 0, "REVIEW", snapshot); + var status = refunds.get(user, UUID.fromString(receipt.receiptId())); + assertThat(status.resolution().revokedCredits()).isZero(); + assertThat(status.resolution().refundedAmount()).isEqualTo(1100); + assertThat(credits.balance(user).available()).isZero(); + assertThat(reclaimedEntries()).isZero(); + } + + @ParameterizedTest + @ValueSource(strings = {"reject", "withdraw"}) + void closingWithoutProviderCancellationReleasesOnlyThisReceiptAndAllowsReapplication(String action) + throws Exception { + var paid = approvePurchase(); + var orderId = UUID.fromString(paid.getPublicId()); + var receipt = refunds.receive(user, request(orderId, STANDARD)); + 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 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); + } + + @Test + void keepsExistingReservationAndRefusesClosureWhenProviderStatusIsUnclear() throws Exception { + var paid = approvePurchase(); + var image = reserve("before-cancel"); + var receipt = refunds.receive(user, request(UUID.fromString(paid.getPublicId()), STANDARD)); + var pending = settlement("preview", receipt, 1100, 7, "STANDARD", null); + assertThat(pending.get("findings")).asList().contains("RESERVATION_STILL_OPEN", "AWAITING_CANCEL_CONFIRMATION"); + feedback.accept(verifier.verify(notification(paid, 8, now))); + var close = operator("preview", "withdraw", receipt, null); + assertThat(close.get("findings")).asList().contains("PROVIDER_STATUS_REQUIRES_REVIEW"); + assertThat(credits.balance(user).reserved()).isEqualTo(3); + reservations.release(user, image.reservationId(), CreditReservation.ReleaseReason.FAILURE); + assertThat(credits.balance(user).spendingBlocked()).isTrue(); + assertThat(grantAvailable(paid)).isEqualTo(10); + } + + private PaymentOrder approvePurchase() { + var order = readyOrder(10); + var approved = orders.findById(order.getId()).orElseThrow().getCreatedAt() + .toInstant(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS); + if (now.isBefore(approved.plusSeconds(1))) now = approved.plusSeconds(1); + var form = notification(order, 4, now); + form.put("pay_date", DateTimeFormatter.ofPattern("uuuuMMddHHmmss").withZone(ZoneId.of("Asia/Seoul")) + .format(approved)); + feedback.accept(verifier.verify(form)); + return orders.findById(order.getId()).orElseThrow(); + } + + private void partial(PaymentOrder order, String transaction, long amount, int state) { + var form = notification(order, state, now); + form.put("orig_mul_no", providerId(order)); + form.put("orig_price", "1100"); + form.put("mul_no", transaction + order.getId()); + form.put("price", Long.toString(amount)); + feedback.accept(verifier.verify(form)); + } + + private Map settlement(String mode, RefundReceipt receipt, long amount, long quantity, + String basis, String snapshot) throws Exception { + var args = new ArrayList<>(List.of("--payment-refund=" + mode, "--receipt-id=" + receipt.receiptId())); + if (!mode.equals("inspect")) { + args.addAll(List.of("--action=settle", "--operator-reference=case-134", "--amount=" + amount, + "--credits=" + quantity, "--basis=" + basis)); + } + 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")); + if (snapshot != null) args.add("--expected-snapshot=" + snapshot); + return RefundResolutionTestDriver.run(dataSource, clock, args.toArray(String[]::new)); + } + + private static String readySnapshot(Map result) { + assertThat(result).containsEntry("decision", "READY").as("%s", result); + return result.get("snapshot").toString(); + } + + private long reclaimedEntries() { + return jdbc.queryForObject("select count(*) from credit_ledger where user_id=? and kind='RECLAIM'", Long.class, user); + } + + private long grantAvailable(PaymentOrder order) { + return jdbc.queryForObject("select available from credit_grants where id=?", Long.class, order.getCreditGrantId()); + } + private long member() { return users.saveAndFlush(User.create(UUID.randomUUID() + "@refund.test", "몽글")).getId(); } diff --git a/src/test/java/com/mongle/backend/operations/payment/RefundResolutionTestDriver.java b/src/test/java/com/mongle/backend/operations/payment/RefundResolutionTestDriver.java new file mode 100644 index 0000000..e9c3968 --- /dev/null +++ b/src/test/java/com/mongle/backend/operations/payment/RefundResolutionTestDriver.java @@ -0,0 +1,14 @@ +package com.mongle.backend.operations.payment; + +import javax.sql.DataSource; +import java.sql.SQLException; +import java.time.Clock; +import java.util.Map; + +public final class RefundResolutionTestDriver { + private RefundResolutionTestDriver() {} + + public static Map run(DataSource source, Clock clock, String... args) throws SQLException { + return new RefundResolver(source, clock).execute(RefundResolutionRequest.parse(args)); + } +} From c30b4245f9dfb7b0dac52bbc899793bc65a76d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=ED=98=84=EC=84=B1?= Date: Sun, 8 Nov 2026 19:48:10 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=ED=8E=98=EC=9D=B4=EC=95=B1=20?= =?UTF-8?q?=ED=99=98=EB=B6=88=20=EC=A0=95=EC=82=B0=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=84=EB=A1=A0=ED=8A=B8=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/frontend-integration.md | 7 +++ docs/payapp-payments.md | 5 ++- docs/payment-refund-intake.md | 4 +- docs/payment-refund-settlement.md | 71 +++++++++++++++++++++++++++++++ docs/user-withdrawal.md | 2 +- 5 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 docs/payment-refund-settlement.md diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 3db08af..3033d96 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -252,6 +252,13 @@ Refresh는 앱 전체에서 한 번씩 실행되도록 묶는다. StrictMode· - 잔액은 available/reserved/total 및 보류 여부를 구분한다. 최초 온보딩 완료 시 기본3크레딧, 크레딧 무기한. 지급 내역이 없는 기존 완료 계정은0이며 자동 소급하지 않는다. 구매 취소와 생성 실패 예약 해제는 다른 처리다. - 구매별 환불 접수와 조회는 [환불 접수 계약](payment-refund-intake.md)을 따른다. 페이앱 환불 실행과 정산은 후속 범위다. +## 환불 접수와 정산 (#132, #134) + +환불 상세·목록의 `processingStatus`로 접수와 최종 처리 결과를 구분합니다. +`RECEIVED`는 `currentEstimate`를 표시하고 종료된 건은 `resolution`의 금액·회수 수량·처리 시각을 표시합니다. +주문 `REFUND_SETTLED`는 충전 폴링을 종료하고, 탈퇴 전에는 환불 접수번호와 문의 이메일을 저장할 수 있게 안내합니다. +[환불 접수 API](payment-refund-intake.md)와 [환불 상태 계약](payment-refund-settlement.md)을 참고합니다. + ## 무료 크레딧 우선 차감 (#130) 새 이미지 생성/재생성 비용은 서버가 무료(SIGNUP·ATTENDANCE·PROMOTION)부터 예약하고 부족한 수량만 유료(PAYMENT)에서 예약합니다. 같은 그룹 내에서는 먼저 지급한 순서입니다. API 경로·요청·응답과 이미지 3/무료/1 비용은 바뀌지 않습니다. diff --git a/docs/payapp-payments.md b/docs/payapp-payments.md index 3a17f49..5ffd742 100644 --- a/docs/payapp-payments.md +++ b/docs/payapp-payments.md @@ -4,7 +4,7 @@ 개인사업자 계정의 상품·실결제 승인은 팀에서 확인했으며 실제 청구·통보·정산 종단 검증은 별도다. 기본은 신규 결제/Provider/크레딧 지급 모두 비활성이다. [실결제 설정·프론트 복구·운영 검증 안내](payapp-live-readiness.md)를 함께 확인한다. 유상/무료 크레딧 모두 무기한으로 사용하는 정책을 반영해 expiry-policy 기본은 NO_EXPIRY로 변경했다. -[구매별 환불 접수](payment-refund-intake.md)는 #132에서 추가했다. PG 환불 실행·실물 상품은 별도 범위다. 가입 지급과 이미지 과금은 별도 크레딧 기능을 재사용한다. +[구매별 환불 접수](payment-refund-intake.md)는 #132에서 추가했다. 환불 결과는 [운영 정산 명령](payment-refund-settlement.md)으로 대조한다. 가입 지급과 이미지 과금은 별도 크레딧 기능을 재사용한다. 신규 이미지 예약은 무료 지급분을 먼저 사용하고 부족한 수량만 구매 지급분에서 사용한다(#130). 같은 그룹 안에서는 지급 ID 순서를 유지하며 기존 예약·사용 원장은 소급 변경하지 않는다. 구매별 사용량은 계정 전체 잔액이 아니라 저장된 출처별 예약·확정 배분으로 확인한다. [크레딧 예약 계약](credits.md)을 따른다. ## 공개 계약 @@ -52,6 +52,7 @@ orderId는 UUID, createdAt은 기존 계약의 UTC LocalDateTime이다. 거래 | PAID | 검증된 결제 완료와 크레딧 지급 커밋 완료 | | CANCELLED | 지급 전 취소 확인. 추가 지급하지 않음 | | PAYMENT_REVIEW | 취소/순서 충돌 등의 결제 정합성 확인 필요. 자동 재지급하지 않음 | +| REFUND_SETTLED | 접수된 환불의 실제 취소 확인과 구매분 정산 완료 | | CREDIT_RECLAIMED | 전액 승인취소된 미사용 구매분의 내부 회수 완료. 결제 성공이 아님. creditGranted는 과거 지급 연결 여부 | creditGranted는 과거 지급이 존재하는지 나타내므로 state와 함께 확인한다. @@ -135,7 +136,7 @@ mongle: ## 환불 기능과 정책의 구분 -[환불 접수 API](payment-refund-intake.md)는 구매별 예상 금액과 신청을 기록하고 대상 구매분의 사용을 보류한다. 페이앱에서의 승인 취소, 취소 결과 확인 및 회수·보류 해제는 후속 정산 경로에서 처리한다. 결제사에서 먼저 취소 통보가 오면 기존 보류가 적용되고, 접수 내역의 현재 산정 상태는 검토 대상으로 바뀐다. +[환불 접수 API](payment-refund-intake.md)는 구매별 예상 금액과 신청을 기록하고 대상 구매분의 사용을 보류한다. 판매자 화면에서 승인 취소한 뒤 [정산 명령](payment-refund-settlement.md)으로 취소 결과 확인·회수·보류 해제를 처리한다. 결제사에서 먼저 취소 통보가 오면 기존 보류가 적용되고, 접수 내역의 현재 산정 상태는 검토 대상으로 바뀐다. #64 이미지 생성 실패의 예약 해제는 구매 대금 환불과 다르다. 사용하지 않은 예약을 원래 잔액으로 돌려주며 이미 성공 확정한 생성 비용을 임의로 반환하지 않는다. 무기한 크레딧 정책을 환불 권리 포기로 해석하지 않는다. diff --git a/docs/payment-refund-intake.md b/docs/payment-refund-intake.md index 10ea86c..a288d10 100644 --- a/docs/payment-refund-intake.md +++ b/docs/payment-refund-intake.md @@ -60,8 +60,8 @@ 1. 기존 CD로 `V9__payment_refund_intake.sql`을 적용한다. 기존 결제의 승인 시각과 환불 접수 건은 백필하지 않는다. 2. `MONGLE_PAYMENT_REFUND_ENABLED` 기본값은 false다. 활성화 시 공개 문의 주소 `MONGLE_PAYMENT_REFUND_SUPPORT_EMAIL`이 필요하다. -3. 실제 페이앱 취소와 크레딧 회수, 접수 종료·보류 해제 처리 경로를 준비한 뒤 공개한다. 이 배포의 상태는 접수 및 검토 대기다. +3. V10 적용 후 [환불 정산 운영 절차](payment-refund-settlement.md)를 검증하고 공개한다. 4. 신규 접수를 끌 때도 이미 접수된 건의 조회·보류·탈퇴 확인·보존은 계속 동작한다. 문의 주소는 유지한다. 접수가 생성된 운영 DB에서 V9 이전 앱은 새 구매분 보류를 읽을 수 없으므로 신규 접수를 중지할 때는 V9 앱의 설정을 사용한다. 운영자는 접수번호로 `payment_refund_requests`와 원주문·지급 원장을 대조한다. -서버의 예상액은 실제 카드 취소 증빙을 대신하지 않으므로 페이앱 거래 확인 후 후속 정산 경로에서 처리한다. +[환불 정산](payment-refund-settlement.md)으로 실제 페이앱 취소를 대조한 뒤 회수와 접수 종료를 처리한다. diff --git a/docs/payment-refund-settlement.md b/docs/payment-refund-settlement.md new file mode 100644 index 0000000..171d956 --- /dev/null +++ b/docs/payment-refund-settlement.md @@ -0,0 +1,71 @@ +# 페이앱 환불 결과 대조와 정산 (#134) + +판매자 계정에서 처리한 환불 결과를 몽글 내부 주문과 대조한 뒤 구매분을 회수한다. +명령은 애플리케이션 서버나 워커를 띄우지 않고, 지정한 MySQL에 검증 TLS로 접속한다. +페이앱 판매자 화면에서 전액·부분취소와 정산 후 반환 절차를 확인하는 단계가 선행된다. +[페이앱 결제 취소와 통보 명세](https://docs.payapp.kr/dev_center01.html)를 참고한다. + +## 권한과 설정 + +운영 DB에 접근할 수 있는 지정 운영자만 새 JAR의 명령을 실행한다. 아래 변수를 해당 명령의 환경에 주입한다. + +| 변수 | 값 | +| --- | --- | +| `MONGLE_PAYMENT_REFUND_OPS_ENABLED` | 명령 실행 시 true | +| `MONGLE_PAYMENT_REFUND_OPS_EXPECTED_HOST` | 지정 MySQL 호스트 | +| `MONGLE_PAYMENT_REFUND_OPS_EXPECTED_DATABASE` | 지정 DB 이름 | +| `DB_URL`, `DB_USERNAME`, `DB_PASSWORD` | 기존 검증 대상 URL과 `mongle_app` 계정 | +| `DB_TRUSTSTORE_URL`, `DB_TRUSTSTORE_PASSWORD` | 기존 PKCS12 신뢰 저장소 | + +`inspect`와 `preview`는 읽기 전용이다. `apply`는 해당 사용자 행 잠금을 잡고 최신 상태를 검증한 뒤 한 번의 트랜잭션으로 처리한다. +운영 근거에는 승인자, 판매자 화면 확인 결과와 고객센터 케이스를 기록한다. 명령에는 해당 기록의 식별자만 넘긴다. + +## 전액·부분취소 정산 + +1. 접수번호로 원주문, 구매액, 사용·예약·미사용 수량과 진행 상태를 확인한다. + + ```bash + java -jar "$MONGLE_JAR" --payment-refund=inspect --receipt-id="$RECEIPT_ID" + ``` + +2. 페이앱 판매자 화면에서 주문 귀속과 취소 가능한 금액을 확인한다. 일반 환불은 `standardEstimatedAmount`를 사용한다. 법정 청약철회·서비스 하자·승인일 불명처럼 별도 판단이 필요하면 최종 승인 금액과 근거를 운영 기록에 남기고 `REVIEW`로 처리한다. 정산 후 반환 대기 또는 기존 취소 결과가 불명확하면 확인이 끝날 때까지 접수 상태를 유지한다. +3. 실제 취소를 처리한 뒤 금액과 회수할 크레딧을 각각 입력한다. 예시는 1,100원에 10개를 구매해 3개를 사용했고 일반 환불의 공제가 없는 경우다. 이미 예약한 수량은 이미지 처리 또는 탈퇴 해제로 확정한 뒤 진행한다. + + ```bash + java -jar "$MONGLE_JAR" --payment-refund=preview --receipt-id="$RECEIPT_ID" \ + --action=settle --amount=770 --credits=7 --basis=STANDARD --operator-reference=case-134-001 + ``` + +4. 판매자 화면의 최종 취소와 `decision=READY`를 모두 확인하고, 같은 입력에 미리보기의 `snapshot`을 더해 실행한다. + + ```bash + java -jar "$MONGLE_JAR" --payment-refund=apply --receipt-id="$RECEIPT_ID" \ + --action=settle --amount=770 --credits=7 --basis=STANDARD --operator-reference=case-134-001 \ + --expected-snapshot="$REFUND_SNAPSHOT" + ``` + +전액 취소(9/64)는 원거래 식별자·원결제액을 대조한다. 부분취소(70/71)는 콜백에서 검증한 원주문 귀속과 별도 취소 거래 식별자를 대조한다. +동일 취소 거래의 반복 통보는 한 번만 합산하고, 기존 취소를 포함한 합계는 승인 금액과 정확히 일치하며 원결제액 이하여야 한다. +원주문 결제완료 통보보다 앞선 취소, 취소 금액 충돌, 원거래와 같은 식별자의 부분취소 등은 확인 대상으로 남긴다. +여러 부분취소 뒤 전액취소 통보가 함께 들어와 합계가 맞지 않으면 판매자 화면에서 대조하고 보류를 유지한다. +정산 후 돌아온 동일 통보는 정산을 반복하지 않는다. 새 취소 증빙이 오면 해당 주문을 다시 검토 상태로 보내고 구매분 보류를 건다. + +정산할 때 남은 해당 구매분 전량을 회수한다. 환불액이 공제 등으로 줄어도 수량은 원화에서 역산하지 않는다. +이미 전량 사용한 별도 검토 건은 회수 수량 0으로 실제 확인된 환불액만 기록할 수 있다. +완료 시 회수·원장·접수 종료·해당 결제 보류 해제를 함께 커밋한다. 다른 주문의 보류는 따로 유지한다. +같은 입력·스냅샷의 재실행은 기존 결과를 반환한다. `SNAPSHOT_CHANGED`이면 최신 결과를 확인한 뒤 미리보기부터 다시 진행한다. + +## 반려와 철회 + +페이앱에서 취소 실행·취소 요청·정산 후 반환 대기가 없음을 확인한 뒤 `--action=reject` 또는 `--action=withdraw`를 사용한다. +`--amount`, `--credits`, `--basis` 없이 먼저 `preview`, 같은 인자와 스냅샷으로 `apply`를 실행한다. +취소 통보·외부 보류가 있거나 원주문이 결제 완료 상태가 아니면 접수 상태를 유지한다. +반려·철회 근거는 운영 참조 번호로 남긴다. 종료된 반려·철회는 보존하며, 다시 신청할 때 새 요청 UUID를 사용한다. + +## 프론트와 운영 인계 + +- 환불 상세·목록의 `processingStatus`는 `RECEIVED`, `COMPLETED`, `REJECTED`, `WITHDRAWN`이다. 접수 중에는 `currentEstimate`, 종료 후에는 `resolution`의 환불 금액·회수량·완료 시각을 표시한다. +- 주문 상태 `REFUND_SETTLED`는 충전 확인 폴링을 끝내고 환불 완료를 표시한다. `PAYMENT_REVIEW`는 별도 확인이 필요한 상태다. 접수번호로 문의할 때 운영자는 신청자와 원주문을 대조한다. +- V10을 CD로 적용한다. V9 접수와 원장은 보존된다. 정산이 열린 동안은 탈퇴 기록을 파기하지 않으며, 완료된 환불의 보관 기한에는 최종 처리 시각을 반영한다. +- 환불 접수 공개 전 대상 판매자 계정의 전액·부분취소, 정산 후 반환, 실제 취소 통보, 탈퇴 후 문의를 운영자와 검증한다. 현재 공개 플래그 기본값은 false다. +- 운영 정산이 시작되면 `REFUND_SETTLED`와 정산 기록을 읽는 버전으로 운영한다. 일시 중지는 신규 접수 플래그를 내려 처리한다. diff --git a/docs/user-withdrawal.md b/docs/user-withdrawal.md index 275532b..8f1f369 100644 --- a/docs/user-withdrawal.md +++ b/docs/user-withdrawal.md @@ -22,7 +22,7 @@ SIGNUP/ATTENDANCE/PROMOTION은 무료다. 지급 출처를 한 번에 SUM 집계 같은 사용자 잠금 아래 실제 DB 접수 내역이 모든 남은 유료 구매분(사용 가능분과 진행 중 예약분)을 커버하는지 확인한다. 접수 완료 후 탈퇴하면 진행 중인 이미지 예약은 기존 절차로 해제하고 유료분과 접수는 별도로 보존한다. 탈퇴 전 접수번호와 문의 이메일을 안내하며, 미완료 접수 건은 보존기간 종료 시에도 파기하지 않는다. -페이앱 승인 취소 실행과 취소 결과에 따른 정산은 후속 작업이다. +탈퇴 뒤에도 [정산 명령](payment-refund-settlement.md)으로 검증된 취소에 따른 회수와 종료를 처리한다. ## 삭제·보존 표