fix: frontend payment surfaces — SCA wire shapes (explicit token precedence), mock token parity, infinite-loop guard, money display, delete-account re-auth, admin progress UI, mobile touch targets

- new_card_token uses explicit newCardToken ?? verificationToken precedence on every charge surface (BookingFlow, UserPaymentModal, TipPayment, PaymentModal, TillPurchases, account gift-card buy); dead verification_code/consent fields + ScaFallbackConsentDialog removed from payment flows
- mock mints cnon:sca-... tokenize-results and tokenizeWithVerification returns verificationToken:null for new cards (real-SDK parity so save-card works in dev)
- UserPaymentModal infinite /payment-methods fetch loop guarded; formatCurrency(totalPaid) no longer 100x too small
- delete-account dialog collects current_password + fresh 2FA code; admin 'Begin appointment'/'Complete' wired to /admin/bookings/{id}/progress
- mobile: 44px touch targets, active: feedback, TimeSlotPicker 50dvh, dialog close sizing, .no-scrollbar utility, CSP meta, receipt fields escaped
- vitest: policy.ts cross-check + ScaFallbackConsentDialog component tests (svelte project via happy-dom)
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent f9e8385d5a
commit 01e7cc575d
34 changed files with 1154 additions and 665 deletions
+9
View File
@@ -145,6 +145,15 @@
display: none;
}
/* Backwards-compatible alias for .scrollbar-hide */
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide number input spinners across the app */
input[type='number'].no-spin {
-moz-appearance: textfield;
+15
View File
@@ -3,6 +3,21 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- CSP for the SPA shell: the backend's CSP header only covers API
responses, so a compromise of any HTML route would otherwise render
with no policy. Kept permissive enough for SvelteKit (inline
scripts/styles for hydration, ws: for the dev HMR socket,
http://localhost:8080 for the local API origin) while restricting
the default source to self. frame-src allows Square Web Payments
iframes (squareup.com / square.com) for the card form.
RESIDUAL RISK: the session tokens live in localStorage
(auth.svelte.ts); the CSP raises the bar against XSS-driven
exfiltration but does not eliminate it — migrating tokens to
httpOnly cookies is the durable fix (out of scope). -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' https: http://localhost:8080 ws:; font-src 'self' data: https://fonts.gstatic.com; frame-src 'self' https://*.squareup.com https://*.square.com"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
@@ -945,7 +945,7 @@
</div>
{:else}
<div
class="mt-2 no-scrollbar flex max-h-40 min-h-25 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4"
class="scrollbar-hide mt-2 flex max-h-40 min-h-25 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4"
>
<div class="grid justify-center gap-2 text-sm text-gray-600">
{newDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', {
@@ -261,7 +261,7 @@
<table>
<tr><td style="width:110px;font-weight:600">Booking Ref</td><td>${esc(selectedBooking.id)}</td></tr>
<tr><td style="font-weight:600">Date</td><td>${parseWallClockDate(selectedBooking.start_time).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</td></tr>
<tr><td style="font-weight:600">Status</td><td style="text-transform:capitalize">${selectedBooking.status.replace('_', ' ')}</td></tr>
<tr><td style="font-weight:600">Status</td><td style="text-transform:capitalize">${esc(selectedBooking.status.replace('_', ' '))}</td></tr>
</table>
<h2>Services</h2>
<table>
@@ -271,7 +271,7 @@
<h2>Payments</h2>
<table>
<tr><th>Type</th><th>Method</th><th style="text-align:right">Net</th><th style="text-align:right">VAT</th><th style="text-align:right">Gross</th></tr>
${paidPayments.map((p) => `<tr><td style="text-transform:capitalize">${p.payment_type}</td><td style="text-transform:capitalize">${p.payment_method ?? '\u2014'}</td><td style="text-align:right">${fmt(p.net_amount)}</td><td style="text-align:right">${p.is_vat_applicable && p.vat_amount != null ? fmt(p.vat_amount) : '\u2014'}</td><td style="text-align:right">\u00a3${p.amount.toFixed(2)}</td></tr>`).join('')}
${paidPayments.map((p) => `<tr><td style="text-transform:capitalize">${esc(p.payment_type)}</td><td style="text-transform:capitalize">${esc(p.payment_method ?? '\u2014')}</td><td style="text-align:right">${fmt(p.net_amount)}</td><td style="text-align:right">${p.is_vat_applicable && p.vat_amount != null ? fmt(p.vat_amount) : '\u2014'}</td><td style="text-align:right">\u00a3${p.amount.toFixed(2)}</td></tr>`).join('')}
${discountPayments.map((d) => `<tr><td>Discount</td><td>\u2014</td><td style="text-align:right;color:#059669">-\u00a3${Math.abs(d.amount).toFixed(2)}</td><td style="text-align:right;color:#059669">\u2014</td><td style="text-align:right;color:#059669">\u2014</td></tr>`).join('')}
${refunds.map((r) => `<tr><td>Refund</td><td>\u2014</td><td style="text-align:right;color:#dc2626">-\u00a3${r.amount.toFixed(2)}</td><td style="text-align:right;color:#dc2626">\u2014</td><td style="text-align:right;color:#dc2626">\u2014</td></tr>`).join('')}
${hasVAT ? `<tr class="total"><td colspan="2"></td><td style="text-align:right">\u00a3${totalNet.toFixed(2)}</td><td style="text-align:right">\u00a3${totalVAT.toFixed(2)}</td><td style="text-align:right">\u00a3${(totalNet + totalVAT).toFixed(2)}</td></tr>` : ''}
@@ -708,8 +708,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
{#if isCancellable}
<Button
variant="destructive"
size="sm"
class="flex-1"
class="min-h-11 flex-1"
onclick={() => (showCancelConfirm = true)}
>
Cancel Booking
@@ -717,8 +716,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
{#if canEditBooking}
<Button
variant="outline"
size="sm"
class="flex-1"
class="min-h-11 flex-1"
onclick={() => {
showEditModal = true;
}}
@@ -799,19 +797,13 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<div class="flex gap-2">
{#if selectedBooking}
<Button
size="sm"
class="flex-1 hover:bg-gray-50"
variant="outline"
onclick={printReceipt}
>
<Button class="min-h-11 flex-1 hover:bg-gray-50" variant="outline" onclick={printReceipt}>
Print Receipt
</Button>
{/if}
{#if isCompleted}
<Button
size="sm"
class="flex-1 hover:bg-fuchsia-50"
class="min-h-11 flex-1 hover:bg-fuchsia-50"
variant="outline"
onclick={() => (showTipModal = true)}
>
@@ -820,8 +812,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
{/if}
{#if depositOutstanding && selectedBooking?.status !== 'pending'}
<Button
size="sm"
class="flex-1 bg-amber-600 text-white hover:bg-amber-700"
class="min-h-11 flex-1 bg-amber-600 text-white hover:bg-amber-700"
onclick={() => (showPaymentModal = true)}
disabled={hasPendingEditRequest}
>
@@ -829,8 +820,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
</Button>
{:else if canPayEarly && !hasPendingEditRequest}
<Button
size="sm"
class="flex-1 bg-emerald-600 text-white hover:bg-emerald-700"
class="min-h-11 flex-1 bg-emerald-600 text-white hover:bg-emerald-700"
onclick={() => (showPaymentModal = true)}
>
Pay Early
@@ -844,7 +834,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
</div>
{/if}
</div>
<Button size="sm" class="w-full" variant="ghost" onclick={() => (open = false)}>Close</Button>
<Button class="min-h-11 w-full" variant="ghost" onclick={() => (open = false)}>Close</Button>
</div>
</Modal.Content>
</Modal.Root>
@@ -34,6 +34,7 @@
let forgiveFeesCancel = $state(false);
let forgiveNoShowCancel = $state(false);
let cancelling = $state(false);
let markingComplete = $state(false);
// Derived values for cancel confirmation
const totalPaid = $derived(
@@ -102,6 +103,34 @@
}
}
async function handleMarkComplete() {
if (!selectedBooking) return;
markingComplete = true;
try {
const response = await apiFetch(`/api/admin/bookings/${selectedBooking.id}/progress`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: 'completed' })
});
if (response.ok) {
toast.success('Booking completed');
fetchBookingDetails();
onChanged?.();
// Keep the today page's appointment cards/calendar/stats in sync.
window.dispatchEvent(new CustomEvent('bookingApproved'));
} else {
const text = await response.text();
toast.error('Failed to complete booking: ' + extractErrorMessage(text));
}
} catch {
toast.error('Network error completing booking');
} finally {
markingComplete = false;
}
}
// Calculate total duration from services
// IMPORTANT: Use override_duration_minutes when present — services may have been
// customised at booking time (discounts, extended sessions). Showing base values
@@ -762,6 +791,28 @@
<Modal.Footer class="flex items-center justify-end gap-2">
{#if selectedBooking && !['completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed'].includes(selectedBooking.status)}
{#if selectedBooking.status === 'in_progress'}
<Button
size="sm"
onclick={handleMarkComplete}
disabled={markingComplete}
class="bg-green-600 hover:bg-green-700"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
{markingComplete ? 'Completing...' : 'Mark Completed'}
</Button>
{/if}
<Button variant="destructive" size="sm" onclick={() => (showCancelModal = true)}>
Cancel Booking
</Button>
@@ -9,26 +9,18 @@
import { apiFetch } from '$lib/utils/api';
import { SvelteMap } from 'svelte/reactivity';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
runSavedCardSCAProactively,
scaFallbackConsentFields,
SCA_REFUSAL_MESSAGE_TILL,
shouldShowSCARefusal,
submitPaymentWithRetry,
tokenizeSavedCardWithVerification,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode,
PAYMENT_METHOD_SAVED_CARD,
VERIFICATION_REQUIRED_MESSAGE,
type SavedCardVerificationResult
} from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
type CartItem = {
@@ -127,25 +119,6 @@
})
);
// B6/B10: charging a customer's saved card via the till requires the
// customer's current 2FA verification code when the backend enforces the
// gate. The backend keys on the CARD OWNER (not the admin) and only gates
// customers who have actually ENABLED 2FA (requireTwoFactorForCardAccess:
// twoFactorEnforced() && UserTwoFactorEnabled(cardUserID)), so the input is
// surfaced only when BOTH hold — mirroring PaymentModal. The customer's
// setup flag is not carried by the till customer search, so it is fetched
// from GET /api/admin/users/{id} when a customer is selected (see
// fetchCustomerTwoFactor). For a 2FA-disabled customer in an enforced
// environment the input stays hidden so the charge can be attempted; the
// backend then returns the clear "Enable it in your account settings" 403,
// which the isTwoFactorVerificationGateFailure self-heal surfaces. Cash,
// card machine, and online (new-card nonce) payments are unaffected. Shared
// two-factor-code state (code, reveal, show/missing derivations, "Request a
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts. The admin
// always supplies the CUSTOMER's code — the admin's own 2FA flag is
// irrelevant to the backend gate, so `enabled` is always true.
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
let customerTwoFactorEnabled = $state(false);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// fallback); every other outcome keeps SCA primary for the next retry.
@@ -153,18 +126,6 @@
// True while the saved-card 3DS challenge is open and the CUSTOMER must
// approve it in their banking app — drives the "waiting for approval" panel.
let awaitingSCA = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === PAYMENT_METHOD_SAVED_CARD,
// C6 SCA-only posture: SCA is ALWAYS the authorisation — the code input
// only ever surfaces via a backend gate rejection (defensive/opt-in).
scaAvailable: () => true,
mint: () =>
selectedCustomer?.id
? adminRequestNewTwoFactorCode(selectedCustomer.id)
: requestNewTwoFactorCode()
});
// The saved-card option is hidden outright unless a customer is selected
// AND has at least one currently-valid card on file.
@@ -216,7 +177,6 @@
customerResults = [];
showCustomerResults = false;
fetchSavedCards(customer.id);
fetchCustomerTwoFactor(customer.id);
}
function clearSelectedCustomer() {
@@ -226,7 +186,6 @@
selectedSavedCardId = null;
customerResults = [];
showCustomerResults = false;
customerTwoFactorEnabled = false;
}
async function fetchSavedCards(userId: string) {
@@ -248,25 +207,6 @@
}
}
// B6/B10: the till customer search (GET /api/admin/users) carries no 2FA
// state, so the selected customer's setup flag is fetched from the admin
// user detail endpoint — the same source PaymentModal's fetchCustomerTwoFactor
// keys on. A failure leaves the flag false; the charge 403 self-heal still
// reveals the input.
async function fetchCustomerTwoFactor(userId: string) {
try {
const res = await apiFetch(`/api/admin/users/${userId}`);
if (res.ok) {
const data = await res.json();
customerTwoFactorEnabled = data?.twoFactorEnabled === true;
} else {
customerTwoFactorEnabled = false;
}
} catch {
customerTwoFactorEnabled = 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));
@@ -411,8 +351,6 @@
// C6: SCA genuinely can't run — abort the whole sale
// BEFORE any charge is submitted; the refusal notice
// is shown above the Charge button (no 2FA fallback).
twoFactor.declineConsent();
twoFactor.reveal = false;
scaAborted = true;
break;
}
@@ -420,13 +358,6 @@
// (new_card_token) alongside the saved-card ref — never
// the legacy verification_token.
if (sca.verificationToken) body.new_card_token = sca.verificationToken;
// B6/B10: the backend requires the CARD OWNER's current 2FA
// verification code when the gate is enforced and no SCA
// token authorises the charge.
if (twoFactor.showInput && !sca.verificationToken) {
body.verification_code = twoFactor.code;
}
Object.assign(body, scaFallbackConsentFields(twoFactor.consentAccepted));
} else if (paymentMethod === 'online_square') {
if (!onlineSquareCardInput) {
throw new Error('Card form is not ready — please wait a moment and try again');
@@ -450,20 +381,12 @@
if (scaAborted) return;
for (const body of saleBodies) {
const res = await submitPaymentWithRetry(
() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
// Finding 4: a saved-card till line gated on 2FA consumed its
// code at the backend gate — a 503 auto-retry would re-send a
// dead code and self-defeat.
{
verificationCodeGated:
paymentMethod === PAYMENT_METHOD_SAVED_CARD && twoFactor.showInput
}
const res = await submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
);
if (!res.ok) {
responseStatus = res.status;
@@ -492,8 +415,6 @@
toast.success('Sale complete');
cart = [];
idempotencyKeys.clear();
twoFactor.setCode('');
twoFactor.reveal = false;
} catch (err) {
const msg = err instanceof Error ? err.message : 'Sale failed';
// C6: a sca-unavailable refusal is communicated by the refusal dialog
@@ -502,21 +423,6 @@
paymentError = null;
return;
}
const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the sale can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
// M13: a verification-required rejection means the backend did NOT
// accept the fallback code (SCA-only posture / invalid token) —
// withdraw consent so the code input never reappears and the error
// surfaces clearly instead of looping on 2FA.
if (
msg === VERIFICATION_REQUIRED_MESSAGE ||
isVerificationRequiredSignal(responseStatus, bodyText)
) {
twoFactor.declineConsent();
}
paymentError = msg;
toast.error(msg);
} finally {
@@ -547,7 +453,6 @@
try {
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.declineConsent();
throw new Error(SCA_REFUSAL_MESSAGE_TILL);
}
let result: SavedCardVerificationResult;
@@ -557,7 +462,6 @@
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.declineConsent();
throw err;
}
lastSCAOutcome = result.outcome;
@@ -581,7 +485,6 @@
}
return;
}
twoFactor.declineConsent();
if (result.outcome === 'sca-unavailable') {
throw new Error(SCA_REFUSAL_MESSAGE_TILL);
}
@@ -884,7 +787,7 @@
{#each availablePaymentMethods as m (m.key)}
<button
type="button"
class="rounded-lg border py-3 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
class="min-h-11 rounded-lg border py-3 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
m.key
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
@@ -933,7 +836,7 @@
{#each validCards as card (card.id)}
<button
type="button"
class="w-full rounded-lg border p-3 text-left transition-colors {selectedSavedCardId ===
class="w-full rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {selectedSavedCardId ===
card.id
? 'border-input bg-fuchsia-100'
: 'border-gray-200 hover:bg-gray-50'}"
@@ -990,26 +893,6 @@
paymentError = null;
}}
/>
<!-- B6/B10: saved-card till charges require the customer's
current 2FA verification code when the backend enforces
the gate. -->
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={true}
/>
{#if twoFactor.showInput}
<Button
variant="outline"
class="min-h-11 w-full"
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
{/if}
</div>
{/if}
@@ -1041,18 +924,17 @@
</p>
</div>
{:else}
<Button
class="mt-3 min-h-11 w-full"
onclick={chargeCart}
loading={processing}
disabled={!canCharge ||
processing ||
twoFactor.missing ||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
(paymentMethod === PAYMENT_METHOD_SAVED_CARD && !selectedSavedCardId)}
>
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
</Button>
<Button
class="mt-3 min-h-11 w-full active:bg-primary/85 active:shadow-none"
onclick={chargeCart}
loading={processing}
disabled={!canCharge ||
processing ||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
(paymentMethod === PAYMENT_METHOD_SAVED_CARD && !selectedSavedCardId)}
>
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
</Button>
{/if}
<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">
@@ -38,7 +38,6 @@
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
@@ -49,10 +48,8 @@
depositChargePence,
isNonceStale,
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
runSavedCardSCAProactively,
scaFallbackConsentFields,
shouldShowSCARefusal,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
@@ -60,7 +57,6 @@
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import {
formatLocalDateTime,
getLondonTodayCalendarDate,
@@ -162,14 +158,6 @@
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
// B6/B10: saved-card deposits (and saving a new card for reuse) require the
// customer's current 2FA verification code whenever the backend enforces the
// gate. The input is surfaced at the charge step; the new-card (nonce) path
// keeps its own SCA via Square tokenizeWithVerification. Shared
// two-factor-code state (code, reveal, show/missing derivations, "Request a
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// fallback); every other outcome keeps SCA primary for the next retry.
@@ -180,14 +168,6 @@
// Retryable deposit failure message shown on the payment step (challenge
// cancelled/failed, decline) so the retry affordance matches the outcome.
let depositError = $state<string | null>(null);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () =>
savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard),
// C6 SCA-only posture: SCA is ALWAYS the authorisation — the code input
// only ever surfaces via a backend gate rejection (defensive/opt-in).
scaAvailable: () => true
});
const depositCardFormValid = $derived(paymentCardSelectionValid);
@@ -478,8 +458,6 @@
// C6: SCA genuinely can't run. Abort this attempt BEFORE
// any charge is submitted and surface the refusal notice —
// there is NO 2FA fallback; the deposit is paid online later.
twoFactor.declineConsent();
twoFactor.reveal = false;
paymentAttempted = false;
return;
}
@@ -494,13 +472,17 @@
amount: amountPence,
idempotency_key: depositIdempotencyKey,
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
// C1: the SCA tokenize-result token for a saved card is the
// charge SOURCE (new_card_token) alongside the card ref — never
// the legacy verification_token.
...(verificationToken ? { new_card_token: verificationToken } : {}),
...(twoFactor.showInput && !verificationToken ? { verification_code: twoFactor.code } : {}),
...scaFallbackConsentFields(twoFactor.consentAccepted)
// The charge SOURCE is the one-time token: a NEW card's nonce
// (cnon:...) or a saved card's SCA tokenize-result. They are
// mutually exclusive today, but an explicit precedence prevents a
// future path from silently overwriting one with the other (which
// destroyed the nonce and 503'd the charge).
...(newCardToken || verificationToken
? {
new_card_token: newCardToken ?? verificationToken,
save_card: depositSaveCard && !verificationToken
}
: {})
};
paymentAttempted = true;
@@ -558,11 +540,7 @@
...body,
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {})
})
}),
// Finding 4: a 2FA-gated charge consumed its code at the backend gate
// — a 503 auto-retry would re-send a dead code and self-defeat. The
// code is the gate only when no SCA tokenize-result is present.
{ verificationCodeGated: twoFactor.showInput && !('new_card_token' in body) }
})
);
if (response.ok) {
@@ -576,8 +554,6 @@
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
depositSaveCard = false;
twoFactor.setCode('');
twoFactor.reveal = false;
overflowConfirm = null;
// Immutable update — avoid mutating the existing object so
// concurrent renders (e.g. a stale fetch) can't observe partial
@@ -601,21 +577,12 @@
// charge), surface the guidance and let the user retry — never re-run SCA
// silently mid-flow.
if (selectedPaymentMethod && isVerificationRequiredSignal(response.status, text)) {
// M13: a verification-required 402 means the backend did NOT accept
// the fallback code (SCA-only posture / invalid token) — withdraw
// consent so the code input never reappears and the user sees the
// SCA guidance instead of looping on 2FA.
twoFactor.declineConsent();
// A verification-required 402 means the backend did NOT accept the
// token — surface the SCA-first guidance and let the user retry.
depositError = VERIFICATION_REQUIRED_MESSAGE;
toast.warning(depositError);
return;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the deposit can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(response.status, extractErrorMessage(text))) {
twoFactor.reveal = true;
}
// 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 +
@@ -2276,7 +2243,7 @@
</div>
<Card.Footer class="flex justify-between border-t px-6 !py-5">
<Button variant="outline" onclick={prevStep}>Back</Button>
<Button variant="outline" onclick={prevStep} class="min-h-11">Back</Button>
<div class="flex items-center space-x-4">
<!-- Desktop appointment summary -->
<div class="hidden text-sm md:block">
@@ -2294,7 +2261,9 @@
Select a date and time
{/if}
</div>
<Button disabled={!canProceedStep2} onclick={nextStep}>Next: Your Details</Button>
<Button disabled={!canProceedStep2} onclick={nextStep} class="min-h-11"
>Next: Your Details</Button
>
</div>
</Card.Footer>
</Card.Root>
@@ -2446,11 +2415,11 @@
</div>
</Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" onclick={prevStep}>Back</Button>
<Button variant="outline" onclick={prevStep} class="min-h-11">Back</Button>
<Button
disabled={!canProceedStep3}
onclick={nextStep}
class="bg-primary text-primary-foreground"
class="min-h-11 bg-primary text-primary-foreground"
>
{#if reservationExpired}
Reservation Expired
@@ -2653,7 +2622,7 @@
</p>
<Button
onclick={() => (showPayEarlyModal = true)}
class="bg-amber-600 text-white hover:bg-amber-700"
class="min-h-11 bg-amber-600 text-white hover:bg-amber-700"
>
Pay Deposit Now
</Button>
@@ -2666,7 +2635,7 @@
</p>
<Button
onclick={() => (showPayEarlyModal = true)}
class="bg-emerald-600 text-white hover:bg-emerald-700"
class="min-h-11 bg-emerald-600 text-white hover:bg-emerald-700"
>
Pay Early
</Button>
@@ -2678,7 +2647,7 @@
<Card.Footer class="flex justify-center">
<Button
onclick={() => (window.location.href = authStore.isAuthenticated ? '/schedule' : '/')}
class="w-full"
class="min-h-11 w-full"
>
{authStore.isAuthenticated ? 'View My Bookings' : 'Return Home'}
</Button>
@@ -2809,28 +2778,6 @@
/>
</div>
<!-- B6/B10: saved-card deposits require the customer's
current 2FA verification code when the backend
enforces the gate. -->
<div class="mb-6">
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={twoFactorEnabled}
/>
{#if twoFactor.showInput && twoFactorEnabled}
<Button
variant="outline"
class="min-h-11 w-full"
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
{/if}
</div>
<div class="flex items-center justify-between border-t pt-4">
<Button
variant="ghost"
@@ -2841,7 +2788,7 @@
Back
</Button>
<Button
disabled={isProcessingPayment || !depositCardFormValid || twoFactor.missing}
disabled={isProcessingPayment || !depositCardFormValid}
onclick={() => processPayment(calculateDepositAmount())}
class="min-h-11 bg-primary text-primary-foreground"
>
@@ -39,7 +39,7 @@
</script>
<div
class="inset-y-0 right-0 no-scrollbar flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-56 md:border-t-0 md:border-l"
class="scrollbar-hide inset-y-0 right-0 flex max-h-[50dvh] w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-56 md:border-t-0 md:border-l"
>
{#if groupedTimeSlots.length > 0}
{#if formattedDate}
@@ -57,7 +57,7 @@
onselect(slot.startTime);
}
}}
class={`w-full hover:bg-fuchsia-50 ${slot.startTime === selectedTime ? 'bg-fuchsia-100' : ''}`}
class={`min-h-11 w-full hover:bg-fuchsia-50 ${slot.startTime === selectedTime ? 'bg-fuchsia-100' : ''}`}
>
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
@@ -66,7 +66,7 @@
<!-- Unavailable slot (already booked) -->
<Button
variant="outline"
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
class="min-h-11 w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
disabled
>
{formatTime(slot.startTime)}
@@ -7,9 +7,11 @@
* dynamically imports this module and calls this export when the mock form is
* active, so the saved-card (ccof) 3DS/SCA path runs end to end. The token is
* deterministic and stateless — it encodes the card prefix, the bound amount and
* the encoded outcome (`_ok`) — and the backend dev mock
* (square_dev.go parseVerifyToken) parses the same shape back, so local-dev
* mirrors production without shared state.
* the encoded outcome (`_ok`) — as a GENUINE tokenize-result shape
* (`cnon:sca-<prefix>_<amount>_ok`) that the backend dev mock's saved-card SCA
* gate recognises via isSCATokenizeResultSource (square_dev.go checks the
* `cnon:sca-` prefix), matching Square's real card-on-file SCA contract where
* the tokenize-result token IS the charge source.
*/
export function tokenizeSavedCard(
amount: number,
@@ -22,7 +24,7 @@
const stripped = squareCardId.startsWith('ccof:') ? squareCardId.slice(5) : squareCardId;
const prefix = stripped.slice(0, 4) || 'test';
return Promise.resolve({
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}_ok`,
verificationToken: `cnon:sca-${prefix}_${String(Math.round(amount))}_ok`,
outcome: 'verified'
});
}
@@ -30,6 +32,7 @@
<script lang="ts">
import CardBrandIcon from './CardBrandIcon.svelte';
import { newCardTokenizeResult, type NewCardTokenizeResult } from '$lib/square/square';
import { generateUUID } from '$lib/utils/uuid';
// Deterministic token mapping the backend dev mock (square_dev.go
@@ -46,8 +49,8 @@
onReady = () => {},
// Dev toggles for the mock 3DS/SCA challenge simulation:
// simulateChallenge waits for the "Approve in banking app" button before
// resolving; challengeResult is the deterministic outcome encoded into the
// verification token (verify_mock_<prefix>_<amount>_ok|_deny).
// resolving; challengeResult is the deterministic outcome encoded into
// the SAVED-card SCA verification token (cnon:sca-<prefix>_<amount>_ok|_deny).
simulateChallenge = false,
challengeResult = 'approve'
}: {
@@ -228,19 +231,22 @@
/**
* 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 — it encodes the card prefix,
* the bound amount and the challenge outcome (verify_mock_<prefix>_<amount>_ok|_deny)
* — and the backend dev mock parses it back (square_dev.go
* parseVerifyToken), so the frontend and backend agree on the outcome without
* shared state. The outcome is challengeResult (default approve), optionally
* gated behind the mock "Approve in banking app" panel via simulateChallenge.
* exercises the SCA path end to end. Like the real SDK, a NEW-card
* tokenize-with-verification returns the SCA-verified cnon nonce as the
* charge source and a NULL verificationToken — a separate verification
* token only exists on the saved-card SCA flow (tokenizeSavedCard /
* verifySavedCard). The challenge simulation still gates the call so the
* mock "Approve in banking app" panel can be exercised in dev, but the
* outcome never mints a verification token here: charge surfaces key
* `save_card` on `!verificationToken`, so an always-non-null token would
* silently suppress "Save this card for next time" on new-card flows.
*
* @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.
* mock only embeds the pence value in the SAVED-card SCA
* token (tokenizeSavedCard/verifySavedCard) for deterministic
* identification, so no conversion is needed here.
*/
export async function tokenizeWithVerification(
amount: number,
@@ -250,18 +256,16 @@
email?: string;
},
_saveCard: boolean = false
): Promise<{ nonce: string; verificationToken: string | null }> {
): Promise<NewCardTokenizeResult> {
if (!complete) {
throw new Error('Card details are incomplete');
}
const outcome = await runChallenge();
// The real SDK still runs the SCA challenge on a new-card
// tokenizeWithVerification (the returned nonce IS the SCA-verified
// charge source), so keep the challenge gate for dev parity.
await runChallenge();
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
const prefix = digits.slice(0, 4) || 'test';
const outcomeSuffix = outcome === 'deny' ? '_deny' : '_ok';
return Promise.resolve({
nonce: token,
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}_${outcomeSuffix}`
});
return newCardTokenizeResult(token);
}
/**
@@ -290,7 +294,8 @@
* available for direct use and honours the challenge simulation toggles. The
* token encodes the card prefix (first 4 chars after `ccof:`), the bound
* amount (pence) and the challenge outcome:
* verify_mock_<prefix>_<amount>_ok|_deny.
* cnon:sca-<prefix>_<amount>_ok|_deny — a genuine tokenize-result the
* backend dev mock's SCA gate accepts via the `cnon:sca-` prefix.
*
* @param amount The amount the saved-card charge will be for, in pence.
* @param ccofToken The saved card's ccof: token (the charge source).
@@ -300,7 +305,7 @@
const stripped = ccofToken.startsWith('ccof:') ? ccofToken.slice(5) : ccofToken;
const prefix = stripped.slice(0, 4) || 'test';
const outcomeSuffix = outcome === 'deny' ? '_deny' : '_ok';
return `verify_mock_${prefix}_${String(Math.round(amount))}_${outcomeSuffix}`;
return `cnon:sca-${prefix}_${String(Math.round(amount))}_${outcomeSuffix}`;
}
</script>
@@ -45,9 +45,11 @@
</div>
</div>
<div class="mt-4 flex gap-2">
<Button class="flex-1" {loading} disabled={loading} autofocus onclick={onConfirm}>
<Button class="min-h-11 flex-1" {loading} disabled={loading} autofocus onclick={onConfirm}>
Confirm
</Button>
<Button variant="outline" class="flex-1" disabled={loading} onclick={onCancel}>Cancel</Button>
<Button variant="outline" class="min-h-11 flex-1" disabled={loading} onclick={onCancel}>
Cancel
</Button>
</div>
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Dialog from '$lib/components/ui/dialog';
@@ -13,23 +13,16 @@
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
PAYMENT_METHOD_SAVED_CARD,
runSavedCardSCAProactively,
sanitizeDecimalInput,
scaFallbackConsentFields,
shouldShowSCARefusal,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { generateUUID } from '$lib/utils/uuid';
import { POLICY } from '$lib/constants/policy';
@@ -136,16 +129,6 @@
selectedMethod = null;
}
// B6/B10: charging a customer's saved card requires the customer's current
// 2FA verification code when the backend enforces the gate. The backend keys
// on the CARD OWNER (the booking's user), so the input is surfaced whenever
// the customer has 2FA enabled in an enforced environment — the operator
// relays the customer's code. `twoFactorRequired` is env-wide enforcement
// (true for every session user when the gate is on); the CUSTOMER's setup
// flag is not carried by the admin booking payload, so it is fetched from
// GET /api/admin/users/{id} on mount (see fetchCustomerTwoFactor).
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
let customerTwoFactorEnabled = $state(false);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// fallback); every other outcome keeps SCA primary for the next retry.
@@ -154,34 +137,6 @@
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
let useLoyalty = $state(false);
// B6/B10: charging a customer's saved card requires the customer's current
// 2FA verification code when the backend enforces the gate. Shared
// verification-code state (code, reveal, show/missing derivations, "Request
// a new code" handler) — see $lib/stores/twoFactorCode.svelte.ts. The admin
// always supplies the CUSTOMER's code — the admin's own 2FA flag is
// irrelevant to the backend gate, so `enabled` is always true.
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === PAYMENT_METHOD_SAVED_CARD,
// C6 SCA-only posture: SCA is ALWAYS the authorisation — the code input
// only ever surfaces via a backend gate rejection (defensive/opt-in).
scaAvailable: () => true,
mint: () => {
const customerID = booking.user_id ?? booking.user?.id;
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
}
});
// Focus the verification-code input whenever the saved-card screen shows it
// (auto-show for a 2FA-enabled customer, or the 403 self-heal reveal) so the
// operator can type the customer's code without an extra click.
$effect(() => {
if (status === 'saved-card-selecting' && twoFactor.showInput) {
tick().then(() => document.getElementById('two-factor-code')?.focus());
}
});
// B3: pence already paid against this booking. The AppointmentInfo handed in
// by /api/admin/today/current-next carries no amount_paid/amount_due/
// payments, so this is fetched fresh from the admin booking detail endpoint
@@ -274,30 +229,11 @@
let serviceOverrides = $state<Record<string, ServiceOverride>>({});
// B6/B10: the admin booking payload carries no 2FA state for the owner, so
// the customer's flag is fetched from the admin user detail endpoint (the
// same source the customer-flag fix keys on). A failure leaves the flag
// false — the charge 403 self-heal still reveals the input.
async function fetchCustomerTwoFactor() {
const targetUserId = booking.user_id ?? booking.user?.id;
if (!targetUserId) return;
try {
const res = await apiFetch(`/api/admin/users/${targetUserId}`);
if (res.ok) {
const data = await res.json();
customerTwoFactorEnabled = data?.twoFactorEnabled === true;
}
} catch {
customerTwoFactorEnabled = false;
}
}
$effect(() => {
const uid = booking.user_id ?? booking.user?.id;
if (uid) {
fetchCustomerGiftCardBalance();
fetchSavedCards();
fetchCustomerTwoFactor();
}
const services = booking.services ?? [];
const overrides: Record<string, ServiceOverride> = {};
@@ -946,8 +882,6 @@
// ccof is never sent — and surface the refusal notice; there
// is NO 2FA fallback. The operator taps OK to close, or Back
// to pick a different payment method / retry SCA.
twoFactor.declineConsent();
twoFactor.reveal = false;
status = 'saved-card-selecting';
return;
}
@@ -970,16 +904,9 @@
// (new_card_token) alongside the saved-card ref — never
// the legacy verification_token.
...(verificationToken ? { new_card_token: verificationToken } : {}),
...(twoFactor.showInput && !verificationToken
? { verification_code: twoFactor.code }
: {}),
...scaFallbackConsentFields(twoFactor.consentAccepted),
idempotency_key: savedCardIdempotencyKey
})
}),
// Finding 4: a 2FA-gated charge consumed its code at the backend
// gate — a 503 auto-retry would re-send a dead code and self-defeat.
{ verificationCodeGated: twoFactor.showInput }
})
);
if (!response.ok) {
@@ -1000,10 +927,6 @@
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId,
...(verificationToken ? { new_card_token: verificationToken } : {}),
...(twoFactor.showInput && !verificationToken
? { verification_code: twoFactor.code }
: {}),
...scaFallbackConsentFields(twoFactor.consentAccepted),
idempotency_key: savedCardIdempotencyKey
}
};
@@ -1037,8 +960,6 @@
// charge gets a fresh UUID and can't be deduped against this one.
savedCardIdempotencyKey = '';
savedCardKeyedAmount = 0;
twoFactor.setCode('');
twoFactor.reveal = false;
toast.success('Saved card payment successful');
onComplete(paymentResult);
} catch (_err) {
@@ -1050,17 +971,11 @@
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
if (isVerificationRequiredSignal(responseStatus, bodyText)) {
// M13: a verification-required 402 means the backend did NOT
// accept the fallback code (SCA-only posture / invalid token) —
// withdraw consent so the code input never reappears and the
// modal shows the SCA guidance instead of looping on 2FA.
twoFactor.declineConsent();
// A verification-required 402 means the backend did NOT accept
// the token — surface the SCA-first guidance and let the
// operator retry.
msg = VERIFICATION_REQUIRED_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the charge can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
// A DEFINITIVE 402 (declined card / stale token) means the charge did
// NOT land — Square's idempotency key would otherwise reject a retry
// that re-runs SCA and mints a fresh token. Regenerate the key on 402
@@ -1109,7 +1024,7 @@
handleClose();
}}
>
<Dialog.Content class="max-w-lg">
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
</Dialog.Header>
@@ -1149,7 +1064,6 @@
<input
type="text"
inputmode="decimal"
tabindex={-1}
class="flex h-10 w-24 min-w-0 rounded-md border border-input bg-background px-2 py-1 text-base ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none md:text-sm"
value={serviceOverrides[service.service_id]?.price ??
service.price?.toFixed(2) ??
@@ -1316,7 +1230,7 @@
<button
type="button"
disabled={nothingToCharge}
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
'card'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1340,7 +1254,7 @@
<button
type="button"
disabled={nothingToCharge}
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
'cash'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1365,7 +1279,7 @@
<button
type="button"
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
PAYMENT_METHOD_SAVED_CARD
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1391,7 +1305,7 @@
<button
type="button"
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
'giftcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1422,7 +1336,7 @@
<button
type="button"
disabled={nothingToCharge}
class="text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
class="min-h-11 w-full text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => {
selectedMethod = PAYMENT_METHOD_SAVED_CARD;
status = 'saved-card-selecting';
@@ -1434,7 +1348,7 @@
<button
type="button"
disabled={nothingToCharge}
class="text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
class="min-h-11 w-full text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => {
selectedMethod = 'giftcard';
status = 'gift-entering';
@@ -1445,7 +1359,7 @@
</div>
<div class="flex gap-3">
<Button variant="ghost" onclick={handleClose} class="flex-1">Cancel</Button>
<Button variant="ghost" onclick={handleClose} class="min-h-11 flex-1">Cancel</Button>
</div>
</div>
{:else if status === 'selecting'}
@@ -1473,7 +1387,7 @@
{#each tipPercentages as tip (tip.pct)}
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPercent ===
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none hover:bg-fuchsia-50 {selectedTipPercent ===
tip.pct
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input'}"
@@ -1489,7 +1403,6 @@
<Input
type="text"
inputmode="decimal"
tabindex={-1}
placeholder="Custom tip amount"
value={customTipAmount}
oninput={handleCustomTipInput}
@@ -1516,8 +1429,8 @@
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button onclick={handleCardPayment} class="flex-1" disabled={nothingToCharge}>
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
<Button onclick={handleCardPayment} class="min-h-11 flex-1" disabled={nothingToCharge}>
Charge Card
</Button>
</div>
@@ -1545,7 +1458,6 @@
id="cash-amount"
type="text"
inputmode="decimal"
tabindex={-1}
value={cashAmount}
oninput={handleCashInput}
class="pl-7 text-lg font-semibold"
@@ -1571,10 +1483,10 @@
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
<Button
onclick={handleCashPayment}
class="flex-1"
class="min-h-11 flex-1"
disabled={cashAmountNum < totalDue || nothingToCharge}
>
Confirm Cash
@@ -1662,7 +1574,6 @@
id="gift-card-id"
type="text"
inputmode="text"
tabindex={-1}
value={giftCardId}
oninput={handleGiftCardInput}
placeholder="XXXX-XXXX-XXXX"
@@ -1676,10 +1587,10 @@
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
<Button
onclick={handleGiftCardPayment}
class="flex-1"
class="min-h-11 flex-1"
disabled={!giftCardValid || nothingToCharge}
>
Apply Gift Card
@@ -1721,7 +1632,7 @@
{#each savedCards as card (card.id)}
<button
type="button"
class="w-full rounded-lg border p-3 text-left transition-colors {selectedSavedCardId ===
class="w-full rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {selectedSavedCardId ===
card.id
? 'border-input bg-fuchsia-100'
: 'border-gray-200 hover:bg-gray-50'}"
@@ -1781,35 +1692,12 @@
}}
/>
<!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. -->
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={true}
/>
{#if twoFactor.showInput}
<p class="mt-1 text-xs text-gray-500">
Enter the customer's verification code — not your own. The customer can request a fresh
code from their account.
</p>
<Button
variant="outline"
class="min-h-11 w-full"
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
<Button
onclick={handleSavedCardPayment}
class="min-h-11 flex-1"
disabled={!selectedSavedCardId || nothingToCharge || twoFactor.missing}
disabled={!selectedSavedCardId || nothingToCharge}
>
Charge Saved Card
</Button>
@@ -1837,8 +1725,8 @@
<p class="text-sm text-red-800">{error}</p>
</div>
<div class="flex gap-3">
<Button variant="ghost" onclick={handleClose} class="flex-1">Close</Button>
<Button onclick={resetToSelect} class="flex-1">Try Again</Button>
<Button variant="ghost" onclick={handleClose} class="min-h-11 flex-1">Close</Button>
<Button onclick={resetToSelect} class="min-h-11 flex-1">Try Again</Button>
</div>
</div>
{:else if status === 'success' && paymentResult}
@@ -1884,7 +1772,7 @@
</div>
</div>
<Button onclick={handleSuccessDone} class="w-full">Done</Button>
<Button onclick={handleSuccessDone} class="min-h-11 w-full">Done</Button>
</div>
{/if}
</Dialog.Content>
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mount, unmount } from 'svelte';
import ScaFallbackConsentDialog from './ScaFallbackConsentDialog.svelte';
import { SCA_REFUSAL_MESSAGE_ONLINE } from '$lib/square/square';
type DialogProps = {
open?: boolean;
onOk: () => void;
message?: string;
};
type Mounted = ReturnType<typeof mount>;
function mountDialog(props: DialogProps): Mounted {
return mount(ScaFallbackConsentDialog, { target: document.body, props });
}
// C6 SCA-unavailable refusal flow: the dialog is the ONLY action a genuine
// `sca-unavailable` outcome may show (the homegrown 2FA fallback was removed —
// PSR 2017 SCA is non-waivable), so it must render the refusal copy and never
// offer a verification-code path. The OK button just closes the flow.
describe('ScaFallbackConsentDialog', () => {
afterEach(() => {
unmountAll();
vi.restoreAllMocks();
});
const mounted: Array<Mounted> = [];
function unmountAll() {
while (mounted.length) {
const instance = mounted.pop();
if (instance) unmount(instance);
}
document.body.innerHTML = '';
}
function mountTracked(props: DialogProps) {
const instance = mountDialog(props);
mounted.push(instance);
return instance;
}
it('renders nothing when closed', () => {
mountTracked({ onOk: () => {} });
expect(document.querySelector('[role="alertdialog"]')).toBeNull();
});
it('shows the SCA-unavailable refusal message when open', () => {
mountTracked({ open: true, onOk: () => {} });
const dialog = document.querySelector('[role="alertdialog"]');
expect(dialog).not.toBeNull();
expect(dialog?.textContent).toContain("Payment can't be processed right now");
expect(dialog?.textContent).toContain(SCA_REFUSAL_MESSAGE_ONLINE);
});
it('renders the surface-specific message when one is supplied (till variant)', () => {
const tillMessage = "we'll take you back to the till so you can pay online later";
mountTracked({ open: true, onOk: () => {}, message: tillMessage });
expect(document.querySelector('[role="alertdialog"]')?.textContent).toContain(tillMessage);
});
it('invokes onOk when the OK button is clicked', () => {
const onOk = vi.fn();
mountTracked({ open: true, onOk });
const button = document.querySelector('button');
expect(button).not.toBeNull();
(button as HTMLButtonElement).click();
expect(onOk).toHaveBeenCalledTimes(1);
});
it('never offers a verification-code action (C6 SCA-only posture)', () => {
mountTracked({ open: true, onOk: () => {} });
const dialog = document.querySelector('[role="alertdialog"]');
expect(dialog?.textContent).not.toMatch(/verification code|2FA|text message|enter.*code/i);
// Exactly one action button: the OK close.
expect(dialog?.querySelectorAll('button')).toHaveLength(1);
});
});
@@ -7,7 +7,6 @@
import { parseWallClockDate } from '$lib/utils/timeSlots';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Card from '$lib/components/ui/card';
@@ -19,16 +18,13 @@
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
isNonceStale,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
runSavedCardSCAProactively,
sanitizeDecimalInput,
scaFallbackConsentFields,
shouldShowSCARefusal,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
// Shared tip-payment UI used by /tip, /pay-tip/[id] and the account
@@ -118,14 +114,6 @@
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
// B6/B10: saved-card tips (and saving a new card for reuse) require the
// customer's current 2FA verification code whenever the backend enforces the
// gate. The input is surfaced at the charge step; the new-card (nonce) path
// keeps its own SCA via Square tokenizeWithVerification. Shared
// two-factor-code state (code, reveal, show/missing derivations, "Request a
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// fallback); every other outcome keeps SCA primary for the next retry.
@@ -137,13 +125,6 @@
// cancelled/failed, decline) — surfaced so the panel text matches the
// specific outcome instead of the generic "Payment failed".
let tipError = $state<string | null>(null);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
// C6 SCA-only posture: SCA is ALWAYS the authorisation — the code input
// only ever surfaces via a backend gate rejection (defensive/opt-in).
scaAvailable: () => true
});
const isCardValid = $derived(cardSelectionValid);
@@ -344,15 +325,13 @@
toast.error(tipError);
return;
}
if (proactive.outcome === 'sca-unavailable') {
// C6: SCA genuinely can't run. Abort this attempt BEFORE
// any charge is submitted and surface the refusal notice —
// there is NO 2FA fallback; the tip is paid online later.
twoFactor.declineConsent();
twoFactor.reveal = false;
paymentState = 'idle';
return;
}
if (proactive.outcome === 'sca-unavailable') {
// C6: SCA genuinely can't run. Abort this attempt BEFORE
// any charge is submitted and surface the refusal notice —
// there is NO 2FA fallback; the tip is paid online later.
paymentState = 'idle';
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
waitingForSCA = false;
@@ -362,27 +341,20 @@
amount: amountInPence,
idempotency_key: tipIdempotencyKey,
...(selectedCardId ? { card_id: selectedCardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
// C1: the SCA tokenize-result token for a saved card is the
// charge SOURCE (new_card_token) alongside the card ref —
// never the legacy verification_token.
...(verificationToken ? { new_card_token: verificationToken } : {}),
...(twoFactor.showInput && !verificationToken
? { verification_code: twoFactor.code }
: {}),
...scaFallbackConsentFields(twoFactor.consentAccepted)
...(newCardToken || verificationToken
? {
new_card_token: newCardToken ?? verificationToken,
save_card: saveCard && !verificationToken
}
: {})
};
const response = await submitPaymentWithRetry(
() =>
apiFetch(`/api/bookings/${booking.id}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
// Finding 4: a 2FA-gated charge consumed its code at the
// backend gate — a 503 auto-retry would re-send a dead code.
{ verificationCodeGated: twoFactor.showInput && !verificationToken }
const response = await submitPaymentWithRetry(() =>
apiFetch(`/api/bookings/${booking.id}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
);
if (!response.ok) {
@@ -409,8 +381,6 @@
tipTokenAmount = 0;
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
twoFactor.setCode('');
twoFactor.reveal = false;
toast.success('Thank you for your tip!');
onSuccess?.();
} catch (err) {
@@ -423,19 +393,11 @@
const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
if (verificationFailure) {
// M13: a verification-required 402 means the backend did NOT
// accept the fallback code (SCA-only posture / invalid token)
// — withdraw consent so the code input never reappears and
// the user sees the SCA guidance instead of looping on 2FA.
twoFactor.declineConsent();
// A verification-required 402 means the backend did NOT accept
// the token — surface the SCA-first guidance and let the user
// retry.
errorMessage = VERIFICATION_REQUIRED_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the tip can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) {
twoFactor.reveal = true;
}
tipError = errorMessage;
toast.error(errorMessage);
// A definitive charge failure (e.g. declined card) consumes the nonce
@@ -615,25 +577,6 @@
onCancel?.();
}}
/>
<!-- B6/B10: saved-card tips require the customer's current 2FA
verification code when the backend enforces the gate. -->
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={twoFactorEnabled}
/>
{#if twoFactor.showInput && twoFactorEnabled}
<Button
variant="outline"
class="min-h-11 w-full"
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
{/if}
</div>
</Card.Content>
</Card.Root>
@@ -664,9 +607,9 @@
{/if}
<Button
class="min-h-11 w-full"
class="min-h-11 w-full active:bg-primary/85 active:shadow-none"
size="lg"
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing' || twoFactor.missing}
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing'}
loading={paymentState === 'processing'}
onclick={submitTip}
>
@@ -1,20 +1,11 @@
<!--
TwoFactorCodeInput.svelte — B6/B10 2FA verification-code input for saved-card
charges. With Square 3DS SCA now the PRIMARY authorisation for saved-card
(ccof) charges, this input is the BACKUP path: it shows only when the charge
hits the backend's requireTwoFactorForCardAccess gate and SCA couldn't
authorise (sca-unavailable), or a charge 403/SCA-failure has revealed it. The
backend requires the CARD OWNER's current one-time code on every 2FA-gated
saved-card charge; this input collects it so the charge body carries
`verification_code`. Shared by the customer booking, tip, admin booking and
till saved-card surfaces so the field name, hint copy and the
enabled/not-enabled presentation can't drift between them.
When `enabled` is false (the session user has not completed 2FA setup) the
editable input is replaced by an "enable 2FA in your account settings" hint
— the charge cannot succeed without a setup code. Admin surfaces pass
`enabled` regardless of the admin's own flag: the operator relays the
CUSTOMER's code.
TwoFactorCodeInput.svelte — 2FA verification-code input used by the
ACCOUNT 2FA setup/disable verification flows. Saved-card CHARGES are
authorised exclusively by Square PSD2 SCA (tokenizeWithVerification) — the
backend is SCA-only and never accepts a verification code on a charge
(402 verification_required) — so no payment surface renders this input. The
component is kept for the account flows, which collect `verification_code`
for account actions rather than card charges.
-->
<script lang="ts">
import { Input } from '$lib/components/ui/input';
@@ -8,13 +8,11 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import type { Booking } from '$lib/types/booking';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import { authStore } from '$lib/stores/auth.svelte';
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { apiFetch } from '$lib/utils/api';
import { generateUUID } from '$lib/utils/uuid';
import { formatCurrency } from '$lib/utils/format';
@@ -24,11 +22,9 @@
depositChargePence,
isNonceStale,
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
runSavedCardSCAProactively,
sanitizeDecimalInput,
scaFallbackConsentFields,
shouldShowSCARefusal,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
@@ -55,14 +51,6 @@
// the checkbox inside CardSelection; defaults to false (opt-in).
let saveCard = $state(false);
// B6/B10: saved-card charges (and saving a new card for reuse) require the
// customer's current 2FA verification code whenever the backend enforces the
// gate. The input is surfaced at the charge step; the new-card (nonce) path
// keeps its own SCA via Square tokenizeWithVerification. Shared
// two-factor-code state (code, reveal, show/missing derivations, "Request a
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// fallback); every other outcome keeps SCA primary for the next retry.
@@ -72,13 +60,6 @@
// buyer approves in their banking app), so the form shows a waiting panel
// and blocks modal close until it resolves.
let waitingForSCA = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
// C6 SCA-only posture: SCA is ALWAYS the authorisation — the code input
// only ever surfaces via a backend gate rejection (defensive/opt-in).
scaAvailable: () => true
});
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
@@ -367,6 +348,22 @@
await savedCardsStore.fetch();
}
// Fetch payment methods + loyalty on mount if authenticated. The
// paymentMethodsFetched guard makes this run exactly ONCE per mount: the
// effect body re-runs on unrelated state changes, but the guard short-
// circuits before loadSavedCards() can read/write any tracked state, so no
// fetch can be triggered by the store's loading toggle (the root cause of
// the unbounded GET /api/user/payment-methods refetch loop for users with
// zero saved cards).
let paymentMethodsFetched = $state(false);
$effect(() => {
if (authStore.isAuthenticated && !paymentMethodsFetched) {
paymentMethodsFetched = true;
loadSavedCards();
fetchLoyaltyData();
}
});
async function fetchLoyaltyData() {
if (!authStore.isAuthenticated) return;
try {
@@ -526,8 +523,6 @@
// C6: SCA genuinely can't run. Abort this attempt BEFORE any
// charge is submitted and surface the refusal notice — there
// is NO 2FA fallback; the user pays online later.
twoFactor.declineConsent();
twoFactor.reveal = false;
status = 'idle';
return;
}
@@ -574,21 +569,21 @@
payment_type: paymentType,
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
// C1: the SCA tokenize-result token for a saved card is
// the charge SOURCE (new_card_token) alongside the
// card ref — never the legacy verification_token.
...(verificationToken ? { new_card_token: verificationToken } : {}),
...(twoFactor.showInput && !verificationToken
? { verification_code: twoFactor.code }
// The charge SOURCE is the one-time token: a NEW card's
// nonce (cnon:...) or a saved card's SCA tokenize-result.
// They are mutually exclusive today, but an explicit
// precedence prevents a future path from silently
// overwriting one with the other (which destroyed the
// nonce and 503'd the charge).
...(newCardToken || verificationToken
? {
new_card_token: newCardToken ?? verificationToken,
save_card: saveCard && !verificationToken
}
: {}),
...scaFallbackConsentFields(twoFactor.consentAccepted),
idempotency_key: payIdempotencyKey
})
}),
// Finding 4: a 2FA-gated charge consumed its code at the backend
// gate — a 503 auto-retry would re-send a dead code and self-defeat.
{ verificationCodeGated: twoFactor.showInput && !verificationToken }
})
);
if (!response.ok) {
@@ -646,8 +641,6 @@
newCardTokenAmount = 0;
newCardTokenizedAt = 0;
newCardTokenizedForSaveCard = false;
twoFactor.setCode('');
twoFactor.reveal = false;
// The backend skips the Square charge entirely when an eligible
// campaign discount covers the whole deposit
// (`deposit_covered_by_discount` — a £0 charge is invalid at
@@ -678,17 +671,11 @@
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
if (verificationFailure) {
// M13: a verification-required 402 means the backend did NOT
// accept the fallback code (SCA-only posture / invalid token) —
// withdraw consent so the code input never reappears and the
// user sees the SCA guidance instead of looping on 2FA.
twoFactor.declineConsent();
// A verification-required 402 means the backend did NOT accept
// the token — surface the SCA-first guidance and let the user
// retry.
msg = VERIFICATION_REQUIRED_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the charge can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
error = msg;
toast.error(verificationFailure ? msg : `${msg}. Please try again or use another card.`);
// A definitive charge failure consumes the nonce + SCA verification
@@ -781,14 +768,6 @@
onClose();
}
// Fetch payment methods + loyalty on mount if authenticated
$effect(() => {
if (authStore.isAuthenticated) {
loadSavedCards();
fetchLoyaltyData();
}
});
// Cleanup on unmount
$effect(() => {
return () => {
@@ -1053,7 +1032,7 @@
{/if}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Amount Paid</span>
<span class="font-medium text-green-700">{formatCurrency(totalPaid / 100)}</span>
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
</div>
{#if useLoyalty && loyaltyDiscount > 0}
<div class="flex justify-between text-sm">
@@ -1112,25 +1091,6 @@
}}
/>
<!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. -->
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={twoFactorEnabled}
/>
{#if twoFactor.showInput && twoFactorEnabled}
<Button
variant="outline"
class="min-h-11 w-full"
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
{/if}
{#if depositPolicyWarning}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<p class="font-semibold text-amber-900">Cancellation &amp; Deposit Policy</p>
@@ -1150,7 +1110,7 @@
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {paymentType ===
'deposit'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1160,7 +1120,7 @@
</button>
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {paymentType ===
'full'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1175,7 +1135,7 @@
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
class="min-h-11 w-full"
loading={status === 'processing'}
disabled={payButtonDisabled || twoFactor.missing}
disabled={payButtonDisabled}
>
{#if paymentType === 'deposit'}
Pay Deposit ({formatCurrency(
@@ -1208,7 +1168,7 @@
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {paymentType ===
'full'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1218,7 +1178,7 @@
</button>
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {paymentType ===
'partial'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1259,7 +1219,7 @@
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
class="min-h-11 w-full"
loading={status === 'processing'}
disabled={payButtonDisabled || twoFactor.missing}
disabled={payButtonDisabled}
>
{#if paymentType === 'partial'}
Pay {partialAmountValid
@@ -1,5 +1,6 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { toast } from 'svelte-sonner';
import { formatDuration } from '$lib/utils/format';
@@ -15,9 +16,10 @@
interface Props {
openEditBookingModal: (bookingId: string, nextAppointmentStart?: string | null) => void;
openUserModal: (userId: string) => void;
openBookingModal?: (bookingId: string) => void;
}
const { openEditBookingModal, openUserModal }: Props = $props();
const { openEditBookingModal, openUserModal, openBookingModal }: Props = $props();
type Booking = {
id: string;
@@ -82,6 +84,7 @@
let timeRemaining = $state(0);
let isInProgress = $state(false);
let showPaymentModal = $state(false);
let beginning = $state(false);
// Done-for-the-day state
let doneForDay = $state(false);
@@ -244,8 +247,30 @@
};
});
function handleBegin() {
toast.info('Begin appointment - Coming soon');
async function handleBegin() {
if (!activeAppointment) return;
beginning = true;
try {
const response = await apiFetch(`/api/admin/bookings/${activeAppointment.id}/progress`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: 'in_progress' })
});
if (response.ok) {
toast.success('Appointment started');
// Refreshes this card plus TodayCalendar/TodayStats via their listeners.
window.dispatchEvent(new CustomEvent('bookingApproved'));
} else {
const text = await response.text();
toast.error('Failed to start appointment: ' + extractErrorMessage(text));
}
} catch {
toast.error('Network error starting appointment');
} finally {
beginning = false;
}
}
function handleEdit() {
@@ -268,7 +293,15 @@
}
function handleCancel() {
toast.info('Cancel appointment - Coming soon');
// The backend cancel route (POST /api/admin/bookings/{id}/cancel) has a
// refund-aware confirmation flow — route through the booking details
// modal so the operator sees the refund impact before cancelling.
if (!activeAppointment) return;
if (openBookingModal) {
openBookingModal(activeAppointment.id);
} else {
toast.info('Use the booking details to cancel this appointment');
}
}
function isMilestone(visits: number): boolean {
@@ -752,8 +785,8 @@
</div>
{/if}
<div class="grid grid-cols-2 gap-2">
{#if !isInProgress}
<Button size="sm" onclick={handleBegin} class="col-span-2">
{#if !isInProgress && activeAppointment?.status === 'confirmed'}
<Button size="sm" onclick={handleBegin} disabled={beginning} class="col-span-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
@@ -766,7 +799,7 @@
clip-rule="evenodd"
/>
</svg>
Begin
{beginning ? 'Starting...' : 'Begin'}
</Button>
{/if}
<Button size="sm" variant="outline" onclick={handleEdit}>
@@ -4,7 +4,7 @@
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 relative inline-flex shrink-0 items-center justify-center gap-2 overflow-hidden rounded-md text-sm font-medium whitespace-nowrap outline-hidden transition-all select-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
base: "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 relative inline-flex shrink-0 items-center justify-center gap-2 overflow-hidden rounded-md text-sm font-medium whitespace-nowrap outline-hidden transition-all select-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 active:opacity-80 active:scale-[0.99] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-2xs',
@@ -116,7 +116,7 @@
{@render children?.()}
{#if !hideClose}
<DialogPrimitive.Close
class="absolute top-4 right-4 flex h-10 w-10 items-center justify-center rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0"
class="absolute top-4 right-4 flex h-11 w-11 items-center justify-center rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<XIcon />
<span class="sr-only">Close</span>
@@ -37,6 +37,8 @@
<button
type="button"
onclick={toggle}
aria-haspopup="menu"
aria-expanded={open}
class="inline cursor-pointer text-xs underline hover:text-amber-700"
>
{#if trigger}
@@ -48,12 +50,14 @@
{#if open}
<div
class="absolute top-full left-0 z-50 mt-1 w-48 rounded-md border border-gray-200 bg-white p-2 shadow-lg"
role="menu"
class="absolute top-full left-1/2 z-50 mt-1 w-48 max-w-[calc(100vw-3rem)] -translate-x-1/2 rounded-md border border-gray-200 bg-white p-2 shadow-lg"
>
<a
{href}
target="_blank"
rel="noopener noreferrer external"
role="menuitem"
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
onclick={() => (open = false)}
>
@@ -61,6 +65,7 @@
</a>
<button
type="button"
role="menuitem"
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
onclick={() => {
open = false;
+10
View File
@@ -0,0 +1,10 @@
// Shared support-contact constants (see Technical Manual pre-launch checklist).
// TODO pre-launch: set the real support email address.
export const SUPPORT_EMAIL = 'support@crussell.salon';
// TODO pre-launch: set the real salon phone number. 01632 is Ofcom's reserved
// UK range for fictional use, so this can never reach a real person.
export const SUPPORT_PHONE = '+44 1632 960000';
export const BUSINESS_ADDRESS = '41 Pollock Walk, Dunfermline KY12 9DA';
export const BUSINESS_NAME = 'Crussell Salon';
// TODO pre-launch: verify the owner's legal name for the trader identity.
export const TRADER_LEGAL_NAME = 'Chelsea Russell trading as Crussell Salon';
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { POLICY } from './policy';
// The policy constants are single-sourced against the backend:
// backend/handlers/payments/refund_policy.go (RequiredDepositPct 0.20,
// ProtectedDepositMaxPct 0.50, FullRefundThreshold 72h,
// PartialRefundThreshold 24h, NoShowThreshold 24h, DepositAdvanceWindow 36h).
// The backend runs a cross-check test that READS this file and asserts the same
// values (payments/policy_ts_crosscheck_test.go), so a drift in either
// direction fails CI. Any change here MUST be mirrored in refund_policy.go.
describe('POLICY constants', () => {
it('deposit percentages match backend refund_policy.go', () => {
expect(POLICY.REQUIRED_DEPOSIT_PCT).toBe(0.2);
expect(POLICY.PROTECTED_DEPOSIT_MAX_PCT).toBe(0.5);
});
it('refund thresholds match backend refund_policy.go', () => {
expect(POLICY.FULL_REFUND_THRESHOLD_HOURS).toBe(72);
expect(POLICY.PARTIAL_REFUND_THRESHOLD_HOURS).toBe(24);
expect(POLICY.NO_SHOW_THRESHOLD_HOURS).toBe(24);
expect(POLICY.DEPOSIT_ADVANCE_HOURS).toBe(36);
});
it('reschedule-block hours track the backend refund thresholds', () => {
// No named backend reschedule constants exist; the values are derived
// from the refund tiers (FullRefundThreshold / PartialRefundThreshold).
expect(POLICY.RESCHEDULE_BLOCK_HOURS_WITH_PAYMENTS).toBe(72);
expect(POLICY.RESCHEDULE_BLOCK_HOURS_NO_PAYMENTS).toBe(24);
});
});
+133 -3
View File
@@ -17,6 +17,7 @@ import {
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
newCardTokenizeResult,
parseTokenizeVerificationResult,
requestNewTwoFactorCode,
sanitizeDecimalInput,
@@ -427,6 +428,37 @@ describe('parseTokenizeVerificationResult', () => {
});
});
describe('new-card tokenizeWithVerification contract (dev/mock parity with the real SDK)', () => {
// The real Square Web Payments SDK returns `{ nonce, verificationToken:
// null }` from a NEW-card tokenizeWithVerification — the SCA-verified cnon
// nonce IS the charge source, and a separate verificationToken exists only
// on the saved-card-on-file SCA flow. The dev mock must mirror this exactly:
// the charge surfaces send `new_card_token: newCardToken ?? verificationToken`
// and gate `save_card: X && !verificationToken`, so an always-non-null mock
// verificationToken silently suppressed "Save this card for next time" on
// new-card flows in dev. MockCardForm.tokenizeWithVerification builds its
// result through newCardTokenizeResult (the shared single source), so these
// tests pin the contract the mock and the real form both honour.
it('returns the cnon nonce as the charge source and a NULL verificationToken', () => {
expect(newCardTokenizeResult('cnon:test-card')).toEqual({
nonce: 'cnon:test-card',
verificationToken: null
});
});
it('keeps the cnon: nonce prefix the backend dev mock accepts as a charge source', () => {
expect(newCardTokenizeResult('cnon:visa').nonce).toMatch(/^cnon:/);
});
it('pins the save_card gate: the nonce wins the charge source and the save stays live', () => {
const { nonce, verificationToken } = newCardTokenizeResult('cnon:test-card');
// `new_card_token: newCardToken ?? verificationToken` → the nonce is used.
expect(nonce ?? verificationToken).toBe('cnon:test-card');
// `save_card: X && !verificationToken` → the save is honoured (regression pin).
expect(verificationToken).toBeNull();
});
});
describe('isTwoFactorVerificationGateFailure', () => {
it.each([
[403, 'A two-factor verification code is required to use this saved card', true],
@@ -778,7 +810,7 @@ describe('runSavedCardSCAProactively', () => {
it('maps a verified tokenize result and records it via onOutcome', async () => {
mockSCATokenize.fn.mockResolvedValue({
verificationToken: 'verify_mock_ok',
verificationToken: 'cnon:sca-test_5000_ok',
outcome: 'verified'
});
const mod = await loadSquareInMockMode();
@@ -789,7 +821,7 @@ describe('runSavedCardSCAProactively', () => {
onOutcome: (o) => outcomes.push(o)
});
expect(res.outcome).toBe('verified');
expect(res.verificationToken).toBe('verify_mock_ok');
expect(res.verificationToken).toBe('cnon:sca-test_5000_ok');
expect(outcomes).toEqual(['verified']);
});
@@ -865,9 +897,107 @@ describe('runSavedCardSCAProactively', () => {
onOutcome: (o) => outcomes.push(o)
});
expect(res.outcome).toBe('verified');
expect(res.verificationToken).toMatch(/^verify_mock_/);
expect(res.verificationToken).toMatch(/^cnon:sca-/);
expect(outcomes).toEqual(['verified']);
});
it('mints saved-card SCA tokens the backend mock accepts (cnon:sca- prefix)', async () => {
// The backend dev mock's saved-card SCA gate (square_dev.go
// isSCATokenizeResultSource) only accepts a GENUINE tokenize-result —
// a cnon: source carrying the `cnon:sca-` marker. The dev frontend's
// deterministic token must match that prefix so the mock charge lands
// in dev exactly as a real Square tokenize-result would.
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
const mod = await loadSquareInMockMode();
const res = await mod.runSavedCardSCAProactively({
amountPence: 5000,
squareCardId: 'ccof:mock_123',
onOutcome: () => {}
});
expect(res.verificationToken).toMatch(/^cnon:sca-mock_5000_ok$/);
});
});
describe('dev mock token-shape parity with the backend gate', () => {
async function loadSquareInMockMode(): Promise<typeof SquareModule> {
vi.resetModules();
vi.stubEnv('VITE_SQUARE_ENVIRONMENT', 'mock');
vi.stubEnv('VITE_SQUARE_APPLICATION_ID', '');
vi.stubEnv('VITE_SQUARE_LOCATION_ID', '');
vi.stubEnv('DEV', true);
return await import('./square');
}
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
mockSCATokenize.fn.mockClear();
});
// The backend dev mock's isSCATokenizeResultSource accepts a cnon: source
// ONLY when it carries the `cnon:sca-` marker (or the legacy verify_mock_
// transition shape). The frontend's deterministic fallback token must stay
// inside that accepted set or dev saved-card charges fail at the mock.
it('the deterministic token satisfies the backend isSCATokenizeResultSource predicate', async () => {
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
const mod = await loadSquareInMockMode();
const res = await mod.runSavedCardSCAProactively({
amountPence: 2500,
squareCardId: 'ccof:1234567890',
onOutcome: () => {}
});
expect(res.outcome).toBe('verified');
// Predicate parity — the same test the backend mock runs:
// isSCATokenizeResultSource = cnon:sca-* || verify_mock_*.
expect(res.verificationToken).toMatch(/^cnon:sca-/);
});
it('derives the token prefix from the first 4 chars of the ccof card id', async () => {
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
const mod = await loadSquareInMockMode();
const res = await mod.runSavedCardSCAProactively({
amountPence: 2500,
squareCardId: 'ccof:1234567890',
onOutcome: () => {}
});
expect(res.verificationToken).toBe('cnon:sca-1234_2500_ok');
});
it('falls back to the test prefix when the card id yields no 4-char prefix', async () => {
mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure'));
const mod = await loadSquareInMockMode();
const res = await mod.runSavedCardSCAProactively({
amountPence: 2500,
squareCardId: 'ccof:',
onOutcome: () => {}
});
expect(res.verificationToken).toBe('cnon:sca-test_2500_ok');
});
});
describe('backend 402 error-body parity (errors.go writeVerificationRequiredResponse)', () => {
it('VERIFICATION_REQUIRED_MESSAGE matches the backend response text verbatim', () => {
// backend/handlers/payments/errors.go writes:
// {"error": "Your card issuer requires verification. Approve this
// payment in your banking app.", "code": "verification_required"}
// The frontend must key the SCA challenge on the byte-identical copy.
expect(VERIFICATION_REQUIRED_MESSAGE).toBe(
'Your card issuer requires verification. Approve this payment in your banking app.'
);
});
it('isVerificationRequiredSignal detects the exact structured 402 body the backend writes', () => {
const backendBody = JSON.stringify({
error: 'Your card issuer requires verification. Approve this payment in your banking app.',
code: 'verification_required'
});
expect(isVerificationRequiredSignal(402, backendBody)).toBe(true);
});
it('isVerificationRequiredSignal rejects a backend body carrying a different code', () => {
const body = JSON.stringify({ error: 'Payment failed', code: 'card_declined' });
expect(isVerificationRequiredSignal(402, body)).toBe(false);
});
});
// The twoFactorCode.svelte.ts composable is tested under Svelte 5 rune stubs:
+30 -3
View File
@@ -205,6 +205,28 @@ export interface SavedCardVerificationResult {
outcome: SavedCardVerificationOutcome;
}
/** Result of a NEW-card `tokenizeWithVerification` the SCA-verified cnon
* nonce is the charge source and there is NO separate verificationToken. This
* is the exact contract of the real Square Web Payments SDK (`card.tokenize()`
* returns the verified token in the single `token` field a separate
* verification token only exists on the saved-card-on-file SCA flow), and it
* is what the charge surfaces depend on: they send
* `new_card_token: newCardToken ?? verificationToken` and gate
* `save_card: X && !verificationToken`, so a new-card tokenize MUST yield a
* null verificationToken or the save is silently suppressed. */
export interface NewCardTokenizeResult {
nonce: string;
verificationToken: null;
}
/** Builds a new-card tokenize-with-verification result: the cnon nonce is the
* charge source, verificationToken is always null (mirroring the real SDK).
* The dev mock (MockCardForm.tokenizeWithVerification) returns this so it can
* never drift from the real contract. */
export function newCardTokenizeResult(nonce: string): NewCardTokenizeResult {
return { nonce, verificationToken: null };
}
/** Square Web Payments `card.tokenize()` result shape. Per the CURRENT SDK
* (Square.js /v1/), tokenize returns `{ status, token, details?, errors }`
* the SCA-verified token for both the new-card and the card-on-file
@@ -292,8 +314,13 @@ export async function tokenizeSavedCardWithVerification(
if (isSquareMock()) {
// DEV-ONLY mock: the mock agent extends MockCardForm with a saved-card
// SCA method. Use it when present (so the mock exercises the same
// challenge path), otherwise fall back to a deterministic fake token
// the backend dev mock accepts.
// challenge path), otherwise fall back to a deterministic tokenize-result
// token the backend dev mock accepts. The token is a GENUINE
// tokenize-result shape — `cnon:sca-<prefix>_<amount>_ok` — so the
// backend mock's saved-card SCA gate recognises it via
// isSCATokenizeResultSource (square_dev.go checks the `cnon:sca-`
// prefix) instead of the old `verify_mock_...` shape, which real Square
// would never accept as a charge source.
try {
const mockModule = (await import('$lib/components/payments/MockCardForm.svelte')) as {
tokenizeSavedCard?: (
@@ -318,7 +345,7 @@ export async function tokenizeSavedCardWithVerification(
}
const prefix = squareCardId.replace(/^ccof:/, '').slice(0, 4) || 'test';
return {
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}`,
verificationToken: `cnon:sca-${prefix}_${String(Math.round(amount))}_ok`,
outcome: 'verified'
};
}
+6 -1
View File
@@ -20,7 +20,12 @@ function createSavedCardsStore() {
let loaded = $state(false);
async function load() {
if (loading) return;
// Idempotent: once a fetch has succeeded, subsequent load() calls are
// no-ops so a caller's $effect can't re-trigger a refetch (which, by
// reading AND writing the tracked `loading` state synchronously, would
// re-run the effect forever). invalidate() deliberately resets `loaded`
// first, so a forced refresh still works.
if (loading || loaded) return;
loading = true;
try {
const res = await apiFetch('/api/user/payment-methods');
+216 -30
View File
@@ -17,8 +17,8 @@
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
requestNewTwoFactorCode,
runSavedCardSCAProactively,
scaFallbackConsentFields,
shouldShowSCARefusal,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
@@ -247,6 +247,10 @@
let buyAmount = $state<10 | 20 | 50>(10);
let buyRecipientType = $state<'self' | 'friend'>('self');
let buyRecipientEmail = $state('');
// Express acknowledgement that a self-purchase (auto-redeem) forfeits the
// 14-day statutory cancellation right (CCR 2013) — required before the buy
// button enables for the self option. Friends receive a cancellable code.
let buySelfAck = $state(false);
let buySelectedCard = $state('');
let buyingGiftCard = $state(false);
// Synchronous double-click guard for buyGiftCard. buyingGiftCard is only set
@@ -577,16 +581,34 @@
amount: buyAmount * 100, // pence
recipient_type: buyRecipientType,
recipient_email: buyRecipientEmail,
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
// C1: the SCA tokenize-result token for a saved card
// is the charge SOURCE (new_card_token) alongside
// the card ref — never the legacy verification_token.
...(verificationToken ? { new_card_token: verificationToken } : {}),
// SCA path (verificationToken present): the saved-card ref
// goes in saved_card_id — the CANONICAL saved-card SCA wire
// shape (the booking/tip surfaces send the same
// user_saved_cards.id under saved_card_id, and the backend's
// resolveChargeSource reads either field). ValidateCardInfo
// rejects card_id + new_card_token coexisting, so the SCA
// path must NOT send card_id. The legacy/2FA-fallback path
// (no verificationToken) keeps card_id.
...(cardId
? verificationToken
? { saved_card_id: cardId }
: { card_id: cardId }
: {}),
// The charge SOURCE is the one-time token: a NEW card's
// nonce (cnon:...) or a saved card's SCA tokenize-result.
// They are mutually exclusive today, but an explicit
// precedence prevents a future path from silently
// overwriting one with the other (which destroyed the
// nonce and 503'd the charge).
...(newCardToken || verificationToken
? {
new_card_token: newCardToken ?? verificationToken,
save_card: buySaveCard && !verificationToken
}
: {}),
...(buyTwoFactor.showInput && !verificationToken
? { verification_code: buyTwoFactor.code }
: {}),
...scaFallbackConsentFields(buyTwoFactor.consentAccepted),
idempotency_key: buyIdempotencyKey
})
}),
@@ -1544,19 +1566,80 @@
let showDeleteAlert = $state(false);
let deleteConfirmText = $state('');
let deletingAccount = $state(false);
let deleteCurrentPassword = $state('');
let deleteVerificationCode = $state('');
let deleteSendingCode = $state(false);
let deleteError = $state('');
// Revealed server-side: the DELETE endpoint demands a 2FA code even though
// the loaded profile said 2FA was off (enforcement/2FA state changed since
// page load) — the code entry must surface so the deletion can complete.
let deleteRevealTwoFactor = $state(false);
// Mirrors the backend gate (twoFARequired() && twoFactorEnabled): show the
// verification-code step only when 2FA is enforced AND active for the user.
const deleteTwoFactorRequired = $derived(
!!authStore.currentUser?.twoFactorRequired && !!authStore.currentUser?.twoFactorEnabled
);
function resetDeleteDialog() {
deleteConfirmText = '';
deleteCurrentPassword = '';
deleteVerificationCode = '';
deleteError = '';
deleteRevealTwoFactor = false;
}
// Mint a fresh 2FA verification code for the account (POST /api/user/2fa/code —
// the same shared helper the saved-card charge flows use). The backend applies
// a per-user mint cooldown (429) and fails closed without a delivery channel (503).
async function sendDeleteVerificationCode() {
if (deleteSendingCode) return;
deleteSendingCode = true;
try {
const result = await requestNewTwoFactorCode();
if (result.ok) {
deleteVerificationCode = '';
toast.success(result.message);
} else if (result.status === 429) {
toast.error(result.message || 'Too many requests. Wait before requesting a new code.');
} else if (result.status === 503) {
toast.error(
result.message || 'Verification codes are unavailable right now. Try again later.'
);
} else {
toast.error(result.message);
}
} finally {
deleteSendingCode = false;
}
}
async function deleteAccount() {
if (deleteConfirmText !== 'DELETE') {
toast.error('Please type DELETE to confirm');
return;
}
if (deleteCurrentPassword === '') {
toast.error('Enter your current password to confirm');
return;
}
const twoFactorActive = deleteTwoFactorRequired || deleteRevealTwoFactor;
if (twoFactorActive && deleteVerificationCode.trim() === '') {
toast.error('Enter your verification code to confirm');
return;
}
deletingAccount = true;
deleteError = '';
const loadingToast = toast.loading('Deleting account...');
try {
const response = await apiFetch('/api/user/account', {
method: 'DELETE'
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current_password: deleteCurrentPassword,
...(twoFactorActive ? { verification_code: deleteVerificationCode.trim() } : {})
})
});
if (response.ok) {
@@ -1565,10 +1648,17 @@
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto('/');
} else {
const status = response.status;
const text = await response.text();
toast.error(sanitizeText(extractErrorMessage(text)) || 'Failed to delete account', {
id: loadingToast
});
const message = sanitizeText(extractErrorMessage(text)) || 'Failed to delete account';
// The backend re-verifies credentials server-side: if it demands a
// 2FA code we did not know about, reveal the code entry so the
// deletion is recoverable with a freshly minted code.
if (status === 400 && /two-factor verification code is required/i.test(message)) {
deleteRevealTwoFactor = true;
}
deleteError = message;
toast.error(message, { id: loadingToast });
}
} catch (err) {
console.error('Error deleting account:', err);
@@ -2661,7 +2751,10 @@
'self'
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => (buyRecipientType = 'self')}
onclick={() => {
buyRecipientType = 'self';
buySelfAck = false;
}}
>
For Myself (Auto-Redeem)
</button>
@@ -2671,13 +2764,32 @@
'friend'
? 'border-input bg-accent text-card-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => (buyRecipientType = 'friend')}
onclick={() => {
buyRecipientType = 'friend';
buySelfAck = false;
}}
>
For a Friend (Gift Code)
</button>
</div>
</div>
{#if buyRecipientType === 'self'}
<div class="space-y-2 rounded-md border border-amber-200 bg-amber-50 p-3">
<label class="flex items-start gap-2 text-xs text-amber-800">
<input
type="checkbox"
bind:checked={buySelfAck}
class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-amber-600"
/>
<span>
Buying for yourself adds the value to your account balance immediately and you
won't have the 14-day statutory cancellation right.
</span>
</label>
</div>
{/if}
{#if buyRecipientType === 'friend'}
<div class="space-y-2">
<label for="recipient-email" class="text-sm font-medium text-gray-700"
@@ -2770,6 +2882,7 @@
disabled={buyingGiftCard ||
!isBuyCardValid ||
buyTwoFactor.missing ||
(buyRecipientType === 'self' && !buySelfAck) ||
buyDailyTotal + buyAmount > dailyGiftCardBuyLimit}
class="mt-2 min-h-11 w-full"
>
@@ -2782,6 +2895,16 @@
<p class="mt-4 text-center text-xs text-gray-500">
Secure payment powered by Square
</p>
<p class="text-center text-xs text-gray-500">
Online gift-card purchases have a 14-day right to cancel (unless the value is
redeemed immediately, as with auto-redeem for yourself). See our
<a
href="/gift-card-terms"
class="font-semibold text-primary hover:underline"
target="_blank"
rel="noopener noreferrer external">gift card terms</a
>.
</p>
{/if}
</Card.Content>
</Card.Root>
@@ -3230,7 +3353,13 @@
<p class="mb-3 text-sm text-gray-600">
Once you delete your account, there is no going back. Please be certain.
</p>
<Button onclick={() => (showDeleteAlert = true)} variant="destructive">
<Button
onclick={() => {
resetDeleteDialog();
showDeleteAlert = true;
}}
variant="destructive"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
@@ -3475,31 +3604,82 @@
{/if}
<!-- Delete Account Alert -->
<AlertDialog.Root bind:open={showDeleteAlert}>
<AlertDialog.Root
bind:open={showDeleteAlert}
onOpenChange={(open) => {
if (!open) resetDeleteDialog();
}}
>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Account?</AlertDialog.Title>
<AlertDialog.Description>
This action cannot be undone. This will permanently delete your account and remove all
your data from our servers.
your data from our servers. Enter your current password to confirm.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="px-6 py-4">
<label for="delete-confirm" class="text-sm font-medium"
>Type <strong>DELETE</strong> to confirm:</label
>
<Input
id="delete-confirm"
type="text"
bind:value={deleteConfirmText}
placeholder="DELETE"
class="mt-2"
/>
<div class="space-y-4 px-6 py-4">
<div>
<label for="delete-confirm" class="text-sm font-medium"
>Type <strong>DELETE</strong> to confirm:</label
>
<Input
id="delete-confirm"
type="text"
bind:value={deleteConfirmText}
placeholder="DELETE"
class="mt-2"
/>
</div>
<div>
<label for="delete-password" class="text-sm font-medium">Current Password</label>
<Input
id="delete-password"
type="password"
bind:value={deleteCurrentPassword}
placeholder="Enter your current password"
autocomplete="current-password"
class="mt-2"
/>
</div>
{#if deleteTwoFactorRequired || deleteRevealTwoFactor}
<div class="space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3">
<label for="delete-verification-code" class="text-sm font-medium">
Verification Code
</label>
<Input
id="delete-verification-code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
bind:value={deleteVerificationCode}
placeholder="6-digit code"
class="mt-1"
/>
<p class="text-xs text-gray-500">
Deleting your account requires a fresh verification code sent to your registered
method.
</p>
<Button
variant="outline"
size="sm"
class="w-full"
loading={deleteSendingCode}
disabled={deleteSendingCode}
onclick={sendDeleteVerificationCode}
>
Send code
</Button>
</div>
{/if}
{#if deleteError}
<p class="text-xs font-medium text-red-600" role="alert">{deleteError}</p>
{/if}
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel
onclick={() => {
deleteConfirmText = '';
resetDeleteDialog();
}}
>
Cancel
@@ -3507,7 +3687,13 @@
<Button
variant="destructive"
onclick={deleteAccount}
disabled={deletingAccount || deleteConfirmText !== 'DELETE'}
disabled={
deletingAccount ||
deleteConfirmText !== 'DELETE' ||
deleteCurrentPassword === '' ||
((deleteTwoFactorRequired || deleteRevealTwoFactor) &&
deleteVerificationCode.trim() === '')
}
>
{deletingAccount ? 'Deleting...' : 'Delete Account'}
</Button>
@@ -44,7 +44,7 @@
<h1 class="mb-2 border-b border-gray-200 pb-4 text-2xl font-bold">
Booking, Deposit & Cancellation Policy
</h1>
<p class="mb-8 font-mono text-xs text-gray-500">Last updated: 5 August 2026</p>
<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">
@@ -249,8 +249,10 @@
<p class="mb-3">
This policy is strictly aligned with the <strong>Consumer Rights Act 2015</strong>. Nothing
in these terms limits your statutory right to receive services carried out with reasonable
care and skill, or your right to a full refund if we are forced to cancel your appointment
due to our own scheduling conflicts.
care and skill, or your right to a refund or free reschedule if we are forced to cancel your
appointment. If we have to cancel, you are offered a full refund or a free reschedule; if you
choose a refund, it is processed under our standard refund tiers above unless we waive them
(for example when the cancellation is due to our own scheduling conflicts).
</p>
<p class="mb-3">
Please note that in accordance with UK statutory exclusions for distance contracts, the
+5 -4
View File
@@ -3,6 +3,7 @@
import ContactCard from '$lib/components/layout/ContactCard.svelte';
import { Map, MapMarker, MapControls, MarkerContent, MarkerPopup } from '$lib/components/ui/map';
import { onMount, tick } from 'svelte';
import { SUPPORT_PHONE, SUPPORT_EMAIL, BUSINESS_ADDRESS } from '$lib/constants/contact';
type ContactInfo = {
name: string;
@@ -55,7 +56,7 @@
phone={contact.phone}
email={contact.email}
instagram="crussell"
address="41 Pollock Walk, Dunfermline KY12 9DA"
address={BUSINESS_ADDRESS}
profileImage={contact.profilePicUrl ||
'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png'}
/>
@@ -63,10 +64,10 @@
<ContactCard
name="Chelsea Russell"
role="Owner / Beauty Specialist"
phone="+44 8008135"
email="chelsea@emailaddress.com"
phone={SUPPORT_PHONE}
email={SUPPORT_EMAIL}
instagram="crussell"
address="41 Pollock Walk, Dunfermline KY12 9DA"
address={BUSINESS_ADDRESS}
/>
{/if}
</div>
@@ -1,9 +1,43 @@
<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 element is removed before print so it 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>Gift Card Terms</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">
@@ -12,6 +46,12 @@
</h1>
<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… 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>
<h2 class="mb-3 text-base font-semibold text-gray-900">1. What These Terms Cover</h2>
+92 -11
View File
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { SUPPORT_EMAIL, BUSINESS_ADDRESS, TRADER_LEGAL_NAME } from '$lib/constants/contact';
let format = $state('html');
@@ -69,9 +70,9 @@
<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>
<!-- TODO pre-launch: replace {{SUPPORT_EMAIL}} with the real support address before go-live. -->
<p>Email: {'{{SUPPORT_EMAIL}}'}</p>
<p>Trader: {TRADER_LEGAL_NAME}</p>
<p>{BUSINESS_ADDRESS}</p>
<p>Email: {SUPPORT_EMAIL}</p>
<p class="mt-2 font-semibold text-gray-900">ICO registration (operator responsibility)</p>
<p class="mt-1">
As a data controller, the salon must register with the Information Commissioner's Office
@@ -159,7 +160,7 @@
<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>Payment transaction records (our ledger of record, retained for 7 years for HMRC)</li>
<li>
Saved-card references (tokenised, stored with our payment provider Square &mdash; see
&sect;2.2)
@@ -173,7 +174,7 @@
<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.
and keep the display metadata (last 4 digits and expiry) in your account record.
</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
@@ -211,8 +212,10 @@
</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.
We never store full card numbers (PANs) or card security codes (CVV). When you save a card,
we store a Square tokenised reference plus the last 4 digits and the expiry month/year of the
card on our systems, so the card can be displayed in your account and refunds can be matched
to the original payment method.
</p>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
@@ -270,12 +273,82 @@
<code>SNAPSHOT_ENC_KEY</code> before go-live so buyer email and card-token data in these records
is encrypted.
</p>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
2.6 Third Parties &amp; Infrastructure
</h3>
<p class="mb-3">
We use a small number of third-party services to operate the Platform. Each receives only
the data needed for its function:
</p>
<ul class="mb-4 list-disc space-y-1 pl-5">
<li>
<strong>Cloudflare</strong> &mdash; our edge proxy and CDN. Cloudflare routes traffic to
the Platform and enforces our UK-only geo-block; its edge servers see the IP address you
connect from (conveyed to us as <code>CF-Connecting-IP</code> where we need to identify a
connection).
</li>
<li>
<strong>Cloudflare R2 / S3-compatible object storage</strong> &mdash; profile pictures are
stored in object storage (the <code>crussell-profile-pics</code> bucket).
</li>
<li>
<strong>CardDAV / sabre/dav sync</strong> &mdash; your profile photo is synchronised to a
CardDAV address-book endpoint so it displays consistently across the Platform.
</li>
<li>
<strong>Google Fonts</strong> &mdash; the Playfair Display typeface is loaded from
<code>fonts.googleapis.com</code>; Google&rsquo;s servers see your IP address when your
device fetches the font.
</li>
<li>
<strong>CARTO</strong> &mdash; map tiles on the contact page are served from
<code>basemaps.cartocdn.com</code>; CARTO&rsquo;s servers see your IP address when your
device fetches map tiles.
</li>
</ul>
</section>
<!-- Section 3 -->
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">3. International Transfers</h2>
<p class="mb-3">
Our payment processor, <strong>Square</strong>, is based in the United States. When you pay
by card or save a card, the personal data that supports the payment &mdash; your name, email
address, and card-payment references &mdash; is processed by Square and may be transferred
outside the UK.
</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
<strong>What actually crosses the border:</strong> Square&rsquo;s payment script (Square.js)
runs in your browser and tokenises your card details into a one-time nonce or a stored-card
reference before anything is sent to our servers. We never send your full card number to
Square&rsquo;s US systems ourselves; only these nonces and references (plus the name and
email we already hold) travel to Square.
</li>
<li>
<strong>Lawful basis and safeguards:</strong> transfers are made under UK GDPR
<strong>Article 46</strong> on the basis of appropriate safeguards. We rely on
<strong>Square&rsquo;s Data Processing Addendum</strong>, which incorporates the
<strong>UK International Data Transfer Addendum</strong> and/or the
<strong>Standard Contractual Clauses</strong> issued by the Information Commissioner&rsquo;s
Office, to protect your data when it leaves the UK.
</li>
<li>
<strong>More information:</strong> Square&rsquo;s privacy policy (linked in &sect;2.2)
explains how Square handles data on our behalf.
</li>
</ul>
<p class="text-xs text-gray-500 italic">
This is a summary of a general nature, not legal advice; please verify the position with a
solicitor before going live.
</p>
</section>
<!-- Section 4 -->
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">
3. Data Retention &amp; Deletion Process
4. Data Retention &amp; Deletion Process
</h2>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">3.1 Retention Schedule</h3>
@@ -316,6 +389,14 @@
Contract performance (Art 6(1)(b)); card-network card-on-file rules
</td>
</tr>
<tr>
<td class="px-3 py-2">Scrubbed saved-card metadata</td>
<td class="px-3 py-2">
7 years (soft-deleted rows are scrubbed of Square ids, last-4 digits and expiry,
then retained for chargeback and audit)
</td>
<td class="px-3 py-2">Card-scheme chargeback rules; HMRC record-keeping</td>
</tr>
<tr>
<td class="px-3 py-2">Allergy/health records</td>
<td class="px-3 py-2">7 years</td>
@@ -408,9 +489,9 @@
</ol>
</section>
<!-- Section 4 -->
<!-- Section 5 -->
<section class="border-t border-gray-200 pt-6">
<h2 class="mb-2 text-base font-semibold text-gray-900">4. Your Rights</h2>
<h2 class="mb-2 text-base font-semibold text-gray-900">5. 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>
@@ -424,7 +505,7 @@
<li><strong>Withdraw Consent</strong> (Article 7(3))</li>
</ul>
<p class="mb-4">
To exercise these rights, contact {'{{SUPPORT_EMAIL}}'}. You also have the right to complain
To exercise these rights, contact {SUPPORT_EMAIL}. You also have the right to complain
to the Information Commissioner&rsquo;s Office (ICO) at any time &mdash; via the ICO website
(ico.org.uk) or by writing to the ICO, Wycliffe House, Water Lane, Wilmslow, Cheshire SK9
5AF. If you have concerns, we would ask you to contact us first so we can try to resolve
+63 -16
View File
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { SUPPORT_EMAIL, BUSINESS_NAME, BUSINESS_ADDRESS, TRADER_LEGAL_NAME } from '$lib/constants/contact';
let format = $state('html');
@@ -66,10 +67,10 @@
</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">Business Details</p>
<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 class="mt-1">Trading name: {BUSINESS_NAME}</p>
<p>Trader: {TRADER_LEGAL_NAME}</p>
<p>Address: {BUSINESS_ADDRESS}</p>
<p>Contact email: {SUPPORT_EMAIL}</p>
<p>VAT: Not currently registered (threshold &pound;90,000; will register when reached)</p>
</div>
</section>
@@ -90,10 +91,11 @@
<li>You will lose access to loyalty stamps, referral codes, and booking history.</li>
</ul>
<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>If your account has a balance:</strong> the balance is retained on your anonymised
account record after deletion, and you can recover it with your
<strong>Account ID</strong>. Your Account ID will be sent to you by email once email
delivery is available. Contact us if you believe a balance is missing. 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,
@@ -127,7 +129,11 @@
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>
<li>
Some services require a deposit (typically 20% of the service cost; a
protected-deposit cap of 50% may be retained on short-notice cancellation, capped at
what you actually paid).
</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Cancellations &amp; rescheduling</p>
<p class="mb-2">
@@ -156,7 +162,12 @@
<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>
<li>
<strong>Business cancellation:</strong> if we have to cancel, you are offered a full
refund or a free reschedule. If you choose a refund, it is processed under our standard
refund tiers above unless we waive them (for example when the cancellation is our own
scheduling conflict).
</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Deposits</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
@@ -165,7 +176,7 @@
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>
<li>If we cancel, any deposit paid is refunded under the business-cancellation terms above.</li>
</ul>
<p class="mb-2 font-medium text-gray-800">Service changes</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
@@ -300,7 +311,43 @@
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">6. Liability</h2>
<h2 class="mb-3 text-base font-semibold text-gray-900">
6. Distance Contracts &amp; Right to Cancel
</h2>
<p class="mb-3">
Purchases made on our Platform (rather than face-to-face in the salon) are
<strong>distance contracts</strong> under the Consumer Contracts (Information, Cancellation
and Additional Charges) Regulations 2013. This gives you a
<strong>14-day right to cancel</strong> most online purchases, running from the day after
purchase.
</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
<strong>Gift cards bought online</strong> carry this 14-day right, refunded to the
original payment method &mdash; in full if unused, or the unspent balance if partly used
on salon services (the card is then cancelled). See section 5 and our
<a
href={resolve('/gift-card-terms')}
class="font-medium text-blue-600 underline hover:text-blue-800">Gift Card Terms</a
>
for the full position.
</li>
<li>
<strong>Appointment bookings</strong> made online for a specific date are services with a
specified date of performance (regulation 28(1)(h) &mdash; services related to leisure
activities), so the 14-day right does not apply to the service itself; our cancellation and
refund policy in section 3 applies instead.
</li>
<li>
<strong>How to exercise it:</strong> for gift cards, cancel in-app from your account
within 14 days (no email needed); for anything else, email {SUPPORT_EMAIL} within 14 days.
Refunds are made within 14 days, to the original payment method.
</li>
</ul>
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">7. Liability</h2>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
Nothing in these Terms excludes or limits any rights you have under consumer law &mdash;
@@ -324,7 +371,7 @@
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">7. Acceptable Use</h2>
<h2 class="mb-3 text-base font-semibold text-gray-900">8. Acceptable Use</h2>
<p class="mb-3">By using the Platform you agree not to:</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
@@ -349,11 +396,11 @@
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">
8. Complaints &amp; Dispute Resolution
9. Complaints &amp; Dispute Resolution
</h2>
<p class="mb-3">
If you are unhappy with any part of our service, please contact us first at
{'{{SUPPORT_EMAIL}}'} &mdash; we will do our best to resolve your complaint fairly. You can get
{SUPPORT_EMAIL} &mdash; we will do our best to resolve your complaint fairly. You can get
free, impartial consumer advice from
<a
href="https://consumeradvice.scot"
@@ -366,7 +413,7 @@
</section>
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">9. Governing Law</h2>
<h2 class="mb-3 text-base font-semibold text-gray-900">10. Governing Law</h2>
<p class="mb-3">
These Terms are governed by the laws of <strong>Scotland</strong>. Any dispute arising out
of or in connection with these Terms is subject to the exclusive jurisdiction of the
+1 -1
View File
@@ -145,7 +145,7 @@
</div>
<!-- Current/Next Appointment Card (Full Width) -->
<CurrentAppointment {openEditBookingModal} {openUserModal} />
<CurrentAppointment {openEditBookingModal} {openUserModal} {openBookingModal} />
<!-- Quick Booking + Till Purchases Grid -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">