Implement full Square payment review fixes + frontend polish

Implement every finding from the deep payment review (P0-P2, minors,
nitpicks), then close the post-implementation re-review items, then
align card-form typography and roll out the Square trust badge.

Backend - Square API alignment:
- tip_settings.allow_tipping nested under device_options (was top-level:
  terminal tips were silently lost in prod)
- CreateCardOnFile now accepts customerID and sends card.customer_id;
  saved-card (ccof:) charges forward square_customer_id as CustomerID
- New SquareClient methods GetPayment, CreateCustomer, CancelCheckout
- SCA verification_token accepted + forwarded in all charge paths
- ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout
  NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/
  ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts
  emails, ForceRefundPending hook

Backend - money safety:
- sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id
  instead of stranding them forever
- SweepStalePendingPayments reconciles at Square before failing (tri-state:
  leave pending on transport error, rescue completed, fail definitively)
- GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution;
  SweepStaleTerminalCheckouts covers terminal_checkouts table
- till gift-card clawback on definitive failure incl. retry path +
  INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT
- cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id))
- customer provisioning (lazy, save-only); one-off/guest mint no customer
- discount preview/apply unified in discounts.go (global-milestone visible
  in preview, N+1 eliminated, redemption counter preserved on failures)
- webhook event_id dedup; refund loop dedup; stale comment fixes
- test-isolation t.Cleanup on committed sweep tests

Frontend:
- SCA tokenizeWithVerification across all charge flows (amount as
  major-units decimal), 5-min token-expiry re-tokenize, verification_token
  in request bodies
- PaymentModal synchronous double-click + zero/negative-amount guards
- till online-card UI wired to /api/admin/till/sale
- policyPopover generalised; new /privacy-policy route; consent checkbox
  copy + Square privacy link
- Square card iframe styled to app typography (Inter 14px, oklch tokens);
  mock form md:text-sm parity
- 'Secure payment powered by Square' badge on all 8 card-payment flows

Schema/docs: terminal_checkouts + square_customer_id + per-user card
constraint in init-script.sql; README migrations; P14 plan + backlog +
Technical Manual updated.

