// Env vars (frontend build-time, public — safe for the browser): // VITE_SQUARE_APPLICATION_ID Square Web Payments application ID (client-side public) // VITE_SQUARE_LOCATION_ID Square location ID // VITE_SQUARE_ENVIRONMENT 'sandbox' | 'production' | 'mock' (optional; // auto-derived from the application ID prefix when // omitted). 'mock' is LOCAL-DEV ONLY: it renders a // token-only mock card form (never a real Square.js // iframe). It is additionally gated on the dev build // (import.meta.env.DEV), so it can never activate in // a production bundle even if the var is mis-set. const APP_ID = (import.meta.env.VITE_SQUARE_APPLICATION_ID as string | undefined) ?? ''; const LOCATION_ID = (import.meta.env.VITE_SQUARE_LOCATION_ID as string | undefined) ?? ''; const SQUARE_ENV = (import.meta.env.VITE_SQUARE_ENVIRONMENT as string | undefined) ?? ''; export interface SquareConfig { appId: string; locationId: string; } // Square card nonces are single-use, and the SCA verification token issued with // them is amount-bound. A cached nonce older than this is treated as stale and // forces a fresh tokenization before the charge attempt. export const NONCE_STALENESS_MS = 240_000; /** * True when a user's role is allowed to save cards for reuse. Only VERIFIED * accounts (verified_email, admin) may save cards — guests, unverified accounts * and affiliates must never see the save-card option. Single source of truth so * the predicate can't drift between booking, account and tip surfaces. */ export function canSaveCardsForRole(role: string | undefined): boolean { return role === 'verified_email' || role === 'admin'; } /** * Sanitizes a decimal-money text input: strips every non-numeric character * except the decimal point and keeps only the FIRST dot (so "£1.2.3" → "1.23"). * The caller still validates the result against /^\d+(\.\d{0,2})?$/ when a * max-two-decimal rule applies — this only normalizes what the user typed. * Shared by every decimal money input (booking partials, admin service * overrides, tips, cash amounts) so the sanitizer can't drift between them. */ export function sanitizeDecimalInput(value: string): string { const cleaned = value.replace(/[^0-9.]/g, ''); const firstDot = cleaned.indexOf('.'); if (firstDot !== -1) { const integerPart = cleaned.substring(0, firstDot); const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, ''); return integerPart + '.' + decimalPart; } return cleaned; } /** Shape of the `/api/bookings/{id}/discount-preview` response, as consumed by * the payment modals when computing the eligible campaign credit. */ export interface DiscountPreview { eligible: boolean; discounts: Array<{ source: string; name: string; percent: number; amount: number }>; original_total: number; discounted_total: number; } /** * Total eligible campaign-discount credit in pence. The backend auto-applies * eligible campaigns at payment/completion, so the modals must charge the * DISCOUNTED amount — sharing the computation keeps the customer and admin * modals from drifting on how the preview is reduced to pence. */ export function campaignDiscountPence(discountPreview: DiscountPreview | null): number { return discountPreview?.eligible ? discountPreview.discounts.reduce((sum, d) => sum + Math.round(d.amount * 100), 0) : 0; } /** * The deposit charge as the backend computes it (A4/A6 in * backend/handlers/payments/handlers.go): deposits are sent RAW by the * frontend and charged at `deposit − eligible campaign credit`, clamping UP * to the full deposit when the credit ≥ the deposit (the credit then covers * the residual balance via the discount row). Payment surfaces must display * this same amount so the customer never sees a higher deposit than the card * is actually charged. */ export function depositChargePence(depositPence: number, discountPence: number): number { return discountPence >= depositPence ? depositPence : depositPence - discountPence; } /** True when a cached card nonce can no longer be reused: it was tokenized for a * different amount than `amount`, or it is older than NONCE_STALENESS_MS. */ export function isNonceStale( nonceTokenizedAt: number, nonceTokenizedFor: number, amount: number, now: number = Date.now() ): boolean { return nonceTokenizedFor !== amount || now - nonceTokenizedAt > NONCE_STALENESS_MS; } /** * Payment failure classification mirrors the backend's `chargeFailureStatus`: * - 503 (ambiguous): transport/network errors, Square 5xx, context * cancellation — the pending record stays resumable, so a same-key retry * makes the backend re-attempt/reuse it instead of double-charging. * - 402 (definitive): card declined, expired, AVS/CVV failure — retrying * with the same inputs can never succeed. */ const PAYMENT_AMBIGUOUS_STATUS = 503; const PAYMENT_DEFINITIVE_STATUS = 402; /** * True when a definitive (402) charge failure on a SAVED CARD should be * surfaced as a card-issuer verification problem rather than a plain decline. * * This is the DEFENSIVE/unexpected path: customer-initiated saved-card (ccof) * surfaces now run the client-side SCA challenge PROACTIVELY before the first * charge attempt (tokenizeSavedCardWithVerification) and carry a fresh * `verification_token` on the charge — or have demoted to the 2FA gate when * SCA is unavailable — so a naked ccof charge should never reach Square. A * 402 here therefore means the verification token was consumed/expired * between tokenize and charge (or a config drift), and the buyer should be * pointed at the retry affordance rather than silently re-challenged. The * backend sets customer_details.customer_initiated=true on saved-card (ccof) * charges and classifies issuer-verification rejections — Square's * CARD_DECLINED_VERIFICATION_REQUIRED and friends — as definitive 402s, but the * response body is the generic "Payment failed" text with no distinguishing * code. Retrying the same saved card can never succeed, and the buyer must pay * with a freshly tokenized card, re-add theirs, or re-run the SCA challenge. * New-card (cnon) charges carry their own SCA verification token, so they are * never classified this way. */ export function isSavedCardVerificationRequired(status: number, usedSavedCard: boolean): boolean { return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS; } /** * Machine-readable code the backend returns on a 402 when a saved-card (ccof) * charge requires Strong Customer Authentication and no `verification_token` * was supplied. The saved-card charge path returns a JSON body of the form * `{"error": "...", "code": "verification_required"}` — the shared error-text * extractor only surfaces the human-readable message, so this checks the raw * body for the code field exactly like isOverflowTipConfirmationRequired does. */ const VERIFICATION_REQUIRED_CODE = 'verification_required'; /** * True when a charge response is Square's SCA "verification required" signal: * HTTP 402 with a JSON body `{"error": "...", "code": "verification_required"}` * (the shape the backend now returns on a saved-card charge Square refuses for * want of a verification token), OR the raw body text matching the known * verification-required strings (`CARD_DECLINED_VERIFICATION_REQUIRED` and the * plain-text "verification required" phrasing, for dev/mock parity where the * backend mock may not emit the structured code). The caller runs the * client-side 3DS challenge (tokenizeSavedCardWithVerification) and retries * with the fresh token instead of surfacing a dead-end decline. Returns false * for any non-402 status, non-matching text, or non-JSON body. */ export function isVerificationRequiredSignal(status: number, bodyText: string): boolean { if (status !== PAYMENT_DEFINITIVE_STATUS) return false; const trimmed = bodyText.trim(); if (!trimmed) return false; if (/verification required|CARD_DECLINED_VERIFICATION_REQUIRED/i.test(trimmed)) return true; try { const parsed = JSON.parse(trimmed) as Record; return parsed?.code === VERIFICATION_REQUIRED_CODE; } catch { // Not JSON — cannot be the structured verification-required body return false; } } /** * True only when an SCA attempt reported that buyer verification is genuinely * unavailable (no 3DS challenge could be run), so the surface falls back to the * homegrown 2FA gate. Every other outcome — verified, a cancelled challenge, or * a hard SCA failure — keeps SCA as the primary path (a cancelled/failed * challenge is retryable, and SCA should be attempted again). */ export function shouldFallbackTo2FA(scaOutcome: string): boolean { return scaOutcome === 'sca-unavailable'; } /** Outcome of a saved-card SCA challenge, used by the payment surfaces to * decide whether to retry with the fresh verification token, surface a * retryable failure, or fall back to the 2FA gate. */ export type SavedCardVerificationOutcome = 'verified' | 'challenge-cancelled' | 'sca-unavailable' | 'sca-failed'; /** Result of tokenizeSavedCardWithVerification. */ export interface SavedCardVerificationResult { verificationToken: string | null; outcome: SavedCardVerificationOutcome; } /** Square Web Payments `card.tokenize()` result shape. Per the CURRENT SDK * (Square.js /v1/), tokenize returns `{ status, token, details?, errors }` — * the SCA-verified token for both the new-card and the card-on-file * (`card.tokenize(verificationDetails, cardId)`) flows comes back in the SAME * `token` field. There is NO nested `verificationResult` — that only exists on * the deprecated `payments.verifyBuyer()` flow, which Square is retiring (see * developer.squareup.com/docs/web-payments/take-card-payment → "Migrate from * Payments.verifyBuyer()"). */ export interface SquareTokenizeResult { status: string; token?: string; errors?: Array<{ message?: string; code?: string }>; } /** * Maps a Square `card.tokenize()` result to the saved-card SCA outcome. * * - `status === 'OK'` means buyer verification either completed or was NOT * required by the issuer — the charge may proceed. The verification-aware * token (when present) is the `token` field; a tokenless OK means no SCA was * demanded, so the charge proceeds token-less (the backend 2FA gate / Square * risk rules are the fallback), never a dead-end. * - `VERIFICATION_CHALLENGE` / cancel-coded errors mean the challenge was * shown but not completed — the buyer can retry, so this is retryable. * - `CARD_DECLINED_VERIFICATION_REQUIRED` means no challenge could run — SCA * is unavailable and the surface falls back to the 2FA gate. * - anything else is a hard SCA failure. */ export function parseTokenizeVerificationResult( result: SquareTokenizeResult ): SavedCardVerificationResult { if (result.status === 'OK') { return { verificationToken: result.token ?? null, outcome: 'verified' }; } const codes = (result.errors ?? []).map((e) => e.code ?? '').filter(Boolean); const errorText = codes.join(' ') + ' ' + (result.errors ?? []).map((e) => e.message ?? '').join(' '); if (result.status === 'VERIFICATION_CHALLENGE' || /cancel/i.test(errorText)) { return { verificationToken: null, outcome: 'challenge-cancelled' }; } if (codes.includes('CARD_DECLINED_VERIFICATION_REQUIRED')) { return { verificationToken: null, outcome: 'sca-unavailable' }; } return { verificationToken: null, outcome: 'sca-failed' }; } /** User-facing guidance for a saved-card charge whose issuer requires Strong * Customer Authentication: the buyer must approve the payment in their banking * app (the client-side tokenizeSavedCardWithVerification challenge does this). */ export const VERIFICATION_REQUIRED_MESSAGE = 'Your card issuer requires verification. Approve this payment in your banking app.'; /** User-facing message for a saved-card SCA challenge that was cancelled or did * not complete. Retryable via SCA — deliberately does NOT promise the 2FA code * input, which the customer surfaces only surface on 'sca-unavailable'. */ export const CARD_VERIFICATION_RETRY_MESSAGE = "Card verification was cancelled or didn't complete. Please try again."; /** User-facing guidance appended to VERIFICATION_REQUIRED_MESSAGE when the * issuer's SCA challenge genuinely cannot run — the 2FA code input is the * only available authorisation and is surfaced as the fallback gate. */ export const SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE = "In-app approval isn't available for this card — enter the verification code instead."; /** User-facing guidance for a saved-card charge the issuer requires * verification to complete. Retrying the same saved card is pointless — the * buyer must pay with a new card or re-add their card. */ export const SAVED_CARD_VERIFICATION_MESSAGE = 'Your card issuer requires verification. Please pay with a new card or re-add your card.'; /** * True when a saved-card charge error is a 2FA verification-gate rejection * (backend/handlers/payments/twofa.go): 403 when no code was supplied or the * card owner hasn't enabled 2FA, 429 when the brute-force lockout tripped, and * 400 for an invalid or expired code. All are recoverable by entering the * customer's CURRENT code (a lockout invalidates the pending code, so a fresh * code must be requested). Callers keep the verification-code input populated * and surfaced on these statuses. */ export function isTwoFactorVerificationGateFailure(status: number, message: string): boolean { if (status === 403 || status === 429) return true; if (status !== 400) return false; return /invalid verification code|verification code expired/i.test(message); } /** Shape returned by requestNewTwoFactorCode: the HTTP status (0 for a * network error before any response), whether the mint succeeded, and a * user-facing message extracted from the server body or a sensible fallback. */ export interface TwoFactorCodeRequestResult { status: number; ok: boolean; message: string; } /** * Mints a fresh 2FA verification code for the signed-in account via * POST /api/user/2fa/code (RequireAuth + RequireNonGuest). The backend applies * a per-user mint cooldown, so a too-fast re-request returns 429; production * with no delivery channel configured fails closed with 503. A successful mint * delivers the code through the build-dependent channel ([2FA] server log in * dev/test builds), so this surfaces the server's `message` — never the code * itself. The Authorization header is read from localStorage (authToken) — * exactly where the auth store persists it — so this helper stays free of * `$lib` imports and the pure-logic vitest suite can exercise it without a * SvelteKit plugin resolving the `$lib` alias. */ // Shared 2FA code-mint POST — the session and admin variants differ only in // the URL, so keeping one body stops the two copies drifting apart. async function requestTwoFactorCode(path: string): Promise { const headers: Record = {}; if (typeof localStorage !== 'undefined') { const token = localStorage.getItem('authToken'); if (token) headers['Authorization'] = `Bearer ${token}`; } try { const response = await fetch(path, { method: 'POST', headers }); if (response.ok) { const data = (await response.json().catch(() => null)) as { message?: unknown } | null; const message = typeof data?.message === 'string' ? data.message : 'A new verification code has been sent.'; return { status: response.status, ok: true, message }; } const body = await response.text(); return { status: response.status, ok: false, message: extractServerErrorMessage(body) || 'Failed to request a new verification code' }; } catch { return { status: 0, ok: false, message: 'Network error requesting a new code' }; } } export async function requestNewTwoFactorCode(): Promise { return requestTwoFactorCode('/api/user/2fa/code'); } /** Admin-scoped 2FA mint: requests a fresh code FOR the given customer (the * card owner) at the till/admin payment modal. The backend keys the mint to * the CUSTOMER's userID, so the code is delivered to the customer and can * satisfy the card-owner gate — the admin's session never receives or * authenticates the customer's card. */ export async function adminRequestNewTwoFactorCode( userID: string ): Promise { return requestTwoFactorCode(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`); } /** Minimal `{"error"|"message": "..."}` extractor for the 2FA code-request * endpoint bodies (429/503), kept inline so square.ts stays import-free for * the vitest suite. */ function extractServerErrorMessage(body: string): string { const trimmed = body.trim(); if (!trimmed) return ''; try { const parsed = JSON.parse(trimmed) as { error?: unknown; message?: unknown }; if (typeof parsed.error === 'string') return parsed.error; if (typeof parsed.message === 'string') return parsed.message; } catch { // Not JSON — use the raw body below. } return trimmed; } /** * Error code the booking-payment endpoint (POST /api/bookings/{id}/payment) * returns with a 400 when a payment would exceed the booking's remaining * balance without `confirm_overflow_tip`. buildSplitRecords records any * overflow beyond the booking total as a tip — but a tip is gratuity for * service already rendered, so the backend refuses to silently convert an * unconfirmed overpayment into a tip (see CreateBookingPayment). The guard * applies both BEFORE and AFTER the appointment has started (B12); the * frontend must surface a Confirm/Cancel prompt and resend the SAME * request with `confirm_overflow_tip: true` on confirm. This fires mainly on * stale booking data (multi-tab, admin-changed totals, refunds that reopened * capacity), so the response body carries no amount — the caller computes the * overflow as `req.Amount - remainingPence` from its booking data. */ const OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE = 'overflow_tip_confirmation_required'; /** * True when an API error body is the backend's overflow-tip confirmation guard * (a 400 JSON body of the form `{"error": "...", "code": * "overflow_tip_confirmation_required"}`). The shared error-parsing helper * (`extractErrorMessage`) surfaces only the human-readable message text, not * the machine-readable `code` field, so this checks the raw response body * directly. Returns false for any non-JSON body or any other error. */ export function isOverflowTipConfirmationRequired(errorText: string): boolean { const trimmed = errorText.trim(); if (!trimmed) return false; try { const parsed = JSON.parse(trimmed) as Record; return parsed?.code === OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE; } catch { // Not JSON — cannot be the overflow guard return false; } } /** True when a payment submission response is an ambiguous failure (503) that * must be retried with the SAME idempotency key so the backend resumes the * pending record. */ export function isAmbiguousPaymentFailure(status: number): boolean { return status === PAYMENT_AMBIGUOUS_STATUS; } const PAYMENT_RETRY_DELAY_MS = 1500; const PAYMENT_MAX_RETRIES = 3; /** * Submits a payment and transparently retries on ambiguous (503) responses * with the SAME request body — the body carries the idempotency key, so every * retry reuses it and the backend re-attempts / resumes the pending record * instead of issuing a second charge. Definitive failures (402) and every * other status return immediately so the caller surfaces the error; the caller * must NOT retry those on its own. Defaults to up to 3 retries with a 1.5s * backoff (Square/backend transient failures typically clear within seconds). */ export async function submitPaymentWithRetry( submit: () => Promise, options: { maxRetries?: number; retryDelayMs?: number } = {} ): Promise { const maxRetries = options.maxRetries ?? PAYMENT_MAX_RETRIES; const retryDelayMs = options.retryDelayMs ?? PAYMENT_RETRY_DELAY_MS; for (let attempt = 0; ; attempt++) { const response = await submit(); if (response.ok || !isAmbiguousPaymentFailure(response.status) || attempt >= maxRetries) { return response; } await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); } } /** True when the frontend runs in local-dev mock mode: VITE_SQUARE_ENVIRONMENT === 'mock' * AND the dev build (import.meta.env.DEV). The DEV gate makes the mock structurally * impossible in any production bundle — even if the env var is mis-set at build time. */ export function isSquareMock(): boolean { return SQUARE_ENV === 'mock' && import.meta.env.DEV; } /** True when a card form can be shown: real Square credentials OR local-dev mock mode. */ export function isSquareConfigured(): boolean { return isSquareMock() || (APP_ID !== '' && LOCATION_ID !== ''); } export function getSquareConfig(): SquareConfig | null { if (!isSquareConfigured()) return null; return { appId: APP_ID, locationId: LOCATION_ID }; } // Pinned SDK version: Square.js is loaded from the /v1/ path, which is // Square's stable major version. Square does not publish SRI hashes, so no // integrity attribute can be set — the /v1/ version pin is the supply-chain // control (S-3). Monitor Square's release notes before major bumps. const SQUARE_SDK_VERSION = 'v1'; function sdkUrl(): string { const env = (import.meta.env.VITE_SQUARE_ENVIRONMENT as string | undefined) ?? ''; const isSandbox = env === 'sandbox' || (APP_ID !== '' && APP_ID.startsWith('sandbox-')); return isSandbox ? `https://sandbox.web.squarecdn.com/${SQUARE_SDK_VERSION}/square.js` : `https://web.squarecdn.com/${SQUARE_SDK_VERSION}/square.js`; } let sdkPromise: Promise | null = null; /** * Loads the Square.js script once and resolves with the global `Square` object. * The promise is cached so concurrent card forms share a single script load. */ export function loadSquareSdk(): Promise { if (typeof window === 'undefined') { return Promise.reject(new Error('Square SDK requires a browser environment')); } const win = window as unknown as { Square?: unknown }; if (win.Square) { return Promise.resolve(win.Square); } if (sdkPromise) return sdkPromise; sdkPromise = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = sdkUrl(); script.async = true; script.dataset.squareSdk = 'true'; // Non-sticky-rejection invariant: a FAILED load attempt must never poison // the cached promise. Concurrent callers during one attempt share the same // in-flight promise (good — single script load), but a REJECTED promise is // cleared here so the next getSquarePayments() call starts a fresh attempt // instead of returning the same error forever. The dead