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.
973 lines
33 KiB
Svelte
973 lines
33 KiB
Svelte
<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 - £8
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="justify-start gap-2"
|
||
disabled={processing}
|
||
onclick={() => addItem('Nail Files (Pack)', 5)}
|
||
>
|
||
Nail Files - £5
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="justify-start gap-2"
|
||
disabled={processing}
|
||
onclick={() => addItem('Hand Cream', 6)}
|
||
>
|
||
Hand Cream - £6
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="justify-start gap-2"
|
||
disabled={processing}
|
||
onclick={() => addItem('Base Coat', 7)}
|
||
>
|
||
Base Coat - £7
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="justify-start gap-2"
|
||
disabled={processing}
|
||
onclick={() => addItem('Top Coat', 7)}
|
||
>
|
||
Top Coat - £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"
|
||
>£</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 (£{GIFT_CARD_MAX_AMOUNT})
|
||
</p>
|
||
{/if}
|
||
<p class="text-xs text-muted-foreground">
|
||
Gift card limit £{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)}
|
||
>
|
||
−
|
||
</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'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>
|