// 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; } /** 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. * 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. Saved cards skip the client-side tokenizeWithVerification SCA step, so * a 402 on the saved-card path means the issuer still requires verification: * retrying the same saved card can never succeed, and the buyer must pay with * a freshly tokenized card or re-add theirs. 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; } /** 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. */ export async function requestNewTwoFactorCode(): Promise { const headers: Record = {}; if (typeof localStorage !== 'undefined') { const token = localStorage.getItem('authToken'); if (token) headers['Authorization'] = `Bearer ${token}`; } try { const response = await fetch('/api/user/2fa/code', { 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' }; } } /** 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