fix: round-2 loop-A fresh review (503c326 baseline) — B1 replay cap, A6 discount record, 2FA reissue+cooldown, notification flood, lockout saturation, VAT/refund-status consolidation

Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed:

MONEY:
- CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) —
  a rejected auto-refund no longer re-replays the expired key every sweep run
  (which minted a stacking unauthorized charge each time); FAILED-webhook
  demotion respects the cap; never re-replay a key whose B1 refund failed
- HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible
  campaign discount rows immediately (capped) instead of skipping with no
  discount recorded — no more promised-discount-not-recorded overcharge
- MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed
  new-card+save_card charges (re-issue guard now covers req.SaveCard)
- LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining
  now matches the authoritative tip-excluded balance)

SECURITY:
- MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap
  on critical_payment_log + refresh_token_reuse rows)
- MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer
  clears LastMintAt on gate-verify; cleared on terminal charge success)
- MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked
  state instead of a fresh 5-guess budget per request
- MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs
  instead of sleeping unboundedly; login bcrypt concurrency semaphore added
- LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check;
  email-verification per-user attempt counter

DUP/MOD:
- formatCurrency single source (frontend format.ts, 7 files consolidated);
  SquareRefundStatusToLocal single source (errors.go, all sites); admin
  audit-log helper dedup; SCA retry model unified (proactive on all 6
  surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from
  backend; generateUUID at all card-form sites; magic numbers named
  (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card
  terminal charges now audited; DAV_SKIP_INIT documented in manuals

Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet
tags, frontend tests+build, env-docs 42/42.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 4e64e32f09
commit 3866cc5963
36 changed files with 2032 additions and 719 deletions
@@ -16,6 +16,7 @@
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { apiFetch } from '$lib/utils/api';
import { generateUUID } from '$lib/utils/uuid';
import { formatCurrency } from '$lib/utils/format';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
@@ -24,16 +25,12 @@
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
runSavedCardSCAProactively,
sanitizeDecimalInput,
shouldFallbackTo2FA,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationOutcome,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
const LOYALTY_DISCOUNT_RATE = 0.1;
@@ -236,10 +233,16 @@
get text(): string | null {
if (!booking.deposit_required && totalPaid === 0 && booking.amount_due <= 0) return null;
if (booking.deposit_required) {
return `A ${expectedDepositPercent}% deposit (at least £${(booking.total_amount * 0.2).toFixed(2)}) is required. Any payments up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) are treated as deposit for cancellations.`;
return `A ${expectedDepositPercent}% deposit (at least ${formatCurrency(
booking.total_amount * 0.2
)}) is required. Any payments up to 50% of total (${formatCurrency(
booking.total_amount * 0.5
)}) are treated as deposit for cancellations.`;
}
if (totalPaid > 0 || booking.amount_due > 0) {
return `Any payment up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
return `Any payment up to 50% of total (${formatCurrency(
booking.total_amount * 0.5
)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
}
return null;
}
@@ -281,13 +284,6 @@
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
);
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(pence / 100);
}
function formatTimer(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
@@ -311,8 +307,12 @@
method: 'POST'
});
if (response.ok) {
const data = await response.json();
// Honor the backend's lock TTL (ttl_min — PaymentLockDuration);
// fall back to 5 min when the field is absent so the countdown
// can never drift from the server's value.
lockTimer = (Number(data?.ttl_min) || 5) * 60;
lockAcquired = true;
lockTimer = 300;
}
} catch (_err) {
console.error('Failed to acquire payment lock:', _err);
@@ -344,7 +344,8 @@
method: 'POST'
});
if (response.ok) {
lockTimer = 300;
const data = await response.json();
lockTimer = (Number(data?.ttl_min) || 5) * 60;
lockAcquired = true;
}
} catch (_err) {
@@ -499,7 +500,17 @@
if (cardId && !verificationToken) {
waitingForSCA = true;
try {
const proactive = await runSavedCardSCAProactively(amountPence, cardId);
const squareCardId = savedCardsStore.cards.find((c) => c.id === cardId)?.square_card_id;
const proactive = await runSavedCardSCAProactively({
amountPence,
squareCardId: squareCardId ?? '',
buyer: {
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
},
onOutcome: (o) => (lastSCAOutcome = o)
});
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
status = 'error';
error = CARD_VERIFICATION_RETRY_MESSAGE;
@@ -682,43 +693,6 @@
}
}
/**
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first charge
* attempt (never after a 402). Square's card.tokenize(verificationDetails,
* squareCardId) determines UP FRONT whether buyer verification is required
* and returns a fresh verification_token bound to the exact amount:
* - 'verified' → the caller charges with the returned token (the first
* attempt carries it — a naked ccof is never sent);
* - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup
* to the only available gate and the caller proceeds WITHOUT a token;
* - 'challenge-cancelled' / 'sca-failed' → the caller must NOT charge:
* the pending row stays retryable and the user taps Pay again to re-run
* the challenge.
*/
async function runSavedCardSCAProactively(
amountPence: number,
cardId: string
): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> {
const squareCardId = savedCardsStore.cards.find((c) => c.id === cardId)?.square_card_id;
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
});
} catch (_err) {
lastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
}
lastSCAOutcome = result.outcome;
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
}
// Confirm the overpayment: resend the SAME rejected request with
// confirm_overflow_tip: true so the excess is recorded as a tip. Works for
// both pre-start and post-start overflows (B12).
@@ -864,8 +838,8 @@
discountNote={overflowConfirm.paymentType === 'deposit' &&
overflowConfirm.chargePence !== undefined
? `An eligible campaign discount of ${formatCurrency(
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
)} applies you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence) / 100
)} applies you'll be charged ${formatCurrency(overflowConfirm.chargePence / 100)}.`
: undefined}
loading={status === 'processing'}
onConfirm={confirmOverflowPayment}
@@ -950,9 +924,9 @@
<span class="text-gray-600">{service.service_name || 'Unknown Service'}</span>
<span class="font-medium">
{service.override_price
? formatCurrency(Math.round(service.override_price * 100))
? formatCurrency(Math.round(service.override_price * 100) / 100)
: service.price
? formatCurrency(Math.round(service.price * 100))
? formatCurrency(Math.round(service.price * 100) / 100)
: '-'}
</span>
</div>
@@ -974,7 +948,7 @@
<div class="text-sm font-medium text-fuchsia-900">Use my Loyalty Stamp Card</div>
<div class="mt-0.5 text-xs text-fuchsia-700">
{stamps} stamps available &middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off
({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) / 100)})
</div>
</label>
</div>
@@ -1035,7 +1009,7 @@
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
<div class="flex justify-between text-sm">
<span class="text-gray-600">Total</span>
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100) / 100)}</span
>
</div>
{#if discountPreview?.eligible}
@@ -1043,19 +1017,19 @@
<div class="flex justify-between text-sm">
<span class="text-gray-600">{d.name}</span>
<span class="font-medium text-green-700"
>-{formatCurrency(Math.round(d.amount * 100))}</span
>-{formatCurrency(Math.round(d.amount * 100) / 100)}</span
>
</div>
{/each}
{/if}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Amount Paid</span>
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
<span class="font-medium text-green-700">{formatCurrency(totalPaid / 100)}</span>
</div>
{#if useLoyalty && loyaltyDiscount > 0}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Loyalty Stamp Card (10% Off)</span>
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount)}</span>
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount / 100)}</span>
</div>
{/if}
<div class="flex justify-between border-t border-gray-200 pt-2">
@@ -1065,7 +1039,7 @@
Math.max(
0,
remainingBalancePence - campaignDiscountPence(discountPreview) - loyaltyDiscount
)
) / 100
)}
</span>
</div>
@@ -1169,7 +1143,7 @@
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100),
campaignDiscountPence(discountPreview)
)
) / 100
)})
{:else}
Pay {formatCurrency(
@@ -1178,7 +1152,7 @@
Math.round(booking.amount_due * 100) -
campaignDiscountPence(discountPreview) -
(useLoyalty ? loyaltyDiscount : 0)
)
) / 100
)}
{/if}
</Button>
@@ -1248,7 +1222,7 @@
>
{#if paymentType === 'partial'}
Pay {partialAmountValid
? formatCurrency(Math.round(partialAmountNum * 100))
? formatCurrency(Math.round(partialAmountNum * 100) / 100)
: 'Part'}
{:else}
Pay {formatCurrency(
@@ -1257,7 +1231,7 @@
Math.round(booking.amount_due * 100) -
campaignDiscountPence(discountPreview) -
(useLoyalty ? loyaltyDiscount : 0)
)
) / 100
)}
{/if}
</Button>