From 0fdb2f02cdb440280bba2c01515ac667ad5edcc2 Mon Sep 17 00:00:00 2001
From: Stephen Adamson
Date: Sun, 16 Aug 2026 00:19:02 +0100
Subject: [PATCH] =?UTF-8?q?feat:=20frontend=20SCA-only=20posture=20?=
=?UTF-8?q?=E2=80=94=20tokenize-result=20as=20charge=20source=20(C1),=20C6?=
=?UTF-8?q?=20refusal=20dialog,=20no=202FA=20fallback?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- square.ts: shouldFallbackTo2FA replaced by shouldShowSCARefusal — a genuine
'sca-unavailable' now drives the REFUSAL path (the customer is told the
payment cannot complete and to pay online later), never the 2FA code fallback
(PSR 2017 SCA is non-waivable; merchant liability is not cured by consent).
SCA_REFUSAL_MESSAGE_ONLINE/TILL copy added; SCA_FALLBACK_CONSENT_VERSION 'v1'
+ scaFallbackConsentFields() carry the versioned consent on the explicit
opt-in path only (shipped surfaces send none). SquareTokenizeResult docs
updated: tokenize-result token is the charge source, tokenless OK proceeds
token-less under the backend's SCA-only gate.
- C1 wire contract on every saved-card surface (booking, tip, gift-card buy,
till, account): the proactive SCA tokenize-result is sent as new_card_token
(the charge SOURCE alongside the saved-card ref), never the legacy
verification_token; 402 verification-required now means the tokenize-result
was consumed/expired between tokenize and charge.
- New ScaFallbackConsentDialog surfaces the refusal notice; the code input
(useTwoFactorCodeForSavedCard scaAvailable: () => true) only ever appears via
a backend gate rejection (defensive/opt-in).
- Till (M10): proactive saved-card SCA runs per sale line BEFORE the first
charge; sca-unavailable aborts the whole sale before any charge.
- Card save (M11/M12): STORE-intent tokenizeForStore with SCA at tokenization;
402 verification-required on save surfaces SCA-first guidance instead of a
generic failure.
---
.../account/UserBookingModal.svelte | 6 +-
.../lib/components/admin/TillPurchases.svelte | 200 ++++++---
.../lib/components/booking/BookingFlow.svelte | 66 ++-
.../components/payments/CardSelection.svelte | 62 +--
.../components/payments/MockCardForm.svelte | 18 +
.../components/payments/PaymentModal.svelte | 72 ++-
.../payments/ScaFallbackConsentDialog.svelte | 56 +++
.../payments/SquareCardInput.svelte | 67 ++-
.../lib/components/payments/TipPayment.svelte | 65 ++-
.../payments/UserPaymentModal.svelte | 56 ++-
.../alert-dialog/alert-dialog-content.svelte | 9 +-
frontend/src/lib/square/square.test.ts | 410 +++++++++++++++++-
frontend/src/lib/square/square.ts | 129 ++++--
frontend/src/lib/stores/auth.svelte.ts | 19 +-
.../src/lib/stores/twoFactorCode.svelte.ts | 70 ++-
frontend/src/routes/+layout.svelte | 18 +-
frontend/src/routes/account/+page.svelte | 97 ++++-
.../routes/cancellation-policy/+page.svelte | 34 +-
frontend/src/routes/gdpr/+page.svelte | 47 +-
.../src/routes/privacy-policy/+page.svelte | 102 ++++-
frontend/src/routes/terms/+page.svelte | 116 ++++-
21 files changed, 1404 insertions(+), 315 deletions(-)
create mode 100644 frontend/src/lib/components/payments/ScaFallbackConsentDialog.svelte
diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte
index 89c15d1..00e5c48 100644
--- a/frontend/src/lib/components/account/UserBookingModal.svelte
+++ b/frontend/src/lib/components/account/UserBookingModal.svelte
@@ -969,7 +969,11 @@ ${hasVAT ? `
VAT is included at ${biz?.default_vat_rate ?? 20}
-
+ (showTipModal = false)}
+ />
diff --git a/frontend/src/lib/components/admin/TillPurchases.svelte b/frontend/src/lib/components/admin/TillPurchases.svelte
index 0d381ec..18d6ea8 100644
--- a/frontend/src/lib/components/admin/TillPurchases.svelte
+++ b/frontend/src/lib/components/admin/TillPurchases.svelte
@@ -16,16 +16,20 @@
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
runSavedCardSCAProactively,
- shouldFallbackTo2FA,
- SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
+ scaFallbackConsentFields,
+ SCA_REFUSAL_MESSAGE_TILL,
+ shouldShowSCARefusal,
submitPaymentWithRetry,
+ tokenizeSavedCardWithVerification,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode,
PAYMENT_METHOD_SAVED_CARD,
- VERIFICATION_REQUIRED_MESSAGE
+ 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 = {
id: string;
@@ -34,7 +38,8 @@
qty: number;
};
- type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square' | (typeof PAYMENT_METHOD_SAVED_CARD);
+ type TillPaymentMethod =
+ 'cash' | 'card_machine' | 'online_square' | typeof PAYMENT_METHOD_SAVED_CARD;
const PAYMENT_METHODS: Array<{ key: TillPaymentMethod; label: string }> = [
{ key: 'cash', label: 'Cash' },
@@ -141,9 +146,9 @@
// 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' demotes 2FA
- // from backup to the only available gate (scaAvailable → false); every other
- // outcome keeps SCA primary for the next retry.
+ // 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.
let lastSCAOutcome = $state('');
// 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.
@@ -152,7 +157,9 @@
enabled: () => true,
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === PAYMENT_METHOD_SAVED_CARD,
- scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
+ // 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)
@@ -348,7 +355,10 @@
);
return;
}
- if (paymentMethod === PAYMENT_METHOD_SAVED_CARD && (!selectedCustomer || !selectedSavedCardId)) {
+ if (
+ paymentMethod === PAYMENT_METHOD_SAVED_CARD &&
+ (!selectedCustomer || !selectedSavedCardId)
+ ) {
toast.error('Select a customer and a saved card before charging');
return;
}
@@ -360,37 +370,84 @@
// One sale per cart line × quantity — each till sale funds its own
// gift card (the backend only accepts item_type 'gift_card').
const saleBodies: Record[] = [];
- for (const item of cart) {
- for (let i = 0; i < item.qty; i++) {
- const body: Record = {
- item_type: 'gift_card',
- action: 'create',
- amount: item.price,
- payment_method: paymentMethod,
- idempotency_key: idempotencyKeyFor(item, i)
- };
- if (paymentMethod === PAYMENT_METHOD_SAVED_CARD) {
- body.user_id = selectedCustomer?.id;
- body.user_saved_card_id = selectedSavedCardId;
- // B6/B10: the backend requires the CARD OWNER's current 2FA
- // verification code when the gate is enforced.
- if (twoFactor.showInput) body.verification_code = twoFactor.code;
- } else if (paymentMethod === 'online_square') {
- if (!onlineSquareCardInput) {
- throw new Error('Card form is not ready — please wait a moment and try again');
- }
- // SCA verification amount must match the sale amount (pence).
- const tokenized = await onlineSquareCardInput.tokenizeWithVerification(
- Math.round(item.price * 100)
- );
- body.card_token = tokenized.nonce;
- if (tokenized.verificationToken) {
- body.verification_token = tokenized.verificationToken;
+ // M10: proactive saved-card (ccof) SCA. Run the client-side challenge
+ // for every sale line BEFORE the first charge so a naked ccof till
+ // charge is never sent to the backend (mirrors PaymentModal/UserPaymentModal
+ // running SCA at charge init). Each line binds its token to its own
+ // amount. 'challenge-cancelled'/'sca-failed' abort the whole sale
+ // (retryable); 'sca-unavailable' aborts before any charge and surfaces
+ // the C6 refusal notice (no 2FA fallback).
+ let scaAborted = false;
+ awaitingSCA = true;
+ try {
+ for (const item of cart) {
+ for (let i = 0; i < item.qty; i++) {
+ const body: Record = {
+ item_type: 'gift_card',
+ action: 'create',
+ amount: item.price,
+ payment_method: paymentMethod,
+ idempotency_key: idempotencyKeyFor(item, i)
+ };
+ if (paymentMethod === PAYMENT_METHOD_SAVED_CARD) {
+ body.user_id = selectedCustomer?.id;
+ body.user_saved_card_id = selectedSavedCardId;
+ const squareCardId = savedCards.find(
+ (c) => c.id === selectedSavedCardId
+ )?.square_card_id;
+ const sca = await runSavedCardSCAProactively({
+ // The till body carries the amount in POUNDS (the
+ // backend multiplies by 100); the SCA challenge binds
+ // to pence, so convert for the challenge.
+ amountPence: Math.round(item.price * 100),
+ squareCardId: squareCardId ?? '',
+ buyer: { email: selectedCustomer?.email },
+ onOutcome: (o) => (lastSCAOutcome = o)
+ });
+ if (sca.outcome === 'challenge-cancelled' || sca.outcome === 'sca-failed') {
+ throw new Error(CARD_VERIFICATION_RETRY_MESSAGE);
+ }
+ if (sca.outcome === 'sca-unavailable') {
+ // 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;
+ }
+ // C1: the SCA tokenize-result token is the charge SOURCE
+ // (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');
+ }
+ // SCA verification amount must match the sale amount (pence).
+ const tokenized = await onlineSquareCardInput.tokenizeWithVerification(
+ Math.round(item.price * 100)
+ );
+ body.card_token = tokenized.nonce;
+ if (tokenized.verificationToken) {
+ body.verification_token = tokenized.verificationToken;
+ }
}
+ saleBodies.push(body);
}
- saleBodies.push(body);
+ if (scaAborted) break;
}
+ } finally {
+ awaitingSCA = false;
}
+ if (scaAborted) return;
for (const body of saleBodies) {
const res = await submitPaymentWithRetry(
@@ -404,7 +461,8 @@
// 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
+ verificationCodeGated:
+ paymentMethod === PAYMENT_METHOD_SAVED_CARD && twoFactor.showInput
}
);
if (!res.ok) {
@@ -438,10 +496,27 @@
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
+ // above the Charge button — don't duplicate it in the error panel.
+ if (shouldShowSCARefusal(lastSCAOutcome)) {
+ 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 {
@@ -452,13 +527,16 @@
/**
* Saved-card (ccof) SCA challenge, run when a till sale line came back 402
- * with the verification-required signal. The CUSTOMER approves the 3DS
- * challenge in their banking app; the operator's screen shows the waiting
- * state. 'verified' retries the SAME sale line with the fresh verification_token
- * and its SAME cached idempotency key (never regenerated here); 'sca-unavailable'
- * demotes 2FA from backup to the available gate; 'challenge-cancelled' /
- * 'sca-failed' keep the pending row retryable (the idempotency key stays
- * cached). Throws to stop the whole sale on any non-verified outcome.
+ * with the verification-required signal (the proactive token was stale or
+ * expired between tokenize and charge — the first attempt now always runs
+ * proactive SCA, so this is the defensive path). The CUSTOMER approves the
+ * 3DS challenge in their banking app; the operator's screen shows the waiting
+ * state. 'verified' retries the SAME sale line with the fresh tokenize-result
+ * token as new_card_token and its SAME cached idempotency key (never
+ * regenerated here); 'sca-unavailable' refuses the sale (C6 — the refusal
+ * dialog is driven by lastSCAOutcome); 'challenge-cancelled' / 'sca-failed'
+ * keep the pending row retryable (the idempotency key stays cached). Throws
+ * to stop the whole sale on any non-verified outcome.
*/
async function runTillSavedCardSCA(body: Record): Promise {
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
@@ -469,8 +547,8 @@
try {
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
- twoFactor.reveal = true;
- throw new Error(VERIFICATION_REQUIRED_MESSAGE);
+ twoFactor.declineConsent();
+ throw new Error(SCA_REFUSAL_MESSAGE_TILL);
}
let result: SavedCardVerificationResult;
try {
@@ -479,7 +557,7 @@
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
- twoFactor.reveal = true;
+ twoFactor.declineConsent();
throw err;
}
lastSCAOutcome = result.outcome;
@@ -490,19 +568,22 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...body,
- verification_token: result.verificationToken
+ // C1: the SCA tokenize-result token is the charge SOURCE.
+ new_card_token: result.verificationToken
})
})
);
if (!retry.ok) {
const errText = await retry.text();
- throw new Error(extractErrorMessage(errText) || 'Till sale failed');
+ const err = new Error(extractErrorMessage(errText) || 'Till sale failed');
+ (err as { bodyText?: string }).bodyText = errText;
+ throw err;
}
return;
}
- twoFactor.reveal = true;
+ twoFactor.declineConsent();
if (result.outcome === 'sca-unavailable') {
- throw new Error(`${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`);
+ throw new Error(SCA_REFUSAL_MESSAGE_TILL);
}
throw new Error(CARD_VERIFICATION_RETRY_MESSAGE);
} finally {
@@ -898,6 +979,18 @@
{/if}
+
+ {
+ lastSCAOutcome = '';
+ paymentError = null;
+ }}
+ />
+
@@ -909,8 +1002,7 @@
{#if twoFactor.showInput}
+
+ If your booking is evicted, you are refunded in full. If another customer books
+ the overlapping time and pays while your booking is in "Pending Release", your booking is evicted
+ and every payment you made toward it is refunded to you (the slot was lost through no fault of
+ yours, so no cancellation fee applies). This refund follows the same refund-method rules in Section
+ 3 below.
+
@@ -123,19 +130,23 @@
-
Notice between 24 and 72 hours
+
+ Notice between 24 and 72 hours (including exactly 24 or 72 hours)
+
Any booking payments made up to 50% of the total booking value are treated as a
Protected Deposit. This Protected Deposit is retained to cover the short-notice vacancy,
- while any balance paid above 50% will be fully refunded.
+ while any balance paid above 50% will be fully refunded. The protected deposit is capped
+ at 50% of the booking total AND at what you actually paid — so if you only paid a
+ 20% deposit, no more than that is ever retained.
Notice of less than 24 hours
- All booking payments and deposits are entirely non-refundable and will be retained. The
- cancellation will be logged as a missed appointment history strike.
+ All booking payments and deposits are retained (no refund). The cancellation will be
+ logged as a missed appointment history strike.
@@ -224,10 +235,9 @@
Each completed booking with a payment reduces the required deposit count by one. Once the
- count reaches zero, all prior no-show records within the 6-month window are forgiven and
- your account returns to normal — no upfront deposits required — until a new no-show occurs.
- The salon can also forgive individual no-shows at management's discretion, which immediately
- removes them from the count.
+ count reaches zero, your account returns to normal — no upfront deposits required — until a
+ new no-show occurs. The salon can also forgive individual no-shows at management's
+ discretion, which immediately removes them from the count.
@@ -257,8 +267,12 @@
Where a partly-used card is cancelled, the card is cancelled automatically when the refund
- is issued, so the remaining balance cannot then be spent. See our Gift Card Terms for the
- full position.
+ is issued, so the remaining balance cannot then be spent. See our
+ Gift Card Terms
+ for the full position.
If you believe your statutory consumer rights have not been met, you can get free, impartial
diff --git a/frontend/src/routes/gdpr/+page.svelte b/frontend/src/routes/gdpr/+page.svelte
index e039d87..820c176 100644
--- a/frontend/src/routes/gdpr/+page.svelte
+++ b/frontend/src/routes/gdpr/+page.svelte
@@ -159,12 +159,9 @@
status: string;
created_at: string;
}>;
- verification_codes?: Array<{
- purpose: string;
- created_at: string;
- used_at?: string;
- expires_at: string;
- }>;
+ // verification codes are excluded from the export (they are authentication
+ // tokens, not personal data), so there is deliberately no verification_codes
+ // key and no card for it below.
forgiven_no_shows?: Array<{
id: string;
booking_id: string;
@@ -1412,41 +1409,9 @@
-
- {#if gdprData.verification_codes && gdprData.verification_codes.length > 0}
-
- Verification Codes
-
-
-
-
Purpose
Created
Used At
Expires
-
- {#each gdprData.verification_codes as vc (vc.purpose + vc.created_at)}
-
+ As a data controller, the salon must register with the Information Commissioner's Office
+ (ICO) and pay the data-protection fee unless an exemption applies. This is the operator's
+ responsibility — nothing in the Platform registers the business. See the ICO website
+ (ico.org.uk) for the fee and exemptions.
+
@@ -84,6 +86,33 @@
2. Data We Collect
+
Lawful Bases at a Glance
+
+ We only process personal data where UK GDPR gives us a lawful basis. The main bases we rely
+ on:
+
+
+
+ Contract performance (Article 6(1)(b)) — holding and managing your booking,
+ taking and refunding payments, and saving your card at your request.
+
+
+ Legal obligation (Article 6(1)(c)) — keeping financial and tax records
+ for 7 years (HMRC), and holding health-and-safety records for insurance.
+
+
+ Legitimate interest (Article 6(1)(f)) — running the salon safely and
+ efficiently, preventing fraud (including rate limiting and account lockouts), and defending
+ against claims. We weigh these interests against your rights before relying on them.
+
+
+ Consent (Article 6(1)(a)) — marketing preferences and explicit consent
+ for health data (Article 9(2)(a)) only. Card payment authorisation is not consent-based: the
+ SCA check on saved-card payments is carried out under contract performance and our legitimate
+ interest in fraud prevention (see §2.4).
+
+
+
2.1 Personal Data (Identifiable Information)
@@ -204,6 +233,43 @@
to you if your account is deleted, and allergy/access information held in your treatment notes
is retained de-identified (see §3.1).
+
+
+ 2.4 Secure Card Authorisation (SCA Only)
+
+
+ Online card payments, including saved-card payments, are authorised exclusively through your
+ bank’s in-app approval step (Strong Customer Authentication, SCA / 3-D Secure),
+ carried out by Square PSD2 SCA. When you pay online, your bank may ask you to approve the
+ payment in your banking app. No saved-card payment is taken without this bank-level
+ authentication.
+
+
+ If your bank cannot complete the SCA step, the payment cannot be processed and is refused.
+ For online payments, this means the relevant deposit, early payment or gift-card purchase
+ does not go through. If you are paying in person at the salon and your bank cannot complete
+ SCA, we may ask you to pay online later instead.
+
+
+ Lawful basis: the SCA check is carried out under contract performance (Article
+ 6(1)(b)) and our legitimate interest in fraud prevention (Article 6(1)(f)). No consent-based processing
+ is used for card authorisation.
+
+ To rescue a payment that is stuck in a pending state, the Platform stores the exact payment
+ request for replay. In sandbox/production deployments these snapshots are encrypted at rest
+ (AES-256-GCM) under a deployment-provided key (SNAPSHOT_ENC_KEY).
+
+
+ Deployment requirement: if the key is not set, snapshots are stored in
+ plaintext at rest (a startup warning is logged). The operator must set
+ SNAPSHOT_ENC_KEY before go-live so buyer email and card-token data in these records
+ is encrypted.
+
@@ -255,6 +321,27 @@
7 years
Insurance requirement
+
+
Guest booking PII
+
6 months after the appointment, then anonymized
+
GDPR storage limitation
+
+
+
Gift cards
+
+ 24-month rolling expiry (from last use) for the card; account balances do not expire
+
None stored. Card payments are authorised by your bank's secure-authentication step
+ (see §2.4); we do not record any separate consent or authorisation records for
+ a card payment.
To exercise these rights, contact {'{{SUPPORT_EMAIL}}'}. You also have the right to complain
- to the Information Commissioner’s Office (ICO) at any time.
+ 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
+ them.
Questions about how we handle your data? Please use our official
diff --git a/frontend/src/routes/terms/+page.svelte b/frontend/src/routes/terms/+page.svelte
index 7cb216c..0357811 100644
--- a/frontend/src/routes/terms/+page.svelte
+++ b/frontend/src/routes/terms/+page.svelte
@@ -43,13 +43,8 @@
Terms & Conditions
-
- DRAFT — for review
-
-
Last updated: June 2026
+
Last updated: August 2026
{#if format === 'pdf' && pdfNotice}
@@ -194,6 +189,20 @@
Card payments are processed securely via Square.
We do not store full card details.
+
+ Strong Customer Authentication (SCA / 3-D Secure): online card payments, including
+ saved-card payments, are authenticated by Square PSD2 SCA. Your bank may ask you to approve
+ the payment in your banking app. If your bank cannot complete the secure authentication, the
+ payment cannot be processed: for online payments the relevant deposit, early payment or gift-card
+ purchase does not go through. If you are paying in person at the salon and your bank cannot
+ complete SCA, we may ask you to pay online later instead.
+
+
+ Chargebacks: if you dispute a payment with your card issuer, we will respond
+ with the booking and payment records we hold and, where the payment was SCA-authenticated, the
+ authentication evidence. We comply with the card-scheme dispute process and may be required
+ to refund a disputed payment if the scheme decides against us.
+
Refunds are returned to the original payment method where possible:
@@ -270,12 +279,99 @@
balance, no additional VAT is charged (it has already been paid).
- Right to cancel: if you buy a gift card online, you can cancel the purchase within
- 14 days for a refund to the original payment method. If the card has been partly used on salon
- services, only the unspent balance is refunded and the card is then cancelled. A card that has
- been redeemed to an account balance or fully spent cannot be cancelled. See our Gift Card Terms
+ Right to cancel: if you buy a gift card online, you can cancel the purchase
+ within 14 days for a refund to the original payment method. If the card has been partly used
+ on salon services, only the unspent balance is refunded and the card is then cancelled. A
+ card that has been redeemed to an account balance or fully spent cannot be cancelled. See
+ our
+ Gift Card Terms
for the full position.
+
+ Non-refundable except where the law requires: except for the 14-day right
+ to cancel online purchases above, and except as required by consumer law or by an express
+ refund we offer under our cancellation policy, gift cards and gift-card balances are
+ non-refundable — they are not redeemable for cash and cannot be exchanged
+ for money. Where consumer law gives you a right of refund, that right is unaffected.
+
+
+
+
+
6. Liability
+
+
+ Nothing in these Terms excludes or limits any rights you have under consumer law —
+ including your statutory rights under the Consumer Rights Act 2015 that services are
+ provided with reasonable care and skill and match their description.
+
+
+ We are not liable for losses that were not a foreseeable consequence of a breach of these
+ Terms, or for losses caused by events outside our reasonable control.
+
+
+ To the fullest extent permitted by law, our total liability arising out of or in
+ connection with these Terms is limited to the total amount you have paid us for the
+ service concerned.
+
+
+ We are not liable for the acts or omissions of third parties we rely on to provide the
+ Platform (for example our payment processor), except as required by law.
+
+
+
+
+
+
7. Acceptable Use
+
By using the Platform you agree not to:
+
+
+ Use the Platform for any unlawful purpose, or in a way that infringes anyone else’s
+ rights.
+
+
+ Attempt to access another user’s account, or to interfere with the operation of the
+ Platform.
+
+
+ Misuse the booking system (for example by repeatedly reserving slots with no intention of
+ completing a booking, or by booking under false details).
+
+
Use the Platform to send spam, offensive content, or content that misleads others.
+
+
+ We may suspend or refuse service where we reasonably believe these rules are being breached,
+ while respecting your legal rights.
+
+
+
+
+
+ 8. Complaints & Dispute Resolution
+
+
+ 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
+ free, impartial consumer advice from
+ consumeradvice.scot, and you can escalate a complaint to your local Trading Standards office. Claims up to
+ £5,000 can be pursued through the Scottish courts’ Simple Procedure.
+
+
+
+
+
9. Governing Law
+
+ These Terms are governed by the laws of Scotland. Any dispute arising out
+ of or in connection with these Terms is subject to the exclusive jurisdiction of the
+ Scottish courts. Nothing in this clause limits your statutory rights as a consumer.
+