Fix review findings: aggregated-refund/saved-card/legacy-refund idempotency keys, structured Square error classification, CSP for Square SDK

Money-safety idempotency fixes (external review bugs 1-3):
- processChargeGroup: aggregated refund key now hashes the sorted pending-row
  set (chargeID-square-agg-<sha256 suffix>) so a changed group can never mark
  a new row completed against an old smaller refund; >45-char chargeIDs use a
  hashed prefix instead of verbatim truncation (which would collide charges on
  Square's global key dedup). Same-set crash-retry keeps Square's dedup.
- CreateTerminalPayment saved_card: two-tier idempotency key — client-supplied
  per-attempt UUID preferred (distinct identical charges no longer collapse),
  deterministic booking+type+amount+card fallback for no-key retry safety.
  PaymentModal sends a per-charge UUID cleared after success.
- ensureRefundKey: legacy NULL-key manual refunds persist a generated key to
  the row BEFORE the Square call (race-safe AND idempotency_key IS NULL guard),
  so a lost-response retry reuses the key and never double-refunds. Wired into
  resumeManualPendingRefund and the sweep's manual-retry loop.

Classification + money-safety hardening:
- till.go/sweep.go: structured square.ErrorCode/IsNotFound are authoritative
  when present; message-substring matching only for non-structured errors
  (dev mock, client-side status errors). Fixes fragile string-matching driving
  sweep retries and gift-card clawbacks.
- SaveCardForUser: ON CONFLICT (user_id, square_card_id) DO NOTHING + re-select
  (was a latent UNIQUE-violation 500 on save-card retry).
- CreateBookingPayment: partial payments re-validated against remaining balance
  inside the advisory lock (closes concurrent-overpayment race).
- InvalidateSquareCustomerCache on GDPR erasure paths (account.go,
  time-blockers.go stale-guest anonymization).
- GetUserGiftCardBalanceAdmin: in-handler admin check (defense-in-depth).
- getCheckoutHTTP: warn on multi-payment checkouts instead of dropping
  payments[1:].
- Cash/giftcard terminal branch: removed dead idempotency SELECT, "tip-" ->
  "till-" prefix.
- UserPaymentModal: removed vestigial polling state; proper interval cleanup.
- account/+page.svelte: gift-card redeem dialog links /terms.
- nginx CSP: allow *.squarecdn.com and js.squareup.com so the Square Web
  Payments SDK + card iframe can tokenize behind the proxy.

Tests: +8 regression tests covering changed-set refund keys, legacy NULL-key
single-refund, saved-card client-key dedup/no-dedup, concurrent partials, and
cache invalidation. Full suite + race detector clean via run-tests.sh lockfile.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 726ac8cb65
commit a8d54f1e2a
21 changed files with 1279 additions and 111 deletions
@@ -616,6 +616,16 @@
let loadingSavedCards = $state(false);
let selectedSavedCardId = $state<string | null>(null);
// Per-attempt idempotency key for saved-card charges: regenerated whenever
// the (booking.id, selectedSavedCardId, charge amount) tuple changes, so
// two DISTINCT identical charges get different UUIDs, but reused across
// retries of the SAME charge so a lost-response retry dedups server-side.
// Mirrors the TipPayment.svelte tipIdempotencyKey/tipKeyedAmount pattern.
let savedCardIdempotencyKey = $state('');
let savedCardKeyedBookingId = $state('');
let savedCardKeyedCardId = $state('');
let savedCardKeyedAmount = $state(0);
async function fetchSavedCards() {
const targetUserId = booking.user_id ?? booking.user?.id;
if (!targetUserId) return;
@@ -641,13 +651,30 @@
return;
}
const chargeAmount = Math.round(totalDue * 100) - loyaltyDiscount;
// totalDue can be £0 (fully discounted) and the loyalty discount is
// applied on top — the effective charge could otherwise be 0 or negative.
if (Math.round(totalDue * 100) - loyaltyDiscount <= 0) {
if (chargeAmount <= 0) {
toast.error('Nothing to charge — the booking is fully covered by discounts');
return;
}
// Reuse the key while the charge context is unchanged (retry of the
// same charge → server-side dedup); regenerate when the card or amount
// changes so distinct charges never collapse on one key.
if (
!savedCardIdempotencyKey ||
savedCardKeyedBookingId !== booking.id ||
savedCardKeyedCardId !== selectedSavedCardId ||
savedCardKeyedAmount !== chargeAmount
) {
savedCardIdempotencyKey = crypto.randomUUID();
savedCardKeyedBookingId = booking.id;
savedCardKeyedCardId = selectedSavedCardId;
savedCardKeyedAmount = chargeAmount;
}
isProcessingPaymentSync = true;
status = 'saved-card-processing';
error = null;
@@ -659,10 +686,11 @@
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: Math.round(totalDue * 100) - loyaltyDiscount,
amount: chargeAmount,
payment_type: 'full',
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId
saved_card_id: selectedSavedCardId,
idempotency_key: savedCardIdempotencyKey
})
});
@@ -680,6 +708,10 @@
last4: data.card_last4,
amount: data.amount
};
// The charge succeeded — clear the cached key so the next (distinct)
// charge gets a fresh UUID and can't be deduped against this one.
savedCardIdempotencyKey = '';
savedCardKeyedAmount = 0;
toast.success('Saved card payment successful');
onComplete(paymentResult);
} catch (_err) {
@@ -35,7 +35,7 @@
// the checkbox inside CardSelection; defaults to false (opt-in).
let saveCard = $state(false);
type PaymentStatus = 'idle' | 'processing' | 'polling' | 'success' | 'error';
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
let status = $state<PaymentStatus>('idle');
let error = $state<string | null>(null);
@@ -102,8 +102,6 @@
paymentType = defaultType as 'full' | 'partial' | 'deposit';
});
let pollingInterval: ReturnType<typeof setInterval> | null = null;
// Payment lock state
let lockTimer = $state(-1);
let lockAcquired = $state(false);
@@ -506,17 +504,9 @@
function handleClose() {
releaseLock();
stopPolling();
onClose();
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
// Fetch payment methods on mount if authenticated
$effect(() => {
if (authStore.isAuthenticated) {
@@ -528,7 +518,8 @@
// Cleanup on unmount
$effect(() => {
return () => {
stopPolling();
if (countdownInterval) clearInterval(countdownInterval);
if (lockInterval) clearInterval(lockInterval);
};
});
@@ -927,16 +918,6 @@
Close
</Button>
</div>
{:else if status === 'polling'}
<!-- Polling State -->
<div class="flex flex-col items-center justify-center py-8">
<div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
></div>
<p class="text-lg font-medium text-gray-700">Processing payment...</p>
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
<Button variant="ghost" onclick={handleClose} class="mt-6">Cancel</Button>
</div>
{:else if status === 'success' && paymentResult}
<!-- Success State -->
<div class="space-y-4">