Implement full Square payment review fixes + frontend polish
Implement every finding from the deep payment review (P0-P2, minors, nitpicks), then close the post-implementation re-review items, then align card-form typography and roll out the Square trust badge. Backend - Square API alignment: - tip_settings.allow_tipping nested under device_options (was top-level: terminal tips were silently lost in prod) - CreateCardOnFile now accepts customerID and sends card.customer_id; saved-card (ccof:) charges forward square_customer_id as CustomerID - New SquareClient methods GetPayment, CreateCustomer, CancelCheckout - SCA verification_token accepted + forwarded in all charge paths - ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/ ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts emails, ForceRefundPending hook Backend - money safety: - sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id instead of stranding them forever - SweepStalePendingPayments reconciles at Square before failing (tri-state: leave pending on transport error, rescue completed, fail definitively) - GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution; SweepStaleTerminalCheckouts covers terminal_checkouts table - till gift-card clawback on definitive failure incl. retry path + INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT - cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id)) - customer provisioning (lazy, save-only); one-off/guest mint no customer - discount preview/apply unified in discounts.go (global-milestone visible in preview, N+1 eliminated, redemption counter preserved on failures) - webhook event_id dedup; refund loop dedup; stale comment fixes - test-isolation t.Cleanup on committed sweep tests Frontend: - SCA tokenizeWithVerification across all charge flows (amount as major-units decimal), 5-min token-expiry re-tokenize, verification_token in request bodies - PaymentModal synchronous double-click + zero/negative-amount guards - till online-card UI wired to /api/admin/till/sale - policyPopover generalised; new /privacy-policy route; consent checkbox copy + Square privacy link - Square card iframe styled to app typography (Inter 14px, oklch tokens); mock form md:text-sm parity - 'Secure payment powered by Square' badge on all 8 card-payment flows Schema/docs: terminal_checkouts + square_customer_id + per-user card constraint in init-script.sql; README migrations; P14 plan + backlog + Technical Manual updated. Includes 39 modified/new test files; full backend suite (25 pkgs), -race on payments+square, and frontend build are green.
This commit is contained in:
@@ -3,6 +3,11 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { isSquareConfigured } from '$lib/square/square';
|
||||
|
||||
type CartItem = {
|
||||
id: string;
|
||||
@@ -11,13 +16,35 @@
|
||||
qty: number;
|
||||
};
|
||||
|
||||
type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square';
|
||||
|
||||
const PAYMENT_METHODS: Array<{ key: TillPaymentMethod; label: string }> = [
|
||||
{ key: 'cash', label: 'Cash' },
|
||||
{ key: 'card_machine', label: 'Card Machine' },
|
||||
{ key: 'online_square', label: 'Online Card' }
|
||||
];
|
||||
|
||||
let cart = $state<CartItem[]>([]);
|
||||
let giftCardAmount = $state('25');
|
||||
let showGiftCardInput = $state(false);
|
||||
|
||||
let paymentMethod = $state<TillPaymentMethod>('cash');
|
||||
let onlineSquareCardReady = $state(false);
|
||||
let onlineSquareCardInput = $state<SquareCardInput | null>(null);
|
||||
let processing = $state(false);
|
||||
let paymentError = $state<string | null>(null);
|
||||
// Synchronous double-click guard (see BookingFlow) — Svelte 5 reactivity is
|
||||
// async, so `processing` may not reach the button before a fast second click.
|
||||
let isProcessingPaymentSync = false;
|
||||
|
||||
const subtotal = $derived(cart.reduce((sum, item) => sum + item.price * item.qty, 0));
|
||||
const itemCount = $derived(cart.reduce((sum, item) => sum + item.qty, 0));
|
||||
|
||||
// The backend till sale API currently only accepts item_type 'gift_card', so
|
||||
// retail items cannot be charged yet — gate the Charge button to gift-card-only carts.
|
||||
const hasRetailItems = $derived(cart.some((i) => i.label !== 'Gift Card'));
|
||||
const canCharge = $derived(cart.length > 0 && !hasRetailItems && subtotal > 0);
|
||||
|
||||
function formatCurrency(n: number): string {
|
||||
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n);
|
||||
}
|
||||
@@ -52,6 +79,95 @@
|
||||
})
|
||||
.filter((i): i is CartItem => i !== null);
|
||||
}
|
||||
|
||||
/** Polls a card-machine checkout until it completes (mirrors the gift-card management flow). */
|
||||
async function pollTillCheckout(checkoutId: string): Promise<void> {
|
||||
const maxAttempts = 60;
|
||||
for (let attempts = 0; attempts < maxAttempts; attempts++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/till/sale/checkout/${checkoutId}/status`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.status === 'COMPLETED') return;
|
||||
}
|
||||
} catch {
|
||||
// Keep polling — a transient network error is not fatal.
|
||||
}
|
||||
}
|
||||
throw new Error('Card machine payment timed out. Please check the Square dashboard.');
|
||||
}
|
||||
|
||||
async function chargeCart() {
|
||||
if (isProcessingPaymentSync) return;
|
||||
if (cart.length === 0) {
|
||||
toast.error('Cart is empty');
|
||||
return;
|
||||
}
|
||||
if (hasRetailItems) {
|
||||
toast.error('Retail items cannot be charged yet — the till API currently supports gift card sales only');
|
||||
return;
|
||||
}
|
||||
isProcessingPaymentSync = true;
|
||||
processing = true;
|
||||
paymentError = null;
|
||||
try {
|
||||
// One sale per cart line × quantity — each till sale funds its own
|
||||
// gift card (the backend only accepts item_type 'gift_card').
|
||||
const saleBodies: Record<string, unknown>[] = [];
|
||||
for (const item of cart) {
|
||||
for (let i = 0; i < item.qty; i++) {
|
||||
const body: Record<string, unknown> = {
|
||||
item_type: 'gift_card',
|
||||
action: 'create',
|
||||
amount: item.price,
|
||||
payment_method: paymentMethod,
|
||||
idempotency_key: generateUUID()
|
||||
};
|
||||
if (paymentMethod === 'online_square') {
|
||||
if (!onlineSquareCardInput) {
|
||||
throw new Error('Card form is not ready — please wait a moment and try again');
|
||||
}
|
||||
// SCA verification amount must match the sale amount (pence).
|
||||
const tokenized = await onlineSquareCardInput.tokenizeWithVerification(
|
||||
Math.round(item.price * 100)
|
||||
);
|
||||
body.card_token = tokenized.nonce;
|
||||
if (tokenized.verificationToken) {
|
||||
body.verification_token = tokenized.verificationToken;
|
||||
}
|
||||
}
|
||||
saleBodies.push(body);
|
||||
}
|
||||
}
|
||||
|
||||
for (const body of saleBodies) {
|
||||
const res = await apiFetch('/api/admin/till/sale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
|
||||
}
|
||||
const data = await res.json();
|
||||
if (paymentMethod === 'card_machine' && data.status === 'pending' && data.checkout_id) {
|
||||
await pollTillCheckout(data.checkout_id as string);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success('Sale complete');
|
||||
cart = [];
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Sale failed';
|
||||
paymentError = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
isProcessingPaymentSync = false;
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border bg-card">
|
||||
@@ -64,6 +180,7 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
disabled={processing}
|
||||
onclick={() => addItem('Cuticle Oil', 8)}
|
||||
>
|
||||
Cuticle Oil - £8
|
||||
@@ -72,6 +189,7 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
disabled={processing}
|
||||
onclick={() => addItem('Nail Files (Pack)', 5)}
|
||||
>
|
||||
Nail Files - £5
|
||||
@@ -80,6 +198,7 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
disabled={processing}
|
||||
onclick={() => addItem('Hand Cream', 6)}
|
||||
>
|
||||
Hand Cream - £6
|
||||
@@ -88,6 +207,7 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
disabled={processing}
|
||||
onclick={() => addItem('Base Coat', 7)}
|
||||
>
|
||||
Base Coat - £7
|
||||
@@ -96,6 +216,7 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
disabled={processing}
|
||||
onclick={() => addItem('Top Coat', 7)}
|
||||
>
|
||||
Top Coat - £7
|
||||
@@ -112,12 +233,13 @@
|
||||
inputmode="decimal"
|
||||
bind:value={giftCardAmount}
|
||||
class="h-9 pl-5 text-sm"
|
||||
disabled={processing}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') addGiftCard();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs"
|
||||
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs" disabled={processing}
|
||||
>Add</Button
|
||||
>
|
||||
</div>
|
||||
@@ -126,6 +248,7 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full justify-start gap-2"
|
||||
disabled={processing}
|
||||
onclick={() => (showGiftCardInput = true)}
|
||||
>
|
||||
Gift Card
|
||||
@@ -154,7 +277,8 @@
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={processing}
|
||||
onclick={() => updateQty(item.id, -1)}
|
||||
>
|
||||
−
|
||||
@@ -162,7 +286,8 @@
|
||||
<span class="w-5 text-center text-sm font-semibold tabular-nums">{item.qty}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={processing}
|
||||
onclick={() => updateQty(item.id, 1)}
|
||||
>
|
||||
+
|
||||
@@ -173,7 +298,8 @@
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove item"
|
||||
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-xs text-muted-foreground hover:bg-red-50 hover:text-red-600"
|
||||
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-xs text-muted-foreground hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={processing}
|
||||
onclick={() => removeItem(item.id)}
|
||||
>
|
||||
<svg
|
||||
@@ -200,11 +326,69 @@
|
||||
<span class="text-lg font-bold tabular-nums">{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
|
||||
<Button class="mt-3 w-full" disabled>
|
||||
Charge {formatCurrency(subtotal)}
|
||||
<div class="mt-3">
|
||||
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
|
||||
>Payment Method</span
|
||||
>
|
||||
<div class="mt-2 grid grid-cols-3 gap-2">
|
||||
{#each PAYMENT_METHODS as m (m.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
|
||||
m.key
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
disabled={processing}
|
||||
onclick={() => (paymentMethod = m.key)}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if paymentMethod === 'online_square'}
|
||||
<div class="mt-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={onlineSquareCardInput}
|
||||
onReady={(r) => (onlineSquareCardReady = r)}
|
||||
disabled={processing}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-xs text-gray-500">
|
||||
Online card entry is unavailable — Square is not configured.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasRetailItems}
|
||||
<p class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
|
||||
Retail items can't be charged yet — the till API currently supports gift card sales
|
||||
only. Remove retail items to complete this sale.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if paymentError}
|
||||
<p class="mt-3 rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-800">
|
||||
{paymentError}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
class="mt-3 w-full"
|
||||
onclick={chargeCart}
|
||||
loading={processing}
|
||||
disabled={
|
||||
!canCharge || processing || (paymentMethod === 'online_square' && !onlineSquareCardReady)
|
||||
}
|
||||
>
|
||||
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
|
||||
</Button>
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Payment flow and backend integration coming soon.
|
||||
Gift card sales are processed through the till; retail items require manual recording for now.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user