Till: saved-card payment surface with customer picker and valid-card gating

Staff may charge a customer's saved card at the till but cannot add or save
one. A customer picker (reusing the admin user-search pattern, excluding
admin/guest/affiliate) loads the customer's cards via
GET /admin/users/{id}/payment-methods; the 'Saved card' payment option is
hidden outright when the customer has no currently-valid cards, computed
client-side with the Square convention (valid through the end of
exp_month/exp_year). The saved-card charge sends payment_method saved_card
plus user_id/user_saved_card_id with no card_token or verification_token, and
the per-line idempotency keys also key on the selected card so switching cards
yields fresh keys. No Square Dashboard hint, no new-card form, no save
checkbox — the till can never persist a card.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent c7d7169cd2
commit ea771ffaf5
@@ -16,12 +16,13 @@
qty: number; qty: number;
}; };
type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square'; type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square' | 'saved_card';
const PAYMENT_METHODS: Array<{ key: TillPaymentMethod; label: string }> = [ const PAYMENT_METHODS: Array<{ key: TillPaymentMethod; label: string }> = [
{ key: 'cash', label: 'Cash' }, { key: 'cash', label: 'Cash' },
{ key: 'card_machine', label: 'Card Machine' }, { key: 'card_machine', label: 'Card Machine' },
{ key: 'online_square', label: 'Online Card' } { key: 'online_square', label: 'Online Card' },
{ key: 'saved_card', label: 'Saved Card' }
]; ];
let cart = $state<CartItem[]>([]); let cart = $state<CartItem[]>([]);
@@ -37,6 +38,144 @@
// async, so `processing` may not reach the button before a fast second click. // async, so `processing` may not reach the button before a fast second click.
let isProcessingPaymentSync = false; let isProcessingPaymentSync = false;
// Idempotency keys are cached per cart line (item id, quantity index, price,
// payment method) so a lost-response retry of the SAME cart reuses the keys:
// the backend re-attempts the charge with the stored key, Square dedups, and
// the customer is not charged twice. A changed cart/amount/payment method
// yields a different composite key, so genuinely new sales get fresh keys.
// Mirrors the BookingFlow/PaymentModal/TipPayment per-charge caching pattern.
let idempotencyKeys = new Map<string, string>();
function idempotencyKeyFor(item: CartItem, qtyIndex: number): string {
// saved_card charges also key on the selected card id so switching to a
// different card (or back to another method) yields fresh keys.
const composite = `${item.id}:${qtyIndex}:${item.price}:${paymentMethod}:${paymentMethod === 'saved_card' ? (selectedSavedCardId ?? '') : ''}`;
let key = idempotencyKeys.get(composite);
if (!key) {
key = generateUUID();
idempotencyKeys.set(composite, key);
}
return key;
}
// ---------- Customer picker (saved-card payments) ----------
type TillCustomer = {
id: string;
name: string;
email?: string;
};
// Fields mirror the backend SavedCard shape (payment-methods endpoint).
type SavedCard = {
id: string;
brand: string;
last_4: string;
exp_month: number;
exp_year: number;
cardholder_name?: string;
};
let customerQuery = $state('');
let customerResults = $state<TillCustomer[]>([]);
let loadingCustomers = $state(false);
let showCustomerResults = $state(false);
let selectedCustomer = $state<TillCustomer | null>(null);
let savedCards = $state<SavedCard[]>([]);
let loadingSavedCards = $state(false);
let savedCardsError = $state<string | null>(null);
let selectedSavedCardId = $state<string | null>(null);
// Square convention: a card is valid through the end of its exp_month/exp_year.
const validCards = $derived(
savedCards.filter((card) => {
const now = new Date();
return (
card.exp_year > now.getFullYear() ||
(card.exp_year === now.getFullYear() && card.exp_month >= now.getMonth() + 1)
);
})
);
// The saved-card option is hidden outright unless a customer is selected
// AND has at least one currently-valid card on file.
const showSavedCardOption = $derived(selectedCustomer !== null && validCards.length > 0);
const availablePaymentMethods = $derived(
PAYMENT_METHODS.filter((m) => m.key !== 'saved_card' || showSavedCardOption)
);
// If the saved-card option disappears (customer cleared, no valid cards, or
// a card expires mid-session) fall back to cash instead of leaving the till
// on an unrenderable method.
$effect(() => {
if (paymentMethod === 'saved_card' && !showSavedCardOption) {
paymentMethod = 'cash';
selectedSavedCardId = null;
}
});
async function searchCustomers() {
if (!customerQuery.trim()) return;
loadingCustomers = true;
try {
const res = await apiFetch(
`/api/admin/users?page=1&per_page=5&q=${encodeURIComponent(customerQuery.trim())}`
);
if (res.ok) {
const data = await res.json();
const excludedRoles = ['admin', 'guest', 'affiliate'];
customerResults = (data.users || [])
.filter((u: { account_role: string }) => !excludedRoles.includes(u.account_role))
.map((u: { id: string; fullName: string; email?: string }) => ({
id: u.id,
name: u.fullName || 'Customer',
email: u.email
}));
showCustomerResults = true;
}
} catch {
toast.error('Failed to search customers');
} finally {
loadingCustomers = false;
}
}
function selectCustomer(customer: TillCustomer) {
selectedCustomer = customer;
customerQuery = '';
customerResults = [];
showCustomerResults = false;
fetchSavedCards(customer.id);
}
function clearSelectedCustomer() {
selectedCustomer = null;
savedCards = [];
savedCardsError = null;
selectedSavedCardId = null;
customerResults = [];
showCustomerResults = false;
}
async function fetchSavedCards(userId: string) {
loadingSavedCards = true;
savedCards = [];
savedCardsError = null;
selectedSavedCardId = null;
try {
const res = await apiFetch(`/api/admin/users/${userId}/payment-methods`);
if (res.ok) {
savedCards = await res.json();
} else {
savedCardsError = 'Failed to load saved cards';
}
} catch {
savedCardsError = 'Failed to load saved cards';
} finally {
loadingSavedCards = false;
}
}
const subtotal = $derived(cart.reduce((sum, item) => sum + item.price * item.qty, 0)); const subtotal = $derived(cart.reduce((sum, item) => sum + item.price * item.qty, 0));
const itemCount = $derived(cart.reduce((sum, item) => sum + item.qty, 0)); const itemCount = $derived(cart.reduce((sum, item) => sum + item.qty, 0));
@@ -105,7 +244,13 @@
return; return;
} }
if (hasRetailItems) { if (hasRetailItems) {
toast.error('Retail items cannot be charged yet — the till API currently supports gift card sales only'); toast.error(
'Retail items cannot be charged yet — the till API currently supports gift card sales only'
);
return;
}
if (paymentMethod === 'saved_card' && (!selectedCustomer || !selectedSavedCardId)) {
toast.error('Select a customer and a saved card before charging');
return; return;
} }
isProcessingPaymentSync = true; isProcessingPaymentSync = true;
@@ -122,9 +267,12 @@
action: 'create', action: 'create',
amount: item.price, amount: item.price,
payment_method: paymentMethod, payment_method: paymentMethod,
idempotency_key: generateUUID() idempotency_key: idempotencyKeyFor(item, i)
}; };
if (paymentMethod === 'online_square') { if (paymentMethod === 'saved_card') {
body.user_id = selectedCustomer?.id;
body.user_saved_card_id = selectedSavedCardId;
} else if (paymentMethod === 'online_square') {
if (!onlineSquareCardInput) { if (!onlineSquareCardInput) {
throw new Error('Card form is not ready — please wait a moment and try again'); throw new Error('Card form is not ready — please wait a moment and try again');
} }
@@ -159,6 +307,7 @@
toast.success('Sale complete'); toast.success('Sale complete');
cart = []; cart = [];
idempotencyKeys.clear();
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Sale failed'; const msg = err instanceof Error ? err.message : 'Sale failed';
paymentError = msg; paymentError = msg;
@@ -239,8 +388,12 @@
}} }}
/> />
</div> </div>
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs" disabled={processing} <Button
>Add</Button size="sm"
variant="outline"
onclick={addGiftCard}
class="h-9 px-2 text-xs"
disabled={processing}>Add</Button
> >
</div> </div>
{:else} {:else}
@@ -257,6 +410,114 @@
</div> </div>
</div> </div>
<div class="border-b border-gray-200 px-4 py-3">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Customer (saved card payments)</span
>
{#if selectedCustomer}
<div
class="mt-2 flex items-center justify-between gap-2 rounded-md border border-gray-200 bg-gray-50/50 p-3"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-card-foreground">{selectedCustomer.name}</p>
{#if selectedCustomer.email}
<p class="truncate text-xs text-muted-foreground">{selectedCustomer.email}</p>
{/if}
{#if savedCardsError}
<p class="mt-1 text-xs text-red-700">{savedCardsError}</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
{#if loadingSavedCards}
<span class="text-xs text-muted-foreground">Loading cards...</span>
{:else if !savedCardsError}
<span class="text-xs text-muted-foreground">
{validCards.length} valid card{validCards.length === 1 ? '' : 's'}
</span>
{/if}
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
disabled={processing}
onclick={clearSelectedCustomer}
>
Clear
</Button>
</div>
</div>
{:else}
<div class="relative mt-2">
<div class="flex gap-2">
<div class="relative flex-1">
<Input
type="text"
placeholder="Search by name, email or phone..."
bind:value={customerQuery}
disabled={processing}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
searchCustomers();
} else if (e.key === 'Escape') {
showCustomerResults = false;
}
}}
onfocus={() => (showCustomerResults = true)}
onblur={() => (showCustomerResults = false)}
/>
</div>
<Button
size="sm"
variant="outline"
class="h-9"
disabled={processing || !customerQuery.trim()}
onclick={searchCustomers}
>
{loadingCustomers ? '...' : 'Search'}
</Button>
</div>
{#if showCustomerResults}
<div
class="absolute z-10 mt-1 w-full rounded-md border border-gray-200 bg-background shadow-lg"
>
{#if loadingCustomers}
<div class="flex justify-center p-6">
<div
class="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-primary"
></div>
</div>
{:else if customerResults.length === 0}
<div class="p-4 text-center text-xs text-gray-500">
{customerQuery.trim()
? 'No customers found.'
: 'Type a name, email or phone to search.'}
</div>
{:else}
<ul class="max-h-56 divide-y divide-gray-200 overflow-y-auto">
{#each customerResults as user (user.id)}
<li>
<button
type="button"
class="flex w-full flex-col items-start px-4 py-3 text-left transition-colors hover:bg-fuchsia-50/40"
onmousedown={(e) => e.preventDefault()}
onclick={() => selectCustomer(user)}
>
<span class="text-sm font-semibold text-card-foreground">{user.name}</span>
{#if user.email}
<span class="text-xs text-gray-500">{user.email}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<Separator /> <Separator />
<div class="px-4 py-3"> <div class="px-4 py-3">
@@ -330,8 +591,12 @@
<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</span >Payment Method</span
> >
<div class="mt-2 grid grid-cols-3 gap-2"> <div
{#each PAYMENT_METHODS as m (m.key)} class="mt-2 grid gap-2 {availablePaymentMethods.length > 3
? 'grid-cols-2'
: 'grid-cols-3'}"
>
{#each availablePaymentMethods as m (m.key)}
<button <button
type="button" type="button"
class="rounded-lg border py-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod === class="rounded-lg border py-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
@@ -363,6 +628,75 @@
</div> </div>
{/if} {/if}
{#if paymentMethod === 'saved_card'}
<div class="mt-3 space-y-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
{#if loadingSavedCards}
<div class="flex justify-center py-6">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
</div>
{:else if savedCardsError}
<p class="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-800">
{savedCardsError}
</p>
{:else}
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Select a Saved Card</span
>
<div class="space-y-2">
{#each validCards as card (card.id)}
<button
type="button"
class="w-full rounded-lg border p-3 text-left transition-colors {selectedSavedCardId ===
card.id
? 'border-input bg-fuchsia-100'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => (selectedSavedCardId = card.id)}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg
class="h-5 w-5 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
<span class="font-medium text-gray-900">{card.brand} ••••{card.last_4}</span>
</div>
<span class="text-xs text-gray-500"
>{String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
>
</div>
</button>
{/each}
</div>
<div class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3">
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-xs text-amber-800">
This card may require bank app confirmation to complete. Ensure the customer has
their phone ready.
</p>
</div>
{/if}
</div>
{/if}
{#if hasRetailItems} {#if hasRetailItems}
<p class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800"> <p class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
Retail items can&apos;t be charged yet — the till API currently supports gift card sales Retail items can&apos;t be charged yet — the till API currently supports gift card sales
@@ -380,15 +714,17 @@
class="mt-3 w-full" class="mt-3 w-full"
onclick={chargeCart} onclick={chargeCart}
loading={processing} loading={processing}
disabled={ disabled={!canCharge ||
!canCharge || processing || (paymentMethod === 'online_square' && !onlineSquareCardReady) processing ||
} (paymentMethod === 'online_square' && !onlineSquareCardReady) ||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
> >
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`} {processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
</Button> </Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p> <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"> <p class="mt-1 text-xs text-muted-foreground">
Gift card sales are processed through the till; retail items require manual recording for now. Gift card sales are processed through the till; retail items require manual recording for
now.
</p> </p>
{/if} {/if}
</div> </div>