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
@@ -136,13 +136,22 @@
const cardError = $derived(
cardNumberTouched && !isValidLuhn(newCardNumber) && newCardNumber.length > 0
? 'Invalid card number'
: cardExpiryTouched && expiryParts !== null && (() => { const em = expiryParts.year * 12 + expiryParts.month; const now2 = new SvelteDate(); const cm = now2.getFullYear() * 12 + now2.getMonth() + 1; return em < cm; })()
: cardExpiryTouched &&
expiryParts !== null &&
(() => {
const em = expiryParts.year * 12 + expiryParts.month;
const now2 = new SvelteDate();
const cm = now2.getFullYear() * 12 + now2.getMonth() + 1;
return em < cm;
})()
? 'This card has expired'
: cardExpiryTouched && !/^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardExpiry.length > 0
? 'Enter expiry as MM/YY'
: cardCVCTouched && newCardCVC.length < 3 && newCardCVC.length > 0
? 'Enter your CVC number'
: isValidLuhn(newCardNumber) && /^\d{2}\/\d{2}$/.test(newCardExpiry) && newCardCVC.length >= 3
: isValidLuhn(newCardNumber) &&
/^\d{2}\/\d{2}$/.test(newCardExpiry) &&
newCardCVC.length >= 3
? null
: newCardNumber.length === 0 && newCardExpiry.length === 0 && newCardCVC.length === 0
? null
@@ -345,10 +354,23 @@
const bookingId = confirmedBooking.id;
const amountCents = Math.round(amount * 100);
// Cache the idempotency key per amount+card so a lost-response retry
// reuses it (backend dedups) instead of double-charging.
const cardKey = selectedPaymentMethod ?? newCardNumber.replace(/\s/g, '') ?? '';
if (
!depositIdempotencyKey ||
depositKeyedAmount !== amountCents ||
depositKeyedCard !== cardKey
) {
depositIdempotencyKey = generateUUID();
depositKeyedAmount = amountCents;
depositKeyedCard = cardKey;
}
const body: Record<string, unknown> = {
payment_type: 'deposit',
amount: amountCents,
idempotency_key: generateUUID()
idempotency_key: depositIdempotencyKey
};
if (selectedPaymentMethod) {
@@ -373,6 +395,9 @@
if (response.ok) {
depositPaid = true;
depositIdempotencyKey = '';
depositKeyedAmount = 0;
depositKeyedCard = '';
// Immutable update — avoid mutating the existing object so
// concurrent renders (e.g. a stale fetch) can't observe partial
// state. (See audit: HIGH issue #3 — confirmedBooking mutated
@@ -400,6 +425,13 @@
let paymentAttempted = $state(false);
// Cached idempotency key per deposit attempt (amount + card): reused on
// retry so a lost-response retry dedups instead of double-charging,
// regenerated when the amount or card changes. Matches the tip-flow pattern.
let depositIdempotencyKey = $state('');
let depositKeyedAmount = $state(0);
let depositKeyedCard = $state('');
function formatCardExpiry(month: number, year: number): string {
return `${String(month).padStart(2, '0')}/${year.toString().slice(-2)}`;
}
@@ -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,