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:
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user