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,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}