Files
Crussell/frontend/src/lib/components/admin/TillPurchases.svelte
T
popertots fe88f2084d fix: review-loop B — adversarial findings (sweep auto-refund, admin clamp, 2FA real challenge, opaque refresh tokens, gated client IP, GBP pence)
Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests

All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
2026-08-22 00:34:50 +01:00

815 lines
26 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';
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.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
// B6/B10: verification code for the customer's saved-card till charge,
// collected on the saved-card payment screen. Kept populated across retries
// so an invalid/expired code can be corrected without re-typing it. The
// admin always supplies the CUSTOMER's code — the admin's own 2FA flag is
// irrelevant to the backend gate.
let twoFactorCode = $state('');
// Set true when a charge 403s for a missing code — reveals the input even
// if the session user's flag is unset.
let reveal2FACodeInput = $state(false);
const show2FACodeInput = $derived(
reveal2FACodeInput || (savedCardChargeRequires2FACode && paymentMethod === 'saved_card')
);
const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === '');
// 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 (show2FACodeInput) body.verification_code = twoFactorCode;
} 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();
twoFactorCode = '';
reveal2FACodeInput = 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)) reveal2FACodeInput = 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={twoFactorCode} showInput={show2FACodeInput} enabled={true} />
</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 ||
missing2FACode ||
(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>