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.
272 lines
9.7 KiB
Svelte
272 lines
9.7 KiB
Svelte
<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}
|