diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c7ba23a..f453d3c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,6 +36,7 @@ "eslint-config-prettier": "^10.0.1", "eslint-plugin-svelte": "^3.0.0", "globals": "^17.3.0", + "happy-dom": "^20.11.2", "mode-watcher": "^1.1.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^4.1.1", @@ -1344,6 +1345,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", @@ -1951,6 +1969,19 @@ "concat-map": "0.0.1" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2178,6 +2209,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -2626,6 +2670,25 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.11.2", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.2.tgz", + "integrity": "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4919,6 +4982,16 @@ "integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==", "license": "Apache-2.0" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4962,6 +5035,28 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2188956..cbf5edd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ "eslint-config-prettier": "^10.0.1", "eslint-plugin-svelte": "^3.0.0", "globals": "^17.3.0", + "happy-dom": "^20.11.2", "mode-watcher": "^1.1.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^4.1.1", diff --git a/frontend/src/app.css b/frontend/src/app.css index d71b0b9..2a3ac83 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; diff --git a/frontend/src/app.html b/frontend/src/app.html index f273cc5..9696d2e 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -3,6 +3,21 @@ + + %sveltekit.head% diff --git a/frontend/src/lib/components/account/EditRequestModal.svelte b/frontend/src/lib/components/account/EditRequestModal.svelte index 4bcb583..09c82f8 100644 --- a/frontend/src/lib/components/account/EditRequestModal.svelte +++ b/frontend/src/lib/components/account/EditRequestModal.svelte @@ -945,7 +945,7 @@ {:else}
{newDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 00e5c48..4d798a5 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -261,7 +261,7 @@ - +
Booking Ref${esc(selectedBooking.id)}
Date${parseWallClockDate(selectedBooking.start_time).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
Status${selectedBooking.status.replace('_', ' ')}
Status${esc(selectedBooking.status.replace('_', ' '))}

Services

@@ -271,7 +271,7 @@

Payments

- ${paidPayments.map((p) => ``).join('')} + ${paidPayments.map((p) => ``).join('')} ${discountPayments.map((d) => ``).join('')} ${refunds.map((r) => ``).join('')} ${hasVAT ? `` : ''} @@ -708,8 +708,7 @@ ${hasVAT ? `

VAT is included at ${biz?.default_vat_rate ?? 20} {#if isCancellable} {/if} {#if isCompleted} {:else if canPayEarly && !hasPendingEditRequest} + diff --git a/frontend/src/lib/components/admin/BookingModal.svelte b/frontend/src/lib/components/admin/BookingModal.svelte index 44f3fd5..c1652d0 100644 --- a/frontend/src/lib/components/admin/BookingModal.svelte +++ b/frontend/src/lib/components/admin/BookingModal.svelte @@ -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 @@ {#if selectedBooking && !['completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed'].includes(selectedBooking.status)} + {#if selectedBooking.status === 'in_progress'} + + {/if} diff --git a/frontend/src/lib/components/admin/TillPurchases.svelte b/frontend/src/lib/components/admin/TillPurchases.svelte index 18d6ea8..43a8b67 100644 --- a/frontend/src/lib/components/admin/TillPurchases.svelte +++ b/frontend/src/lib/components/admin/TillPurchases.svelte @@ -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)} - {/if} {/if} @@ -1041,18 +924,17 @@

{:else} - + {/if}

Secure payment powered by Square

diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index 208dcb7..a532464 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -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(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 @@ - +

- +
@@ -2446,11 +2415,11 @@ - + @@ -2666,7 +2635,7 @@

@@ -2678,7 +2647,7 @@ @@ -2809,28 +2778,6 @@ /> - -
- - {#if twoFactor.showInput && twoFactorEnabled} - - {/if} -
-
- - +
diff --git a/frontend/src/lib/components/payments/PaymentModal.svelte b/frontend/src/lib/components/payments/PaymentModal.svelte index 14206f9..c52ca00 100644 --- a/frontend/src/lib/components/payments/PaymentModal.svelte +++ b/frontend/src/lib/components/payments/PaymentModal.svelte @@ -1,5 +1,5 @@ Gift Card Terms +
@@ -12,6 +46,12 @@

Last updated: August 2026

+ {#if format === 'pdf' && pdfNotice} +

+ Generating PDF… If the print dialog does not appear, use Ctrl+P / Cmd+P. +

+ {/if} +

1. What These Terms Cover

diff --git a/frontend/src/routes/privacy-policy/+page.svelte b/frontend/src/routes/privacy-policy/+page.svelte index 1116192..372eab3 100644 --- a/frontend/src/routes/privacy-policy/+page.svelte +++ b/frontend/src/routes/privacy-policy/+page.svelte @@ -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 @@

Data Controller

Crussell Salon

-

Edinburgh, Scotland

- -

Email: {'{{SUPPORT_EMAIL}}'}

+

Trader: {TRADER_LEGAL_NAME}

+

{BUSINESS_ADDRESS}

+

Email: {SUPPORT_EMAIL}

ICO registration (operator responsibility)

As a data controller, the salon must register with the Information Commissioner's Office @@ -159,7 +160,7 @@

  • Gift card codes and balances
  • Account balances
  • -
  • Payment transaction records (processed via Square, not stored by us)
  • +
  • Payment transaction records (our ledger of record, retained for 7 years for HMRC)
  • Saved-card references (tokenised, stored with our payment provider Square — see §2.2) @@ -173,7 +174,7 @@

    When you choose to save a card for next time, we store a tokenised reference to your card with our payment processor, Square (a data processor), - rather than on our own systems. + and keep the display metadata (last 4 digits and expiry) in your account record.

    • @@ -211,8 +212,10 @@

    - 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.

    @@ -270,12 +273,82 @@ SNAPSHOT_ENC_KEY before go-live so buyer email and card-token data in these records is encrypted.

    + +

    + 2.6 Third Parties & Infrastructure +

    +

    + We use a small number of third-party services to operate the Platform. Each receives only + the data needed for its function: +

    +
      +
    • + Cloudflare — 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 CF-Connecting-IP where we need to identify a + connection). +
    • +
    • + Cloudflare R2 / S3-compatible object storage — profile pictures are + stored in object storage (the crussell-profile-pics bucket). +
    • +
    • + CardDAV / sabre/dav sync — your profile photo is synchronised to a + CardDAV address-book endpoint so it displays consistently across the Platform. +
    • +
    • + Google Fonts — the Playfair Display typeface is loaded from + fonts.googleapis.com; Google’s servers see your IP address when your + device fetches the font. +
    • +
    • + CARTO — map tiles on the contact page are served from + basemaps.cartocdn.com; CARTO’s servers see your IP address when your + device fetches map tiles. +
    • +
+
+

3. International Transfers

+

+ Our payment processor, Square, 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. +

+
    +
  • + What actually crosses the border: 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. +
  • +
  • + Lawful basis and safeguards: transfers are made under UK GDPR + Article 46 on the basis of appropriate safeguards. We rely on + Square’s Data Processing Addendum, which incorporates the + UK International Data Transfer Addendum and/or the + Standard Contractual Clauses issued by the Information Commissioner’s + Office, to protect your data when it leaves the UK. +
  • +
  • + More information: Square’s privacy policy (linked in §2.2) + explains how Square handles data on our behalf. +
  • +
+

+ This is a summary of a general nature, not legal advice; please verify the position with a + solicitor before going live. +

+
+ +

- 3. Data Retention & Deletion Process + 4. Data Retention & Deletion Process

3.1 Retention Schedule

@@ -316,6 +389,14 @@ Contract performance (Art 6(1)(b)); card-network card-on-file rules +
+ + + + @@ -408,9 +489,9 @@ - +
-

4. Your Rights

+

5. Your Rights

Under UK GDPR, you have the right to:

  • Access your personal data (Article 15)
  • @@ -424,7 +505,7 @@
  • Withdraw Consent (Article 7(3))

- 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 diff --git a/frontend/src/routes/terms/+page.svelte b/frontend/src/routes/terms/+page.svelte index 0357811..ac9e01e 100644 --- a/frontend/src/routes/terms/+page.svelte +++ b/frontend/src/routes/terms/+page.svelte @@ -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 @@

Business Details

-

Trading name: Crussell Salon

-

Registered address: Edinburgh, Scotland

- -

Contact email: {'{{SUPPORT_EMAIL}}'}

+

Trading name: {BUSINESS_NAME}

+

Trader: {TRADER_LEGAL_NAME}

+

Address: {BUSINESS_ADDRESS}

+

Contact email: {SUPPORT_EMAIL}

VAT: Not currently registered (threshold £90,000; will register when reached)

@@ -90,10 +91,11 @@
  • You will lose access to loyalty stamps, referral codes, and booking history.
  • - If your account has a balance: your balance becomes dormant and is - transferred to our recovery registry. You will receive your - Account ID by email (once email delivery is available) and can recover your balance - at any time by providing it. All other personal data is anonymized. + If your account has a balance: the balance is retained on your anonymised + account record after deletion, and you can recover it with your + Account ID. 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.

    Warning: 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). -

  • Some services require a deposit (typically 20–50% of the service cost).
  • +
  • + 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). +
  • Cancellations & rescheduling

    @@ -156,7 +162,12 @@ No-show: all booking payments and deposits are retained; may affect future booking eligibility. -

  • Business cancellation: full refund or reschedule offered.
  • +
  • + Business cancellation: 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). +
  • Deposits

    Service changes

    TypeMethodNetVATGross
    ${p.payment_type}${p.payment_method ?? '\u2014'}${fmt(p.net_amount)}${p.is_vat_applicable && p.vat_amount != null ? fmt(p.vat_amount) : '\u2014'}\u00a3${p.amount.toFixed(2)}
    ${esc(p.payment_type)}${esc(p.payment_method ?? '\u2014')}${fmt(p.net_amount)}${p.is_vat_applicable && p.vat_amount != null ? fmt(p.vat_amount) : '\u2014'}\u00a3${p.amount.toFixed(2)}
    Discount\u2014-\u00a3${Math.abs(d.amount).toFixed(2)}\u2014\u2014
    Refund\u2014-\u00a3${r.amount.toFixed(2)}\u2014\u2014
    \u00a3${totalNet.toFixed(2)}\u00a3${totalVAT.toFixed(2)}\u00a3${(totalNet + totalVAT).toFixed(2)}
    Scrubbed saved-card metadata + 7 years (soft-deleted rows are scrubbed of Square ids, last-4 digits and expiry, + then retained for chargeback and audit) + Card-scheme chargeback rules; HMRC record-keeping
    Allergy/health records 7 years