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:
@@ -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">
|
||||
|
||||
@@ -2070,10 +2070,14 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- TODO: Link full Terms & Conditions once the T&Cs page is created -->
|
||||
<p class="text-xs text-muted-foreground italic">
|
||||
By redeeming this gift card, you agree to our Terms & Conditions.
|
||||
<em>Link T&Cs here once available.</em>
|
||||
By redeeming this gift card, you agree to our
|
||||
<a
|
||||
href="/terms"
|
||||
class="font-semibold text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer external">Terms & Conditions</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog.Footer>
|
||||
|
||||
Reference in New Issue
Block a user