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)
)
)}
+60 -9
View File
@@ -2,15 +2,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
NONCE_STALENESS_MS,
OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE,
PAYMENT_AMBIGUOUS_STATUS,
PAYMENT_DEFINITIVE_STATUS,
SAVED_CARD_VERIFICATION_MESSAGE,
campaignDiscountPence,
canSaveCardsForRole,
isAmbiguousPaymentFailure,
isNonceStale,
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
sanitizeDecimalInput,
submitPaymentWithRetry
} from './square';
import type * as SquareModule from './square';
@@ -121,6 +120,56 @@ describe('isSquareMock / isSquareConfigured / getSquareConfig', () => {
});
});
describe('sanitizeDecimalInput', () => {
it.each([
['', ''],
['0', '0'],
['12.34', '12.34'],
['1.2.3', '1.23'],
['£50', '50'],
['1,234.56', '1234.56'],
['abc', ''],
['..', '.'],
['1.', '1.']
])('sanitizes %s → %s', (input, expected) => {
expect(sanitizeDecimalInput(input)).toBe(expected);
});
});
describe('campaignDiscountPence', () => {
const base = {
eligible: true,
discounts: [
{ source: 'campaign', name: '10% Off', percent: 10, amount: 5 },
{ source: 'campaign', name: 'Referral', percent: 5, amount: 2.5 }
],
original_total: 50,
discounted_total: 42.5
};
it('sums eligible discount amounts in pence', () => {
expect(campaignDiscountPence(base)).toBe(750);
});
it('rounds each discount amount to pence before summing', () => {
expect(campaignDiscountPence({ ...base, discounts: [{ ...base.discounts[0], amount: 5.005 }] })).toBe(
501
);
});
it('is 0 when no preview', () => {
expect(campaignDiscountPence(null)).toBe(0);
});
it('is 0 when the preview is not eligible', () => {
expect(campaignDiscountPence({ ...base, eligible: false })).toBe(0);
});
it('is 0 for an empty discount list', () => {
expect(campaignDiscountPence({ ...base, discounts: [] })).toBe(0);
});
});
describe('canSaveCardsForRole', () => {
it.each([
['admin', true],
@@ -137,12 +186,12 @@ describe('canSaveCardsForRole', () => {
});
describe('payment failure classification', () => {
it('PAYMENT_DEFINITIVE_STATUS is 402', () => {
expect(PAYMENT_DEFINITIVE_STATUS).toBe(402);
it('a definitive 402 on a saved-card charge is an issuer verification failure', () => {
expect(isSavedCardVerificationRequired(402, true)).toBe(true);
});
it('PAYMENT_AMBIGUOUS_STATUS is 503', () => {
expect(PAYMENT_AMBIGUOUS_STATUS).toBe(503);
it('a 402 on a new-card charge is a plain decline, not a verification failure', () => {
expect(isSavedCardVerificationRequired(402, false)).toBe(false);
});
it('isAmbiguousPaymentFailure matches only 503', () => {
@@ -172,14 +221,16 @@ describe('isOverflowTipConfirmationRequired', () => {
it('matches the backend overflow-guard error body by its code', () => {
const body = JSON.stringify({
error: 'The extra amount will be recorded as a tip. Confirm to continue.',
code: OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE
code: 'overflow_tip_confirmation_required'
});
expect(isOverflowTipConfirmationRequired(body)).toBe(true);
});
it('is false for a 400 body with a different code', () => {
expect(
isOverflowTipConfirmationRequired(JSON.stringify({ error: 'Bad amount', code: 'invalid_amount' }))
isOverflowTipConfirmationRequired(
JSON.stringify({ error: 'Bad amount', code: 'invalid_amount' })
)
).toBe(false);
});
+44 -4
View File
@@ -33,6 +33,46 @@ export function canSaveCardsForRole(role: string | undefined): boolean {
return role === 'verified_email' || role === 'admin';
}
/**
* Sanitizes a decimal-money text input: strips every non-numeric character
* except the decimal point and keeps only the FIRST dot (so "£1.2.3" "1.23").
* The caller still validates the result against /^\d+(\.\d{0,2})?$/ when a
* max-two-decimal rule applies this only normalizes what the user typed.
* Shared by every decimal money input (booking partials, admin service
* overrides, tips, cash amounts) so the sanitizer can't drift between them.
*/
export function sanitizeDecimalInput(value: string): string {
const cleaned = value.replace(/[^0-9.]/g, '');
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;
}
/** Shape of the `/api/bookings/{id}/discount-preview` response, as consumed by
* the payment modals when computing the eligible campaign credit. */
export interface DiscountPreview {
eligible: boolean;
discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
original_total: number;
discounted_total: number;
}
/**
* Total eligible campaign-discount credit in pence. The backend auto-applies
* eligible campaigns at payment/completion, so the modals must charge the
* DISCOUNTED amount sharing the computation keeps the customer and admin
* modals from drifting on how the preview is reduced to pence.
*/
export function campaignDiscountPence(discountPreview: DiscountPreview | null): number {
return discountPreview?.eligible
? discountPreview.discounts.reduce((sum, d) => sum + Math.round(d.amount * 100), 0)
: 0;
}
/** True when a cached card nonce can no longer be reused: it was tokenized for a
* different amount than `amount`, or it is older than NONCE_STALENESS_MS. */
export function isNonceStale(
@@ -52,8 +92,8 @@ export function isNonceStale(
* - 402 (definitive): card declined, expired, AVS/CVV failure retrying
* with the same inputs can never succeed.
*/
export const PAYMENT_AMBIGUOUS_STATUS = 503;
export const PAYMENT_DEFINITIVE_STATUS = 402;
const PAYMENT_AMBIGUOUS_STATUS = 503;
const PAYMENT_DEFINITIVE_STATUS = 402;
/**
* True when a definitive (402) charge failure on a SAVED CARD should be
@@ -89,9 +129,9 @@ export const SAVED_CARD_VERIFICATION_MESSAGE =
* request with `confirm_overflow_tip: true` on confirm. This fires mainly on
* stale booking data (multi-tab, admin-changed totals, refunds that reopened
* capacity), so the response body carries no amount the caller computes the
* overflow as `req.Amount - remainingCents` from its booking data.
* overflow as `req.Amount - remainingPence` from its booking data.
*/
export const OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE = 'overflow_tip_confirmation_required';
const OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE = 'overflow_tip_confirmation_required';
/**
* True when an API error body is the backend's overflow-tip confirmation guard
+10
View File
@@ -60,6 +60,16 @@ class AuthStore {
return this.user;
}
// PSD2 SCA stand-in: 2FA required but not yet enabled blocks saved-card
// use (charging a saved card, selecting one as default) and saving new
// cards for reuse. The new-card (nonce) path has its own SCA via Square
// tokenizeWithVerification, so only the saved-card surfaces are gated.
// Single source of truth so the predicate can't drift between the booking,
// account, tip and admin payment surfaces.
get twoFactorBlocksSavedCards() {
return !!this.user?.twoFactorRequired && !this.user?.twoFactorEnabled;
}
get currentToken() {
return this.token;
}
+256 -239
View File
@@ -8,7 +8,14 @@
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import { canSaveCardsForRole, isNonceStale, isSquareConfigured, submitPaymentWithRetry } from '$lib/square/square';
import {
canSaveCardsForRole,
isNonceStale,
isSavedCardVerificationRequired,
isSquareConfigured,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
} from '$lib/square/square';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
@@ -387,120 +394,132 @@
if (buySelectedCard) {
// saved card — nothing to tokenize
} else if (buyCardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
// verification token on retry (tokenization is one-shot; the backend
// idempotency key dedups).
if (
!buyNonce ||
buyTokenizedForSaveCard !== buySaveCard ||
isNonceStale(buyTokenizedAt, buyTokenAmount, buyAmount * 100)
) {
try {
const tokenized = await buyCardSelection.tokenizeWithVerification(
buyAmount * 100,
{
givenName: userData?.firstName,
familyName: userData?.lastName,
email: userData?.email
},
buySaveCard
);
buyNonce = tokenized.nonce;
buyVerificationToken = tokenized.verificationToken ?? '';
buyTokenAmount = buyAmount * 100;
buyTokenizedAt = Date.now();
buyTokenizedForSaveCard = buySaveCard;
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
buyingGiftCard = false;
return;
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
// verification token on retry (tokenization is one-shot; the backend
// idempotency key dedups).
if (
!buyNonce ||
buyTokenizedForSaveCard !== buySaveCard ||
isNonceStale(buyTokenizedAt, buyTokenAmount, buyAmount * 100)
) {
try {
const tokenized = await buyCardSelection.tokenizeWithVerification(
buyAmount * 100,
{
givenName: userData?.firstName,
familyName: userData?.lastName,
email: userData?.email
},
buySaveCard
);
buyNonce = tokenized.nonce;
buyVerificationToken = tokenized.verificationToken ?? '';
buyTokenAmount = buyAmount * 100;
buyTokenizedAt = Date.now();
buyTokenizedForSaveCard = buySaveCard;
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
buyingGiftCard = false;
return;
}
}
}
newCardToken = buyNonce;
verificationToken = buyVerificationToken || undefined;
} else {
toast.error('Please select a payment method');
buyingGiftCard = false;
return;
}
buyingGiftCard = true;
try {
const cardId = buySelectedCard;
// Cache the idempotency key per amount+card so a lost-response retry
// reuses the same key (backend dedups) instead of double-charging.
// Regenerate when the amount or card changes.
// Cache the idempotency key per amount+card so a lost-response
// retry reuses it (backend dedups) instead of double-charging. The
// new-card identity is a STABLE sentinel, NOT the cnon: nonce: the
// nonce is one-shot and cleared on a failed charge, so keying on it
// would regenerate the key on retry and a network-timeout retry
// (where the charge actually landed) would double-charge.
const cardKey = cardId || 'new-card';
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
buyIdempotencyKey = generateIdempotencyKey();
buyKeyedAmount = buyAmount;
buyKeyedCard = cardKey;
}
const res = await submitPaymentWithRetry(() =>
apiFetch('/api/user/giftcards/buy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: buyAmount * 100, // cents
recipient_type: buyRecipientType,
recipient_email: buyRecipientEmail,
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
idempotency_key: buyIdempotencyKey
})
})
);
if (res.ok) {
const data = await res.json();
toast.success('Gift card purchased successfully!');
purchaseResultCode = data.code;
// Track confirmed purchases toward the £500/day cap (the backend
// is authoritative; this only feeds the client-side nudge).
buyDailyTotal += buyAmount;
buyIdempotencyKey = '';
buyKeyedAmount = 0;
buyKeyedCard = '';
buyNonce = '';
buyVerificationToken = '';
buyTokenAmount = 0;
buyTokenizedAt = 0;
buyTokenizedForSaveCard = false;
await fetchGiftCardBalance();
newCardToken = buyNonce;
verificationToken = buyVerificationToken || undefined;
} else {
const errText = await res.text();
toast.error(extractErrorMessage(errText) || 'Failed to purchase gift card');
// A definitive charge failure (e.g. declined card) consumes the
// nonce + SCA verification token — clear the cached pair so the
// next retry re-tokenizes fresh. The idempotency key stays for
// network-timeout dedup.
toast.error('Please select a payment method');
buyingGiftCard = false;
return;
}
buyingGiftCard = true;
try {
const cardId = buySelectedCard;
// Cache the idempotency key per amount+card so a lost-response retry
// reuses the same key (backend dedups) instead of double-charging.
// Regenerate when the amount or card changes.
// Cache the idempotency key per amount+card so a lost-response
// retry reuses it (backend dedups) instead of double-charging. The
// new-card identity is a STABLE sentinel, NOT the cnon: nonce: the
// nonce is one-shot and cleared on a failed charge, so keying on it
// would regenerate the key on retry and a network-timeout retry
// (where the charge actually landed) would double-charge.
const cardKey = cardId || 'new-card';
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
buyIdempotencyKey = generateIdempotencyKey();
buyKeyedAmount = buyAmount;
buyKeyedCard = cardKey;
}
const res = await submitPaymentWithRetry(() =>
apiFetch('/api/user/giftcards/buy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: buyAmount * 100, // cents
recipient_type: buyRecipientType,
recipient_email: buyRecipientEmail,
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
idempotency_key: buyIdempotencyKey
})
})
);
if (res.ok) {
const data = await res.json();
toast.success('Gift card purchased successfully!');
purchaseResultCode = data.code;
// Track confirmed purchases toward the £500/day cap (the backend
// is authoritative; this only feeds the client-side nudge).
buyDailyTotal += buyAmount;
buyIdempotencyKey = '';
buyKeyedAmount = 0;
buyKeyedCard = '';
buyNonce = '';
buyVerificationToken = '';
buyTokenAmount = 0;
buyTokenizedAt = 0;
buyTokenizedForSaveCard = false;
await fetchGiftCardBalance();
} else {
// Capture the status BEFORE consuming the body — the saved-card
// SCA check needs it, and text() can only be read once.
const status = res.status;
const errText = await res.text();
// A saved-card (ccof) charge skips the client-side SCA step, so a
// definitive 402 on the saved-card path means the issuer still
// requires verification — surface the fix instead of the generic
// backend text.
const verificationRequired = isSavedCardVerificationRequired(status, !!buySelectedCard);
toast.error(
verificationRequired
? SAVED_CARD_VERIFICATION_MESSAGE
: extractErrorMessage(errText) || 'Failed to purchase gift card'
);
// A definitive charge failure (e.g. declined card) consumes the
// nonce + SCA verification token — clear the cached pair so the
// next retry re-tokenizes fresh. The idempotency key stays for
// network-timeout dedup.
buyNonce = '';
buyVerificationToken = '';
buyTokenAmount = 0;
buyTokenizedAt = 0;
buyTokenizedForSaveCard = false;
}
} catch (err) {
console.error('buyGiftCard error:', err);
toast.error('Network error');
// Same for thrown errors (network / malformed response): a retry must
// re-tokenize fresh rather than resubmit a consumed nonce.
buyNonce = '';
buyVerificationToken = '';
buyTokenAmount = 0;
buyTokenizedAt = 0;
buyTokenizedForSaveCard = false;
} finally {
buyingGiftCard = false;
}
} catch (err) {
console.error('buyGiftCard error:', err);
toast.error('Network error');
// Same for thrown errors (network / malformed response): a retry must
// re-tokenize fresh rather than resubmit a consumed nonce.
buyNonce = '';
buyVerificationToken = '';
buyTokenAmount = 0;
buyTokenizedAt = 0;
} finally {
buyingGiftCard = false;
}
} finally {
isBuyingSync = false;
}
@@ -2615,9 +2634,9 @@
class="flex flex-col gap-2 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<div class="font-mono text-sm font-bold text-gray-900"
>{formatCardCode(gc.code)}</div
>
<div class="font-mono text-sm font-bold text-gray-900">
{formatCardCode(gc.code)}
</div>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
<span>Value: <strong>{formatCurrency(gc.amount)}</strong></span>
<span>Purchased: {formatShortDate(gc.purchased_at)}</span>
@@ -2834,139 +2853,137 @@
</label>
</div>
</div>
</div>
<Separator />
{/if}
</div>
<Separator />
{/if}
<!-- Two-Factor Authentication (visible to all roles; the notification
<!-- Two-Factor Authentication (visible to all roles; the notification
preferences above are the role-gated part of this area) -->
<div>
<h3 class="mb-2 text-sm font-semibold">Two-Factor Authentication</h3>
<p class="mb-3 text-sm text-gray-600">
Protect online card payments with a one-time verification code
</p>
{#if authStore.currentUser?.twoFactorEnabled}
<div class="mb-3 rounded-lg border p-3">
<div class="text-sm font-medium">
Enabled
{#if authStore.currentUser?.twoFactorMethod}
({authStore.currentUser.twoFactorMethod === 'email' ? 'Email' : 'SMS'})
{/if}
</div>
<div class="mt-1 text-xs text-gray-500">
A verification code is required for online card payments
</div>
</div>
{:else if authStore.currentUser?.twoFactorRequired}
<div
class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"
>
You must enable 2FA to use online card payments.
</div>
{/if}
<div class="space-y-3">
<div class="flex items-center justify-between rounded-lg border p-3">
<div>
<div class="text-sm font-medium">Email</div>
<div class="text-xs text-gray-500">
Receive your verification code by email
</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
checked={selectedTwoFA === 'email'}
onchange={() => {
selectedTwoFA = selectedTwoFA === 'email' ? 'none' : 'email';
}}
/>
<div
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
<div class="flex items-center justify-between rounded-lg border p-3">
<div>
<div class="text-sm font-medium">SMS</div>
<div class="text-xs text-gray-500">
Receive your verification code by text message
</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
checked={selectedTwoFA === 'sms'}
onchange={() => {
selectedTwoFA = selectedTwoFA === 'sms' ? 'none' : 'sms';
}}
/>
<div
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
</div>
{#if twoFASetupPending}
<div class="mt-3 flex items-center gap-2">
<Input
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="one-time-code"
maxlength={6}
placeholder="6-digit code"
bind:value={twoFACode}
/>
<Button disabled={twoFAVerifying} onclick={verifyTwoFASetup}>
{twoFAVerifying ? 'Verifying...' : 'Verify'}
</Button>
</div>
{/if}
{#if showDisableCodeEntry}
<div class="mt-3">
<p class="mb-2 text-sm text-gray-600">
Enter the verification code to disable two-factor authentication.
<div>
<h3 class="mb-2 text-sm font-semibold">Two-Factor Authentication</h3>
<p class="mb-3 text-sm text-gray-600">
Protect online card payments with a one-time verification code
</p>
<div class="flex items-center gap-2">
<Input
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="one-time-code"
maxlength={6}
placeholder="6-digit code"
bind:value={twoFADisableCode}
disabled={twoFADisableConfirming}
/>
<Button disabled={twoFADisableConfirming} onclick={confirmDisableWithCode}>
{twoFADisableConfirming ? 'Disabling...' : 'Confirm Disable'}
</Button>
{#if authStore.currentUser?.twoFactorEnabled}
<div class="mb-3 rounded-lg border p-3">
<div class="text-sm font-medium">
Enabled
{#if authStore.currentUser?.twoFactorMethod}
({authStore.currentUser.twoFactorMethod === 'email' ? 'Email' : 'SMS'})
{/if}
</div>
<div class="mt-1 text-xs text-gray-500">
A verification code is required for online card payments
</div>
</div>
{:else if authStore.currentUser?.twoFactorRequired}
<div
class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"
>
You must enable 2FA to use online card payments.
</div>
{/if}
<div class="space-y-3">
<div class="flex items-center justify-between rounded-lg border p-3">
<div>
<div class="text-sm font-medium">Email</div>
<div class="text-xs text-gray-500">Receive your verification code by email</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
checked={selectedTwoFA === 'email'}
onchange={() => {
selectedTwoFA = selectedTwoFA === 'email' ? 'none' : 'email';
}}
/>
<div
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
<div class="flex items-center justify-between rounded-lg border p-3">
<div>
<div class="text-sm font-medium">SMS</div>
<div class="text-xs text-gray-500">
Receive your verification code by text message
</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
checked={selectedTwoFA === 'sms'}
onchange={() => {
selectedTwoFA = selectedTwoFA === 'sms' ? 'none' : 'sms';
}}
/>
<div
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
></div>
</label>
</div>
</div>
{#if twoFASetupPending}
<div class="mt-3 flex items-center gap-2">
<Input
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="one-time-code"
maxlength={6}
placeholder="6-digit code"
bind:value={twoFACode}
/>
<Button disabled={twoFAVerifying} onclick={verifyTwoFASetup}>
{twoFAVerifying ? 'Verifying...' : 'Verify'}
</Button>
</div>
{/if}
{#if showDisableCodeEntry}
<div class="mt-3">
<p class="mb-2 text-sm text-gray-600">
Enter the verification code to disable two-factor authentication.
</p>
<div class="flex items-center gap-2">
<Input
type="text"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="one-time-code"
maxlength={6}
placeholder="6-digit code"
bind:value={twoFADisableCode}
disabled={twoFADisableConfirming}
/>
<Button disabled={twoFADisableConfirming} onclick={confirmDisableWithCode}>
{twoFADisableConfirming ? 'Disabling...' : 'Confirm Disable'}
</Button>
</div>
</div>
{/if}
{#if twoFADirty && !twoFASetupPending}
<Button
class="mt-3"
disabled={twoFASettingUp || twoFADisabling}
onclick={applyTwoFA}
>
{twoFASettingUp ? 'Sending...' : 'Apply'}
</Button>
{/if}
</div>
{/if}
{#if twoFADirty && !twoFASetupPending}
<Button
class="mt-3"
disabled={twoFASettingUp || twoFADisabling}
onclick={applyTwoFA}
>
{twoFASettingUp ? 'Sending...' : 'Apply'}
</Button>
{/if}
</div>
<Separator />
<Separator />
<div>
<h3 class="mb-2 text-sm font-semibold">Policies</h3>
<div>
<h3 class="mb-2 text-sm font-semibold">Policies</h3>
<p class="mb-3 text-sm text-gray-600">
View our cancellation, deposit, and no-show policies
</p>
@@ -3341,8 +3358,8 @@
<AlertDialog.Header>
<AlertDialog.Title>Disable two-factor authentication?</AlertDialog.Title>
<AlertDialog.Description>
Disabling two-factor authentication means online card payments will be blocked
while 2FA is required. You can re-enable it at any time.
Disabling two-factor authentication means online card payments will be blocked while 2FA
is required. You can re-enable it at any time.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
+71 -21
View File
@@ -74,7 +74,7 @@
<p class="mt-1">Trading name: Crussell Salon</p>
<p>Registered address: Edinburgh, Scotland</p>
<!-- TODO pre-launch: replace {{SUPPORT_EMAIL}} with the real support address before go-live. -->
<p>Contact email: {"{{SUPPORT_EMAIL}}"}</p>
<p>Contact email: {'{{SUPPORT_EMAIL}}'}</p>
<p>VAT: Not currently registered (threshold &pound;90,000; will register when reached)</p>
</div>
</section>
@@ -97,15 +97,14 @@
<p class="mb-3">
<strong>If your account has a balance:</strong> your balance becomes dormant and is
transferred to our recovery registry. You will receive your
<strong>Account ID</strong> by email (once email delivery is available) and can recover your
balance at any time by providing it.
All other personal data is anonymized.
<strong>Account ID</strong> by email (once email delivery is available) and can recover your balance
at any time by providing it. All other personal data is anonymized.
</p>
<p class="mb-3">
<strong>Warning:</strong> account deletion is permanent. You will lose access to your account,
your booking history, loyalty stamps and referral codes, and your account balance (unless you
retain your Account ID). Treatment and safety notes are retained after deletion in a form
that cannot be traced back to you, and financial records are retained for 7 years (HMRC).
retain your Account ID). Treatment and safety notes are retained after deletion in a form that
cannot be traced back to you, and financial records are retained for 7 years (HMRC).
</p>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.3 Inactive Account Policy</h3>
<p class="mb-2">
@@ -129,20 +128,46 @@
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Bookings &amp; Appointments</h2>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Bookings are subject to availability.</li>
<li>You will receive confirmation on-screen and in your account (via email/SMS once email delivery is available).</li>
<li>
You will receive confirmation on-screen and in your account (via email/SMS once email
delivery is available).
</li>
<li>Some services require a deposit (typically 20&ndash;50% of the service cost).</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Cancellations &amp; rescheduling</p>
<p class="mb-2">
Eligibility for a refund before your service depends on the amount of notice provided prior
to your scheduled appointment time. These thresholds represent a genuine pre-estimate of the
operational costs and loss of business incurred by late cancellations. Refunds apply to <strong
>booking payments only</strong
>, up to 100% of the total booking value.
</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li><strong>Client cancellation:</strong> at least 24 hours before the appointment.</li>
<li><strong>Late cancellation (&lt;24 hours):</strong> deposit may be forfeited.</li>
<li><strong>No-show:</strong> deposit forfeited; may affect future booking eligibility.</li>
<li>
<strong>Notice of more than 72 hours:</strong> you are entitled to a full 100% refund of all
booking payments made.
</li>
<li>
<strong>Notice between 24 and 72 hours:</strong> any booking payments made up to 50% of the
total booking value are treated as a Protected Deposit. This Protected Deposit is retained to
cover the short-notice vacancy, while any balance paid above 50% will be fully refunded.
</li>
<li>
<strong>Notice of less than 24 hours:</strong> all booking payments and deposits are entirely
non-refundable and will be retained. The cancellation will be logged as a missed appointment
history strike.
</li>
<li>
<strong>No-show:</strong> all booking payments and deposits are retained; may affect future
booking eligibility.
</li>
<li><strong>Business cancellation:</strong> full refund or reschedule offered.</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Deposits</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
Deposits are non-refundable if you cancel less than 24 hours before the appointment.
Deposits are retained on cancellation in line with the tiered schedule above (up to 50% of
the booking value between 24 and 72 hours' notice; all payments retained under 24 hours).
</li>
<li>Deposits are applied to your final bill.</li>
<li>If we cancel, the deposit is fully refunded.</li>
@@ -170,7 +195,33 @@
<li>Card payments are processed securely via Square.</li>
<li>We do not store full card details.</li>
<li>
Refunds are processed to the original payment method within 5&ndash;10 business days.
<strong>Refunds are returned to the original payment method where possible:</strong>
<ul class="mt-1 list-disc space-y-1 pl-5">
<li>
<strong>Card payments</strong> (debit/credit card processed online or in-person): refunded
directly back to the original card via Square. Processing times vary by card issuer (typically
3&ndash;10 working days).
</li>
<li>
<strong>Gift card payments</strong>: refunded back to the original gift card (or, if
you paid from your account balance, back to that balance). The gift card&rsquo;s
remaining balance is incremented and is immediately available for use. Expired gift
cards are non-refundable.
</li>
<li>
<strong>Cash payments</strong>: credited to your account balance, available for
immediate use against future bookings or services. Guests (walk-in bookings made
without an account) do not hold a rolling balance, so their cash refunds are arranged
in person at the salon &mdash; please bring your receipt and an admin will process the
refund at the till.
</li>
</ul>
</li>
<li>
If a card refund cannot be processed (e.g. the card is expired or the Square payment
reference is unavailable), we will notify you via your account and arrange collection of
the refund in person at the salon &mdash; please allow at least a day&rsquo;s notice so we
can have cash on hand. You will never be left out of pocket.
</li>
<li>
<strong>Saving a card for next time</strong> stores a tokenised reference with our payment provider,
@@ -219,11 +270,11 @@
balance, no additional VAT is charged (it has already been paid).
</p>
<p class="mb-3">
<strong>Right to cancel:</strong> if you buy a gift card online, you can cancel the purchase
within 14 days for a refund to the original payment method. If the card has been partly used
on salon services, only the unspent balance is refunded and the card is then cancelled. A card
that has been redeemed to an account balance or fully spent cannot be cancelled. See our Gift
Card Terms for the full position.
<strong>Right to cancel:</strong> if you buy a gift card online, you can cancel the purchase within
14 days for a refund to the original payment method. If the card has been partly used on salon
services, only the unspent balance is refunded and the card is then cancelled. A card that has
been redeemed to an account balance or fully spent cannot be cancelled. See our Gift Card Terms
for the full position.
</p>
</section>
@@ -245,14 +296,13 @@
</li>
</ul>
<p class="mb-3">
If you have a consumer dispute that you cannot resolve with us, you can get free,
impartial advice from
If you have a consumer dispute that you cannot resolve with us, you can get free, impartial
advice from
<a
href="https://consumeradvice.scot"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-blue-600 underline hover:text-blue-800"
>consumeradvice.scot</a
class="font-medium text-blue-600 underline hover:text-blue-800">consumeradvice.scot</a
>.
</p>
<p class="text-xs text-gray-500">