Files
Crussell/frontend/src/lib/components/admin/TillPurchases.svelte
T
popertots a6a4683b74 fix: review round — B1 clock-skew tolerance + re-poll escalation, refresh-token access-token revocation, shared 2FA composable, per-package-DB test alignment
Three fresh reviews (money/security/dup-mod) cross-validated findings:
- MEDIUM: B1 'new charge' discrimination adds a lower-bound tolerance (replayRescueLowerBoundSkew) so a retained-key replay of the ORIGINAL charge (DB clock ahead of Square) is never auto-refunded; ambiguous margins leave PENDING + CRITICAL
- MEDIUM: B1 re-poll escalates after stalePendingB1RefundAge (48h) — FAILED/REJECTED refunds go terminal (fail parent, claw back till-sale funding, CRITICAL notification); no more unbounded re-polling / stranded parents without webhooks
- DRIFT-REAL: processManualPaymentGroup now checks PENDING/FAILED/REJECTED on the synchronous refund response (mirrors processChargeGroup/manual handler) — no more premature 'completed'
- HIGH: refresh-token family kill now also invalidates the attacker's freshly-minted ACCESS token — access tokens carry a family_id claim and VerifyToken rejects tokens whose family was deleted (GenerateTokenForFamily + family-alive check); 30s grace window for concurrent two-tab refresh (no false theft alert)
- LOW: 2FA mint endpoint returns remaining_seconds; in-memory 2FA counters documented; 90-day refresh expiry single-sourced (RefreshTokenLifetime + make_interval)
- Dup/mod: NEW shared useTwoFactorCodeForSavedCard Svelte composable replaces 6 surface copies of the 2FA gate logic (Request-a-new-code added to BookingFlow + TillPurchases); account page adopts generateUUID
- Test architecture: removed t.Parallel() from 8 global-SquareClient-swapping tests per Testing Architecture doc line 89 (B1 flaky-test lesson) — fixes within-package race
- SQL alias pence rename (total_cents/paid_cents -> total_pence/paid_pence)

26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
2026-08-22 00:34:50 +01:00

