// 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; // Canonical payment_method value for charging a customer's saved card. The // backend keys on this exact string ('saved_card' — see till.go's payment-method // switch and the terminal saved-card branch), so every surface that charges a // saved card must send it verbatim. Single source of truth so the gate // expressions and request bodies can't drift between 'savedcard' and // 'saved_card' across the booking, tip, account, till and admin surfaces. export const PAYMENT_METHOD_SAVED_CARD = 'saved_card'; /** * Cash till-sale request body shared by every cash payment surface. The * backend's CreateTerminalPaymentRequest derives the tip from * `amount - remaining` when `tip_enabled && remaining < amount` — it has NO * `tip_amount` field — so the tip must be FOLDED INTO the amount (a separate * `tip_amount` key is dead: it is never parsed and the tip would be silently * dropped). Single source of truth so the admin and customer cash flows can't * drift. `tip_enabled` is set only when there actually IS a tip, exactly like * the old inline bodies. */ export type CashTillPaymentBody = { amount: number; payment_type: 'full'; payment_method: 'cash'; tip_enabled?: true; }; export function buildCashTillPaymentBody( cashDuePence: number, tipPence: number ): CashTillPaymentBody { const body: CashTillPaymentBody = { amount: cashDuePence + tipPence, payment_type: 'full', payment_method: 'cash' }; if (tipPence > 0) body.tip_enabled = true; return body; } /** * The cash till-sale charge base in pence. The backend's * CreateTerminalPayment derives the recorded tip from `amount − remaining`, * where `remaining` comes from GetBookingRemainingBalancePence (service.go) — * which does NOT subtract the pending campaign discount or loyalty discount * (both mint `payment_method='discount'` rows that never reduce the balance). * The charge base must therefore be the FULL totalDuePence — neither the * campaign credit nor the loyalty discount is subtracted, because the backend * applies both at completion via separate discount rows. The campaign and * loyalty parameters are kept for call-site compatibility (the calling modals * still compute them for display) but are deliberately unused in the * arithmetic: subtracting them would undercharge the booking and cause the * backend's tip carve (`amount − remaining`) to absorb the tip or record a * negative tip. * * FIX-1/FIX-2: `campaignPence` and `loyaltyPence` are accepted but IGNORED. * The correct charge base is the full total due, with campaign and loyalty * applied server-side at completion. */ export function cashChargeBasePence( totalDuePence: number, _campaignPence: number, _loyaltyPence: number ): number { return Math.max(0, totalDuePence); } /** * 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 * tokenize-result token as `new_card_token` on the charge — or refuse when SCA * is unavailable (C6) — so a naked ccof charge should never * reach Square. A 402 here therefore means the SCA tokenize-result 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 SCA tokenize-result * token (`new_card_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). The C6 legal verdict (PSR 2017 * SCA is non-waivable; the merchant is liable regardless of consent) made the * homegrown 2FA code gate unlawful as an SCA fallback for saved-card charges, * so this now drives the REFUSAL path: the charge cannot complete and the * customer is told to pay online later — never offered the verification-code * fallback. 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 shouldShowSCARefusal(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 refuse the charge (SCA-only posture). */ export type SavedCardVerificationOutcome = 'verified' | 'challenge-cancelled' | 'sca-unavailable' | 'sca-failed'; /** Result of tokenizeSavedCardWithVerification. */ export interface SavedCardVerificationResult { verificationToken: string | null; outcome: SavedCardVerificationOutcome; } /** Result of a NEW-card `tokenizeWithVerification` — the SCA-verified cnon * nonce is the charge source and there is NO separate verificationToken. This * is the exact contract of the real Square Web Payments SDK (`card.tokenize()` * returns the verified token in the single `token` field — a separate * verification token only exists on the saved-card-on-file SCA flow), and it * is what the charge surfaces depend on: they send * `new_card_token: newCardToken ?? verificationToken` and gate * `save_card: X && !verificationToken`, so a new-card tokenize MUST yield a * null verificationToken or the save is silently suppressed. */ export interface NewCardTokenizeResult { nonce: string; verificationToken: null; } /** Builds a new-card tokenize-with-verification result: the cnon nonce is the * charge source, verificationToken is always null (mirroring the real SDK). * The dev mock (MockCardForm.tokenizeWithVerification) returns this so it can * never drift from the real contract. */ export function newCardTokenizeResult(nonce: string): NewCardTokenizeResult { return { nonce, verificationToken: null }; } /** 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 and the backend's SCA-only * verification-required gate is the arbiter, never a silent 2FA fallback. * - `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 refuses the charge (C6, see * shouldShowSCARefusal). * - 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' }; } /** * 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, refuse the charge (C6). */ export async function tokenizeSavedCardWithVerification( amount: number, squareCardId: string, contact?: SquareVerificationContact ): Promise { 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 tokenize-result // token the backend dev mock accepts. The token is a GENUINE // tokenize-result shape — `cnon:sca-__ok` — so the // backend mock's saved-card SCA gate recognises it via // isSCATokenizeResultSource (square_dev.go checks the `cnon:sca-` // prefix) instead of the old `verify_mock_...` shape, which real Square // would never accept as a charge source. try { const mockModule = (await import('$lib/components/payments/MockCardForm.svelte')) as { tokenizeSavedCard?: ( amount: number, squareCardId: string, contact?: SquareVerificationContact ) => Promise; default?: { tokenizeSavedCard?: ( amount: number, squareCardId: string, contact?: SquareVerificationContact ) => Promise; }; }; 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: `cnon:sca-${prefix}_${String(Math.round(amount))}_ok`, outcome: 'verified' }; } const payments = (await getSquarePayments()) as { card: () => Promise<{ tokenize: ( verificationDetails: SquareVerificationDetails, cardId: string ) => Promise; }>; }; 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 and the surface refuses. 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 → sca-unavailable (C6 refusal). 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 the refusal path. */ 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 shows the refusal * notice (C6 — no 2FA fallback); '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). */ 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 no longer offer (C6 SCA-only posture). */ export const CARD_VERIFICATION_RETRY_MESSAGE = "Card verification was cancelled or didn't complete. Please try again."; /** * User-facing refusal shown when a saved-card charge hits genuine * `sca-unavailable` (the issuer's in-app SCA challenge cannot run). C6 legal * verdict: the homegrown 2FA code gate cannot legally substitute for SCA (PSR * 2017 SCA is non-waivable and the merchant stays liable regardless of * consent), so the customer is told the payment cannot complete and is invited * to pay online later — never offered a verification-code fallback. */ export const SCA_REFUSAL_MESSAGE_ONLINE = "We couldn't complete secure authentication with your bank. To keep your payment protected, it can't be processed right now, so this deposit, payment or purchase did not go through. You can try again later."; /** * In-person till variant: the customer is physically at the salon, so the * natural fallback is to pay online later from home. */ export const SCA_REFUSAL_MESSAGE_TILL = "We couldn't complete secure authentication with your bank. To keep your payment protected, it can't be processed at the till right now, so you can pay online later instead."; /** * Version of the SCA-unavailable 2FA-fallback informational notice. Bump when * the notice's wording or the consent payload changes so a charge can never * claim consent under an outdated notice. Under the C6 SCA-only posture this * notice is shown ONLY on the explicit opt-in path (a deployment that enables * the backend `TWO_FACTOR_FALLBACK`); the shipped surfaces refuse instead and * never reach it. The notice is an INFORMATION notice (approved as such by * legal review) — it tells the customer the fallback is less secure than * their bank's authentication and that their refund/chargeback rights are * unaffected; it is NOT a liability waiver and must never be framed as one. */ export const SCA_FALLBACK_CONSENT_VERSION = 'v1'; /** * Consent payload carried on a 2FA-fallback charge once the customer accepted * the SCA-unavailable notice — the EXPLICIT OPT-IN path only (a deployment * that enables the backend `TWO_FACTOR_FALLBACK`). The frontend cannot detect * the deployment posture (no build-time env, no /api/config endpoint), so the * shipped charge surfaces default to the C6 refusal and never produce * consent_accepted on their own: they call this with `false`, which returns * `{}` and sends no consent fields. The backend ignores unknown fields until * it adds audit capture, so sending these is forward-compatible. * `consent_accepted` is true exactly when the customer chose to continue with * the verification code (never when they chose "Cancel and pay later" — no * charge is sent then). */ export function scaFallbackConsentFields(consentAccepted: boolean): Record { return consentAccepted ? { consent_version: SCA_FALLBACK_CONSENT_VERSION, consent_accepted: true } : {}; } /** 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`); } /** * Re-sends the account email-verification code via POST /api/verify/generate * (backend/handlers/auth/local.go GenerateVerificationCodeHandler), minting a * fresh code for the given email with purpose `email_verify`. Response shape: * `{ success, message }`. * * The legacy `/api/verify-email` endpoint does NOT exist in the backend; its * ONLY caller was +layout.svelte's verify_email() (frontend/src/routes/+layout.svelte * lines ~45-61), which still POSTs to the dead URL with no body. +layout.svelte * is OUTSIDE this fix's ownership, so this helper is the fix vehicle: the * round-2 agent must switch verify_email() to call * `resendEmailVerification(authStore.currentUser?.email)` — the endpoint keys * on the logged-in user's email, so it must be passed in, never read from the * store inside this helper (keeps it import-free for the vitest suite). No * other call site references the dead endpoint. */ export async function resendEmailVerification(email: string): Promise { return fetch('/api/verify/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, purpose: 'email_verify' }) }); } /** * Submits the emailed verification code via POST /api/verify/check * (backend/handlers/auth/local.go VerifyCodeHandler), escalating the account * from unverified_email to verified_email. The handler keys on the submitted * `code` alone (the remaining fields are ignored server-side but kept for * forward-compatibility with a purpose-keyed check). Response shape: * `{ success, message }` — a success clears the +layout.svelte banner via a * reload. */ export async function verifyEmailCode(email: string, code: string): Promise { return fetch('/api/verify/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, code, purpose: 'email_verify' }) }); } /** 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). * * `verificationCodeGated` disables the auto-retry for charges whose body * carries the homegrown 2FA `verification_code` (finding 4): a fresh saved-card * charge already CONSUMED that code at the backend gate (single-use), and the * backend re-issued a fresh one on the ambiguous failure — so a same-body retry * would re-send a dead code and come back 400 "Invalid verification code". The * 503 is surfaced to the caller instead, which re-runs the gate / asks the * customer for the re-issued code. */ export async function submitPaymentWithRetry( submit: () => Promise, options: { maxRetries?: number; retryDelayMs?: number; verificationCodeGated?: boolean; } = {} ): 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) || options.verificationCodeGated || 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