Restart-loop dup/mod review findings: - Account page gift-card buy flow now passes the 2FA verification-code gate end-to-end: TwoFactorCodeInput + Request-a-new-code wired for saved-card/save-card charges, verification_code in the /api/user/giftcards/buy body, 403/429 gate-failure self-heal, buy button gated on missing code (backend BuyGiftCard gate at giftcards.go:1471 already required it — the frontend never sent it) - Removed dead requires2FACodeForSavedCard export from square.ts (zero consumers; all surfaces use authStore.savedCardChargeRequires2FACode) + its test; auth store getter documented as THE single source of truth - Login page now delegates token persistence to authStore.setToken instead of direct localStorage writes (drift-risk closed; setToken persists both tokens identically so the full-reload init still works) - Pence comment corrected (GBP minor unit) - account/+page.svelte:84+6; square.ts -16; square.test.ts -26; auth.svelte.ts comment; login/+page.svelte delegation Frontend 72/72 tests + build clean; backend builds.
361 lines
16 KiB
TypeScript
361 lines
16 KiB
TypeScript
// 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<TwoFactorCodeRequestResult> {
|
|
const headers: Record<string, string> = {};
|
|
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<string, unknown>;
|
|
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<Response>,
|
|
options: { maxRetries?: number; retryDelayMs?: number } = {}
|
|
): Promise<Response> {
|
|
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<unknown> | 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<unknown> {
|
|
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 <script> is also
|
|
// detached so a retry injects a clean element.
|
|
const fail = (error: Error) => {
|
|
script.remove();
|
|
sdkPromise = null;
|
|
reject(error);
|
|
};
|
|
|
|
script.onload = () => {
|
|
if (win.Square) {
|
|
resolve(win.Square);
|
|
} else {
|
|
fail(new Error('Square.js loaded but the Square global is missing'));
|
|
}
|
|
};
|
|
script.onerror = () => {
|
|
fail(new Error('Failed to load Square Web Payments SDK'));
|
|
};
|
|
document.head.appendChild(script);
|
|
});
|
|
return sdkPromise;
|
|
}
|
|
|
|
/** Returns `Square.payments(appId, locationId)` once the SDK is loaded. */
|
|
export async function getSquarePayments(): Promise<unknown> {
|
|
const config = getSquareConfig();
|
|
if (!config) {
|
|
throw new Error(
|
|
'Square is not configured — set VITE_SQUARE_APPLICATION_ID and VITE_SQUARE_LOCATION_ID'
|
|
);
|
|
}
|
|
const Square = (await loadSquareSdk()) as {
|
|
payments: (appId: string, locationId: string) => unknown;
|
|
};
|
|
return Square.payments(config.appId, config.locationId);
|
|
}
|