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:
@@ -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
|
||||
· {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 · {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>
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user