diff --git a/.env.example b/.env.example index eae2db3..6cf33f9 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,14 @@ SQUARE_ENVIRONMENT=mock SQUARE_WEBHOOK_SIGNATURE_KEY= SQUARE_WEBHOOK_NOTIFICATION_URL= +# Frontend (public — safe for the browser). Square Web Payments SDK: +# VITE_SQUARE_APPLICATION_ID — client-side application ID (sandbox IDs start with "sandbox-") +# VITE_SQUARE_LOCATION_ID — Square location ID +# VITE_SQUARE_ENVIRONMENT — 'sandbox' | 'production' (optional; derived from the app ID prefix when omitted) +VITE_SQUARE_APPLICATION_ID= +VITE_SQUARE_LOCATION_ID= +VITE_SQUARE_ENVIRONMENT= + # Test Database (separate from main DB) # Used by testutils/testdb for running tests without corrupting dev data TEST_DB_HOST=localhost diff --git a/README.md b/README.md index 1bd796f..c075269 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL **Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: Centralised cron scheduler (`backend/internal/jobs/`) runs 21 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, staged default hours auto-apply, GDPR anonymization, financial aggregation, and token/code cleanup. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation (`closing_time.go`) resolves both current and staged default hours. -**Payments**: Square Terminal (in-person, via `CreateTerminalCheckout`) + online card payments via saved cards; new-card entry is tokenized through Square Web Payments SDK nonces (`cnon:`) and gated until nonces are available (backlog P11 — see `obsidian/Crussell/plans/p11-square-web-payments-sdk.md`). The backend accepts only tokens, never raw PANs (PCI-DSS parity, mirrored in the dev mock). Cash with change calculation. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment >20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first 50% of each payment is always carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. A PostgreSQL `pg_advisory_lock` serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record (same-key retries reuse it). +**Payments**: Square Terminal (in-person, via `CreateTerminalCheckout`) + online card payments via saved cards or new cards tokenized through the Square Web Payments SDK (`cnon:` nonces — gated off in local dev until `VITE_SQUARE_APPLICATION_ID`/`VITE_SQUARE_LOCATION_ID` are set). The backend accepts only tokens, never raw PANs (PCI-DSS parity, mirrored in the dev mock). Cash with change calculation. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment >20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first 50% of each payment is always carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. A PostgreSQL `pg_advisory_lock` serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record (same-key retries reuse it). **Gift Cards**: Multi-method purchase (cash, card machine, online card, giveaway). Inventory cards for stock management. 24-month rolling expiry. Idle account cleanup (2yr/5yr thresholds). Expired balance recovery with admin audit trail. Transaction audit log. Idempotency keys for purchases. diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 99ea21c..8385667 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -14,6 +14,10 @@ declare global { __walkInModalCountdownInterval?: ReturnType; __bookingCreateCountdownInterval?: ReturnType; __bookingFlowCountdownInterval?: ReturnType | null; + /** Square Web Payments SDK global, present after square.js loads. */ + Square?: { + payments: (appId: string, locationId: string) => unknown; + }; } } diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 8c2de45..0452052 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -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([]); let tipLoadingCards = $state(false); let tipSelectedCardId = $state(null); + let tipShowNewCardForm = $state(false); + let tipSquareCardReady = $state(false); + let tipSquareCardInput = $state(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 = { 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 ? `

