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.
522 lines
24 KiB
TypeScript
522 lines
24 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;
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|