fix: loop-A fresh review (503c326 baseline) — overflow-guard bypass, discounted-deposit retry, GDPR audit scrub, till cap, sweep rescue, 2FA reissue + SCA retry, consolidation round

Loop A fresh money/security/dup-mod review of the whole payments overhaul. 28 consolidated findings fixed:

MONEY:
- HIGH-1: B12 overflow guard now uses the discounted obligation — a pre-start deposit can never mint an unintended tip; the discount is never truncated to £0 when the customer pays the discounted deposit
- HIGH-2: discounted-deposit pending-reuse retry compares pendingStoredAmountPence vs chargeAmount (the actual Square amount), not req.Amount — no more permanent amount_mismatch 400 on lost-response retries
- MEDIUM-3: sweep rescue now carves overflow as a tip record + runs completion side-effects (was booking overflow as service revenue, skipping completion)
- MEDIUM-4 (shared w/ security): admin_audit_log.admin_id made nullable + anonymize_user/delete_guest_user NULL it + scrub details.card_last4 — 2fa_fallback_charge PII no longer survives account deletion
- MEDIUM-5: till gift-card payment now passes the £5,000/day admin cap (giftcard_limits)
- LOW-6: expired gift-card balance surfaced as expired/zero in GetUserGiftCardBalance

SECURITY:
- 2FA single-use consume made atomic at verify time for all 5 saved-card gates (fresh charges consume; pending-reuse retries don't); deferred consumption removed
- reissueTwoFACodeAfterFailedCharge routed through the fail-closed issuance gate (pepper check, cooldown) + fresh-only semantics (only when a code was actually consumed)
- family-alive cache invalidated on the stale-family cleanup DELETE (no 30s warm window after expiry)
- frontend 503-retry no longer reuses a consumed 2FA code — aligns with backend re-issue

DUP/MOD:
- reissue helper single-sourced (5 call sites), squareRefundStatusToLocal (10 inline switches), writeChargeSnapshot (7 sites, immutability guard on gift-card/till), postChargeRecheck (3+1 sites), scanIdempotencySlot (2), applyVATToChargeRecord (3 patterns), user_saved_cards upsert (2), BuyGiftCard pending INSERT via service
- till completed-dedup now re-validates paymentHasLiveRefund (aligns with booking/tip/gift-card)
- frontend 402 idempotency-key regeneration added to PaymentModal (aligns with other CIT surfaces)
- PAYMENT_METHOD_SAVED_CARD constant standardised ('saved_card' everywhere)
- admin audit coverage added for AdminRefundBooking + gift-card buy/top-up
- audit-helper cross-package dedup (user/twofa.go now calls payments' exported insert)

Verified: 26/26 dev + 24/24 prod packages, both vet tags, frontend tests + build, gitleaks clean.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent b7122be3a0
commit 36887167c6
32 changed files with 1565 additions and 590 deletions
@@ -19,6 +19,7 @@
submitPaymentWithRetry,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode,
PAYMENT_METHOD_SAVED_CARD,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import {
@@ -35,13 +36,13 @@
qty: number;
};
type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square' | 'saved_card';
type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square' | (typeof PAYMENT_METHOD_SAVED_CARD);
const PAYMENT_METHODS: Array<{ key: TillPaymentMethod; label: string }> = [
{ key: 'cash', label: 'Cash' },
{ key: 'card_machine', label: 'Card Machine' },
{ key: 'online_square', label: 'Online Card' },
{ key: 'saved_card', label: 'Saved Card' }
{ key: PAYMENT_METHOD_SAVED_CARD, label: 'Saved Card' }
];
let cart = $state<CartItem[]>([]);
@@ -73,7 +74,7 @@
function idempotencyKeyFor(item: CartItem, qtyIndex: number): string {
// saved_card charges also key on the selected card id so switching to a
// different card (or back to another method) yields fresh keys.
const composite = `${item.id}:${qtyIndex}:${item.price}:${paymentMethod}:${paymentMethod === 'saved_card' ? (selectedSavedCardId ?? '') : ''}`;
const composite = `${item.id}:${qtyIndex}:${item.price}:${paymentMethod}:${paymentMethod === PAYMENT_METHOD_SAVED_CARD ? (selectedSavedCardId ?? '') : ''}`;
let key = idempotencyKeys.get(composite);
if (!key) {
key = generateUUID();
@@ -152,7 +153,7 @@
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === PAYMENT_METHOD_SAVED_CARD,
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
mint: () =>
selectedCustomer?.id
@@ -165,14 +166,14 @@
const showSavedCardOption = $derived(selectedCustomer !== null && validCards.length > 0);
const availablePaymentMethods = $derived(
PAYMENT_METHODS.filter((m) => m.key !== 'saved_card' || showSavedCardOption)
PAYMENT_METHODS.filter((m) => m.key !== PAYMENT_METHOD_SAVED_CARD || showSavedCardOption)
);
// If the saved-card option disappears (customer cleared, no valid cards, or
// a card expires mid-session) fall back to cash instead of leaving the till
// on an unrenderable method.
$effect(() => {
if (paymentMethod === 'saved_card' && !showSavedCardOption) {
if (paymentMethod === PAYMENT_METHOD_SAVED_CARD && !showSavedCardOption) {
paymentMethod = 'cash';
selectedSavedCardId = null;
}
@@ -353,7 +354,7 @@
);
return;
}
if (paymentMethod === 'saved_card' && (!selectedCustomer || !selectedSavedCardId)) {
if (paymentMethod === PAYMENT_METHOD_SAVED_CARD && (!selectedCustomer || !selectedSavedCardId)) {
toast.error('Select a customer and a saved card before charging');
return;
}
@@ -374,7 +375,7 @@
payment_method: paymentMethod,
idempotency_key: idempotencyKeyFor(item, i)
};
if (paymentMethod === 'saved_card') {
if (paymentMethod === PAYMENT_METHOD_SAVED_CARD) {
body.user_id = selectedCustomer?.id;
body.user_saved_card_id = selectedSavedCardId;
// B6/B10: the backend requires the CARD OWNER's current 2FA
@@ -398,12 +399,19 @@
}
for (const body of saleBodies) {
const res = await submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const res = await submitPaymentWithRetry(
() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
// Finding 4: a saved-card till line gated on 2FA consumed its
// code at the backend gate — a 503 auto-retry would re-send a
// dead code and self-defeat.
{
verificationCodeGated: paymentMethod === PAYMENT_METHOD_SAVED_CARD && twoFactor.showInput
}
);
if (!res.ok) {
responseStatus = res.status;
@@ -415,7 +423,7 @@
// its SAME cached idempotency key. runTillSavedCardSCA throws to
// stop the whole sale on any non-verified outcome.
if (
paymentMethod === 'saved_card' &&
paymentMethod === PAYMENT_METHOD_SAVED_CARD &&
isVerificationRequiredSignal(responseStatus, errText)
) {
await runTillSavedCardSCA(body);
@@ -830,7 +838,7 @@
</div>
{/if}
{#if paymentMethod === 'saved_card'}
{#if paymentMethod === PAYMENT_METHOD_SAVED_CARD}
<div class="mt-3 space-y-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
{#if loadingSavedCards}
<div class="flex justify-center py-6">
@@ -955,7 +963,7 @@
processing ||
twoFactor.missing ||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
(paymentMethod === PAYMENT_METHOD_SAVED_CARD && !selectedSavedCardId)}
>
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
</Button>