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:
@@ -16,12 +16,13 @@
|
||||
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 }> = [
|
||||
{ key: 'cash', label: 'Cash' },
|
||||
{ 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[]>([]);
|
||||
@@ -37,6 +38,144 @@
|
||||
// async, so `processing` may not reach the button before a fast second click.
|
||||
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 itemCount = $derived(cart.reduce((sum, item) => sum + item.qty, 0));
|
||||
|
||||
@@ -105,7 +244,13 @@
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
isProcessingPaymentSync = true;
|
||||
@@ -122,9 +267,12 @@
|
||||
action: 'create',
|
||||
amount: item.price,
|
||||
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) {
|
||||
throw new Error('Card form is not ready — please wait a moment and try again');
|
||||
}
|
||||
@@ -159,6 +307,7 @@
|
||||
|
||||
toast.success('Sale complete');
|
||||
cart = [];
|
||||
idempotencyKeys.clear();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Sale failed';
|
||||
paymentError = msg;
|
||||
@@ -239,8 +388,12 @@
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs" disabled={processing}
|
||||
>Add</Button
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={addGiftCard}
|
||||
class="h-9 px-2 text-xs"
|
||||
disabled={processing}>Add</Button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -257,6 +410,114 @@
|
||||
</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 />
|
||||
|
||||
<div class="px-4 py-3">
|
||||
@@ -330,8 +591,12 @@
|
||||
<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)}
|
||||
<div
|
||||
class="mt-2 grid gap-2 {availablePaymentMethods.length > 3
|
||||
? 'grid-cols-2'
|
||||
: 'grid-cols-3'}"
|
||||
>
|
||||
{#each availablePaymentMethods 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 ===
|
||||
@@ -363,6 +628,75 @@
|
||||
</div>
|
||||
{/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}
|
||||
<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
|
||||
@@ -380,15 +714,17 @@
|
||||
class="mt-3 w-full"
|
||||
onclick={chargeCart}
|
||||
loading={processing}
|
||||
disabled={
|
||||
!canCharge || processing || (paymentMethod === 'online_square' && !onlineSquareCardReady)
|
||||
}
|
||||
disabled={!canCharge ||
|
||||
processing ||
|
||||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
||||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
|
||||
>
|
||||
{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">
|
||||
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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user