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

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

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

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

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

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

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

Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/
gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent c4c65d9dd8
commit b7122be3a0
78 changed files with 2861 additions and 1120 deletions
@@ -1,6 +1,6 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { SvelteMap } from 'svelte/reactivity';
import { SvelteDate, SvelteMap } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
@@ -16,7 +16,11 @@
getLunchProtectionForSlots,
timeToMinutes
} from '$lib/lunchProtection';
import { formatLocalDateTime, getLondonTodayCalendarDate, parseWallClockDate } from '$lib/utils/timeSlots';
import {
formatLocalDateTime,
getLondonTodayCalendarDate,
parseWallClockDate
} from '$lib/utils/timeSlots';
import ClockIcon from '@lucide/svelte/icons/clock';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
@@ -71,7 +75,7 @@
// ─── Date constants ─────────────────────────────────────
const todayCalendarDate = getLondonTodayCalendarDate();
const minDate = todayCalendarDate;
const maxDate = new Date(
const maxDate = new SvelteDate(
todayCalendarDate.year,
todayCalendarDate.month - 1,
todayCalendarDate.day
@@ -458,7 +462,7 @@
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 0; i <= daysToCheck; i++) {
const nextDate = new Date(currentDate);
const nextDate = new SvelteDate(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -2,7 +2,7 @@
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
import { SvelteDate, SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay';
@@ -217,7 +217,7 @@
// Date Boundaries
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new Date(today.year, today.month - 1, today.day);
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
@@ -408,7 +408,7 @@
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 0; i <= daysToCheck; i++) {
const checkDate = new Date(now);
const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const calDate = new CalendarDate(
@@ -456,29 +456,29 @@
/>
{/if}
<div>
{#if selectedBooking.user}
{@const customer = selectedBooking.user}
{@const userName = formatUserName(
customer.full_name || '—',
customer.previous_first_name,
customer.previous_last_name
)}
<div class="text-lg font-semibold">
{#if openUserModal}
<button
type="button"
class="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline"
onclick={() => openUserModal(customer.id)}
>
{#if selectedBooking.user}
{@const customer = selectedBooking.user}
{@const userName = formatUserName(
customer.full_name || '—',
customer.previous_first_name,
customer.previous_last_name
)}
<div class="text-lg font-semibold">
{#if openUserModal}
<button
type="button"
class="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline"
onclick={() => openUserModal(customer.id)}
>
{userName}
</button>
{:else}
{userName}
</button>
{:else}
{userName}
{/if}
</div>
{:else}
<div class="text-lg font-semibold"></div>
{/if}
{/if}
</div>
{:else}
<div class="text-lg font-semibold"></div>
{/if}
{#if selectedBooking.user?.date_of_birth}
<div class="text-sm text-gray-500">
{calculateAge(selectedBooking.user.date_of_birth)} years old
@@ -388,8 +388,8 @@
<span class="text-xs text-muted-foreground">Expiry Period</span>
<p class="text-sm font-medium">{settings.gift_card_expiry_months} months</p>
<p class="text-xs text-muted-foreground">
Rolling from last use — each balance check, top-up, redemption or payment
resets the timer.
Rolling from last use — each balance check, top-up, redemption or payment resets the
timer.
</p>
</div>
<div>
@@ -5,6 +5,7 @@
import { range, formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { SvelteDate } from 'svelte/reactivity';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
@@ -179,7 +180,7 @@
function addWeeksToException(fromISO: string, toISO: string, dest: string[]) {
const from = new Date(fromISO + 'T00:00:00Z');
const to = new Date(toISO + 'T00:00:00Z');
const first = new Date(from);
const first = new SvelteDate(from);
const day = first.getDay();
const daysToMonday = day === 0 ? -6 : 1 - day;
@@ -187,7 +188,7 @@
first.setDate(first.getDate() + daysToMonday);
// Add all Mondays in the range
for (let d = new Date(first); d <= to; d.setDate(d.getDate() + 7)) {
for (let d = new SvelteDate(first); d <= to; d.setDate(d.getDate() + 7)) {
dest.push(isoDateOf(new Date(d)));
}
}
@@ -749,7 +750,7 @@
<Button
variant="outline"
size="sm"
class="h-6 text-xs ml-auto"
class="ml-auto h-6 text-xs"
onclick={checkConflictingBookings}
>
Refresh
@@ -1,5 +1,5 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
@@ -58,7 +58,7 @@
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new Date(today.year, today.month - 1, today.day);
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
@@ -391,7 +391,7 @@
);
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) {
const checkDate = new Date(now);
const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const calDate = new CalendarDate(
@@ -458,8 +458,8 @@
<details class="ml-6 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700">When to use this</summary>
<p class="mt-1">
Use for genuinely excusable cancellations, or when the salon cancels and chooses
not to keep the money. When unchecked, standard notice-period fees apply (e.g. a
Use for genuinely excusable cancellations, or when the salon cancels and chooses not
to keep the money. When unchecked, standard notice-period fees apply (e.g. a
customer who calls up to cancel).
</p>
</details>
@@ -6,24 +6,27 @@
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import { SvelteMap } from 'svelte/reactivity';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import {
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
shouldFallbackTo2FA,
submitPaymentWithRetry,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
shouldFallbackTo2FA,
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
submitPaymentWithRetry,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
type CartItem = {
id: string;
@@ -65,7 +68,7 @@
// the customer is not charged twice. A changed cart/amount/payment method
// yields a different composite key, so genuinely new sales get fresh keys.
// Mirrors the BookingFlow/PaymentModal/TipPayment per-charge caching pattern.
let idempotencyKeys = new Map<string, string>();
let idempotencyKeys = new SvelteMap<string, string>();
function idempotencyKeyFor(item: CartItem, qtyIndex: number): string {
// saved_card charges also key on the selected card id so switching to a
@@ -148,10 +151,13 @@
let awaitingSCA = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
mint: () =>
selectedCustomer?.id ? adminRequestNewTwoFactorCode(selectedCustomer.id) : requestNewTwoFactorCode()
selectedCustomer?.id
? adminRequestNewTwoFactorCode(selectedCustomer.id)
: requestNewTwoFactorCode()
});
// The saved-card option is hidden outright unless a customer is selected
@@ -408,7 +414,10 @@
// challenge and retry the SAME sale line with the fresh token and
// its SAME cached idempotency key. runTillSavedCardSCA throws to
// stop the whole sale on any non-verified outcome.
if (paymentMethod === 'saved_card' && isVerificationRequiredSignal(responseStatus, errText)) {
if (
paymentMethod === 'saved_card' &&
isVerificationRequiredSignal(responseStatus, errText)
) {
await runTillSavedCardSCA(body);
continue;
}
@@ -433,75 +442,71 @@
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
paymentError = msg;
toast.error(msg);
} finally {
isProcessingPaymentSync = false;
processing = false;
} finally {
isProcessingPaymentSync = false;
processing = false;
}
}
}
/**
* Saved-card (ccof) SCA challenge, run when a till sale line came back 402
* with the verification-required signal. The CUSTOMER approves the 3DS
* challenge in their banking app; the operator's screen shows the waiting
* state. 'verified' retries the SAME sale line with the fresh verification_token
* and its SAME cached idempotency key (never regenerated here); 'sca-unavailable'
* demotes 2FA from backup to the available gate; 'challenge-cancelled' /
* 'sca-failed' keep the pending row retryable (the idempotency key stays
* cached). Throws to stop the whole sale on any non-verified outcome.
*/
async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void> {
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
// The till body carries the amount in POUNDS (the backend multiplies by
// 100); the SCA challenge binds to pence, so convert for the challenge.
const amountPence = Math.round((Number(body.amount) || 0) * 100);
awaitingSCA = true;
try {
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw new Error(VERIFICATION_REQUIRED_MESSAGE);
}
let result: SavedCardVerificationResult;
/**
* Saved-card (ccof) SCA challenge, run when a till sale line came back 402
* with the verification-required signal. The CUSTOMER approves the 3DS
* challenge in their banking app; the operator's screen shows the waiting
* state. 'verified' retries the SAME sale line with the fresh verification_token
* and its SAME cached idempotency key (never regenerated here); 'sca-unavailable'
* demotes 2FA from backup to the available gate; 'challenge-cancelled' /
* 'sca-failed' keep the pending row retryable (the idempotency key stays
* cached). Throws to stop the whole sale on any non-verified outcome.
*/
async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void> {
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
// The till body carries the amount in POUNDS (the backend multiplies by
// 100); the SCA challenge binds to pence, so convert for the challenge.
const amountPence = Math.round((Number(body.amount) || 0) * 100);
awaitingSCA = true;
try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
email: selectedCustomer?.email
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw err;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
const retry = await submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...body,
verification_token: result.verificationToken
})
})
);
if (!retry.ok) {
const errText = await retry.text();
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw new Error(VERIFICATION_REQUIRED_MESSAGE);
}
return;
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
email: selectedCustomer?.email
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw err;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
const retry = await submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...body,
verification_token: result.verificationToken
})
})
);
if (!retry.ok) {
const errText = await retry.text();
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
}
return;
}
twoFactor.reveal = true;
if (result.outcome === 'sca-unavailable') {
throw new Error(`${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`);
}
throw new Error(CARD_VERIFICATION_RETRY_MESSAGE);
} finally {
awaitingSCA = false;
}
twoFactor.reveal = true;
if (result.outcome === 'sca-unavailable') {
throw new Error(
`${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.`
);
}
throw new Error(
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
} finally {
awaitingSCA = false;
}
}
</script>
<div class="rounded-xl border bg-card">
@@ -585,13 +590,13 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
>
</div>
{#if giftCardAmountTooHigh}
<p class="text-xs text-red-700"
>Gift card amount exceeds maximum (&pound;{GIFT_CARD_MAX_AMOUNT})</p
>
<p class="text-xs text-red-700">
Gift card amount exceeds maximum (&pound;{GIFT_CARD_MAX_AMOUNT})
</p>
{/if}
<p class="text-xs text-muted-foreground"
>Gift card limit &pound;{GIFT_CARD_MAX_AMOUNT} per transaction</p
>
<p class="text-xs text-muted-foreground">
Gift card limit &pound;{GIFT_CARD_MAX_AMOUNT} per transaction
</p>
</div>
{:else}
<Button
@@ -885,9 +890,9 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-xs text-amber-800">
Your card issuer will ask you to approve this payment in your banking app.
</p>
<p class="text-xs text-amber-800">
Your card issuer will ask you to approve this payment in your banking app.
</p>
</div>
{/if}
@@ -928,7 +933,9 @@ async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void>
{/if}
{#if awaitingSCA}
<div class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6">
<div
class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6"
>
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
@@ -204,7 +204,8 @@
const sortedBlockers = $derived.by(() => {
return [...blockers].sort(
(a, b) => parseWallClockDate(a.start_time).getTime() - parseWallClockDate(b.start_time).getTime()
(a, b) =>
parseWallClockDate(a.start_time).getTime() - parseWallClockDate(b.start_time).getTime()
);
});
@@ -878,7 +879,7 @@
<Button
variant="outline"
size="sm"
class="h-6 text-xs ml-auto"
class="ml-auto h-6 text-xs"
onclick={checkOverlappingBookings}
>
Refresh
@@ -731,8 +731,8 @@
selectedUser.previousFirstName,
selectedUser.previousLastName
)}
? They will no longer need a 2FA code for card payments. Use this only when the user has
lost access to their 2FA method.
? They will no longer need a 2FA code for card payments. Use this only when the user has lost
access to their 2FA method.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
@@ -3,7 +3,7 @@
import { apiFetch } from '$lib/utils/api';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
@@ -479,7 +479,7 @@
const [hours, minutes] = availableStartTime.split(':').map(Number);
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
start = new Date(y, m - 1, d, hours, minutes, 0, 0);
start = new SvelteDate(y, m - 1, d, hours, minutes, 0, 0);
} else {
// Fallback: Calculate immediate start time (rounded to next 15 min)
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -491,8 +491,8 @@
});
const [y, m, d] = londonDateStr.split('-').map(Number);
const [h, min] = londonTimeStr.split(':').map(Number);
const now = new Date(y, m - 1, d, h, min, 0, 0);
start = new Date(now);
const now = new SvelteDate(y, m - 1, d, h, min, 0, 0);
start = new SvelteDate(now);
const minutes = start.getMinutes();
const remainder = 15 - (minutes % 15);
if (remainder !== 15 && remainder !== 0) {
@@ -6,6 +6,7 @@
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { formatDuration, range } from '$lib/utils/format';
import { SvelteDate } from 'svelte/reactivity';
// shadcn-svelte components
import { Button } from '$lib/components/ui/button';
@@ -228,7 +229,7 @@
}
function getDefaultEffectiveDate(): string {
const d = new Date();
const d = new SvelteDate();
d.setDate(d.getDate() + 1);
return d.toISOString().slice(0, 10);
}
@@ -814,7 +815,7 @@
</div>
<!-- Effective Date & Conflict Resolution -->
<div class="border-t px-4 pb-4 pt-4">
<div class="border-t px-4 pt-4 pb-4">
<div class="space-y-3">
<div>
<label for="effective_date" class="mb-1 block text-sm font-medium text-gray-700">
@@ -879,7 +880,7 @@
<Button
variant="outline"
size="sm"
class="h-6 text-xs ml-auto"
class="ml-auto h-6 text-xs"
onclick={checkConflictingBookings}
>
Refresh
@@ -19,6 +19,7 @@
// TIMESTAMPTZ (UTC) and converts to Europe/London for display. This ensures a booking at
// "10am June 15" stays at 10am BST regardless of when the booking was made.
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { SvelteDate } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/auth.svelte';
@@ -40,6 +41,7 @@
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
campaignDiscountPence,
depositChargePence,
@@ -173,6 +175,12 @@
// from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge is in flight (the buyer
// approves in their banking app) — drives the "approve in banking app" panel.
let waitingForSCA = $state(false);
// Retryable deposit failure message shown on the payment step (challenge
// cancelled/failed, decline) so the retry affordance matches the outcome.
let depositError = $state<string | null>(null);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () =>
@@ -412,6 +420,9 @@
depositTokenAmount = amountPence;
depositTokenizedAt = Date.now();
depositTokenizedForSaveCard = depositSaveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it.
lastSCAOutcome = 'verified';
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
@@ -449,14 +460,18 @@
// the cached idempotency key above (never regenerated across the
// challenge-then-charge).
if (selectedPaymentMethod && !verificationToken) {
const proactive = await runDepositSCAProactively(amountPence);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
toast.error(
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
return;
waitingForSCA = true;
try {
const proactive = await runDepositSCAProactively(amountPence);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
depositError = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(depositError);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
waitingForSCA = false;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
}
const body: Record<string, unknown> = {
@@ -562,7 +577,8 @@
// charge), surface the guidance and let the user retry — never re-run SCA
// silently mid-flow.
if (selectedPaymentMethod && isVerificationRequiredSignal(response.status, text)) {
toast.warning(VERIFICATION_REQUIRED_MESSAGE);
depositError = VERIFICATION_REQUIRED_MESSAGE;
toast.warning(depositError);
return;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
@@ -589,9 +605,7 @@
amountPence,
overflowPence: Math.max(
0,
amountPence -
Math.round((confirmedBooking?.amount_due ?? 0) * 100) -
depositDiscountPence
amountPence - Math.round((confirmedBooking?.amount_due ?? 0) * 100) - depositDiscountPence
),
chargePence: Math.max(0, amountPence - depositDiscountPence),
depositAmount,
@@ -626,21 +640,22 @@
depositPaid = confirmedBooking.deposit_paid;
toast.success('Payment successful!');
} else {
toast.warning(
depositError =
extractErrorMessage(text) ||
'Payment failed — you can pay again from your booking details.'
);
'Payment failed — you can pay again from your booking details.';
toast.warning(depositError);
}
} catch {
toast.warning(
depositError =
extractErrorMessage(text) ||
'Payment failed — you can pay again from your booking details.'
);
'Payment failed — you can pay again from your booking details.';
toast.warning(depositError);
}
} else {
toast.warning(
extractErrorMessage(text) || 'Payment failed — you can pay again from your booking details.'
);
depositError =
extractErrorMessage(text) ||
'Payment failed — you can pay again from your booking details.';
toast.warning(depositError);
}
// A definitive charge failure (declined card, any 4xx) consumes the
// nonce + SCA verification token (Square nonces are single-use) —
@@ -653,6 +668,17 @@
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
// A DEFINITIVE 402 (declined card / stale token) means the deposit
// charge did NOT land — a retry that re-runs SCA and mints a fresh
// token would otherwise dead-loop on IDEMPOTENCY_KEY_REUSED under the
// same key. Regenerate the key on 402 so the next Pay click gets a
// fresh key + fresh pending row. Keep it on 503/network (ambiguous)
// and on challenge-cancelled/sca-failed (no charge was attempted).
if (response.status === 402) {
depositIdempotencyKey = '';
depositKeyedAmount = 0;
depositKeyedCard = '';
}
}
/**
@@ -999,7 +1025,7 @@
// Initialize date boundaries
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new Date(today.year, today.month - 1, today.day);
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
@@ -1083,7 +1109,7 @@
const daysToCheck = Math.min(daysDifference, 180);
for (let i = 1; i <= daysToCheck; i++) {
const nextDate = new Date(currentDate);
const nextDate = new SvelteDate(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
@@ -1306,7 +1332,7 @@
// =============== Time Slot Generation ===============
function calculateEndTime(startTime: string, durationMinutes: number): string {
const [hours, minutes] = startTime.split(':').map(Number);
const date = new Date();
const date = new SvelteDate();
date.setHours(hours, minutes, 0, 0);
date.setMinutes(date.getMinutes() + durationMinutes);
const endHours = date.getHours().toString().padStart(2, '0');
@@ -2695,6 +2721,38 @@
onCancel={cancelOverflowConfirmation}
/>
{:else}
<!-- Out-of-band SCA challenge: the buyer approves in their
banking app while this panel shows. -->
{#if waitingForSCA}
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<div
class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
></div>
<div>
<p class="text-sm font-medium text-amber-900">
Approve this payment in your banking app on your phone…
</p>
<p class="mt-0.5 text-xs text-amber-700">
The payment is waiting for your approval. This may take a few moments.
</p>
</div>
</div>
</div>
{/if}
{#if depositError}
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm text-red-800">{depositError}</p>
<Button
variant="outline"
size="sm"
class="mt-3 w-full"
onclick={() => (depositError = null)}
>
Try Again
</Button>
</div>
{/if}
<BookingSummary
services={selectedServices}
date={selectedDate}
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { formatTime } from '$lib/utils/timeSlots';
import { SvelteDate } from 'svelte/reactivity';
type DefaultHours = {
weekday: number; // 0=Mon, 1=Tue, ..., 6=Sun
@@ -80,7 +81,7 @@
const today = getLondonDate();
const dayOfWeek = today.getDay(); // 0=Sun
const offset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = new Date(today);
const monday = new SvelteDate(today);
monday.setDate(today.getDate() + offset);
return fmtDate(monday);
}
@@ -115,7 +115,9 @@
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<p class="text-sm text-amber-800">
Two-factor authentication is required to use online card payments.
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
<a href={resolve('/account')} class="font-medium underline"
>Enable it in your account settings</a
>.
</p>
</div>
{/if}
@@ -127,7 +129,7 @@
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
card.id && !showNewCardForm
card.id && !showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
@@ -373,8 +373,10 @@
<div class="rounded-md border border-amber-300 bg-amber-50 p-3">
<p class="text-sm font-medium text-amber-800">Waiting for approval in the banking app…</p>
<p class="mt-1 text-xs text-amber-700">
Simulated 3DS/SCA challenge (mock mode). The backend mock applies the encoded outcome
({challengeResult === 'deny' ? 'deny' : 'approve'}).
Simulated 3DS/SCA challenge (mock mode). The backend mock applies the encoded outcome ({challengeResult ===
'deny'
? 'deny'
: 'approve'}).
</p>
<button
type="button"
@@ -51,22 +51,9 @@
</div>
</div>
<div class="mt-4 flex gap-2">
<Button
class="flex-1"
loading={loading}
disabled={loading}
autofocus
onclick={onConfirm}
>
<Button class="flex-1" {loading} disabled={loading} autofocus onclick={onConfirm}>
Confirm
</Button>
<Button
variant="outline"
class="flex-1"
disabled={loading}
onclick={onCancel}
>
Cancel
</Button>
<Button variant="outline" class="flex-1" disabled={loading} onclick={onCancel}>Cancel</Button>
</div>
</div>
@@ -9,13 +9,13 @@
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
import { apiFetch } from '$lib/utils/api';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
sanitizeDecimalInput,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE,
adminRequestNewTwoFactorCode,
@@ -491,6 +491,20 @@
onClose();
}
// True while a charge (or the out-of-band SCA challenge the customer must
// approve in their banking app) is in flight — ESC/overlay close must be
// blocked then, because the charge may still land.
function isChargeInFlight(status: PaymentStatus): boolean {
return (
status === 'card-processing' ||
status === 'card-polling' ||
status === 'cash-confirming' ||
status === 'gift-confirming' ||
status === 'saved-card-processing' ||
status === 'saved-card-waiting-sca'
);
}
// Called from the success state's Done button: notify the parent (so it can
// refresh the booking/payment data) and then close the modal. Kept separate
// from handleClose so a success state never closes without the callback.
@@ -852,7 +866,9 @@
await runSavedCardSCA(chargeAmount);
return;
}
const err = new Error(extractErrorMessage(errData) || 'Failed to process saved card payment');
const err = new Error(
extractErrorMessage(errData) || 'Failed to process saved card payment'
);
(err as { bodyText?: string }).bodyText = errData;
throw err;
}
@@ -879,16 +895,14 @@
onComplete(paymentResult);
} catch (_err) {
status = 'error';
// A definitive 402 on the saved-card path means the issuer still
// requires verification. A structured verification-required signal
// surfaces the SCA-first guidance; the legacy saved-card check is the
// fallback for generic 402s (the SCA flow above intercepts the
// structured ones, so this is the belt-and-braces path).
// A 402 carrying the structured verification-required signal (or the
// dev/mock text parity) surfaces the SCA-first guidance. A plain
// decline 402 shows the normal decline error — it must not be
// relabeled "requires verification".
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
if (scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, true)) {
msg = scaVerificationRequired ? VERIFICATION_REQUIRED_MESSAGE : SAVED_CARD_VERIFICATION_MESSAGE;
if (isVerificationRequiredSignal(responseStatus, bodyText)) {
msg = VERIFICATION_REQUIRED_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
@@ -1004,13 +1018,20 @@
status = 'error';
error =
result.outcome === 'sca-unavailable'
? `${VERIFICATION_REQUIRED_MESSAGE} In-app approval isn't available for this card — enter the verification code instead.`
: "Card verification was cancelled or didn't complete. Try again, or enter the verification code instead.";
? `${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`
: CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(error);
}
</script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Root
open={true}
onOpenChange={(open) => {
if (open) return;
if (isChargeInFlight(status)) return;
handleClose();
}}
>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
@@ -1250,7 +1271,7 @@
<button
type="button"
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
'savedcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1276,7 +1297,7 @@
<button
type="button"
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
'giftcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
@@ -1657,7 +1678,11 @@
<!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. -->
<TwoFactorCodeInput bind:code={twoFactor.code} showInput={twoFactor.showInput} enabled={true} />
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={true}
/>
{#if twoFactor.showInput}
<p class="mt-1 text-xs text-gray-500">
Enter the customer's verification code — not your own. The customer can request a fresh
@@ -1695,7 +1720,9 @@
<p class="text-lg font-medium text-gray-700">
Waiting for customer to approve in their banking app…
</p>
<p class="mt-2 text-sm text-gray-500">The customer may need to approve this payment in their banking app</p>
<p class="mt-2 text-sm text-gray-500">
The customer may need to approve this payment in their banking app
</p>
{:else}
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
{/if}
@@ -1,5 +1,18 @@
<script module lang="ts">
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
import {
getSquarePayments,
isSquareConfigured,
isSquareMock,
parseTokenizeVerificationResult,
type SquareTokenizeResult
} from '$lib/square/square';
/** Re-exported for the payment surfaces that import these from this
* component — the types now live with the shared parse logic in square.ts. */
export type SavedCardVerificationOutcome =
import('$lib/square/square').SavedCardVerificationOutcome;
export type SavedCardVerificationResult =
import('$lib/square/square').SavedCardVerificationResult;
/**
* Billing contact passed to Square's tokenize() verificationDetails for
@@ -18,21 +31,6 @@
verificationToken: string | null;
}
/** Outcome of a saved-card SCA challenge, used by the payment surfaces to
* decide whether to retry with the fresh verification token, surface a
* retryable failure, or fall back to the 2FA gate. */
export type SavedCardVerificationOutcome =
| 'verified'
| 'challenge-cancelled'
| 'sca-unavailable'
| 'sca-failed';
/** Result of tokenizeSavedCardWithVerification. */
export interface SavedCardVerificationResult {
verificationToken: string | null;
outcome: SavedCardVerificationOutcome;
}
/** Square Web Payments `card.tokenize()` verification details shape. */
interface SquareVerificationDetails {
amount: string;
@@ -43,14 +41,6 @@
sellerKeyedIn: boolean;
}
/** Square Web Payments `card.tokenize()` result shape (verification path). */
interface SquareTokenizeResult {
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}
/**
* Runs the SCA challenge for a SAVED card (ccof) whose charge Square refused
* with a "verification required" signal. Square's card-on-file flow binds
@@ -136,27 +126,13 @@
return { verificationToken: null, outcome: 'sca-unavailable' };
}
if (result.status === 'OK' && result.verificationResult?.token) {
return { verificationToken: result.verificationResult.token, outcome: 'verified' };
}
const codes = (result.errors ?? []).map((e) => e.code ?? '').filter(Boolean);
const errorText =
codes.join(' ') +
' ' +
(result.errors ?? [])
.map((e) => e.message ?? '')
.join(' ');
// VERIFICATION_CHALLENGE / cancel-coded errors mean the challenge was
// shown but not completed — the buyer can retry, so this is retryable.
if (result.status === 'VERIFICATION_CHALLENGE' || /cancel/i.test(errorText)) {
return { verificationToken: null, outcome: 'challenge-cancelled' };
}
// The card/issuer cannot complete buyer verification at all — SCA is not
// available for this charge, so the surface falls back to the 2FA gate.
if (codes.includes('CARD_DECLINED_VERIFICATION_REQUIRED')) {
return { verificationToken: null, outcome: 'sca-unavailable' };
}
return { verificationToken: null, outcome: 'sca-failed' };
// The shared parse maps the SDK result to the saved-card outcome:
// `status === 'OK'` → 'verified' (the SCA-verified token is `result.token`
// in the current SDK — never a nested verificationResult, which only
// exists on the deprecated verifyBuyer() flow), tokenless when the issuer
// demanded no challenge; VERIFICATION_CHALLENGE / cancel → retryable;
// CARD_DECLINED_VERIFICATION_REQUIRED → 2FA fallback.
return parseTokenizeVerificationResult(result);
}
/**
@@ -239,12 +215,7 @@
attach: (selector: string) => Promise<void>;
tokenize: (
verificationDetails?: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
) => Promise<SquareTokenizeResult>;
destroy: () => void;
}>;
};
@@ -289,14 +260,7 @@
return mockForm.tokenize();
}
const card = cardInstance as {
tokenize: (
verificationDetails?: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
tokenize: (verificationDetails?: SquareVerificationDetails) => Promise<SquareTokenizeResult>;
} | null;
if (!card) {
throw new Error('Card form is not ready — please wait a moment and try again');
@@ -350,14 +314,7 @@
return mockForm.tokenizeWithVerification(amount, contact, saveCard);
}
const card = cardInstance as {
tokenize: (
verificationDetails: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
tokenize: (verificationDetails: SquareVerificationDetails) => Promise<SquareTokenizeResult>;
} | null;
if (!card) {
throw new Error('Card form is not ready — please wait a moment and try again');
@@ -384,13 +341,10 @@
// In the current tokenize-with-verification flow the returned nonce
// (result.token) is ALREADY the 3DS-verified token — Square binds the
// SCA challenge to this exact amount, so charging it as
// `new_card_token`/`card_token` is sufficient. `verificationResult`
// only exists on the deprecated verifyBuyer() flow; we still read it
// defensively since the backend accepts an explicit verification_token.
return {
nonce: result.token,
verificationToken: result.verificationResult?.token ?? null
};
// `new_card_token`/`card_token` is sufficient and no separate
// verification_token exists (that nested shape only came from the
// deprecated verifyBuyer() flow).
return { nonce: result.token, verificationToken: null };
}
const detail =
result.errors
@@ -14,14 +14,13 @@
import { onMount } from 'svelte';
import { generateUUID } from '$lib/utils/uuid';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
isNonceStale,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
sanitizeDecimalInput,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
@@ -125,6 +124,13 @@
// from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge is in flight (the buyer
// approves in their banking app) — drives the "approve in banking app" panel.
let waitingForSCA = $state(false);
// The retryable failure message shown in the error panel (challenge
// cancelled/failed, decline) — surfaced so the panel text matches the
// specific outcome instead of the generic "Payment failed".
let tipError = $state<string | null>(null);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
@@ -277,6 +283,9 @@
tipTokenAmount = tipAmount;
tipTokenizedAt = Date.now();
tipTokenizedForSaveCard = saveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it.
lastSCAOutcome = 'verified';
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
@@ -291,7 +300,6 @@
paymentState = 'processing';
const usedSavedCard = !!selectedCardId;
let responseStatus = 0;
try {
@@ -299,32 +307,36 @@
// rationale as the booking/account flows). Include the card so a
// same-amount tip on a DIFFERENT card gets a fresh key instead of
// deduping against the previous card's charge.
const cardKey = selectedCardId || 'new-card';
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
tipIdempotencyKey = generateUUID();
tipKeyedAmount = tipAmount;
tipKeyedCard = cardKey;
}
const amountInPence = Math.round(tipAmount * 100);
// Proactive saved-card (ccof) SCA: run the client-side challenge
// BEFORE the first tip charge attempt so it carries a fresh
// verification_token — a naked ccof is never sent. Only
// 'sca-unavailable' proceeds token-less (the 2FA gate is the
// fallback); a cancelled/failed challenge does NOT charge — the user
// taps Pay Tip again to re-run it, and the cached idempotency key
// above is never regenerated across the challenge-then-charge.
if (selectedCardId && !verificationToken) {
const proactive = await runTipSCAProactively(amountInPence, selectedCardId);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
paymentState = 'error';
toast.error(
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead."
);
return;
const cardKey = selectedCardId || 'new-card';
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
tipIdempotencyKey = generateUUID();
tipKeyedAmount = tipAmount;
tipKeyedCard = cardKey;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
}
const body: Record<string, unknown> = {
const amountInPence = Math.round(tipAmount * 100);
// Proactive saved-card (ccof) SCA: run the client-side challenge
// BEFORE the first tip charge attempt so it carries a fresh
// verification_token — a naked ccof is never sent. Only
// 'sca-unavailable' proceeds token-less (the 2FA gate is the
// fallback); a cancelled/failed challenge does NOT charge — the user
// taps Pay Tip again to re-run it, and the cached idempotency key
// above is never regenerated across the challenge-then-charge.
if (selectedCardId && !verificationToken) {
waitingForSCA = true;
try {
const proactive = await runTipSCAProactively(amountInPence, selectedCardId);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
paymentState = 'error';
tipError = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(tipError);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
waitingForSCA = false;
}
}
const body: Record<string, unknown> = {
amount: amountInPence,
idempotency_key: tipIdempotencyKey,
...(selectedCardId ? { card_id: selectedCardId } : {}),
@@ -372,19 +384,14 @@
} catch (err) {
paymentState = 'error';
let errorMessage = err instanceof Error ? err.message : 'Payment failed';
// A definitive 402 on the saved-card path means the issuer still
// requires verification. A structured verification-required signal
// surfaces the SCA-first guidance; the legacy saved-card check is
// the fallback for generic 402s.
// A 402 carrying the structured verification-required signal (or
// the dev/mock text parity) surfaces the SCA-first guidance. A
// plain decline 402 shows the normal decline error — it must not
// be relabeled "requires verification".
const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
if (
scaVerificationRequired ||
isSavedCardVerificationRequired(responseStatus, usedSavedCard)
) {
errorMessage = scaVerificationRequired
? VERIFICATION_REQUIRED_MESSAGE
: SAVED_CARD_VERIFICATION_MESSAGE;
const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
if (verificationFailure) {
errorMessage = VERIFICATION_REQUIRED_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
@@ -392,16 +399,28 @@
if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) {
twoFactor.reveal = true;
}
tipError = errorMessage;
toast.error(errorMessage);
// A definitive charge failure (e.g. declined card) consumes the nonce
// and SCA verification token — they can never succeed again. Clear the
// cached pair so the next retry re-tokenizes fresh. The idempotency
// key is kept: it's still correct for network-timeout dedup.
// cached pair so the next retry re-tokenizes fresh.
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
// A DEFINITIVE 402 (declined card / stale token) means the tip
// charge did NOT land — a retry that re-runs SCA and mints a fresh
// token would otherwise dead-loop on IDEMPOTENCY_KEY_REUSED under
// the same key. Regenerate the key on 402 so the next Pay Tip click
// gets a fresh key + fresh pending row. Keep it on 503/network
// (ambiguous) and on challenge-cancelled/sca-failed (no charge was
// attempted).
if (responseStatus === 402) {
tipIdempotencyKey = '';
tipKeyedAmount = 0;
tipKeyedCard = '';
}
}
} finally {
isSubmittingTipSync = false;
@@ -447,6 +466,7 @@
function retryPayment() {
paymentState = 'idle';
tipError = null;
}
</script>
@@ -609,11 +629,29 @@
{#if paymentState === 'error'}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-red-700">Payment failed. Please try again.</p>
<p class="text-red-700">{tipError ?? 'Payment failed. Please try again.'}</p>
<Button variant="outline" class="mt-3 w-full" onclick={retryPayment}>Try Again</Button>
</div>
{/if}
{#if waitingForSCA}
<div class="mb-4 rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<div
class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
></div>
<div>
<p class="text-sm font-medium text-amber-900">
Approve this payment in your banking app on your phone…
</p>
<p class="mt-0.5 text-xs text-amber-700">
The payment is waiting for your approval. This may take a few moments.
</p>
</div>
</div>
</div>
{/if}
<Button
class="w-full"
size="lg"
@@ -57,7 +57,9 @@
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<p class="text-sm text-amber-800">
Two-factor authentication is required to use online card payments.
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
<a href={resolve('/account')} class="font-medium underline"
>Enable it in your account settings</a
>.
</p>
</div>
{/if}
@@ -17,16 +17,15 @@
import { apiFetch } from '$lib/utils/api';
import { generateUUID } from '$lib/utils/uuid';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
depositChargePence,
isNonceStale,
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
sanitizeDecimalInput,
shouldFallbackTo2FA,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
@@ -70,6 +69,11 @@
// from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state('');
// True while the proactive saved-card SCA challenge (card.tokenize with
// verificationDetails) is in flight — the challenge is out-of-band (the
// buyer approves in their banking app), so the form shows a waiting panel
// and blocks modal close until it resolves.
let waitingForSCA = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
@@ -443,6 +447,10 @@
newCardTokenAmount = amountPence;
newCardTokenizedAt = Date.now();
newCardTokenizedForSaveCard = saveCard;
// This attempt carries SCA verification — a prior
// 'sca-unavailable' demotion must not leak onto it (the 2FA
// input only surfaces while SCA genuinely cannot authorise).
lastSCAOutcome = 'verified';
} catch (_err) {
status = 'error';
const msg = _err instanceof Error ? _err.message : 'Card entry failed';
@@ -486,17 +494,22 @@
// proceeds token-less (the 2FA gate is the fallback); a cancelled/failed
// challenge does NOT charge — the user taps Pay again to re-run it, and
// the cached idempotency key above is never regenerated across the
// challenge-then-charge.
// challenge-then-charge. waitingForSCA drives the "approve in your
// banking app" panel and blocks modal close while the challenge is open.
if (cardId && !verificationToken) {
const proactive = await runSavedCardSCAProactively(amountPence, cardId);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
status = 'error';
error =
"Card verification was cancelled or didn't complete. Try again, or enter the verification code instead.";
toast.error(error);
return;
waitingForSCA = true;
try {
const proactive = await runSavedCardSCAProactively(amountPence, cardId);
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
status = 'error';
error = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(error);
return;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
} finally {
waitingForSCA = false;
}
if (proactive.verificationToken) verificationToken = proactive.verificationToken;
}
await submitBookingPayment(
@@ -624,14 +637,13 @@
overflowConfirm = null;
let msg = _err instanceof Error ? _err.message : 'Payment declined';
// A 402 with the structured verification-required signal (or the
// dev/mock text parity) surfaces the SCA-first guidance; the legacy
// saved-card verification check is the fallback for generic 402s.
// dev/mock text parity) surfaces the SCA-first guidance. A plain
// decline 402 shows the normal decline error — it must not be
// relabeled "requires verification".
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
const scaVerificationRequired = isVerificationRequiredSignal(responseStatus, bodyText);
const verificationFailure =
scaVerificationRequired || isSavedCardVerificationRequired(responseStatus, !!cardId);
const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
if (verificationFailure) {
msg = scaVerificationRequired ? VERIFICATION_REQUIRED_MESSAGE : SAVED_CARD_VERIFICATION_MESSAGE;
msg = VERIFICATION_REQUIRED_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
@@ -649,6 +661,19 @@
newCardTokenAmount = 0;
newCardTokenizedAt = 0;
newCardTokenizedForSaveCard = false;
// A DEFINITIVE 402 (declined card / stale token) means the charge
// did NOT land — Square's idempotency key would otherwise reject a
// retry that re-runs SCA and mints a fresh token (the key's purpose
// is dedup on AMBIGUOUS 503 retries, where the charge may have
// landed). Regenerate the key on 402 so the next Pay click gets a
// fresh key + fresh pending row. Keep it on 503/network (ambiguous)
// and on challenge-cancelled/sca-failed (no charge was attempted).
if (responseStatus === 402) {
payIdempotencyKey = '';
payKeyedAmount = 0;
payKeyedType = '';
payKeyedCard = '';
}
releaseLock();
}
}
@@ -792,6 +817,11 @@
open={true}
onOpenChange={(open) => {
if (open) return;
// ESC/overlay while a payment is in flight (the charge, or the
// out-of-band SCA challenge the buyer must approve in their banking
// app) must NOT close the modal — the charge may still land, and
// closing mid-challenge strands it. Block close until it resolves.
if (status === 'processing' || waitingForSCA) return;
// ESC while the overflow-confirm prompt is showing must dismiss the
// prompt (back to the amount-editing form) instead of closing the whole
// modal — the payment was rejected by the guard and the user needs to
@@ -882,6 +912,25 @@
<span>Slot no longer secured — please close and retry</span>
</div>
{/if}
<!-- Out-of-band SCA challenge: the buyer approves in their banking
app while this panel shows. -->
{#if waitingForSCA}
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<div
class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
></div>
<div>
<p class="text-sm font-medium text-amber-900">
Approve this payment in your banking app on your phone…
</p>
<p class="mt-0.5 text-xs text-amber-700">
The payment is waiting for your approval. This may take a few moments.
</p>
</div>
</div>
</div>
{/if}
<!-- Service Breakdown -->
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
@@ -145,14 +145,7 @@
if (closingTime) {
const [ch, cm] = closingTime.split(':').map(Number);
const today = new Date();
const closing = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
ch,
cm,
0
);
const closing = new Date(today.getFullYear(), today.getMonth(), today.getDate(), ch, cm, 0);
const minutesToClosing = Math.max(
0,
Math.floor((closing.getTime() - endTime.getTime()) / 60000)
@@ -187,8 +187,7 @@
const data = await response.json();
const newApprovals = (data.approvals || []).sort(
(a: PendingApproval, b: PendingApproval) =>
parseWallClockDate(a.created_at).getTime() -
parseWallClockDate(b.created_at).getTime()
parseWallClockDate(a.created_at).getTime() - parseWallClockDate(b.created_at).getTime()
);
const newJson = JSON.stringify(newApprovals);
if (newJson !== prevApprovalsJson) {
@@ -1,6 +1,7 @@
<script lang="ts">
import { apiFetch } from '$lib/utils/api';
import { CalendarDate } from '@internationalized/date';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
import { formatUserName } from '$lib/utils/nameDisplay';
@@ -117,7 +118,7 @@
const weekStartStr = $derived.by(() => {
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const d = new Date(londonDateStr + 'T00:00:00Z');
const d = new SvelteDate(londonDateStr + 'T00:00:00Z');
const day = d.getDay();
const diff = day === 0 ? 6 : day - 1;
d.setDate(d.getDate() - diff);
@@ -125,7 +126,7 @@
});
const weekEndStr = $derived.by(() => {
const d = new Date(weekStartStr + 'T00:00:00Z');
const d = new SvelteDate(weekStartStr + 'T00:00:00Z');
d.setDate(d.getDate() + 6);
return formatDateISO(d);
});
@@ -4,6 +4,7 @@
import * as Card from '$lib/components/ui/card';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
import { range } from '$lib/utils/format';
import { SvelteDate } from 'svelte/reactivity';
type TodayAppointment = {
id: string;
@@ -54,7 +55,7 @@
let spansClosed = false;
for (let i = 1; i <= 14; i++) {
const d = new Date(now);
const d = new SvelteDate(now);
d.setDate(d.getDate() - i);
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -71,7 +72,7 @@
if (day?.isOpen) {
const closeTime = day.endTime;
const [h, m] = closeTime.split(':').map(Number);
const closeDate = new Date(d);
const closeDate = new SvelteDate(d);
closeDate.setHours(h, m, 0, 0);
return { cutoff: formatLocalDateTime(closeDate), spansClosed };
} else {
@@ -80,7 +81,7 @@
}
}
const fallback = new Date(now);
const fallback = new SvelteDate(now);
fallback.setDate(fallback.getDate() - 1);
fallback.setHours(17, 0, 0, 0);
return { cutoff: formatLocalDateTime(fallback), spansClosed };
@@ -108,7 +108,7 @@
// media-query variant is a different twMerge group than the plain
// utility, so it survives consumer `max-h-*` overrides that would
// otherwise wipe the keyboard-safe mobile height.
'fixed bottom-0 left-0 right-0 grid w-full max-w-none translate-x-0 translate-y-0 gap-4 rounded-t-xl border border-b-0 bg-background p-6 pb-[max(1rem,env(safe-area-inset-bottom))] shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 max-h-[90vh] max-sm:max-h-[calc(100dvh-4rem)] overflow-y-auto sm:top-[50%] sm:left-[50%] sm:right-auto sm:bottom-auto sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg sm:border-b sm:pb-6',
'fixed right-0 bottom-0 left-0 grid max-h-[90vh] w-full max-w-none translate-x-0 translate-y-0 gap-4 overflow-y-auto rounded-t-xl border border-b-0 bg-background p-6 pb-[max(1rem,env(safe-area-inset-bottom))] shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-[130ms] data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 max-sm:max-h-[calc(100dvh-4rem)] sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg sm:border-b sm:pb-6',
stripZIndexClasses(className)
)}
{...restProps}
@@ -32,11 +32,6 @@ export function nextZIndex(): number {
return top;
}
/** The current top of the stack (the last claimed z-index). */
export function currentZIndex(): number {
return top;
}
/**
* Reset the stack to its base. Called once from the root layout on mount so
* hot reloads / repeated test runs don't let the counter climb forever.
@@ -26,7 +26,7 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
'flex min-h-11 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 md:h-9 md:min-h-9',
'flex min-h-11 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:h-9 md:min-h-9 dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
@@ -51,7 +51,7 @@
class="absolute top-full left-0 z-50 mt-1 w-48 rounded-md border border-gray-200 bg-white p-2 shadow-lg"
>
<a
href={href}
{href}
target="_blank"
rel="noopener noreferrer external"
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
+93 -18
View File
@@ -14,6 +14,7 @@ import {
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
parseTokenizeVerificationResult,
requestNewTwoFactorCode,
sanitizeDecimalInput,
shouldFallbackTo2FA,
@@ -240,20 +241,26 @@ describe('isVerificationRequiredSignal', () => {
it('matches the raw CARD_DECLINED_VERIFICATION_REQUIRED text (dev/mock parity)', () => {
expect(
isVerificationRequiredSignal(402, 'CARD_DECLINED_VERIFICATION_REQUIRED: card requires verification')
isVerificationRequiredSignal(
402,
'CARD_DECLINED_VERIFICATION_REQUIRED: card requires verification'
)
).toBe(true);
});
it('matches the plain "verification required" phrasing', () => {
expect(isVerificationRequiredSignal(402, 'Payment failed: verification required by your card issuer')).toBe(
true
);
expect(
isVerificationRequiredSignal(402, 'Payment failed: verification required by your card issuer')
).toBe(true);
});
it('is false for a 402 body with a different code', () => {
expect(isVerificationRequiredSignal(402, JSON.stringify({ error: 'Declined', code: 'card_declined' }))).toBe(
false
);
expect(
isVerificationRequiredSignal(
402,
JSON.stringify({ error: 'Declined', code: 'card_declined' })
)
).toBe(false);
});
it('is false for a 402 body with only error text and no code', () => {
@@ -269,20 +276,32 @@ describe('isVerificationRequiredSignal', () => {
});
it('is false for any non-402 status even with the code present', () => {
expect(isVerificationRequiredSignal(503, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
false
);
expect(isVerificationRequiredSignal(400, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
false
);
expect(isVerificationRequiredSignal(200, JSON.stringify({ error: 'x', code: 'verification_required' }))).toBe(
false
);
expect(
isVerificationRequiredSignal(
503,
JSON.stringify({ error: 'x', code: 'verification_required' })
)
).toBe(false);
expect(
isVerificationRequiredSignal(
400,
JSON.stringify({ error: 'x', code: 'verification_required' })
)
).toBe(false);
expect(
isVerificationRequiredSignal(
200,
JSON.stringify({ error: 'x', code: 'verification_required' })
)
).toBe(false);
});
it('is false when the code is not an exact match (guards against prefix drift)', () => {
expect(
isVerificationRequiredSignal(402, JSON.stringify({ error: 'x', code: 'verification_required_extra' }))
isVerificationRequiredSignal(
402,
JSON.stringify({ error: 'x', code: 'verification_required_extra' })
)
).toBe(false);
});
});
@@ -311,6 +330,58 @@ describe('shouldFallbackTo2FA', () => {
});
});
describe('parseTokenizeVerificationResult', () => {
it('maps a status OK result with a token to a verified outcome carrying the SAME token', () => {
expect(
parseTokenizeVerificationResult({ status: 'OK', token: 'ccof:sca-verified-token' })
).toEqual({
verificationToken: 'ccof:sca-verified-token',
outcome: 'verified'
});
});
it('maps a status OK result with NO token to a verified, tokenless outcome (no SCA required)', () => {
expect(parseTokenizeVerificationResult({ status: 'OK' })).toEqual({
verificationToken: null,
outcome: 'verified'
});
});
it('maps a VERIFICATION_CHALLENGE status to a retryable challenge-cancelled outcome', () => {
expect(parseTokenizeVerificationResult({ status: 'VERIFICATION_CHALLENGE' })).toEqual({
verificationToken: null,
outcome: 'challenge-cancelled'
});
});
it('maps a cancel-coded error to a retryable challenge-cancelled outcome', () => {
expect(
parseTokenizeVerificationResult({
status: 'FAILED',
errors: [{ code: 'CANCEL', message: 'challenge cancelled by buyer' }]
})
).toEqual({ verificationToken: null, outcome: 'challenge-cancelled' });
});
it('maps CARD_DECLINED_VERIFICATION_REQUIRED to sca-unavailable (2FA fallback)', () => {
expect(
parseTokenizeVerificationResult({
status: 'FAILED',
errors: [{ code: 'CARD_DECLINED_VERIFICATION_REQUIRED' }]
})
).toEqual({ verificationToken: null, outcome: 'sca-unavailable' });
});
it('maps any other failure to sca-failed', () => {
expect(
parseTokenizeVerificationResult({
status: 'FAILED',
errors: [{ code: 'CARD_DECLINED', message: 'card declined' }]
})
).toEqual({ verificationToken: null, outcome: 'sca-failed' });
});
});
describe('isTwoFactorVerificationGateFailure', () => {
it.each([
[403, 'A two-factor verification code is required to use this saved card', true],
@@ -545,7 +616,11 @@ describe('adminRequestNewTwoFactorCode', () => {
it('surfaces the 429 mint-cooldown error message', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(jsonResponse({ error: 'Too many requests. Wait before requesting.' }, 429))
vi
.fn()
.mockResolvedValue(
jsonResponse({ error: 'Too many requests. Wait before requesting.' }, 429)
)
);
const result = await adminRequestNewTwoFactorCode('usr_abc');
expect(result.ok).toBe(false);
+73 -1
View File
@@ -180,12 +180,82 @@ export function shouldFallbackTo2FA(scaOutcome: string): boolean {
return scaOutcome === 'sca-unavailable';
}
/** Outcome of a saved-card SCA challenge, used by the payment surfaces to
* decide whether to retry with the fresh verification token, surface a
* retryable failure, or fall back to the 2FA gate. */
export type SavedCardVerificationOutcome =
'verified' | 'challenge-cancelled' | 'sca-unavailable' | 'sca-failed';
/** Result of tokenizeSavedCardWithVerification. */
export interface SavedCardVerificationResult {
verificationToken: string | null;
outcome: SavedCardVerificationOutcome;
}
/** Square Web Payments `card.tokenize()` result shape. Per the CURRENT SDK
* (Square.js /v1/), tokenize returns `{ status, token, details?, errors }`
* the SCA-verified token for both the new-card and the card-on-file
* (`card.tokenize(verificationDetails, cardId)`) flows comes back in the SAME
* `token` field. There is NO nested `verificationResult` that only exists on
* the deprecated `payments.verifyBuyer()` flow, which Square is retiring (see
* developer.squareup.com/docs/web-payments/take-card-payment "Migrate from
* Payments.verifyBuyer()"). */
export interface SquareTokenizeResult {
status: string;
token?: string;
errors?: Array<{ message?: string; code?: string }>;
}
/**
* Maps a Square `card.tokenize()` result to the saved-card SCA outcome.
*
* - `status === 'OK'` means buyer verification either completed or was NOT
* required by the issuer the charge may proceed. The verification-aware
* token (when present) is the `token` field; a tokenless OK means no SCA was
* demanded, so the charge proceeds token-less (the backend 2FA gate / Square
* risk rules are the fallback), never a dead-end.
* - `VERIFICATION_CHALLENGE` / cancel-coded errors mean the challenge was
* shown but not completed the buyer can retry, so this is retryable.
* - `CARD_DECLINED_VERIFICATION_REQUIRED` means no challenge could run SCA
* is unavailable and the surface falls back to the 2FA gate.
* - anything else is a hard SCA failure.
*/
export function parseTokenizeVerificationResult(
result: SquareTokenizeResult
): SavedCardVerificationResult {
if (result.status === 'OK') {
return { verificationToken: result.token ?? null, outcome: 'verified' };
}
const codes = (result.errors ?? []).map((e) => e.code ?? '').filter(Boolean);
const errorText =
codes.join(' ') + ' ' + (result.errors ?? []).map((e) => e.message ?? '').join(' ');
if (result.status === 'VERIFICATION_CHALLENGE' || /cancel/i.test(errorText)) {
return { verificationToken: null, outcome: 'challenge-cancelled' };
}
if (codes.includes('CARD_DECLINED_VERIFICATION_REQUIRED')) {
return { verificationToken: null, outcome: 'sca-unavailable' };
}
return { verificationToken: null, outcome: 'sca-failed' };
}
/** User-facing guidance for a saved-card charge whose issuer requires Strong
* Customer Authentication: the buyer must approve the payment in their banking
* app (the client-side tokenizeSavedCardWithVerification challenge does this). */
export const VERIFICATION_REQUIRED_MESSAGE =
'Your card issuer requires verification. Approve this payment in your banking app.';
/** User-facing message for a saved-card SCA challenge that was cancelled or did
* not complete. Retryable via SCA deliberately does NOT promise the 2FA code
* input, which the customer surfaces only surface on 'sca-unavailable'. */
export const CARD_VERIFICATION_RETRY_MESSAGE =
"Card verification was cancelled or didn't complete. Please try again.";
/** User-facing guidance appended to VERIFICATION_REQUIRED_MESSAGE when the
* issuer's SCA challenge genuinely cannot run the 2FA code input is the
* only available authorisation and is surfaced as the fallback gate. */
export const SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE =
"In-app approval isn't available for this card — enter the verification code instead.";
/** User-facing guidance for a saved-card charge the issuer requires
* verification to complete. Retrying the same saved card is pointless the
* buyer must pay with a new card or re-add their card. */
@@ -264,7 +334,9 @@ export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestRes
* the CUSTOMER's userID, so the code is delivered to the customer and can
* satisfy the card-owner gate the admin's session never receives or
* authenticates the customer's card. */
export async function adminRequestNewTwoFactorCode(userID: string): Promise<TwoFactorCodeRequestResult> {
export async function adminRequestNewTwoFactorCode(
userID: string
): Promise<TwoFactorCodeRequestResult> {
return requestTwoFactorCode(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`);
}
+2 -1
View File
@@ -95,7 +95,8 @@ export interface Payment {
id: string;
booking_id: string;
payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial';
payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount' | 'on_the_house';
payment_method:
'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount' | 'on_the_house';
vendor_code?: string;
invoice_number?: number;
status: 'pending' | 'completed' | 'failed' | 'refunded';
+3 -4
View File
@@ -95,10 +95,9 @@ export function timeToMinutes(time: string): number {
}
export function getDayWithOrdinal(date: CalendarDate): string {
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString(
'en-GB',
{ month: 'long' }
);
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString('en-GB', {
month: 'long'
});
const day = date.day;
if (day > 3 && day < 21) return monthName + ' ' + day + 'th';
switch (day % 10) {