Implement P11: Square Web Payments SDK new-card tokenization

Re-enable new-card entry across all 8 flows via Square Web Payments SDK
cnon: nonces (backend was already P11-ready):
- Add square.ts SDK loader (env-gated on VITE_SQUARE_APPLICATION_ID/LOCATION_ID,
  sandbox vs prod URL auto-derived from app-ID prefix) + SquareCardInput.svelte
  (tokenize() via bind:this, onReady state, CardEntryUnavailable fallback)
- CardSelection.svelte: replace newCardDisabled gate with new-card toggle +
  SquareCardInput; expose tokenize() for parent flows
- Wire new-card mode into tip x3, booking payment (UserPaymentModal), deposit
  (BookingFlow incl. guest), Buy a Gift Card + Add a Card (account), and admin
  till online_square (GiftCardsManagement create/topup)
- Retry-safe: each flow caches the one-shot nonce and reuses it on retry so the
  backend idempotency key dedups instead of re-tokenizing
- Docs: README, Gap Backlog P11, Feature Catalog, Technical Manual, P11 plan
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 1cdefb1834
commit 64d4b65083
17 changed files with 936 additions and 222 deletions
@@ -17,6 +17,8 @@
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import { isSquareConfigured } from '$lib/square/square';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
interface Props {
open: boolean;
@@ -149,12 +151,20 @@
let tipSavedCards = $state<SavedCard[]>([]);
let tipLoadingCards = $state(false);
let tipSelectedCardId = $state<string | null>(null);
let tipShowNewCardForm = $state(false);
let tipSquareCardReady = $state(false);
let tipSquareCardInput = $state<SquareCardInput | null>(null);
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
// of re-tokenizing (the backend idempotency key dedups).
let tipNonce = $state('');
const canSaveCards = $derived(
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
);
const isTipCardValid = $derived(tipSelectedCardId !== null);
const isTipCardValid = $derived(
tipSelectedCardId !== null || (tipShowNewCardForm && tipSquareCardReady)
);
const tipPresets = $derived(
selectedBooking
@@ -220,11 +230,21 @@
return;
}
if (tipSavedCards.length === 0) {
toast.error('Please add a saved card or contact the salon to pay by another method');
return;
}
if (!tipSelectedCardId) {
let newCardToken: string | undefined;
if (tipSelectedCardId) {
// saved card — nothing to tokenize
} else if (tipShowNewCardForm && tipSquareCardInput) {
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
if (!tipNonce) {
try {
tipNonce = await tipSquareCardInput.tokenize();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
}
}
newCardToken = tipNonce;
} else {
toast.error('Please select a payment method');
return;
}
@@ -239,7 +259,8 @@
const body: Record<string, unknown> = {
amount: Math.round(tipAmount * 100),
idempotency_key: tipIdempotencyKey,
card_id: tipSelectedCardId
...(tipSelectedCardId ? { card_id: tipSelectedCardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {})
};
const response = await apiFetch(`/api/bookings/${selectedBooking.id}/tip`, {
@@ -254,6 +275,7 @@
toast.success('Thank you for your tip!');
tipIdempotencyKey = '';
tipKeyedAmount = 0;
tipNonce = '';
showTipModal = false;
fetchBookingDetails();
} catch (err) {
@@ -1090,6 +1112,9 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
customTipInput = '';
tipIdempotencyKey = '';
tipKeyedAmount = 0;
tipSelectedCardId = null;
tipShowNewCardForm = false;
tipNonce = '';
}
}}
>
@@ -1150,10 +1175,14 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipSelectedCardId ===
card.id
card.id && !tipShowNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => (tipSelectedCardId = card.id)}
onclick={() => {
tipSelectedCardId = card.id;
tipShowNewCardForm = false;
tipNonce = '';
}}
>
<div class="flex items-center gap-3">
<CardBrandIcon brand={card.brand} />
@@ -1164,16 +1193,55 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
>
</div>
</div>
{#if tipSelectedCardId === card.id}
{#if tipSelectedCardId === card.id && !tipShowNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/each}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipShowNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
tipSelectedCardId = null;
tipNonce = '';
tipShowNewCardForm = !tipShowNewCardForm;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
</div>
{#if tipShowNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{:else}
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
/>
{#if isSquareConfigured()}
<SquareCardInput
bind:this={tipSquareCardInput}
onReady={(r) => (tipSquareCardReady = r)}
/>
{:else}
<CardEntryUnavailable />
{/if}
{/if}
{#if tipSavedCards.length > 0 && tipShowNewCardForm}
{#if isSquareConfigured()}
<SquareCardInput
bind:this={tipSquareCardInput}
onReady={(r) => (tipSquareCardReady = r)}
/>
{:else}
<CardEntryUnavailable />
{/if}
{/if}
</div>
</div>
@@ -9,7 +9,8 @@
import { EmailInput } from '$lib/components/ui/email-input';
import * as Modal from '$lib/components/ui/dialog';
import { Skeleton } from '$lib/components/ui/skeleton';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import { isSquareConfigured } from '$lib/square/square';
import { range } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
@@ -152,6 +153,12 @@
let cardMachineItemID = $state<string | null>(null);
let processingMessage = $state('Processing payment...');
// Online card (Square Web Payments tokenization) state for the till.
let onlineSquareAction = $state<'create' | 'topup' | null>(null);
let onlineSquareCardReady = $state(false);
let onlineSquareCardInput = $state<SquareCardInput | null>(null);
let onlineSquareProcessing = $state(false);
// Idempotency Key
let idempotencyKey = $state('');
@@ -450,6 +457,8 @@
generateEmail = '';
generateUserQuery = '';
generateUsers = [];
onlineSquareAction = null;
onlineSquareProcessing = false;
}
function resetTopUpModal() {
@@ -463,6 +472,8 @@
paymentResult = null;
cardMachineItemID = null;
idempotencyKey = '';
onlineSquareAction = null;
onlineSquareProcessing = false;
}
// =============== Embedded Payment Handlers ===============
@@ -594,6 +605,58 @@
setModalStep(actionType, 'error');
}
async function handleEmbeddedOnlineSquarePayment(actionType: 'create' | 'topup', gcId?: string) {
if (!onlineSquareCardInput) return;
onlineSquareProcessing = true;
paymentError = '';
try {
let token: string;
try {
token = await onlineSquareCardInput.tokenize();
} catch (err) {
paymentError = err instanceof Error ? err.message : 'Card entry failed';
setModalStep(actionType, 'error');
return;
}
const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount);
const body: Record<string, unknown> = {
item_type: 'gift_card',
action: actionType,
amount: amt,
payment_method: 'online_square',
card_token: token,
idempotency_key: getIdempotencyKey()
};
if (gcId) body.gift_card_id = gcId;
if (selectedCustomer) body.user_id = selectedCustomer.id;
if (actionType === 'create' && generateType === 'account' && selectedCustomer)
body.redeem_to_user_id = selectedCustomer.id;
const res = await apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (res.ok) {
const data = await res.json();
paymentResult = { ...data };
setModalStep(actionType, 'success');
await fetchGiftCards();
} else {
paymentError = await res.text();
setModalStep(actionType, 'error');
}
} catch {
paymentError = 'Network error processing online card payment';
setModalStep(actionType, 'error');
} finally {
onlineSquareProcessing = false;
onlineSquareAction = null;
}
}
async function handleEmbeddedGiveawayTopUp(gcId: string) {
topUpStep = 'processing';
processingMessage = 'Processing on-the-house top-up...';
@@ -1810,12 +1873,44 @@
</svg>
Cash
</button>
<div class="sm:col-span-2">
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please take payment by card machine or cash."
/>
</div>
{#if isSquareConfigured()}
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
onclick={() => (onlineSquareAction = 'create')}
>
<svg
class="mx-auto mb-2 h-8 w-8 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="2" y="5" width="20" height="14" rx="2" />
<line x1="2" y1="10" x2="22" y2="10" />
</svg>
Online Card
</button>
{/if}
</div>
{#if onlineSquareAction === 'create'}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<SquareCardInput
bind:this={onlineSquareCardInput}
onReady={(r) => (onlineSquareCardReady = r)}
/>
<Button
class="mt-3 w-full"
variant="outline"
onclick={() => handleEmbeddedOnlineSquarePayment('create')}
disabled={onlineSquareProcessing || !onlineSquareCardReady}
loading={onlineSquareProcessing}
>
{onlineSquareProcessing ? 'Processing...' : 'Pay by Card'}
</Button>
</div>
{/if}
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (generateStep = 'amount_email')}>Back</Button>
@@ -2049,12 +2144,45 @@
</svg>
Cash
</button>
<div class="sm:col-span-2">
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please take payment by card machine or cash."
/>
</div>
{#if isSquareConfigured()}
<button
type="button"
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
onclick={() => (onlineSquareAction = 'topup')}
>
<svg
class="mx-auto mb-2 h-8 w-8 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="2" y="5" width="20" height="14" rx="2" />
<line x1="2" y1="10" x2="22" y2="10" />
</svg>
Online Card
</button>
{/if}
</div>
{#if onlineSquareAction === 'topup'}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<SquareCardInput
bind:this={onlineSquareCardInput}
onReady={(r) => (onlineSquareCardReady = r)}
/>
<Button
class="mt-3 w-full"
variant="outline"
onclick={() =>
handleEmbeddedOnlineSquarePayment('topup', selectedCardId ?? undefined)}
disabled={onlineSquareProcessing || !onlineSquareCardReady}
loading={onlineSquareProcessing}
>
{onlineSquareProcessing ? 'Processing...' : 'Pay by Card'}
</Button>
</div>
{/if}
</div>
<Modal.Footer>
<Button variant="ghost" onclick={() => (topUpStep = 'amount')}>Back</Button>
@@ -37,6 +37,8 @@
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import { isSquareConfigured } from '$lib/square/square';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
@@ -88,6 +90,13 @@
let paymentMethodsLoading = $state(false);
let selectedPaymentMethod = $state<string | null>(null);
let isProcessingPayment = $state(false);
// New-card mode (Square Web Payments tokenization)
let showNewCardForm = $state(false);
let squareCardReady = $state(false);
let squareCardInput = $state<SquareCardInput | null>(null);
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
// of re-tokenizing (the backend idempotency key dedups).
let depositNonce = $state('');
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
// on the next microtask), so `isProcessingPayment` may not propagate to the
// button's `disabled` binding before a fast second click fires. This non-
@@ -97,7 +106,15 @@
// Payment flow state
let depositPaid = $state(false);
const depositCardFormValid = $derived(selectedPaymentMethod !== null);
// New-card form is active when toggled, or implicitly when there is no saved
// card to pick (guest flow / no saved cards yet).
const newCardMode = $derived(
showNewCardForm || !authStore.isAuthenticated || paymentMethods.length === 0
);
const depositCardFormValid = $derived(
selectedPaymentMethod !== null || (newCardMode && squareCardReady)
);
// VAT registration status from public business info (via shared store)
const vatRegistered = $derived(getBusinessInfo()?.is_vat_registered ?? false);
@@ -267,8 +284,22 @@
isProcessingPayment = true;
paymentAttempted = false;
try {
if (!selectedPaymentMethod) {
toast.error('Please select a saved card');
let newCardToken: string | undefined;
if (selectedPaymentMethod) {
// saved card — nothing to tokenize
} else if (newCardMode && squareCardInput) {
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
if (!depositNonce) {
try {
depositNonce = await squareCardInput.tokenize();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
}
}
newCardToken = depositNonce;
} else {
toast.error('Please select a payment method');
return;
}
await submitAndProceed();
@@ -281,7 +312,7 @@
// Cache the idempotency key per amount+card so a lost-response retry
// reuses it (backend dedups) instead of double-charging.
const cardKey = selectedPaymentMethod;
const cardKey = selectedPaymentMethod ?? `new:${newCardToken ?? ''}`;
if (
!depositIdempotencyKey ||
depositKeyedAmount !== amountCents ||
@@ -296,7 +327,8 @@
payment_type: 'deposit',
amount: amountCents,
idempotency_key: depositIdempotencyKey,
card_id: selectedPaymentMethod
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {})
};
paymentAttempted = true;
@@ -315,6 +347,7 @@
depositIdempotencyKey = '';
depositKeyedAmount = 0;
depositKeyedCard = '';
depositNonce = '';
// Immutable update — avoid mutating the existing object so
// concurrent renders (e.g. a stale fetch) can't observe partial
// state. (See audit: HIGH issue #3 — confirmedBooking mutated
@@ -2270,7 +2303,7 @@
{#each paymentMethods as method (method.id)}
<div
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
method.id
method.id && !showNewCardForm
? 'border-primary bg-primary/5'
: ''}"
>
@@ -2289,27 +2322,62 @@
</div>
<Button
size="sm"
variant={selectedPaymentMethod === method.id ? 'default' : 'outline'}
onclick={() => (selectedPaymentMethod = method.id)}
variant={selectedPaymentMethod === method.id && !showNewCardForm
? 'default'
: 'outline'}
onclick={() => {
selectedPaymentMethod = method.id;
showNewCardForm = false;
depositNonce = '';
}}
>
{selectedPaymentMethod === method.id ? 'Selected' : 'Use this card'}
{selectedPaymentMethod === method.id && !showNewCardForm
? 'Selected'
: 'Use this card'}
</Button>
</div>
{/each}
<Button
size="sm"
variant={showNewCardForm ? 'default' : 'outline'}
onclick={() => {
selectedPaymentMethod = null;
depositNonce = '';
showNewCardForm = !showNewCardForm;
}}
>
Use a new card
</Button>
</div>
</div>
{:else}
{/if}
{#if newCardMode}
<div class="mb-6">
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
/>
{#if isSquareConfigured()}
<SquareCardInput
bind:this={squareCardInput}
onReady={(r) => (squareCardReady = r)}
/>
{:else}
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
/>
{/if}
</div>
{/if}
{:else}
<div class="mb-6">
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please contact the salon to pay by another method."
/>
{#if isSquareConfigured()}
<SquareCardInput
bind:this={squareCardInput}
onReady={(r) => (squareCardReady = r)}
/>
{:else}
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please contact the salon to pay by another method."
/>
{/if}
</div>
{/if}
@@ -1,6 +1,8 @@
<script lang="ts">
import CardBrandIcon from './CardBrandIcon.svelte';
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
import SquareCardInput from './SquareCardInput.svelte';
import { isSquareConfigured } from '$lib/square/square';
export interface SelectableCard {
id: string;
@@ -14,20 +16,20 @@
let {
cards = [],
canSaveCards: _canSaveCards = false,
newCardDisabled = false,
selectedCardId = $bindable(''),
onValidityChange = (_valid: boolean) => {}
}: {
cards?: SelectableCard[];
canSaveCards?: boolean;
newCardDisabled?: boolean;
selectedCardId?: string;
onValidityChange?: (valid: boolean) => void;
} = $props();
// Internal — whether the "use a new card" form is shown.
// When no saved cards exist the form shows by default.
// Whether the "use a new card" form is shown. When no saved cards exist the
// form shows by default; otherwise it is toggled by the "Use a new card" row.
let showNewCardForm = $state(false);
let squareCardReady = $state(false);
let squareCardInput = $state<SquareCardInput | null>(null);
// Auto-select the default saved card when cards first load. Guarded by
// !showNewCardForm so the "Use a new card" click (selectedCardId = '') is
@@ -40,12 +42,26 @@
}
});
// Exposed derived state — same semantics as the other card flows.
const isCardValid = $derived(selectedCardId !== '' && cards.length > 0);
// When no saved cards exist the new-card form shows by default (no toggle).
const newCardMode = $derived(showNewCardForm || cards.length === 0);
// Exposed derived state — valid when a saved card is selected OR the new-card
// Square form is ready to tokenize.
const isCardValid = $derived(
(selectedCardId !== '' && cards.length > 0) || (newCardMode && squareCardReady)
);
$effect(() => {
onValidityChange(isCardValid);
});
/** Tokenizes the new-card form. Returns the cnon:xxx nonce; throws on error. */
export async function tokenize(): Promise<string> {
if (!newCardMode || !squareCardInput) {
throw new Error('No new card form is open');
}
return squareCardInput.tokenize();
}
</script>
{#if cards.length > 0}
@@ -77,35 +93,37 @@
</button>
{/each}
{#if !newCardDisabled}
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = '';
showNewCardForm = true;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
<button
type="button"
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
? 'border-input bg-accent'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => {
selectedCardId = '';
showNewCardForm = !showNewCardForm;
}}
>
<div class="flex items-center gap-3">
<div
class="flex h-8 min-w-12 items-center justify-center rounded border border-dashed border-gray-300 text-xs font-medium text-gray-400"
>
NEW
</div>
{#if showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
{/if}
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
</div>
{#if showNewCardForm}
<span class="text-xs font-semibold text-primary">Selected</span>
{/if}
</button>
</div>
{/if}
{#if newCardDisabled}
{#if cards.length === 0}
<CardEntryUnavailable />
{/if}
{#if newCardMode}
<div class="mt-3">
{#if isSquareConfigured()}
<SquareCardInput bind:this={squareCardInput} onReady={(r) => (squareCardReady = r)} />
{:else}
<CardEntryUnavailable />
{/if}
</div>
{/if}
@@ -0,0 +1,102 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
import { getSquarePayments, isSquareConfigured } from '$lib/square/square';
// Module-level counter keeps the element id stable across server/client render
// (a Math.random() id would mismatch during hydration).
let squareCardIdCounter = 0;
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 ready = $state(false);
let initError = $state<string | null>(null);
let uniqueId = $state(`square-card-${squareCardIdCounter++}`);
async function init() {
if (!isSquareConfigured()) {
initError = 'not_configured';
return;
}
try {
const payments = (await getSquarePayments()) as {
card: () => Promise<{
attach: (selector: string) => Promise<void>;
tokenize: () => Promise<{
status: string;
token?: string;
errors?: Array<{ message?: string; code?: string }>;
}>;
destroy: () => void;
}>;
};
const card = await payments.card();
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(() => {
onReady(ready && !disabled);
});
/** Tokenizes the entered card. Returns the cnon:xxx nonce, throws with a user-facing message. */
export async function tokenize(): Promise<string> {
const card = cardInstance as {
tokenize: () => Promise<{
status: string;
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);
}
</script>
{#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}
@@ -37,6 +37,9 @@
let payKeyedAmount = $state(0);
let payKeyedType = $state('');
let payKeyedCard = $state('');
// Cached nonce for the new-card form: tokenization is one-shot, so a retry
// reuses this token instead of re-tokenizing (backend idempotency dedups).
let newCardNonce = $state('');
let paymentResult = $state<{
id: string;
amount: number;
@@ -50,6 +53,7 @@
let paymentMethodsLoading = $state(false);
let selectedCardId = $state('');
let cardSelectionValid = $state(false);
let cardSelection = $state<CardSelection | null>(null);
let stamps = $state(0);
let useLoyalty = $state(false);
@@ -341,19 +345,35 @@
}
let cardId: string | undefined;
let newCardToken: string | undefined;
if (selectedCardId) {
cardId = selectedCardId;
} else if (cardSelection) {
// New-card mode: tokenize once per attempt, then reuse the cached nonce
// on retry (tokenization is one-shot; the backend idempotency key dedups).
if (!newCardNonce) {
try {
newCardNonce = await cardSelection.tokenize();
} catch (_err) {
status = 'error';
const msg = _err instanceof Error ? _err.message : 'Card entry failed';
error = msg;
toast.error(msg);
return;
}
}
newCardToken = newCardNonce;
} else {
status = 'error';
error = 'Please select a saved card';
toast.error('Please select a saved card');
error = 'Please select a payment method';
toast.error('Please select a payment method');
return;
}
// Cache the idempotency key per amount+type+card so a lost-response
// retry reuses it (backend dedups) instead of double-charging.
const cardKey = cardId ?? '';
const cardKey = cardId ?? `new:${newCardToken ?? ''}`;
if (
!payIdempotencyKey ||
payKeyedAmount !== amountCents ||
@@ -373,7 +393,8 @@
body: JSON.stringify({
amount: amountCents,
payment_type: paymentType,
card_id: cardId,
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: canSaveCards } : {}),
idempotency_key: payIdempotencyKey
})
});
@@ -390,6 +411,7 @@
payKeyedAmount = 0;
payKeyedType = '';
payKeyedCard = '';
newCardNonce = '';
paymentResult = {
id: data.id,
amount: data.amount,
@@ -671,9 +693,9 @@
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
{:else}
<CardSelection
bind:this={cardSelection}
cards={paymentMethods}
{canSaveCards}
newCardDisabled
bind:selectedCardId
onValidityChange={(v) => (cardSelectionValid = v)}
/>