Fix P0/P1 review findings: truncation, raw-PAN API edge, refund lock, till pending-retry, idempotency keys

P0 — float truncation: applied math.Round to all remaining int64(x*100)
sites (till penceAmount, refund over-refund guard, GetAlreadyRefundedAmount,
payment summary conversions). A £1.14 till sale previously charged 113p.

P0 — raw PAN stopped at the API edge:
- Deleted CardNumber/CardExpMonth/CardExpYear/CardCVC from TillSaleRequest
  and CardNumber/Expiry/CVC from CreatePaymentMethodRequest. Both now accept
  card_token (Square nonce) and return 400 when absent. PAN+CVV no longer
  transit the application server (PCI-DSS SAQ-A scope).
- Deleted CreateCardOnFileRaw from the SquareClient interface and all
  implementations (MockClient, ProdClient, devProdClient).
- Added idempotency_key column to refunds table (UNIQUE).

P0 — RefundPayment hardened: advisory lock on payment ID (prevents two
concurrent refunds passing the over-refund guard), pending-refund-record-
then-Square pattern (scheduler reprocesses on failure), same-key dedup.

P1 — till sale pending-retry now re-attempts the Square charge instead of
returning the stale 'pending' status (gift card was already funded in the
committed tx — silent money loss otherwise). Sale row reused, not duplicated.

P1 — idempotency key caching in frontend: BuyGiftCard and
UserPaymentModal/BookingFlow now cache the key per amount+card, regenerated
on change and cleared on success — matches the tip-flow pattern so a
lost-response retry dedups instead of double-charging.

P1 — CreateTerminalPayment cash/giftcard INSERTs now persist idempotency_key.
Key is unique per payment (booking+type+amount would wrongly dedup two
legitimate identical payments, e.g. two £50 cash receipts).

P1 — gift-card codes no longer logged (spendable credential; value+recipient
only).

Tests: till pending-retry re-attempt, refund same-key dedup, mock CreatePayment
idempotency dedup, CreatePaymentMethod nonce happy path + raw-PAN rejection,
till online_square card_token required/valid.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent d54f526b56
commit 54f6bf3c1a
14 changed files with 632 additions and 345 deletions
@@ -29,6 +29,14 @@
let status = $state<PaymentStatus>('idle');
let error = $state<string | null>(null);
// Cached idempotency key per payment attempt (amount + type + card): reused
// on retry so a lost-response retry dedups instead of double-charging,
// regenerated when any of those change. Matches the tip-flow pattern.
let payIdempotencyKey = $state('');
let payKeyedAmount = $state(0);
let payKeyedType = $state('');
let payKeyedCard = $state('');
let paymentResult = $state<{
id: string;
amount: number;
@@ -354,7 +362,20 @@
return;
}
const idempotencyKey = generateIdempotencyKey();
// Cache the idempotency key per amount+type+card so a lost-response
// retry reuses it (backend dedups) instead of double-charging.
const cardKey = cardId ?? newCardToken ?? '';
if (
!payIdempotencyKey ||
payKeyedAmount !== amountCents ||
payKeyedType !== paymentType ||
payKeyedCard !== cardKey
) {
payIdempotencyKey = generateIdempotencyKey();
payKeyedAmount = amountCents;
payKeyedType = paymentType;
payKeyedCard = cardKey;
}
try {
const response = await apiFetch(`/api/bookings/${booking.id}/payment`, {
@@ -366,7 +387,7 @@
card_id: cardId,
new_card_token: newCardToken,
save_card: saveCard,
idempotency_key: idempotencyKey
idempotency_key: payIdempotencyKey
})
});
@@ -378,6 +399,10 @@
const data = await response.json();
// Payment is synchronous (completed immediately)
status = 'success';
payIdempotencyKey = '';
payKeyedAmount = 0;
payKeyedType = '';
payKeyedCard = '';
paymentResult = {
id: data.id,
amount: data.amount,