826 lines
27 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { Button } from '$lib/components/ui/button';
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 TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import {
isSquareConfigured,
isTwoFactorVerificationGateFailure,
submitPaymentWithRetry
} from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
type CartItem = {
id: string;
label: string;
price: number;
qty: number;
};
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: 'saved_card', label: 'Saved Card' }
];
let cart = $state<CartItem[]>([]);
let giftCardAmount = $state('25');
let showGiftCardInput = $state(false);
// Gift card funding is capped at £250 per transaction (the backend enforces
// the same limit) — mirror the cap client-side so the till cannot queue an
// oversized gift card.
const GIFT_CARD_MAX_AMOUNT = 250;
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;
// 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)
);
})
);
// B6/B10: charging a customer's saved card via the till requires the
// customer's current 2FA verification code when the backend enforces the
// gate. The backend keys on the CARD OWNER (not the admin), so the input is
// surfaced whenever the gate is enforced — the operator relays the
// customer's code. Cash, card machine, and online (new-card nonce) payments
// are unaffected. Shared two-factor-code state (code, reveal, show/missing
// derivations, "Request a new code" handler) — see
// $lib/stores/twoFactorCode.svelte.ts. The admin always supplies the
// CUSTOMER's code — the admin's own 2FA flag is irrelevant to the backend
// gate, so `enabled` is always true.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => savedCardChargeRequires2FACode && paymentMethod === 'saved_card'
});
// 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));
// 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 &&
!cart.some((i) => i.price > GIFT_CARD_MAX_AMOUNT)
);
// Client-side parity with the per-transaction gift card cap: the amount
// typed into the gift-card input must not exceed £250.
const parsedGiftCardAmount = $derived(parseFloat(giftCardAmount));
const giftCardAmountTooHigh = $derived(
!isNaN(parsedGiftCardAmount) && parsedGiftCardAmount > GIFT_CARD_MAX_AMOUNT
);
function formatCurrency(n: number): string {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n);
}
function addItem(label: string, price: number) {
const existing = cart.find((i) => i.label === label);
if (existing) {
existing.qty++;
} else {
cart = [...cart, { id: generateUUID(), label, price, qty: 1 }];
}
}
function addGiftCard() {
const amt = parseFloat(giftCardAmount);
if (isNaN(amt) || amt <= 0) return;
// The Add button is disabled via `giftCardAmountTooHigh`, but the
// input's Enter key bypasses that — reject here too (defense in depth).
if (amt > GIFT_CARD_MAX_AMOUNT) return;
addItem('Gift Card', amt);
giftCardAmount = '25';
showGiftCardInput = false;
}
function removeItem(id: string) {
cart = cart.filter((i) => i.id !== id);
}
function updateQty(id: string, delta: number) {
cart = cart
.map((i) => {
if (i.id !== id) return i;
const next = i.qty + delta;
return next <= 0 ? null : { ...i, qty: next };
})
.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 (cart.some((item) => item.price > GIFT_CARD_MAX_AMOUNT)) {
toast.error(`Gift card amount exceeds maximum (£${GIFT_CARD_MAX_AMOUNT})`);
return;
}
if (hasRetailItems) {
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;
processing = true;
paymentError = null;
let responseStatus = 0;
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: idempotencyKeyFor(item, i)
};
if (paymentMethod === 'saved_card') {
body.user_id = selectedCustomer?.id;
body.user_saved_card_id = selectedSavedCardId;
// B6/B10: the backend requires the CARD OWNER's current 2FA
// verification code when the gate is enforced.
if (twoFactor.showInput) body.verification_code = twoFactor.code;
} else 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 submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
);
if (!res.ok) {
responseStatus = res.status;
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 = [];
idempotencyKeys.clear();
twoFactor.setCode('');
twoFactor.reveal = false;
} catch (err) {
const msg = err instanceof Error ? err.message : 'Sale failed';
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the sale can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
paymentError = msg;
toast.error(msg);
} finally {
isProcessingPaymentSync = false;
processing = false;
}
}
</script>
<div class="rounded-xl border bg-card">
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
<h3 class="text-base font-semibold">Till Sales</h3>
</div>
<div class="grid grid-cols-2 gap-2 p-4 sm:grid-cols-3">
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Cuticle Oil', 8)}
>
Cuticle Oil - &pound;8
</Button>
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Nail Files (Pack)', 5)}
>
Nail Files - &pound;5
</Button>
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Hand Cream', 6)}
>
Hand Cream - &pound;6
</Button>
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Base Coat', 7)}
>
Base Coat - &pound;7
</Button>
<Button
variant="outline"
size="sm"
class="justify-start gap-2"
disabled={processing}
onclick={() => addItem('Top Coat', 7)}
>
Top Coat - &pound;7
</Button>
<div class="relative">
{#if showGiftCardInput}
<div class="flex flex-col gap-1">
<div class="flex gap-1">
<div class="relative flex-1">
<span class="absolute top-1/2 left-2 -translate-y-1/2 text-xs text-gray-400"
>&pound;</span
>
<Input
type="text"
inputmode="decimal"
bind:value={giftCardAmount}
max={GIFT_CARD_MAX_AMOUNT}
class="h-9 pl-5 text-sm"
disabled={processing}
error={giftCardAmountTooHigh ? 'Gift card amount exceeds maximum' : ''}
onkeydown={(e) => {
if (e.key === 'Enter') addGiftCard();
}}
/>
</div>
<Button
size="sm"
variant="outline"
onclick={addGiftCard}
class="h-9 px-2 text-xs"
disabled={processing || giftCardAmountTooHigh}>Add</Button
>
</div>
{#if giftCardAmountTooHigh}
<p class="text-xs text-red-700"
>Gift card amount exceeds maximum (&pound;{GIFT_CARD_MAX_AMOUNT})</p
>
{/if}
<p class="text-xs text-muted-foreground"
>Gift card limit &pound;{GIFT_CARD_MAX_AMOUNT} per transaction</p
>
</div>
{:else}
<Button
variant="outline"
size="sm"
class="w-full justify-start gap-2"
disabled={processing}
onclick={() => (showGiftCardInput = true)}
>
Gift Card
</Button>
{/if}
</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">
{#if cart.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">
Tap items above to add them to the sale.
</p>
{:else}
<div class="max-h-48 space-y-1 overflow-y-auto">
{#each cart as item (item.id)}
<div class="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<div class="min-w-0 flex-1">
<span class="font-medium text-card-foreground">{item.label}</span>
<span class="ml-2 text-xs text-muted-foreground"
>{formatCurrency(item.price)} each</span
>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="flex h-9 w-9 min-w-9 items-center justify-center rounded border text-base text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
disabled={processing}
onclick={() => updateQty(item.id, -1)}
>
&minus;
</button>
<span class="w-5 text-center text-sm font-semibold tabular-nums">{item.qty}</span>
<button
type="button"
class="flex h-9 w-9 min-w-9 items-center justify-center rounded border text-base text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
disabled={processing}
onclick={() => updateQty(item.id, 1)}
>
+
</button>
<span class="w-14 text-right text-sm font-semibold tabular-nums"
>{formatCurrency(item.price * item.qty)}</span
>
<button
type="button"
aria-label="Remove item"
class="ml-1 flex h-9 w-9 min-w-9 items-center justify-center rounded text-base 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
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{/each}
</div>
<Separator class="my-3" />
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">
{itemCount} item{itemCount !== 1 ? 's' : ''}
</span>
<span class="text-lg font-bold tabular-nums">{formatCurrency(subtotal)}</span>
</div>
<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-2 gap-2 {availablePaymentMethods.length > 3
? ''
: 'sm:grid-cols-3'}"
>
{#each availablePaymentMethods as m (m.key)}
<button
type="button"
class="rounded-lg border py-3 text-sm 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 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}
<!-- B6/B10: saved-card till charges require the customer's
current 2FA verification code when the backend enforces
the gate. -->
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={true}
/>
{#if twoFactor.showInput}
<Button
variant="outline"
size="sm"
class="w-full"
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
{/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&apos;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 ||
twoFactor.missing ||
(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.
</p>
{/if}
</div>
</div>