Files
Crussell/frontend/src/lib/components/payments/SquareCardInput.svelte
T
popertots 54a5b1024e Implement full Square payment review fixes + frontend polish
Implement every finding from the deep payment review (P0-P2, minors,
nitpicks), then close the post-implementation re-review items, then
align card-form typography and roll out the Square trust badge.

Backend - Square API alignment:
- tip_settings.allow_tipping nested under device_options (was top-level:
  terminal tips were silently lost in prod)
- CreateCardOnFile now accepts customerID and sends card.customer_id;
  saved-card (ccof:) charges forward square_customer_id as CustomerID
- New SquareClient methods GetPayment, CreateCustomer, CancelCheckout
- SCA verification_token accepted + forwarded in all charge paths
- ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout
  NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/
  ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts
  emails, ForceRefundPending hook

Backend - money safety:
- sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id
  instead of stranding them forever
- SweepStalePendingPayments reconciles at Square before failing (tri-state:
  leave pending on transport error, rescue completed, fail definitively)
- GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution;
  SweepStaleTerminalCheckouts covers terminal_checkouts table
- till gift-card clawback on definitive failure incl. retry path +
  INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT
- cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id))
- customer provisioning (lazy, save-only); one-off/guest mint no customer
- discount preview/apply unified in discounts.go (global-milestone visible
  in preview, N+1 eliminated, redemption counter preserved on failures)
- webhook event_id dedup; refund loop dedup; stale comment fixes
- test-isolation t.Cleanup on committed sweep tests

Frontend:
- SCA tokenizeWithVerification across all charge flows (amount as
  major-units decimal), 5-min token-expiry re-tokenize, verification_token
  in request bodies
- PaymentModal synchronous double-click + zero/negative-amount guards
- till online-card UI wired to /api/admin/till/sale
- policyPopover generalised; new /privacy-policy route; consent checkbox
  copy + Square privacy link
- Square card iframe styled to app typography (Inter 14px, oklch tokens);
  mock form md:text-sm parity
- 'Secure payment powered by Square' badge on all 8 card-payment flows

Schema/docs: terminal_checkouts + square_customer_id + per-user card
constraint in init-script.sql; README migrations; P14 plan + backlog +
Technical Manual updated.

Includes 39 modified/new test files; full backend suite (25 pkgs),
-race on payments+square, and frontend build are green.
2026-08-22 00:34:49 +01:00

