fix: round-2 loop-A fresh review (503c326 baseline) — B1 replay cap, A6 discount record, 2FA reissue+cooldown, notification flood, lockout saturation, VAT/refund-status consolidation

Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed:

MONEY:
- CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) —
  a rejected auto-refund no longer re-replays the expired key every sweep run
  (which minted a stacking unauthorized charge each time); FAILED-webhook
  demotion respects the cap; never re-replay a key whose B1 refund failed
- HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible
  campaign discount rows immediately (capped) instead of skipping with no
  discount recorded — no more promised-discount-not-recorded overcharge
- MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed
  new-card+save_card charges (re-issue guard now covers req.SaveCard)
- LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining
  now matches the authoritative tip-excluded balance)

SECURITY:
- MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap
  on critical_payment_log + refresh_token_reuse rows)
- MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer
  clears LastMintAt on gate-verify; cleared on terminal charge success)
- MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked
  state instead of a fresh 5-guess budget per request
- MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs
  instead of sleeping unboundedly; login bcrypt concurrency semaphore added
- LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check;
  email-verification per-user attempt counter

DUP/MOD:
- formatCurrency single source (frontend format.ts, 7 files consolidated);
  SquareRefundStatusToLocal single source (errors.go, all sites); admin
  audit-log helper dedup; SCA retry model unified (proactive on all 6
  surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from
  backend; generateUUID at all card-form sites; magic numbers named
  (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card
  terminal charges now audited; DAV_SKIP_INIT documented in manuals

Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet
tags, frontend tests+build, env-docs 42/42.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 4e64e32f09
commit 3866cc5963
36 changed files with 2032 additions and 719 deletions
@@ -12,7 +12,7 @@
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import { isSquareConfigured, submitPaymentWithRetry } from '$lib/square/square';
import { range } from '$lib/utils/format';
import { formatCurrency, range } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { SvelteURLSearchParams } from 'svelte/reactivity';
@@ -775,10 +775,6 @@
return `${id.slice(0, 4)}-${id.slice(4, 8)}-${id.slice(8, 12)}`.toUpperCase();
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
}
function formatDate(dateStr: string): string {
return parseWallClockDate(dateStr).toLocaleDateString('en-GB', {
day: 'numeric',
@@ -3,6 +3,7 @@
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import { generateUUID } from '$lib/utils/uuid';
import { formatCurrency } from '$lib/utils/format';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
@@ -14,6 +15,7 @@
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
runSavedCardSCAProactively,
shouldFallbackTo2FA,
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
submitPaymentWithRetry,
@@ -22,10 +24,6 @@
PAYMENT_METHOD_SAVED_CARD,
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';
@@ -282,10 +280,6 @@
!isNaN(parsedGiftCardAmount) && parsedGiftCardAmount > GIFT_CARD_MAX_AMOUNT
);
function formatCurrency(n: number): string {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n);
}
function addItem(label: string, price: number) {
const existing = cart.find((i) => i.label === label);
if (existing) {
@@ -27,6 +27,7 @@
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { formatCurrency } from '$lib/utils/format';
import { onDestroy } from 'svelte';
// Components
@@ -49,15 +50,11 @@
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
runSavedCardSCAProactively,
shouldFallbackTo2FA,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationOutcome,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
@@ -276,13 +273,6 @@
discounted_total: number;
} | null>(null);
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(pence / 100);
}
// =============== Payment Functions ===============
async function fetchUserDepositsRequired() {
if (!authStore.isAuthenticated) {
@@ -462,7 +452,19 @@
if (selectedPaymentMethod && !verificationToken) {
waitingForSCA = true;
try {
const proactive = await runDepositSCAProactively(amountPence);
const squareCardId = paymentMethods.find(
(c) => c.id === selectedPaymentMethod
)?.square_card_id;
const proactive = await runSavedCardSCAProactively({
amountPence,
squareCardId: squareCardId ?? '',
buyer: {
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
email: customerInfo.email || authStore.currentUser?.email
},
onOutcome: (o) => (lastSCAOutcome = o)
});
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
depositError = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(depositError);
@@ -686,42 +688,6 @@
}
}
/**
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first deposit
* charge attempt (never after a 402). Square's tokenize(verificationDetails,
* squareCardId) determines UP FRONT whether buyer verification is required
* and returns a fresh verification_token bound to the exact amount:
* - 'verified' → the caller charges with the returned token (the first
* attempt carries it — a naked ccof is never sent);
* - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup
* to the only available gate and the caller proceeds WITHOUT a token;
* - 'challenge-cancelled' / 'sca-failed' → the caller must NOT charge: the
* deposit step stays retryable and the user taps Pay again to re-run the
* challenge.
*/
async function runDepositSCAProactively(
amountPence: number
): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> {
const squareCardId = paymentMethods.find((c) => c.id === selectedPaymentMethod)?.square_card_id;
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
email: customerInfo.email || authStore.currentUser?.email
});
} catch (_err) {
lastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
}
lastSCAOutcome = result.outcome;
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
}
let paymentAttempted = $state(false);
// Pre-start overpayment confirmation (mirrors UserPaymentModal). The backend
@@ -2592,12 +2558,12 @@
{#each discountPreview.discounts as d (d.name)}
<div class="flex justify-between text-sm text-gray-600">
<span>{d.name}</span>
<span>-£{d.amount.toFixed(2)}</span>
<span>-{formatCurrency(d.amount)}</span>
</div>
{/each}
<div class="flex justify-between font-semibold text-emerald-700">
<span>Estimated Total After Discount</span>
<span>£{discountPreview.discounted_total.toFixed(2)}</span>
<span>{formatCurrency(discountPreview.discounted_total)}</span>
</div>
{:else}
<div class="flex justify-between font-semibold">
@@ -2641,7 +2607,7 @@
</div>
<h3 class="text-lg font-semibold text-emerald-800">Deposit Paid</h3>
<p class="mt-1 text-emerald-700">
Your deposit of <strong>£{calculateDepositAmount().toFixed(2)}</strong> has been
Your deposit of <strong>{formatCurrency(calculateDepositAmount())}</strong> has been
paid successfully. See you at your appointment!
</p>
</div>
@@ -2650,7 +2616,7 @@
<h3 class="mb-2 text-lg font-semibold text-amber-800">Deposit Not Paid</h3>
<p class="mb-4 text-amber-700">
Your booking is confirmed but the deposit of <strong
>£{calculateDepositAmount().toFixed(2)}</strong
>{formatCurrency(calculateDepositAmount())}</strong
>
was not paid. If the deposit remains unpaid within 24 hours of your appointment,
the slot may be released and the booking could be cancelled or rebooked by someone
@@ -2718,8 +2684,8 @@
discountNote={overflowConfirm.chargePence !== undefined &&
overflowConfirm.chargePence < overflowConfirm.amountPence
? `An eligible campaign discount of ${formatCurrency(
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
)} applies — you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence) / 100
)} applies — you'll be charged ${formatCurrency(overflowConfirm.chargePence / 100)}.`
: undefined}
loading={isProcessingPayment}
onConfirm={confirmOverflowPayment}
@@ -2845,7 +2811,7 @@
depositChargePence(
Math.round(calculateDepositAmount() * 100),
campaignDiscountPence(discountPreview)
)
) / 100
)}`}
</Button>
</div>
@@ -6,6 +6,7 @@
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { isSquareConfigured } from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import { generateUUID } from '$lib/utils/uuid';
import { resolve } from '$app/paths';
export interface SelectableCard {
@@ -43,7 +44,7 @@
// Unique per instance: a plain counter would be instance-scoped in Svelte 5
// (every instance restarting at 0), so two mounted CardSelection instances
// would collide on the same checkbox id. Pure SPA, so no SSR concern.
const consentId = `save-card-consent-${crypto.randomUUID()}`;
const consentId = `save-card-consent-${generateUUID()}`;
// B6/B10: saved-card charges require the customer's current 2FA verification
// code. This no longer BLOCKS saved-card selection — the code is collected
@@ -30,6 +30,7 @@
<script lang="ts">
import CardBrandIcon from './CardBrandIcon.svelte';
import { generateUUID } from '$lib/utils/uuid';
// Deterministic token mapping the backend dev mock (square_dev.go
// detectCardInfo) resolves back to the brand/last4 the user typed.
@@ -88,10 +89,10 @@
// Per-instance ids so two mounted forms never collide on the same id. Pure
// SPA (no SSR/hydration), so a random id cannot mismatch.
const cardNumberId = `mock-card-number-${crypto.randomUUID()}`;
const expiryId = `mock-card-exp-${crypto.randomUUID()}`;
const cvcId = `mock-card-cvc-${crypto.randomUUID()}`;
const nameId = `mock-card-name-${crypto.randomUUID()}`;
const cardNumberId = `mock-card-number-${generateUUID()}`;
const expiryId = `mock-card-exp-${generateUUID()}`;
const cvcId = `mock-card-cvc-${generateUUID()}`;
const nameId = `mock-card-name-${generateUUID()}`;
const inputClasses =
'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base 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 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm';
@@ -1,5 +1,6 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { formatCurrency } from '$lib/utils/format';
interface Props {
overflowPence: number;
@@ -12,13 +13,6 @@
}
const { overflowPence, onConfirm, onCancel, loading = false, discountNote }: Props = $props();
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(pence / 100);
}
</script>
<!-- Overpayment confirmation: the backend rejected the payment because the
@@ -43,7 +37,7 @@
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
<p class="mt-1 text-sm text-amber-800">
The balance for this booking has changed since it was last loaded. The extra
{formatCurrency(overflowPence)} will be recorded as a tip. Confirm to continue?
{formatCurrency(overflowPence / 100)} will be recorded as a tip. Confirm to continue?
</p>
{#if discountNote}
<p class="mt-2 text-sm font-medium text-amber-800">{discountNote}</p>
@@ -8,12 +8,14 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
import { apiFetch } from '$lib/utils/api';
import { formatCurrency } from '$lib/utils/format';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
PAYMENT_METHOD_SAVED_CARD,
runSavedCardSCAProactively,
sanitizeDecimalInput,
shouldFallbackTo2FA,
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
@@ -22,10 +24,6 @@
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
@@ -344,9 +342,9 @@
const totalWithTip = $derived(tipEnabled ? netTotal * tipMultiplier : netTotal);
const tipDisplay = $derived(
selectedTipPercent !== null
? `${selectedTipPercent}%`
? `${selectedTipPercent}%`
: customTipAmount && parseFloat(customTipAmount) > 0
? `£${parseFloat(customTipAmount).toFixed(2)}`
? `${formatCurrency(parseFloat(customTipAmount))}`
: ''
);
@@ -360,13 +358,6 @@
// disabled in that state; the handlers also guard defensively.
const nothingToCharge = $derived(totalDue <= 0);
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(value);
}
async function applyLoyaltyRedemption(): Promise<void> {
if (!useLoyalty) return;
const res = await apiFetch(`/api/admin/bookings/${booking.id}/apply-redemption`, {
@@ -840,6 +831,50 @@
try {
await applyLoyaltyRedemption();
// Proactive saved-card (ccof) SCA: run the client-side challenge
// BEFORE the first charge attempt so the first charge carries a
// fresh verification_token — a naked ccof is never sent to the
// backend. Only 'sca-unavailable' proceeds token-less (the 2FA gate
// is the fallback); a cancelled/failed challenge does NOT charge —
// the operator taps Pay again to re-run it, reusing the SAME cached
// idempotency key above so the retry dedups instead of double-charging.
let verificationToken = '';
status = 'saved-card-waiting-sca';
try {
const squareCardId = savedCards.find(
(c) => c.id === selectedSavedCardId
)?.square_card_id;
const proactive = await runSavedCardSCAProactively({
amountPence: chargeAmount,
squareCardId: squareCardId ?? '',
buyer: {
givenName: booking.user?.first_name,
familyName: booking.user?.last_name,
email: booking.user?.email
},
onOutcome: (o) => (lastSCAOutcome = o)
});
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
status = 'error';
error = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(error);
return;
}
if (proactive.outcome === 'sca-unavailable') {
// MIT surface: a token-less ccof is never sent even when SCA
// can't run — stop the charge and surface the 2FA fallback
// gate (the operator enters the customer's code and re-taps).
twoFactor.reveal = true;
status = 'error';
error = `${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`;
toast.error(error);
return;
}
verificationToken = proactive.verificationToken ?? '';
} finally {
status = 'saved-card-processing';
}
const response = await submitPaymentWithRetry(
() =>
apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
@@ -850,6 +885,7 @@
payment_type: 'full',
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId,
...(verificationToken ? { verification_token: verificationToken } : {}),
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
idempotency_key: savedCardIdempotencyKey
})
@@ -862,15 +898,11 @@
if (!response.ok) {
responseStatus = response.status;
const errData = await response.text();
// Saved-card (ccof) SCA: the backend returns 402 +
// `verification_required` when Square requires buyer verification
// and no verification_token was supplied. Run the client-side 3DS
// challenge (the CUSTOMER approves in their banking app) and retry
// with the fresh token + the SAME cached idempotency key.
if (isVerificationRequiredSignal(responseStatus, errData)) {
await runSavedCardSCA(chargeAmount);
return;
}
// A 402 verification-required here means the fresh proactive
// token was stale/expired at Square — the charge did NOT land.
// Surface the SCA-first guidance; the operator taps Pay again and
// a fresh challenge runs under the SAME cached idempotency key
// (no double-charge). A plain decline 402 shows the normal error.
const err = new Error(
extractErrorMessage(errData) || 'Failed to process saved card payment'
);
@@ -917,8 +949,7 @@
// NOT land — Square's idempotency key would otherwise reject a retry
// that re-runs SCA and mints a fresh token. 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 the verification-required signal
// (that path runs the SCA challenge and returns before this catch).
// on 503/network (ambiguous).
if (responseStatus === 402) {
savedCardIdempotencyKey = '';
savedCardKeyedBookingId = '';
@@ -944,101 +975,6 @@
fetchSavedCards();
}
});
/**
* Saved-card (ccof) SCA challenge, run when the charge 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 charge with the fresh verification_token
* and the SAME cached idempotency key (never regenerated here);
* - 'challenge-cancelled' / 'sca-failed' → leaves the pending row retryable
* (the idempotency key stays cached) and reveals the 2FA-fallback input;
* - 'sca-unavailable' → demotes 2FA from backup to the available gate.
*/
async function runSavedCardSCA(chargeAmount: number) {
if (!selectedSavedCardId) return;
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
status = 'saved-card-waiting-sca';
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
status = 'error';
error = VERIFICATION_REQUIRED_MESSAGE;
toast.error(error);
return;
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(chargeAmount, squareCardId, {
givenName: booking.user?.first_name,
familyName: booking.user?.last_name,
email: booking.user?.email
});
} catch (_err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
status = 'error';
error = _err instanceof Error ? _err.message : 'Card verification failed';
toast.error(error);
return;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
try {
const retry = await submitPaymentWithRetry(() =>
apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: chargeAmount,
payment_type: 'full',
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId,
verification_token: result.verificationToken,
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
idempotency_key: savedCardIdempotencyKey
})
})
);
if (!retry.ok) {
const retryText = await retry.text();
const err = new Error(
extractErrorMessage(retryText) || 'Failed to process saved card payment'
);
(err as { bodyText?: string }).bodyText = retryText;
throw err;
}
const data = await retry.json();
status = 'success';
paymentResult = {
checkout_id: data.payment_id || data.checkout_id || data.id || '',
status: 'COMPLETED',
card_brand: data.card_brand,
last4: data.card_last4,
amount: data.amount
};
savedCardIdempotencyKey = '';
savedCardKeyedAmount = 0;
twoFactor.setCode('');
twoFactor.reveal = false;
toast.success('Saved card payment successful');
onComplete(paymentResult);
return;
} catch (_err) {
status = 'error';
error = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
toast.error(error);
return;
}
}
twoFactor.reveal = true;
status = 'error';
error =
result.outcome === 'sca-unavailable'
? `${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`
: CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(error);
}
</script>
<Dialog.Root
@@ -1082,7 +1018,7 @@
/>
{#if serviceOverrides[service.service_id] && Math.abs(parseFloat(serviceOverrides[service.service_id].price) - serviceOverrides[service.service_id].originalPrice) > 0.01}
<span class="min-w-0 text-xs text-amber-600">
(was £{serviceOverrides[service.service_id].originalPrice.toFixed(2)})
(was {formatCurrency(serviceOverrides[service.service_id].originalPrice)})
</span>
{/if}
</div>
@@ -1104,7 +1040,7 @@
<div class="mt-0.5 text-xs text-fuchsia-700">
{Math.floor(stamps / 10)} full card{Math.floor(stamps / 10) === 1 ? '' : 's'} available
&middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(
Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE)
Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) / 100
)})
</div>
</label>
@@ -1196,7 +1132,7 @@
class="flex items-center justify-between rounded-md border border-green-200 bg-green-50 p-3"
>
<span class="text-sm font-medium text-green-800">Already paid</span>
<span class="text-base font-bold text-green-800">{formatCurrency(amountPaidPence)}</span
<span class="text-base font-bold text-green-800">{formatCurrency(amountPaidPence / 100)}</span
>
</div>
{/if}
@@ -1207,7 +1143,7 @@
<div class="flex justify-between text-sm">
<span class="text-gray-600">{d.name}</span>
<span class="font-medium text-green-700"
>-{formatCurrency(Math.round(d.amount * 100))}</span
>-{formatCurrency(Math.round(d.amount * 100) / 100)}</span
>
</div>
{/each}
@@ -1403,7 +1339,7 @@
onclick={() => selectTipPercent(tip.pct)}
>
<div>{tip.pct}%</div>
<div class="text-xs font-normal text-gray-500">£{tip.amount.toFixed(2)}</div>
<div class="text-xs font-normal text-gray-500">{formatCurrency(tip.amount)}</div>
</button>
{/each}
</div>
@@ -4,7 +4,8 @@
isSquareConfigured,
isSquareMock,
parseTokenizeVerificationResult,
type SquareTokenizeResult
type SquareTokenizeResult,
type SquareVerificationContact
} from '$lib/square/square';
/** Re-exported for the payment surfaces that import these from this
@@ -14,16 +15,13 @@
export type SavedCardVerificationResult =
import('$lib/square/square').SavedCardVerificationResult;
/**
* Billing contact passed to Square's tokenize() verificationDetails for
* Strong Customer Authentication (SCA). Only fields we already hold are
* included; omit the object entirely when nothing is available.
*/
export interface SquareVerificationContact {
givenName?: string;
familyName?: string;
email?: string;
}
/** Re-exported from square.ts — the single shared home of the saved-card SCA
* logic (tokenizer + the proactive runner). Kept here so the six payment
* surfaces' existing imports stay unchanged. */
export {
tokenizeSavedCardWithVerification,
type SquareVerificationContact
} from '$lib/square/square';
/** Result of a tokenize-with-verification call. */
export interface TokenizeWithVerificationResult {
@@ -31,7 +29,7 @@
verificationToken: string | null;
}
/** Square Web Payments `card.tokenize()` verification details shape. */
/** Square Web Payments `card.tokenize()` verificationDetails shape. */
interface SquareVerificationDetails {
amount: string;
billingContact?: SquareVerificationContact;
@@ -41,100 +39,6 @@
sellerKeyedIn: boolean;
}
/**
* 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
* buyer verification to the exact charge amount, so the challenge must use
* the same major-units amount as the pending charge.
*
* Returns a verification token (retry the SAME charge with it) plus an
* outcome the surfaces map to UX: 'verified' → retry with the token;
* 'challenge-cancelled' / 'sca-failed' → retryable, keep the pending row;
* 'sca-unavailable' → no challenge could run, fall back to the 2FA gate.
*/
export async function tokenizeSavedCardWithVerification(
amount: number,
squareCardId: string,
contact?: SquareVerificationContact
): Promise<SavedCardVerificationResult> {
if (isSquareMock()) {
// DEV-ONLY mock: the mock agent extends MockCardForm with a saved-card
// SCA method. Use it when present (so the mock exercises the same
// challenge path), otherwise fall back to a deterministic fake token
// the backend dev mock accepts.
try {
const mockModule = (await import('./MockCardForm.svelte')) as {
tokenizeSavedCard?: (
amount: number,
squareCardId: string,
contact?: SquareVerificationContact
) => Promise<SavedCardVerificationResult>;
default?: {
tokenizeSavedCard?: (
amount: number,
squareCardId: string,
contact?: SquareVerificationContact
) => Promise<SavedCardVerificationResult>;
};
};
const mockTokenize = mockModule.tokenizeSavedCard ?? mockModule.default?.tokenizeSavedCard;
if (mockTokenize) {
return await mockTokenize(amount, squareCardId, contact);
}
} catch {
// Dynamic import failure → fall through to the deterministic token.
}
const prefix = squareCardId.replace(/^ccof:/, '').slice(0, 4) || 'test';
return {
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}`,
outcome: 'verified'
};
}
const payments = (await getSquarePayments()) as {
card: () => Promise<{
tokenize: (
verificationDetails: SquareVerificationDetails,
cardId: string
) => Promise<SquareTokenizeResult>;
}>;
};
const card = await payments.card();
// Same verification-details shape as tokenizeWithVerification: a
// MAJOR-units decimal amount string (W3C valid-decimal-monetary-value)
// bound to the exact pending charge, intent CHARGE (the card is already
// stored — nothing new to save), GBP, customer-initiated, not seller-keyed.
const verificationDetails: SquareVerificationDetails = {
amount: (amount / 100).toFixed(2),
intent: 'CHARGE',
currencyCode: 'GBP',
customerInitiated: true,
sellerKeyedIn: false
};
if (contact && (contact.givenName || contact.familyName || contact.email)) {
verificationDetails.billingContact = contact;
}
let result: SquareTokenizeResult;
try {
result = await card.tokenize(verificationDetails, squareCardId);
} catch (err) {
// A thrown error (SDK load failure, network) means no challenge could
// run — SCA is unavailable for this charge, fall back to the 2FA gate.
console.error('Saved-card SCA tokenization failed:', err);
return { verificationToken: null, outcome: 'sca-unavailable' };
}
// 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);
}
/**
* The real card form is a CROSS-ORIGIN iframe (web.squarecdn.com) that does
* NOT inherit the page font or CSS — parent stylesheets cannot reach it; only
@@ -179,6 +83,7 @@
import { onMount, onDestroy } from 'svelte';
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
import type MockCardForm from './MockCardForm.svelte';
import { generateUUID } from '$lib/utils/uuid';
interface Props {
/** Disable the form while a payment is processing. */
@@ -198,7 +103,7 @@
// Unique per instance so two mounted card forms never share an element id
// (e.g. the deposit step + the pay-early modal on the same page). This app
// is a pure SPA (no SSR/hydration), so a random id cannot mismatch.
let uniqueId = $state(`square-card-${crypto.randomUUID()}`);
let uniqueId = $state(`square-card-${generateUUID()}`);
async function init() {
if (isSquareMock()) {
@@ -13,23 +13,20 @@
import * as Card from '$lib/components/ui/card';
import { onMount } from 'svelte';
import { generateUUID } from '$lib/utils/uuid';
import { formatCurrency } from '$lib/utils/format';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
isNonceStale,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
runSavedCardSCAProactively,
sanitizeDecimalInput,
shouldFallbackTo2FA,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationOutcome,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
// Shared tip-payment UI used by /tip, /pay-tip/[id] and the account
// booking-modal tip dialog. The routes resolve the booking (most-recent past
@@ -202,10 +199,6 @@
return `${start.toLocaleTimeString('en-GB', formatOpt)} ${end.toLocaleTimeString('en-GB', formatOpt)}`;
}
function formatPrice(pounds: number): string {
return `£${pounds.toFixed(2)}`;
}
function selectTip(amount: number) {
selectedTip = amount;
customTip = '';
@@ -324,7 +317,17 @@
if (selectedCardId && !verificationToken) {
waitingForSCA = true;
try {
const proactive = await runTipSCAProactively(amountInPence, selectedCardId);
const squareCardId = savedCards.find((c) => c.id === selectedCardId)?.square_card_id;
const proactive = await runSavedCardSCAProactively({
amountPence: amountInPence,
squareCardId: squareCardId ?? '',
buyer: {
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
},
onOutcome: (o) => (lastSCAOutcome = o)
});
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
paymentState = 'error';
tipError = CARD_VERIFICATION_RETRY_MESSAGE;
@@ -431,43 +434,6 @@
}
}
/**
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first tip
* charge attempt (never after a 402). Square's tokenize(verificationDetails,
* squareCardId) determines UP FRONT whether buyer verification is required
* and returns a fresh verification_token bound to the exact amount:
* - 'verified' → the caller charges with the returned token (the first
* attempt carries it — a naked ccof is never sent);
* - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup
* to the only available gate and the caller proceeds WITHOUT a token;
* - 'challenge-cancelled' / 'sca-failed' → the caller must NOT charge: the
* pending row stays retryable and the user taps Pay Tip again to re-run
* the challenge.
*/
async function runTipSCAProactively(
amountInPence: number,
cardId: string
): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> {
const squareCardId = savedCards.find((c) => c.id === cardId)?.square_card_id;
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(amountInPence, squareCardId, {
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
});
} catch (_err) {
lastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
}
lastSCAOutcome = result.outcome;
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
}
function retryPayment() {
paymentState = 'idle';
tipError = null;
@@ -522,12 +488,12 @@
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-500">Subtotal</span>
<span class="font-medium">{formatPrice(subtotal)}</span>
<span class="font-medium">{formatCurrency(subtotal)}</span>
</div>
{#if tipsPaid > 0}
<div class="flex justify-between">
<span class="text-sm text-gray-500">Tips</span>
<span class="font-medium">{formatPrice(tipsPaid)}</span>
<span class="font-medium">{formatCurrency(tipsPaid)}</span>
</div>
{/if}
{#if booking.services && booking.services.length > 0}
@@ -538,7 +504,7 @@
<div class="flex justify-between text-sm">
<span class="text-gray-700">{service.service_name || '—'}</span>
<span class="text-gray-500"
>{formatPrice(service.override_price ?? service.price ?? 0)}</span
>{formatCurrency(service.override_price ?? service.price ?? 0)}</span
>
</div>
{/each}
@@ -563,7 +529,7 @@
onclick={() => selectTip(tip.amount)}
type="button"
>
<div>{formatPrice(tip.amount)}</div>
<div>{formatCurrency(tip.amount)}</div>
<div class="text-xs font-normal text-gray-500">{tip.pct}%</div>
</button>
{/each}
@@ -663,7 +629,7 @@
loading={paymentState === 'processing'}
onclick={submitTip}
>
{paymentState === 'processing' ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
{paymentState === 'processing' ? 'Processing...' : `Pay Tip ${formatCurrency(tipAmount)}`}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
@@ -16,6 +16,7 @@
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { apiFetch } from '$lib/utils/api';
import { generateUUID } from '$lib/utils/uuid';
import { formatCurrency } from '$lib/utils/format';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
@@ -24,16 +25,12 @@
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
runSavedCardSCAProactively,
sanitizeDecimalInput,
shouldFallbackTo2FA,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import {
tokenizeSavedCardWithVerification,
type SavedCardVerificationOutcome,
type SavedCardVerificationResult
} from '$lib/components/payments/SquareCardInput.svelte';
const LOYALTY_DISCOUNT_RATE = 0.1;
@@ -236,10 +233,16 @@
get text(): string | null {
if (!booking.deposit_required && totalPaid === 0 && booking.amount_due <= 0) return null;
if (booking.deposit_required) {
return `A ${expectedDepositPercent}% deposit (at least £${(booking.total_amount * 0.2).toFixed(2)}) is required. Any payments up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) are treated as deposit for cancellations.`;
return `A ${expectedDepositPercent}% deposit (at least ${formatCurrency(
booking.total_amount * 0.2
)}) is required. Any payments up to 50% of total (${formatCurrency(
booking.total_amount * 0.5
)}) are treated as deposit for cancellations.`;
}
if (totalPaid > 0 || booking.amount_due > 0) {
return `Any payment up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
return `Any payment up to 50% of total (${formatCurrency(
booking.total_amount * 0.5
)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
}
return null;
}
@@ -281,13 +284,6 @@
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
);
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(pence / 100);
}
function formatTimer(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
@@ -311,8 +307,12 @@
method: 'POST'
});
if (response.ok) {
const data = await response.json();
// Honor the backend's lock TTL (ttl_min — PaymentLockDuration);
// fall back to 5 min when the field is absent so the countdown
// can never drift from the server's value.
lockTimer = (Number(data?.ttl_min) || 5) * 60;
lockAcquired = true;
lockTimer = 300;
}
} catch (_err) {
console.error('Failed to acquire payment lock:', _err);
@@ -344,7 +344,8 @@
method: 'POST'
});
if (response.ok) {
lockTimer = 300;
const data = await response.json();
lockTimer = (Number(data?.ttl_min) || 5) * 60;
lockAcquired = true;
}
} catch (_err) {
@@ -499,7 +500,17 @@
if (cardId && !verificationToken) {
waitingForSCA = true;
try {
const proactive = await runSavedCardSCAProactively(amountPence, cardId);
const squareCardId = savedCardsStore.cards.find((c) => c.id === cardId)?.square_card_id;
const proactive = await runSavedCardSCAProactively({
amountPence,
squareCardId: squareCardId ?? '',
buyer: {
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
},
onOutcome: (o) => (lastSCAOutcome = o)
});
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
status = 'error';
error = CARD_VERIFICATION_RETRY_MESSAGE;
@@ -682,43 +693,6 @@
}
}
/**
* Saved-card (ccof) SCA challenge, run PROACTIVELY before the first charge
* attempt (never after a 402). Square's card.tokenize(verificationDetails,
* squareCardId) determines UP FRONT whether buyer verification is required
* and returns a fresh verification_token bound to the exact amount:
* - 'verified' → the caller charges with the returned token (the first
* attempt carries it — a naked ccof is never sent);
* - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup
* to the only available gate and the caller proceeds WITHOUT a token;
* - 'challenge-cancelled' / 'sca-failed' → the caller must NOT charge:
* the pending row stays retryable and the user taps Pay again to re-run
* the challenge.
*/
async function runSavedCardSCAProactively(
amountPence: number,
cardId: string
): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> {
const squareCardId = savedCardsStore.cards.find((c) => c.id === cardId)?.square_card_id;
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
});
} catch (_err) {
lastSCAOutcome = 'sca-unavailable';
return { outcome: 'sca-unavailable' };
}
lastSCAOutcome = result.outcome;
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
}
// Confirm the overpayment: resend the SAME rejected request with
// confirm_overflow_tip: true so the excess is recorded as a tip. Works for
// both pre-start and post-start overflows (B12).
@@ -864,8 +838,8 @@
discountNote={overflowConfirm.paymentType === 'deposit' &&
overflowConfirm.chargePence !== undefined
? `An eligible campaign discount of ${formatCurrency(
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
)} applies — you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence) / 100
)} applies — you'll be charged ${formatCurrency(overflowConfirm.chargePence / 100)}.`
: undefined}
loading={status === 'processing'}
onConfirm={confirmOverflowPayment}
@@ -950,9 +924,9 @@
<span class="text-gray-600">{service.service_name || 'Unknown Service'}</span>
<span class="font-medium">
{service.override_price
? formatCurrency(Math.round(service.override_price * 100))
? formatCurrency(Math.round(service.override_price * 100) / 100)
: service.price
? formatCurrency(Math.round(service.price * 100))
? formatCurrency(Math.round(service.price * 100) / 100)
: '-'}
</span>
</div>
@@ -974,7 +948,7 @@
<div class="text-sm font-medium text-fuchsia-900">Use my Loyalty Stamp Card</div>
<div class="mt-0.5 text-xs text-fuchsia-700">
{stamps} stamps available &middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off
({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) / 100)})
</div>
</label>
</div>
@@ -1035,7 +1009,7 @@
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
<div class="flex justify-between text-sm">
<span class="text-gray-600">Total</span>
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100) / 100)}</span
>
</div>
{#if discountPreview?.eligible}
@@ -1043,19 +1017,19 @@
<div class="flex justify-between text-sm">
<span class="text-gray-600">{d.name}</span>
<span class="font-medium text-green-700"
>-{formatCurrency(Math.round(d.amount * 100))}</span
>-{formatCurrency(Math.round(d.amount * 100) / 100)}</span
>
</div>
{/each}
{/if}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Amount Paid</span>
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
<span class="font-medium text-green-700">{formatCurrency(totalPaid / 100)}</span>
</div>
{#if useLoyalty && loyaltyDiscount > 0}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Loyalty Stamp Card (10% Off)</span>
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount)}</span>
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount / 100)}</span>
</div>
{/if}
<div class="flex justify-between border-t border-gray-200 pt-2">
@@ -1065,7 +1039,7 @@
Math.max(
0,
remainingBalancePence - campaignDiscountPence(discountPreview) - loyaltyDiscount
)
) / 100
)}
</span>
</div>
@@ -1169,7 +1143,7 @@
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100),
campaignDiscountPence(discountPreview)
)
) / 100
)})
{:else}
Pay {formatCurrency(
@@ -1178,7 +1152,7 @@
Math.round(booking.amount_due * 100) -
campaignDiscountPence(discountPreview) -
(useLoyalty ? loyaltyDiscount : 0)
)
) / 100
)}
{/if}
</Button>
@@ -1248,7 +1222,7 @@
>
{#if paymentType === 'partial'}
Pay {partialAmountValid
? formatCurrency(Math.round(partialAmountNum * 100))
? formatCurrency(Math.round(partialAmountNum * 100) / 100)
: 'Part'}
{:else}
Pay {formatCurrency(
@@ -1257,7 +1231,7 @@
Math.round(booking.amount_due * 100) -
campaignDiscountPence(discountPreview) -
(useLoyalty ? loyaltyDiscount : 0)
)
) / 100
)}
{/if}
</Button>
+162
View File
@@ -246,6 +246,168 @@ export function parseTokenizeVerificationResult(
return { verificationToken: null, outcome: 'sca-failed' };
}
/**
* Billing contact passed to Square's tokenize() verificationDetails for
* Strong Customer Authentication (SCA). Only fields we already hold are
* included; omit the object entirely when nothing is available.
*/
export interface SquareVerificationContact {
givenName?: string;
familyName?: string;
email?: string;
}
/** Square Web Payments `card.tokenize()` verificationDetails shape. */
interface SquareVerificationDetails {
amount: string;
billingContact?: SquareVerificationContact;
intent: string;
currencyCode: string;
customerInitiated: boolean;
sellerKeyedIn: boolean;
}
/**
* 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
* buyer verification to the exact charge amount, so the challenge must use
* the same major-units amount as the pending charge.
*
* Returns a verification token (retry the SAME charge with it) plus an
* outcome the surfaces map to UX: 'verified' retry with the token;
* 'challenge-cancelled' / 'sca-failed' retryable, keep the pending row;
* 'sca-unavailable' no challenge could run, fall back to the 2FA gate.
*/
export async function tokenizeSavedCardWithVerification(
amount: number,
squareCardId: string,
contact?: SquareVerificationContact
): Promise<SavedCardVerificationResult> {
if (isSquareMock()) {
// DEV-ONLY mock: the mock agent extends MockCardForm with a saved-card
// SCA method. Use it when present (so the mock exercises the same
// challenge path), otherwise fall back to a deterministic fake token
// the backend dev mock accepts.
try {
const mockModule = (await import('$lib/components/payments/MockCardForm.svelte')) as {
tokenizeSavedCard?: (
amount: number,
squareCardId: string,
contact?: SquareVerificationContact
) => Promise<SavedCardVerificationResult>;
default?: {
tokenizeSavedCard?: (
amount: number,
squareCardId: string,
contact?: SquareVerificationContact
) => Promise<SavedCardVerificationResult>;
};
};
const mockTokenize = mockModule.tokenizeSavedCard ?? mockModule.default?.tokenizeSavedCard;
if (mockTokenize) {
return await mockTokenize(amount, squareCardId, contact);
}
} catch {
// Dynamic import failure → fall through to the deterministic token.
}
const prefix = squareCardId.replace(/^ccof:/, '').slice(0, 4) || 'test';
return {
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}`,
outcome: 'verified'
};
}
const payments = (await getSquarePayments()) as {
card: () => Promise<{
tokenize: (
verificationDetails: SquareVerificationDetails,
cardId: string
) => Promise<SquareTokenizeResult>;
}>;
};
const card = await payments.card();
// Same verification-details shape as tokenizeWithVerification: a
// MAJOR-units decimal amount string (W3C valid-decimal-monetary-value)
// bound to the exact pending charge, intent CHARGE (the card is already
// stored — nothing new to save), GBP, customer-initiated, not seller-keyed.
const verificationDetails: SquareVerificationDetails = {
amount: (amount / 100).toFixed(2),
intent: 'CHARGE',
currencyCode: 'GBP',
customerInitiated: true,
sellerKeyedIn: false
};
if (contact && (contact.givenName || contact.familyName || contact.email)) {
verificationDetails.billingContact = contact;
}
let result: SquareTokenizeResult;
try {
result = await card.tokenize(verificationDetails, squareCardId);
} catch (err) {
// A thrown error (SDK load failure, network) means no challenge could
// run — SCA is unavailable for this charge, fall back to the 2FA gate.
console.error('Saved-card SCA tokenization failed:', err);
return { verificationToken: null, outcome: 'sca-unavailable' };
}
// 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);
}
/** Options for runSavedCardSCAProactively. */
export interface RunSavedCardSCAOptions {
/** Charge amount in pence — Square binds the verification token to it. */
amountPence: number;
/** The resolved Square card id (ccof:…) of the selected saved card. */
squareCardId: string;
/** Billing contact passed to Square's verificationDetails (optional). */
buyer?: SquareVerificationContact;
/** Records the challenge outcome on the calling surface every surface
* keeps its own `lastSCAOutcome` state to drive SCA-vs-2FA fallback. */
onOutcome: (outcome: SavedCardVerificationOutcome) => void;
}
/**
* Runs the saved-card SCA challenge PROACTIVELY BEFORE the first charge
* attempt so no surface ever sends a naked ccof charge when a verification
* token is expected (the 402-challenge-then-retry pattern is legacy). This is
* the SINGLE shared implementation used by all six saved-card surfaces
* (account gift-card buy, booking-flow deposit, tip, customer payment modal,
* admin payment modal, till), so the tokenize/catch/outcome wiring can never
* drift between them again. The card lookup and the buyer-contact source stay
* with each surface (their card lists and user data differ); this helper owns
* everything downstream of the resolved squareCardId.
*
* Returns the outcome plus the verification token ('verified' retry the
* SAME charge with it). On 'sca-unavailable' the caller falls back to the 2FA
* gate; 'challenge-cancelled'/'sca-failed' are retryable without a token.
*/
export async function runSavedCardSCAProactively(
options: RunSavedCardSCAOptions
): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> {
const { amountPence, squareCardId, buyer, onOutcome } = options;
if (!squareCardId) {
onOutcome('sca-unavailable');
return { outcome: 'sca-unavailable' };
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, buyer);
} catch (_err) {
onOutcome('sca-unavailable');
return { outcome: 'sca-unavailable' };
}
onOutcome(result.outcome);
return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
}
/** 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). */
+21
View File
@@ -81,6 +81,27 @@ export function calculateAge(dateOfBirth: string | undefined | null): number | n
return age;
}
// Single cached GBP formatter shared by every `formatCurrency` call — one
// `Intl.NumberFormat` instance instead of a fresh allocation per call, since
// currency formatting runs on the app's hottest rendering paths. The explicit
// `minimumFractionDigits: 2` guarantees whole pounds render as "£5.00", never
// "£5". `formatCurrency` takes the amount in POUNDS (not pence).
const gbpFormatter = new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
minimumFractionDigits: 2
});
/**
* Format a monetary amount as GBP, e.g. 19.5 "£19.50".
*
* The amount must be in POUNDS (e.g. `booking.total_amount`, `subtotal`).
* For pence values, divide by 100 at the call site: `formatCurrency(pence / 100)`.
*/
export function formatCurrency(amount: number): string {
return gbpFormatter.format(amount);
}
/**
* Create an array of numbers from 0 to n-1.
*