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
@@ -50,6 +50,9 @@
let refundReason = $state('');
let refundLoading = $state(false);
let refundIdempotencyKey = $state('');
// Pence of the selected payment already returned via completed refunds —
// the refund amount is pre-filled with the residual (amount this).
let refundAlreadyRefundedPence = $state(0);
$effect(() => {
if (open && bookingId) {
@@ -342,15 +345,42 @@
}
}
function openRefundModal(paymentId: string, amountPence: number) {
refundPaymentId = paymentId;
refundAmount = (amountPence / 100).toFixed(2);
async function openRefundModal(payment: Payment) {
refundPaymentId = payment.id;
refundAmount = (payment.amount / 100).toFixed(2);
refundAlreadyRefundedPence = 0;
refundReason = '';
// Unique per refund attempt so two equal partial refunds of the same
// payment don't collide on the backend's amount-derived key; reused on
// retry (the backend dedups on it) so a timeout can't double-refund.
refundIdempotencyKey = crypto.randomUUID();
showRefundModal = true;
// Pre-fill the refund with the RESIDUAL (payment.amount already
// refunded) and surface the already-refunded total, so a partially
// refunded payment doesn't look fully refundable. The admin booking
// detail doesn't include refunds, so fetch the payment summary.
try {
const res = await apiFetch(`/api/bookings/${bookingId}/payment-summary`);
if (res.ok) {
const data = await res.json();
const alreadyRefunded = (
(data.refunds ?? []) as Array<{
payment_id: string;
amount: number;
status: string;
}>
)
.filter((r) => r.payment_id === payment.id && r.status === 'completed')
.reduce((sum, r) => sum + r.amount, 0);
if (alreadyRefunded > 0) {
refundAlreadyRefundedPence = alreadyRefunded;
refundAmount = (Math.max(0, payment.amount - alreadyRefunded) / 100).toFixed(2);
}
}
} catch {
// Non-fatal — the modal stays open with the full amount pre-filled.
}
}
async function processRefund() {
@@ -611,7 +641,7 @@
size="sm"
variant="outline"
class="text-red-600 hover:bg-red-50 hover:text-red-700"
onclick={() => openRefundModal(payment.id, payment.amount)}
onclick={() => openRefundModal(payment)}
>
Refund
</Button>
@@ -837,6 +867,12 @@
placeholder="0.00"
/>
</div>
{#if refundAlreadyRefundedPence > 0}
<p class="mt-1 text-xs text-gray-500">
Already refunded: £{(refundAlreadyRefundedPence / 100).toFixed(2)} — the amount above is the
remaining balance.
</p>
{/if}
</div>
<div>
@@ -10,6 +10,7 @@
import * as Modal from '$lib/components/ui/dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import { isSquareConfigured, submitPaymentWithRetry } from '$lib/square/square';
import { range } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
@@ -37,7 +38,6 @@
email: string;
balance: number;
updated_at: string;
/* TODO: add previousFirstName/previousLastName when backend sends them */
}
interface GiftCardSummary {
@@ -167,6 +167,12 @@
let onlineSquareCardReady = $state(false);
let onlineSquareCardInput = $state<SquareCardInput | null>(null);
let onlineSquareProcessing = $state(false);
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
// on the next microtask), so the reactive `onlineSquareProcessing` may not
// propagate to the button's `disabled`/`loading` bindings before a fast
// second click fires. This non-reactive flag is checked synchronously at
// the start of the handler.
let onlineSquareProcessingSync = false;
// Idempotency: the client deliberately sends NO idempotency_key. The backend
// (CreateTillSale) derives a DETERMINISTIC key server-side from the canonical
@@ -309,8 +315,8 @@
toast.success('Balance claimed successfully');
await fetchExpiredBalances();
} else {
const err = await res.json();
toast.error(err.error || 'Failed to claim balance');
const err = await res.text();
toast.error(extractErrorMessage(err) || 'Failed to claim balance');
}
} catch {
toast.error('Network error claiming balance');
@@ -647,7 +653,9 @@
}
async function handleEmbeddedOnlineSquarePayment(actionType: 'create' | 'topup', gcId?: string) {
if (onlineSquareProcessingSync) return;
if (!onlineSquareCardInput) return;
onlineSquareProcessingSync = true;
onlineSquareProcessing = true;
paymentError = '';
try {
@@ -708,7 +716,12 @@
setModalStep(actionType, 'error');
} finally {
onlineSquareProcessing = false;
onlineSquareAction = null;
onlineSquareProcessingSync = false;
// Deliberately keep onlineSquareAction set: the card form stays
// mounted for the payment step, so after a failure the "Try Again"
// button (error step → payment step) returns to the SAME card form
// instead of dropping back to the payment-method grid. It is cleared
// by resetGenerateModal/resetTopUpModal when the step is left.
}
}
@@ -843,7 +856,9 @@
return (a.amount_remaining - b.amount_remaining) * mul;
case 'created':
return (
(parseWallClockDate(a.created_at).getTime() - parseWallClockDate(b.created_at).getTime()) * mul
(parseWallClockDate(a.created_at).getTime() -
parseWallClockDate(b.created_at).getTime()) *
mul
);
case 'status': {
const aVal = a.redeemed_by ? 2 : a.amount_remaining === 0 ? 1 : 0;
@@ -874,7 +889,9 @@
return (a.balance - b.balance) * mul;
case 'updated':
return (
(parseWallClockDate(a.updated_at).getTime() - parseWallClockDate(b.updated_at).getTime()) * mul
(parseWallClockDate(a.updated_at).getTime() -
parseWallClockDate(b.updated_at).getTime()) *
mul
);
default:
return 0;
@@ -1124,11 +1141,7 @@
</Button>
{/if}
{#if gc.cancellable}
<Button
variant="outline"
size="sm"
onclick={() => openCancelCardModal(gc)}
>
<Button variant="outline" size="sm" onclick={() => openCancelCardModal(gc)}>
Cancel & refund
</Button>
{:else if gc.cancellation_reason}
@@ -1341,9 +1354,7 @@
{:else}
{#each sortedBalances as ub (ub.user_id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-medium text-gray-900"
>{ub.name}<!-- TODO: add formerly name when previous name data is available --></td
>
<td class="py-3 font-medium text-gray-900">{ub.name}</td>
<td class="py-3 text-gray-600">{ub.email}</td>
<td class="py-3 font-semibold text-primary">{formatCurrency(ub.balance)}</td>
<td class="py-3 text-gray-600">{formatDate(ub.updated_at)}</td>
@@ -1372,9 +1383,7 @@
{#each sortedBalances as ub (ub.user_id)}
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<span class="font-medium text-gray-900"
>{ub.name}<!-- TODO: add formerly name when previous name data is available --></span
>
<span class="font-medium text-gray-900">{ub.name}</span>
</div>
<div class="grid grid-cols-2 gap-2 border-t border-b py-2 text-xs text-gray-600">
<div class="col-span-2">
@@ -1972,33 +1981,35 @@
</svg>
Cash
</button>
{#if isSquareConfigured()}
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
onclick={() => (onlineSquareAction = 'create')}
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
onclick={() => (onlineSquareAction = 'create')}
>
<svg
class="mx-auto mb-2 h-8 w-8 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<svg
class="mx-auto mb-2 h-8 w-8 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="2" y="5" width="20" height="14" rx="2" />
<line x1="2" y1="10" x2="22" y2="10" />
</svg>
Online Card
</button>
{/if}
<rect x="2" y="5" width="20" height="14" rx="2" />
<line x1="2" y1="10" x2="22" y2="10" />
</svg>
Online Card
</button>
</div>
{#if onlineSquareAction === 'create'}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<SquareCardInput
bind:this={onlineSquareCardInput}
onReady={(r) => (onlineSquareCardReady = r)}
/>
{#if isSquareConfigured()}
<SquareCardInput
bind:this={onlineSquareCardInput}
onReady={(r) => (onlineSquareCardReady = r)}
/>
{:else}
<CardEntryUnavailable />
{/if}
<Button
class="mt-3 w-full"
variant="outline"
@@ -2245,33 +2256,35 @@
</svg>
Cash
</button>
{#if isSquareConfigured()}
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
onclick={() => (onlineSquareAction = 'topup')}
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
onclick={() => (onlineSquareAction = 'topup')}
>
<svg
class="mx-auto mb-2 h-8 w-8 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<svg
class="mx-auto mb-2 h-8 w-8 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="2" y="5" width="20" height="14" rx="2" />
<line x1="2" y1="10" x2="22" y2="10" />
</svg>
Online Card
</button>
{/if}
<rect x="2" y="5" width="20" height="14" rx="2" />
<line x1="2" y1="10" x2="22" y2="10" />
</svg>
Online Card
</button>
</div>
{#if onlineSquareAction === 'topup'}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<SquareCardInput
bind:this={onlineSquareCardInput}
onReady={(r) => (onlineSquareCardReady = r)}
/>
{#if isSquareConfigured()}
<SquareCardInput
bind:this={onlineSquareCardInput}
onReady={(r) => (onlineSquareCardReady = r)}
/>
{:else}
<CardEntryUnavailable />
{/if}
<Button
class="mt-3 w-full"
variant="outline"
@@ -2447,7 +2460,8 @@
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p class="font-semibold">14-day statutory cancellation right</p>
<p class="mt-1 text-xs text-amber-700">
The unspent balance will be refunded to the original payment method. This action cannot be undone.
The unspent balance will be refunded to the original payment method. This action cannot be
undone.
</p>
</div>
@@ -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}
@@ -45,9 +45,7 @@
// selection and save-for-later are blocked. The new-card (nonce) path has
// its own SCA via Square tokenizeWithVerification, so only the saved-card
// list and the save toggle are gated here.
const twoFactorBlocksSavedCards = $derived(
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
);
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
// Auto-select the default saved card when cards first load. Guarded by
// !showNewCardForm so the "Use a new card" click (selectedCardId = '') is
@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Dialog from '$lib/components/ui/dialog';
@@ -7,7 +8,13 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
import { apiFetch } from '$lib/utils/api';
import { submitPaymentWithRetry } from '$lib/square/square';
import {
campaignDiscountPence,
isSavedCardVerificationRequired,
sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
} from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import { resolve } from '$app/paths';
@@ -59,9 +66,7 @@
// PSD2 SCA stand-in: 2FA required but not enabled blocks charging a
// customer's saved card online (the admin's own 2FA status gates it). The
// card-machine and new-card paths have their own SCA.
const twoFactorBlocksSavedCards = $derived(
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
);
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
let useLoyalty = $state(false);
@@ -77,18 +82,21 @@
useLoyalty ? Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) : 0
);
// Campaign discount preview — fetched on mount, mirroring the customer flow
// (UserPaymentModal). The backend AUTO-APPLIES eligible campaigns at payment
// /completion, so the admin modal must show and charge the DISCOUNTED amount:
// charging the pre-campaign total would over-credit the ledger (the backend
// records the full payment AND the discount rows). `netTotal` therefore
// subtracts these pence, and every charge handler derives from it.
let discountPreview = $state<{
eligible: boolean;
discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
original_total: number;
discounted_total: number;
} | null>(null);
let customerBalance = $state(0);
let giftCardPaymentAmount = $state('');
let savedCardList = $state<
Array<{
id: string;
brand: string;
last_4: string;
exp_month: number;
exp_year: number;
cardholder_name?: string;
}>
>([]);
async function fetchCustomerGiftCardBalance() {
const targetUserId = booking.user_id ?? booking.user?.id;
if (!targetUserId) return;
@@ -107,19 +115,6 @@
}
}
async function fetchSavedCardList() {
const targetUserId = booking.user_id ?? booking.user?.id;
if (!targetUserId) return;
try {
const res = await apiFetch(`/api/admin/users/${targetUserId}/payment-methods`);
if (res.ok) {
savedCardList = await res.json();
}
} catch {
// ignore
}
}
type ServiceOverride = {
price: string;
originalPrice: number;
@@ -131,7 +126,7 @@
const uid = booking.user_id ?? booking.user?.id;
if (uid) {
fetchCustomerGiftCardBalance();
fetchSavedCardList();
fetchSavedCards();
}
const services = booking.services ?? [];
const overrides: Record<string, ServiceOverride> = {};
@@ -145,17 +140,13 @@
serviceOverrides = overrides;
});
// Single shared sanitizer for all decimal money inputs: strips non-numeric
// characters and keeps only the first decimal point (so "1.2.3" → "1.23").
// Defined once in square.ts and imported here so the payment surfaces can't
// drift.
function handlePriceInput(serviceId: string, value: string) {
const cleaned = value.replace(/[^0-9.]/g, '');
const firstDot = cleaned.indexOf('.');
let sanitized: string;
if (firstDot !== -1) {
const integerPart = cleaned.substring(0, firstDot);
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
sanitized = integerPart + '.' + decimalPart;
} else {
sanitized = cleaned;
}
const sanitized = sanitizeDecimalInput(value);
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
serviceOverrides = {
...serviceOverrides,
@@ -181,16 +172,7 @@
function handleCustomTipInput(e: Event) {
const input = e.target as HTMLInputElement;
const cleaned = input.value.replace(/[^0-9.]/g, '');
const firstDot = cleaned.indexOf('.');
let sanitized: string;
if (firstDot !== -1) {
const integerPart = cleaned.substring(0, firstDot);
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
sanitized = integerPart + '.' + decimalPart;
} else {
sanitized = cleaned;
}
const sanitized = sanitizeDecimalInput(input.value);
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
customTipAmount = sanitized;
}
@@ -213,7 +195,27 @@
const discountSum = $derived(
(booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0)
);
const netTotal = $derived(Math.max(0, subtotal - discountSum));
// Campaign discounts apply automatically at payment/completion server-side,
// so the charge must be the subtotal minus already-applied discounts minus
// the eligible campaign credit — otherwise the customer is overcharged.
//
// NOTE (round-A-4 UX residual, deliberately NOT "fixed"): this ignores
// payments already made against the booking. The admin backend path
// (CreateTerminalPayment) treats the amount it receives as the charge to
// record verbatim — it does NOT compute "remaining due" and subtract prior
// payments server-side — and the booking object handed to this modal (from
// /api/admin/today/current-next, AppointmentInfo) carries no amount_paid /
// amount_due / payments fields to derive them client-side. Subtracting an
// unverifiable prior-paid total would risk under-collecting. When a deposit
// was already paid, charging the full subtotal here is money-safe server-side
// (buildSplitRecords/buildTerminalSplitRecords carve any excess beyond the
// remaining booking value into a payment_type='tip' record, so the ledger
// still closes exactly at the booking total) but the excess lands as an
// UNINTENDED tip. Revisit when the today endpoint exposes the booking's paid
// total: netTotal = max(0, subtotal discountSum campaignDiscountPence amountPaidPence).
const netTotal = $derived(
Math.max(0, subtotal - discountSum - campaignDiscountPence(discountPreview))
);
const tipPercentages = $derived.by(() => {
if (netTotal <= 0) return [];
@@ -408,6 +410,20 @@
};
});
// Fetch the eligible campaign discount preview once on mount. Mirrors the
// customer flow (UserPaymentModal) so the admin modal charges the same
// discounted amount the backend will auto-apply.
onMount(async () => {
try {
const resp = await apiFetch(`/api/bookings/${booking.id}/discount-preview`);
if (resp.ok) {
discountPreview = await resp.json();
}
} catch (_err) {
console.error('Failed to fetch discount preview:', _err);
}
});
let cashAmount = $state<string>('');
const cashAmountNum = $derived(cashAmount === '' ? 0 : parseFloat(cashAmount));
const changeDue = $derived(cashAmountNum > totalDue ? cashAmountNum - totalDue : 0);
@@ -415,16 +431,7 @@
function handleCashInput(e: Event) {
const input = e.target as HTMLInputElement;
const cleaned = input.value.replace(/[^0-9.]/g, '');
const firstDot = cleaned.indexOf('.');
let sanitized: string;
if (firstDot !== -1) {
const integerPart = cleaned.substring(0, firstDot);
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
sanitized = integerPart + '.' + decimalPart;
} else {
sanitized = cleaned;
}
const sanitized = sanitizeDecimalInput(input.value);
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
cashAmount = sanitized;
}
@@ -563,7 +570,7 @@
return;
}
let payAmountCents = Math.round(giftDue * 100);
let payAmountPence = Math.round(giftDue * 100);
if (useAccountBalance) {
const parsedAmt = parseFloat(giftCardPaymentAmount);
if (isNaN(parsedAmt) || parsedAmt <= 0) {
@@ -574,7 +581,7 @@
toast.error('Payment amount exceeds available balance');
return;
}
payAmountCents = Math.round(parsedAmt * 100);
payAmountPence = Math.round(parsedAmt * 100);
}
isProcessingPaymentSync = true;
@@ -590,7 +597,7 @@
payment_method: string;
gift_card_id?: string;
} = {
amount: payAmountCents,
amount: payAmountPence,
payment_type: 'full',
payment_method: 'giftcard'
};
@@ -660,7 +667,7 @@
savedCards = [];
selectedSavedCardId = null;
try {
const res = await apiFetch(`/api/admin/users/${booking.user_id}/payment-methods`);
const res = await apiFetch(`/api/admin/users/${targetUserId}/payment-methods`);
if (res.ok) {
savedCards = await res.json();
}
@@ -710,6 +717,7 @@
status = 'saved-card-processing';
error = null;
let responseStatus = 0;
try {
await applyLoyaltyRedemption();
@@ -728,6 +736,7 @@
);
if (!response.ok) {
responseStatus = response.status;
const errData = await response.text();
throw new Error(extractErrorMessage(errData) || 'Failed to process saved card payment');
}
@@ -752,8 +761,15 @@
onComplete(paymentResult);
} catch (_err) {
status = 'error';
error = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
toast.error(error ?? 'Unknown error');
// Saved-card (ccof) charges skip the client-side SCA step, so a
// definitive 402 on the saved-card path means the issuer still
// requires verification — retrying the same saved card can never
// succeed. Surface the fix instead of the generic backend text.
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
if (isSavedCardVerificationRequired(responseStatus, true))
msg = SAVED_CARD_VERIFICATION_MESSAGE;
error = msg;
toast.error(msg);
} finally {
isProcessingPaymentSync = false;
}
@@ -923,6 +939,19 @@
</p>
{/if}
{#if discountPreview?.eligible && discountPreview.discounts.length > 0}
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
{#each discountPreview.discounts as d (d.name)}
<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
>
</div>
{/each}
</div>
{/if}
{#if tipEnabled}
<div class="rounded-md border border-green-200 bg-green-50 p-3">
<div class="flex justify-between">
@@ -941,7 +970,7 @@
{/if}
<div
class="grid grid-cols-2 gap-3 {savedCardList.length > 0
class="grid grid-cols-2 gap-3 {savedCards.length > 0
? 'sm:grid-cols-4'
: 'sm:grid-cols-3'}"
>
@@ -993,7 +1022,7 @@
</svg>
Cash
</button>
{#if savedCardList.length > 0 && !twoFactorBlocksSavedCards}
{#if savedCards.length > 0 && !twoFactorBlocksSavedCards}
<button
type="button"
disabled={nothingToCharge}
@@ -1049,17 +1078,19 @@
</button>
</div>
{#if twoFactorBlocksSavedCards && savedCardList.length > 0}
{#if twoFactorBlocksSavedCards && savedCards.length > 0}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<p class="text-sm text-amber-800">
Two-factor authentication is required to use online card payments.
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
<a href={resolve('/account')} class="font-medium underline"
>Enable it in your account settings</a
>.
</p>
</div>
{/if}
<div class="flex flex-wrap gap-3 sm:hidden">
{#if savedCardList.length > 0 && !twoFactorBlocksSavedCards}
{#if savedCards.length > 0 && !twoFactorBlocksSavedCards}
<button
type="button"
disabled={nothingToCharge}
@@ -1311,7 +1342,11 @@
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button onclick={handleGiftCardPayment} class="flex-1" disabled={!giftCardValid || nothingToCharge}>
<Button
onclick={handleGiftCardPayment}
class="flex-1"
disabled={!giftCardValid || nothingToCharge}
>
Apply Gift Card
</Button>
</div>
@@ -15,6 +15,7 @@
canSaveCardsForRole,
isNonceStale,
isSavedCardVerificationRequired,
sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
} from '$lib/square/square';
@@ -103,9 +104,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 isCardValid = $derived(cardSelectionValid);
@@ -183,16 +182,7 @@
function handleCustomTipInput(e: Event) {
const input = e.target as HTMLInputElement;
const cleaned = input.value.replace(/[^0-9.]/g, '');
const firstDot = cleaned.indexOf('.');
let sanitized: string;
if (firstDot !== -1) {
const integerPart = cleaned.substring(0, firstDot);
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
sanitized = integerPart + '.' + decimalPart;
} else {
sanitized = cleaned;
}
const sanitized = sanitizeDecimalInput(input.value);
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
customTip = sanitized;
}
@@ -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)
)
)}