272 lines
9.7 KiB
Svelte
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.
<script module lang="ts">
/**
* The real card form is a CROSS-ORIGIN iframe (web.squarecdn.com) that does
* NOT inherit the page font or CSS — parent stylesheets cannot reach it; only
* the SDK `style` option can. This mirrors the app's shadcn Input (see
* ui/input/input.svelte + the tokens in app.css) so the iframe reads as the
* same input as the surrounding form instead of Square's default Helvetica
* Neue 16px. Selectors follow Square's CardClassSelectors schema; only the
* properties listed here are supported (fontSize is capped at 16px, and there
* is no per-field `inputs()` API).
*/
type SquareCardClassSelectors = Record<string, Record<string, string>>;
const cardStyle: SquareCardClassSelectors = {
input: {
fontSize: '14px', // md:text-sm (text-base md:text-sm — desktop size)
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
fontWeight: '400',
color: 'oklch(0.129 0.042 264.695)', // --foreground
backgroundColor: 'oklch(1 0 0)' // --background
},
'input::placeholder': { color: 'oklch(0.554 0.046 257.417)' }, // --muted-foreground
'input.is-focus': { color: 'oklch(0.129 0.042 264.695)' },
'input.is-error': { color: 'oklch(0.64 0.21 25)' }, // --destructive
'.input-container': {
borderColor: 'oklch(0.929 0.013 255.508)', // --input (=== --border)
borderRadius: '8px' // rounded-md = calc(0.625rem - 2px) = --radius-md
},
'.input-container.is-focus': { borderColor: 'oklch(0.704 0.04 256.788)' }, // --ring
'.input-container.is-error': { borderColor: 'oklch(0.64 0.21 25)' },
'.message-text': { color: 'oklch(0.554 0.046 257.417)' },
'.message-text.is-error': { color: 'oklch(0.64 0.21 25)' },
'.message-icon': { color: 'oklch(0.554 0.046 257.417)' },
'.message-icon.is-error': { color: 'oklch(0.64 0.21 25)' }
};
</script>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
import type MockCardForm from './MockCardForm.svelte';
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
/**
* 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;
}
/** Result of a tokenize-with-verification call. */
export interface TokenizeWithVerificationResult {
nonce: string;
verificationToken: string | null;
}
/** Square Web Payments `card.tokenize()` verification details shape. */
interface SquareVerificationDetails {
amount: string;
billingContact?: SquareVerificationContact;
intent: string;
currencyCode: string;
customerInitiated: boolean;
sellerKeyedIn: boolean;
}
interface Props {
/** Disable the form while a payment is processing. */
disabled?: boolean;
/** Called when the card form finishes (re)initializing; true = ready to tokenize. */
onReady?: (ready: boolean) => void;
}
let { disabled = false, onReady = () => {} }: Props = $props();
let containerEl = $state<HTMLDivElement | null>(null);
let cardInstance: unknown | null = null;
let mockForm = $state<MockCardForm | null>(null);
let ready = $state(false);
let initError = $state<string | null>(null);
// Unique per instance so two mounted card forms never share an element id
// (e.g. the deposit step + the pay-early modal on the same page). This app
// is a pure SPA (no SSR/hydration), so a random id cannot mismatch.
let uniqueId = $state(`square-card-${crypto.randomUUID()}`);
async function init() {
if (isSquareMock()) {
// Local dev mock: no SDK load, no iframe. MockCardForm handles everything.
return;
}
if (!isSquareConfigured()) {
initError = 'not_configured';
return;
}
try {
const payments = (await getSquarePayments()) as {
card: (options?: { style?: SquareCardClassSelectors }) => Promise<{
attach: (selector: string) => Promise<void>;
tokenize: (
verificationDetails?: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
destroy: () => void;
}>;
};
// The iframe only honours styling passed via the SDK `style` option.
// card.configure({ style: cardStyle }) could re-apply it later if we
// ever need to restyle the already-attached form — no need to call it
// at init.
const card = await payments.card({ style: cardStyle });
await card.attach(`#${uniqueId}`);
cardInstance = card;
ready = true;
initError = null;
} catch (err) {
console.error('Square card form init failed:', err);
initError = 'init_failed';
}
}
onMount(() => {
init();
});
onDestroy(() => {
const card = cardInstance as { destroy?: () => void } | null;
card?.destroy?.();
cardInstance = null;
ready = false;
});
$effect(() => {
if (!isSquareMock()) {
onReady(ready && !disabled);
}
});
/** Tokenizes the entered card. Returns the cnon:xxx nonce, throws with a user-facing message. */
export async function tokenize(): Promise<string> {
if (isSquareMock()) {
if (!mockForm) {
throw new Error('Card form is not ready — please wait a moment and try again');
}
return mockForm.tokenize();
}
const card = cardInstance as {
tokenize: (
verificationDetails?: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
} | null;
if (!card) {
throw new Error('Card form is not ready — please wait a moment and try again');
}
const result = await card.tokenize();
if (result.status === 'OK' && result.token) {
return result.token;
}
const detail =
result.errors
?.map((e) => e.message || e.code)
.filter(Boolean)
.join(', ') || 'Card details are incomplete';
throw new Error(detail);
}
/**
* Tokenizes the entered card together with SCA verification details for a
* card-not-present charge. UK merchants must run Strong Customer
* Authentication for most online payments — without verificationDetails,
* Square rejects in-scope cards with CARD_DECLINED_VERIFICATION_REQUIRED.
*
* Returns BOTH the card nonce and the verification token, which the caller
* must send to the backend as `verification_token` alongside
* `new_card_token`/`card_token`.
*
* @param amount The amount that WILL be charged, in pence (minor units).
* Square requires this to match the eventual payment amount.
* It is sent to Square as a MAJOR-units decimal string (e.g.
* 5000 pence → "50.00" for £50.00) per the W3C
* valid-decimal-monetary-value standard — sending pence as an
* integer string ("5000") would make Square's 3DS bind a
* 100×-too-large amount and fail SCA.
* @param contact Optional billing contact (name/email we already hold).
*/
export async function tokenizeWithVerification(
amount: number,
contact?: SquareVerificationContact
): Promise<TokenizeWithVerificationResult> {
if (isSquareMock()) {
if (!mockForm) {
throw new Error('Card form is not ready — please wait a moment and try again');
}
return mockForm.tokenizeWithVerification(amount, contact);
}
const card = cardInstance as {
tokenize: (
verificationDetails: SquareVerificationDetails
) => Promise<{
status: string;
token?: string;
verificationResult?: { token?: string };
errors?: Array<{ message?: string; code?: string }>;
}>;
} | null;
if (!card) {
throw new Error('Card form is not ready — please wait a moment and try again');
}
const verificationDetails: SquareVerificationDetails = {
// Square expects a MAJOR-units decimal string (W3C valid-decimal-
// monetary-value), e.g. "50.00" for £50.00 — NOT the minor-unit
// integer ("5000"), which would bind a 100×-too-large 3DS amount.
amount: (amount / 100).toFixed(2),
intent: 'CHARGE',
currencyCode: 'GBP',
customerInitiated: true,
sellerKeyedIn: false
};
if (contact && (contact.givenName || contact.familyName || contact.email)) {
verificationDetails.billingContact = contact;
}
const result = await card.tokenize(verificationDetails);
if (result.status === 'OK' && result.token) {
// In the current tokenize-with-verification flow the returned nonce
// (result.token) is ALREADY the 3DS-verified token — Square binds the
// SCA challenge to this exact amount, so charging it as
// `new_card_token`/`card_token` is sufficient. `verificationResult`
// only exists on the deprecated verifyBuyer() flow; we still read it
// defensively since the backend accepts an explicit verification_token.
return {
nonce: result.token,
verificationToken: result.verificationResult?.token ?? null
};
}
const detail =
result.errors
?.map((e) => e.message || e.code)
.filter(Boolean)
.join(', ') || 'Card details are incomplete';
throw new Error(detail);
}
</script>
{#if isSquareMock()}
{#await import('./MockCardForm.svelte')}
<div class="flex h-9 items-center text-sm text-gray-400">Loading card form...</div>
{:then { default: MockCardFormCtor }}
<MockCardFormCtor bind:this={mockForm} {disabled} {onReady} />
{/await}
{:else if initError === 'not_configured'}
<CardEntryUnavailable />
{:else if initError === 'init_failed'}
<CardEntryUnavailable
message="The secure card form failed to load. Please try again or use a saved card."
/>
{:else}
<div id={uniqueId} bind:this={containerEl}></div>
{/if}