VAT is included at ${biz?.default_vat_rate ?? 20} customTipInput = ''; tipIdempotencyKey = ''; tipKeyedAmount = 0; + tipSelectedCardId = null; + tipShowNewCardForm = false; + tipNonce = ''; } }} > @@ -1150,10 +1175,14 @@ ${hasVAT ? `

VAT is included at ${biz?.default_vat_rate ?? 20} {/each} + {:else} - + {#if isSquareConfigured()} + (tipSquareCardReady = r)} + /> + {:else} + + {/if} + {/if} + + {#if tipSavedCards.length > 0 && tipShowNewCardForm} + {#if isSquareConfigured()} + (tipSquareCardReady = r)} + /> + {:else} + + {/if} {/if} diff --git a/frontend/src/lib/components/admin/GiftCardsManagement.svelte b/frontend/src/lib/components/admin/GiftCardsManagement.svelte index af268ca..6b00b70 100644 --- a/frontend/src/lib/components/admin/GiftCardsManagement.svelte +++ b/frontend/src/lib/components/admin/GiftCardsManagement.svelte @@ -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(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(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 = { + 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 @@ Cash -

- -
+ {#if isSquareConfigured()} + + {/if} + + {#if onlineSquareAction === 'create'} +
+ (onlineSquareCardReady = r)} + /> + +
+ {/if} @@ -2049,12 +2144,45 @@ Cash -
- -
+ {#if isSquareConfigured()} + + {/if} + + {#if onlineSquareAction === 'topup'} +
+ (onlineSquareCardReady = r)} + /> + +
+ {/if} diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index d42db55..2594cc7 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -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(null); let isProcessingPayment = $state(false); + // New-card mode (Square Web Payments tokenization) + let showNewCardForm = $state(false); + let squareCardReady = $state(false); + let squareCardInput = $state(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)}
@@ -2289,27 +2322,62 @@
{/each} + - {:else} + {/if} + + {#if newCardMode}
- + {#if isSquareConfigured()} + (squareCardReady = r)} + /> + {:else} + + {/if}
{/if} {:else}
- + {#if isSquareConfigured()} + (squareCardReady = r)} + /> + {:else} + + {/if}
{/if} diff --git a/frontend/src/lib/components/payments/CardSelection.svelte b/frontend/src/lib/components/payments/CardSelection.svelte index 4a7cb7d..009d47a 100644 --- a/frontend/src/lib/components/payments/CardSelection.svelte +++ b/frontend/src/lib/components/payments/CardSelection.svelte @@ -1,6 +1,8 @@ {#if cards.length > 0} @@ -77,35 +93,37 @@ {/each} - {#if !newCardDisabled} - - {/if} + Use a new card + + {#if showNewCardForm} + Selected + {/if} + {/if} -{#if newCardDisabled} - {#if cards.length === 0} - - {/if} +{#if newCardMode} +
+ {#if isSquareConfigured()} + (squareCardReady = r)} /> + {:else} + + {/if} +
{/if} diff --git a/frontend/src/lib/components/payments/SquareCardInput.svelte b/frontend/src/lib/components/payments/SquareCardInput.svelte new file mode 100644 index 0000000..4321fd9 --- /dev/null +++ b/frontend/src/lib/components/payments/SquareCardInput.svelte @@ -0,0 +1,102 @@ + + +{#if initError === 'not_configured'} + +{:else if initError === 'init_failed'} + +{:else} +
+{/if} diff --git a/frontend/src/lib/components/payments/UserPaymentModal.svelte b/frontend/src/lib/components/payments/UserPaymentModal.svelte index d9d7884..b94665d 100644 --- a/frontend/src/lib/components/payments/UserPaymentModal.svelte +++ b/frontend/src/lib/components/payments/UserPaymentModal.svelte @@ -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(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 @@
Loading payment methods...
{:else} (cardSelectionValid = v)} /> diff --git a/frontend/src/lib/square/square.ts b/frontend/src/lib/square/square.ts new file mode 100644 index 0000000..804bd6d --- /dev/null +++ b/frontend/src/lib/square/square.ts @@ -0,0 +1,82 @@ +// Env vars (frontend build-time, public — safe for the browser): +// VITE_SQUARE_APPLICATION_ID Square Web Payments application ID (client-side public) +// VITE_SQUARE_LOCATION_ID Square location ID +// VITE_SQUARE_ENVIRONMENT 'sandbox' | 'production' (optional; auto-derived from +// the application ID prefix when omitted) + +const APP_ID = (import.meta.env.VITE_SQUARE_APPLICATION_ID as string | undefined) ?? ''; +const LOCATION_ID = (import.meta.env.VITE_SQUARE_LOCATION_ID as string | undefined) ?? ''; + +export interface SquareConfig { + appId: string; + locationId: string; +} + +/** True when both Square application + location IDs are configured at build time. */ +export function isSquareConfigured(): boolean { + return APP_ID !== '' && LOCATION_ID !== ''; +} + +export function getSquareConfig(): SquareConfig | null { + if (!isSquareConfigured()) return null; + return { appId: APP_ID, locationId: LOCATION_ID }; +} + +function sdkUrl(): string { + const env = (import.meta.env.VITE_SQUARE_ENVIRONMENT as string | undefined) ?? ''; + const isSandbox = env === 'sandbox' || (APP_ID !== '' && APP_ID.startsWith('sandbox-')); + return isSandbox + ? 'https://sandbox.web.squarecdn.com/v1/square.js' + : 'https://web.squarecdn.com/v1/square.js'; +} + +let sdkPromise: Promise | null = null; + +/** + * Loads the Square.js script once and resolves with the global `Square` object. + * The promise is cached so concurrent card forms share a single script load. + */ +export function loadSquareSdk(): Promise { + if (typeof window === 'undefined') { + return Promise.reject(new Error('Square SDK requires a browser environment')); + } + const win = window as unknown as { Square?: unknown }; + if (win.Square) { + return Promise.resolve(win.Square); + } + if (sdkPromise) return sdkPromise; + + sdkPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = sdkUrl(); + script.async = true; + script.dataset.squareSdk = 'true'; + script.onload = () => { + if (win.Square) { + resolve(win.Square); + } else { + reject(new Error('Square.js loaded but the Square global is missing')); + } + }; + script.onerror = () => { + sdkPromise = null; + reject(new Error('Failed to load Square Web Payments SDK')); + }; + document.head.appendChild(script); + }); + return sdkPromise; +} + +/** Returns `Square.payments(appId, locationId)` once the SDK is loaded. */ +export async function getSquarePayments(): Promise { + const config = getSquareConfig(); + if (!config) { + throw new Error( + 'Square is not configured — set VITE_SQUARE_APPLICATION_ID and VITE_SQUARE_LOCATION_ID' + ); + } + const Square = (await loadSquareSdk()) as { + payments: (appId: string, locationId: string) => unknown; + }; + return Square.payments(config.appId, config.locationId); +} diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 33a8e7e..660eb14 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -5,13 +5,15 @@ import { browser } from '$app/environment'; import { toast } from 'svelte-sonner'; 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 { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe'; import { apiFetch } from '$lib/utils/api'; import UserBookingModal from '$lib/components/account/UserBookingModal.svelte'; import { isValidUKPhone, formatPhoneDisplay, toE164UK } from '$lib/utils/phone'; import { range } from '$lib/utils/format'; import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte'; - import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte'; // zxcvbn-ts imports import { ZxcvbnFactory } from '@zxcvbn-ts/core'; @@ -135,6 +137,41 @@ let cardToDelete = $state(null); let showDeleteCardDialog = $state(false); + // Add-a-Card (Square Web Payments tokenization) + let addCardSquareCardInput = $state(null); + let addCardReady = $state(false); + let addingCard = $state(false); + + async function addCard() { + if (!addCardSquareCardInput) return; + let token: string; + try { + token = await addCardSquareCardInput.tokenize(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Card entry failed'); + return; + } + addingCard = true; + try { + const res = await apiFetch('/api/user/payment-methods', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ card_token: token }) + }); + if (res.ok) { + toast.success('Card saved'); + await savedCardsStore.invalidate(); + } else { + const errText = await res.text(); + toast.error(extractErrorMessage(errText) || 'Failed to add card'); + } + } catch { + toast.error('Network error'); + } finally { + addingCard = false; + } + } + async function deleteCard(card: SavedCard) { try { const res = await apiFetch(`/api/user/payment-methods/${card.id}`, { @@ -168,6 +205,12 @@ let buySelectedCard = $state(''); let buyingGiftCard = $state(false); let purchaseResultCode = $state(null); + let buyShowNewCardForm = $state(false); + let buySquareCardReady = $state(false); + let buySquareCardInput = $state(null); + // Cached nonce: tokenization is one-shot — a retry reuses this token instead + // of re-tokenizing (the backend idempotency key dedups). + let buyNonce = $state(''); // Cached idempotency key: generated once per purchase attempt, reused on // retry (so a lost-response retry dedups instead of double-charging), @@ -177,10 +220,10 @@ let buyKeyedCard = $state(''); $effect(() => { - // Auto-select the default saved card when cards first load. A new card - // cannot be entered online right now (see CardEntryUnavailable), so this - // only ever needs to pick between saved cards. - if (savedCardsStore.cards.length > 0 && !buySelectedCard) { + // Auto-select the default saved card when cards first load. When the + // "Use a new card" toggle is open, selectedCardId is cleared so the + // effect doesn't override the user's choice. + if (savedCardsStore.cards.length > 0 && !buySelectedCard && !buyShowNewCardForm) { const defaultCard = savedCardsStore.cards.find((c) => c.is_default) || savedCardsStore.cards[0]; buySelectedCard = defaultCard.id; @@ -188,7 +231,9 @@ }); // Derived validation for Buy Gift Card form - const isBuyCardValid = $derived(buySelectedCard !== ''); + const isBuyCardValid = $derived( + buySelectedCard !== '' || (buyShowNewCardForm && buySquareCardReady) + ); async function fetchGiftCardBalance() { loadingBalance = true; @@ -235,8 +280,23 @@ } async function buyGiftCard() { - if (!buySelectedCard) { - toast.error('Please select a saved card'); + let newCardToken: string | undefined; + if (buySelectedCard) { + // saved card — nothing to tokenize + } else if (buyShowNewCardForm && buySquareCardInput) { + // New-card mode: tokenize once per attempt, reuse the nonce on retry. + if (!buyNonce) { + try { + buyNonce = await buySquareCardInput.tokenize(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Card entry failed'); + buyingGiftCard = false; + return; + } + } + newCardToken = buyNonce; + } else { + toast.error('Please select a payment method'); buyingGiftCard = false; return; } @@ -248,7 +308,7 @@ // Cache the idempotency key per amount+card so a lost-response retry // reuses the same key (backend dedups) instead of double-charging. // Regenerate when the amount or card changes. - const cardKey = cardId; + const cardKey = cardId ?? `new:${newCardToken ?? ''}`; if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) { buyIdempotencyKey = generateIdempotencyKey(); buyKeyedAmount = buyAmount; @@ -262,7 +322,8 @@ amount: buyAmount * 100, // cents recipient_type: buyRecipientType, recipient_email: buyRecipientEmail, - card_id: cardId, + ...(cardId ? { card_id: cardId } : {}), + ...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {}), idempotency_key: buyIdempotencyKey }) }); @@ -274,6 +335,7 @@ buyIdempotencyKey = ''; buyKeyedAmount = 0; buyKeyedCard = ''; + buyNonce = ''; await fetchGiftCardBalance(); } else { const errText = await res.text(); @@ -1806,44 +1868,60 @@ - {:else if savedCardsStore.cards.length === 0} -
-

No saved cards yet

- -
{:else}
- {#each savedCardsStore.cards as card (card.id)} -
-
- -
-
- **** {card.last_4} -
-
- Expires {String(card.exp_month).padStart(2, '0')}/{card.exp_year} + {#if savedCardsStore.cards.length === 0} +

No saved cards yet

+ {:else} + {#each savedCardsStore.cards as card (card.id)} +
+
+ +
+
+ **** {card.last_4} +
+
+ Expires {String(card.exp_month).padStart(2, '0')}/{card.exp_year} +
+
+ {/each} + {/if} + +
+
Add a new card
+ {#if isSquareConfigured()} + (addCardReady = r)} + /> -
- {/each} - + {:else} + + {/if} +
{/if} @@ -2107,10 +2185,14 @@
- {#if buySelectedCard === card.id} + {#if buySelectedCard === card.id && !buyShowNewCardForm} Selected {/if} {/each} +
- {:else} - + {/if} + + {#if buyShowNewCardForm || savedCardsStore.cards.length === 0} + {#if isSquareConfigured()} + (buySquareCardReady = r)} + /> + {:else} + + {/if} {/if}
diff --git a/frontend/src/routes/pay-tip/[id]/+page.svelte b/frontend/src/routes/pay-tip/[id]/+page.svelte index 2ed64ff..9eae0ca 100644 --- a/frontend/src/routes/pay-tip/[id]/+page.svelte +++ b/frontend/src/routes/pay-tip/[id]/+page.svelte @@ -13,6 +13,8 @@ import { apiFetch } from '$lib/utils/api'; 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'; // Types @@ -53,8 +55,14 @@ // Card selection state let savedCards = $state([]); let selectedCardId = $state(null); + let showNewCardForm = $state(false); + let squareCardReady = $state(false); + let squareCardInput = $state(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 isCardValid = $derived(selectedCardId !== null); + const isCardValid = $derived(selectedCardId !== null || (showNewCardForm && squareCardReady)); // Tip selection state let selectedTip = $state(null); @@ -193,11 +201,21 @@ return; } - if (savedCards.length === 0) { - toast.error('Please add a saved card or contact the salon to pay by another method'); - return; - } - if (!selectedCardId) { + let newCardToken: string | undefined; + if (selectedCardId) { + // saved card — nothing to tokenize + } else if (showNewCardForm && squareCardInput) { + // New-card mode: tokenize once per attempt, reuse the nonce on retry. + if (!tipNonce) { + try { + tipNonce = await squareCardInput.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; } @@ -213,7 +231,8 @@ const body: Record = { amount: amountInPence, idempotency_key: tipIdempotencyKey, - card_id: selectedCardId + ...(selectedCardId ? { card_id: selectedCardId } : {}), + ...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {}) }; const response = await apiFetch(`/api/bookings/${bookingId}/tip`, { @@ -230,6 +249,7 @@ paymentState = 'success'; tipIdempotencyKey = ''; tipKeyedAmount = 0; + tipNonce = ''; toast.success('Thank you for your tip!'); } catch (err) { paymentState = 'error'; @@ -455,10 +475,14 @@ {/each} + {:else} @@ -481,11 +530,24 @@ Payment Method - + {#if isSquareConfigured()} + (squareCardReady = r)} + /> + {:else} + + {/if} {/if} + + {#if savedCards.length > 0 && showNewCardForm} + {#if isSquareConfigured()} + (squareCardReady = r)} /> + {:else} + + {/if} + {/if} diff --git a/frontend/src/routes/tip/+page.svelte b/frontend/src/routes/tip/+page.svelte index bd0859a..25dee11 100644 --- a/frontend/src/routes/tip/+page.svelte +++ b/frontend/src/routes/tip/+page.svelte @@ -8,6 +8,8 @@ import { extractErrorMessage } from '$lib/utils/toast-safe'; 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'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; @@ -59,8 +61,14 @@ // Card selection state (same pattern as UserPaymentModal) let savedCards = $state([]); let selectedCardId = $state(null); + let showNewCardForm = $state(false); + let squareCardReady = $state(false); + let squareCardInput = $state(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 isCardValid = $derived(selectedCardId !== null); + const isCardValid = $derived(selectedCardId !== null || (showNewCardForm && squareCardReady)); let selectedTip = $state(null); let customTip = $state(''); @@ -142,11 +150,21 @@ return; } - if (savedCards.length === 0) { - toast.error('Please add a saved card or contact the salon to pay by another method'); - return; - } - if (!selectedCardId) { + let newCardToken: string | undefined; + if (selectedCardId) { + // saved card — nothing to tokenize + } else if (showNewCardForm && squareCardInput) { + // New-card mode: tokenize once per attempt, reuse the nonce on retry. + if (!tipNonce) { + try { + tipNonce = await squareCardInput.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; } @@ -162,7 +180,8 @@ const body: Record = { amount: amountInPence, idempotency_key: tipIdempotencyKey, - card_id: selectedCardId + ...(selectedCardId ? { card_id: selectedCardId } : {}), + ...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {}) }; const response = await apiFetch(`/api/bookings/${booking.id}/tip`, { @@ -179,6 +198,7 @@ paymentState = 'success'; tipIdempotencyKey = ''; tipKeyedAmount = 0; + tipNonce = ''; toast.success('Thank you for your tip!'); } catch (err) { paymentState = 'error'; @@ -473,10 +493,14 @@ {/each} + - {:else} - + {/if} + + {#if showNewCardForm || savedCards.length === 0} + {#if isSquareConfigured()} + (squareCardReady = r)} + /> + {:else} + + {/if} {/if} diff --git a/obsidian/Crussell/Feature Catalog.md b/obsidian/Crussell/Feature Catalog.md index 1c92323..d7e8352 100644 --- a/obsidian/Crussell/Feature Catalog.md +++ b/obsidian/Crussell/Feature Catalog.md @@ -162,8 +162,8 @@ Multi-method payment system accepting Square (card terminal & online), cash, gif **Related:** [[Booking System|1. Booking System]] (deposits), [[Gift Cards|4. Gift Cards]] (pay by gift card), [[Admin Dashboard|5. Admin Dashboard]] (till purchases) -### 2.1 Online Card Payment (Square — saved cards; new-card entry gated pending P11) -**What it does:** Customers pay online with a card. Saved-card payments work end-to-end via Square tokenized card IDs (`ccof:`). New-card entry is currently gated in the UI (a `CardEntryUnavailable` notice) pending Square Web Payments SDK nonce tokenization (P11) — the backend already accepts `cnon:` nonces everywhere and rejects raw PANs (PCI-DSS parity, mirrored by the dev mock). See `plans/p11-square-web-payments-sdk.md`. Used for deposits, full payments, balance payments, and tips. +### 2.1 Online Card Payment (Square — saved cards or new cards via Web Payments SDK) +**What it does:** Customers pay online with a card. Saved-card payments work via Square tokenized card IDs (`ccof:`); new-card payments are tokenized client-side through the Square Web Payments SDK into `cnon:` nonces and accepted by the backend everywhere. The backend rejects raw PANs (PCI-DSS parity, mirrored by the dev mock). Local dev without Square credentials (`VITE_SQUARE_APPLICATION_ID`/`VITE_SQUARE_LOCATION_ID`) keeps new-card entry gated behind a `CardEntryUnavailable` notice. Used for deposits, full payments, balance payments, and tips. **Layman summary:** "Pay online with your card — just like any online shop." @@ -191,7 +191,7 @@ Multi-method payment system accepting Square (card terminal & online), cash, gif **Related:** [[Gift Cards|4. Gift Cards]], [[VAT Calculation|2.10 VAT Calculation]] ### 2.5 Saved Cards -**What it does:** Customers can save their card details for faster checkout next time. Cards are tokenized via Square (`ccof:` card IDs; the full PAN exists only in Square's vault — our DB stores only the reference + brand/last4/fingerprint). The dev mock mirrors this (raw PANs rejected). Soft-deleted with 7-year UK retention. The "Add Card" flow posts a `card_token` (nonce) to `CreatePaymentMethodFromToken`, which calls `CreateCardOnFile` — the UI is currently gated pending P11 (Web Payments SDK nonces) — see `plans/p11-square-web-payments-sdk.md`. +**What it does:** Customers can save their card details for faster checkout next time. Cards are tokenized via Square (`ccof:` card IDs; the full PAN exists only in Square's vault — our DB stores only the reference + brand/last4/fingerprint). The dev mock mirrors this (raw PANs rejected). Soft-deleted with 7-year UK retention. The "Add Card" flow posts a `card_token` (a Web Payments SDK `cnon:` nonce) to `CreatePaymentMethodFromToken`, which calls `CreateCardOnFile`. When frontend Square credentials are unset (local dev), add-card shows the `CardEntryUnavailable` notice. **Layman summary:** "Save your card for next time — one-click payment." diff --git a/obsidian/Crussell/Future Work - Gap Backlog.md b/obsidian/Crussell/Future Work - Gap Backlog.md index 5adb0c8..a1dea95 100644 --- a/obsidian/Crussell/Future Work - Gap Backlog.md +++ b/obsidian/Crussell/Future Work - Gap Backlog.md @@ -35,7 +35,7 @@ These are things that work fine in dev (with mocks) but need real implementation | P8 | **Social auth stubs (Google/Microsoft/Facebook)** | L (2-3d) | Backend + Frontend | `handlers/auth/social.go` is 1 line (`package auth`). Frontend login page has 3 social buttons that show `toast.info("${provider} login coming soon")`. The `user_social_logins` table and `account_type` enum values exist from early schema design. | The schema was designed for social auth from the start (table + enum values). The OAuth flow itself was never implemented. Buttons exist as UI placeholders. | | P9 | **Tip payments: replace placeholder card tokens** | S (1d) | Frontend | ✅ COMPLETED July 2026 — `card_token: 'placeholder'` replaced with real saved card selection + CardInput with Luhn/expiry/CVC validation across all 3 tip pages. | | | P10 | **No automated database backups** | M (1d) | Infrastructure | PostgreSQL volume is persistent in Docker but no `pg_dump` cron, no point-in-time recovery. | Standard production DB setup task. | -| P11 | **Square Web Payments SDK: re-enable new-card entry with nonce-based flow** | S-M (2-3d) | Frontend | Backend groundwork is DONE (Aug 2026): `CreateCardOnFileRaw` deleted; all card-creation paths (`CreatePaymentMethodFromToken`, till `online_square`, tip/booking/gift-card `new_card_token`) accept `cnon:`/`ccof:` tokens via `CreateCardOnFile`. The frontend no longer sends raw PAN anywhere — new-card entry is **gated** behind `CardEntryUnavailable` (saved-card payments work). Remaining work is frontend-only: load the Web Payments SDK, create a `SquareCardInput` (the old hand-rolled `CardInput.svelte` was deleted), tokenize to `cnon:xxx`, and re-enable the 8 gated flows (tips ×3, booking payment, deposit, Buy a Gift Card, account Add Card, till `online_square`). See `plans/p11-square-web-payments-sdk.md` for the full plan. | **Action plan:** 1) Load Square Web Payments SDK (script tag in `app.html` or `@square/web-payments-sdk` npm). 2) Create `SquareCardInput.svelte` using `payments.card()` + `card.tokenize()`. 3) Send only the nonce as `new_card_token` / `card_token` in each flow. 4) Set `newCardDisabled={false}` / remove the `CardEntryUnavailable` gate. 5) Re-enable admin till `online_square` and account Add Card. 6) Update docs. | +| P11 | **Square Web Payments SDK: re-enable new-card entry with nonce-based flow** | S-M (2-3d) | Frontend | ✅ **COMPLETED Aug 2026** — `SquareCardInput.svelte` tokenizes cards to `cnon:` nonces via the Web Payments SDK (env-gated on `VITE_SQUARE_APPLICATION_ID`/`VITE_SQUARE_LOCATION_ID`); all 8 flows re-enabled (tips ×3, booking payment, deposit, Buy a Gift Card, account Add Card, till `online_square`); `CardEntryUnavailable` kept only as the no-credentials fallback. See `plans/p11-square-web-payments-sdk.md`. | | --- diff --git a/obsidian/Crussell/Technical Manual.md b/obsidian/Crussell/Technical Manual.md index 1eb0d4a..bd0be4c 100644 --- a/obsidian/Crussell/Technical Manual.md +++ b/obsidian/Crussell/Technical Manual.md @@ -50,7 +50,7 @@ Backend (:8080) |---------|--------|---------| | SabreDAV (CardDAV/CalDAV) | Active | Contact sync (profile photos), calendar events | | S3/R2 | Active (dev) | Portfolio images (AVIF), profile pictures (WebP) | -| Square | **Active** | Payment processing — in-person Terminal (`CreateTerminalCheckout`) + online card payments (saved cards working; new-card entry gated pending Square Web Payments SDK nonce integration — backlog P11, see `plans/p11-square-web-payments-sdk.md`). Backend accepts only `cnon:`/`ccof:` tokens (raw PANs rejected). Dev mock (`//go:build dev`) mirrors production PCI-DSS behaviour; prod client (`!dev`) connects to live API. | +| Square | **Active** | Payment processing — in-person Terminal (`CreateTerminalCheckout`) + online card payments (saved cards + new cards tokenized via the Square Web Payments SDK `cnon:` nonces; new-card entry is gated only when the frontend Square env vars are unset — see `plans/p11-square-web-payments-sdk.md`). Backend accepts only `cnon:`/`ccof:` tokens (raw PANs rejected). Dev mock (`//go:build dev`) mirrors production PCI-DSS behaviour; prod client (`!dev`) connects to live API. | | SMTP | Not implemented | Email/SMS notifications — backend not wired | --- diff --git a/obsidian/Crussell/plans/p11-square-web-payments-sdk.md b/obsidian/Crussell/plans/p11-square-web-payments-sdk.md index 8f3432c..695f356 100644 --- a/obsidian/Crussell/plans/p11-square-web-payments-sdk.md +++ b/obsidian/Crussell/plans/p11-square-web-payments-sdk.md @@ -1,6 +1,6 @@ # P11 — Square Web Payments SDK Implementation Plan -**Status:** READY TO PICK UP (updated August 2026 — revised after the P0/P1/P3 payment-safety work landed) +**Status:** ✅ COMPLETE (implemented August 2026 — all 8 flows re-enabled; new-card entry tokenized via `cnon:` nonces) **Owner:** Agent implementing P11 (Square Web Payments SDK) **Estimated effort:** 2-3 days (backend groundwork already landed; this is now a frontend-only integration) **Backlog reference:** `Future Work - Gap Backlog.md` item P11 @@ -9,29 +9,29 @@ ## Executive Summary -New-card entry is currently **gated off** in the UI (a deliberate interim state after the PCI-DSS parity work): every "Use a new card" path shows a `CardEntryUnavailable` notice because the frontend has no way to produce a Square nonce. Saved-card payments work end-to-end. The **backend is fully P11-ready** — it already accepts `cnon:`/`ccof:` tokens everywhere (`CreateCardOnFileRaw` is deleted; all card-creation paths call `CreateCardOnFile` with a token). **This plan re-enables new-card payments** by integrating Square's Web Payments SDK client-side to generate `cnon:xxx` nonces, then removing the gating. +New-card entry is **tokenized via the Square Web Payments SDK** (`cnon:` nonces) across all 8 flows. The **backend was already P11-ready** — it accepts `cnon:`/`ccof:` tokens everywhere. This plan re-enabled new-card payments by integrating Square's Web Payments SDK client-side to generate `cnon:xxx` nonces, then removing the gating. --- ## Current State (verified August 2026) -### Frontend — new-card entry is GATED (not sending raw PAN): -No `new_card_token` / `card_number` / `card_cvc` fields remain in any request body. Each flow now has a saved-card list and, when the user has no saved card (or tries to add one), shows `CardEntryUnavailable` (`frontend/src/lib/components/payments/CardEntryUnavailable.svelte`, message in `frontend/src/lib/constants/payments.ts`): +### Frontend — new-card entry is TOKENIZED (no raw PANs anywhere): +All 8 flows now render `SquareCardInput` (`frontend/src/lib/components/payments/SquareCardInput.svelte`), which loads the Square Web Payments SDK (`frontend/src/lib/square/square.ts`, env-gated on `VITE_SQUARE_APPLICATION_ID`/`VITE_SQUARE_LOCATION_ID`) and tokenizes the entered card into a `cnon:xxx` nonce sent as `new_card_token`. The tokenized form is the only card-entry path — there is no raw-PAN fallback. When the SDK env vars are not configured (e.g. local dev), flows keep the `CardEntryUnavailable` notice. -1. `frontend/src/routes/tip/+page.svelte` — tip; saved-card list + `card_id`, gated new-card +1. `frontend/src/routes/tip/+page.svelte` — tip; saved-card list + `card_id`, SquareCardInput for new card 2. `frontend/src/routes/pay-tip/[id]/+page.svelte` — tip; same pattern 3. `frontend/src/lib/components/account/UserBookingModal.svelte` — tip modal; same pattern -4. `frontend/src/lib/components/payments/UserPaymentModal.svelte` — booking payment; uses `CardSelection` with `newCardDisabled` -5. `frontend/src/lib/components/booking/BookingFlow.svelte` — deposit; saved-card list + `card_id`, gated new-card -6. `frontend/src/routes/account/+page.svelte` — Buy a Gift Card; saved-card list + `card_id`, gated new-card -7. `frontend/src/lib/components/admin/GiftCardsManagement.svelte` — admin till; `online_square`/`saved_card` card options removed from the UI (cash / card_machine / on_the_house only) -8. Account "Add a Card" — gated off (no raw-PAN add-card UI remains) +4. `frontend/src/lib/components/payments/UserPaymentModal.svelte` — booking payment; uses `CardSelection` with SquareCardInput +5. `frontend/src/lib/components/booking/BookingFlow.svelte` — deposit; saved-card list + `card_id`, SquareCardInput for new card (incl. guest flow) +6. `frontend/src/routes/account/+page.svelte` — Buy a Gift Card; saved-card list + `card_id`, SquareCardInput for new card +7. `frontend/src/lib/components/admin/GiftCardsManagement.svelte` — admin till; `online_square` option restored (SquareCardInput → `card_token` → till nonce path) +8. Account "Add a Card" — re-enabled with SquareCardInput → `card_token` → `CreatePaymentMethodFromToken` ### `CardSelection.svelte` (the reusable card picker): -`frontend/src/lib/components/payments/CardSelection.svelte` — saved-card list + "Use a new card" toggle + `newCardDisabled` prop that swaps the new-card section for the `CardEntryUnavailable` notice. **Currently used only by UserPaymentModal.** The other flows (tip ×3, account Buy Gift Card, BookingFlow) have their own simpler saved-card lists with `CardEntryUnavailable` — the ~100-line duplicated validation blocks were deleted during the raw-PAN cleanup, so there is far less to consolidate than when this plan was first written. +`frontend/src/lib/components/payments/CardSelection.svelte` — saved-card list + "Use a new card" toggle + SquareCardInput; exposes a `tokenize()` method via `bind:this` that parents call at submit time. Used by UserPaymentModal. The other flows have their own saved-card lists wired to SquareCardInput directly. -### `CardInput.svelte`: -**DELETED.** The hand-rolled card entry form was removed in the P3 cleanup. P11 creates a fresh `SquareCardInput.svelte` from scratch — nothing to migrate. +### `SquareCardInput.svelte` (NEW — P11): +The tokenization component. Loads the SDK, attaches the Square card iframe form, and exposes `tokenize()` returning the `cnon:` nonce (or a user-facing error). One-shot nonce: each flow caches the token and reuses it on retry so a retry does not re-tokenize (the backend idempotency key dedups). ### Backend (already P11-ready — verified): - `backend/internal/square/square_http_client.go` — `createCardOnFileHTTP` accepts a `source_id` token and calls `POST /v2/cards`. Works with `cnon:xxx` nonces. @@ -53,19 +53,13 @@ No `new_card_token` / `card_number` / `card_cvc` fields remain in any request bo --- -## Implementation Steps +## Implementation Steps (ALL COMPLETE) -### Step 1 — Load the Square Web Payments SDK +### Step 1 — Load the Square Web Payments SDK ✅ +- **Chosen**: dynamic script injection (`frontend/src/lib/square/square.ts`) — loads `https://sandbox.web.squarecdn.com/v1/square.js` (sandbox) or `https://web.squarecdn.com/v1/square.js` (prod) lazily when a card form mounts, cached across forms. Env-gated: no `VITE_SQUARE_*` vars → `isSquareConfigured()` returns false and flows keep the `CardEntryUnavailable` fallback. -Two options (pick one): -- **npm**: `@square/web-payments-sdk` — provides `Square.payments(appId, locationId)` -- **script tag**: `` in `app.html` (sandbox) or `https://web.squarecdn.com/v1/square.js` (prod) - -Load based on `SQUARE_ENVIRONMENT` so sandbox/prod use the right URL. - -### Step 2 — Create a Square card form component (`SquareCardInput.svelte`, new) - -The deleted `CardInput.svelte` is replaced by a new Square-backed component: +### Step 2 — Create a Square card form component (`SquareCardInput.svelte`, NEW) ✅ +`frontend/src/lib/components/payments/SquareCardInput.svelte`: ```js const payments = window.Square.payments(appId, locationId); const card = await payments.card(); @@ -74,47 +68,33 @@ await card.attach('#square-card-container'); const tokenResult = await card.tokenize(); // tokenResult.token → "cnon:xxx" ``` +Exposes `tokenize()` (via `bind:this`) returning the nonce; reports readiness via `onReady`. No raw-PAN fallback exists. Local dev without Square credentials keeps the `CardEntryUnavailable` notice. -**Design decision**: create `SquareCardInput.svelte` and swap it into `CardSelection.svelte` (and the gated flows) when a Square app ID is configured. Because the frontend no longer has a raw-PAN fallback form, the gating logic (`newCardDisabled` / `CardEntryUnavailable`) is what the tokenized form replaces — there is no hand-rolled form left to fall back to. Local dev without Square credentials keeps the gated state. - -### Step 3 — Re-enable the payment flows with nonces - -For each gated flow, replace the `CardEntryUnavailable` notice / `newCardDisabled` gate with the tokenized `SquareCardInput` and send the resulting `cnon:xxx` as `new_card_token` (the backend already accepts it in `CreateTipPayment`, `CreateBookingPayment`, `BuyGiftCard`, `CreatePaymentMethodFromToken`, and till `online_square`): - -1. Extend `CardSelection.svelte` to the 5 remaining card UIs (tip ×3, account Buy Gift Card, BookingFlow) OR wire `SquareCardInput` directly into each saved-card list — the duplication is now small (saved-card list only), so either approach is cheap. -2. UserPaymentModal: set `newCardDisabled={false}` and use the tokenized form inside `CardSelection`. -3. Re-enable the account "Add a Card" flow (posts `card_token` → `CreatePaymentMethodFromToken`). -4. Re-enable the admin till `online_square` option (posts `card_token` → till.go nonce path) and remove the `CardEntryUnavailable` notice. -5. `card_expiry`/`card_cvc` are already removed from all request bodies — no work needed. +### Step 3 — Re-enable the payment flows with nonces ✅ +All 8 flows render `SquareCardInput` and send the resulting `cnon:xxx` as `new_card_token` (backend already accepted it in `CreateTipPayment`, `CreateBookingPayment`, `BuyGiftCard`, `CreatePaymentMethodFromToken`, and till `online_square`): +1. `CardSelection.svelte` extended (new-card toggle + SquareCardInput + `tokenize()` method). +2. UserPaymentModal: uses `CardSelection`; calls `tokenize()` in new-card mode; sends `new_card_token` (+ `save_card` when `canSaveCards`). +3. Account "Add a Card" re-enabled: `SquareCardInput` → `card_token` → `CreatePaymentMethodFromToken`. +4. Admin till `online_square` re-enabled: `SquareCardInput` → `card_token` → till.go nonce path (create + topup). +5. `card_expiry`/`card_cvc` already removed from all request bodies — no work needed. ### Step 4 — Migrate the two raw-PAN backend paths ✅ **DONE (previous sessions)** -- `CreatePaymentMethodFromDetails` → replaced by `CreatePaymentMethodFromToken` using `CreateCardOnFile` with the nonce. **Verified at service.go:504-526.** -- Till `online_square` → `CreateCardOnFileRaw` → `CreateCardOnFile` with a nonce. **Verified at till.go:497-509.** - ### Step 5 — Remove `CreateCardOnFileRaw` entirely ✅ **DONE (previous sessions)** -- Deleted from `SquareClient` interface (`types.go`), all 3 implementations (MockClient, ProdClient, devProdClient), and the PCI-block error stubs. -- Tests updated (`TestDevClient_CreateCardOnFileRaw_Rejected_ProdParity` removed; handler tests assert the nonce-based success path). - -### Step 6 — Update docs - -- `README.md` line 9: update the "currently raw-PAN entry in dev only; production nonce integration is backlog item P11" caveat — the current truth is "new-card entry is gated pending P11 nonce integration". -- `Future Work - Gap Backlog.md` P11: mark completed -- `Feature Catalog.md` (lines ~166, ~194): update the "raw PAN entry in the UI is a documented dead end" claims — new-card entry is gated, not raw-PAN -- `Technical Manual.md` (line ~53): update the "new-card entry pending Web Payments SDK nonces" claim — now that P11 lands, say "new-card payments tokenized via Web Payments SDK nonces" +### Step 6 — Update docs ✅ +- `README.md` line 9 updated — new-card entry now tokenized via Web Payments SDK nonces (gated only when frontend Square env vars are absent) +- `Future Work - Gap Backlog.md` P11: marked completed +- `Feature Catalog.md` (lines ~166, ~194): updated — new-card entry is tokenized, not gated +- `Technical Manual.md` (line ~53): updated — new-card payments tokenized via Web Payments SDK nonces --- ## Testing Plan -1. **Unit tests**: - - `square_http_client_test.go`: `createCardOnFileHTTP` request-shape tests via `httptest.Server` (verify `source_id` is the token, `idempotency_key` deterministic sha256, no raw PAN in body). - - Error-path tests for the HTTP client (non-2xx, malformed body). -2. **Integration tests**: - - Handler tests using `cnon:` tokens through the mock (mock accepts `cnon:` nonces) — `TestCreatePaymentMethod_HappyPath` (now asserts 200 with `cnon:visa`) and `TestDevClient_CreateCardOnFile_...` cover the backend; extend to the payment flows (tip/booking/giftcard with a `cnon:` source). - - Add a test: card created with nonce → payment with saved card works. -3. **Manual/sandbox tests** (requires Square sandbox credentials): +1. **Unit tests** (existing — backend unchanged): `square_http_client_test.go` covers `createCardOnFileHTTP` (source_id token, deterministic sha256 idempotency key, no raw PAN); handler tests assert `cnon:` nonce paths (`TestCreatePaymentMethod`, `TestCreateBookingPayment`, `TestCreateTipPayment`, `TestBuyGiftCard`, `TestCreateTillSale_OnlineSquare`). +2. **Frontend verification**: `svelte-check --fail-on-warnings` (0 errors), `eslint .` (0 errors), `npm run build` (adapter-static) all pass. +3. **Manual/sandbox tests** (requires Square sandbox credentials — `VITE_SQUARE_APPLICATION_ID`/`VITE_SQUARE_LOCATION_ID` with `sandbox-` prefix): - Each of the 8 flows: enter new card → tokenize → pay → verify charge in Square dashboard. - Saved-card flow still works. Refund still works. @@ -123,19 +103,19 @@ For each gated flow, replace the `CardEntryUnavailable` notice / `newCardDisable ## Risks / Gotchas - **Square iframe requires HTTPS** — localhost is exempt, but any non-local dev URL needs TLS. -- **Tokenization is one-shot** — a `cnon:` nonce is single-use. The idempotency key logic (cached per payment attempt) handles retries, but a retry must NOT re-tokenize if the first tokenize succeeded and the payment failed — the backend's idempotency dedup handles this, but the frontend should reuse the cached token on retry if the payment record is pending. -- **Do not regress the PCI-DSS parity work** — the backend rejects raw PANs by design; the tokenized form must never fall back to sending PAN/CVC to our server. -- **`CardEntryUnavailable` stays as the offline/dev fallback** — when no Square app ID is configured, flows keep the gated notice rather than breaking. +- **Tokenization is one-shot** — a `cnon:` nonce is single-use. Implemented per the plan: each flow caches the token after the first `tokenize()` and **reuses it on retry** (the backend idempotency key dedups), so a retry does not re-tokenize or double-charge. +- **PCI-DSS parity preserved** — the backend rejects raw PANs by design; the tokenized form never falls back to sending PAN/CVC to our server. +- **`CardEntryUnavailable` stays as the offline/dev fallback** — when no `VITE_SQUARE_*` credentials are configured, flows keep the gated notice rather than breaking. --- -## Definition of Done +## Definition of Done (ALL COMPLETE) -- [ ] Square Web Payments SDK loads (sandbox + prod URLs, env-gated) -- [ ] `SquareCardInput` tokenizes cards → `cnon:xxx` -- [ ] All 8 flows re-enabled to send nonces, not PANs (tip ×3, booking payment, deposit, Buy a Gift Card, account Add Card, admin till `online_square`) -- [ ] `CardEntryUnavailable` notice removed from active flows (kept only as the no-credentials fallback) -- [ ] Backend nonce paths already in place — verified unchanged (Step 4/5 done) -- [ ] All handler tests pass with nonce-based flows -- [ ] Sandbox smoke test: new-card payment succeeds end-to-end -- [ ] Docs updated (README, Gap Backlog, Feature Catalog, Technical Manual) +- [x] Square Web Payments SDK loads (sandbox + prod URLs, env-gated) +- [x] `SquareCardInput` tokenizes cards → `cnon:xxx` +- [x] All 8 flows re-enabled to send nonces, not PANs (tip ×3, booking payment, deposit, Buy a Gift Card, account Add Card, admin till `online_square`) +- [x] `CardEntryUnavailable` kept only as the no-credentials fallback +- [x] Backend nonce paths verified unchanged (Step 4/5 done) +- [x] Frontend checks pass: svelte-check 0 errors, eslint 0 errors, build succeeds +- [ ] Sandbox smoke test (requires Square sandbox credentials): new-card payment succeeds end-to-end +- [x] Docs updated (README, Gap Backlog, Feature Catalog, Technical Manual)