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:
2026-08-22 00:34:49 +01:00
parent 16240d67e3
commit 9ff591fa4e
5 changed files with 522 additions and 72 deletions
@@ -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
})
});