Files
Crussell/frontend/src/lib/square/square.ts
T
popertots b7122be3a0 fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity
7 review agents (pipeline run, self-review, codebase-context, frontend-placement,
backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work.
ALL findings fixed, including every pre-existing red CI job:

GDPR (HIGH):
- anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors
  delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII)
  no longer survive registered-user account deletion; gdpr test added

BACKEND TEST GAPS (all 10):
- delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker
- twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper
- insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all
  6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors)
- CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip,
  token-less fallback + SCA-required (new terminal_sca_test.go)
- isVerificationRequiredError at all 5 charge sites (402 + code:verification_required)
- customer_initiated handler-level assertions (MIT false admin / CIT true customer)
- Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix,
  parseVerifyToken unit tests

FRONTEND SCA + Square-API (CRITICAL):
- tokenizeSavedCardWithVerification reads result.token (the verified token) not
  result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could
  never succeed in production before); parseTokenizeVerificationResult pure fn
  extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless
- HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token
  under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled
- challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show;
  'waiting for approval in your banking app' state on CIT surfaces
- sca-unavailable demotion resets per attempt; card selection disabled mid-challenge;
  genuine saved-card declines no longer relabeled 'requires verification';
  modal-close guard during processing; retry affordance standardized

PIPELINE (every red job now green):
- prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe)
- govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean
- race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic
- DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes)
- frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex),
  deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases

DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical
Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY,
Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified
against code everywhere

Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/
gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
2026-08-22 00:34:50 +01:00

522 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, unknown>;
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<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(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<TwoFactorCodeRequestResult> {
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<TwoFactorCodeRequestResult> {
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<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);
}