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:
@@ -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;
|
||||
|
||||
@@ -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 & 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;
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 — see
|
||||
§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 & 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> — 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> — profile pictures are
|
||||
stored in object storage (the <code>crussell-profile-pics</code> bucket).
|
||||
</li>
|
||||
<li>
|
||||
<strong>CardDAV / sabre/dav sync</strong> — your profile photo is synchronised to a
|
||||
CardDAV address-book endpoint so it displays consistently across the Platform.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Google Fonts</strong> — the Playfair Display typeface is loaded from
|
||||
<code>fonts.googleapis.com</code>; Google’s servers see your IP address when your
|
||||
device fetches the font.
|
||||
</li>
|
||||
<li>
|
||||
<strong>CARTO</strong> — map tiles on the contact page are served from
|
||||
<code>basemaps.cartocdn.com</code>; CARTO’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 — your name, email
|
||||
address, and card-payment references — 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’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’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’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’s
|
||||
Office, to protect your data when it leaves the UK.
|
||||
</li>
|
||||
<li>
|
||||
<strong>More information:</strong> Square’s privacy policy (linked in §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 & Deletion Process
|
||||
4. Data Retention & 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’s Office (ICO) at any time — 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
|
||||
|
||||
@@ -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 £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–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 & 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 & 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 — 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) — 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 —
|
||||
@@ -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 & Dispute Resolution
|
||||
9. Complaints & Dispute Resolution
|
||||
</h2>
|
||||
<p class="mb-3">
|
||||
If you are unhappy with any part of our service, please contact us first at
|
||||
{'{{SUPPORT_EMAIL}}'} — we will do our best to resolve your complaint fairly. You can get
|
||||
{SUPPORT_EMAIL} — 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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user