Add dev-only frontend Square mock mode for as-if-live walkthroughs

VITE_SQUARE_ENVIRONMENT=mock renders a plain HTML card form (MockCardForm)
instead of the Square Web Payments SDK iframe, minting the same cnon: tokens
the backend dev mock accepts — all 8 payment flows run end-to-end locally with
zero credentials.

- isSquareMock() gated on import.meta.env.DEV: structurally impossible in a
  production build even if the env var is mis-set
- MockCardForm: Luhn/brand/expiry/CVC validation, Amex 15-digit + 4-digit CVC,
  error states, disabled propagation — mirrors the real form's onReady contract
  so CardSelection.isCardValid and submit guards behave identically
- tokenize() maps typed card -> deterministic cnon: token matching backend
  detectCardInfo (4242->test-card, 4111->visa, 5555->mastercard, 3782->amex)
- lazy-loaded via dynamic import: mock code ships in its own chunk, referenced
  only from the mock branch, never statically imported into the main bundle
- docs: .env.example (mock pairing with SQUARE_ENVIRONMENT=mock), P11 plan
  (mock opt-in + canonical-last4 caveat), Feature Catalog (2.1, 2.5)
- prettier formatting fixes in 10 unrelated files (line wrapping only)
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 7439fa86c1
commit 52e2bfff55
15 changed files with 378 additions and 71 deletions
+4 -2
View File
@@ -45,10 +45,12 @@ 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_ENVIRONMENT — 'mock' | 'sandbox' | 'production'. Local dev: 'mock' renders the
# frontend's built-in mock card form (tokens only; pairs with
# SQUARE_ENVIRONMENT=mock above). NEVER set 'mock' in production.
VITE_SQUARE_APPLICATION_ID=
VITE_SQUARE_LOCATION_ID=
VITE_SQUARE_ENVIRONMENT=
VITE_SQUARE_ENVIRONMENT=mock
# Test Database (separate from main DB)
# Used by testutils/testdb for running tests without corrupting dev data
@@ -335,7 +335,9 @@
onApproved();
} else {
const text = await response.text();
toast.error('Failed to confirm: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
toast.error('Failed to confirm: ' + sanitizeText(extractErrorMessage(text)), {
id: loadingToast
});
}
} catch {
toast.error('Network error confirming booking', { id: loadingToast });
@@ -363,7 +365,9 @@
onApproved();
} else {
const text = await response.text();
toast.error('Failed to decline: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
toast.error('Failed to decline: ' + sanitizeText(extractErrorMessage(text)), {
id: loadingToast
});
}
} catch {
toast.error('Network error declining booking', { id: loadingToast });
@@ -279,7 +279,9 @@
toast.error(`Validation error: ${extractErrorMessage(errorText)}`, { id: loadingToast });
} else {
const errorText = await response.text();
toast.error(`Failed to create service: ${extractErrorMessage(errorText)}`, { id: loadingToast });
toast.error(`Failed to create service: ${extractErrorMessage(errorText)}`, {
id: loadingToast
});
}
} catch (err) {
console.error('Error creating service:', err);
@@ -448,7 +448,9 @@
await fetchBlockers();
} else {
const text = await response.text();
toast.error('Failed to create: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
toast.error('Failed to create: ' + sanitizeText(extractErrorMessage(text)), {
id: loadingToast
});
}
} catch (err) {
console.error('Error creating time blocker:', err);
@@ -475,7 +477,9 @@
await fetchBlockers();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
toast.error('Failed to delete: ' + sanitizeText(extractErrorMessage(text)), {
id: loadingToast
});
}
} catch (err) {
console.error('Error deleting time blocker:', err);
@@ -872,7 +876,12 @@
? ''
: 's'} with this slot
</span>
<Button variant="outline" size="sm" class="h-6 text-xs ml-auto" onclick={checkOverlappingBookings}>
<Button
variant="outline"
size="sm"
class="h-6 text-xs ml-auto"
onclick={checkOverlappingBookings}
>
Refresh
</Button>
</div>
@@ -23,16 +23,14 @@
type AvailabilityState = 'sleeping' | 'with_client' | 'busy' | 'prepping' | 'available';
const STATUS_MAP: Record<
AvailabilityState,
{ label: string; color: 'green' | 'amber' | 'red' }
> = {
sleeping: { label: 'Unable to take calls right now', color: 'red' },
with_client: { label: 'With a client', color: 'red' },
busy: { label: 'Busy', color: 'amber' },
prepping: { label: 'Prepping for a client', color: 'amber' },
available: { label: 'Available to contact', color: 'green' }
};
const STATUS_MAP: Record<AvailabilityState, { label: string; color: 'green' | 'amber' | 'red' }> =
{
sleeping: { label: 'Unable to take calls right now', color: 'red' },
with_client: { label: 'With a client', color: 'red' },
busy: { label: 'Busy', color: 'amber' },
prepping: { label: 'Prepping for a client', color: 'amber' },
available: { label: 'Available to contact', color: 'green' }
};
let availabilityState = $state<AvailabilityState | null>(null);
@@ -59,9 +57,7 @@
}
}
let contactStatus = $derived(
availabilityState ? STATUS_MAP[availabilityState] : null
);
let contactStatus = $derived(availabilityState ? STATUS_MAP[availabilityState] : null);
</script>
<div class="mx-auto h-full max-w-sm">
@@ -91,8 +87,8 @@
class="font-medium"
class:text-green-700={contactStatus.color === 'green'}
class:text-amber-700={contactStatus.color === 'amber'}
class:text-red-700={contactStatus.color === 'red'}
>{contactStatus.label}</span>
class:text-red-700={contactStatus.color === 'red'}>{contactStatus.label}</span
>
</div>
{/if}
@@ -38,7 +38,9 @@
<section class="w-full overflow-hidden py-16">
<div class="mx-auto max-w-6xl px-6">
<h2 class="mb-12 text-center font-['Playfair_Display'] text-2xl font-semibold">Our Portfolio</h2>
<h2 class="mb-12 text-center font-['Playfair_Display'] text-2xl font-semibold">
Our Portfolio
</h2>
</div>
<div class="relative w-full overflow-hidden">
@@ -0,0 +1,253 @@
<!-- DEV-ONLY mock card form — enabled only by VITE_SQUARE_ENVIRONMENT === 'mock'
(never in production). The PAN lives only in local component state; only a
cnon: token is ever returned. -->
<script lang="ts">
import CardBrandIcon from './CardBrandIcon.svelte';
// Deterministic token mapping the backend dev mock (square_dev.go
// detectCardInfo) resolves back to the brand/last4 the user typed.
const MOCK_TOKENS: Record<string, string> = {
'4242': 'cnon:test-card',
'4111': 'cnon:visa',
'5555': 'cnon:mastercard',
'3782': 'cnon:amex'
};
let {
disabled = false,
onReady = () => {}
}: { disabled?: boolean; onReady?: (ready: boolean) => void } = $props();
let cardNumber = $state('');
let expiry = $state('');
let cvc = $state('');
let cardholderName = $state('');
let cardNumberTouched = $state(false);
let expiryTouched = $state(false);
let cvcTouched = $state(false);
// Per-instance ids so two mounted forms never collide on the same id. Pure
// SPA (no SSR/hydration), so a random id cannot mismatch.
const cardNumberId = `mock-card-number-${crypto.randomUUID()}`;
const expiryId = `mock-card-exp-${crypto.randomUUID()}`;
const cvcId = `mock-card-cvc-${crypto.randomUUID()}`;
const nameId = `mock-card-name-${crypto.randomUUID()}`;
const inputClasses =
'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20';
const digits = $derived(cardNumber.replace(/\D/g, ''));
const detectedBrand = $derived.by(() => {
if (digits.startsWith('4')) return 'VISA';
if (digits.startsWith('34') || digits.startsWith('37')) return 'AMERICAN_EXPRESS';
if (digits.length >= 2) {
const two = Number(digits.slice(0, 2));
if (two >= 51 && two <= 55) return 'MASTERCARD';
}
if (digits.length >= 4) {
const four = Number(digits.slice(0, 4));
if (four >= 2221 && four <= 2720) return 'MASTERCARD';
}
return '';
});
function formatNumber(raw: string): string {
const isAmex = raw.startsWith('34') || raw.startsWith('37');
const d = raw.replace(/\D/g, '').slice(0, isAmex ? 15 : 16);
if (isAmex) {
if (d.length <= 4) return d;
if (d.length <= 10) return `${d.slice(0, 4)} ${d.slice(4)}`;
return `${d.slice(0, 4)} ${d.slice(4, 10)} ${d.slice(10)}`;
}
return d.replace(/(.{4})/g, '$1 ').trim();
}
function luhnValid(value: string): boolean {
let sum = 0;
let double = false;
for (let i = value.length - 1; i >= 0; i--) {
let digit = value.charCodeAt(i) - 48;
if (double) {
digit *= 2;
if (digit > 9) digit -= 9;
}
sum += digit;
double = !double;
}
return sum % 10 === 0;
}
const numberLuhnValid = $derived(digits.length >= 12 && luhnValid(digits));
const expiryDigits = $derived(expiry.replace(/\D/g, ''));
const expiryMonth = $derived(Number(expiryDigits.slice(0, 2)));
const expiryYear = $derived(Number(expiryDigits.slice(2)));
const expiryValid = $derived.by(() => {
if (expiryDigits.length !== 4) return false;
if (expiryMonth < 1 || expiryMonth > 12) return false;
const now = new Date();
const currentYear = now.getFullYear() % 100;
const currentMonth = now.getMonth() + 1;
if (expiryYear < currentYear) return false;
if (expiryYear === currentYear && expiryMonth < currentMonth) return false;
return true;
});
const cvcValid = $derived(cvc.length === (detectedBrand === 'AMERICAN_EXPRESS' ? 4 : 3));
// Mirrors the real form's onReady(ready && !disabled).
const complete = $derived(numberLuhnValid && expiryValid && cvcValid && !disabled);
$effect(() => {
onReady(complete);
});
const cardNumberError = $derived.by(() => {
if (!cardNumberTouched) return '';
if (digits.length === 0) return 'Card number is required';
if (digits.length < 12) return 'Card number is too short';
if (!luhnValid(digits)) return 'Card number is invalid';
return '';
});
const expiryError = $derived.by(() => {
if (!expiryTouched) return '';
if (expiryDigits.length === 0) return 'Expiry date is required';
if (expiryDigits.length < 4) return 'Enter a valid expiry date';
if (expiryMonth < 1 || expiryMonth > 12) return 'Invalid month';
return expiryValid ? '' : 'Card has expired';
});
const cvcError = $derived.by(() => {
if (!cvcTouched) return '';
if (cvc.length === 0) return 'Security code is required';
const expected = detectedBrand === 'AMERICAN_EXPRESS' ? 4 : 3;
return cvc.length === expected ? '' : `Security code must be ${expected} digits`;
});
let lastExpiry = '';
function onCardNumberInput(e: Event) {
const raw = (e.currentTarget as HTMLInputElement).value;
cardNumber = formatNumber(raw);
cardNumberTouched = true;
}
function onExpiryInput(e: Event) {
const raw = (e.currentTarget as HTMLInputElement).value;
const deleting = raw.length < lastExpiry.length;
lastExpiry = raw;
const d = raw.replace(/\D/g, '').slice(0, 4);
expiry =
d.length <= 1
? d
: d.length === 2
? deleting
? d
: `${d}/`
: `${d.slice(0, 2)}/${d.slice(2)}`;
expiryTouched = true;
}
function onCvcInput(e: Event) {
const raw = (e.currentTarget as HTMLInputElement).value;
cvc = raw.replace(/\D/g, '').slice(0, detectedBrand === 'AMERICAN_EXPRESS' ? 4 : 3);
cvcTouched = true;
}
/** Tokenizes the entered card into a deterministic cnon: token the backend dev mock accepts. */
export async function tokenize(): Promise<string> {
if (!complete) {
throw new Error('Card details are incomplete');
}
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
return Promise.resolve(token);
}
</script>
<div class="space-y-3">
<div>
<label for={cardNumberId} class="mb-1 block text-sm text-gray-600">Card number</label>
<div class="relative">
<input
id={cardNumberId}
type="text"
value={cardNumber}
oninput={(e) => onCardNumberInput(e)}
placeholder="1234 5678 9012 3456"
inputmode="numeric"
autocomplete="cc-number"
{disabled}
class="{inputClasses} pr-14"
aria-invalid={cardNumberError !== ''}
aria-describedby={cardNumberError ? `${cardNumberId}-error` : undefined}
/>
{#if detectedBrand !== ''}
<div class="absolute inset-y-0 right-2 flex items-center">
<CardBrandIcon brand={detectedBrand} />
</div>
{/if}
</div>
{#if cardNumberError}
<p id={`${cardNumberId}-error`} class="mt-1 text-xs text-red-500">{cardNumberError}</p>
{/if}
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label for={expiryId} class="mb-1 block text-sm text-gray-600">Expiry</label>
<input
id={expiryId}
type="text"
value={expiry}
oninput={(e) => onExpiryInput(e)}
placeholder="MM/YY"
inputmode="numeric"
autocomplete="cc-exp"
{disabled}
class={inputClasses}
aria-invalid={expiryError !== ''}
aria-describedby={expiryError ? `${expiryId}-error` : undefined}
/>
{#if expiryError}
<p id={`${expiryId}-error`} class="mt-1 text-xs text-red-500">{expiryError}</p>
{/if}
</div>
<div>
<label for={cvcId} class="mb-1 block text-sm text-gray-600">CVC</label>
<input
id={cvcId}
type="text"
value={cvc}
oninput={(e) => onCvcInput(e)}
placeholder="123"
inputmode="numeric"
autocomplete="cc-csc"
{disabled}
class={inputClasses}
aria-invalid={cvcError !== ''}
aria-describedby={cvcError ? `${cvcId}-error` : undefined}
/>
{#if cvcError}
<p id={`${cvcId}-error`} class="mt-1 text-xs text-red-500">{cvcError}</p>
{/if}
</div>
</div>
<div>
<label for={nameId} class="mb-1 block text-sm text-gray-600">
Name on card <span class="text-gray-400">(optional)</span>
</label>
<input
id={nameId}
type="text"
bind:value={cardholderName}
placeholder="Jane Doe"
autocomplete="cc-name"
{disabled}
class={inputClasses}
/>
</div>
</div>
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
import { getSquarePayments, isSquareConfigured } from '$lib/square/square';
import type MockCardForm from './MockCardForm.svelte';
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
interface Props {
/** Disable the form while a payment is processing. */
@@ -14,6 +15,7 @@
let containerEl = $state<HTMLDivElement | null>(null);
let cardInstance: unknown | null = null;
let mockForm = $state<MockCardForm | null>(null);
let ready = $state(false);
let initError = $state<string | null>(null);
@@ -23,6 +25,10 @@
let uniqueId = $state(`square-card-${crypto.randomUUID()}`);
async function init() {
if (isSquareMock()) {
// Local dev mock: no SDK load, no iframe. MockCardForm handles everything.
return;
}
if (!isSquareConfigured()) {
initError = 'not_configured';
return;
@@ -62,11 +68,19 @@
});
$effect(() => {
onReady(ready && !disabled);
if (!isSquareMock()) {
onReady(ready && !disabled);
}
});
/** Tokenizes the entered card. Returns the cnon:xxx nonce, throws with a user-facing message. */
export async function tokenize(): Promise<string> {
if (isSquareMock()) {
if (!mockForm) {
throw new Error('Card form is not ready — please wait a moment and try again');
}
return mockForm.tokenize();
}
const card = cardInstance as {
tokenize: () => Promise<{
status: string;
@@ -90,7 +104,13 @@
}
</script>
{#if initError === 'not_configured'}
{#if isSquareMock()}
{#await import('./MockCardForm.svelte')}
<div class="flex h-9 items-center text-sm text-gray-400">Loading card form...</div>
{:then { default: MockCardFormCtor }}
<MockCardFormCtor bind:this={mockForm} {disabled} {onReady} />
{/await}
{:else if initError === 'not_configured'}
<CardEntryUnavailable />
{:else if initError === 'init_failed'}
<CardEntryUnavailable
@@ -523,7 +523,9 @@
await fetchTodayBlockersData();
} else {
const text = await response.text();
toast.error('Failed to create: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
toast.error('Failed to create: ' + sanitizeText(extractErrorMessage(text)), {
id: loadingToast
});
}
} catch (err) {
console.error('Error creating time blocker:', err);
@@ -547,7 +549,9 @@
await fetchTodayBlockersData();
} else {
const text = await response.text();
toast.error('Failed to delete: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
toast.error('Failed to delete: ' + sanitizeText(extractErrorMessage(text)), {
id: loadingToast
});
}
} catch (err) {
console.error('Error deleting time blocker:', err);
+17 -4
View File
@@ -1,20 +1,33 @@
// 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)
// VITE_SQUARE_ENVIRONMENT 'sandbox' | 'production' | 'mock' (optional;
// auto-derived from the application ID prefix when
// omitted). 'mock' is LOCAL-DEV ONLY: it renders a
// token-only mock card form (never a real Square.js
// iframe). It is additionally gated on the dev build
// (import.meta.env.DEV), so it can never activate in
// a production bundle even if the var is mis-set.
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) ?? '';
const SQUARE_ENV = (import.meta.env.VITE_SQUARE_ENVIRONMENT as string | undefined) ?? '';
export interface SquareConfig {
appId: string;
locationId: string;
}
/** True when both Square application + location IDs are configured at build time. */
/** True when the frontend runs in local-dev mock mode: VITE_SQUARE_ENVIRONMENT === 'mock'
* AND the dev build (import.meta.env.DEV). The DEV gate makes the mock structurally
* impossible in any production bundle — even if the env var is mis-set at build time. */
export function isSquareMock(): boolean {
return SQUARE_ENV === 'mock' && import.meta.env.DEV;
}
/** True when a card form can be shown: real Square credentials OR local-dev mock mode. */
export function isSquareConfigured(): boolean {
return APP_ID !== '' && LOCATION_ID !== '';
return isSquareMock() || (APP_ID !== '' && LOCATION_ID !== '');
}
export function getSquareConfig(): SquareConfig | null {
+2 -2
View File
@@ -12,8 +12,8 @@
let hideFooter = $derived(
$page.url.pathname === '/admin/schedule' ||
$page.url.pathname === '/account' ||
$page.url.searchParams.get('format') === 'pdf'
$page.url.pathname === '/account' ||
$page.url.searchParams.get('format') === 'pdf'
);
// Default to 'top-center' (mobile-first approach)
+28 -30
View File
@@ -135,33 +135,33 @@
<section class="bg-gray-50">
<div class="mx-auto max-w-4xl px-6 py-16">
<h2 class="mb-8 text-center font-['Playfair_Display'] text-2xl font-semibold">Our Services</h2>
<div class="grid grid-cols-1 gap-8 md:grid-cols-4">
<div class="rounded-lg border p-6">
<h3 class="mb-2 text-lg font-semibold">Hands</h3>
<p class="text-gray-600">
Gel polish, nail art, and natural nail care — finished with a soothing hand massage.
</p>
<h2 class="mb-8 text-center font-['Playfair_Display'] text-2xl font-semibold">Our Services</h2>
<div class="grid grid-cols-1 gap-8 md:grid-cols-4">
<div class="rounded-lg border p-6">
<h3 class="mb-2 text-lg font-semibold">Hands</h3>
<p class="text-gray-600">
Gel polish, nail art, and natural nail care — finished with a soothing hand massage.
</p>
</div>
<div class="rounded-lg border p-6">
<h3 class="mb-2 text-lg font-semibold">Feet</h3>
<p class="text-gray-600">
Luxurious pedicures with exfoliation, cuticle care, and a long-lasting polish finish.
</p>
</div>
<div class="rounded-lg border p-6">
<h3 class="mb-2 text-lg font-semibold">Brows</h3>
<p class="text-gray-600">
Expert shaping, tinting, and lamination for brows that frame your face perfectly.
</p>
</div>
<div class="rounded-lg border p-6">
<h3 class="mb-2 text-lg font-semibold">Wax</h3>
<p class="text-gray-600">
Gentle waxing for face, body, and brows — smooth results with minimal discomfort.
</p>
</div>
</div>
<div class="rounded-lg border p-6">
<h3 class="mb-2 text-lg font-semibold">Feet</h3>
<p class="text-gray-600">
Luxurious pedicures with exfoliation, cuticle care, and a long-lasting polish finish.
</p>
</div>
<div class="rounded-lg border p-6">
<h3 class="mb-2 text-lg font-semibold">Brows</h3>
<p class="text-gray-600">
Expert shaping, tinting, and lamination for brows that frame your face perfectly.
</p>
</div>
<div class="rounded-lg border p-6">
<h3 class="mb-2 text-lg font-semibold">Wax</h3>
<p class="text-gray-600">
Gentle waxing for face, body, and brows — smooth results with minimal discomfort.
</p>
</div>
</div>
</div>
</section>
@@ -169,8 +169,8 @@
<section class="bg-gray-50">
<div class="mx-auto max-w-4xl px-6 py-16">
<h2 class="mb-8 text-center font-['Playfair_Display'] text-2xl font-semibold">Opening Hours</h2>
<BusinessHours />
<h2 class="mb-8 text-center font-['Playfair_Display'] text-2xl font-semibold">Opening Hours</h2>
<BusinessHours />
</div>
</section>
@@ -178,5 +178,3 @@
<h2 class="mb-4 font-['Playfair_Display'] text-2xl font-semibold">Ready to Treat Yourself?</h2>
<Button href={resolve('/prices')} class="px-6 py-3 text-lg">View Price List</Button>
</section>
@@ -766,7 +766,11 @@
{/if}
</div>
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} onChanged={() => fetchWeekData()} />
<BookingModal
bind:open={showBookingModal}
bookingId={selectedBookingId ?? ''}
onChanged={() => fetchWeekData()}
/>
{/if}
<style>
+2 -2
View File
@@ -163,7 +163,7 @@ Multi-method payment system accepting Square (card terminal & online), cash, gif
**Related:** [[Booking System|1. Booking System]] (deposits), [[Gift Cards|4. Gift Cards]] (pay by gift card), [[Admin Dashboard|5. Admin Dashboard]] (till purchases)
### 2.1 Online Card Payment (Square — saved cards or new cards via Web Payments SDK)
**What it does:** Customers pay online with a card. Saved-card payments work via Square tokenized card IDs (`ccof:`); new-card payments are tokenized client-side through the Square Web Payments SDK into `cnon:` nonces and accepted by the backend everywhere. The backend rejects raw PANs (PCI-DSS parity, mirrored by the dev mock). Local dev without Square credentials (`VITE_SQUARE_APPLICATION_ID`/`VITE_SQUARE_LOCATION_ID`) keeps new-card entry gated behind a `CardEntryUnavailable` notice. Used for deposits, full payments, balance payments, and tips.
**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 can opt into the built-in frontend mock (`VITE_SQUARE_ENVIRONMENT=mock`), which renders a plain HTML card form and mints the same `cnon:` tokens the backend dev mock accepts — a full as-if-live walkthrough with zero credentials; without credentials or mock mode, new-card entry is gated behind a `CardEntryUnavailable` notice. Used for deposits, full payments, balance payments, and tips.
**Layman summary:** "Pay online with your card — just like any online shop."
@@ -191,7 +191,7 @@ Multi-method payment system accepting Square (card terminal & online), cash, gif
**Related:** [[Gift Cards|4. Gift Cards]], [[VAT Calculation|2.10 VAT Calculation]]
### 2.5 Saved Cards
**What it does:** Customers can save their card details for faster checkout next time. Cards are tokenized via Square (`ccof:` card IDs; the full PAN exists only in Square's vault — our DB stores only the reference + brand/last4/fingerprint). The dev mock mirrors this (raw PANs rejected). Soft-deleted with 7-year UK retention. The "Add Card" flow posts a `card_token` (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.
**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 and mock mode is off (local dev), add-card shows the `CardEntryUnavailable` notice; with `VITE_SQUARE_ENVIRONMENT=mock` it uses the frontend mock form instead (saved mock cards appear as `ccof:mock_*` rows in the dev DB).
**Layman summary:** "Save your card for next time — one-click payment."
@@ -16,7 +16,7 @@ New-card entry is **tokenized via the Square Web Payments SDK** (`cnon:` nonces)
## Current State (verified August 2026)
### Frontend — new-card entry is TOKENIZED (no raw PANs anywhere):
All 8 flows now render `SquareCardInput` (`frontend/src/lib/components/payments/SquareCardInput.svelte`), which loads the Square Web Payments SDK (`frontend/src/lib/square/square.ts`, env-gated on `VITE_SQUARE_APPLICATION_ID`/`VITE_SQUARE_LOCATION_ID`) and tokenizes the entered card into a `cnon:xxx` nonce sent as `new_card_token`. The tokenized form is the only card-entry path — there is no raw-PAN fallback. When the SDK env vars are not configured (e.g. local dev), flows keep the `CardEntryUnavailable` notice.
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. Local dev can opt into the built-in frontend mock (`VITE_SQUARE_ENVIRONMENT=mock`): `SquareCardInput` renders a plain HTML card form (`MockCardForm.svelte`) and `tokenize()` returns the deterministic `cnon:` tokens the backend dev mock (`SQUARE_ENVIRONMENT=mock`) accepts, so all 8 flows run end-to-end with zero credentials. When neither the SDK env vars nor mock mode are configured, flows keep the `CardEntryUnavailable` notice. Note: in mock mode the response's brand/last4 always reflects the token's canonical test card (e.g. `4242 4242 4242 4242` → VISA 4242, `4111 1111 1111 1111` → VISA 1111) — an arbitrarily typed card that is Luhn-valid but not one of the four canonical numbers still maps to `cnon:test-card` (VISA 4242), so its displayed last4 is the canonical one, not the typed digits. Cosmetic and dev-only.
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
@@ -105,7 +105,7 @@ All 8 flows render `SquareCardInput` and send the resulting `cnon:xxx` as `new_c
- **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. Implemented per the plan: each flow caches the token after the first `tokenize()` and **reuses it on retry** (the backend idempotency key dedups), so a retry does not re-tokenize or double-charge.
- **PCI-DSS parity preserved** — the backend rejects raw PANs by design; the tokenized form never falls back to sending PAN/CVC to our server.
- **`CardEntryUnavailable` stays as the offline/dev fallback** — when no `VITE_SQUARE_*` credentials are configured, flows keep the gated notice rather than breaking.
- **`CardEntryUnavailable` stays as the fallback** — when neither the `VITE_SQUARE_*` credentials nor dev mock mode (`VITE_SQUARE_ENVIRONMENT=mock`) are configured, flows keep the gated notice rather than breaking. The dev mock is local-only and token-only; `VITE_SQUARE_ENVIRONMENT=mock` must never be set in a deployed (non-local) build.
---
@@ -115,7 +115,7 @@ All 8 flows render `SquareCardInput` and send the resulting `cnon:xxx` as `new_c
- [x] Square Web Payments SDK loads (sandbox + prod URLs, env-gated)
- [x] `SquareCardInput` tokenizes cards → `cnon:xxx`
- [x] All 8 flows re-enabled to send nonces, not PANs (tip ×3, booking payment, deposit, Buy a Gift Card, account Add Card, admin till `online_square`)
- [x] `CardEntryUnavailable` kept only as the no-credentials fallback
- [x] `CardEntryUnavailable` kept only as the no-credentials/no-mock fallback (local dev opts into the built-in frontend mock via `VITE_SQUARE_ENVIRONMENT=mock`)
- [x] Backend nonce paths verified unchanged (Step 4/5 done)
- [x] Frontend checks pass: svelte-check 0 errors, eslint 0 errors, build succeeds
- [x] Docs updated (README, Gap Backlog, Feature Catalog, Technical Manual)