Files
Crussell/frontend/src/lib/components/admin/TillPurchases.svelte
T
popertots 3866cc5963 fix: round-2 loop-A fresh review (503c326 baseline) — B1 replay cap, A6 discount record, 2FA reissue+cooldown, notification flood, lockout saturation, VAT/refund-status consolidation
Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed:

MONEY:
- CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) —
  a rejected auto-refund no longer re-replays the expired key every sweep run
  (which minted a stacking unauthorized charge each time); FAILED-webhook
  demotion respects the cap; never re-replay a key whose B1 refund failed
- HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible
  campaign discount rows immediately (capped) instead of skipping with no
  discount recorded — no more promised-discount-not-recorded overcharge
- MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed
  new-card+save_card charges (re-issue guard now covers req.SaveCard)
- LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining
  now matches the authoritative tip-excluded balance)

SECURITY:
- MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap
  on critical_payment_log + refresh_token_reuse rows)
- MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer
  clears LastMintAt on gate-verify; cleared on terminal charge success)
- MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked
  state instead of a fresh 5-guess budget per request
- MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs
  instead of sleeping unboundedly; login bcrypt concurrency semaphore added
- LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check;
  email-verification per-user attempt counter

DUP/MOD:
- formatCurrency single source (frontend format.ts, 7 files consolidated);
  SquareRefundStatusToLocal single source (errors.go, all sites); admin
  audit-log helper dedup; SCA retry model unified (proactive on all 6
  surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from
  backend; generateUUID at all card-form sites; magic numbers named
  (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card
  terminal charges now audited; DAV_SKIP_INIT documented in manuals

Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet
tags, frontend tests+build, env-docs 42/42.
2026-08-22 00:34:50 +01:00

973 lines
33 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 { formatCurrency } from '$lib/utils/format';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import { SvelteMap } from 'svelte/reactivity';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
isSquareConfigured,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
runSavedCardSCAProactively,
shouldFallbackTo2FA,
SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE,
submitPaymentWithRetry,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode,
PAYMENT_METHOD_SAVED_CARD,
VERIFICATION_REQUIRED_MESSAGE
} 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' | (typeof PAYMENT_METHOD_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: PAYMENT_METHOD_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 SvelteMap<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 === PAYMENT_METHOD_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;
// Square's card-on-file id (`ccof:...`), needed to run the saved-card SCA
// challenge (tokenizeSavedCardWithVerification).
square_card_id?: 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) and only gates
// customers who have actually ENABLED 2FA (requireTwoFactorForCardAccess:
// twoFactorEnforced() && UserTwoFactorEnabled(cardUserID)), so the input is
// surfaced only when BOTH hold — mirroring PaymentModal. The customer's
// setup flag is not carried by the till customer search, so it is fetched
// from GET /api/admin/users/{id} when a customer is selected (see
// fetchCustomerTwoFactor). For a 2FA-disabled customer in an enforced
// environment the input stays hidden so the charge can be attempted; the
// backend then returns the clear "Enable it in your account settings" 403,
// which the isTwoFactorVerificationGateFailure self-heal surfaces. 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 twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
let customerTwoFactorEnabled = $state(false);
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA
// from backup to the only available gate (scaAvailable → false); every other
// outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state('');
// True while the saved-card 3DS challenge is open and the CUSTOMER must
// approve it in their banking app — drives the "waiting for approval" panel.
let awaitingSCA = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === PAYMENT_METHOD_SAVED_CARD,
scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome),
mint: () =>
selectedCustomer?.id
? adminRequestNewTwoFactorCode(selectedCustomer.id)
: requestNewTwoFactorCode()
});
// 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 !== PAYMENT_METHOD_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 === PAYMENT_METHOD_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);
fetchCustomerTwoFactor(customer.id);
}
function clearSelectedCustomer() {
selectedCustomer = null;
savedCards = [];
savedCardsError = null;
selectedSavedCardId = null;
customerResults = [];
showCustomerResults = false;
customerTwoFactorEnabled = 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;
}
}
// B6/B10: the till customer search (GET /api/admin/users) carries no 2FA
// state, so the selected customer's setup flag is fetched from the admin
// user detail endpoint — the same source PaymentModal's fetchCustomerTwoFactor
// keys on. A failure leaves the flag false; the charge 403 self-heal still
// reveals the input.
async function fetchCustomerTwoFactor(userId: string) {
try {
const res = await apiFetch(`/api/admin/users/${userId}`);
if (res.ok) {
const data = await res.json();
customerTwoFactorEnabled = data?.twoFactorEnabled === true;
} else {
customerTwoFactorEnabled = false;
}
} catch {
customerTwoFactorEnabled = 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 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 === PAYMENT_METHOD_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 === PAYMENT_METHOD_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)
}),
// Finding 4: a saved-card till line gated on 2FA consumed its
// code at the backend gate — a 503 auto-retry would re-send a
// dead code and self-defeat.
{
verificationCodeGated: paymentMethod === PAYMENT_METHOD_SAVED_CARD && twoFactor.showInput
}
);
if (!res.ok) {
responseStatus = res.status;
const errText = await res.text();
// Saved-card (ccof) SCA: the backend returns 402 +
// `verification_required` when Square requires buyer verification
// and no verification_token was supplied. Run the client-side 3DS
// challenge and retry the SAME sale line with the fresh token and
// its SAME cached idempotency key. runTillSavedCardSCA throws to
// stop the whole sale on any non-verified outcome.
if (
paymentMethod === PAYMENT_METHOD_SAVED_CARD &&
isVerificationRequiredSignal(responseStatus, errText)
) {
await runTillSavedCardSCA(body);
continue;
}
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;
}
}
/**
* Saved-card (ccof) SCA challenge, run when a till sale line came back 402
* with the verification-required signal. The CUSTOMER approves the 3DS
* challenge in their banking app; the operator's screen shows the waiting
* state. 'verified' retries the SAME sale line with the fresh verification_token
* and its SAME cached idempotency key (never regenerated here); 'sca-unavailable'
* demotes 2FA from backup to the available gate; 'challenge-cancelled' /
* 'sca-failed' keep the pending row retryable (the idempotency key stays
* cached). Throws to stop the whole sale on any non-verified outcome.
*/
async function runTillSavedCardSCA(body: Record<string, unknown>): Promise<void> {
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
// The till body carries the amount in POUNDS (the backend multiplies by
// 100); the SCA challenge binds to pence, so convert for the challenge.
const amountPence = Math.round((Number(body.amount) || 0) * 100);
awaitingSCA = true;
try {
if (!squareCardId) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw new Error(VERIFICATION_REQUIRED_MESSAGE);
}
let result: SavedCardVerificationResult;
try {
result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
email: selectedCustomer?.email
});
} catch (err) {
lastSCAOutcome = 'sca-unavailable';
twoFactor.reveal = true;
throw err;
}
lastSCAOutcome = result.outcome;
if (result.outcome === 'verified') {
const retry = await submitPaymentWithRetry(() =>
apiFetch('/api/admin/till/sale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...body,
verification_token: result.verificationToken
})
})
);
if (!retry.ok) {
const errText = await retry.text();
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
}
return;
}
twoFactor.reveal = true;
if (result.outcome === 'sca-unavailable') {
throw new Error(`${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`);
}
throw new Error(CARD_VERIFICATION_RETRY_MESSAGE);
} finally {
awaitingSCA = 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 === PAYMENT_METHOD_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">
Your card issuer will ask you to approve this payment in your banking app.
</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}
{#if awaitingSCA}
<div
class="mt-3 flex flex-col items-center justify-center rounded-md border border-gray-200 bg-gray-50/50 p-6"
>
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="mt-3 text-sm font-medium text-gray-700">
Waiting for customer to approve in their banking app…
</p>
<p class="mt-1 text-xs text-muted-foreground">
The customer may need to approve this payment in their banking app
</p>
</div>
{:else}
<Button
class="mt-3 w-full"
onclick={chargeCart}
loading={processing}
disabled={!canCharge ||
processing ||
twoFactor.missing ||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
(paymentMethod === PAYMENT_METHOD_SAVED_CARD && !selectedSavedCardId)}
>
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
</Button>
{/if}
<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>