fix: review-loop A — discount credit on admin payments, campaign over-credit cap, sweep replay window, dedup refund revalidation, duplication/modularisation, GBP pence naming

Round-A fresh review (6 agents) + fix + secondary cross-cutting + verification rounds:
- F1: campaign discounts reduce the charged amount (deposit credit + admin PaymentModal discounted total); capDiscountToRemainingObligation prevents over-credit at completion in all four campaign blocks
- F2: sweep replay rescue distinguishes legitimate same-key retries (21h window) from expired-key new charges; ccof blind-fails leave pending + CRITICAL instead of clawing back
- F3: post-start online overflow carved as a tip record (mirrors terminal split builder)
- A1: single-source Square decline-code classification (till delegates to square.IsDefinitivePaymentError)
- A2/A5: refund attempt-cap literals consolidated; refund-failure counter capped + reset on terminal resolutions + admin notifications
- A3/A9: idempotency helpers adopted across derivations; IsExplicitDevOrMockEnv relocated + all gates unified (incl. health-check)
- A7: 2FA user+IP limiter + TRUST_PROXY_HEADERS startup warning; SNAPSHOT_ENC_KEY startup validation; TWO_FACTOR_PEPPER docs corrected
- A8: snapshot encryption on all 6 write sites + marker-aware reuse paths; MPV->SPV effective voucher type (single VAT point)
- A10/A11/A12/A16: gift-card slot scan advances past failed; amount-aware refund reconciliation; completed-booking refund re-check; PaymentWasRefunded on SquareClient interface
- Dedup refund revalidation on tip/terminal/gift-card paths; sweep acknowledged_at IS NULL parity; refund-notification single source (exported payments.InsertRefundFailedNotifications)
- Duplication/modularisation round: shared frontend helpers (sanitizeDecimalInput, campaignDiscountCents, twoFactorBlocksSavedCards getter, generateUUID), single-source MaxIdempotencyKeyLength, notification-helper consolidation, snapshot-guard comments
- Cross-cutting GBP rename: Cents->Pence across backend + frontend + tests (26 identifiers, 16 files)
- Tests: 11 behavior-change tests updated to new invariants; coverage for fixed functions; frontend vitest 55 tests; docs corrected (test counts, 2FA delivery, pre-launch checklist, resolution status)
- gitleaks: allowlist backend/internal/square test fixtures (mock idempotency keys)

