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:
@@ -41,6 +41,14 @@ SQUARE_ENVIRONMENT=mock
|
|||||||
SQUARE_WEBHOOK_SIGNATURE_KEY=
|
SQUARE_WEBHOOK_SIGNATURE_KEY=
|
||||||
SQUARE_WEBHOOK_NOTIFICATION_URL=
|
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)
|
# Test Database (separate from main DB)
|
||||||
# Used by testutils/testdb for running tests without corrupting dev data
|
# Used by testutils/testdb for running tests without corrupting dev data
|
||||||
TEST_DB_HOST=localhost
|
TEST_DB_HOST=localhost
|
||||||
|
|||||||
@@ -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.
|
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
Vendored
+4
@@ -14,6 +14,10 @@ declare global {
|
|||||||
__walkInModalCountdownInterval?: ReturnType<typeof setInterval>;
|
__walkInModalCountdownInterval?: ReturnType<typeof setInterval>;
|
||||||
__bookingCreateCountdownInterval?: ReturnType<typeof setInterval>;
|
__bookingCreateCountdownInterval?: ReturnType<typeof setInterval>;
|
||||||
__bookingFlowCountdownInterval?: ReturnType<typeof setInterval> | null;
|
__bookingFlowCountdownInterval?: ReturnType<typeof setInterval> | null;
|
||||||
|
/** Square Web Payments SDK global, present after square.js loads. */
|
||||||
|
Square?: {
|
||||||
|
payments: (appId: string, locationId: string) => unknown;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
||||||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
||||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.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 { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -149,12 +151,20 @@
|
|||||||
let tipSavedCards = $state<SavedCard[]>([]);
|
let tipSavedCards = $state<SavedCard[]>([]);
|
||||||
let tipLoadingCards = $state(false);
|
let tipLoadingCards = $state(false);
|
||||||
let tipSelectedCardId = $state<string | null>(null);
|
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(
|
const canSaveCards = $derived(
|
||||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
||||||
);
|
);
|
||||||
|
|
||||||
const isTipCardValid = $derived(tipSelectedCardId !== null);
|
const isTipCardValid = $derived(
|
||||||
|
tipSelectedCardId !== null || (tipShowNewCardForm && tipSquareCardReady)
|
||||||
|
);
|
||||||
|
|
||||||
const tipPresets = $derived(
|
const tipPresets = $derived(
|
||||||
selectedBooking
|
selectedBooking
|
||||||
@@ -220,11 +230,21 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tipSavedCards.length === 0) {
|
let newCardToken: string | undefined;
|
||||||
toast.error('Please add a saved card or contact the salon to pay by another method');
|
if (tipSelectedCardId) {
|
||||||
return;
|
// saved card — nothing to tokenize
|
||||||
}
|
} else if (tipShowNewCardForm && tipSquareCardInput) {
|
||||||
if (!tipSelectedCardId) {
|
// 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');
|
toast.error('Please select a payment method');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -239,7 +259,8 @@
|
|||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
amount: Math.round(tipAmount * 100),
|
amount: Math.round(tipAmount * 100),
|
||||||
idempotency_key: tipIdempotencyKey,
|
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`, {
|
const response = await apiFetch(`/api/bookings/${selectedBooking.id}/tip`, {
|
||||||
@@ -254,6 +275,7 @@
|
|||||||
toast.success('Thank you for your tip!');
|
toast.success('Thank you for your tip!');
|
||||||
tipIdempotencyKey = '';
|
tipIdempotencyKey = '';
|
||||||
tipKeyedAmount = 0;
|
tipKeyedAmount = 0;
|
||||||
|
tipNonce = '';
|
||||||
showTipModal = false;
|
showTipModal = false;
|
||||||
fetchBookingDetails();
|
fetchBookingDetails();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1090,6 +1112,9 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
|||||||
customTipInput = '';
|
customTipInput = '';
|
||||||
tipIdempotencyKey = '';
|
tipIdempotencyKey = '';
|
||||||
tipKeyedAmount = 0;
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {tipSelectedCardId ===
|
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-input bg-accent'
|
||||||
: 'border-gray-200 hover:bg-gray-50'}"
|
: '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">
|
<div class="flex items-center gap-3">
|
||||||
<CardBrandIcon brand={card.brand} />
|
<CardBrandIcon brand={card.brand} />
|
||||||
@@ -1164,16 +1193,55 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if tipSelectedCardId === card.id}
|
{#if tipSelectedCardId === card.id && !tipShowNewCardForm}
|
||||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<CardEntryUnavailable
|
{#if isSquareConfigured()}
|
||||||
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
import { EmailInput } from '$lib/components/ui/email-input';
|
import { EmailInput } from '$lib/components/ui/email-input';
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
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 { range } from '$lib/utils/format';
|
||||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
|
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
|
||||||
@@ -152,6 +153,12 @@
|
|||||||
let cardMachineItemID = $state<string | null>(null);
|
let cardMachineItemID = $state<string | null>(null);
|
||||||
let processingMessage = $state('Processing payment...');
|
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
|
// Idempotency Key
|
||||||
let idempotencyKey = $state('');
|
let idempotencyKey = $state('');
|
||||||
|
|
||||||
@@ -450,6 +457,8 @@
|
|||||||
generateEmail = '';
|
generateEmail = '';
|
||||||
generateUserQuery = '';
|
generateUserQuery = '';
|
||||||
generateUsers = [];
|
generateUsers = [];
|
||||||
|
onlineSquareAction = null;
|
||||||
|
onlineSquareProcessing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetTopUpModal() {
|
function resetTopUpModal() {
|
||||||
@@ -463,6 +472,8 @@
|
|||||||
paymentResult = null;
|
paymentResult = null;
|
||||||
cardMachineItemID = null;
|
cardMachineItemID = null;
|
||||||
idempotencyKey = '';
|
idempotencyKey = '';
|
||||||
|
onlineSquareAction = null;
|
||||||
|
onlineSquareProcessing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============== Embedded Payment Handlers ===============
|
// =============== Embedded Payment Handlers ===============
|
||||||
@@ -594,6 +605,58 @@
|
|||||||
setModalStep(actionType, 'error');
|
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) {
|
async function handleEmbeddedGiveawayTopUp(gcId: string) {
|
||||||
topUpStep = 'processing';
|
topUpStep = 'processing';
|
||||||
processingMessage = 'Processing on-the-house top-up...';
|
processingMessage = 'Processing on-the-house top-up...';
|
||||||
@@ -1810,12 +1873,44 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Cash
|
Cash
|
||||||
</button>
|
</button>
|
||||||
<div class="sm:col-span-2">
|
{#if isSquareConfigured()}
|
||||||
<CardEntryUnavailable
|
<button
|
||||||
message="Online card entry is temporarily unavailable. Please take payment by card machine or cash."
|
type="button"
|
||||||
/>
|
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
|
||||||
</div>
|
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>
|
</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>
|
</div>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button variant="ghost" onclick={() => (generateStep = 'amount_email')}>Back</Button>
|
<Button variant="ghost" onclick={() => (generateStep = 'amount_email')}>Back</Button>
|
||||||
@@ -2049,12 +2144,45 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Cash
|
Cash
|
||||||
</button>
|
</button>
|
||||||
<div class="sm:col-span-2">
|
{#if isSquareConfigured()}
|
||||||
<CardEntryUnavailable
|
<button
|
||||||
message="Online card entry is temporarily unavailable. Please take payment by card machine or cash."
|
type="button"
|
||||||
/>
|
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
|
||||||
</div>
|
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>
|
</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>
|
</div>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button variant="ghost" onclick={() => (topUpStep = 'amount')}>Back</Button>
|
<Button variant="ghost" onclick={() => (topUpStep = 'amount')}>Back</Button>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
|
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
|
||||||
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
||||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.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 PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||||
import { POLICY } from '$lib/constants/policy';
|
import { POLICY } from '$lib/constants/policy';
|
||||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||||
@@ -88,6 +90,13 @@
|
|||||||
let paymentMethodsLoading = $state(false);
|
let paymentMethodsLoading = $state(false);
|
||||||
let selectedPaymentMethod = $state<string | null>(null);
|
let selectedPaymentMethod = $state<string | null>(null);
|
||||||
let isProcessingPayment = $state(false);
|
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
|
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
|
||||||
// on the next microtask), so `isProcessingPayment` may not propagate to the
|
// on the next microtask), so `isProcessingPayment` may not propagate to the
|
||||||
// button's `disabled` binding before a fast second click fires. This non-
|
// button's `disabled` binding before a fast second click fires. This non-
|
||||||
@@ -97,7 +106,15 @@
|
|||||||
// Payment flow state
|
// Payment flow state
|
||||||
let depositPaid = $state(false);
|
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)
|
// VAT registration status from public business info (via shared store)
|
||||||
const vatRegistered = $derived(getBusinessInfo()?.is_vat_registered ?? false);
|
const vatRegistered = $derived(getBusinessInfo()?.is_vat_registered ?? false);
|
||||||
@@ -267,8 +284,22 @@
|
|||||||
isProcessingPayment = true;
|
isProcessingPayment = true;
|
||||||
paymentAttempted = false;
|
paymentAttempted = false;
|
||||||
try {
|
try {
|
||||||
if (!selectedPaymentMethod) {
|
let newCardToken: string | undefined;
|
||||||
toast.error('Please select a saved card');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
await submitAndProceed();
|
await submitAndProceed();
|
||||||
@@ -281,7 +312,7 @@
|
|||||||
|
|
||||||
// Cache the idempotency key per amount+card so a lost-response retry
|
// Cache the idempotency key per amount+card so a lost-response retry
|
||||||
// reuses it (backend dedups) instead of double-charging.
|
// reuses it (backend dedups) instead of double-charging.
|
||||||
const cardKey = selectedPaymentMethod;
|
const cardKey = selectedPaymentMethod ?? `new:${newCardToken ?? ''}`;
|
||||||
if (
|
if (
|
||||||
!depositIdempotencyKey ||
|
!depositIdempotencyKey ||
|
||||||
depositKeyedAmount !== amountCents ||
|
depositKeyedAmount !== amountCents ||
|
||||||
@@ -296,7 +327,8 @@
|
|||||||
payment_type: 'deposit',
|
payment_type: 'deposit',
|
||||||
amount: amountCents,
|
amount: amountCents,
|
||||||
idempotency_key: depositIdempotencyKey,
|
idempotency_key: depositIdempotencyKey,
|
||||||
card_id: selectedPaymentMethod
|
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
|
||||||
|
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
paymentAttempted = true;
|
paymentAttempted = true;
|
||||||
@@ -315,6 +347,7 @@
|
|||||||
depositIdempotencyKey = '';
|
depositIdempotencyKey = '';
|
||||||
depositKeyedAmount = 0;
|
depositKeyedAmount = 0;
|
||||||
depositKeyedCard = '';
|
depositKeyedCard = '';
|
||||||
|
depositNonce = '';
|
||||||
// Immutable update — avoid mutating the existing object so
|
// Immutable update — avoid mutating the existing object so
|
||||||
// concurrent renders (e.g. a stale fetch) can't observe partial
|
// concurrent renders (e.g. a stale fetch) can't observe partial
|
||||||
// state. (See audit: HIGH issue #3 — confirmedBooking mutated
|
// state. (See audit: HIGH issue #3 — confirmedBooking mutated
|
||||||
@@ -2270,7 +2303,7 @@
|
|||||||
{#each paymentMethods as method (method.id)}
|
{#each paymentMethods as method (method.id)}
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
|
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'
|
? 'border-primary bg-primary/5'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
@@ -2289,27 +2322,62 @@
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={selectedPaymentMethod === method.id ? 'default' : 'outline'}
|
variant={selectedPaymentMethod === method.id && !showNewCardForm
|
||||||
onclick={() => (selectedPaymentMethod = method.id)}
|
? '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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={showNewCardForm ? 'default' : 'outline'}
|
||||||
|
onclick={() => {
|
||||||
|
selectedPaymentMethod = null;
|
||||||
|
depositNonce = '';
|
||||||
|
showNewCardForm = !showNewCardForm;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use a new card
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{/if}
|
||||||
|
|
||||||
|
{#if newCardMode}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<CardEntryUnavailable
|
{#if isSquareConfigured()}
|
||||||
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<CardEntryUnavailable
|
{#if isSquareConfigured()}
|
||||||
message="Online card entry is temporarily unavailable. Please contact the salon to pay by another method."
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CardBrandIcon from './CardBrandIcon.svelte';
|
import CardBrandIcon from './CardBrandIcon.svelte';
|
||||||
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
|
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
|
||||||
|
import SquareCardInput from './SquareCardInput.svelte';
|
||||||
|
import { isSquareConfigured } from '$lib/square/square';
|
||||||
|
|
||||||
export interface SelectableCard {
|
export interface SelectableCard {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,20 +16,20 @@
|
|||||||
let {
|
let {
|
||||||
cards = [],
|
cards = [],
|
||||||
canSaveCards: _canSaveCards = false,
|
canSaveCards: _canSaveCards = false,
|
||||||
newCardDisabled = false,
|
|
||||||
selectedCardId = $bindable(''),
|
selectedCardId = $bindable(''),
|
||||||
onValidityChange = (_valid: boolean) => {}
|
onValidityChange = (_valid: boolean) => {}
|
||||||
}: {
|
}: {
|
||||||
cards?: SelectableCard[];
|
cards?: SelectableCard[];
|
||||||
canSaveCards?: boolean;
|
canSaveCards?: boolean;
|
||||||
newCardDisabled?: boolean;
|
|
||||||
selectedCardId?: string;
|
selectedCardId?: string;
|
||||||
onValidityChange?: (valid: boolean) => void;
|
onValidityChange?: (valid: boolean) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// Internal — whether the "use a new card" form is shown.
|
// Whether the "use a new card" form is shown. When no saved cards exist the
|
||||||
// When no saved cards exist the form shows by default.
|
// form shows by default; otherwise it is toggled by the "Use a new card" row.
|
||||||
let showNewCardForm = $state(false);
|
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
|
// Auto-select the default saved card when cards first load. Guarded by
|
||||||
// !showNewCardForm so the "Use a new card" click (selectedCardId = '') is
|
// !showNewCardForm so the "Use a new card" click (selectedCardId = '') is
|
||||||
@@ -40,12 +42,26 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Exposed derived state — same semantics as the other card flows.
|
// When no saved cards exist the new-card form shows by default (no toggle).
|
||||||
const isCardValid = $derived(selectedCardId !== '' && cards.length > 0);
|
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(() => {
|
$effect(() => {
|
||||||
onValidityChange(isCardValid);
|
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>
|
</script>
|
||||||
|
|
||||||
{#if cards.length > 0}
|
{#if cards.length > 0}
|
||||||
@@ -77,35 +93,37 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if !newCardDisabled}
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
|
||||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {showNewCardForm
|
? 'border-input bg-accent'
|
||||||
? 'border-input bg-accent'
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
: 'border-gray-200 hover:bg-gray-50'}"
|
onclick={() => {
|
||||||
onclick={() => {
|
selectedCardId = '';
|
||||||
selectedCardId = '';
|
showNewCardForm = !showNewCardForm;
|
||||||
showNewCardForm = true;
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<div
|
||||||
<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"
|
||||||
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
|
||||||
NEW
|
|
||||||
</div>
|
|
||||||
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
|
|
||||||
</div>
|
</div>
|
||||||
{#if showNewCardForm}
|
<span class="animate-pulse text-sm font-medium text-gray-700">Use a new card</span>
|
||||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
</div>
|
||||||
{/if}
|
{#if showNewCardForm}
|
||||||
</button>
|
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if newCardDisabled}
|
{#if newCardMode}
|
||||||
{#if cards.length === 0}
|
<div class="mt-3">
|
||||||
<CardEntryUnavailable />
|
{#if isSquareConfigured()}
|
||||||
{/if}
|
<SquareCardInput bind:this={squareCardInput} onReady={(r) => (squareCardReady = r)} />
|
||||||
|
{:else}
|
||||||
|
<CardEntryUnavailable />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/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 payKeyedAmount = $state(0);
|
||||||
let payKeyedType = $state('');
|
let payKeyedType = $state('');
|
||||||
let payKeyedCard = $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<{
|
let paymentResult = $state<{
|
||||||
id: string;
|
id: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -50,6 +53,7 @@
|
|||||||
let paymentMethodsLoading = $state(false);
|
let paymentMethodsLoading = $state(false);
|
||||||
let selectedCardId = $state('');
|
let selectedCardId = $state('');
|
||||||
let cardSelectionValid = $state(false);
|
let cardSelectionValid = $state(false);
|
||||||
|
let cardSelection = $state<CardSelection | null>(null);
|
||||||
|
|
||||||
let stamps = $state(0);
|
let stamps = $state(0);
|
||||||
let useLoyalty = $state(false);
|
let useLoyalty = $state(false);
|
||||||
@@ -341,19 +345,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cardId: string | undefined;
|
let cardId: string | undefined;
|
||||||
|
let newCardToken: string | undefined;
|
||||||
|
|
||||||
if (selectedCardId) {
|
if (selectedCardId) {
|
||||||
cardId = 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 {
|
} else {
|
||||||
status = 'error';
|
status = 'error';
|
||||||
error = 'Please select a saved card';
|
error = 'Please select a payment method';
|
||||||
toast.error('Please select a saved card');
|
toast.error('Please select a payment method');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the idempotency key per amount+type+card so a lost-response
|
// Cache the idempotency key per amount+type+card so a lost-response
|
||||||
// retry reuses it (backend dedups) instead of double-charging.
|
// retry reuses it (backend dedups) instead of double-charging.
|
||||||
const cardKey = cardId ?? '';
|
const cardKey = cardId ?? `new:${newCardToken ?? ''}`;
|
||||||
if (
|
if (
|
||||||
!payIdempotencyKey ||
|
!payIdempotencyKey ||
|
||||||
payKeyedAmount !== amountCents ||
|
payKeyedAmount !== amountCents ||
|
||||||
@@ -373,7 +393,8 @@
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
amount: amountCents,
|
amount: amountCents,
|
||||||
payment_type: paymentType,
|
payment_type: paymentType,
|
||||||
card_id: cardId,
|
...(cardId ? { card_id: cardId } : {}),
|
||||||
|
...(newCardToken ? { new_card_token: newCardToken, save_card: canSaveCards } : {}),
|
||||||
idempotency_key: payIdempotencyKey
|
idempotency_key: payIdempotencyKey
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -390,6 +411,7 @@
|
|||||||
payKeyedAmount = 0;
|
payKeyedAmount = 0;
|
||||||
payKeyedType = '';
|
payKeyedType = '';
|
||||||
payKeyedCard = '';
|
payKeyedCard = '';
|
||||||
|
newCardNonce = '';
|
||||||
paymentResult = {
|
paymentResult = {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
@@ -671,9 +693,9 @@
|
|||||||
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
|
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
|
||||||
{:else}
|
{:else}
|
||||||
<CardSelection
|
<CardSelection
|
||||||
|
bind:this={cardSelection}
|
||||||
cards={paymentMethods}
|
cards={paymentMethods}
|
||||||
{canSaveCards}
|
{canSaveCards}
|
||||||
newCardDisabled
|
|
||||||
bind:selectedCardId
|
bind:selectedCardId
|
||||||
onValidityChange={(v) => (cardSelectionValid = v)}
|
onValidityChange={(v) => (cardSelectionValid = v)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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<unknown> | 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<unknown> {
|
||||||
|
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<unknown> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -5,13 +5,15 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
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 { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||||
import { apiFetch } from '$lib/utils/api';
|
import { apiFetch } from '$lib/utils/api';
|
||||||
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||||||
import { isValidUKPhone, formatPhoneDisplay, toE164UK } from '$lib/utils/phone';
|
import { isValidUKPhone, formatPhoneDisplay, toE164UK } from '$lib/utils/phone';
|
||||||
import { range } from '$lib/utils/format';
|
import { range } from '$lib/utils/format';
|
||||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
|
|
||||||
|
|
||||||
// zxcvbn-ts imports
|
// zxcvbn-ts imports
|
||||||
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
|
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
|
||||||
@@ -135,6 +137,41 @@
|
|||||||
let cardToDelete = $state<SavedCard | null>(null);
|
let cardToDelete = $state<SavedCard | null>(null);
|
||||||
let showDeleteCardDialog = $state(false);
|
let showDeleteCardDialog = $state(false);
|
||||||
|
|
||||||
|
// Add-a-Card (Square Web Payments tokenization)
|
||||||
|
let addCardSquareCardInput = $state<SquareCardInput | null>(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) {
|
async function deleteCard(card: SavedCard) {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(`/api/user/payment-methods/${card.id}`, {
|
const res = await apiFetch(`/api/user/payment-methods/${card.id}`, {
|
||||||
@@ -168,6 +205,12 @@
|
|||||||
let buySelectedCard = $state('');
|
let buySelectedCard = $state('');
|
||||||
let buyingGiftCard = $state(false);
|
let buyingGiftCard = $state(false);
|
||||||
let purchaseResultCode = $state<string | null>(null);
|
let purchaseResultCode = $state<string | null>(null);
|
||||||
|
let buyShowNewCardForm = $state(false);
|
||||||
|
let buySquareCardReady = $state(false);
|
||||||
|
let buySquareCardInput = $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 buyNonce = $state('');
|
||||||
|
|
||||||
// Cached idempotency key: generated once per purchase attempt, reused on
|
// Cached idempotency key: generated once per purchase attempt, reused on
|
||||||
// retry (so a lost-response retry dedups instead of double-charging),
|
// retry (so a lost-response retry dedups instead of double-charging),
|
||||||
@@ -177,10 +220,10 @@
|
|||||||
let buyKeyedCard = $state('');
|
let buyKeyedCard = $state('');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Auto-select the default saved card when cards first load. A new card
|
// Auto-select the default saved card when cards first load. When the
|
||||||
// cannot be entered online right now (see CardEntryUnavailable), so this
|
// "Use a new card" toggle is open, selectedCardId is cleared so the
|
||||||
// only ever needs to pick between saved cards.
|
// effect doesn't override the user's choice.
|
||||||
if (savedCardsStore.cards.length > 0 && !buySelectedCard) {
|
if (savedCardsStore.cards.length > 0 && !buySelectedCard && !buyShowNewCardForm) {
|
||||||
const defaultCard =
|
const defaultCard =
|
||||||
savedCardsStore.cards.find((c) => c.is_default) || savedCardsStore.cards[0];
|
savedCardsStore.cards.find((c) => c.is_default) || savedCardsStore.cards[0];
|
||||||
buySelectedCard = defaultCard.id;
|
buySelectedCard = defaultCard.id;
|
||||||
@@ -188,7 +231,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Derived validation for Buy Gift Card form
|
// Derived validation for Buy Gift Card form
|
||||||
const isBuyCardValid = $derived(buySelectedCard !== '');
|
const isBuyCardValid = $derived(
|
||||||
|
buySelectedCard !== '' || (buyShowNewCardForm && buySquareCardReady)
|
||||||
|
);
|
||||||
|
|
||||||
async function fetchGiftCardBalance() {
|
async function fetchGiftCardBalance() {
|
||||||
loadingBalance = true;
|
loadingBalance = true;
|
||||||
@@ -235,8 +280,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buyGiftCard() {
|
async function buyGiftCard() {
|
||||||
if (!buySelectedCard) {
|
let newCardToken: string | undefined;
|
||||||
toast.error('Please select a saved card');
|
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;
|
buyingGiftCard = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -248,7 +308,7 @@
|
|||||||
// Cache the idempotency key per amount+card so a lost-response retry
|
// Cache the idempotency key per amount+card so a lost-response retry
|
||||||
// reuses the same key (backend dedups) instead of double-charging.
|
// reuses the same key (backend dedups) instead of double-charging.
|
||||||
// Regenerate when the amount or card changes.
|
// Regenerate when the amount or card changes.
|
||||||
const cardKey = cardId;
|
const cardKey = cardId ?? `new:${newCardToken ?? ''}`;
|
||||||
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
|
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
|
||||||
buyIdempotencyKey = generateIdempotencyKey();
|
buyIdempotencyKey = generateIdempotencyKey();
|
||||||
buyKeyedAmount = buyAmount;
|
buyKeyedAmount = buyAmount;
|
||||||
@@ -262,7 +322,8 @@
|
|||||||
amount: buyAmount * 100, // cents
|
amount: buyAmount * 100, // cents
|
||||||
recipient_type: buyRecipientType,
|
recipient_type: buyRecipientType,
|
||||||
recipient_email: buyRecipientEmail,
|
recipient_email: buyRecipientEmail,
|
||||||
card_id: cardId,
|
...(cardId ? { card_id: cardId } : {}),
|
||||||
|
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {}),
|
||||||
idempotency_key: buyIdempotencyKey
|
idempotency_key: buyIdempotencyKey
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -274,6 +335,7 @@
|
|||||||
buyIdempotencyKey = '';
|
buyIdempotencyKey = '';
|
||||||
buyKeyedAmount = 0;
|
buyKeyedAmount = 0;
|
||||||
buyKeyedCard = '';
|
buyKeyedCard = '';
|
||||||
|
buyNonce = '';
|
||||||
await fetchGiftCardBalance();
|
await fetchGiftCardBalance();
|
||||||
} else {
|
} else {
|
||||||
const errText = await res.text();
|
const errText = await res.text();
|
||||||
@@ -1806,44 +1868,60 @@
|
|||||||
<Skeleton class="h-16 w-full" />
|
<Skeleton class="h-16 w-full" />
|
||||||
<Skeleton class="h-16 w-full" />
|
<Skeleton class="h-16 w-full" />
|
||||||
</div>
|
</div>
|
||||||
{:else if savedCardsStore.cards.length === 0}
|
|
||||||
<div class="space-y-4 py-2">
|
|
||||||
<p class="text-center text-gray-500">No saved cards yet</p>
|
|
||||||
<CardEntryUnavailable
|
|
||||||
message="Online card entry is temporarily unavailable, so new cards cannot be added right now. Please contact the salon to pay by another method."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each savedCardsStore.cards as card (card.id)}
|
{#if savedCardsStore.cards.length === 0}
|
||||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
<p class="py-2 text-center text-gray-500">No saved cards yet</p>
|
||||||
<div class="flex items-center gap-3">
|
{:else}
|
||||||
<CardBrandIcon brand={card.brand} />
|
{#each savedCardsStore.cards as card (card.id)}
|
||||||
<div>
|
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div class="text-sm font-medium">
|
<div class="flex items-center gap-3">
|
||||||
**** {card.last_4}
|
<CardBrandIcon brand={card.brand} />
|
||||||
</div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">
|
<div class="text-sm font-medium">
|
||||||
Expires {String(card.exp_month).padStart(2, '0')}/{card.exp_year}
|
**** {card.last_4}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
Expires {String(card.exp_month).padStart(2, '0')}/{card.exp_year}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
class="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
onclick={() => {
|
||||||
|
cardToDelete = card;
|
||||||
|
showDeleteCardDialog = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="border-t pt-4">
|
||||||
|
<div class="mb-3 text-sm font-medium text-gray-700">Add a new card</div>
|
||||||
|
{#if isSquareConfigured()}
|
||||||
|
<SquareCardInput
|
||||||
|
bind:this={addCardSquareCardInput}
|
||||||
|
onReady={(r) => (addCardReady = r)}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
class="mt-3 w-full"
|
||||||
variant="ghost"
|
onclick={addCard}
|
||||||
class="text-red-600 hover:bg-red-50 hover:text-red-700"
|
disabled={addingCard || !addCardReady}
|
||||||
onclick={() => {
|
loading={addingCard}
|
||||||
cardToDelete = card;
|
|
||||||
showDeleteCardDialog = true;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Remove
|
{addingCard ? 'Adding...' : 'Add Card'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
{:else}
|
||||||
{/each}
|
<CardEntryUnavailable
|
||||||
<CardEntryUnavailable
|
message="Online card entry is temporarily unavailable, so new cards cannot be added right now. Please contact the salon to pay by another method."
|
||||||
message="Online card entry is temporarily unavailable, so new cards cannot be added right now. Please contact the salon to pay by another method."
|
/>
|
||||||
/>
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
@@ -2107,10 +2185,14 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard ===
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buySelectedCard ===
|
||||||
card.id
|
card.id && !buyShowNewCardForm
|
||||||
? 'border-input bg-accent'
|
? 'border-input bg-accent'
|
||||||
: 'border-gray-200 hover:bg-gray-50'}"
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
onclick={() => (buySelectedCard = card.id)}
|
onclick={() => {
|
||||||
|
buySelectedCard = card.id;
|
||||||
|
buyShowNewCardForm = false;
|
||||||
|
buyNonce = '';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<CardBrandIcon brand={card.brand} />
|
<CardBrandIcon brand={card.brand} />
|
||||||
@@ -2121,14 +2203,48 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if buySelectedCard === card.id}
|
{#if buySelectedCard === card.id && !buyShowNewCardForm}
|
||||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {buyShowNewCardForm
|
||||||
|
? 'border-input bg-accent'
|
||||||
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
|
onclick={() => {
|
||||||
|
buySelectedCard = '';
|
||||||
|
buyNonce = '';
|
||||||
|
buyShowNewCardForm = !buyShowNewCardForm;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 buyShowNewCardForm}
|
||||||
|
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{/if}
|
||||||
<CardEntryUnavailable />
|
|
||||||
|
{#if buyShowNewCardForm || savedCardsStore.cards.length === 0}
|
||||||
|
{#if isSquareConfigured()}
|
||||||
|
<SquareCardInput
|
||||||
|
bind:this={buySquareCardInput}
|
||||||
|
onReady={(r) => (buySquareCardReady = r)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<CardEntryUnavailable />
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
import { apiFetch } from '$lib/utils/api';
|
import { apiFetch } from '$lib/utils/api';
|
||||||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
||||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.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 { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@@ -53,8 +55,14 @@
|
|||||||
// Card selection state
|
// Card selection state
|
||||||
let savedCards = $state<SavedCard[]>([]);
|
let savedCards = $state<SavedCard[]>([]);
|
||||||
let selectedCardId = $state<string | null>(null);
|
let selectedCardId = $state<string | null>(null);
|
||||||
|
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 tipNonce = $state('');
|
||||||
|
|
||||||
const isCardValid = $derived(selectedCardId !== null);
|
const isCardValid = $derived(selectedCardId !== null || (showNewCardForm && squareCardReady));
|
||||||
|
|
||||||
// Tip selection state
|
// Tip selection state
|
||||||
let selectedTip = $state<number | null>(null);
|
let selectedTip = $state<number | null>(null);
|
||||||
@@ -193,11 +201,21 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (savedCards.length === 0) {
|
let newCardToken: string | undefined;
|
||||||
toast.error('Please add a saved card or contact the salon to pay by another method');
|
if (selectedCardId) {
|
||||||
return;
|
// saved card — nothing to tokenize
|
||||||
}
|
} else if (showNewCardForm && squareCardInput) {
|
||||||
if (!selectedCardId) {
|
// 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');
|
toast.error('Please select a payment method');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -213,7 +231,8 @@
|
|||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
amount: amountInPence,
|
amount: amountInPence,
|
||||||
idempotency_key: tipIdempotencyKey,
|
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`, {
|
const response = await apiFetch(`/api/bookings/${bookingId}/tip`, {
|
||||||
@@ -230,6 +249,7 @@
|
|||||||
paymentState = 'success';
|
paymentState = 'success';
|
||||||
tipIdempotencyKey = '';
|
tipIdempotencyKey = '';
|
||||||
tipKeyedAmount = 0;
|
tipKeyedAmount = 0;
|
||||||
|
tipNonce = '';
|
||||||
toast.success('Thank you for your tip!');
|
toast.success('Thank you for your tip!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
paymentState = 'error';
|
paymentState = 'error';
|
||||||
@@ -455,10 +475,14 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
|
||||||
card.id
|
card.id && !showNewCardForm
|
||||||
? 'border-input bg-accent'
|
? 'border-input bg-accent'
|
||||||
: 'border-gray-200 hover:bg-gray-50'}"
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
onclick={() => (selectedCardId = card.id)}
|
onclick={() => {
|
||||||
|
selectedCardId = card.id;
|
||||||
|
showNewCardForm = false;
|
||||||
|
tipNonce = '';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<CardBrandIcon brand={card.brand} />
|
<CardBrandIcon brand={card.brand} />
|
||||||
@@ -469,11 +493,36 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if selectedCardId === card.id}
|
{#if selectedCardId === card.id && !showNewCardForm}
|
||||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
<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 = null;
|
||||||
|
tipNonce = '';
|
||||||
|
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>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -481,11 +530,24 @@
|
|||||||
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">
|
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase">
|
||||||
Payment Method
|
Payment Method
|
||||||
</span>
|
</span>
|
||||||
<CardEntryUnavailable
|
{#if isSquareConfigured()}
|
||||||
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
|
<SquareCardInput
|
||||||
/>
|
bind:this={squareCardInput}
|
||||||
|
onReady={(r) => (squareCardReady = r)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<CardEntryUnavailable />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if savedCards.length > 0 && showNewCardForm}
|
||||||
|
{#if isSquareConfigured()}
|
||||||
|
<SquareCardInput bind:this={squareCardInput} onReady={(r) => (squareCardReady = r)} />
|
||||||
|
{:else}
|
||||||
|
<CardEntryUnavailable />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||||
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
import CardBrandIcon from '$lib/components/payments/CardBrandIcon.svelte';
|
||||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.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 { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
@@ -59,8 +61,14 @@
|
|||||||
// Card selection state (same pattern as UserPaymentModal)
|
// Card selection state (same pattern as UserPaymentModal)
|
||||||
let savedCards = $state<SavedCard[]>([]);
|
let savedCards = $state<SavedCard[]>([]);
|
||||||
let selectedCardId = $state<string | null>(null);
|
let selectedCardId = $state<string | null>(null);
|
||||||
|
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 tipNonce = $state('');
|
||||||
|
|
||||||
const isCardValid = $derived(selectedCardId !== null);
|
const isCardValid = $derived(selectedCardId !== null || (showNewCardForm && squareCardReady));
|
||||||
|
|
||||||
let selectedTip = $state<number | null>(null);
|
let selectedTip = $state<number | null>(null);
|
||||||
let customTip = $state('');
|
let customTip = $state('');
|
||||||
@@ -142,11 +150,21 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (savedCards.length === 0) {
|
let newCardToken: string | undefined;
|
||||||
toast.error('Please add a saved card or contact the salon to pay by another method');
|
if (selectedCardId) {
|
||||||
return;
|
// saved card — nothing to tokenize
|
||||||
}
|
} else if (showNewCardForm && squareCardInput) {
|
||||||
if (!selectedCardId) {
|
// 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');
|
toast.error('Please select a payment method');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -162,7 +180,8 @@
|
|||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
amount: amountInPence,
|
amount: amountInPence,
|
||||||
idempotency_key: tipIdempotencyKey,
|
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`, {
|
const response = await apiFetch(`/api/bookings/${booking.id}/tip`, {
|
||||||
@@ -179,6 +198,7 @@
|
|||||||
paymentState = 'success';
|
paymentState = 'success';
|
||||||
tipIdempotencyKey = '';
|
tipIdempotencyKey = '';
|
||||||
tipKeyedAmount = 0;
|
tipKeyedAmount = 0;
|
||||||
|
tipNonce = '';
|
||||||
toast.success('Thank you for your tip!');
|
toast.success('Thank you for your tip!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
paymentState = 'error';
|
paymentState = 'error';
|
||||||
@@ -473,10 +493,14 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
|
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
|
||||||
card.id
|
card.id && !showNewCardForm
|
||||||
? 'border-input bg-accent'
|
? 'border-input bg-accent'
|
||||||
: 'border-gray-200 hover:bg-gray-50'}"
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
onclick={() => (selectedCardId = card.id)}
|
onclick={() => {
|
||||||
|
selectedCardId = card.id;
|
||||||
|
showNewCardForm = false;
|
||||||
|
tipNonce = '';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<CardBrandIcon brand={card.brand} />
|
<CardBrandIcon brand={card.brand} />
|
||||||
@@ -487,16 +511,48 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if selectedCardId === card.id}
|
{#if selectedCardId === card.id && !showNewCardForm}
|
||||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
<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 = null;
|
||||||
|
tipNonce = '';
|
||||||
|
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>
|
||||||
|
<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>
|
</div>
|
||||||
{:else}
|
{/if}
|
||||||
<CardEntryUnavailable
|
|
||||||
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
|
{#if showNewCardForm || savedCards.length === 0}
|
||||||
/>
|
{#if isSquareConfigured()}
|
||||||
|
<SquareCardInput
|
||||||
|
bind:this={squareCardInput}
|
||||||
|
onReady={(r) => (squareCardReady = r)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<CardEntryUnavailable />
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
|
|||||||
@@ -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)
|
**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)
|
### 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 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.
|
**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."
|
**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]]
|
**Related:** [[Gift Cards|4. Gift Cards]], [[VAT Calculation|2.10 VAT Calculation]]
|
||||||
|
|
||||||
### 2.5 Saved Cards
|
### 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."
|
**Layman summary:** "Save your card for next time — one-click payment."
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
| 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. | |
|
| 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. |
|
| 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`. | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Backend (:8080)
|
|||||||
|---------|--------|---------|
|
|---------|--------|---------|
|
||||||
| SabreDAV (CardDAV/CalDAV) | Active | Contact sync (profile photos), calendar events |
|
| SabreDAV (CardDAV/CalDAV) | Active | Contact sync (profile photos), calendar events |
|
||||||
| S3/R2 | Active (dev) | Portfolio images (AVIF), profile pictures (WebP) |
|
| 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 |
|
| SMTP | Not implemented | Email/SMS notifications — backend not wired |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# P11 — Square Web Payments SDK Implementation Plan
|
# 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)
|
**Owner:** Agent implementing P11 (Square Web Payments SDK)
|
||||||
**Estimated effort:** 2-3 days (backend groundwork already landed; this is now a frontend-only integration)
|
**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
|
**Backlog reference:** `Future Work - Gap Backlog.md` item P11
|
||||||
@@ -9,29 +9,29 @@
|
|||||||
|
|
||||||
## Executive Summary
|
## 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)
|
## Current State (verified August 2026)
|
||||||
|
|
||||||
### Frontend — new-card entry is GATED (not sending raw PAN):
|
### Frontend — new-card entry is TOKENIZED (no raw PANs anywhere):
|
||||||
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`):
|
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
|
2. `frontend/src/routes/pay-tip/[id]/+page.svelte` — tip; same pattern
|
||||||
3. `frontend/src/lib/components/account/UserBookingModal.svelte` — tip modal; 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`
|
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`, gated new-card
|
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`, gated new-card
|
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`/`saved_card` card options removed from the UI (cash / card_machine / on_the_house only)
|
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" — gated off (no raw-PAN add-card UI remains)
|
8. Account "Add a Card" — re-enabled with SquareCardInput → `card_token` → `CreatePaymentMethodFromToken`
|
||||||
|
|
||||||
### `CardSelection.svelte` (the reusable card picker):
|
### `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`:
|
### `SquareCardInput.svelte` (NEW — P11):
|
||||||
**DELETED.** The hand-rolled card entry form was removed in the P3 cleanup. P11 creates a fresh `SquareCardInput.svelte` from scratch — nothing to migrate.
|
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 (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.
|
- `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):
|
### Step 2 — Create a Square card form component (`SquareCardInput.svelte`, NEW) ✅
|
||||||
- **npm**: `@square/web-payments-sdk` — provides `Square.payments(appId, locationId)`
|
`frontend/src/lib/components/payments/SquareCardInput.svelte`:
|
||||||
- **script tag**: `<script src="https://sandbox.web.squarecdn.com/v1/square.js" type="text/javascript"></script>` 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:
|
|
||||||
```js
|
```js
|
||||||
const payments = window.Square.payments(appId, locationId);
|
const payments = window.Square.payments(appId, locationId);
|
||||||
const card = await payments.card();
|
const card = await payments.card();
|
||||||
@@ -74,47 +68,33 @@ await card.attach('#square-card-container');
|
|||||||
const tokenResult = await card.tokenize();
|
const tokenResult = await card.tokenize();
|
||||||
// tokenResult.token → "cnon:xxx"
|
// 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 ✅
|
||||||
|
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`):
|
||||||
### Step 3 — Re-enable the payment flows with nonces
|
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`).
|
||||||
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`):
|
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).
|
||||||
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.
|
5. `card_expiry`/`card_cvc` already removed from all request bodies — no work needed.
|
||||||
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 4 — Migrate the two raw-PAN backend paths ✅ **DONE (previous sessions)**
|
### 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)**
|
### 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.
|
### Step 6 — Update docs ✅
|
||||||
- Tests updated (`TestDevClient_CreateCardOnFileRaw_Rejected_ProdParity` removed; handler tests assert the nonce-based success path).
|
- `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
|
||||||
### Step 6 — Update docs
|
- `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
|
||||||
- `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"
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing Plan
|
## Testing Plan
|
||||||
|
|
||||||
1. **Unit tests**:
|
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`).
|
||||||
- `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).
|
2. **Frontend verification**: `svelte-check --fail-on-warnings` (0 errors), `eslint .` (0 errors), `npm run build` (adapter-static) all pass.
|
||||||
- Error-path tests for the HTTP client (non-2xx, malformed body).
|
3. **Manual/sandbox tests** (requires Square sandbox credentials — `VITE_SQUARE_APPLICATION_ID`/`VITE_SQUARE_LOCATION_ID` with `sandbox-` prefix):
|
||||||
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):
|
|
||||||
- Each of the 8 flows: enter new card → tokenize → pay → verify charge in Square dashboard.
|
- Each of the 8 flows: enter new card → tokenize → pay → verify charge in Square dashboard.
|
||||||
- Saved-card flow still works. Refund still works.
|
- Saved-card flow still works. Refund still works.
|
||||||
|
|
||||||
@@ -123,19 +103,19 @@ For each gated flow, replace the `CardEntryUnavailable` notice / `newCardDisable
|
|||||||
## Risks / Gotchas
|
## Risks / Gotchas
|
||||||
|
|
||||||
- **Square iframe requires HTTPS** — localhost is exempt, but any non-local dev URL needs TLS.
|
- **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.
|
- **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.
|
||||||
- **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.
|
- **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 Square app ID is configured, flows keep the gated notice rather than breaking.
|
- **`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)
|
- [x] Square Web Payments SDK loads (sandbox + prod URLs, env-gated)
|
||||||
- [ ] `SquareCardInput` tokenizes cards → `cnon:xxx`
|
- [x] `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`)
|
- [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`)
|
||||||
- [ ] `CardEntryUnavailable` notice removed from active flows (kept only as the no-credentials fallback)
|
- [x] `CardEntryUnavailable` kept only as the no-credentials fallback
|
||||||
- [ ] Backend nonce paths already in place — verified unchanged (Step 4/5 done)
|
- [x] Backend nonce paths verified unchanged (Step 4/5 done)
|
||||||
- [ ] All handler tests pass with nonce-based flows
|
- [x] Frontend checks pass: svelte-check 0 errors, eslint 0 errors, build succeeds
|
||||||
- [ ] Sandbox smoke test: new-card payment succeeds end-to-end
|
- [ ] Sandbox smoke test (requires Square sandbox credentials): new-card payment succeeds end-to-end
|
||||||
- [ ] Docs updated (README, Gap Backlog, Feature Catalog, Technical Manual)
|
- [x] Docs updated (README, Gap Backlog, Feature Catalog, Technical Manual)
|
||||||
|
|||||||
Reference in New Issue
Block a user