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
@@ -7,15 +7,18 @@
import { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import type { Booking } from '$lib/types/booking';
import type { UserSavedCard } from '$lib/types';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
import { apiFetch } from '$lib/utils/api';
import { generateUUID } from '$lib/utils/uuid';
import {
campaignDiscountPence,
isNonceStale,
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
} from '$lib/square/square';
@@ -45,9 +48,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);
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
@@ -86,9 +87,8 @@
payment_type: string;
} | null>(null);
// Card selection state
let paymentMethods = $state<UserSavedCard[]>([]);
let paymentMethodsLoading = $state(false);
// Card selection state — cards and loading live in the shared savedCards
// store so all payment surfaces fetch /api/user/payment-methods identically.
let selectedCardId = $state('');
let cardSelectionValid = $state(false);
let cardSelection = $state<CardSelection | null>(null);
@@ -140,15 +140,17 @@
.reduce((sum, p) => sum + p.amount, 0) || 0
);
const amountRemaining = $derived(booking.total_amount - totalPaid);
// Mirror of the backend's GetBookingRemainingBalanceCents (see
// backend/handlers/payments/service.go): total completed non-tip payments
// + completed refunds, clamped to the booking total and floored at 0. The
// backend rejects an unconfirmed pre-start overpayment when req.Amount >
// this value, and records the excess (req.Amount remainingCents) as a tip
// once confirmed — so the overflow-confirmation prompt shows exactly that.
const remainingBalanceCents = $derived.by(() => {
// Tip-excluding remaining balance in pounds, mirroring the backend's
// GetBookingRemainingBalancePence (see backend/handlers/payments/service.go):
// total completed non-tip payments + completed refunds, clamped to the
// booking total and floored at 0. The backend rejects an unconfirmed
// pre-start overpayment when req.Amount > this value, and records the excess
// (req.Amount remainingPence) as a tip once confirmed — so the
// overflow-confirmation prompt shows exactly that. A completed TIP must not
// reduce what the customer can still pay for the booking itself (gratuity,
// not booking credit), so this deliberately differs from `totalPaid` (which
// includes tips and drives the scenario labels / "Amount Paid" row).
const remainingBalance = $derived.by(() => {
const total = booking.total_amount ?? 0;
const paid = (booking.payments ?? [])
.filter((p) => p.status === 'completed' && p.payment_type !== 'tip')
@@ -156,23 +158,29 @@
const refunded = (booking.refunds ?? [])
.filter((r) => r.status === 'completed')
.reduce((sum, r) => sum + r.amount, 0);
return Math.round(Math.max(0, Math.min(total - paid + refunded, total)) * 100);
return Math.max(0, Math.min(total - paid + refunded, total));
});
const remainingBalancePence = $derived(Math.round(remainingBalance * 100));
// Pre-start overpayment confirmation. The backend rejects a 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. The frontend caps amounts at
// amountRemaining in normal flows, so this fires on STALE booking data
// the remaining balance in normal flows, so this fires on STALE booking data
// (multi-tab, admin-changed totals, refunds that reopened capacity) where
// the user would otherwise be stuck with an unresolvable 400. On the guard
// firing, the rejected request (amount, type, cached card tokens) is parked
// here and a Confirm/Cancel prompt is shown; Confirm resends the SAME
// request with the flag, Cancel returns to the amount-editing form.
let overflowConfirm = $state<{
amountCents: number;
amountPence: number;
paymentType: string;
overflowCents: number;
overflowPence: number;
// Actual amount the backend will charge. For deposits the backend
// charges req.Amount minus the eligible campaign credit (the frontend
// sends deposits RAW), so this can differ from amountPence.
chargePence?: number;
cardId?: string;
newCardToken?: string;
verificationToken?: string;
@@ -216,7 +224,7 @@
partialAmount !== '' &&
!isNaN(partialAmountNum) &&
partialAmountNum > 0 &&
partialAmountNum <= amountRemaining &&
partialAmountNum <= remainingBalance &&
/^\d+(\.\d{0,2})?$/.test(partialAmount)
);
@@ -228,7 +236,7 @@
? 'Invalid amount format'
: partialAmountNum <= 0
? 'Amount must be greater than 0'
: partialAmountNum > amountRemaining
: partialAmountNum > remainingBalance
? 'Amount exceeds balance'
: 'Invalid amount'
: null
@@ -241,12 +249,6 @@
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
);
function campaignDiscountCents(): number {
return discountPreview?.eligible
? discountPreview.discounts.reduce((sum, d) => sum + Math.round(d.amount * 100), 0)
: 0;
}
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
@@ -319,37 +321,12 @@
}, 60000);
}
function generateIdempotencyKey(): string {
const array = new Uint8Array(16);
if (typeof window !== 'undefined' && window.crypto) {
window.crypto.getRandomValues(array);
} else {
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
}
array[6] = (array[6] & 0x0f) | 0x40;
array[8] = (array[8] & 0x3f) | 0x80;
return [...array]
.map((b, i) => {
const hex = b.toString(16).padStart(2, '0');
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
return hex;
})
.join('');
}
async function fetchPaymentMethods() {
// Cached payment methods come from the shared savedCards store (single
// fetch of /api/user/payment-methods), so the account, booking and tip
// surfaces can't drift on the API shape or the loading semantics.
async function loadSavedCards() {
if (!authStore.isAuthenticated) return;
paymentMethodsLoading = true;
try {
const response = await apiFetch('/api/user/payment-methods');
if (response.ok) {
paymentMethods = await response.json();
}
} catch (_err) {
console.error('Failed to fetch payment methods:', _err);
} finally {
paymentMethodsLoading = false;
}
await savedCardsStore.fetch();
}
async function fetchLoyaltyData() {
@@ -365,22 +342,9 @@
}
}
function sanitizeAmountInput(value: string): string {
// Remove all non-numeric chars except .
const cleaned = value.replace(/[^0-9.]/g, '');
// Keep only the first .
const firstDot = cleaned.indexOf('.');
if (firstDot !== -1) {
const integerPart = cleaned.substring(0, firstDot);
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
return integerPart + '.' + decimalPart;
}
return cleaned;
}
function handlePartialAmountInput(e: Event) {
const input = e.target as HTMLInputElement;
const sanitized = sanitizeAmountInput(input.value);
const sanitized = sanitizeDecimalInput(input.value);
// Only update if the sanitized value passes the regex (max 2 decimal places)
if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
partialAmount = sanitized;
@@ -392,7 +356,7 @@
}
}
async function makePayment(paymentType: string, amountCents: number) {
async function makePayment(paymentType: string, amountPence: number) {
status = 'processing';
error = null;
@@ -433,11 +397,11 @@
if (
!newCardNonce ||
newCardTokenizedForSaveCard !== saveCard ||
isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountCents)
isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountPence)
) {
try {
const tokenized = await cardSelection.tokenizeWithVerification(
amountCents,
amountPence,
{
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
@@ -447,7 +411,7 @@
);
newCardNonce = tokenized.nonce;
newCardVerificationToken = tokenized.verificationToken ?? '';
newCardTokenAmount = amountCents;
newCardTokenAmount = amountPence;
newCardTokenizedAt = Date.now();
newCardTokenizedForSaveCard = saveCard;
} catch (_err) {
@@ -477,17 +441,24 @@
const cardKey = cardId ?? 'new-card';
if (
!payIdempotencyKey ||
payKeyedAmount !== amountCents ||
payKeyedAmount !== amountPence ||
payKeyedType !== paymentType ||
payKeyedCard !== cardKey
) {
payIdempotencyKey = generateIdempotencyKey();
payKeyedAmount = amountCents;
payIdempotencyKey = generateUUID();
payKeyedAmount = amountPence;
payKeyedType = paymentType;
payKeyedCard = cardKey;
}
await submitBookingPayment(paymentType, amountCents, cardId, newCardToken, verificationToken, false);
await submitBookingPayment(
paymentType,
amountPence,
cardId,
newCardToken,
verificationToken,
false
);
}
// Submits a booking-payment request and processes the outcome. Shared by
@@ -499,7 +470,7 @@
// the key is still the correct dedup identity for this amount+type+card).
async function submitBookingPayment(
paymentType: string,
amountCents: number,
amountPence: number,
cardId: string | undefined,
newCardToken: string | undefined,
verificationToken: string | undefined,
@@ -512,7 +483,7 @@
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: amountCents,
amount: amountPence,
payment_type: paymentType,
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
...(cardId ? { card_id: cardId } : {}),
@@ -532,10 +503,19 @@
// nonce + SCA verification token + idempotency key are NOT
// cleared here — the confirm resend is the same logical charge.
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(errData)) {
// The backend's overflow guard compares against the DISCOUNTED
// remaining (remaining + eligible campaign credit), and for a
// DEPOSIT it charges req.Amount the campaign credit (the
// frontend sends deposits raw). Both the displayed overflow
// and the amount actually charged must therefore account for
// the eligible campaign discount on the deposit path.
const depositDiscountPence =
paymentType === 'deposit' ? campaignDiscountPence(discountPreview) : 0;
overflowConfirm = {
amountCents,
amountPence,
paymentType,
overflowCents: Math.max(0, amountCents - remainingBalanceCents),
overflowPence: Math.max(0, amountPence - remainingBalancePence - depositDiscountPence),
chargePence: Math.max(0, amountPence - depositDiscountPence),
cardId,
newCardToken,
verificationToken
@@ -567,7 +547,7 @@
payment_type: data.payment_type
};
toast.success('Payment successful');
fetchPaymentMethods();
savedCardsStore.invalidate();
onComplete();
releaseLock();
} catch (_err) {
@@ -605,7 +585,7 @@
error = null;
await submitBookingPayment(
pending.paymentType,
pending.amountCents,
pending.amountPence,
pending.cardId,
pending.newCardToken,
pending.verificationToken,
@@ -623,17 +603,20 @@
}
function handlePayDeposit() {
const depositCents = booking.deposit_amount
const depositPence = booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100);
makePayment('deposit', depositCents);
makePayment('deposit', depositPence);
}
function handlePayFull() {
const fullCents = Math.round(booking.amount_due * 100);
const discountedCents = Math.max(0, fullCents - campaignDiscountCents() - loyaltyDiscount);
const fullPence = Math.round(booking.amount_due * 100);
const discountedPence = Math.max(
0,
fullPence - campaignDiscountPence(discountPreview) - loyaltyDiscount
);
const paymentType = booking.amount_paid > 0 ? 'balance' : 'full';
makePayment(paymentType, discountedCents);
makePayment(paymentType, discountedPence);
}
function handlePayPartial() {
@@ -649,10 +632,10 @@
onClose();
}
// Fetch payment methods on mount if authenticated
// Fetch payment methods + loyalty on mount if authenticated
$effect(() => {
if (authStore.isAuthenticated) {
fetchPaymentMethods();
loadSavedCards();
fetchLoyaltyData();
}
});
@@ -690,7 +673,21 @@
});
</script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Root
open={true}
onOpenChange={(open) => {
if (open) return;
// ESC while the overflow-confirm prompt is showing must dismiss the
// prompt (back to the amount-editing form) instead of closing the whole
// modal — the payment was rejected by the guard and the user needs to
// confirm or adjust, not lose the flow entirely.
if (overflowConfirm) {
cancelOverflowConfirmation();
return;
}
handleClose();
}}
>
<Dialog.Content class="max-w-[calc(100%-2rem)] sm:max-w-md">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
@@ -721,9 +718,18 @@
<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
{formatCurrency(overflowConfirm.overflowCents)} will be recorded as a tip. Confirm
to continue?
{formatCurrency(overflowConfirm.overflowPence)} will be recorded as a tip. Confirm to
continue?
</p>
{#if overflowConfirm.paymentType === 'deposit' && overflowConfirm.chargePence !== undefined}
<p class="mt-2 text-sm font-medium text-amber-800">
An eligible campaign discount of
{formatCurrency(
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
)}
applies — you'll be charged {formatCurrency(overflowConfirm.chargePence)}.
</p>
{/if}
</div>
</div>
<div class="mt-4 flex gap-2">
@@ -731,6 +737,7 @@
class="flex-1"
loading={status === 'processing'}
disabled={status === 'processing'}
autofocus
onclick={confirmOverflowPayment}
>
Confirm
@@ -919,7 +926,7 @@
{formatCurrency(
Math.max(
0,
Math.round(amountRemaining * 100) - campaignDiscountCents() - loyaltyDiscount
remainingBalancePence - campaignDiscountPence(discountPreview) - loyaltyDiscount
)
)}
</span>
@@ -937,12 +944,12 @@
"Card entry failed". Staying mounted keeps both alive for the
full duration of makePayment. -->
{#if authStore.isAuthenticated}
{#if paymentMethodsLoading}
{#if savedCardsStore.loading}
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
{:else}
<CardSelection
bind:this={cardSelection}
cards={paymentMethods}
cards={savedCardsStore.cards}
{canSaveCards}
bind:selectedCardId
bind:saveCard
@@ -1008,7 +1015,7 @@
Math.max(
0,
Math.round(booking.amount_due * 100) -
campaignDiscountCents() -
campaignDiscountPence(discountPreview) -
(useLoyalty ? loyaltyDiscount : 0)
)
)}
@@ -1087,7 +1094,7 @@
Math.max(
0,
Math.round(booking.amount_due * 100) -
campaignDiscountCents() -
campaignDiscountPence(discountPreview) -
(useLoyalty ? loyaltyDiscount : 0)
)
)}