All 25 backend packages pass; frontend 55/55 + build clean; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 6d82535780
commit faceb9809c
49 changed files with 2006 additions and 1074 deletions
@@ -38,7 +38,12 @@
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import { canSaveCardsForRole, isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
import {
canSaveCardsForRole,
isNonceStale,
isOverflowTipConfirmationRequired,
submitPaymentWithRetry
} from '$lib/square/square';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
import {
@@ -142,9 +147,7 @@
// PSD2 SCA stand-in: 2FA required but not enabled blocks saved-card use
// and saving new cards for reuse. The new-card (nonce) path has its own
// SCA via Square tokenizeWithVerification.
const twoFactorBlocksSavedCards = $derived(
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
);
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
const depositCardFormValid = $derived(paymentCardSelectionValid);
@@ -334,7 +337,6 @@
toast.error('Booking was not created. Please try again.');
return;
}
const bookingId = confirmedBooking.id;
// Charge the SERVER-computed deposit: the booking response carries the
// authoritative deposit_amount (20% of the server-side total, which
// accounts for discounts / admin adjustments). The client-side
@@ -343,7 +345,7 @@
confirmedBooking.deposit_amount && confirmedBooking.deposit_amount > 0
? confirmedBooking.deposit_amount
: _amount;
const amountCents = Math.round(depositAmount * 100);
const amountPence = Math.round(depositAmount * 100);
let newCardToken: string | undefined;
let verificationToken: string | undefined;
@@ -359,11 +361,11 @@
if (
!depositNonce ||
depositTokenizedForSaveCard !== depositSaveCard ||
isNonceStale(depositTokenizedAt, depositTokenAmount, amountCents)
isNonceStale(depositTokenizedAt, depositTokenAmount, amountPence)
) {
try {
const tokenized = await paymentCardSelection.tokenizeWithVerification(
amountCents,
amountPence,
{
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
@@ -373,7 +375,7 @@
);
depositNonce = tokenized.nonce;
depositVerificationToken = tokenized.verificationToken ?? '';
depositTokenAmount = amountCents;
depositTokenAmount = amountPence;
depositTokenizedAt = Date.now();
depositTokenizedForSaveCard = depositSaveCard;
} catch (err) {
@@ -396,17 +398,17 @@
const cardKey = selectedPaymentMethod || 'new-card';
if (
!depositIdempotencyKey ||
depositKeyedAmount !== amountCents ||
depositKeyedAmount !== amountPence ||
depositKeyedCard !== cardKey
) {
depositIdempotencyKey = generateUUID();
depositKeyedAmount = amountCents;
depositKeyedAmount = amountPence;
depositKeyedCard = cardKey;
}
const body: Record<string, unknown> = {
payment_type: 'deposit',
amount: amountCents,
amount: amountPence,
idempotency_key: depositIdempotencyKey,
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
@@ -415,93 +417,12 @@
paymentAttempted = true;
const response = await submitPaymentWithRetry(() =>
apiFetch(`/api/bookings/${bookingId}/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify(body)
})
);
if (response.ok) {
depositPaid = true;
depositIdempotencyKey = '';
depositKeyedAmount = 0;
depositKeyedCard = '';
depositNonce = '';
depositVerificationToken = '';
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
depositSaveCard = false;
// 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
// in place, potential overcharge on double-click race.)
confirmedBooking = {
...confirmedBooking,
deposit_paid: true,
amount_paid: (confirmedBooking.amount_paid || 0) + depositAmount,
amount_due: Math.max(0, (confirmedBooking.amount_due || 0) - depositAmount)
};
toast.success('Payment successful!');
} else {
const text = await response.text();
// A 409 "already paid" (double-tab, or a lost-response retry that
// actually landed) must not leave the user wedged on the pay form
// with a stale deposit_paid=false — money was taken. Reconcile
// against the server's truth so the confirmation gate (depositPaid
// / confirmedBooking.deposit_paid) opens and the user reaches the
// confirmation screen. The body-text match is a belt-and-braces
// fallback for 4xx responses that still report the charge as
// already processed.
if (response.status === 409 || /already|paid|processed/.test(text.toLowerCase())) {
try {
const bookingResp = await apiFetch(`/api/bookings/${bookingId}`);
if (bookingResp.ok) {
const serverBooking = await bookingResp.json();
// Immutable update — spread, never mutate (see audit note above).
confirmedBooking = {
...confirmedBooking,
status: serverBooking.status ?? confirmedBooking.status,
deposit_paid: serverBooking.deposit_paid ?? confirmedBooking.deposit_paid,
deposit_amount:
serverBooking.deposit_amount ?? confirmedBooking.deposit_amount,
amount_paid: serverBooking.amount_paid ?? confirmedBooking.amount_paid,
amount_due: serverBooking.amount_due ?? confirmedBooking.amount_due,
payments: serverBooking.payments ?? confirmedBooking.payments,
total_amount: serverBooking.total_amount ?? confirmedBooking.total_amount
};
depositPaid = confirmedBooking.deposit_paid;
toast.success('Payment successful!');
} else {
toast.warning(
text || 'Payment failed — you can pay again from your booking details.'
);
}
} catch {
toast.warning(
text || 'Payment failed — you can pay again from your booking details.'
);
}
} else {
toast.warning(text || 'Payment failed — you can pay again from your booking details.');
}
// A definitive charge failure (declined card, any 4xx) consumes the
// nonce + SCA verification token (Square nonces are single-use) —
// clear the cached pair so a retry re-tokenizes fresh instead of
// resubmitting a spent nonce for up to 240s. The idempotency key
// stays so a lost-response retry still dedups against the original
// charge (matches the TipPayment pattern).
depositNonce = '';
depositVerificationToken = '';
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
}
await submitDepositPayment({
body,
amountPence,
depositAmount,
confirmOverflowTip: false
});
} catch {
toast.error(
'An error occurred. Your booking may still be confirmed — check your appointments.'
@@ -520,8 +441,181 @@
}
}
// Submits the deposit payment request and processes the outcome. Shared by
// the initial attempt and the overflow-tip confirm resend so both use the
// exact same success/error handling. `confirmOverflowTip` adds the backend's
// opt-in flag for a pre-start overpayment; the resend reuses the SAME body
// (cached nonce / verification token / idempotency key) as the rejected
// attempt — the guard fired before any Square call, so the tokens are
// unconsumed and the key is still the correct dedup identity.
async function submitDepositPayment(options: {
body: Record<string, unknown>;
amountPence: number;
depositAmount: number;
confirmOverflowTip: boolean;
}): Promise<void> {
const { body, amountPence, depositAmount, confirmOverflowTip } = options;
if (!confirmedBooking) return;
const bookingId = confirmedBooking.id;
const response = await submitPaymentWithRetry(() =>
apiFetch(`/api/bookings/${bookingId}/payment`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({
...body,
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {})
})
})
);
if (response.ok) {
depositPaid = true;
depositIdempotencyKey = '';
depositKeyedAmount = 0;
depositKeyedCard = '';
depositNonce = '';
depositVerificationToken = '';
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
depositSaveCard = false;
overflowConfirm = null;
// 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
// in place, potential overcharge on double-click race.)
confirmedBooking = {
...confirmedBooking,
deposit_paid: true,
amount_paid: (confirmedBooking.amount_paid || 0) + depositAmount,
amount_due: Math.max(0, (confirmedBooking.amount_due || 0) - depositAmount)
};
toast.success('Payment successful!');
return;
}
const text = await response.text();
// Pre-start overpayment guard on stale booking data: park the rejected
// request (body + amount) and surface the Confirm/Cancel prompt instead
// of a dead-end 400. The cached nonce + SCA verification token +
// idempotency key are NOT cleared — the confirm resend is the same
// logical charge.
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(text)) {
overflowConfirm = {
amountPence,
overflowPence: Math.max(
0,
amountPence - Math.round((confirmedBooking?.amount_due ?? 0) * 100)
),
depositAmount,
body
};
return;
}
// A 409 "already paid" (double-tab, or a lost-response retry that
// actually landed) must not leave the user wedged on the pay form
// with a stale deposit_paid=false — money was taken. Reconcile
// against the server's truth so the confirmation gate (depositPaid
// / confirmedBooking.deposit_paid) opens and the user reaches the
// confirmation screen. The body-text match is a belt-and-braces
// fallback for 4xx responses that still report the charge as
// already processed.
if (response.status === 409 || /already|paid|processed/.test(text.toLowerCase())) {
try {
const bookingResp = await apiFetch(`/api/bookings/${bookingId}`);
if (bookingResp.ok) {
const serverBooking = await bookingResp.json();
// Immutable update — spread, never mutate (see audit note above).
confirmedBooking = {
...confirmedBooking,
status: serverBooking.status ?? confirmedBooking.status,
deposit_paid: serverBooking.deposit_paid ?? confirmedBooking.deposit_paid,
deposit_amount: serverBooking.deposit_amount ?? confirmedBooking.deposit_amount,
amount_paid: serverBooking.amount_paid ?? confirmedBooking.amount_paid,
amount_due: serverBooking.amount_due ?? confirmedBooking.amount_due,
payments: serverBooking.payments ?? confirmedBooking.payments,
total_amount: serverBooking.total_amount ?? confirmedBooking.total_amount
};
depositPaid = confirmedBooking.deposit_paid;
toast.success('Payment successful!');
} else {
toast.warning(
extractErrorMessage(text) ||
'Payment failed — you can pay again from your booking details.'
);
}
} catch {
toast.warning(
extractErrorMessage(text) ||
'Payment failed — you can pay again from your booking details.'
);
}
} else {
toast.warning(
extractErrorMessage(text) || 'Payment failed — you can pay again from your booking details.'
);
}
// A definitive charge failure (declined card, any 4xx) consumes the
// nonce + SCA verification token (Square nonces are single-use) —
// clear the cached pair so a retry re-tokenizes fresh instead of
// resubmitting a spent nonce for up to 240s. The idempotency key
// stays so a lost-response retry still dedups against the original
// charge (matches the TipPayment pattern).
depositNonce = '';
depositVerificationToken = '';
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
}
let paymentAttempted = $state(false);
// Pre-start overpayment confirmation (mirrors UserPaymentModal). The backend
// rejects a deposit payment that exceeds the booking's remaining balance
// before the appointment has started unless the request carries
// `confirm_overflow_tip: true` (a tip is gratuity for service already
// rendered). This fires on STALE booking data where the user would otherwise
// be stuck with an unresolvable 400. The rejected request body (including
// the cached nonce / SCA token / idempotency key) is parked here and a
// Confirm/Cancel prompt is shown; Confirm resends the SAME body with the
// flag, Cancel returns to the amount-editing form.
let overflowConfirm = $state<{
amountPence: number;
overflowPence: number;
depositAmount: number;
body: Record<string, unknown>;
} | null>(null);
function cancelOverflowConfirmation() {
overflowConfirm = null;
isProcessingPayment = false;
isProcessingPaymentSync = false;
}
// Confirm the pre-start overpayment: resend the SAME rejected request with
// confirm_overflow_tip: true so the excess is recorded as a tip.
async function confirmOverflowPayment() {
const pending = overflowConfirm;
if (!pending || isProcessingPayment) return;
isProcessingPayment = true;
isProcessingPaymentSync = true;
try {
await submitDepositPayment({
body: pending.body,
amountPence: pending.amountPence,
depositAmount: pending.depositAmount,
confirmOverflowTip: true
});
} finally {
isProcessingPayment = false;
isProcessingPaymentSync = 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.
@@ -2458,33 +2552,93 @@
</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<BookingSummary
services={selectedServices}
date={selectedDate}
time={selectedTime}
customer={authStore.isAuthenticated
? {
firstName: authStore.currentUser?.firstName ?? '',
lastName: authStore.currentUser?.lastName ?? '',
email: authStore.currentUser?.email ?? '',
phone: authStore.currentUser?.phone ?? '',
specialRequests: customerInfo.specialRequests
}
: customerInfo}
showCustomer={true}
/>
{#if overflowConfirm}
<!-- Pre-start overpayment confirmation: the backend rejected the
payment because the booking's remaining balance has changed
since it was loaded (stale data). The excess over the
remaining balance will be recorded as a tip once confirmed. -->
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div class="flex items-start gap-2.5">
<svg
class="mt-0.5 h-5 w-5 shrink-0 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 16v-4M12 8h.01" />
<circle cx="12" cy="12" r="10" />
</svg>
<div>
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
<p class="mt-1 text-sm text-amber-800">
The balance for this booking has changed since it was last loaded. The extra £{(
overflowConfirm.overflowPence / 100
).toFixed(2)} will be recorded as a tip. Confirm to continue?
</p>
</div>
</div>
<div class="mt-4 flex gap-2">
<Button
class="flex-1"
loading={isProcessingPayment}
disabled={isProcessingPayment}
onclick={confirmOverflowPayment}
>
Confirm
</Button>
<Button
variant="outline"
class="flex-1"
disabled={isProcessingPayment}
onclick={cancelOverflowConfirmation}
>
Cancel
</Button>
</div>
</div>
{:else}
<BookingSummary
services={selectedServices}
date={selectedDate}
time={selectedTime}
customer={authStore.isAuthenticated
? {
firstName: authStore.currentUser?.firstName ?? '',
lastName: authStore.currentUser?.lastName ?? '',
email: authStore.currentUser?.email ?? '',
phone: authStore.currentUser?.phone ?? '',
specialRequests: customerInfo.specialRequests
}
: customerInfo}
showCustomer={true}
/>
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
{#if authStore.isAuthenticated}
{#if paymentMethodsLoading}
<div class="mb-6 py-4 text-center text-gray-500">Loading payment methods...</div>
{#if authStore.isAuthenticated}
{#if paymentMethodsLoading}
<div class="mb-6 py-4 text-center text-gray-500">
Loading payment methods...
</div>
{:else}
<div class="mb-6">
<CardSelection
bind:this={paymentCardSelection}
cards={paymentMethods}
{canSaveCards}
bind:selectedCardId={selectedPaymentMethod}
bind:saveCard={depositSaveCard}
onValidityChange={(v) => (paymentCardSelectionValid = v)}
/>
</div>
{/if}
{:else}
<div class="mb-6">
<CardSelection
bind:this={paymentCardSelection}
cards={paymentMethods}
cards={[]}
{canSaveCards}
bind:selectedCardId={selectedPaymentMethod}
bind:saveCard={depositSaveCard}
@@ -2492,40 +2646,27 @@
/>
</div>
{/if}
{:else}
<div class="mb-6">
<CardSelection
bind:this={paymentCardSelection}
cards={[]}
{canSaveCards}
bind:selectedCardId={selectedPaymentMethod}
bind:saveCard={depositSaveCard}
onValidityChange={(v) => (paymentCardSelectionValid = v)}
/>
<div class="flex items-center justify-between border-t pt-4">
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
Back
</Button>
<Button
disabled={isProcessingPayment || !depositCardFormValid}
onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground"
>
{isProcessingPayment
? 'Processing...'
: `Pay Deposit £${calculateDepositAmount()}`}
</Button>
</div>
{/if}
<div class="flex items-center justify-between border-t pt-4">
<Button
variant="ghost"
onclick={prevStep}
disabled={isProcessingPayment}
>
Back
</Button>
<Button
disabled={isProcessingPayment || !depositCardFormValid}
onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground"
>
{isProcessingPayment
? 'Processing...'
: `Pay Deposit £${calculateDepositAmount()}`}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">
Secure payment powered by Square
</p>
</div>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}