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
@@ -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)}
/>