Includes 39 modified/new test files; full backend suite (25 pkgs),
-race on payments+square, and frontend build are green.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent fb21538532
commit 54a5b1024e
45 changed files with 6815 additions and 937 deletions
@@ -154,6 +154,15 @@
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
// of re-tokenizing (the backend idempotency key dedups).
let tipNonce = $state('');
// Cached SCA verification token paired with tipNonce (both one-shot, reused
// together on retry). The verification token is amount-bound, so changing
// the tip invalidates the cached pair.
let tipVerificationToken = $state('');
let tipTokenAmount = $state(0);
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
// verification tokens expire after ~5 minutes, so a stale pair is discarded
// on late retries and re-tokenized instead of rejected by Square.
let tipTokenizedAt = $state(0);
const canSaveCards = $derived(
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
@@ -226,19 +235,35 @@
}
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (tipSelectedCardId) {
// saved card — nothing to tokenize
} else if (tipCardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
if (!tipNonce) {
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
// verification token on retry (tokenization is one-shot; the backend
// idempotency key dedups). The verification token is amount-bound, so
// a changed tip amount forces a fresh tokenization.
if (!tipNonce || tipTokenAmount !== tipAmount || Date.now() - tipTokenizedAt > 240_000) {
try {
tipNonce = await tipCardSelection.tokenize();
const tokenized = await tipCardSelection.tokenizeWithVerification(
Math.round(tipAmount * 100),
{
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
}
);
tipNonce = tokenized.nonce;
tipVerificationToken = tokenized.verificationToken ?? '';
tipTokenAmount = tipAmount;
tipTokenizedAt = Date.now();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
}
}
newCardToken = tipNonce;
verificationToken = tipVerificationToken || undefined;
} else {
toast.error('Please select a payment method');
return;
@@ -255,7 +280,8 @@
amount: Math.round(tipAmount * 100),
idempotency_key: tipIdempotencyKey,
...(tipSelectedCardId ? { card_id: tipSelectedCardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: tipSaveCard } : {})
...(newCardToken ? { new_card_token: newCardToken, save_card: tipSaveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {})
};
const response = await apiFetch(`/api/bookings/${selectedBooking.id}/tip`, {
@@ -271,6 +297,9 @@
tipIdempotencyKey = '';
tipKeyedAmount = 0;
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
showTipModal = false;
fetchBookingDetails();
} catch (err) {
@@ -1110,6 +1139,9 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
tipSelectedCardId = '';
tipSaveCard = false;
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
}
}}
>
@@ -1188,5 +1220,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
{tipProcessing ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
</Button>
</Modal.Footer>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</Modal.Content>
</Modal.Root>
@@ -610,15 +610,27 @@
onlineSquareProcessing = true;
paymentError = '';
try {
const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount);
let token: string;
let verificationToken: string | null;
try {
token = await onlineSquareCardInput.tokenize();
const contact = selectedCustomer
? {
givenName: selectedCustomer.name?.split(' ')[0],
email: selectedCustomer.email
}
: undefined;
const tokenized = await onlineSquareCardInput.tokenizeWithVerification(
Math.round(amt * 100),
contact
);
token = tokenized.nonce;
verificationToken = tokenized.verificationToken;
} catch (err) {
paymentError = err instanceof Error ? err.message : 'Card entry failed';
setModalStep(actionType, 'error');
return;
}
const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount);
const body: Record<string, unknown> = {
item_type: 'gift_card',
action: actionType,
@@ -627,6 +639,7 @@
card_token: token,
idempotency_key: getIdempotencyKey()
};
if (verificationToken) body.verification_token = verificationToken;
if (gcId) body.gift_card_id = gcId;
if (selectedCustomer) body.user_id = selectedCustomer.id;
if (actionType === 'create' && generateType === 'account' && selectedCustomer)
@@ -1909,6 +1922,7 @@
>
{onlineSquareProcessing ? 'Processing...' : 'Pay by Card'}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</div>
{/if}
</div>
@@ -2181,6 +2195,7 @@
>
{onlineSquareProcessing ? 'Processing...' : 'Pay by Card'}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</div>
{/if}
</div>
@@ -3,6 +3,11 @@
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import { generateUUID } from '$lib/utils/uuid';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import { isSquareConfigured } from '$lib/square/square';
type CartItem = {
id: string;
@@ -11,13 +16,35 @@
qty: number;
};
type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square';
const PAYMENT_METHODS: Array<{ key: TillPaymentMethod; label: string }> = [
{ key: 'cash', label: 'Cash' },
{ key: 'card_machine', label: 'Card Machine' },
{ key: 'online_square', label: 'Online Card' }
];
let cart = $state<CartItem[]>([]);
let giftCardAmount = $state('25');
let showGiftCardInput = $state(false);
let paymentMethod = $state<TillPaymentMethod>('cash');
let onlineSquareCardReady = $state(false);
let onlineSquareCardInput = $state<SquareCardInput | null>(null);
let processing = $state(false);
let paymentError = $state<string | null>(null);
// Synchronous double-click guard (see BookingFlow) — Svelte 5 reactivity is
// async, so `processing` may not reach the button before a fast second click.
let isProcessingPaymentSync = false;
const subtotal = $derived(cart.reduce((sum, item) => sum + item.price * item.qty, 0));
const itemCount = $derived(cart.reduce((sum, item) => sum + item.qty, 0));
// The backend till sale API currently only accepts item_type 'gift_card', so
// retail items cannot be charged yet — gate the Charge button to gift-card-only carts.
const hasRetailItems = $derived(cart.some((i) => i.label !== 'Gift Card'));
const canCharge = $derived(cart.length > 0 && !hasRetailItems && subtotal > 0);
function formatCurrency(n: number): string {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n);
}
@@ -52,6 +79,95 @@
})
.filter((i): i is CartItem => i !== null);
}
/** Polls a card-machine checkout until it completes (mirrors the gift-card management flow). */
async function pollTillCheckout(checkoutId: string): Promise<void> {
const maxAttempts = 60;
for (let attempts = 0; attempts < maxAttempts; attempts++) {
await new Promise((r) => setTimeout(r, 2000));
try {
const res = await apiFetch(`/api/admin/till/sale/checkout/${checkoutId}/status`);
if (res.ok) {
const data = await res.json();
if (data.status === 'COMPLETED') return;
}
} catch {
// Keep polling — a transient network error is not fatal.
}
}
throw new Error('Card machine payment timed out. Please check the Square dashboard.');
}
async function chargeCart() {
if (isProcessingPaymentSync) return;
if (cart.length === 0) {
toast.error('Cart is empty');
return;
}
if (hasRetailItems) {
toast.error('Retail items cannot be charged yet — the till API currently supports gift card sales only');
return;
}
isProcessingPaymentSync = true;
processing = true;
paymentError = null;
try {
// One sale per cart line × quantity — each till sale funds its own
// gift card (the backend only accepts item_type 'gift_card').
const saleBodies: Record<string, unknown>[] = [];
for (const item of cart) {
for (let i = 0; i < item.qty; i++) {
const body: Record<string, unknown> = {
item_type: 'gift_card',
action: 'create',
amount: item.price,
payment_method: paymentMethod,
idempotency_key: generateUUID()
};
if (paymentMethod === 'online_square') {
if (!onlineSquareCardInput) {
throw new Error('Card form is not ready — please wait a moment and try again');
}
// SCA verification amount must match the sale amount (pence).
const tokenized = await onlineSquareCardInput.tokenizeWithVerification(
Math.round(item.price * 100)
);
body.card_token = tokenized.nonce;
if (tokenized.verificationToken) {
body.verification_token = tokenized.verificationToken;
}
}
saleBodies.push(body);
}
}
for (const body of saleBodies) {
const res = await apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const errText = await res.text();
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
}
const data = await res.json();
if (paymentMethod === 'card_machine' && data.status === 'pending' && data.checkout_id) {
await pollTillCheckout(data.checkout_id as string);
}
}
toast.success('Sale complete');
cart = [];
} catch (err) {
const msg = err instanceof Error ? err.message : 'Sale failed';
paymentError = msg;
toast.error(msg);
} finally {
isProcessingPaymentSync = false;
processing = false;
}
}
</script>
<div class="rounded-xl border bg-card">
@@ -64,6 +180,7 @@
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Cuticle Oil', 8)}
>
Cuticle Oil - &pound;8
@@ -72,6 +189,7 @@
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Nail Files (Pack)', 5)}
>
Nail Files - &pound;5
@@ -80,6 +198,7 @@
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Hand Cream', 6)}
>
Hand Cream - &pound;6
@@ -88,6 +207,7 @@
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Base Coat', 7)}
>
Base Coat - &pound;7
@@ -96,6 +216,7 @@
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Top Coat', 7)}
>
Top Coat - &pound;7
@@ -112,12 +233,13 @@
inputmode="decimal"
bind:value={giftCardAmount}
class="h-9 pl-5 text-sm"
disabled={processing}
onkeydown={(e) => {
if (e.key === 'Enter') addGiftCard();
}}
/>
</div>
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs"
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs" disabled={processing}
>Add</Button
>
</div>
@@ -126,6 +248,7 @@
variant="outline"
size="sm"
class="w-full justify-start gap-2"
disabled={processing}
onclick={() => (showGiftCardInput = true)}
>
Gift Card
@@ -154,7 +277,8 @@
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent"
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
disabled={processing}
onclick={() => updateQty(item.id, -1)}
>
&minus;
@@ -162,7 +286,8 @@
<span class="w-5 text-center text-sm font-semibold tabular-nums">{item.qty}</span>
<button
type="button"
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent"
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
disabled={processing}
onclick={() => updateQty(item.id, 1)}
>
+
@@ -173,7 +298,8 @@
<button
type="button"
aria-label="Remove item"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-xs text-muted-foreground hover:bg-red-50 hover:text-red-600"
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-xs text-muted-foreground hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
disabled={processing}
onclick={() => removeItem(item.id)}
>
<svg
@@ -200,11 +326,69 @@
<span class="text-lg font-bold tabular-nums">{formatCurrency(subtotal)}</span>
</div>
<Button class="mt-3 w-full" disabled>
Charge {formatCurrency(subtotal)}
<div class="mt-3">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Payment Method</span
>
<div class="mt-2 grid grid-cols-3 gap-2">
{#each PAYMENT_METHODS as m (m.key)}
<button
type="button"
class="rounded-lg border py-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
m.key
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
disabled={processing}
onclick={() => (paymentMethod = m.key)}
>
{m.label}
</button>
{/each}
</div>
</div>
{#if paymentMethod === 'online_square'}
<div class="mt-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
{#if isSquareConfigured()}
<SquareCardInput
bind:this={onlineSquareCardInput}
onReady={(r) => (onlineSquareCardReady = r)}
disabled={processing}
/>
{:else}
<p class="text-xs text-gray-500">
Online card entry is unavailable — Square is not configured.
</p>
{/if}
</div>
{/if}
{#if hasRetailItems}
<p class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
Retail items can&apos;t be charged yet — the till API currently supports gift card sales
only. Remove retail items to complete this sale.
</p>
{/if}
{#if paymentError}
<p class="mt-3 rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-800">
{paymentError}
</p>
{/if}
<Button
class="mt-3 w-full"
onclick={chargeCart}
loading={processing}
disabled={
!canCharge || processing || (paymentMethod === 'online_square' && !onlineSquareCardReady)
}
>
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
<p class="mt-1 text-xs text-muted-foreground">
Payment flow and backend integration coming soon.
Gift card sales are processed through the till; retail items require manual recording for now.
</p>
{/if}
</div>
@@ -103,6 +103,16 @@
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
// of re-tokenizing (the backend idempotency key dedups).
let depositNonce = $state('');
// Cached SCA verification token paired with depositNonce (tokenizeWithVerification
// returns both; both are one-shot and must be reused together on retry). The
// verification token is amount-bound, so a changed deposit amount forces a
// fresh tokenization.
let depositVerificationToken = $state('');
let depositTokenAmount = $state(0);
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
// verification tokens expire after ~5 minutes, so a stale pair is discarded
// on late retries and re-tokenized instead of rejected by Square.
let depositTokenizedAt = $state(0);
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
// on the next microtask), so `isProcessingPayment` may not propagate to the
// button's `disabled` binding before a fast second click fires. This non-
@@ -286,24 +296,6 @@
isProcessingPayment = true;
paymentAttempted = false;
try {
let newCardToken: string | undefined;
if (selectedPaymentMethod) {
// saved card — nothing to tokenize
} else if (paymentCardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
if (!depositNonce) {
try {
depositNonce = await paymentCardSelection.tokenize();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
}
}
newCardToken = depositNonce;
} else {
toast.error('Please select a payment method');
return;
}
await submitAndProceed();
if (!confirmedBooking) {
toast.error('Booking was not created. Please try again.');
@@ -320,6 +312,40 @@
: _amount;
const amountCents = Math.round(depositAmount * 100);
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (selectedPaymentMethod) {
// saved card — nothing to tokenize
} else if (paymentCardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
// verification token on retry (tokenization is one-shot; the
// backend idempotency key dedups). Tokenizing AFTER the booking is
// created so the SCA verification amount matches the exact charge.
// The verification token is amount-bound, so a changed deposit
// amount forces a fresh tokenization.
if (!depositNonce || depositTokenAmount !== amountCents || Date.now() - depositTokenizedAt > 240_000) {
try {
const tokenized = await paymentCardSelection.tokenizeWithVerification(amountCents, {
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
email: customerInfo.email || authStore.currentUser?.email
});
depositNonce = tokenized.nonce;
depositVerificationToken = tokenized.verificationToken ?? '';
depositTokenAmount = amountCents;
depositTokenizedAt = Date.now();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
}
}
newCardToken = depositNonce;
verificationToken = depositVerificationToken || undefined;
} else {
toast.error('Please select a payment method');
return;
}
// Cache the idempotency key per amount+card so a lost-response retry
// reuses it (backend dedups) instead of double-charging.
const cardKey = selectedPaymentMethod || `new:${newCardToken ?? ''}`;
@@ -338,7 +364,8 @@
amount: amountCents,
idempotency_key: depositIdempotencyKey,
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {})
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {})
};
paymentAttempted = true;
@@ -358,6 +385,9 @@
depositKeyedAmount = 0;
depositKeyedCard = '';
depositNonce = '';
depositVerificationToken = '';
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositSaveCard = false;
// Immutable update — avoid mutating the existing object so
// concurrent renders (e.g. a stale fetch) can't observe partial
@@ -2364,6 +2394,8 @@
: `Pay Deposit £${calculateDepositAmount()}`}
</Button>
</div>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</div>
</Card.Content>
</Card.Root>
@@ -2,6 +2,8 @@
import CardBrandIcon from './CardBrandIcon.svelte';
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
import SquareCardInput from './SquareCardInput.svelte';
import type { SquareVerificationContact } from './SquareCardInput.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { isSquareConfigured } from '$lib/square/square';
export interface SelectableCard {
@@ -68,6 +70,22 @@
}
return squareCardInput.tokenize();
}
/**
* Tokenizes the new-card form with SCA verification details (SCA-mandated
* for UK card-not-present charges). Returns the nonce AND the verification
* token, which the caller must send to the backend as `verification_token`
* alongside the nonce so the charge completes.
*/
export async function tokenizeWithVerification(
amount: number,
contact?: SquareVerificationContact
): Promise<{ nonce: string; verificationToken: string | null }> {
if (!newCardMode || !squareCardInput) {
throw new Error('No new card form is open');
}
return squareCardInput.tokenizeWithVerification(amount, contact);
}
</script>
{#if cards.length > 0}
@@ -143,7 +161,10 @@
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary accent-primary"
bind:checked={saveCard}
/>
<span>Save this card for next time</span>
<span>
Save this card securely with our payment provider (Square) for next time.
<PolicyPopover label="privacy policy" href="/privacy-policy" />
</span>
</label>
{/if}
</div>
@@ -35,7 +35,7 @@
const nameId = `mock-card-name-${crypto.randomUUID()}`;
const inputClasses =
'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20';
'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm';
const digits = $derived(cardNumber.replace(/\D/g, ''));
@@ -165,6 +165,37 @@
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
return Promise.resolve(token);
}
/**
* Mirrors SquareCardInput.tokenizeWithVerification() so the dev mock
* exercises the full SCA path (card nonce + verification token) end to end.
* The fake verification token is deterministic and the backend mock accepts
* it alongside the cnon: nonce.
*
* @param amount The amount that WILL be charged, in pence — same pence input
* contract as the real form. The real form serializes this to
* a major-units decimal string ("50.00") on Square's wire; the
* mock only embeds the pence value in the fake token for
* deterministic identification, so no conversion is needed here.
*/
export async function tokenizeWithVerification(
amount: number,
_contact?: {
givenName?: string;
familyName?: string;
email?: string;
}
): Promise<{ nonce: string; verificationToken: string | null }> {
if (!complete) {
throw new Error('Card details are incomplete');
}
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
const prefix = digits.slice(0, 4) || 'test';
return Promise.resolve({
nonce: token,
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}`
});
}
</script>
<div class="space-y-3">
@@ -47,6 +47,11 @@
let checkoutId = $state<string | null>(null);
let paymentResult = $state<PaymentResult | null>(null);
let error = $state<string | null>(null);
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
// on the next microtask), so the reactive `status` may not propagate to the
// button's `disabled` binding before a fast second click fires. This non-
// reactive flag is checked synchronously at the start of every handler.
let isProcessingPaymentSync = false;
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
let useLoyalty = $state(false);
@@ -228,6 +233,11 @@
const totalDue = $derived(tipEnabled ? totalWithTip : netTotal);
// True when there is genuinely nothing to charge — the booking is fully
// covered by discounts (and no tip is being added). Payment entry is
// disabled in that state; the handlers also guard defensively.
const nothingToCharge = $derived(totalDue <= 0);
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
@@ -248,13 +258,21 @@
}
async function handleCardPayment() {
if (isProcessingPaymentSync) return;
const finalAmount = totalDue;
if (isNaN(finalAmount) || finalAmount <= 0) {
toast.error('Please enter a valid amount');
return;
}
// The loyalty redemption is applied on top of totalDue; guard against
// the effective charge being zero or negative.
if (Math.round(finalAmount * 100) - loyaltyDiscount <= 0) {
toast.error('Nothing to charge — the booking is fully covered by discounts');
return;
}
isProcessingPaymentSync = true;
status = 'card-processing';
error = null;
@@ -284,6 +302,8 @@
status = 'error';
error = _err instanceof Error ? _err.message : 'Failed to initiate payment';
toast.error(error ?? 'Unknown error');
} finally {
isProcessingPaymentSync = false;
}
}
@@ -381,8 +401,13 @@
}
async function handleCashPayment() {
if (isProcessingPaymentSync) return;
const cashDue = totalDue - loyaltyDiscount / 100;
if (cashDue <= 0) {
toast.error('Nothing to charge — the booking is fully covered by discounts');
return;
}
if (cashAmountNum < cashDue) {
toast.error('Cash amount must cover the total');
return;
@@ -390,6 +415,7 @@
const tipAmount = extraAsTip ? cashAmountNum - cashDue : 0;
isProcessingPaymentSync = true;
status = 'cash-confirming';
error = null;
@@ -430,6 +456,8 @@
status = 'error';
error = _err instanceof Error ? _err.message : 'Failed to process payment';
toast.error(error ?? 'Unknown error');
} finally {
isProcessingPaymentSync = false;
}
}
@@ -492,6 +520,7 @@
const giftCardValid = $derived(useAccountBalance || giftCardId.replace(/-/g, '').length === 12);
async function handleGiftCardPayment() {
if (isProcessingPaymentSync) return;
if (!giftCardValid) {
toast.error('Please enter a valid 12-character gift card code');
return;
@@ -499,6 +528,11 @@
const giftDue = totalDue - loyaltyDiscount / 100;
if (giftDue <= 0) {
toast.error('Nothing to charge — the booking is fully covered by discounts');
return;
}
let payAmountCents = Math.round(giftDue * 100);
if (useAccountBalance) {
const parsedAmt = parseFloat(giftCardPaymentAmount);
@@ -513,6 +547,7 @@
payAmountCents = Math.round(parsedAmt * 100);
}
isProcessingPaymentSync = true;
status = 'gift-confirming';
error = null;
@@ -557,6 +592,8 @@
status = 'error';
error = _err instanceof Error ? _err.message : 'Failed to process gift card';
toast.error(error ?? 'Unknown error');
} finally {
isProcessingPaymentSync = false;
}
}
@@ -595,11 +632,20 @@
}
async function handleSavedCardPayment() {
if (isProcessingPaymentSync) return;
if (!selectedSavedCardId) {
toast.error('Please select a saved card');
return;
}
// totalDue can be £0 (fully discounted) and the loyalty discount is
// applied on top — the effective charge could otherwise be 0 or negative.
if (Math.round(totalDue * 100) - loyaltyDiscount <= 0) {
toast.error('Nothing to charge — the booking is fully covered by discounts');
return;
}
isProcessingPaymentSync = true;
status = 'saved-card-processing';
error = null;
@@ -637,6 +683,8 @@
status = 'error';
error = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
toast.error(error ?? 'Unknown error');
} finally {
isProcessingPaymentSync = false;
}
}
@@ -779,6 +827,12 @@
</div>
</div>
{#if nothingToCharge}
<p class="rounded-md border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
The booking is fully covered by discounts — nothing to charge.
</p>
{/if}
{#if tipEnabled}
<div class="flex justify-between rounded-md border border-green-200 bg-green-50 p-3">
<span class="text-sm font-medium text-green-800">
@@ -797,7 +851,8 @@
>
<button
type="button"
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
disabled={nothingToCharge}
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
'card'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -820,7 +875,8 @@
</button>
<button
type="button"
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
disabled={nothingToCharge}
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
'cash'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -844,7 +900,8 @@
{#if savedCardList.length > 0}
<button
type="button"
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
'savedcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -869,7 +926,8 @@
{/if}
<button
type="button"
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
'giftcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -899,7 +957,8 @@
{#if savedCardList.length > 0}
<button
type="button"
class="text-sm text-gray-600 underline hover:text-gray-900"
disabled={nothingToCharge}
class="text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => {
selectedMethod = 'savedcard';
status = 'saved-card-selecting';
@@ -910,7 +969,8 @@
{/if}
<button
type="button"
class="text-sm text-gray-600 underline hover:text-gray-900"
disabled={nothingToCharge}
class="text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => {
selectedMethod = 'giftcard';
status = 'gift-entering';
@@ -975,7 +1035,9 @@
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button onclick={handleCardPayment} class="flex-1">Charge Card</Button>
<Button onclick={handleCardPayment} class="flex-1" disabled={nothingToCharge}>
Charge Card
</Button>
</div>
</div>
{:else if status === 'card-processing' || status === 'card-polling'}
@@ -1028,7 +1090,11 @@
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button onclick={handleCashPayment} class="flex-1" disabled={cashAmountNum < totalDue}>
<Button
onclick={handleCashPayment}
class="flex-1"
disabled={cashAmountNum < totalDue || nothingToCharge}
>
Confirm Cash
</Button>
</div>
@@ -1129,7 +1195,7 @@
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button onclick={handleGiftCardPayment} class="flex-1" disabled={!giftCardValid}>
<Button onclick={handleGiftCardPayment} class="flex-1" disabled={!giftCardValid || nothingToCharge}>
Apply Gift Card
</Button>
</div>
@@ -1221,7 +1287,11 @@
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button onclick={handleSavedCardPayment} class="flex-1" disabled={!selectedSavedCardId}>
<Button
onclick={handleSavedCardPayment}
class="flex-1"
disabled={!selectedSavedCardId || nothingToCharge}
>
Charge Saved Card
</Button>
</div>
@@ -1,9 +1,73 @@
<script module lang="ts">
/**
* The real card form is a CROSS-ORIGIN iframe (web.squarecdn.com) that does
* NOT inherit the page font or CSS — parent stylesheets cannot reach it; only
* the SDK `style` option can. This mirrors the app's shadcn Input (see
* ui/input/input.svelte + the tokens in app.css) so the iframe reads as the
* same input as the surrounding form instead of Square's default Helvetica
* Neue 16px. Selectors follow Square's CardClassSelectors schema; only the
* properties listed here are supported (fontSize is capped at 16px, and there
* is no per-field `inputs()` API).
*/
type SquareCardClassSelectors = Record<string, Record<string, string>>;
const cardStyle: SquareCardClassSelectors = {
input: {
fontSize: '14px', // md:text-sm (text-base md:text-sm — desktop size)
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
fontWeight: '400',
color: 'oklch(0.129 0.042 264.695)', // --foreground
backgroundColor: 'oklch(1 0 0)' // --background
},
'input::placeholder': { color: 'oklch(0.554 0.046 257.417)' }, // --muted-foreground
'input.is-focus': { color: 'oklch(0.129 0.042 264.695)' },
'input.is-error': { color: 'oklch(0.64 0.21 25)' }, // --destructive
'.input-container': {
borderColor: 'oklch(0.929 0.013 255.508)', // --input (=== --border)
borderRadius: '8px' // rounded-md = calc(0.625rem - 2px) = --radius-md
},
'.input-container.is-focus': { borderColor: 'oklch(0.704 0.04 256.788)' }, // --ring
'.input-container.is-error': { borderColor: 'oklch(0.64 0.21 25)' },
'.message-text': { color: 'oklch(0.554 0.046 257.417)' },
'.message-text.is-error': { color: 'oklch(0.64 0.21 25)' },
'.message-icon': { color: 'oklch(0.554 0.046 257.417)' },
'.message-icon.is-error': { color: 'oklch(0.64 0.21 25)' }
};
</script>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
import type MockCardForm from './MockCardForm.svelte';
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
/**
* Billing contact passed to Square's tokenize() verificationDetails for
* Strong Customer Authentication (SCA). Only fields we already hold are
* included; omit the object entirely when nothing is available.
*/
export interface SquareVerificationContact {
givenName?: string;
familyName?: string;
email?: string;
}
/** Result of a tokenize-with-verification call. */
export interface TokenizeWithVerificationResult {
nonce: string;
verificationToken: string | null;
}
/** Square Web Payments `card.tokenize()` verification details shape. */
interface SquareVerificationDetails {
amount: string;
billingContact?: SquareVerificationContact;
intent: string;
currencyCode: string;
customerInitiated: boolean;
sellerKeyedIn: boolean;
}
interface Props {
/** Disable the form while a payment is processing. */
disabled?: boolean;
@@ -35,17 +99,24 @@
}
try {
const payments = (await getSquarePayments()) as {
card: () => Promise<{
card: (options?: { style?: SquareCardClassSelectors }) => Promise<{
attach: (selector: string) => Promise<void>;
tokenize: () => Promise<{
tokenize: (
verificationDetails?: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
destroy: () => void;
}>;
};
const card = await payments.card();
// The iframe only honours styling passed via the SDK `style` option.
// card.configure({ style: cardStyle }) could re-apply it later if we
// ever need to restyle the already-attached form — no need to call it
// at init.
const card = await payments.card({ style: cardStyle });
await card.attach(`#${uniqueId}`);
cardInstance = card;
ready = true;
@@ -82,9 +153,12 @@
return mockForm.tokenize();
}
const card = cardInstance as {
tokenize: () => Promise<{
tokenize: (
verificationDetails?: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
} | null;
@@ -102,6 +176,82 @@
.join(', ') || 'Card details are incomplete';
throw new Error(detail);
}
/**
* Tokenizes the entered card together with SCA verification details for a
* card-not-present charge. UK merchants must run Strong Customer
* Authentication for most online payments — without verificationDetails,
* Square rejects in-scope cards with CARD_DECLINED_VERIFICATION_REQUIRED.
*
* Returns BOTH the card nonce and the verification token, which the caller
* must send to the backend as `verification_token` alongside
* `new_card_token`/`card_token`.
*
* @param amount The amount that WILL be charged, in pence (minor units).
* Square requires this to match the eventual payment amount.
* It is sent to Square as a MAJOR-units decimal string (e.g.
* 5000 pence → "50.00" for £50.00) per the W3C
* valid-decimal-monetary-value standard — sending pence as an
* integer string ("5000") would make Square's 3DS bind a
* 100×-too-large amount and fail SCA.
* @param contact Optional billing contact (name/email we already hold).
*/
export async function tokenizeWithVerification(
amount: number,
contact?: SquareVerificationContact
): Promise<TokenizeWithVerificationResult> {
if (isSquareMock()) {
if (!mockForm) {
throw new Error('Card form is not ready — please wait a moment and try again');
}
return mockForm.tokenizeWithVerification(amount, contact);
}
const card = cardInstance as {
tokenize: (
verificationDetails: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
} | null;
if (!card) {
throw new Error('Card form is not ready — please wait a moment and try again');
}
const verificationDetails: SquareVerificationDetails = {
// Square expects a MAJOR-units decimal string (W3C valid-decimal-
// monetary-value), e.g. "50.00" for £50.00 — NOT the minor-unit
// integer ("5000"), which would bind a 100×-too-large 3DS amount.
amount: (amount / 100).toFixed(2),
intent: 'CHARGE',
currencyCode: 'GBP',
customerInitiated: true,
sellerKeyedIn: false
};
if (contact && (contact.givenName || contact.familyName || contact.email)) {
verificationDetails.billingContact = contact;
}
const result = await card.tokenize(verificationDetails);
if (result.status === 'OK' && result.token) {
// In the current tokenize-with-verification flow the returned nonce
// (result.token) is ALREADY the 3DS-verified token — Square binds the
// SCA challenge to this exact amount, so charging it as
// `new_card_token`/`card_token` is sufficient. `verificationResult`
// only exists on the deprecated verifyBuyer() flow; we still read it
// defensively since the backend accepts an explicit verification_token.
return {
nonce: result.token,
verificationToken: result.verificationResult?.token ?? null
};
}
const detail =
result.errors
?.map((e) => e.message || e.code)
.filter(Boolean)
.join(', ') || 'Card details are incomplete';
throw new Error(detail);
}
</script>
{#if isSquareMock()}
@@ -50,6 +50,15 @@
// Cached nonce for the new-card form: tokenization is one-shot, so a retry
// reuses this token instead of re-tokenizing (backend idempotency dedups).
let newCardNonce = $state('');
// Cached SCA verification token paired with newCardNonce (both one-shot,
// reused together on retry). The verification token is amount-bound, so a
// changed payment amount invalidates the cached pair.
let newCardVerificationToken = $state('');
let newCardTokenAmount = $state(0);
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
// verification tokens expire after ~5 minutes, so a stale pair is discarded
// on late retries and re-tokenized instead of rejected by Square.
let newCardTokenizedAt = $state(0);
let paymentResult = $state<{
id: string;
amount: number;
@@ -356,15 +365,27 @@
let cardId: string | undefined;
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (selectedCardId) {
cardId = selectedCardId;
} else if (cardSelection) {
// New-card mode: tokenize once per attempt, then reuse the cached nonce
// on retry (tokenization is one-shot; the backend idempotency key dedups).
if (!newCardNonce) {
// New-card mode: tokenize once per attempt WITH SCA verification, then
// reuse the cached nonce + verification token on retry (tokenization
// is one-shot; the backend idempotency key dedups). The verification
// token is amount-bound, so a changed amount forces a fresh
// tokenization.
if (!newCardNonce || newCardTokenAmount !== amountCents || Date.now() - newCardTokenizedAt > 240_000) {
try {
newCardNonce = await cardSelection.tokenize();
const tokenized = await cardSelection.tokenizeWithVerification(amountCents, {
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
});
newCardNonce = tokenized.nonce;
newCardVerificationToken = tokenized.verificationToken ?? '';
newCardTokenAmount = amountCents;
newCardTokenizedAt = Date.now();
} catch (_err) {
status = 'error';
const msg = _err instanceof Error ? _err.message : 'Card entry failed';
@@ -374,6 +395,7 @@
}
}
newCardToken = newCardNonce;
verificationToken = newCardVerificationToken || undefined;
} else {
status = 'error';
error = 'Please select a payment method';
@@ -405,6 +427,7 @@
payment_type: paymentType,
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
idempotency_key: payIdempotencyKey
})
});
@@ -422,6 +445,9 @@
payKeyedType = '';
payKeyedCard = '';
newCardNonce = '';
newCardVerificationToken = '';
newCardTokenAmount = 0;
newCardTokenizedAt = 0;
paymentResult = {
id: data.id,
amount: data.amount,
@@ -777,6 +803,7 @@
)}
{/if}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</div>
{/if}
@@ -855,6 +882,7 @@
)}
{/if}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</div>
{/if}
@@ -2,9 +2,15 @@
import type { Snippet } from 'svelte';
const {
trigger
trigger,
label = 'cancellation policy',
href = '/cancellation-policy'
}: {
trigger?: Snippet;
/** Button label shown when no custom trigger snippet is provided. */
label?: string;
/** Route the "Open" link and "Download PDF" action point at. */
href?: string;
} = $props();
let open = $state(false);
@@ -36,7 +42,7 @@
{#if trigger}
{@render trigger()}
{:else}
cancellation policy
{label}
{/if}
</button>
@@ -45,7 +51,7 @@
class="absolute top-full left-0 z-50 mt-1 w-48 rounded-md border border-gray-200 bg-white p-2 shadow-lg"
>
<a
href="/cancellation-policy"
href={href}
target="_blank"
rel="noopener noreferrer external"
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
@@ -58,7 +64,7 @@
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
onclick={() => {
open = false;
const win = window.open('/cancellation-policy?format=pdf', '_blank');
const win = window.open(`${href}?format=pdf`, '_blank');
if (win) win.focus();
}}
>
+51 -4
View File
@@ -212,6 +212,15 @@
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
// of re-tokenizing (the backend idempotency key dedups).
let buyNonce = $state('');
// Cached SCA verification token paired with buyNonce (both one-shot, reused
// together on retry). The verification token is amount-bound, so changing
// the amount invalidates the cached pair.
let buyVerificationToken = $state('');
let buyTokenAmount = $state(0);
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
// verification tokens expire after ~5 minutes, so a stale pair is discarded
// on late retries and re-tokenized instead of rejected by Square.
let buyTokenizedAt = $state(0);
// Cached idempotency key: generated once per purchase attempt, reused on
// retry (so a lost-response retry dedups instead of double-charging),
@@ -269,13 +278,24 @@
async function buyGiftCard() {
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (buySelectedCard) {
// saved card — nothing to tokenize
} else if (buyCardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
if (!buyNonce) {
// 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 || buyTokenAmount !== buyAmount * 100 || Date.now() - buyTokenizedAt > 240_000) {
try {
buyNonce = await buyCardSelection.tokenize();
const tokenized = await buyCardSelection.tokenizeWithVerification(buyAmount * 100, {
givenName: userData?.firstName,
familyName: userData?.lastName,
email: userData?.email
});
buyNonce = tokenized.nonce;
buyVerificationToken = tokenized.verificationToken ?? '';
buyTokenAmount = buyAmount * 100;
buyTokenizedAt = Date.now();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
buyingGiftCard = false;
@@ -283,6 +303,7 @@
}
}
newCardToken = buyNonce;
verificationToken = buyVerificationToken || undefined;
} else {
toast.error('Please select a payment method');
buyingGiftCard = false;
@@ -312,6 +333,7 @@
recipient_email: buyRecipientEmail,
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
idempotency_key: buyIdempotencyKey
})
});
@@ -324,6 +346,9 @@
buyKeyedAmount = 0;
buyKeyedCard = '';
buyNonce = '';
buyVerificationToken = '';
buyTokenAmount = 0;
buyTokenizedAt = 0;
await fetchGiftCardBalance();
} else {
const errText = await res.text();
@@ -1848,7 +1873,11 @@
<Card.Root>
<Card.Header>
<Card.Title>Saved Cards</Card.Title>
<Card.Description>Manage your saved payment methods</Card.Description>
<Card.Description>
Manage your saved payment methods &mdash; cards are stored securely with our
payment provider (Square).
<PolicyPopover label="privacy policy" href="/privacy-policy" />
</Card.Description>
</Card.Header>
<Card.Content>
{#if loadingCards}
@@ -2184,6 +2213,7 @@
>
{buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
{/if}
</Card.Content>
</Card.Root>
@@ -2376,6 +2406,23 @@
</Button>
{/snippet}
</PolicyPopover>
<PolicyPopover label="privacy policy" href="/privacy-policy">
{#snippet trigger()}
<Button variant="outline" class="mt-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
Privacy Policy
</Button>
{/snippet}
</PolicyPopover>
</div>
<Separator />
+33 -4
View File
@@ -58,6 +58,15 @@
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
// of re-tokenizing (the backend idempotency key dedups).
let tipNonce = $state('');
// Cached SCA verification token paired with tipNonce (both one-shot, reused
// together on retry). The verification token is amount-bound, so changing
// the tip invalidates the cached pair.
let tipVerificationToken = $state('');
let tipTokenAmount = $state(0);
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
// verification tokens expire after ~5 minutes, so a stale pair is discarded
// on late retries and re-tokenized instead of rejected by Square.
let tipTokenizedAt = $state(0);
const canSaveCards = $derived(
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
@@ -203,19 +212,35 @@
}
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (selectedCardId) {
// saved card — nothing to tokenize
} else if (cardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
if (!tipNonce) {
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
// verification token on retry (tokenization is one-shot; the backend
// idempotency key dedups). The verification token is amount-bound, so
// a changed tip amount forces a fresh tokenization.
if (!tipNonce || tipTokenAmount !== tipAmount || Date.now() - tipTokenizedAt > 240_000) {
try {
tipNonce = await cardSelection.tokenize();
const tokenized = await cardSelection.tokenizeWithVerification(
Math.round(tipAmount * 100),
{
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
}
);
tipNonce = tokenized.nonce;
tipVerificationToken = tokenized.verificationToken ?? '';
tipTokenAmount = tipAmount;
tipTokenizedAt = Date.now();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
}
}
newCardToken = tipNonce;
verificationToken = tipVerificationToken || undefined;
} else {
toast.error('Please select a payment method');
return;
@@ -233,7 +258,8 @@
amount: amountInPence,
idempotency_key: tipIdempotencyKey,
...(selectedCardId ? { card_id: selectedCardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {})
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {})
};
const response = await apiFetch(`/api/bookings/${bookingId}/tip`, {
@@ -251,6 +277,9 @@
tipIdempotencyKey = '';
tipKeyedAmount = 0;
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
toast.success('Thank you for your tip!');
} catch (err) {
paymentState = 'error';
@@ -0,0 +1,285 @@
<script lang="ts">
import { page } from '$app/stores';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
let format = $state('html');
let pdfNotice = $state(true);
onMount(() => {
format = $page.url.searchParams.get('format') || 'html';
if (format === 'pdf') {
// Strip ?format=pdf from the URL so a refresh doesn't re-trigger the print dialog.
const clean = window.location.pathname + window.location.hash;
history.replaceState(null, '', clean);
// Open the print dialog once the page is rendered.
// The notice and draft badges are removed before print so they won't appear in the PDF.
setTimeout(() => {
pdfNotice = false;
// Small delay so Svelte can remove the element before the print engine snapshots.
setTimeout(() => window.print(), 50);
}, 100);
}
});
</script>
<svelte:head>
<title>Privacy Policy</title>
<style>
@media print {
:global(nav),
:global(.no-print) {
display: none !important;
}
:global(body) {
padding-top: 0 !important;
}
}
</style>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8 text-gray-900">
<div class="mb-2 flex items-center gap-3">
<h1 class="border-b border-gray-200 pb-4 text-2xl font-bold">Privacy Policy</h1>
<span
class="no-print shrink-0 rounded-full border border-amber-300 bg-amber-50 px-2.5 py-0.5 text-xs font-semibold text-amber-800"
>
DRAFT &mdash; for review
</span>
</div>
<p class="mb-8 font-mono text-xs text-gray-500">Last updated: August 2026</p>
{#if format === 'pdf' && pdfNotice}
<p class="mb-6 rounded border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600 italic">
Generating PDF&hellip; If the print dialog does not appear, use Ctrl+P / Cmd+P.
</p>
{/if}
<div class="space-y-8 text-sm leading-relaxed text-gray-700">
<!-- Section 1 -->
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">1. Introduction</h2>
<p class="mb-3">
This Privacy Policy explains how Crussell Salon (&ldquo;we&rdquo;, &ldquo;us&rdquo;,
&ldquo;our&rdquo;) collects, uses, and protects your personal data when you use our
booking platform (&ldquo;Platform&rdquo;).
</p>
<p class="mb-3">
We are committed to protecting your privacy and complying with the
<strong>UK General Data Protection Regulation (UK GDPR)</strong> and
<strong>Data Protection Act 2018</strong>.
</p>
<div class="rounded-md border border-gray-200 bg-gray-50/50 p-4 text-xs text-gray-600">
<p class="font-semibold text-gray-900">Data Controller</p>
<p class="mt-1">Crussell Salon</p>
<p>Edinburgh, Scotland</p>
<p>Email: help@crussell.invalid</p>
</div>
</section>
<!-- Section 2 -->
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">2. Data We Collect</h2>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.1 Personal Data (Identifiable Information)</h3>
<p class="mb-2 font-medium text-gray-800">Account Information:</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Name (first, last)</li>
<li>Email address</li>
<li>Phone number</li>
<li>Date of birth (optional, for age verification)</li>
<li>Account ID (for balance recovery after deletion)</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Booking Information:</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Appointment dates, times, services</li>
<li>Treatment notes and preferences</li>
<li>Allergy and patch test records (health data &mdash; special category)</li>
<li>Payment history and transaction records</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Financial Data:</p>
<ul class="mb-4 list-disc space-y-1 pl-5">
<li>Gift card codes and balances</li>
<li>Account balances</li>
<li>Payment transaction records (processed via Square, not stored by us)</li>
<li>Saved-card references (tokenised, stored with our payment provider Square &mdash; see &sect;2.2)</li>
<li>Dormant balance records (Account ID only, no PII)</li>
</ul>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.2 Saved Cards &amp; Payment Provider (Square)</h3>
<p class="mb-3">
When you choose to <strong>save a card for next time</strong>, we store a tokenised
reference to your card with our payment processor, <strong>Square</strong> (a data
processor), rather than on our own systems.
</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
<strong>What Square stores:</strong> a tokenised reference to your card (never your
full card number or CVV), plus the name and email address we already hold on your
account, grouped into a Square customer profile.
</li>
<li>
<strong>Lawful basis:</strong> UK GDPR Article 6(1)(b) &mdash; necessary for the
performance of the contract (you asked to save your card for future payments).
</li>
<li>
<strong>Why:</strong> so you can pay for future bookings, tips, or gift-card purchases
without re-entering your card details.
</li>
<li>
<strong>One-off payments:</strong> if you do not tick &ldquo;save this card&rdquo;,
<strong>no card is stored and no Square customer profile is created</strong> for you
&mdash; your card is used only for that single payment.
</li>
<li>
<strong>Retention &amp; removal:</strong> the reference remains stored until you delete
the card from your account (Account &rarr; Saved Cards) or your account is deleted. You
can remove a saved card at any time.
</li>
<li>
<strong>Square&rsquo;s privacy policy:</strong>
<a
href="https://squareup.com/gb/en/legal/privacy-no-account"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-blue-600 underline hover:text-blue-800"
>Square Privacy Policy</a
>
applies to data Square holds on our behalf.
</li>
</ul>
<p class="mb-4">
We never store full card numbers, card security codes (CVV), or card expiry data on our
own systems at any point.
</p>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.3 Special Category Data (Health Data)</h3>
<p class="mb-3">We collect health-related information with your <strong>explicit consent</strong>:</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Allergy records</li>
<li>Patch test results</li>
<li>Medical conditions affecting treatment</li>
<li>Skin sensitivity notes</li>
</ul>
<p class="mb-3">
<strong>Legal basis:</strong> UK GDPR Article 9(2)(a) &mdash; Explicit consent<br />
<strong>Retention:</strong> 7 years (insurance requirement) or account deletion (whichever
is later)
</p>
</section>
<!-- Section 3 -->
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Data Retention &amp; Deletion Process</h2>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">3.1 Retention Schedule</h3>
<div class="overflow-x-auto rounded-md border border-gray-200">
<table class="w-full border-collapse text-xs">
<thead>
<tr class="border-b border-gray-200 bg-gray-50/50 text-left text-gray-900">
<th class="px-3 py-2 font-semibold">Data Category</th>
<th class="px-3 py-2 font-semibold">Retention Period</th>
<th class="px-3 py-2 font-semibold">Legal Basis</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 text-gray-600">
<tr>
<td class="px-3 py-2">Active account data</td>
<td class="px-3 py-2">Account active + 2 years</td>
<td class="px-3 py-2">Legitimate interest</td>
</tr>
<tr>
<td class="px-3 py-2">Inactive accounts (no balance)</td>
<td class="px-3 py-2">2 years idle</td>
<td class="px-3 py-2">GDPR storage limitation</td>
</tr>
<tr>
<td class="px-3 py-2">Inactive accounts (with balance)</td>
<td class="px-3 py-2">5 years idle</td>
<td class="px-3 py-2">Scottish prescriptive period</td>
</tr>
<tr>
<td class="px-3 py-2">Financial records</td>
<td class="px-3 py-2">7 years</td>
<td class="px-3 py-2">HMRC requirement</td>
</tr>
<tr>
<td class="px-3 py-2">Saved-card references (Square)</td>
<td class="px-3 py-2">Until user deletes card or account is deleted (Square-side)</td>
<td class="px-3 py-2">
Contract performance (Art 6(1)(b)); card-network card-on-file rules
</td>
</tr>
<tr>
<td class="px-3 py-2">Allergy/health records</td>
<td class="px-3 py-2">7 years</td>
<td class="px-3 py-2">Insurance requirement</td>
</tr>
<tr>
<td class="px-3 py-2">Dormant balances</td>
<td class="px-3 py-2">Indefinite (Account ID only)</td>
<td class="px-3 py-2">Recovery mechanism</td>
</tr>
<tr>
<td class="px-3 py-2">Marketing preferences</td>
<td class="px-3 py-2">Until withdrawn</td>
<td class="px-3 py-2">Consent</td>
</tr>
</tbody>
</table>
</div>
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Deletion Process</h3>
<p class="mb-2 font-medium text-gray-800">Account deletion (your request):</p>
<ol class="mb-3 list-decimal space-y-1 pl-5">
<li>You confirm deletion (warning about data loss).</li>
<li>If balance exists, transferred to dormant balance system.</li>
<li>Account ID sent to you via email.</li>
<li>Personal data anonymized (name, email, phone replaced with placeholders).</li>
<li>Financial records retained 7 years (HMRC) then aggregated.</li>
<li>Allergy records retained 7 years (insurance) then deleted.</li>
</ol>
<p class="mb-3">
<strong>Saved cards:</strong> Deleting your account also removes your saved-card
references from our system and disables the corresponding card tokens at Square (see
&sect;2.2). Card transaction records for payments already made are retained per the HMRC
schedule above.
</p>
<p class="mb-2 font-medium text-gray-800">Inactive account deletion (automatic):</p>
<ol class="mb-3 list-decimal space-y-1 pl-5">
<li>Warning emails sent at 18/23 months (no balance) or 4/59 months (with balance).</li>
<li>If no activity, account deleted as above.</li>
<li>Dormant balance recoverable with Account ID.</li>
</ol>
</section>
<!-- Section 4 -->
<section class="border-t border-gray-200 pt-6">
<h2 class="mb-2 text-base font-semibold text-gray-900">4. Your Rights</h2>
<p class="mb-3">Under UK GDPR, you have the right to:</p>
<ul class="mb-4 list-disc space-y-1 pl-5">
<li><strong>Access</strong> your personal data (Article 15)</li>
<li><strong>Rectify</strong> inaccurate data (Article 16)</li>
<li><strong>Erase</strong> your data (Article 17 &mdash; subject to HMRC/insurance retention)</li>
<li><strong>Restrict</strong> processing (Article 18)</li>
<li><strong>Data Portability</strong> (Article 20)</li>
<li><strong>Object</strong> to processing (Article 21)</li>
<li><strong>Withdraw Consent</strong> (Article 7(3))</li>
</ul>
<p class="mb-4">
To exercise these rights, contact help@crussell.invalid. You also have the right to
complain to the Information Commissioner&rsquo;s Office (ICO) at any time.
</p>
<p class="text-xs text-gray-500">
Questions about how we handle your data? Please use our official
<a
href={resolve('/contact')}
class="font-medium text-blue-600 underline hover:text-blue-800">Contact Channels</a
>
to get in touch.
</p>
</section>
</div>
</div>
+33 -4
View File
@@ -65,6 +65,15 @@
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
// of re-tokenizing (the backend idempotency key dedups).
let tipNonce = $state('');
// Cached SCA verification token paired with tipNonce (both one-shot, reused
// together on retry). The verification token is amount-bound, so changing
// the tip invalidates the cached pair.
let tipVerificationToken = $state('');
let tipTokenAmount = $state(0);
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
// verification tokens expire after ~5 minutes, so a stale pair is discarded
// on late retries and re-tokenized instead of rejected by Square.
let tipTokenizedAt = $state(0);
const canSaveCards = $derived(
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
@@ -153,19 +162,35 @@
}
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (selectedCardId) {
// saved card — nothing to tokenize
} else if (cardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
if (!tipNonce) {
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
// verification token on retry (tokenization is one-shot; the backend
// idempotency key dedups). The verification token is amount-bound, so
// a changed tip amount forces a fresh tokenization.
if (!tipNonce || tipTokenAmount !== tipAmount || Date.now() - tipTokenizedAt > 240_000) {
try {
tipNonce = await cardSelection.tokenize();
const tokenized = await cardSelection.tokenizeWithVerification(
Math.round(tipAmount * 100),
{
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
}
);
tipNonce = tokenized.nonce;
tipVerificationToken = tokenized.verificationToken ?? '';
tipTokenAmount = tipAmount;
tipTokenizedAt = Date.now();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
}
}
newCardToken = tipNonce;
verificationToken = tipVerificationToken || undefined;
} else {
toast.error('Please select a payment method');
return;
@@ -183,7 +208,8 @@
amount: amountInPence,
idempotency_key: tipIdempotencyKey,
...(selectedCardId ? { card_id: selectedCardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {})
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {})
};
const response = await apiFetch(`/api/bookings/${booking.id}/tip`, {
@@ -201,6 +227,9 @@
tipIdempotencyKey = '';
tipKeyedAmount = 0;
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
toast.success('Thank you for your tip!');
} catch (err) {
paymentState = 'error';