Fix payment review round 2: refund idempotency, pending-resume safety, terminal-completion lock
Refund idempotency (P2):
- RefundRequest gains an optional client idempotency_key: two DISTINCT equal
partial refunds of one payment no longer collide on the amount-derived key
(the second was silently swallowed as a dedup)
- Extract resumeManualPendingRefund: resumes a pending refund with the row's
OWN stored key, so Square's key dedup returns the original refund if the
prior attempt completed — never issues a second
- (payment, amount) pending fallback: when the exact-key lookup misses (admin
reopened the modal, new UUID), resume the matching pending row instead of
creating a second pending row the sweep would double-process
- 409 in-flight guard: if a pending refund exists for the payment but no
same-amount row matches, reject a different-amount refund (money state at
Square is unknown — no new refund is safe until it resolves)
- Frontend (EditBookingModal): UUID per refund attempt, reused on retry,
mirroring the tip flow
Terminal completion (P3):
- GetCheckoutStatus serializes on pg_advisory_lock('crussell:terminal:' ||
SquarePayID) on a pinned connection — concurrent polls of the same checkout
can no longer both pass the dedup SELECT and race the UNIQUE constraint
Card-on-file / doc-only:
- Document why CreateCardOnFile is NOT rolled back on payment failure
(deterministic sha256 retry returns the same card; deletion breaks it)
- Document HasCompletedPayment's deliberate 'tip' exclusion
Regression tests:
- TestRefund_TwoEqualPartialRefunds_ClientKeyDisambiguates
- TestRefund_PendingResume_NewKeyAfterModalReopen (proves stored-key resume)
- TestRefund_PendingResume_DifferentAmountRejected (409 + no second row)
- TestRefund_GuardCountsPendingRefunds updated: 400 -> 409 (in-flight guard
fires first — strictly safer, blocks before any Square attempt)
- TestGetCheckoutStatus_ConcurrentPolls_SingleRecord (real two-goroutine race)
This commit is contained in:
@@ -50,6 +50,7 @@
|
||||
let refundAmount = $state('');
|
||||
let refundReason = $state('');
|
||||
let refundLoading = $state(false);
|
||||
let refundIdempotencyKey = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (open && bookingId) {
|
||||
@@ -346,6 +347,10 @@
|
||||
refundPaymentId = paymentId;
|
||||
refundAmount = (amountPence / 100).toFixed(2);
|
||||
refundReason = '';
|
||||
// Unique per refund attempt so two equal partial refunds of the same
|
||||
// payment don't collide on the backend's amount-derived key; reused on
|
||||
// retry (the backend dedups on it) so a timeout can't double-refund.
|
||||
refundIdempotencyKey = crypto.randomUUID();
|
||||
showRefundModal = true;
|
||||
}
|
||||
|
||||
@@ -370,7 +375,8 @@
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: amountPence,
|
||||
reason: refundReason.trim()
|
||||
reason: refundReason.trim(),
|
||||
idempotency_key: refundIdempotencyKey
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user