fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity

7 review agents (pipeline run, self-review, codebase-context, frontend-placement,
backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work.
ALL findings fixed, including every pre-existing red CI job:

GDPR (HIGH):
- anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors
  delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII)
  no longer survive registered-user account deletion; gdpr test added

BACKEND TEST GAPS (all 10):
- delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker
- twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper
- insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all
  6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors)
- CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip,
  token-less fallback + SCA-required (new terminal_sca_test.go)
- isVerificationRequiredError at all 5 charge sites (402 + code:verification_required)
- customer_initiated handler-level assertions (MIT false admin / CIT true customer)
- Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix,
  parseVerifyToken unit tests

FRONTEND SCA + Square-API (CRITICAL):
- tokenizeSavedCardWithVerification reads result.token (the verified token) not
  result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could
  never succeed in production before); parseTokenizeVerificationResult pure fn
  extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless
- HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token
  under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled
- challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show;
  'waiting for approval in your banking app' state on CIT surfaces
- sca-unavailable demotion resets per attempt; card selection disabled mid-challenge;
  genuine saved-card declines no longer relabeled 'requires verification';
  modal-close guard during processing; retry affordance standardized

PIPELINE (every red job now green):
- prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe)
- govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean
- race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic
- DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes)
- frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex),
  deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases

DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical
Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY,
Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified
against code everywhere

Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/
gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent c4c65d9dd8
commit b7122be3a0
78 changed files with 2861 additions and 1120 deletions
+12
View File
@@ -28,6 +28,18 @@ export default defineConfig(
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// Match eslint.config.js: underscore-prefixed variables are
// intentionally unused (catch params, no-op callbacks, map keys).
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_'
}
]
}
},
{
+657 -466
View File
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { SvelteMap } from 'svelte/reactivity';
import { SvelteDate, SvelteMap } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
@@ -16,7 +16,11 @@
getLunchProtectionForSlots,
timeToMinutes
} from '$lib/lunchProtection';
import { formatLocalDateTime, getLondonTodayCalendarDate, parseWallClockDate } from '$lib/utils/timeSlots';
import {
formatLocalDateTime,
getLondonTodayCalendarDate,
parseWallClockDate
} from '$lib/utils/timeSlots';
import ClockIcon from '@lucide/svelte/icons/clock';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
@@ -71,7 +75,7 @@
// ─── Date constants ─────────────────────────────────────
const todayCalendarDate = getLondonTodayCalendarDate();
const minDate = todayCalendarDate;
const maxDate = new Date(
const maxDate = new SvelteDate(
todayCalendarDate.year,
todayCalendarDate.month - 1,
todayCalendarDate.day
@@ -458,7 +462,7 @@
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 0; i <= daysToCheck; i++) {
const nextDate = new Date(currentDate);
const nextDate = new SvelteDate(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -2,7 +2,7 @@
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
import { SvelteDate, SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay';
@@ -217,7 +217,7 @@
// Date Boundaries
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new Date(today.year, today.month - 1, today.day);
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
@@ -408,7 +408,7 @@
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 0; i <= daysToCheck; i++) {
const checkDate = new Date(now);
const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const calDate = new CalendarDate(
@@ -456,29 +456,29 @@
/>
{/if}
<div>
{#if selectedBooking.user}
{@const customer = selectedBooking.user}
{@const userName = formatUserName(
customer.full_name || '—',
customer.previous_first_name,
customer.previous_last_name
)}
<div class="text-lg font-semibold">
{#if openUserModal}
<button
type="button"
class="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline"
onclick={() => openUserModal(customer.id)}
>
{#if selectedBooking.user}
{@const customer = selectedBooking.user}
{@const userName = formatUserName(
customer.full_name || '—',
customer.previous_first_name,
customer.previous_last_name
)}
<div class="text-lg font-semibold">
{#if openUserModal}
<button
type="button"
class="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline"
onclick={() => openUserModal(customer.id)}
>
{userName}
</button>
{:else}
{userName}
</button>
{:else}
{userName}
{/if}
</div>
{:else}
<div class="text-lg font-semibold"></div>
{/if}
{/if}
</div>
{:else}
<div class="text-lg font-semibold"></div>
{/if}
{#if selectedBooking.user?.date_of_birth}
<div class="text-sm text-gray-500">
{calculateAge(selectedBooking.user.date_of_birth)} years old
@@ -388,8 +388,8 @@
<span class="text-xs text-muted-foreground">Expiry Period</span>
<p class="text-sm font-medium">{settings.gift_card_expiry_months} months</p>
<p class="text-xs text-muted-foreground">
Rolling from last use — each balance check, top-up, redemption or payment
resets the timer.
Rolling from last use — each balance check, top-up, redemption or payment resets the
timer.
</p>
</div>
<div>
@@ -5,6 +5,7 @@
import { range, formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { SvelteDate } from 'svelte/reactivity';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
@@ -179,7 +180,7 @@
function addWeeksToException(fromISO: string, toISO: string, dest: string[]) {
const from = new Date(fromISO + 'T00:00:00Z');
const to = new Date(toISO + 'T00:00:00Z');
const first = new Date(from);
const first = new SvelteDate(from);
const day = first.getDay();
const daysToMonday = day === 0 ? -6 : 1 - day;
@@ -187,7 +188,7 @@
first.setDate(first.getDate() + daysToMonday);
// Add all Mondays in the range
for (let d = new Date(first); d <= to; d.setDate(d.getDate() + 7)) {
for (let d = new SvelteDate(first); d <= to; d.setDate(d.getDate() + 7)) {
dest.push(isoDateOf(new Date(d)));
}
}
@@ -749,7 +750,7 @@
<Button
variant="outline"
size="sm"
class="h-6 text-xs ml-auto"
class="ml-auto h-6 text-xs"
onclick={checkConflictingBookings}
>
Refresh
@@ -1,5 +1,5 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
@@ -58,7 +58,7 @@
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new Date(today.year, today.month - 1, today.day);
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
@@ -391,7 +391,7 @@
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) {
const checkDate = new Date(now);
const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const calDate = new CalendarDate(
@@ -458,8 +458,8 @@
<details class="ml-6 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">When to use this</summary>
<p class="mt-1">
Use for genuinely excusable cancellations, or when the salon cancels and chooses
not to keep the money. When unchecked, standard notice-period fees apply (e.g. a
Use for genuinely excusable cancellations, or when the salon cancels and chooses not
to keep the money. When unchecked, standard notice-period fees apply (e.g. a
customer who calls up to cancel).
</p>
</details>
@@ -6,24 +6,27 @@
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
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 {
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
shouldFallbackTo2FA,
submitPaymentWithRetry,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
shouldFallbackTo2FA,
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
submitPaymentWithRetry,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
type CartItem = {
id: string;
@@ -65,7 +68,7 @@
// the customer is not charged twice. A changed cart/amount/payment method
// yields a different composite key, so genuinely new sales get fresh keys.
// Mirrors the BookingFlow/PaymentModal/TipPayment per-charge caching pattern.
let idempotencyKeys = new Map<string, string>();
let idempotencyKeys = new SvelteMap<string, string>();
function idempotencyKeyFor(item: CartItem, qtyIndex: number): string {
// saved_card charges also key on the selected card id so switching to a
@@ -148,10 +151,13 @@
let awaitingSCA = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
mint: () =>
selectedCustomer?.id ? adminRequestNewTwoFactorCode(selectedCustomer.id) : requestNewTwoFactorCode()
selectedCustomer?.id
? adminRequestNewTwoFactorCode(selectedCustomer.id)
: requestNewTwoFactorCode()
});
// The saved-card option is hidden outright unless a customer is selected
@@ -408,7 +414,10 @@
// challenge and retry the SAME sale line with the fresh token and
// its SAME cached idempotency key. runTillSavedCardSCA throws to
// stop the whole sale on any non-verified outcome.
if (paymentMethod === 'saved_card' && isVerificationRequiredSignal(responseStatus, errText)) {
if (
paymentMethod === 'saved_card' &&
isVerificationRequiredSignal(responseStatus, errText)
) {
await runTillSavedCardSCA(body);
continue;
}
@@ -433,75 +442,71 @@
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
paymentError = msg;
toast.error(msg);
} finally {
isProcessingPaymentSync = false;
processing = false;
} finally {
isProcessingPaymentSync = false;
processing = false;
}
}
}
/**
* 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.
*/
async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void> {
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
// The till body carries the amount in POUNDS (the backend multiplies by
// 100); the SCA challenge binds to pence, so convert for the challenge.
const amountPence = Math.round((Number(body.amount) || 0) * 100);
awaitingSCA = true;
try {
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw new Error(VERIFICATION_REQUIRED_MESSAGE);
}
let result: SavedCardVerificationResult;
/**
* 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.
*/
async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void> {
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
// The till body carries the amount in POUNDS (the backend multiplies by
// 100); the SCA challenge binds to pence, so convert for the challenge.
const amountPence = Math.round((Number(body.amount) || 0) * 100);
awaitingSCA = true;
try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
email: selectedCustomer?.email
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw err;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
const retry = await submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...body,
verification_token: result.verificationToken
})
})
);
if (!retry.ok) {
const errText = await retry.text();
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw new Error(VERIFICATION_REQUIRED_MESSAGE);
}
return;
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
email: selectedCustomer?.email
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw err;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
const retry = await submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...body,
verification_token: result.verificationToken
})
})
);
if (!retry.ok) {
const errText = await retry.text();
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
}
return;
}
twoFactor.reveal = true;
if (result.outcome === 'sca-unavailable') {
throw new Error(`${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`);
}
throw new Error(CARD_VERIFICATION_RETRY_MESSAGE);
} finally {
awaitingSCA = false;
}
twoFactor.reveal = true;
if (result.outcome === 'sca-unavailable') {
throw new Error(
`${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.`
);
}
throw new Error(
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
} finally {
awaitingSCA = false;
}
}
</script>
<div class="rounded-xl border bg-card">
@@ -585,13 +590,13 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
>
</div>
{#if giftCardAmountTooHigh}
<p class="text-xs text-red-700"
>Gift card amount exceeds maximum (&pound;{GIFT_CARD_MAX_AMOUNT})</p
>
<p class="text-xs text-red-700">
Gift card amount exceeds maximum (&pound;{GIFT_CARD_MAX_AMOUNT})
</p>
{/if}
<p class="text-xs text-muted-foreground"
>Gift card limit &pound;{GIFT_CARD_MAX_AMOUNT} per transaction</p
>
<p class="text-xs text-muted-foreground">
Gift card limit &pound;{GIFT_CARD_MAX_AMOUNT} per transaction
</p>
</div>
{:else}
<Button
@@ -885,9 +890,9 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-xs text-amber-800">
Your card issuer will ask you to approve this payment in your banking app.
</p>
<p class="text-xs text-amber-800">
Your card issuer will ask you to approve this payment in your banking app.
</p>
</div>
{/if}
@@ -928,7 +933,9 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
{/if}
{#if awaitingSCA}
<div class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6">
<div
class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6"
>
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
@@ -204,7 +204,8 @@
const sortedBlockers = $derived.by(() => {
return [...blockers].sort(
(a, b) => parseWallClockDate(a.start_time).getTime() - parseWallClockDate(b.start_time).getTime()
(a, b) =>
parseWallClockDate(a.start_time).getTime() - parseWallClockDate(b.start_time).getTime()
);
});
@@ -878,7 +879,7 @@
<Button
variant="outline"
size="sm"
class="h-6 text-xs ml-auto"
class="ml-auto h-6 text-xs"
onclick={checkOverlappingBookings}
>
Refresh
@@ -731,8 +731,8 @@
selectedUser.previousFirstName,
selectedUser.previousLastName
)}
? They will no longer need a 2FA code for card payments. Use this only when the user has
lost access to their 2FA method.
? They will no longer need a 2FA code for card payments. Use this only when the user has lost
access to their 2FA method.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
@@ -3,7 +3,7 @@
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
@@ -479,7 +479,7 @@
const [hours, minutes] = availableStartTime.split(':').map(Number);
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
start = new Date(y, m - 1, d, hours, minutes, 0, 0);
start = new SvelteDate(y, m - 1, d, hours, minutes, 0, 0);
} else {
// Fallback: Calculate immediate start time (rounded to next 15 min)
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -491,8 +491,8 @@
});
const [y, m, d] = londonDateStr.split('-').map(Number);
const [h, min] = londonTimeStr.split(':').map(Number);
const now = new Date(y, m - 1, d, h, min, 0, 0);
start = new Date(now);
const now = new SvelteDate(y, m - 1, d, h, min, 0, 0);
start = new SvelteDate(now);
const minutes = start.getMinutes();
const remainder = 15 - (minutes % 15);
if (remainder !== 15 && remainder !== 0) {
@@ -6,6 +6,7 @@
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { formatDuration, range } from '$lib/utils/format';
import { SvelteDate } from 'svelte/reactivity';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
@@ -228,7 +229,7 @@
}
function getDefaultEffectiveDate(): string {
const d = new Date();
const d = new SvelteDate();
d.setDate(d.getDate() + 1);
return d.toISOString().slice(0, 10);
}
@@ -814,7 +815,7 @@
</div>
<!-- Effective Date & Conflict Resolution -->
<div class="border-t px-4 pb-4 pt-4">
<div class="border-t px-4 pt-4 pb-4">
<div class="space-y-3">
<div>
<label for="effective_date" class="mb-1 block text-sm font-medium text-gray-700">
@@ -879,7 +880,7 @@
<Button
variant="outline"
size="sm"
class="h-6 text-xs ml-auto"
class="ml-auto h-6 text-xs"
onclick={checkConflictingBookings}
>
Refresh
@@ -19,6 +19,7 @@
// TIMESTAMPTZ (UTC) and converts to Europe/London for display. This ensures a booking at
// "10am June 15" stays at 10am BST regardless of when the booking was made.
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { SvelteDate } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/auth.svelte';
@@ -40,6 +41,7 @@
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
campaignDiscountPence,
depositChargePence,
@@ -173,6 +175,12 @@
// from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge is in flight (the buyer
// approves in their banking app) — drives the "approve in banking app" panel.
let waitingForSCA = $state(false);
// 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: () =>
@@ -412,6 +420,9 @@
depositTokenAmount = amountPence;
depositTokenizedAt = Date.now();
depositTokenizedForSaveCard = depositSaveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it.
lastSCAOutcome = 'verified';
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
@@ -449,14 +460,18 @@
// the cached idempotency key above (never regenerated across the
// challenge-then-charge).
if (selectedPaymentMethod && !verificationToken) {
const proactive = await runDepositSCAProactively(amountPence);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
toast.error(
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
return;
waitingForSCA = true;
try {
const proactive = await runDepositSCAProactively(amountPence);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
depositError = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(depositError);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
waitingForSCA = false;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
}
const body: Record<string, unknown> = {
@@ -562,7 +577,8 @@
// charge), surface the guidance and let the user retry — never re-run SCA
// silently mid-flow.
if (selectedPaymentMethod && isVerificationRequiredSignal(response.status, text)) {
toast.warning(VERIFICATION_REQUIRED_MESSAGE);
depositError = VERIFICATION_REQUIRED_MESSAGE;
toast.warning(depositError);
return;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
@@ -589,9 +605,7 @@
amountPence,
overflowPence: Math.max(
0,
amountPence -
Math.round((confirmedBooking?.amount_due ?? 0) * 100) -
depositDiscountPence
amountPence - Math.round((confirmedBooking?.amount_due ?? 0) * 100) - depositDiscountPence
),
chargePence: Math.max(0, amountPence - depositDiscountPence),
depositAmount,
@@ -626,21 +640,22 @@
depositPaid = confirmedBooking.deposit_paid;
toast.success('Payment successful!');
} else {
toast.warning(
depositError =
extractErrorMessage(text) ||
'Payment failed — you can pay again from your booking details.'
);
'Payment failed — you can pay again from your booking details.';
toast.warning(depositError);
}
} catch {
toast.warning(
depositError =
extractErrorMessage(text) ||
'Payment failed — you can pay again from your booking details.'
);
'Payment failed — you can pay again from your booking details.';
toast.warning(depositError);
}
} else {
toast.warning(
extractErrorMessage(text) || 'Payment failed — you can pay again from your booking details.'
);
depositError =
extractErrorMessage(text) ||
'Payment failed — you can pay again from your booking details.';
toast.warning(depositError);
}
// A definitive charge failure (declined card, any 4xx) consumes the
// nonce + SCA verification token (Square nonces are single-use) —
@@ -653,6 +668,17 @@
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
// A DEFINITIVE 402 (declined card / stale token) means the deposit
// charge did NOT land — a retry that re-runs SCA and mints a fresh
// token would otherwise dead-loop on IDEMPOTENCY_KEY_REUSED under the
// same key. Regenerate the key on 402 so the next Pay click gets a
// fresh key + fresh pending row. Keep it on 503/network (ambiguous)
// and on challenge-cancelled/sca-failed (no charge was attempted).
if (response.status === 402) {
depositIdempotencyKey = '';
depositKeyedAmount = 0;
depositKeyedCard = '';
}
}
/**
@@ -999,7 +1025,7 @@
// Initialize date boundaries
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new Date(today.year, today.month - 1, today.day);
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
@@ -1083,7 +1109,7 @@
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) {
const nextDate = new Date(currentDate);
const nextDate = new SvelteDate(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -1306,7 +1332,7 @@
// =============== Time Slot Generation ===============
function calculateEndTime(startTime: string, durationMinutes: number): string {
const [hours, minutes] = startTime.split(':').map(Number);
const date = new Date();
const date = new SvelteDate();
date.setHours(hours, minutes, 0, 0);
date.setMinutes(date.getMinutes() + durationMinutes);
const endHours = date.getHours().toString().padStart(2, '0');
@@ -2695,6 +2721,38 @@
onCancel={cancelOverflowConfirmation}
/>
{:else}
<!-- Out-of-band SCA challenge: the buyer approves in their
banking app while this panel shows. -->
{#if waitingForSCA}
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<div
class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
></div>
<div>
<p class="text-sm font-medium text-amber-900">
Approve this payment in your banking app on your phone…
</p>
<p class="mt-0.5 text-xs text-amber-700">
The payment is waiting for your approval. This may take a few moments.
</p>
</div>
</div>
</div>
{/if}
{#if depositError}
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm text-red-800">{depositError}</p>
<Button
variant="outline"
size="sm"
class="mt-3 w-full"
onclick={() => (depositError = null)}
>
Try Again
</Button>
</div>
{/if}
<BookingSummary
services={selectedServices}
date={selectedDate}
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { formatTime } from '$lib/utils/timeSlots';
import { SvelteDate } from 'svelte/reactivity';
type DefaultHours = {
weekday: number; // 0=Mon, 1=Tue, ..., 6=Sun
@@ -80,7 +81,7 @@
const today = getLondonDate();
const dayOfWeek = today.getDay(); // 0=Sun
const offset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = new Date(today);
const monday = new SvelteDate(today);
monday.setDate(today.getDate() + offset);
return fmtDate(monday);
}
@@ -115,7 +115,9 @@
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<p class="text-sm text-amber-800">
Two-factor authentication is required to use online card payments.
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
<a href={resolve('/account')} class="font-medium underline"
>Enable it in your account settings</a
>.
</p>
</div>
{/if}
@@ -127,7 +129,7 @@
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
card.id && !showNewCardForm
card.id && !showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
@@ -373,8 +373,10 @@
<div class="rounded-md border border-amber-300 bg-amber-50 p-3">
<p class="text-sm font-medium text-amber-800">Waiting for approval in the banking app…</p>
<p class="mt-1 text-xs text-amber-700">
Simulated 3DS/SCA challenge (mock mode). The backend mock applies the encoded outcome
({challengeResult === 'deny' ? 'deny' : 'approve'}).
Simulated 3DS/SCA challenge (mock mode). The backend mock applies the encoded outcome ({challengeResult ===
'deny'
? 'deny'
: 'approve'}).
</p>
<button
type="button"
@@ -51,22 +51,9 @@
</div>
</div>
<div class="mt-4 flex gap-2">
<Button
class="flex-1"
loading={loading}
disabled={loading}
autofocus
onclick={onConfirm}
>
<Button class="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="flex-1" disabled={loading} onclick={onCancel}>Cancel</Button>
</div>
</div>
@@ -9,13 +9,13 @@
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
import { apiFetch } from '$lib/utils/api';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
sanitizeDecimalInput,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode,
@@ -491,6 +491,20 @@
onClose();
}
// True while a charge (or the out-of-band SCA challenge the customer must
// approve in their banking app) is in flight — ESC/overlay close must be
// blocked then, because the charge may still land.
function isChargeInFlight(status: PaymentStatus): boolean {
return (
status === 'card-processing' ||
status === 'card-polling' ||
status === 'cash-confirming' ||
status === 'gift-confirming' ||
status === 'saved-card-processing' ||
status === 'saved-card-waiting-sca'
);
}
// Called from the success state's Done button: notify the parent (so it can
// refresh the booking/payment data) and then close the modal. Kept separate
// from handleClose so a success state never closes without the callback.
@@ -852,7 +866,9 @@
await runSavedCardSCA(chargeAmount);
return;
}
const err = new Error(extractErrorMessage(errData) || 'Failed to process saved card payment');
const err = new Error(
extractErrorMessage(errData) || 'Failed to process saved card payment'
);
(err as { bodyText?: string }).bodyText = errData;
throw err;
}
@@ -879,16 +895,14 @@
onComplete(paymentResult);
} catch (_err) {
status = 'error';
// A definitive 402 on the saved-card path means the issuer still
// requires verification. A structured verification-required signal
// surfaces the SCA-first guidance; the legacy saved-card check is the
// fallback for generic 402s (the SCA flow above intercepts the
// structured ones, so this is the belt-and-braces path).
// A 402 carrying the structured verification-required signal (or the
// dev/mock text parity) surfaces the SCA-first guidance. A plain
// decline 402 shows the normal decline error — it must not be
// relabeled "requires verification".
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
if (scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, true)) {
msg = scaVerificationRequired ? VERIFICATION_REQUIRED_MESSAGE : SAVED_CARD_VERIFICATION_MESSAGE;
if (isVerificationRequiredSignal(responseStatus, bodyText)) {
msg = VERIFICATION_REQUIRED_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
@@ -1004,13 +1018,20 @@
status = 'error';
error =
result.outcome === 'sca-unavailable'
? `${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.`
: "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead.";
? `${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`
: CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(error);
}
</script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Root
open={true}
onOpenChange={(open) => {
if (open) return;
if (isChargeInFlight(status)) return;
handleClose();
}}
>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
@@ -1250,7 +1271,7 @@
<button
type="button"
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
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 ===
'savedcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1276,7 +1297,7 @@
<button
type="button"
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
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 ===
'giftcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1657,7 +1678,11 @@
<!-- 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} />
<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
@@ -1695,7 +1720,9 @@
<p class="text-lg font-medium text-gray-700">
Waiting for customer to approve in their banking app…
</p>
<p class="mt-2 text-sm text-gray-500">The customer may need to approve this payment in their banking app</p>
<p class="mt-2 text-sm text-gray-500">
The customer may need to approve this payment in their banking app
</p>
{:else}
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
{/if}
@@ -1,5 +1,18 @@
<script module lang="ts">
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
import {
getSquarePayments,
isSquareConfigured,
isSquareMock,
parseTokenizeVerificationResult,
type SquareTokenizeResult
} from '$lib/square/square';
/** Re-exported for the payment surfaces that import these from this
* component — the types now live with the shared parse logic in square.ts. */
export type SavedCardVerificationOutcome =
import('$lib/square/square').SavedCardVerificationOutcome;
export type SavedCardVerificationResult =
import('$lib/square/square').SavedCardVerificationResult;
/**
* Billing contact passed to Square's tokenize() verificationDetails for
@@ -18,21 +31,6 @@
verificationToken: string | null;
}
/** Outcome of a saved-card SCA challenge, used by the payment surfaces to
* decide whether to retry with the fresh verification token, surface a
* retryable failure, or fall back to the 2FA gate. */
export type SavedCardVerificationOutcome =
| 'verified'
| 'challenge-cancelled'
| 'sca-unavailable'
| 'sca-failed';
/** Result of tokenizeSavedCardWithVerification. */
export interface SavedCardVerificationResult {
verificationToken: string | null;
outcome: SavedCardVerificationOutcome;
}
/** Square Web Payments `card.tokenize()` verification details shape. */
interface SquareVerificationDetails {
amount: string;
@@ -43,14 +41,6 @@
sellerKeyedIn: boolean;
}
/** Square Web Payments `card.tokenize()` result shape (verification path). */
interface SquareTokenizeResult {
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}
/**
* Runs the SCA challenge for a SAVED card (ccof) whose charge Square refused
* with a "verification required" signal. Square's card-on-file flow binds
@@ -136,27 +126,13 @@
return { verificationToken: null, outcome: 'sca-unavailable' };
}
if (result.status === 'OK' && result.verificationResult?.token) {
return { verificationToken: result.verificationResult.token, outcome: 'verified' };
}
const codes = (result.errors ?? []).map((e) => e.code ?? '').filter(Boolean);
const errorText =
codes.join(' ') +
' ' +
(result.errors ?? [])
.map((e) => e.message ?? '')
.join(' ');
// VERIFICATION_CHALLENGE / cancel-coded errors mean the challenge was
// shown but not completed — the buyer can retry, so this is retryable.
if (result.status === 'VERIFICATION_CHALLENGE' || /cancel/i.test(errorText)) {
return { verificationToken: null, outcome: 'challenge-cancelled' };
}
// The card/issuer cannot complete buyer verification at all — SCA is not
// available for this charge, so the surface falls back to the 2FA gate.
if (codes.includes('CARD_DECLINED_VERIFICATION_REQUIRED')) {
return { verificationToken: null, outcome: 'sca-unavailable' };
}
return { verificationToken: null, outcome: 'sca-failed' };
// The shared parse maps the SDK result to the saved-card outcome:
// `status === 'OK'` → 'verified' (the SCA-verified token is `result.token`
// in the current SDK — never a nested verificationResult, which only
// exists on the deprecated verifyBuyer() flow), tokenless when the issuer
// demanded no challenge; VERIFICATION_CHALLENGE / cancel → retryable;
// CARD_DECLINED_VERIFICATION_REQUIRED → 2FA fallback.
return parseTokenizeVerificationResult(result);
}
/**
@@ -239,12 +215,7 @@
attach: (selector: string) => Promise<void>;
tokenize: (
verificationDetails?: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
) => Promise<SquareTokenizeResult>;
destroy: () => void;
}>;
};
@@ -289,14 +260,7 @@
return mockForm.tokenize();
}
const card = cardInstance as {
tokenize: (
verificationDetails?: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
tokenize: (verificationDetails?: SquareVerificationDetails) => Promise<SquareTokenizeResult>;
} | null;
if (!card) {
throw new Error('Card form is not ready — please wait a moment and try again');
@@ -350,14 +314,7 @@
return mockForm.tokenizeWithVerification(amount, contact, saveCard);
}
const card = cardInstance as {
tokenize: (
verificationDetails: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
tokenize: (verificationDetails: SquareVerificationDetails) => Promise<SquareTokenizeResult>;
} | null;
if (!card) {
throw new Error('Card form is not ready — please wait a moment and try again');
@@ -384,13 +341,10 @@
// In the current tokenize-with-verification flow the returned nonce
// (result.token) is ALREADY the 3DS-verified token — Square binds the
// SCA challenge to this exact amount, so charging it as
// `new_card_token`/`card_token` is sufficient. `verificationResult`
// only exists on the deprecated verifyBuyer() flow; we still read it
// defensively since the backend accepts an explicit verification_token.
return {
nonce: result.token,
verificationToken: result.verificationResult?.token ?? null
};
// `new_card_token`/`card_token` is sufficient and no separate
// verification_token exists (that nested shape only came from the
// deprecated verifyBuyer() flow).
return { nonce: result.token, verificationToken: null };
}
const detail =
result.errors
@@ -14,14 +14,13 @@
import { onMount } from 'svelte';
import { generateUUID } from '$lib/utils/uuid';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
isNonceStale,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
sanitizeDecimalInput,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
@@ -125,6 +124,13 @@
// from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge is in flight (the buyer
// approves in their banking app) — drives the "approve in banking app" panel.
let waitingForSCA = $state(false);
// The retryable failure message shown in the error panel (challenge
// 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),
@@ -277,6 +283,9 @@
tipTokenAmount = tipAmount;
tipTokenizedAt = Date.now();
tipTokenizedForSaveCard = saveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it.
lastSCAOutcome = 'verified';
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
@@ -291,7 +300,6 @@
paymentState = 'processing';
const usedSavedCard = !!selectedCardId;
let responseStatus = 0;
try {
@@ -299,32 +307,36 @@
// rationale as the booking/account flows). Include the card so a
// same-amount tip on a DIFFERENT card gets a fresh key instead of
// deduping against the previous card's charge.
const cardKey = selectedCardId || 'new-card';
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
tipIdempotencyKey = generateUUID();
tipKeyedAmount = tipAmount;
tipKeyedCard = cardKey;
}
const amountInPence = Math.round(tipAmount * 100);
// Proactive saved-card (ccof) SCA: run the client-side challenge
// BEFORE the first tip charge attempt so it carries a fresh
// verification_token — a naked ccof is never sent. Only
// 'sca-unavailable' proceeds token-less (the 2FA gate is the
// fallback); a cancelled/failed challenge does NOT charge — the user
// taps Pay Tip again to re-run it, and the cached idempotency key
// above is never regenerated across the challenge-then-charge.
if (selectedCardId && !verificationToken) {
const proactive = await runTipSCAProactively(amountInPence, selectedCardId);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
paymentState = 'error';
toast.error(
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
return;
const cardKey = selectedCardId || 'new-card';
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
tipIdempotencyKey = generateUUID();
tipKeyedAmount = tipAmount;
tipKeyedCard = cardKey;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
}
const body: Record<string, unknown> = {
const amountInPence = Math.round(tipAmount * 100);
// Proactive saved-card (ccof) SCA: run the client-side challenge
// BEFORE the first tip charge attempt so it carries a fresh
// verification_token — a naked ccof is never sent. Only
// 'sca-unavailable' proceeds token-less (the 2FA gate is the
// fallback); a cancelled/failed challenge does NOT charge — the user
// taps Pay Tip again to re-run it, and the cached idempotency key
// above is never regenerated across the challenge-then-charge.
if (selectedCardId && !verificationToken) {
waitingForSCA = true;
try {
const proactive = await runTipSCAProactively(amountInPence, selectedCardId);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
paymentState = 'error';
tipError = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(tipError);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
waitingForSCA = false;
}
}
const body: Record<string, unknown> = {
amount: amountInPence,
idempotency_key: tipIdempotencyKey,
...(selectedCardId ? { card_id: selectedCardId } : {}),
@@ -372,19 +384,14 @@
} catch (err) {
paymentState = 'error';
let errorMessage = err instanceof Error ? err.message : 'Payment failed';
// A definitive 402 on the saved-card path means the issuer still
// requires verification. A structured verification-required signal
// surfaces the SCA-first guidance; the legacy saved-card check is
// the fallback for generic 402s.
// A 402 carrying the structured verification-required signal (or
// the dev/mock text parity) surfaces the SCA-first guidance. A
// plain decline 402 shows the normal decline error — it must not
// be relabeled "requires verification".
const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
if (
scaVerificationRequired ||
isSavedCardVerificationRequired(responseStatus, usedSavedCard)
) {
errorMessage = scaVerificationRequired
? VERIFICATION_REQUIRED_MESSAGE
: SAVED_CARD_VERIFICATION_MESSAGE;
const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
if (verificationFailure) {
errorMessage = VERIFICATION_REQUIRED_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
@@ -392,16 +399,28 @@
if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) {
twoFactor.reveal = true;
}
tipError = errorMessage;
toast.error(errorMessage);
// A definitive charge failure (e.g. declined card) consumes the nonce
// and SCA verification token — they can never succeed again. Clear the
// cached pair so the next retry re-tokenizes fresh. The idempotency
// key is kept: it's still correct for network-timeout dedup.
// cached pair so the next retry re-tokenizes fresh.
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
// A DEFINITIVE 402 (declined card / stale token) means the tip
// charge did NOT land — a retry that re-runs SCA and mints a fresh
// token would otherwise dead-loop on IDEMPOTENCY_KEY_REUSED under
// the same key. Regenerate the key on 402 so the next Pay Tip click
// gets a fresh key + fresh pending row. Keep it on 503/network
// (ambiguous) and on challenge-cancelled/sca-failed (no charge was
// attempted).
if (responseStatus === 402) {
tipIdempotencyKey = '';
tipKeyedAmount = 0;
tipKeyedCard = '';
}
}
} finally {
isSubmittingTipSync = false;
@@ -447,6 +466,7 @@
function retryPayment() {
paymentState = 'idle';
tipError = null;
}
</script>
@@ -609,11 +629,29 @@
{#if paymentState === 'error'}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-red-700">Payment failed. Please try again.</p>
<p class="text-red-700">{tipError ?? 'Payment failed. Please try again.'}</p>
<Button variant="outline" class="mt-3 w-full" onclick={retryPayment}>Try Again</Button>
</div>
{/if}
{#if waitingForSCA}
<div class="mb-4 rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<div
class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
></div>
<div>
<p class="text-sm font-medium text-amber-900">
Approve this payment in your banking app on your phone…
</p>
<p class="mt-0.5 text-xs text-amber-700">
The payment is waiting for your approval. This may take a few moments.
</p>
</div>
</div>
</div>
{/if}
<Button
class="w-full"
size="lg"
@@ -57,7 +57,9 @@
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<p class="text-sm text-amber-800">
Two-factor authentication is required to use online card payments.
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
<a href={resolve('/account')} class="font-medium underline"
>Enable it in your account settings</a
>.
</p>
</div>
{/if}
@@ -17,16 +17,15 @@
import { apiFetch } from '$lib/utils/api';
import { generateUUID } from '$lib/utils/uuid';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
depositChargePence,
isNonceStale,
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
sanitizeDecimalInput,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
@@ -70,6 +69,11 @@
// from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge (card.tokenize with
// verificationDetails) is in flight — the challenge is out-of-band (the
// 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),
@@ -443,6 +447,10 @@
newCardTokenAmount = amountPence;
newCardTokenizedAt = Date.now();
newCardTokenizedForSaveCard = saveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it (the 2FA
// input only surfaces while SCA genuinely cannot authorise).
lastSCAOutcome = 'verified';
} catch (_err) {
status = 'error';
const msg = _err instanceof Error ? _err.message : 'Card entry failed';
@@ -486,17 +494,22 @@
// proceeds token-less (the 2FA gate is the fallback); a cancelled/failed
// challenge does NOT charge — the user taps Pay again to re-run it, and
// the cached idempotency key above is never regenerated across the
// challenge-then-charge.
// challenge-then-charge. waitingForSCA drives the "approve in your
// banking app" panel and blocks modal close while the challenge is open.
if (cardId && !verificationToken) {
const proactive = await runSavedCardSCAProactively(amountPence, cardId);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
status = 'error';
error =
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead.";
toast.error(error);
return;
waitingForSCA = true;
try {
const proactive = await runSavedCardSCAProactively(amountPence, cardId);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
status = 'error';
error = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(error);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
waitingForSCA = false;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
}
await submitBookingPayment(
@@ -624,14 +637,13 @@
overflowConfirm = null;
let msg = _err instanceof Error ? _err.message : 'Payment declined';
// A 402 with the structured verification-required signal (or the
// dev/mock text parity) surfaces the SCA-first guidance; the legacy
// saved-card verification check is the fallback for generic 402s.
// dev/mock text parity) surfaces the SCA-first guidance. A plain
// decline 402 shows the normal decline error — it must not be
// relabeled "requires verification".
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
const verificationFailure =
scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, !!cardId);
const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
if (verificationFailure) {
msg = scaVerificationRequired ? VERIFICATION_REQUIRED_MESSAGE : SAVED_CARD_VERIFICATION_MESSAGE;
msg = VERIFICATION_REQUIRED_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
@@ -649,6 +661,19 @@
newCardTokenAmount = 0;
newCardTokenizedAt = 0;
newCardTokenizedForSaveCard = false;
// 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 (the key's purpose
// is dedup on AMBIGUOUS 503 retries, where the charge may have
// landed). Regenerate the key on 402 so the next Pay click gets a
// fresh key + fresh pending row. Keep it on 503/network (ambiguous)
// and on challenge-cancelled/sca-failed (no charge was attempted).
if (responseStatus === 402) {
payIdempotencyKey = '';
payKeyedAmount = 0;
payKeyedType = '';
payKeyedCard = '';
}
releaseLock();
}
}
@@ -792,6 +817,11 @@
open={true}
onOpenChange={(open) => {
if (open) return;
// ESC/overlay while a payment is in flight (the charge, or the
// out-of-band SCA challenge the buyer must approve in their banking
// app) must NOT close the modal — the charge may still land, and
// closing mid-challenge strands it. Block close until it resolves.
if (status === 'processing' || waitingForSCA) return;
// ESC while the overflow-confirm prompt is showing must dismiss the
// prompt (back to the amount-editing form) instead of closing the whole
// modal — the payment was rejected by the guard and the user needs to
@@ -882,6 +912,25 @@
<span>Slot no longer secured — please close and retry</span>
</div>
{/if}
<!-- Out-of-band SCA challenge: the buyer approves in their banking
app while this panel shows. -->
{#if waitingForSCA}
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<div
class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
></div>
<div>
<p class="text-sm font-medium text-amber-900">
Approve this payment in your banking app on your phone…
</p>
<p class="mt-0.5 text-xs text-amber-700">
The payment is waiting for your approval. This may take a few moments.
</p>
</div>
</div>
</div>
{/if}
<!-- Service Breakdown -->
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
@@ -145,14 +145,7 @@
if (closingTime) {
const [ch, cm] = closingTime.split(':').map(Number);
const today = new Date();
const closing = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
ch,
cm,
0
);
const closing = new Date(today.getFullYear(), today.getMonth(), today.getDate(), ch, cm, 0);
const minutesToClosing = Math.max(
0,
Math.floor((closing.getTime() - endTime.getTime()) / 60000)
@@ -187,8 +187,7 @@
const data = await response.json();
const newApprovals = (data.approvals || []).sort(
(a: PendingApproval, b: PendingApproval) =>
parseWallClockDate(a.created_at).getTime() -
parseWallClockDate(b.created_at).getTime()
parseWallClockDate(a.created_at).getTime() - parseWallClockDate(b.created_at).getTime()
);
const newJson = JSON.stringify(newApprovals);
if (newJson !== prevApprovalsJson) {
@@ -1,6 +1,7 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { CalendarDate } from '@internationalized/date';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { formatUserName } from '$lib/utils/nameDisplay';
@@ -117,7 +118,7 @@
const weekStartStr = $derived.by(() => {
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const d = new Date(londonDateStr + 'T00:00:00Z');
const d = new SvelteDate(londonDateStr + 'T00:00:00Z');
const day = d.getDay();
const diff = day === 0 ? 6 : day - 1;
d.setDate(d.getDate() - diff);
@@ -125,7 +126,7 @@
});
const weekEndStr = $derived.by(() => {
const d = new Date(weekStartStr + 'T00:00:00Z');
const d = new SvelteDate(weekStartStr + 'T00:00:00Z');
d.setDate(d.getDate() + 6);
return formatDateISO(d);
});
@@ -4,6 +4,7 @@
import * as Card from '$lib/components/ui/card';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
import { range } from '$lib/utils/format';
import { SvelteDate } from 'svelte/reactivity';
type TodayAppointment = {
id: string;
@@ -54,7 +55,7 @@
let spansClosed = false;
for (let i = 1; i <= 14; i++) {
const d = new Date(now);
const d = new SvelteDate(now);
d.setDate(d.getDate() - i);
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -71,7 +72,7 @@
if (day?.isOpen) {
const closeTime = day.endTime;
const [h, m] = closeTime.split(':').map(Number);
const closeDate = new Date(d);
const closeDate = new SvelteDate(d);
closeDate.setHours(h, m, 0, 0);
return { cutoff: formatLocalDateTime(closeDate), spansClosed };
} else {
@@ -80,7 +81,7 @@
}
}
const fallback = new Date(now);
const fallback = new SvelteDate(now);
fallback.setDate(fallback.getDate() - 1);
fallback.setHours(17, 0, 0, 0);
return { cutoff: formatLocalDateTime(fallback), spansClosed };
@@ -108,7 +108,7 @@
// media-query variant is a different twMerge group than the plain
// utility, so it survives consumer `max-h-*` overrides that would
// otherwise wipe the keyboard-safe mobile height.
'fixed bottom-0 left-0 right-0 grid w-full max-w-none translate-x-0 translate-y-0 gap-4 rounded-t-xl border border-b-0 bg-background p-6 pb-[max(1rem,env(safe-area-inset-bottom))] shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 max-h-[90vh] max-sm:max-h-[calc(100dvh-4rem)] overflow-y-auto sm:top-[50%] sm:left-[50%] sm:right-auto sm:bottom-auto sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg sm:border-b sm:pb-6',
'fixed right-0 bottom-0 left-0 grid max-h-[90vh] w-full max-w-none translate-x-0 translate-y-0 gap-4 overflow-y-auto rounded-t-xl border border-b-0 bg-background p-6 pb-[max(1rem,env(safe-area-inset-bottom))] shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 max-sm:max-h-[calc(100dvh-4rem)] sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg sm:border-b sm:pb-6',
stripZIndexClasses(className)
)}
{...restProps}
@@ -32,11 +32,6 @@ export function nextZIndex(): number {
return top;
}
/** The current top of the stack (the last claimed z-index). */
export function currentZIndex(): number {
return top;
}
/**
* Reset the stack to its base. Called once from the root layout on mount so
* hot reloads / repeated test runs don't let the counter climb forever.
@@ -26,7 +26,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
'flex min-h-11 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 md:h-9 md:min-h-9',
'flex min-h-11 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:h-9 md:min-h-9 dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
@@ -51,7 +51,7 @@
class="absolute top-full left-0 z-50 mt-1 w-48 rounded-md border border-gray-200 bg-white p-2 shadow-lg"
>
<a
href={href}
{href}
target="_blank"
rel="noopener noreferrer external"
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
+93 -18
View File
@@ -14,6 +14,7 @@ import {
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
parseTokenizeVerificationResult,
requestNewTwoFactorCode,
sanitizeDecimalInput,
shouldFallbackTo2FA,
@@ -240,20 +241,26 @@ describe('isVerificationRequiredSignal', () => {
it('matches the raw CARD_DECLINED_VERIFICATION_REQUIRED text (dev/mock parity)', () => {
expect(
isVerificationRequiredSignal(402, 'CARD_DECLINED_VERIFICATION_REQUIRED: card requires verification')
isVerificationRequiredSignal(
402,
'CARD_DECLINED_VERIFICATION_REQUIRED: card requires verification'
)
).toBe(true);
});
it('matches the plain "verification required" phrasing', () => {
expect(isVerificationRequiredSignal(402, 'Payment failed: verification required by your card issuer')).toBe(
true
);
expect(
isVerificationRequiredSignal(402, 'Payment failed: verification required by your card issuer')
).toBe(true);
});
it('is false for a 402 body with a different code', () => {
expect(isVerificationRequiredSignal(402, JSON.stringify({ error: 'Declined', code: 'card_declined' }))).toBe(
false
);
expect(
isVerificationRequiredSignal(
402,
JSON.stringify({ error: 'Declined', code: 'card_declined' })
)
).toBe(false);
});
it('is false for a 402 body with only error text and no code', () => {
@@ -269,20 +276,32 @@ describe('isVerificationRequiredSignal', () => {
});
it('is false for any non-402 status even with the code present', () => {
expect(isVerificationRequiredSignal(503, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
false
);
expect(isVerificationRequiredSignal(400, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
false
);
expect(isVerificationRequiredSignal(200, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
false
);
expect(
isVerificationRequiredSignal(
503,
JSON.stringify({ error: 'x', code: 'verification_required' })
)
).toBe(false);
expect(
isVerificationRequiredSignal(
400,
JSON.stringify({ error: 'x', code: 'verification_required' })
)
).toBe(false);
expect(
isVerificationRequiredSignal(
200,
JSON.stringify({ error: 'x', code: 'verification_required' })
)
).toBe(false);
});
it('is false when the code is not an exact match (guards against prefix drift)', () => {
expect(
isVerificationRequiredSignal(402, JSON.stringify({ error: 'x', code: 'verification_required_extra' }))
isVerificationRequiredSignal(
402,
JSON.stringify({ error: 'x', code: 'verification_required_extra' })
)
).toBe(false);
});
});
@@ -311,6 +330,58 @@ describe('shouldFallbackTo2FA', () => {
});
});
describe('parseTokenizeVerificationResult', () => {
it('maps a status OK result with a token to a verified outcome carrying the SAME token', () => {
expect(
parseTokenizeVerificationResult({ status: 'OK', token: 'ccof:sca-verified-token' })
).toEqual({
verificationToken: 'ccof:sca-verified-token',
outcome: 'verified'
});
});
it('maps a status OK result with NO token to a verified, tokenless outcome (no SCA required)', () => {
expect(parseTokenizeVerificationResult({ status: 'OK' })).toEqual({
verificationToken: null,
outcome: 'verified'
});
});
it('maps a VERIFICATION_CHALLENGE status to a retryable challenge-cancelled outcome', () => {
expect(parseTokenizeVerificationResult({ status: 'VERIFICATION_CHALLENGE' })).toEqual({
verificationToken: null,
outcome: 'challenge-cancelled'
});
});
it('maps a cancel-coded error to a retryable challenge-cancelled outcome', () => {
expect(
parseTokenizeVerificationResult({
status: 'FAILED',
errors: [{ code: 'CANCEL', message: 'challenge cancelled by buyer' }]
})
).toEqual({ verificationToken: null, outcome: 'challenge-cancelled' });
});
it('maps CARD_DECLINED_VERIFICATION_REQUIRED to sca-unavailable (2FA fallback)', () => {
expect(
parseTokenizeVerificationResult({
status: 'FAILED',
errors: [{ code: 'CARD_DECLINED_VERIFICATION_REQUIRED' }]
})
).toEqual({ verificationToken: null, outcome: 'sca-unavailable' });
});
it('maps any other failure to sca-failed', () => {
expect(
parseTokenizeVerificationResult({
status: 'FAILED',
errors: [{ code: 'CARD_DECLINED', message: 'card declined' }]
})
).toEqual({ verificationToken: null, outcome: 'sca-failed' });
});
});
describe('isTwoFactorVerificationGateFailure', () => {
it.each([
[403, 'A two-factor verification code is required to use this saved card', true],
@@ -545,7 +616,11 @@ describe('adminRequestNewTwoFactorCode', () => {
it('surfaces the 429 mint-cooldown error message', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(jsonResponse({ error: 'Too many requests. Wait before requesting.' }, 429))
vi
.fn()
.mockResolvedValue(
jsonResponse({ error: 'Too many requests. Wait before requesting.' }, 429)
)
);
const result = await adminRequestNewTwoFactorCode('usr_abc');
expect(result.ok).toBe(false);
+73 -1
View File
@@ -180,12 +180,82 @@ export function shouldFallbackTo2FA(scaOutcome: string): boolean {
return scaOutcome === 'sca-unavailable';
}
/** Outcome of a saved-card SCA challenge, used by the payment surfaces to
* decide whether to retry with the fresh verification token, surface a
* retryable failure, or fall back to the 2FA gate. */
export type SavedCardVerificationOutcome =
'verified' | 'challenge-cancelled' | 'sca-unavailable' | 'sca-failed';
/** Result of tokenizeSavedCardWithVerification. */
export interface SavedCardVerificationResult {
verificationToken: string | null;
outcome: SavedCardVerificationOutcome;
}
/** 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
* (`card.tokenize(verificationDetails, cardId)`) flows comes back in the SAME
* `token` field. There is NO nested `verificationResult` that only exists on
* the deprecated `payments.verifyBuyer()` flow, which Square is retiring (see
* developer.squareup.com/docs/web-payments/take-card-payment "Migrate from
* Payments.verifyBuyer()"). */
export interface SquareTokenizeResult {
status: string;
token?: string;
errors?: Array<{ message?: string; code?: string }>;
}
/**
* Maps a Square `card.tokenize()` result to the saved-card SCA outcome.
*
* - `status === 'OK'` means buyer verification either completed or was NOT
* required by the issuer the charge may proceed. The verification-aware
* token (when present) is the `token` field; a tokenless OK means no SCA was
* demanded, so the charge proceeds token-less (the backend 2FA gate / Square
* risk rules are the fallback), never a dead-end.
* - `VERIFICATION_CHALLENGE` / cancel-coded errors mean the challenge was
* shown but not completed the buyer can retry, so this is retryable.
* - `CARD_DECLINED_VERIFICATION_REQUIRED` means no challenge could run SCA
* is unavailable and the surface falls back to the 2FA gate.
* - anything else is a hard SCA failure.
*/
export function parseTokenizeVerificationResult(
result: SquareTokenizeResult
): SavedCardVerificationResult {
if (result.status === 'OK') {
return { verificationToken: result.token ?? null, outcome: 'verified' };
}
const codes = (result.errors ?? []).map((e) => e.code ?? '').filter(Boolean);
const errorText =
codes.join(' ') + ' ' + (result.errors ?? []).map((e) => e.message ?? '').join(' ');
if (result.status === 'VERIFICATION_CHALLENGE' || /cancel/i.test(errorText)) {
return { verificationToken: null, outcome: 'challenge-cancelled' };
}
if (codes.includes('CARD_DECLINED_VERIFICATION_REQUIRED')) {
return { verificationToken: null, outcome: 'sca-unavailable' };
}
return { verificationToken: null, outcome: 'sca-failed' };
}
/** User-facing guidance for a saved-card charge whose issuer requires Strong
* Customer Authentication: the buyer must approve the payment in their banking
* app (the client-side tokenizeSavedCardWithVerification challenge does this). */
export const VERIFICATION_REQUIRED_MESSAGE =
'Your card issuer requires verification. Approve this payment in your banking app.';
/** User-facing message for a saved-card SCA challenge that was cancelled or did
* not complete. Retryable via SCA deliberately does NOT promise the 2FA code
* input, which the customer surfaces only surface on 'sca-unavailable'. */
export const CARD_VERIFICATION_RETRY_MESSAGE =
"Card verification was cancelled or didn't complete. Please try again.";
/** User-facing guidance appended to VERIFICATION_REQUIRED_MESSAGE when the
* issuer's SCA challenge genuinely cannot run the 2FA code input is the
* only available authorisation and is surfaced as the fallback gate. */
export const SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE =
"In-app approval isn't available for this card — enter the verification code instead.";
/** User-facing guidance for a saved-card charge the issuer requires
* verification to complete. Retrying the same saved card is pointless the
* buyer must pay with a new card or re-add their card. */
@@ -264,7 +334,9 @@ export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestRes
* the CUSTOMER's userID, so the code is delivered to the customer and can
* satisfy the card-owner gate the admin's session never receives or
* authenticates the customer's card. */
export async function adminRequestNewTwoFactorCode(userID: string): Promise<TwoFactorCodeRequestResult> {
export async function adminRequestNewTwoFactorCode(
userID: string
): Promise<TwoFactorCodeRequestResult> {
return requestTwoFactorCode(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`);
}
+2 -1
View File
@@ -95,7 +95,8 @@ export interface Payment {
id: string;
booking_id: string;
payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial';
payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount' | 'on_the_house';
payment_method:
'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount' | 'on_the_house';
vendor_code?: string;
invoice_number?: number;
status: 'pending' | 'completed' | 'failed' | 'refunded';
+3 -4
View File
@@ -95,10 +95,9 @@ export function timeToMinutes(time: string): number {
}
export function getDayWithOrdinal(date: CalendarDate): string {
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString(
'en-GB',
{ month: 'long' }
);
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString('en-GB', {
month: 'long'
});
const day = date.day;
if (day > 3 && day < 21) return monthName + ' ' + day + 'th';
switch (day % 10) {
+1 -1
View File
@@ -70,7 +70,7 @@
/>
</svelte:head>
<div class="flex min-h-screen supports-[height:100dvh]:min-h-dvh flex-col">
<div class="flex min-h-screen flex-col supports-[height:100dvh]:min-h-dvh">
<NavBar />
<Toaster position={toasterPosition} />
+76 -32
View File
@@ -10,14 +10,13 @@
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
isNonceStale,
isSavedCardVerificationRequired,
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
@@ -269,6 +268,12 @@
// from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry.
let buyLastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge is in flight (the buyer
// approves in their banking app) — drives the "approve in banking app" panel.
let buyWaitingForSCA = $state(false);
// Retryable purchase failure message shown above the Pay button (challenge
// cancelled/failed, decline) so the retry affordance matches the outcome.
let buyError = $state<string | null>(null);
const buyTwoFactor = useTwoFactorCodeForSavedCard({
enabled: () => buyTwoFactorEnabled,
gateActive: () => buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard),
@@ -446,6 +451,9 @@
buyTokenAmount = buyAmount * 100;
buyTokenizedAt = Date.now();
buyTokenizedForSaveCard = buySaveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it.
buyLastSCAOutcome = 'verified';
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
buyingGiftCard = false;
@@ -488,14 +496,18 @@
// user taps Buy again to re-run it, and the cached idempotency
// key above is never regenerated across the challenge-then-charge.
if (cardId && !verificationToken) {
const proactive = await runBuySCAProactively();
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
toast.error(
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
return;
buyWaitingForSCA = true;
try {
const proactive = await runBuySCAProactively();
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
buyError = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(buyError);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
buyWaitingForSCA = false;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
}
const res = await submitPaymentWithRetry(() =>
@@ -539,42 +551,41 @@
// can only be read once.
const status = res.status;
const errText = await res.text();
// Defensive fallback: a `verification_required` 402 on a
// saved-card buy should no longer happen — the first attempt
// carried a proactive verification_token or demoted to 2FA. If
// it still occurs (e.g. a stale token was consumed between
// tokenize and charge), surface the guidance and let the user
// retry — never re-run SCA silently mid-flow (the
// classification below maps the signal to
// VERIFICATION_REQUIRED_MESSAGE).
// A definitive 402 on the saved-card path means the issuer still
// requires verification. A structured verification-required signal
// surfaces the SCA-first guidance; the legacy saved-card check is
// the fallback for generic 402s.
const scaVerificationRequired = isVerificationRequiredSignal(status, errText);
const verificationRequired =
scaVerificationRequired || isSavedCardVerificationRequired(status, !!buySelectedCard);
// A 402 carrying the structured verification-required signal
// (or the dev/mock text parity) surfaces the SCA-first
// guidance. A plain decline 402 shows the normal decline
// error — it must not be relabeled "requires verification".
const verificationRequired = isVerificationRequiredSignal(status, errText);
// 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.
const buyErrMsg = verificationRequired
? scaVerificationRequired
? VERIFICATION_REQUIRED_MESSAGE
: SAVED_CARD_VERIFICATION_MESSAGE
? VERIFICATION_REQUIRED_MESSAGE
: extractErrorMessage(errText) || 'Failed to purchase gift card';
if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) {
buyTwoFactor.reveal = true;
}
buyError = buyErrMsg;
toast.error(buyErrMsg);
// A definitive charge failure (e.g. declined card) consumes the
// nonce + SCA verification token — clear the cached pair so the
// next retry re-tokenizes fresh. The idempotency key stays for
// network-timeout dedup.
// next retry re-tokenizes fresh.
buyNonce = '';
buyVerificationToken = '';
buyTokenAmount = 0;
buyTokenizedAt = 0;
buyTokenizedForSaveCard = false;
// A DEFINITIVE 402 (declined card / stale token) means the
// purchase did NOT land — a retry that re-runs SCA and mints a
// fresh token would otherwise dead-loop on IDEMPOTENCY_KEY_REUSED
// under the same key. Regenerate the key on 402 so the next Buy
// click gets a fresh key + fresh pending row. Keep it on
// 503/network (ambiguous) and on challenge-cancelled/sca-failed.
if (status === 402) {
buyIdempotencyKey = '';
buyKeyedAmount = 0;
buyKeyedCard = '';
}
}
} catch (err) {
console.error('buyGiftCard error:', err);
@@ -611,7 +622,9 @@
outcome: SavedCardVerificationOutcome;
verificationToken?: string;
}> {
const squareCardId = savedCardsStore.cards.find((c) => c.id === buySelectedCard)?.square_card_id;
const squareCardId = savedCardsStore.cards.find(
(c) => c.id === buySelectedCard
)?.square_card_id;
if (!squareCardId) {
buyLastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
@@ -2564,7 +2577,7 @@
>
{formatCardCode(purchaseResultCode)}
</div>
<p class="text-[10px] text-amber-600 italic font-semibold">
<p class="text-[10px] font-semibold text-amber-600 italic">
⚠️ Please save this code and send it to your friend — no email was sent.
</p>
{/if}
@@ -2681,6 +2694,37 @@
{/if}
</div>
{#if buyWaitingForSCA}
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<div
class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
></div>
<div>
<p class="text-sm font-medium text-amber-900">
Approve this payment in your banking app on your phone…
</p>
<p class="mt-0.5 text-xs text-amber-700">
The payment is waiting for your approval. This may take a few moments.
</p>
</div>
</div>
</div>
{/if}
{#if buyError}
<div class="rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-800">{buyError}</p>
<Button
variant="outline"
size="sm"
class="mt-2 w-full"
onclick={() => (buyError = null)}
>
Try Again
</Button>
</div>
{/if}
<Button
onclick={buyGiftCard}
disabled={buyingGiftCard ||
@@ -3299,7 +3343,7 @@
<!-- Password Change Modal -->
{#if showPasswordModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<Card.Root class="w-full max-w-md max-h-[calc(100dvh-2rem)] overflow-y-auto">
<Card.Root class="max-h-[calc(100dvh-2rem)] w-full max-w-md overflow-y-auto">
<Card.Header>
<Card.Title>Change Password</Card.Title>
<Card.Description>Enter your current and new password</Card.Description>
@@ -1,5 +1,6 @@
<script lang="ts">
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { SvelteDate } from 'svelte/reactivity';
import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
@@ -289,7 +290,7 @@
const d = parseWallClockDate(iso);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today);
const tomorrow = new SvelteDate(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const bookingDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.round((bookingDay.getTime() - today.getTime()) / 86400000);
@@ -9,6 +9,7 @@
import { Skeleton } from '$lib/components/ui/skeleton';
import { Button } from '$lib/components/ui/button';
import { formatDuration } from '$lib/utils/format';
import { SvelteDate } from 'svelte/reactivity';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import BookingModal from '$lib/components/admin/BookingModal.svelte';
@@ -83,7 +84,7 @@
const today = getLondonToday();
const dayOfWeek = today.getDay();
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new Date(today);
const monday = new SvelteDate(today);
monday.setDate(monday.getDate() - diff);
monday.setHours(0, 0, 0, 0);
weekStart = monday;
@@ -93,7 +94,7 @@
// -- Helpers --
function getWeekDays(start: Date): Date[] {
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(start);
const d = new SvelteDate(start);
d.setDate(d.getDate() + i);
return d;
});
@@ -133,7 +134,7 @@
}
function formatWeekLabel(start: Date): string {
const end = new Date(start);
const end = new SvelteDate(start);
end.setDate(end.getDate() + 6);
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
const endOpts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
@@ -226,7 +227,7 @@
if (!initialized) loading = true;
try {
const startStr = formatDate(weekStart);
const end = new Date(weekStart);
const end = new SvelteDate(weekStart);
end.setDate(end.getDate() + 6);
const endStr = formatDate(end);
@@ -312,7 +313,7 @@
function navigateWeek(delta: number) {
if (!weekStart) return;
const d = new Date(weekStart);
const d = new SvelteDate(weekStart);
d.setDate(d.getDate() + delta * 7);
weekStart = d;
}
@@ -321,7 +322,7 @@
const today = getLondonToday();
const dayOfWeek = today.getDay();
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new Date(today);
const monday = new SvelteDate(today);
monday.setDate(monday.getDate() - diff);
monday.setHours(0, 0, 0, 0);
weekStart = monday;
@@ -420,7 +421,7 @@
const isCurrentWeek = $derived(
weekStart !== undefined &&
(() => {
const end = new Date(weekStart);
const end = new SvelteDate(weekStart);
end.setDate(end.getDate() + 6);
end.setHours(23, 59, 59, 999);
return today >= weekStart && today <= end;
@@ -67,9 +67,9 @@
<strong>36 hours in advance</strong>.
</p>
<p class="mb-3">
Payments toward your booking are capped at 100% of the total booking value based on service prices at time of booking.
Once you have paid the full amount, any additional payments above 100% of the booking value will be processed as tips (see
Section 8 below).
Payments toward your booking are capped at 100% of the total booking value based on service
prices at time of booking. Once you have paid the full amount, any additional payments above
100% of the booking value will be processed as tips (see Section 8 below).
</p>
<p class="mb-3">
To maintain fairness and prevent scheduling abuse, accounts with outstanding deposit
@@ -107,14 +107,13 @@
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Cancellation & Refund Tiers</h2>
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Refund before service</h3>
<p class="mb-3">
We understand that plans can change. Eligibility for a refund before your service depends strictly on the amount
of notice provided prior to your scheduled appointment time. These thresholds represent a
genuine pre-estimate of the operational costs and loss of business incurred by late
cancellations. Refunds apply to <strong>booking payments only</strong>, up to 100% of the total
booking value.
We understand that plans can change. Eligibility for a refund before your service depends
strictly on the amount of notice provided prior to your scheduled appointment time. These
thresholds represent a genuine pre-estimate of the operational costs and loss of business
incurred by late cancellations. Refunds apply to <strong>booking payments only</strong>, up
to 100% of the total booking value.
</p>
<div class="mt-4 divide-y divide-gray-200 rounded-md border border-gray-200">
<div class="bg-gray-50/50 p-4">
<p class="font-semibold text-gray-900">Notice of more than 72 hours</p>
@@ -135,8 +134,8 @@
<div class="bg-gray-50/50 p-4">
<p class="font-semibold text-gray-900">Notice of less than 24 hours</p>
<p class="mt-1 text-xs text-gray-600">
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 entirely non-refundable and will be retained. The
cancellation will be logged as a missed appointment history strike.
</p>
</div>
</div>
@@ -144,12 +143,14 @@
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Refund after service</h3>
<p class="mb-3 text-sm leading-relaxed text-gray-700">
Refunds after booked appoinments have been carried out are at the salon owners discretion based on the booking and reason, to arrange a refund please <a
href={resolve('/contact')}
class="font-medium text-blue-600 underline hover:text-blue-800">contact</a> us to discuss a fair refund up to 100% of the value of the booking. Any paid tips will not be considered as part of the refund as they are processed differently.
Refunds after booked appoinments have been carried out are at the salon owners discretion
based on the booking and reason, to arrange a refund please <a
href={resolve('/contact')}
class="font-medium text-blue-600 underline hover:text-blue-800">contact</a
> us to discuss a fair refund up to 100% of the value of the booking. Any paid tips will not be
considered as part of the refund as they are processed differently.
</p>
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Refund Payment Method</h3>
<p class="mb-3 text-sm leading-relaxed text-gray-700">
Refunds are returned to the original payment method where possible:
@@ -161,9 +162,9 @@
days).
</li>
<li>
<strong>Gift card payments</strong>: Refunded back to the original gift card (or, if
you paid from your account balance, back to that balance). The gift card's remaining
balance is incremented and is immediately available for use. Expired gift cards are
<strong>Gift card payments</strong>: Refunded back to the original gift card (or, if you
paid from your account balance, back to that balance). The gift card's remaining balance
is incremented and is immediately available for use. Expired gift cards are
non-refundable.
</li>
<li>
@@ -247,31 +248,30 @@
Regulations 2013 does not apply to online bookings scheduled for a specific date or time.
</p>
<p class="mb-3">
That exclusion does not apply to gift cards: online gift-card purchases may be
cancelled within 14 days for a refund to the original payment method under the
Consumer Contracts Regulations 2013. If the card has been partly used, the amount
already spent on salon services is not refundable, and the remaining unspent balance
is refunded to the original payment method; the card is then cancelled. A card that
has been redeemed to an account balance or fully spent cannot be cancelled.
That exclusion does not apply to gift cards: online gift-card purchases may be cancelled
within 14 days for a refund to the original payment method under the Consumer Contracts
Regulations 2013. If the card has been partly used, the amount already spent on salon
services is not refundable, and the remaining unspent balance is refunded to the original
payment method; the card is then cancelled. A card that has been redeemed to an account
balance or fully spent cannot be cancelled.
</p>
<p class="mb-3">
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.
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.
</p>
<p class="mb-3">
If you believe your statutory consumer rights have not been met, you can get free,
impartial advice from
If you believe your statutory consumer rights have not been met, you can get free, impartial
advice from
<a
href="https://consumeradvice.scot"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-blue-600 underline hover:text-blue-800"
>consumeradvice.scot</a
class="font-medium text-blue-600 underline hover:text-blue-800">consumeradvice.scot</a
>
(advice.scot). If that does not resolve the issue, you can escalate your complaint to your
local Trading Standards office. Claims up to &pound;5,000 can also be pursued through the
Scottish courts' Simple Procedure.
(advice.scot). If that does not resolve the issue, you can escalate your complaint to your local
Trading Standards office. Claims up to &pound;5,000 can also be pursued through the Scottish courts'
Simple Procedure.
</p>
<p class="mb-3">
We recognize that genuine emergencies, sudden severe illness, or bereavement can occur. Our
@@ -328,13 +328,14 @@
</p>
<p class="mb-3">
Tips can be added via your account after the booking has started, or at the time of payment
when paying in person at the salon. When paying by card at the terminal, you will be prompted
to add a tip if you wish.
when paying in person at the salon. When paying by card at the terminal, you will be
prompted to add a tip if you wish.
</p>
<p class="mb-3 text-xs text-gray-500 italic">
If you believe a tip was added in error, please contact us via our official
<a href={resolve('/contact')} class="font-medium text-blue-600 underline hover:text-blue-800"
>Contact Channels</a
<a
href={resolve('/contact')}
class="font-medium text-blue-600 underline hover:text-blue-800">Contact Channels</a
> and we will review your case.
</p>
</section>
+4 -6
View File
@@ -83,11 +83,7 @@
const dob = new Date(dateStr);
const today = new Date();
const sixteenYearsAgo = new Date(
today.getFullYear() - 16,
today.getMonth(),
today.getDate()
);
const sixteenYearsAgo = new Date(today.getFullYear() - 16, today.getMonth(), today.getDate());
const isValid = dob <= sixteenYearsAgo;
validationErrors.dateOfBirth = isValid
@@ -509,7 +505,9 @@
bind:value={formData.dateOfBirth}
onblur={() => validateAge(formData.dateOfBirth)}
max={new Date(
new Date().setFullYear(new Date().getFullYear() - 16)
new Date().getFullYear() - 16,
new Date().getMonth(),
new Date().getDate()
).toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
required
/>
@@ -128,7 +128,7 @@
<title>Leave a Tip - Crussell</title>
</svelte:head>
<div class="mx-auto min-h-screen supports-[height:100dvh]:min-h-dvh px-4 py-8 sm:max-w-md md:py-12">
<div class="mx-auto min-h-screen px-4 py-8 supports-[height:100dvh]:min-h-dvh sm:max-w-md md:py-12">
{#if loading || pageState === 'loading'}
<div class="space-y-6">
<div class="text-center">
+60 -43
View File
@@ -63,8 +63,8 @@
<h2 class="mb-3 text-base font-semibold text-gray-900">1. Introduction</h2>
<p class="mb-3">
This Privacy Policy explains how Crussell Salon (&ldquo;we&rdquo;, &ldquo;us&rdquo;,
&ldquo;our&rdquo;) collects, uses, and protects your personal data when you use our
booking platform (&ldquo;Platform&rdquo;).
&ldquo;our&rdquo;) collects, uses, and protects your personal data when you use our booking
platform (&ldquo;Platform&rdquo;).
</p>
<p class="mb-3">
We are committed to protecting your privacy and complying with the
@@ -76,7 +76,7 @@
<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>Email: {'{{SUPPORT_EMAIL}}'}</p>
</div>
</section>
@@ -84,7 +84,9 @@
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">2. Data We Collect</h2>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.1 Personal Data (Identifiable Information)</h3>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
2.1 Personal Data (Identifiable Information)
</h3>
<p class="mb-2 font-medium text-gray-800">Account Information:</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Name (first, last)</li>
@@ -97,8 +99,8 @@
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Appointment dates, times, services</li>
<li>
Treatment notes and preferences (one health-and-safety record &mdash; includes
allergies, skin sensitivities, and access needs)
Treatment notes and preferences (one health-and-safety record &mdash; includes allergies,
skin sensitivities, and access needs)
</li>
<li>Allergy and patch test records (health data &mdash; special category)</li>
<li>Payment history and transaction records</li>
@@ -115,7 +117,9 @@
GDPR; we record it so we can treat you safely and make reasonable adjustments (Equality Act
2010).
</p>
<p class="mb-3">These notes are seen only by the salon owner and are never shared or exported.</p>
<p class="mb-3">
These notes are seen only by the salon owner and are never shared or exported.
</p>
<p class="mb-4">
On account deletion the rest of your record is erased or anonymized, and your notes are
retained in a form that cannot be traced back to you. We keep them so we can still make safe
@@ -127,39 +131,44 @@
<li>Gift card codes and balances</li>
<li>Account balances</li>
<li>Payment transaction records (processed via Square, not stored by us)</li>
<li>Saved-card references (tokenised, stored with our payment provider Square &mdash; see &sect;2.2)</li>
<li>
Saved-card references (tokenised, stored with our payment provider Square &mdash; see
&sect;2.2)
</li>
<li>Dormant balance records (Account ID only, no PII)</li>
</ul>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.2 Saved Cards &amp; Payment Provider (Square)</h3>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
2.2 Saved Cards &amp; Payment Provider (Square)
</h3>
<p class="mb-3">
When you choose to <strong>save a card for next time</strong>, we store a tokenised
reference to your card with our payment processor, <strong>Square</strong> (a data
processor), rather than on our own systems.
reference to your card with our payment processor, <strong>Square</strong> (a data processor),
rather than on our own systems.
</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>
<strong>What Square stores:</strong> a tokenised reference to your card (never your
full card number or CVV), plus the name and email address we already hold on your
account, grouped into a Square customer profile.
<strong>What Square stores:</strong> a tokenised reference to your card (never your full card
number or CVV), plus the name and email address we already hold on your account, grouped into
a Square customer profile.
</li>
<li>
<strong>Lawful basis:</strong> UK GDPR Article 6(1)(b) &mdash; necessary for the
performance of the contract (you asked to save your card for future payments).
<strong>Lawful basis:</strong> UK GDPR Article 6(1)(b) &mdash; necessary for the performance
of the contract (you asked to save your card for future payments).
</li>
<li>
<strong>Why:</strong> so you can pay for future bookings, tips, or gift-card purchases
without re-entering your card details.
<strong>Why:</strong> so you can pay for future bookings, tips, or gift-card purchases without
re-entering your card details.
</li>
<li>
<strong>One-off payments:</strong> if you do not tick &ldquo;save this card&rdquo;,
<strong>no card is stored and no Square customer profile is created</strong> for you
&mdash; your card is used only for that single payment.
<strong>no card is stored and no Square customer profile is created</strong> for you &mdash;
your card is used only for that single payment.
</li>
<li>
<strong>Retention &amp; removal:</strong> the reference remains stored until you delete
the card from your account (Account &rarr; Saved Cards) or your account is deleted. You
can remove a saved card at any time.
<strong>Retention &amp; removal:</strong> the reference remains stored until you delete the
card from your account (Account &rarr; Saved Cards) or your account is deleted. You can remove
a saved card at any time.
</li>
<li>
<strong>Square&rsquo;s privacy policy:</strong>
@@ -167,19 +176,22 @@
href="https://squareup.com/gb/en/legal/privacy-no-account"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-blue-600 underline hover:text-blue-800"
>Square Privacy Policy</a
class="font-medium text-blue-600 underline hover:text-blue-800">Square Privacy Policy</a
>
applies to data Square holds on our behalf.
</li>
</ul>
<p class="mb-4">
We never store full card numbers, card security codes (CVV), or card expiry data on our
own systems at any point.
We never store full card numbers, card security codes (CVV), or card expiry data on our own
systems at any point.
</p>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.3 Special Category Data (Health Data)</h3>
<p class="mb-3">We collect health-related information with your <strong>explicit consent</strong>:</p>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
2.3 Special Category Data (Health Data)
</h3>
<p class="mb-3">
We collect health-related information with your <strong>explicit consent</strong>:
</p>
<ul class="mb-3 list-disc space-y-1 pl-5">
<li>Allergy records</li>
<li>Patch test results</li>
@@ -188,15 +200,17 @@
</ul>
<p class="mb-3">
<strong>Legal basis:</strong> UK GDPR Article 9(2)(a) &mdash; Explicit consent<br />
<strong>Retention:</strong> 7 years (insurance requirement); patch-test records are kept
unlinked to you if your account is deleted, and allergy/access information held in your
treatment notes is retained de-identified (see &sect;3.1).
<strong>Retention:</strong> 7 years (insurance requirement); patch-test records are kept unlinked
to you if your account is deleted, and allergy/access information held in your treatment notes
is retained de-identified (see &sect;3.1).
</p>
</section>
<!-- Section 3 -->
<section>
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Data Retention &amp; Deletion Process</h2>
<h2 class="mb-3 text-base font-semibold text-gray-900">
3. Data Retention &amp; Deletion Process
</h2>
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">3.1 Retention Schedule</h3>
<div class="overflow-x-auto rounded-md border border-gray-200">
@@ -242,11 +256,13 @@
<td class="px-3 py-2">Insurance requirement</td>
</tr>
<tr>
<td class="px-3 py-2">Treatment &amp; safety notes (incl. allergy/access information)</td>
<td class="px-3 py-2"
>Treatment &amp; safety notes (incl. allergy/access information)</td
>
<td class="px-3 py-2">
Retained after account deletion in de-identified form while they may be needed for
safety adjustments or legal-claims defence; the rest of the account record is
erased at deletion
safety adjustments or legal-claims defence; the rest of the account record is erased
at deletion
</td>
<td class="px-3 py-2">Legitimate interest (safety &amp; legal-claims defence)</td>
</tr>
@@ -289,10 +305,9 @@
</li>
</ol>
<p class="mb-3">
<strong>Saved cards:</strong> Deleting your account also removes your saved-card
references from our system and disables the corresponding card tokens at Square (see
&sect;2.2). Card transaction records for payments already made are retained per the HMRC
schedule above.
<strong>Saved cards:</strong> Deleting your account also removes your saved-card references from
our system and disables the corresponding card tokens at Square (see &sect;2.2). Card transaction
records for payments already made are retained per the HMRC schedule above.
</p>
<p class="mb-2 font-medium text-gray-800">Inactive account deletion (automatic):</p>
<ol class="mb-3 list-decimal space-y-1 pl-5">
@@ -313,15 +328,17 @@
<ul class="mb-4 list-disc space-y-1 pl-5">
<li><strong>Access</strong> your personal data (Article 15)</li>
<li><strong>Rectify</strong> inaccurate data (Article 16)</li>
<li><strong>Erase</strong> your data (Article 17 &mdash; subject to HMRC/insurance retention)</li>
<li>
<strong>Erase</strong> your data (Article 17 &mdash; subject to HMRC/insurance retention)
</li>
<li><strong>Restrict</strong> processing (Article 18)</li>
<li><strong>Data Portability</strong> (Article 20)</li>
<li><strong>Object</strong> to processing (Article 21)</li>
<li><strong>Withdraw Consent</strong> (Article 7(3))</li>
</ul>
<p class="mb-4">
To exercise these rights, contact {"{{SUPPORT_EMAIL}}"}. You also have the right to
complain to the Information Commissioner&rsquo;s Office (ICO) at any time.
To exercise these rights, contact {'{{SUPPORT_EMAIL}}'}. You also have the right to complain
to the Information Commissioner&rsquo;s Office (ICO) at any time.
</p>
<p class="text-xs text-gray-500">
Questions about how we handle your data? Please use our official
+1 -1
View File
@@ -143,7 +143,7 @@
<title>Leave a Tip - Crussell</title>
</svelte:head>
<div class="mx-auto min-h-screen supports-[height:100dvh]:min-h-dvh px-4 py-8 sm:max-w-md md:py-12">
<div class="mx-auto min-h-screen px-4 py-8 supports-[height:100dvh]:min-h-dvh sm:max-w-md md:py-12">
{#if loading}
<div class="space-y-6">
<div class="text-center">
+1 -5
View File
@@ -189,11 +189,7 @@
</div>
<!-- Modals -->
<BookingModal
bind:open={showBookingModal}
bookingId={selectedBookingId ?? ''}
{openUserModal}
/>
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} {openUserModal} />
<EditBookingModal
bind:open={showEditBookingModal}