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;
|
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't be charged yet — the till API currently supports gift card sales
|
Retail items can'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>
|
||||||
|
|||||||
Reference in New Issue
Block a user