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:
@@ -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 (£{GIFT_CARD_MAX_AMOUNT})</p
|
||||
>
|
||||
<p class="text-xs text-red-700">
|
||||
Gift card amount exceeds maximum (£{GIFT_CARD_MAX_AMOUNT})
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground"
|
||||
>Gift card limit £{GIFT_CARD_MAX_AMOUNT} per transaction</p
|
||||
>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Gift card limit £{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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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 £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 £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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (“we”, “us”,
|
||||
“our”) collects, uses, and protects your personal data when you use our
|
||||
booking platform (“Platform”).
|
||||
“our”) collects, uses, and protects your personal data when you use our booking
|
||||
platform (“Platform”).
|
||||
</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 — includes
|
||||
allergies, skin sensitivities, and access needs)
|
||||
Treatment notes and preferences (one health-and-safety record — includes allergies,
|
||||
skin sensitivities, and access needs)
|
||||
</li>
|
||||
<li>Allergy and patch test records (health data — 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 — see §2.2)</li>
|
||||
<li>
|
||||
Saved-card references (tokenised, stored with our payment provider Square — see
|
||||
§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 & Payment Provider (Square)</h3>
|
||||
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">
|
||||
2.2 Saved Cards & 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) — 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) — 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 “save this card”,
|
||||
<strong>no card is stored and no Square customer profile is created</strong> for you
|
||||
— your card is used only for that single payment.
|
||||
<strong>no card is stored and no Square customer profile is created</strong> for you —
|
||||
your card is used only for that single payment.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Retention & removal:</strong> the reference remains stored until you delete
|
||||
the card from your account (Account → Saved Cards) or your account is deleted. You
|
||||
can remove a saved card at any time.
|
||||
<strong>Retention & removal:</strong> the reference remains stored until you delete the
|
||||
card from your account (Account → Saved Cards) or your account is deleted. You can remove
|
||||
a saved card at any time.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Square’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) — 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 §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 §3.1).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Section 3 -->
|
||||
<section>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Data Retention & Deletion Process</h2>
|
||||
<h2 class="mb-3 text-base font-semibold text-gray-900">
|
||||
3. Data Retention & 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 & safety notes (incl. allergy/access information)</td>
|
||||
<td class="px-3 py-2"
|
||||
>Treatment & 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 & 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
|
||||
§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 §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 — subject to HMRC/insurance retention)</li>
|
||||
<li>
|
||||
<strong>Erase</strong> your data (Article 17 — 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’s Office (ICO) at any time.
|
||||
To exercise these rights, contact {'{{SUPPORT_EMAIL}}'}. You also have the right to complain
|
||||
to the Information Commissioner’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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -189,11 +189,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<BookingModal
|
||||
bind:open={showBookingModal}
|
||||
bookingId={selectedBookingId ?? ''}
|
||||
{openUserModal}
|
||||
/>
|
||||
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} {openUserModal} />
|
||||
|
||||
<EditBookingModal
|
||||
bind:open={showEditBookingModal}
|
||||
|
||||
Reference in New Issue
Block a user