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:
@@ -23,6 +23,14 @@ export interface SquareConfig {
|
||||
// forces a fresh tokenization before the charge attempt.
|
||||
export const NONCE_STALENESS_MS = 240_000;
|
||||
|
||||
// Canonical payment_method value for charging a customer's saved card. The
|
||||
// backend keys on this exact string ('saved_card' — see till.go's payment-method
|
||||
// switch and the terminal saved-card branch), so every surface that charges a
|
||||
// saved card must send it verbatim. Single source of truth so the gate
|
||||
// expressions and request bodies can't drift between 'savedcard' and
|
||||
// 'saved_card' across the booking, tip, account, till and admin surfaces.
|
||||
export const PAYMENT_METHOD_SAVED_CARD = 'saved_card';
|
||||
|
||||
/**
|
||||
* True when a user's role is allowed to save cards for reuse. Only VERIFIED
|
||||
* accounts (verified_email, admin) may save cards — guests, unverified accounts
|
||||
@@ -410,16 +418,33 @@ const PAYMENT_MAX_RETRIES = 3;
|
||||
* other status return immediately so the caller surfaces the error; the caller
|
||||
* must NOT retry those on its own. Defaults to up to 3 retries with a 1.5s
|
||||
* backoff (Square/backend transient failures typically clear within seconds).
|
||||
*
|
||||
* `verificationCodeGated` disables the auto-retry for charges whose body
|
||||
* carries the homegrown 2FA `verification_code` (finding 4): a fresh saved-card
|
||||
* charge already CONSUMED that code at the backend gate (single-use), and the
|
||||
* backend re-issued a fresh one on the ambiguous failure — so a same-body retry
|
||||
* would re-send a dead code and come back 400 "Invalid verification code". The
|
||||
* 503 is surfaced to the caller instead, which re-runs the gate / asks the
|
||||
* customer for the re-issued code.
|
||||
*/
|
||||
export async function submitPaymentWithRetry(
|
||||
submit: () => Promise<Response>,
|
||||
options: { maxRetries?: number; retryDelayMs?: number } = {}
|
||||
options: {
|
||||
maxRetries?: number;
|
||||
retryDelayMs?: number;
|
||||
verificationCodeGated?: boolean;
|
||||
} = {}
|
||||
): Promise<Response> {
|
||||
const maxRetries = options.maxRetries ?? PAYMENT_MAX_RETRIES;
|
||||
const retryDelayMs = options.retryDelayMs ?? PAYMENT_RETRY_DELAY_MS;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const response = await submit();
|
||||
if (response.ok || !isAmbiguousPaymentFailure(response.status) || attempt >= maxRetries) {
|
||||
if (
|
||||
response.ok ||
|
||||
!isAmbiguousPaymentFailure(response.status) ||
|
||||
options.verificationCodeGated ||
|
||||
attempt >= maxRetries
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
||||
|
||||
Reference in New Issue
Block a user