Files
Crussell/frontend/src/lib/components/payments/PaymentModal.svelte
T
popertots 35572e1d70 fix: persist service price overrides before payment
Service price overrides in PaymentModal were only used for frontend
calculations but not persisted to the backend. This caused receipts
and subsequent payments to use original prices instead of overridden
ones.

Added saveServiceOverrides() function that calls PUT
/api/admin/bookings/{id}/services before payment to persist any
price changes. Called in both handleCardPayment and
handleSavedCardPayment before applyLoyaltyRedemption().
2026-08-22 00:34:51 +01:00

2019 lines
68 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 { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
import { apiFetch } from '$lib/utils/api';
import { formatCurrency } from '$lib/utils/format';
import {
buildCashTillPaymentBody,
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
cashChargeBasePence,
isOverflowTipConfirmationRequired,
isVerificationRequiredSignal,
PAYMENT_METHOD_SAVED_CARD,
runSavedCardSCAProactively,
sanitizeDecimalInput,
shouldShowSCARefusal,
submitPaymentWithRetry,
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import { generateUUID } from '$lib/utils/uuid';
import { POLICY } from '$lib/constants/policy';
interface Props {
booking: Booking;
onClose: () => void;
onComplete: (payment: PaymentResult) => void;
}
const { booking, onClose, onComplete }: Props = $props();
type PaymentStatus =
| 'idle'
| 'selecting'
| 'card-processing'
| 'card-polling'
| 'cash-entering'
| 'cash-confirming'
| 'gift-entering'
| 'gift-confirming'
| 'saved-card-selecting'
| 'saved-card-processing'
| 'saved-card-waiting-sca'
| 'success'
| 'error';
type PaymentResult = {
checkout_id: string;
status: string;
card_brand?: string;
last4?: string;
amount: number;
};
type PaymentMethod = 'card' | 'cash' | 'giftcard' | typeof PAYMENT_METHOD_SAVED_CARD | null;
let status = $state<PaymentStatus>('idle');
let selectedMethod = $state<PaymentMethod>(null);
let checkoutId = $state<string | null>(null);
let paymentResult = $state<PaymentResult | null>(null);
let error = $state<string | null>(null);
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
// on the next microtask), so the reactive `status` may not propagate to the
// button's `disabled` binding before a fast second click fires. This non-
// reactive flag is checked synchronously at the start of every handler.
let isProcessingPaymentSync = false;
// Overpayment confirmation (mirrors UserPaymentModal/BookingFlow). The
// backend rejects a payment that exceeds the booking's remaining balance
// unless the request carries `confirm_overflow_tip: true` — a tip is
// gratuity for service already rendered. The guard fires on STALE booking
// data (multi-tab, admin-changed totals) where the operator would otherwise
// be stuck with an unresolvable 400; the rejected request body is parked
// here and a Confirm/Cancel prompt is shown, with Confirm resending the SAME
// body plus the flag.
let overflowConfirm = $state<{
amountPence: number;
overflowPence: number;
body: Record<string, unknown>;
} | null>(null);
function confirmOverflowPayment() {
const pending = overflowConfirm;
if (!pending || status === 'saved-card-processing') return;
status = 'saved-card-processing';
error = null;
isProcessingPaymentSync = true;
apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...pending.body, confirm_overflow_tip: true })
})
.then(async (response) => {
if (!response.ok) {
const errData = await response.text();
throw new Error(extractErrorMessage(errData) || 'Failed to process payment');
}
const data = await response.json();
status = 'success';
paymentResult = {
checkout_id: data.payment_id || data.checkout_id || data.id || '',
status: 'COMPLETED',
card_brand: data.card_brand,
last4: data.card_last4,
amount: data.amount
};
overflowConfirm = null;
toast.success('Payment successful');
onComplete(paymentResult);
})
.catch((_err) => {
status = 'error';
error = _err instanceof Error ? _err.message : 'Failed to process payment';
toast.error(error ?? 'Unknown error');
})
.finally(() => {
isProcessingPaymentSync = false;
});
}
function cancelOverflowConfirmation() {
overflowConfirm = null;
status = 'idle';
selectedMethod = null;
}
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
// fallback); every other outcome keeps SCA primary for the next retry.
let lastSCAOutcome = $state('');
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
let useLoyalty = $state(false);
// B3: pence already paid against this booking. The AppointmentInfo handed in
// by /api/admin/today/current-next carries no amount_paid/amount_due/
// payments, so this is fetched fresh from the admin booking detail endpoint
// on mount and subtracted from the charge (see netTotal).
//
// Money finding 1: `amount_paid` (summed over ALL completed payments) can
// include tips — a tip is gratuity, not booking credit, so it must not
// reduce what the customer still owes. The booking detail endpoint computes
// amount_paid in Go over every completed payment (no payment_type filter),
// so the tip-excluded obligation is derived here from the payments list
// rather than trusting amount_paid. This stays consistent whether or not
// the backend starts excluding tips from amount_paid (idempotent either
// way). The tip-INCLUSIVE amount_paid is kept for the "Already paid"
// display (mirrors the customer modal's "Amount Paid" row).
let amountPaidPence = $state(0);
let tipExcludedPaidPence = $state(0);
async function fetchAmountPaid() {
try {
const resp = await apiFetch(`/api/admin/bookings/${booking.id}`);
if (resp.ok) {
const data = await resp.json();
if (typeof data.amount_paid === 'number') {
amountPaidPence = Math.round(data.amount_paid * 100);
}
tipExcludedPaidPence = Math.round(
(data.payments ?? [])
.filter(
(p: { status: string; payment_type: string; payment_method?: string }) =>
p.status === 'completed' && p.payment_type !== 'tip' && p.payment_method !== 'discount'
)
.reduce((sum: number, p: { amount: number }) => sum + (p.amount || 0), 0) * 100
);
return;
}
} catch (_err) {
// fall through to the booking prop below
}
amountPaidPence = Math.round((booking.amount_paid ?? 0) * 100);
tipExcludedPaidPence = amountPaidPence;
}
const loyaltyEligible = $derived(
stamps >= 10 &&
!(booking.discounts ?? []).some((d: BookingDiscount) => d.discount_source === 'loyalty') &&
booking.total_amount > 0 &&
tipExcludedPaidPence === 0
);
const loyaltyDiscount = $derived(
useLoyalty ? Math.round(booking.total_amount * 100 * POLICY.LOYALTY_DISCOUNT_RATE) : 0
);
// Campaign discount preview — fetched on mount, mirroring the customer flow
// (UserPaymentModal). The backend AUTO-APPLIES eligible campaigns at payment
// /completion, so the admin modal must show and charge the DISCOUNTED amount:
// charging the pre-campaign total would over-credit the ledger (the backend
// records the full payment AND the discount rows). `netTotal` therefore
// subtracts these pence, and every charge handler derives from it.
let discountPreview = $state<{
eligible: boolean;
discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
original_total: number;
discounted_total: number;
} | null>(null);
let customerBalance = $state(0);
let giftCardPaymentAmount = $state('');
async function fetchCustomerGiftCardBalance() {
const targetUserId = booking.user_id ?? booking.user?.id;
if (!targetUserId) return;
try {
const res = await apiFetch(`/api/admin/users/${targetUserId}/giftcard-balance`);
if (res.ok) {
const data = await res.json();
customerBalance = data.balance;
giftCardPaymentAmount = Math.min(data.balance, totalDue).toFixed(2);
if (data.balance > 0) {
useAccountBalance = true;
}
}
} catch {
// ignore
}
}
type ServiceOverride = {
price: string;
originalPrice: number;
};
let serviceOverrides = $state<Record<string, ServiceOverride>>({});
$effect(() => {
const uid = booking.user_id ?? booking.user?.id;
if (uid) {
fetchCustomerGiftCardBalance();
fetchSavedCards();
}
const services = booking.services ?? [];
const overrides: Record<string, ServiceOverride> = {};
for (const s of services) {
const price = s.override_price ?? s.price ?? 0;
overrides[s.service_id] = {
price: price.toFixed(2),
originalPrice: s.price ?? 0
};
}
serviceOverrides = overrides;
});
// Single shared sanitizer for all decimal money inputs: strips non-numeric
// characters and keeps only the first decimal point (so "1.2.3" → "1.23").
// Defined once in square.ts and imported here so the payment surfaces can't
// drift.
function handlePriceInput(serviceId: string, value: string) {
const sanitized = sanitizeDecimalInput(value);
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
serviceOverrides = {
...serviceOverrides,
[serviceId]: {
...serviceOverrides[serviceId],
price: sanitized
}
};
}
}
let tipEnabled = $state(false);
let selectedTipPercent = $state<number | null>(null);
let customTipAmount = $state<string>('');
// Partial payment amounts for card and saved card (in pounds, user enters)
let cardPaymentAmount = $state<string>('');
let lastValidCardAmount = $state<string>('');
let savedCardPaymentAmount = $state<string>('');
let lastValidSavedCardAmount = $state<string>('');
let showPartialCharge = $state(false);
let pollingInterval: ReturnType<typeof setInterval> | null = null;
function selectTipPercent(percent: number) {
selectedTipPercent = percent;
customTipAmount = '';
tipEnabled = true;
}
function handleCustomTipInput(e: Event) {
const input = e.target as HTMLInputElement;
const sanitized = sanitizeDecimalInput(input.value);
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
customTipAmount = sanitized;
}
selectedTipPercent = null;
tipEnabled = customTipAmount !== '' && parseFloat(customTipAmount) > 0;
}
function handleCardAmountInput(e: Event) {
const input = e.target as HTMLInputElement;
const sanitized = sanitizeDecimalInput(input.value);
if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
cardPaymentAmount = sanitized;
lastValidCardAmount = sanitized;
} else {
cardPaymentAmount = lastValidCardAmount;
input.value = lastValidCardAmount;
}
}
function handleSavedCardAmountInput(e: Event) {
const input = e.target as HTMLInputElement;
const sanitized = sanitizeDecimalInput(input.value);
if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
savedCardPaymentAmount = sanitized;
lastValidSavedCardAmount = sanitized;
} else {
savedCardPaymentAmount = lastValidSavedCardAmount;
input.value = lastValidSavedCardAmount;
}
}
function getServicePrice(service: BookingService): number {
const override = serviceOverrides[service.service_id];
if (override && override.price !== '') {
const parsed = parseFloat(override.price);
if (!isNaN(parsed) && parsed > 0) return parsed;
}
return service.override_price ?? service.price ?? 0;
}
const subtotal = $derived(
(booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0)
);
const discountSum = $derived(
(booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0)
);
// Campaign discounts apply automatically at payment/completion server-side,
// so the charge must be the subtotal minus already-applied discounts minus
// the eligible campaign credit — otherwise the customer is overcharged.
//
// B3: prior payments are also subtracted. The booking object handed to this
// modal (from /api/admin/today/current-next, AppointmentInfo) carries no
// amount_paid/amount_due/payments, so on mount the modal fetches the
// authoritative paid total from GET /api/admin/bookings/{id} (full Booking
// shape, admin-accessible) and charges only the remaining obligation. The
// backend money agent clamps the booking portion of a payment to the
// remaining value, so the frontend charge and the backend record now agree
// and a prior deposit can no longer land as an unintended tip.
// netTotal: the obligation after subtracting discounts and prior real
// payments, in pounds. subtotal and discountSum are already in pounds;
// campaignDiscountPence (the campaign preview) and tipExcludedPaidPence
// (sum of completed non-tip, non-discount payments) are in pence, so the
// arithmetic is done in pence then converted back to pounds to avoid
// mixing units.
const netTotal = $derived(
Math.max(
0,
( Math.round((subtotal - discountSum) * 100)
- campaignDiscountPence(discountPreview)
- tipExcludedPaidPence
) / 100
)
);
const tipPercentages = $derived.by(() => {
if (netTotal <= 0) return [];
return POLICY.TIP_PRESET_PCTS.map((pct) => ({
pct,
amount: Math.round(netTotal * (pct / 100) * 100) / 100
}));
});
const tipMultiplier = $derived(
selectedTipPercent !== null
? 1 + selectedTipPercent / 100
: customTipAmount && parseFloat(customTipAmount) > 0
? 1 + parseFloat(customTipAmount) / netTotal
: 1
);
const totalWithTip = $derived(tipEnabled ? netTotal * tipMultiplier : netTotal);
const tipDisplay = $derived(
selectedTipPercent !== null
? `${selectedTipPercent}%`
: customTipAmount && parseFloat(customTipAmount) > 0
? `${formatCurrency(parseFloat(customTipAmount))}`
: ''
);
const totalDue = $derived(tipEnabled ? totalWithTip : netTotal);
// The amount added on top of the pre-tip total when a tip is selected.
const tipDelta = $derived(tipEnabled ? totalWithTip - netTotal : 0);
// True when there is genuinely nothing to charge — the booking is fully
// covered by discounts (and no tip is being added). Payment entry is
// disabled in that state; the handlers also guard defensively.
const nothingToCharge = $derived(totalDue <= 0);
// Card payment amount validation
const cardAmountNum = $derived(cardPaymentAmount === '' ? totalDue : parseFloat(cardPaymentAmount));
const cardAmountValid = $derived(
cardPaymentAmount === '' ||
(cardPaymentAmount !== '' &&
!isNaN(cardAmountNum) &&
cardAmountNum > 0 &&
cardAmountNum <= totalDue &&
/^\d+(\.\d{0,2})?$/.test(cardPaymentAmount))
);
const cardValidationError = $derived(
cardPaymentAmount !== '' && !cardAmountValid
? cardPaymentAmount === ''
? ''
: !/^\d+(\.\d{0,2})?$/.test(cardPaymentAmount)
? 'Enter a valid amount (max 2 decimal places)'
: cardAmountNum <= 0
? 'Amount must be greater than 0'
: cardAmountNum > totalDue
? `Amount cannot exceed ${formatCurrency(totalDue)}`
: ''
: ''
);
// Saved card payment amount validation
const savedCardAmountNum = $derived(
savedCardPaymentAmount === '' ? totalDue : parseFloat(savedCardPaymentAmount)
);
const savedCardAmountValid = $derived(
savedCardPaymentAmount === '' ||
(savedCardPaymentAmount !== '' &&
!isNaN(savedCardAmountNum) &&
savedCardAmountNum > 0 &&
savedCardAmountNum <= totalDue &&
/^\d+(\.\d{0,2})?$/.test(savedCardPaymentAmount))
);
const savedCardValidationError = $derived(
savedCardPaymentAmount !== '' && !savedCardAmountValid
? savedCardPaymentAmount === ''
? ''
: !/^\d+(\.\d{0,2})?$/.test(savedCardPaymentAmount)
? 'Enter a valid amount (max 2 decimal places)'
: savedCardAmountNum <= 0
? 'Amount must be greater than 0'
: savedCardAmountNum > totalDue
? `Amount cannot exceed ${formatCurrency(totalDue)}`
: ''
: ''
);
async function applyLoyaltyRedemption(): Promise<void> {
if (!useLoyalty) return;
const res = await apiFetch(`/api/admin/bookings/${booking.id}/apply-redemption`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!res.ok) {
const errData = await res.text();
throw new Error(extractErrorMessage(errData) || 'Failed to apply loyalty discount');
}
}
async function saveServiceOverrides(): Promise<void> {
// Check if any service has an override that differs from the original price
const services = booking.services ?? [];
const hasOverrides = services.some((s) => {
const override = serviceOverrides[s.service_id];
if (!override) return false;
const overridePrice = parseFloat(override.price);
return !isNaN(overridePrice) && Math.abs(overridePrice - override.originalPrice) > 0.01;
});
if (!hasOverrides) return;
// Build the request payload
const serviceIds = services.map((s) => s.service_id).filter((id): id is string => !!id);
const serviceOverridesPayload = services
.filter((s) => {
const override = serviceOverrides[s.service_id];
if (!override) return false;
const overridePrice = parseFloat(override.price);
return !isNaN(overridePrice) && Math.abs(overridePrice - override.originalPrice) > 0.01;
})
.map((s) => ({
service_id: s.service_id,
override_price: parseFloat(serviceOverrides[s.service_id].price)
}));
const res = await apiFetch(`/api/admin/bookings/${booking.id}/services`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service_ids: serviceIds,
service_overrides: serviceOverridesPayload
})
});
if (!res.ok) {
const errData = await res.text();
throw new Error(extractErrorMessage(errData) || 'Failed to save service overrides');
}
}
async function handleCardPayment() {
if (isProcessingPaymentSync) return;
const finalAmount = cardAmountNum;
if (isNaN(finalAmount) || finalAmount <= 0) {
toast.error('Please enter a valid amount');
return;
}
if (finalAmount > totalDue) {
toast.error(`Amount cannot exceed ${formatCurrency(totalDue)}`);
return;
}
// The loyalty redemption is applied on top of totalDue; guard against
// the effective charge being zero or negative.
if (Math.round(finalAmount * 100) - loyaltyDiscount <= 0) {
toast.error('Nothing to charge — the booking is fully covered by discounts');
return;
}
isProcessingPaymentSync = true;
status = 'card-processing';
error = null;
try {
await saveServiceOverrides();
await applyLoyaltyRedemption();
const response = await apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: Math.round(finalAmount * 100) - loyaltyDiscount,
payment_type: 'full',
tip_enabled: tipEnabled
})
});
if (!response.ok) {
const errData = await response.text();
// Overflow guard (defensive — the admin terminal path currently
// clamps instead, but a 400 carrying the code must surface the
// Confirm/Cancel prompt like the customer modal, not a dead-end).
if (isOverflowTipConfirmationRequired(errData)) {
overflowConfirm = {
amountPence: Math.round(finalAmount * 100) - loyaltyDiscount,
overflowPence: Math.max(
0,
Math.round(finalAmount * 100) - loyaltyDiscount - Math.round(netTotal * 100)
),
body: {
amount: Math.round(finalAmount * 100) - loyaltyDiscount,
payment_type: 'full',
tip_enabled: tipEnabled
}
};
return;
}
throw new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
}
const data = await response.json();
checkoutId = data.checkout_id;
status = 'card-polling';
startPolling();
} catch (_err) {
status = 'error';
error = _err instanceof Error ? _err.message : 'Failed to initiate payment';
toast.error(error ?? 'Unknown error');
} finally {
isProcessingPaymentSync = false;
}
}
function startPolling() {
if (!checkoutId) return;
pollingInterval = setInterval(async () => {
try {
const response = await apiFetch(
`/api/admin/payments/${checkoutId}/status?booking_id=${booking.id}`,
{
headers: { 'Content-Type': 'application/json' }
}
);
if (!response.ok) {
throw new Error('Failed to check payment status');
}
const data = await response.json();
if (data.status === 'COMPLETED') {
stopPolling();
status = 'success';
paymentResult = {
checkout_id: checkoutId!,
status: data.status,
card_brand: data.card_brand,
// The API serializes the last-four as card_last4 (see
// PaymentStatusResponse); the success screen reads
// paymentResult.last4, so keep the local field name.
last4: data.card_last4,
amount: data.amount
};
// Deliberately do NOT call onComplete() here: it would make
// the parent close this modal instantly, so the green-tick
// success state would never be seen. The modal stays open
// showing the tick until the operator clicks Done, which
// fires handleSuccessDone() → onComplete + onClose.
} else if (data.status === 'FAILED') {
stopPolling();
status = 'error';
error = data.error_message || 'Payment failed';
toast.error(error as string);
}
} catch {
stopPolling();
status = 'error';
error = 'Failed to check payment status';
toast.error(error);
}
}, 2000);
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
function handleClose() {
stopPolling();
onClose();
}
// True while a charge (or the out-of-band SCA challenge the customer must
// approve in their banking app) is in flight — ESC/overlay close must be
// blocked then, because the charge may still land.
function isChargeInFlight(status: PaymentStatus): boolean {
return (
status === 'card-processing' ||
status === 'card-polling' ||
status === 'cash-confirming' ||
status === 'gift-confirming' ||
status === 'saved-card-processing' ||
status === 'saved-card-waiting-sca'
);
}
// Called from the success state's Done button: notify the parent (so it can
// refresh the booking/payment data) and then close the modal. Kept separate
// from handleClose so a success state never closes without the callback.
function handleSuccessDone() {
stopPolling();
if (paymentResult) {
onComplete(paymentResult);
}
onClose();
}
function resetToSelect() {
stopPolling();
status = 'idle';
selectedMethod = null;
checkoutId = null;
error = null;
// Clear any refusal from a previous attempt so re-entering the saved-card
// screen doesn't re-show it before a fresh SCA attempt.
lastSCAOutcome = '';
}
$effect(() => {
return () => {
stopPolling();
};
});
// Fetch the eligible campaign discount preview once on mount. Mirrors the
// customer flow (UserPaymentModal) so the admin modal charges the same
// discounted amount the backend will auto-apply.
onMount(async () => {
fetchAmountPaid();
try {
const resp = await apiFetch(`/api/bookings/${booking.id}/discount-preview`);
if (resp.ok) {
discountPreview = await resp.json();
}
} catch (_err) {
console.error('Failed to fetch discount preview:', _err);
}
});
let cashAmount = $state<string>('');
const cashAmountNum = $derived(cashAmount === '' ? 0 : parseFloat(cashAmount));
// Cash charge base in pence. The backend's CreateTerminalPayment derives the
// tip from `amount remaining`, and `remaining` (GetBookingRemainingBalancePence)
// does NOT subtract the pending campaign discount — so the campaign preview
// must be restored into the charge base or the tip is absorbed into booking
// credit. Shared with the change/tip display so every cash figure agrees.
const cashDuePence = $derived(
cashChargeBasePence(
Math.round(totalDue * 100),
campaignDiscountPence(discountPreview),
loyaltyDiscount
)
);
const cashDue = $derived(cashDuePence / 100);
const changeDue = $derived(cashAmountNum > cashDue ? cashAmountNum - cashDue : 0);
let extraAsTip = $state(false);
function handleCashInput(e: Event) {
const input = e.target as HTMLInputElement;
const sanitized = sanitizeDecimalInput(input.value);
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
cashAmount = sanitized;
}
}
async function handleCashPayment() {
if (isProcessingPaymentSync) return;
if (cashDue <= 0) {
toast.error('Nothing to charge — the booking is fully covered by discounts');
return;
}
if (cashAmountNum < cashDue) {
toast.error('Cash amount must cover the total');
return;
}
const tipAmount = extraAsTip ? cashAmountNum - cashDue : 0;
isProcessingPaymentSync = true;
status = 'cash-confirming';
error = null;
try {
await applyLoyaltyRedemption();
// Cash till-sale body: the tip is FOLDED into the amount (the backend's
// CreateTerminalPaymentRequest derives the tip from `amount - remaining`
// when tip_enabled; it has no tip_amount field, so sending one would
// silently drop the tip).
const body = buildCashTillPaymentBody(Math.round(cashDue * 100), Math.round(tipAmount * 100));
const response = await apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const errData = await response.text();
throw new Error(extractErrorMessage(errData) || 'Failed to process payment');
}
const data = await response.json();
status = 'success';
paymentResult = {
checkout_id: data.checkout_id || data.id || '',
status: 'COMPLETED',
amount: data.amount
};
toast.success('Cash payment recorded');
onComplete(paymentResult);
} catch (_err) {
status = 'error';
error = _err instanceof Error ? _err.message : 'Failed to process payment';
toast.error(error ?? 'Unknown error');
} finally {
isProcessingPaymentSync = false;
}
}
let giftCardId = $state('');
let useAccountBalance = $state(false);
function formatAndPreserveCursor(
input: HTMLInputElement,
formatter: (val: string) => string,
charRegex: RegExp = /\d/
): string {
const rawValue = input.value;
const oldSelectionStart = input.selectionStart || 0;
let charsBeforeCursor = 0;
for (let i = 0; i < oldSelectionStart; i++) {
if (charRegex.test(rawValue[i])) {
charsBeforeCursor++;
}
}
const formatted = formatter(rawValue);
input.value = formatted;
let newSelectionStart = 0;
let charsFound = 0;
for (let i = 0; i < formatted.length; i++) {
if (charsFound === charsBeforeCursor) {
break;
}
if (charRegex.test(formatted[i])) {
charsFound++;
}
newSelectionStart++;
}
requestAnimationFrame(() => {
input.setSelectionRange(newSelectionStart, newSelectionStart);
});
return formatted;
}
function formatGiftCardId(value: string): string {
let raw = value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
if (raw.length > 12) raw = raw.slice(0, 12);
let formatted = '';
if (raw.length > 0) formatted += raw.slice(0, 4);
if (raw.length > 4) formatted += '-' + raw.slice(4, 8);
if (raw.length > 8) formatted += '-' + raw.slice(8, 12);
return formatted.toUpperCase();
}
function handleGiftCardInput(e: Event) {
const input = e.target as HTMLInputElement;
const formatted = formatAndPreserveCursor(input, formatGiftCardId, /[a-zA-Z0-9]/);
giftCardId = formatted;
}
const giftCardValid = $derived(useAccountBalance || giftCardId.replace(/-/g, '').length === 12);
async function handleGiftCardPayment() {
if (isProcessingPaymentSync) return;
if (!giftCardValid) {
toast.error('Please enter a valid 12-character gift card code');
return;
}
const giftDue = totalDue - loyaltyDiscount / 100;
if (giftDue <= 0) {
toast.error('Nothing to charge — the booking is fully covered by discounts');
return;
}
let payAmountPence = Math.round(giftDue * 100);
if (useAccountBalance) {
const parsedAmt = parseFloat(giftCardPaymentAmount);
if (isNaN(parsedAmt) || parsedAmt <= 0) {
toast.error('Please enter a valid payment amount');
return;
}
if (parsedAmt > customerBalance) {
toast.error('Payment amount exceeds available balance');
return;
}
payAmountPence = Math.round(parsedAmt * 100);
}
isProcessingPaymentSync = true;
status = 'gift-confirming';
error = null;
try {
await applyLoyaltyRedemption();
const body: {
amount: number;
payment_type: string;
payment_method: string;
gift_card_id?: string;
} = {
amount: payAmountPence,
payment_type: 'full',
payment_method: 'giftcard'
};
if (!useAccountBalance) {
body.gift_card_id = giftCardId.replace(/-/g, '');
}
const response = await apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const errData = await response.text();
throw new Error(extractErrorMessage(errData) || 'Failed to process gift card');
}
const data = await response.json();
status = 'success';
paymentResult = {
checkout_id: data.checkout_id || data.id || '',
status: 'COMPLETED',
amount: data.amount
};
toast.success('Gift card payment recorded');
onComplete(paymentResult);
} catch (_err) {
status = 'error';
error = _err instanceof Error ? _err.message : 'Failed to process gift card';
toast.error(error ?? 'Unknown error');
} finally {
isProcessingPaymentSync = false;
}
}
// Saved cards — fields match the backend SavedCard shape (brand, last_4,
// exp_month, exp_year), not the old card_brand/card_last4/card_expiry names
// which rendered blank.
let savedCards = $state<
Array<{
id: string;
brand: string;
last_4: string;
exp_month: number;
exp_year: number;
cardholder_name?: string;
square_card_id?: string;
}>
>([]);
let loadingSavedCards = $state(false);
let selectedSavedCardId = $state<string | null>(null);
// Per-attempt idempotency key for saved-card charges: regenerated whenever
// the (booking.id, selectedSavedCardId, charge amount) tuple changes, so
// two DISTINCT identical charges get different UUIDs, but reused across
// retries of the SAME charge so a lost-response retry dedups server-side.
// Mirrors the TipPayment.svelte tipIdempotencyKey/tipKeyedAmount pattern.
let savedCardIdempotencyKey = $state('');
let savedCardKeyedBookingId = $state('');
let savedCardKeyedCardId = $state('');
let savedCardKeyedAmount = $state(0);
async function fetchSavedCards() {
const targetUserId = booking.user_id ?? booking.user?.id;
if (!targetUserId) return;
loadingSavedCards = true;
savedCards = [];
selectedSavedCardId = null;
try {
const res = await apiFetch(`/api/admin/users/${targetUserId}/payment-methods`);
if (res.ok) {
savedCards = await res.json();
}
} catch {
toast.error('Failed to load saved cards');
} finally {
loadingSavedCards = false;
}
}
async function handleSavedCardPayment() {
if (isProcessingPaymentSync) return;
if (!selectedSavedCardId) {
toast.error('Please select a saved card');
return;
}
const chargeAmount = Math.round(savedCardAmountNum * 100) - loyaltyDiscount;
if (isNaN(savedCardAmountNum) || savedCardAmountNum <= 0) {
toast.error('Please enter a valid amount');
return;
}
if (savedCardAmountNum > totalDue) {
toast.error(`Amount cannot exceed ${formatCurrency(totalDue)}`);
return;
}
// totalDue can be £0 (fully discounted) and the loyalty discount is
// applied on top — the effective charge could otherwise be 0 or negative.
if (chargeAmount <= 0) {
toast.error('Nothing to charge — the booking is fully covered by discounts');
return;
}
// Reuse the key while the charge context is unchanged (retry of the
// same charge → server-side dedup); regenerate when the card or amount
// changes so distinct charges never collapse on one key.
if (
!savedCardIdempotencyKey ||
savedCardKeyedBookingId !== booking.id ||
savedCardKeyedCardId !== selectedSavedCardId ||
savedCardKeyedAmount !== chargeAmount
) {
savedCardIdempotencyKey = generateUUID();
savedCardKeyedBookingId = booking.id;
savedCardKeyedCardId = selectedSavedCardId;
savedCardKeyedAmount = chargeAmount;
}
isProcessingPaymentSync = true;
status = 'saved-card-processing';
error = null;
let responseStatus = 0;
try {
await saveServiceOverrides();
await applyLoyaltyRedemption();
// Proactive saved-card (ccof) SCA: run the client-side challenge
// BEFORE the first charge attempt so the first charge carries a
// fresh verification_token — a naked ccof is never sent to the
// backend. Only 'sca-unavailable' proceeds token-less (the 2FA gate
// is the fallback); a cancelled/failed challenge does NOT charge —
// the operator taps Pay again to re-run it, reusing the SAME cached
// idempotency key above so the retry dedups instead of double-charging.
let verificationToken = '';
status = 'saved-card-waiting-sca';
try {
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
const proactive = await runSavedCardSCAProactively({
amountPence: chargeAmount,
squareCardId: squareCardId ?? '',
buyer: {
givenName: booking.user?.first_name,
familyName: booking.user?.last_name,
email: booking.user?.email
},
onOutcome: (o) => (lastSCAOutcome = o)
});
if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
status = 'error';
error = CARD_VERIFICATION_RETRY_MESSAGE;
toast.error(error);
return;
}
if (proactive.outcome === 'sca-unavailable') {
// C6: SCA genuinely can't run. Stop the charge — a token-less
// ccof is never sent — and surface the refusal notice; there
// is NO 2FA fallback. The operator taps OK to close, or Back
// to pick a different payment method / retry SCA.
status = 'saved-card-selecting';
return;
}
verificationToken = proactive.verificationToken ?? '';
} finally {
status = 'saved-card-processing';
}
const response = await submitPaymentWithRetry(() =>
apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: chargeAmount,
payment_type: 'full',
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId,
// C1: the SCA tokenize-result token is the charge SOURCE
// (new_card_token) alongside the saved-card ref — never
// the legacy verification_token.
...(verificationToken ? { new_card_token: verificationToken } : {}),
idempotency_key: savedCardIdempotencyKey
})
})
);
if (!response.ok) {
responseStatus = response.status;
const errData = await response.text();
// Overflow guard: a 400 carrying the backend's
// `overflow_tip_confirmation_required` code means the charge
// exceeds the booking's remaining balance (stale data). Park the
// rejected request and surface the Confirm/Cancel prompt instead
// of a dead-end 400; Confirm resends the SAME body with the flag.
if (isOverflowTipConfirmationRequired(errData)) {
overflowConfirm = {
amountPence: chargeAmount,
overflowPence: Math.max(0, chargeAmount - Math.round(netTotal * 100)),
body: {
amount: chargeAmount,
payment_type: 'full',
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId,
...(verificationToken ? { new_card_token: verificationToken } : {}),
idempotency_key: savedCardIdempotencyKey
}
};
return;
}
// A 402 verification-required here means the fresh proactive
// token was stale/expired at Square — the charge did NOT land.
// Surface the SCA-first guidance; the operator taps Pay again and
// a fresh challenge runs under the SAME cached idempotency key
// (no double-charge). A plain decline 402 shows the normal error.
const err = new Error(
extractErrorMessage(errData) || 'Failed to process saved card payment'
);
(err as { bodyText?: string }).bodyText = errData;
throw err;
}
const data = await response.json();
status = 'success';
paymentResult = {
// Saved-card charges return payment_id (a DB payment row, not a
// Square checkout) — fall back to the other keys for the
// terminal/checkout responses.
checkout_id: data.payment_id || data.checkout_id || data.id || '',
status: 'COMPLETED',
card_brand: data.card_brand,
last4: data.card_last4,
amount: data.amount
};
// The charge succeeded — clear the cached key so the next (distinct)
// charge gets a fresh UUID and can't be deduped against this one.
savedCardIdempotencyKey = '';
savedCardKeyedAmount = 0;
toast.success('Saved card payment successful');
onComplete(paymentResult);
} catch (_err) {
status = 'error';
// A 402 carrying the structured verification-required signal (or the
// dev/mock text parity) surfaces the SCA-first guidance. A plain
// decline 402 shows the normal decline error — it must not be
// relabeled "requires verification".
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
if (isVerificationRequiredSignal(responseStatus, bodyText)) {
// A verification-required 402 means the backend did NOT accept
// the token — surface the SCA-first guidance and let the
// operator retry.
msg = VERIFICATION_REQUIRED_MESSAGE;
}
// A DEFINITIVE 402 (declined card / stale token) means the charge did
// NOT land — Square's idempotency key would otherwise reject a retry
// that re-runs SCA and mints a fresh token. Regenerate the key on 402
// so the next Pay click gets a fresh key + fresh pending row. Keep it
// on 503/network (ambiguous).
if (responseStatus === 402) {
savedCardIdempotencyKey = '';
savedCardKeyedBookingId = '';
savedCardKeyedCardId = '';
savedCardKeyedAmount = 0;
}
error = msg;
toast.error(msg);
} finally {
isProcessingPaymentSync = false;
}
}
$effect(() => {
if (selectedMethod === 'cash') {
cashAmount = cashDue.toFixed(2);
extraAsTip = false;
}
if (selectedMethod === 'giftcard') {
giftCardId = '';
}
if (selectedMethod === PAYMENT_METHOD_SAVED_CARD) {
fetchSavedCards();
}
});
</script>
<Dialog.Root
open={true}
onOpenChange={(open) => {
if (open) return;
// ESC/overlay while a charge is in flight must not close the modal — the
// charge may still land. ESC while the overflow-confirm prompt is showing
// dismisses the prompt (back to the amount-editing form), mirroring the
// customer modal, instead of closing the whole flow.
if (isChargeInFlight(status)) return;
if (overflowConfirm) {
cancelOverflowConfirmation();
return;
}
handleClose();
}}
>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
</Dialog.Header>
{#if overflowConfirm}
<div class="space-y-4">
<!-- Overpayment confirmation: the backend rejected the payment because
the booking's remaining balance has changed since it was loaded
(stale data). The excess over the remaining balance will be
recorded as a tip once confirmed. Shared component with the
customer payment modal and the booking-flow deposit step so the
admin surface can't drift. -->
<OverflowTipConfirm
overflowPence={overflowConfirm.overflowPence}
loading={status === 'saved-card-processing'}
onConfirm={confirmOverflowPayment}
onCancel={cancelOverflowConfirmation}
/>
<Button variant="ghost" onclick={handleClose} class="w-full">Close</Button>
</div>
{:else if status === 'idle'}
<div class="space-y-4">
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
<div
class="grid grid-cols-1 {(booking.services?.length ?? 0) > 1
? 'sm:grid-cols-2'
: ''} gap-3"
>
{#each booking.services ?? [] as service, i (service.service_id ?? `svc-${i}`)}
<div class="rounded-lg border bg-white p-3">
<div class="mb-2 text-sm font-medium">
{service.service_name || 'Unknown Service'}
</div>
<div class="flex min-w-0 items-center gap-2">
<span class="text-xs text-gray-500">£</span>
<input
type="text"
inputmode="decimal"
class="flex h-10 w-24 min-w-0 rounded-md border border-input bg-background px-2 py-1 text-base ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none md:text-sm"
value={serviceOverrides[service.service_id]?.price ??
service.price?.toFixed(2) ??
'0.00'}
oninput={(e) => handlePriceInput(service.service_id, e.currentTarget.value)}
/>
{#if serviceOverrides[service.service_id] && Math.abs(parseFloat(serviceOverrides[service.service_id].price) - serviceOverrides[service.service_id].originalPrice) > 0.01}
<span class="min-w-0 text-xs text-amber-600">
(was {formatCurrency(serviceOverrides[service.service_id].originalPrice)})
</span>
{/if}
</div>
</div>
{/each}
</div>
</div>
{#if loyaltyEligible}
<div class="rounded-md border border-fuchsia-100 bg-fuchsia-50 p-4">
<div class="flex items-start gap-3">
<Checkbox
id="use-loyalty-admin"
bind:checked={useLoyalty}
disabled={status !== 'idle'}
/>
<label for="use-loyalty-admin" class="cursor-pointer select-none">
<div class="text-sm font-medium text-fuchsia-900">Use Loyalty Stamp Card</div>
<div class="mt-0.5 text-xs text-fuchsia-700">
{Math.floor(stamps / 10)} full card{Math.floor(stamps / 10) === 1 ? '' : 's'} available
&middot; {Math.round(POLICY.LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(
Math.round(booking.total_amount * 100 * POLICY.LOYALTY_DISCOUNT_RATE) / 100
)})
</div>
</label>
</div>
</div>
{/if}
{#if booking.discounts && booking.discounts.length > 0}
<div class="rounded-md border border-gray-100 bg-gray-50/50 p-4">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-1.5 text-sm font-semibold text-gray-800">
<svg
class="h-4 w-4 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"
></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
Applied Discounts
</div>
<span
class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600"
>
{((discountSum / subtotal) * 100).toFixed(0)}% Off Total
</span>
</div>
<div class="space-y-2 text-sm">
{#each booking.discounts as d (d.id)}
<div class="flex items-center justify-between text-gray-600">
<div class="flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
<span>
{#if d.discount_source === 'loyalty'}
Loyalty Stamp Card (10% Off)
{:else if d.campaign_name}
{d.campaign_name}
{:else}
Promo Campaign ({d.discount_percent}% Off)
{/if}
</span>
</div>
<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span
>
</div>
{/each}
</div>
</div>
{/if}
<div
class="flex items-center justify-between rounded-md border border-gray-200 bg-white p-4"
>
{#if tipEnabled}
<span class="text-base font-semibold text-gray-700">Subtotal (pre-tip)</span>
<div class="flex items-baseline gap-2.5">
{#if discountSum > 0.01}
<span class="text-sm font-medium text-gray-400 line-through"
>{formatCurrency(subtotal)}</span
>
{/if}
<span class="text-xl font-bold text-gray-900">{formatCurrency(netTotal)}</span>
</div>
{:else}
<span class="text-base font-semibold text-gray-700">Total</span>
<div class="flex items-baseline gap-2.5">
{#if discountSum > 0.01}
<span class="text-sm font-medium text-gray-400 line-through"
>{formatCurrency(subtotal)}</span
>
{/if}
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
{/if}
</div>
{#if nothingToCharge}
<p class="rounded-md border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
Nothing to charge — the booking is fully covered by discounts or prior payments.
</p>
{/if}
{#if amountPaidPence > 0}
<div
class="flex items-center justify-between rounded-md border border-green-200 bg-green-50 p-3"
>
<span class="text-sm font-medium text-green-800">Already paid</span>
<span class="text-base font-bold text-green-800"
>{formatCurrency(amountPaidPence / 100)}</span
>
</div>
{/if}
{#if discountPreview?.eligible && discountPreview.discounts.length > 0}
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
{#each discountPreview.discounts as d (d.name)}
<div class="flex justify-between text-sm">
<span class="text-gray-600">{d.name}</span>
<span class="font-medium text-green-700"
>-{formatCurrency(Math.round(d.amount * 100) / 100)}</span
>
</div>
{/each}
</div>
{/if}
{#if tipEnabled}
<div class="rounded-md border border-green-200 bg-green-50 p-3">
<div class="flex justify-between">
<span class="text-sm font-medium text-green-800">
Total with Tip ({tipDisplay})
</span>
<span class="text-lg font-bold text-green-800">
{formatCurrency(totalWithTip)}
</span>
</div>
<div class="mt-1 flex justify-between text-xs font-medium text-green-700">
<span>Tip amount</span>
<span>+{formatCurrency(tipDelta)}</span>
</div>
</div>
{/if}
<div
class="grid grid-cols-2 gap-3 {savedCards.length > 0
? 'sm:grid-cols-4'
: 'sm:grid-cols-3'}"
>
<button
type="button"
disabled={nothingToCharge}
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
'card'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = 'card';
status = 'selecting';
}}
>
<svg
class="mx-auto mb-2 h-8 w-8"
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>
Card
</button>
<button
type="button"
disabled={nothingToCharge}
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
'cash'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = 'cash';
status = 'cash-entering';
}}
>
<svg
class="mx-auto mb-2 h-8 w-8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
Cash
</button>
{#if savedCards.length > 0}
<button
type="button"
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
PAYMENT_METHOD_SAVED_CARD
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = PAYMENT_METHOD_SAVED_CARD;
status = 'saved-card-selecting';
}}
>
<svg
class="mx-auto mb-2 h-8 w-8"
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" />
<path d="M6 10h12" />
<path d="M6 14h6" />
</svg>
Saved Card
</button>
{/if}
<button
type="button"
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
'giftcard'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = 'giftcard';
status = 'gift-entering';
}}
>
<svg
class="mx-auto mb-2 h-8 w-8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="20 12 20 22 4 22 4 12" />
<rect x="2" y="7" width="20" height="5" />
<line x1="12" y1="22" x2="12" y2="7" />
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z" />
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z" />
</svg>
Gift Card
</button>
</div>
<div class="flex flex-wrap gap-3 sm:hidden">
{#if savedCards.length > 0}
<button
type="button"
disabled={nothingToCharge}
class="min-h-11 w-full text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => {
selectedMethod = PAYMENT_METHOD_SAVED_CARD;
status = 'saved-card-selecting';
}}
>
Pay with Saved Card
</button>
{/if}
<button
type="button"
disabled={nothingToCharge}
class="min-h-11 w-full text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => {
selectedMethod = 'giftcard';
status = 'gift-entering';
}}
>
Pay with Gift Card
</button>
</div>
<div class="flex gap-3">
<Button variant="ghost" onclick={handleClose} class="min-h-11 flex-1">Cancel</Button>
</div>
</div>
{:else if status === 'selecting'}
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
{#if tipEnabled}
<span class="text-base font-semibold text-gray-700">Subtotal (pre-tip)</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(netTotal)}</span>
{:else}
<span class="text-base font-semibold text-gray-700">Total</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
{/if}
</div>
{#if campaignDiscountPence(discountPreview) > 0}
<p class="rounded-md border border-green-200 bg-green-50 p-2.5 text-xs text-green-800">
Discount {formatCurrency(campaignDiscountPence(discountPreview) / 100)} pending — tip will
be calculated on the discounted amount.
</p>
{/if}
<div class="space-y-3">
<span class="text-sm font-medium text-gray-700">Add a Tip</span>
<div class="grid grid-cols-3 gap-2">
{#each tipPercentages as tip (tip.pct)}
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {selectedTipPercent ===
tip.pct
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input'}"
onclick={() => selectTipPercent(tip.pct)}
>
<div>{tip.pct}%</div>
<div class="text-xs font-normal text-gray-500">{formatCurrency(tip.amount)}</div>
</button>
{/each}
</div>
<div class="relative">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input
type="text"
inputmode="decimal"
placeholder="Custom tip amount"
value={customTipAmount}
oninput={handleCustomTipInput}
class="pl-7"
/>
</div>
</div>
{#if tipEnabled}
<div class="rounded-md border border-green-200 bg-green-50 p-3">
<div class="flex justify-between">
<span class="text-sm font-medium text-green-800">
Total with Tip ({tipDisplay})
</span>
<span class="text-lg font-bold text-green-800">
{formatCurrency(totalWithTip)}
</span>
</div>
<div class="mt-1 flex justify-between text-xs font-medium text-green-700">
<span>Tip amount</span>
<span>+{formatCurrency(tipDelta)}</span>
</div>
</div>
{/if}
<div class="border-t pt-3">
<button
type="button"
onclick={() => (showPartialCharge = !showPartialCharge)}
class="text-sm text-gray-600 hover:text-gray-800 hover:underline"
>
{showPartialCharge ? ' Hide partial charge' : '+ Partial charge'}
</button>
{#if showPartialCharge}
<div class="mt-3">
<label for="card-amount" class="text-sm font-medium text-gray-700">
Amount to Charge
</label>
<div class="relative mt-1">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input
id="card-amount"
type="text"
inputmode="decimal"
step="0.01"
placeholder={totalDue.toFixed(2)}
value={cardPaymentAmount}
oninput={handleCardAmountInput}
class="pl-7"
disabled={status !== 'selecting'}
/>
</div>
{#if cardValidationError}
<p class="mt-1 text-sm text-red-600">{cardValidationError}</p>
{/if}
<p class="mt-1 text-xs text-gray-500">
Leave blank to charge full amount ({formatCurrency(totalDue)})
</p>
</div>
{/if}
</div>
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
<Button
onclick={handleCardPayment}
class="min-h-11 flex-1"
disabled={nothingToCharge || !cardAmountValid}
>
{cardPaymentAmount === '' ? 'Charge Card' : `Charge ${formatCurrency(cardAmountNum)}`}
</Button>
</div>
</div>
{:else if status === 'card-processing' || status === 'card-polling'}
<div class="flex flex-col items-center justify-center py-8">
<div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="text-lg font-medium text-gray-700">Waiting for customer to tap card...</p>
<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
</div>
{:else if status === 'cash-entering'}
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total Due</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(cashDue)}</span>
</div>
<div>
<label for="cash-amount" class="text-sm font-medium text-gray-700"> Cash Received </label>
<div class="relative mt-1">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input
id="cash-amount"
type="text"
inputmode="decimal"
value={cashAmount}
oninput={handleCashInput}
class="pl-7 text-lg font-semibold"
/>
</div>
</div>
{#if cashAmountNum >= cashDue}
<div class="rounded-md border border-green-200 bg-green-50 p-4">
<div class="flex justify-between">
<span class="text-sm font-medium text-green-800">Change Due</span>
<span class="text-lg font-bold text-green-800">{formatCurrency(changeDue)}</span>
</div>
{#if changeDue > 0}
<div class="mt-2 flex items-center gap-2">
<Checkbox id="keep-change" bind:checked={extraAsTip} />
<label for="keep-change" class="text-sm text-green-700">
Keep {formatCurrency(changeDue)} as tip
</label>
</div>
{/if}
</div>
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
<Button
onclick={handleCashPayment}
class="min-h-11 flex-1"
disabled={cashAmountNum < cashDue || nothingToCharge}
>
Confirm Cash
</Button>
</div>
</div>
{:else if status === 'cash-confirming'}
<div class="flex flex-col items-center justify-center py-8">
<div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="text-lg font-medium text-gray-700">Processing cash payment...</p>
</div>
{:else if status === 'gift-entering'}
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total Due</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
{#if (booking.user_id ?? booking.user?.id) && customerBalance > 0}
<div class="space-y-2">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Source</span
>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {useAccountBalance
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => (useAccountBalance = true)}
>
Account Balance ({formatCurrency(customerBalance)})
</button>
<button
type="button"
class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {!useAccountBalance
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-gray-200 hover:bg-gray-50'}"
onclick={() => (useAccountBalance = false)}
>
Physical Gift Card Code
</button>
</div>
</div>
{/if}
{#if useAccountBalance}
<div>
<label for="giftcard-amount" class="text-sm font-medium text-gray-700"
>Amount to pay with Balance (£)</label
>
<div class="mt-1 flex gap-2">
<Input
id="giftcard-amount"
type="text"
inputmode="decimal"
value={giftCardPaymentAmount}
oninput={(e) => (giftCardPaymentAmount = (e.target as HTMLInputElement).value)}
class="flex-1 font-mono text-lg"
/>
<Button
variant="outline"
size="sm"
onclick={() => {
giftCardPaymentAmount = Math.min(customerBalance, totalDue).toFixed(2);
}}
class="shrink-0 text-xs"
>
Full Balance
</Button>
</div>
<p class="mt-1 text-xs text-gray-500">
Available balance: {formatCurrency(customerBalance)}. Maximum of total due or balance
can be used.
</p>
</div>
{:else}
<div>
<label for="gift-card-id" class="text-sm font-medium text-gray-700">
Gift Card Code
</label>
<Input
id="gift-card-id"
type="text"
inputmode="text"
value={giftCardId}
oninput={handleGiftCardInput}
placeholder="XXXX-XXXX-XXXX"
maxlength={14}
class="mt-1 font-mono text-base tracking-wide"
/>
<p class="mt-1 text-xs text-gray-500">
Enter the 12-character code printed on the gift card
</p>
</div>
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
<Button
onclick={handleGiftCardPayment}
class="min-h-11 flex-1"
disabled={!giftCardValid || nothingToCharge}
>
Apply Gift Card
</Button>
</div>
</div>
{:else if status === 'gift-confirming'}
<div class="flex flex-col items-center justify-center py-8">
<div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="text-lg font-medium text-gray-700">Processing gift card...</p>
</div>
{:else if status === 'saved-card-selecting'}
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total Due</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
{#if loadingSavedCards}
<div class="flex justify-center py-8">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
</div>
{:else if savedCards.length === 0}
<div class="rounded-md border border-gray-200 bg-gray-50 p-6 text-center">
<p class="text-sm text-gray-600">No saved cards found for this customer.</p>
<p class="mt-1 text-xs text-gray-500">
Add a card via Square Dashboard or use another payment method.
</p>
</div>
{:else}
<div class="space-y-2">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Select a Saved Card</span
>
{#each savedCards as card (card.id)}
<button
type="button"
class="w-full rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {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>
{#if card.cardholder_name}
<div class="mt-1 text-xs text-gray-500">{card.cardholder_name}</div>
{/if}
</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}
<!-- C6: SCA-unavailable refusal — the ONLY behaviour on a genuine
sca-unavailable outcome: the charge cannot complete and the
customer must pay online later (no 2FA code fallback). -->
<ScaFallbackConsentDialog
open={shouldShowSCARefusal(lastSCAOutcome)}
onOk={() => {
lastSCAOutcome = '';
handleClose();
}}
/>
{#if selectedSavedCardId}
<div class="border-t pt-3">
<button
type="button"
onclick={() => (showPartialCharge = !showPartialCharge)}
class="text-sm text-gray-600 hover:text-gray-800 hover:underline"
>
{showPartialCharge ? ' Hide partial charge' : '+ Partial charge'}
</button>
{#if showPartialCharge}
<div class="mt-3">
<label for="saved-card-amount" class="text-sm font-medium text-gray-700">
Amount to Charge
</label>
<div class="relative mt-1">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input
id="saved-card-amount"
type="text"
inputmode="decimal"
step="0.01"
placeholder={totalDue.toFixed(2)}
value={savedCardPaymentAmount}
oninput={handleSavedCardAmountInput}
class="pl-7"
disabled={status !== 'saved-card-selecting'}
/>
</div>
{#if savedCardValidationError}
<p class="mt-1 text-sm text-red-600">{savedCardValidationError}</p>
{/if}
<p class="mt-1 text-xs text-gray-500">
Leave blank to charge full amount ({formatCurrency(totalDue)})
</p>
</div>
{/if}
</div>
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
<Button
onclick={handleSavedCardPayment}
class="min-h-11 flex-1"
disabled={
!selectedSavedCardId || nothingToCharge || !savedCardAmountValid
}
>
{savedCardPaymentAmount === ''
? 'Charge Saved Card'
: `Charge ${formatCurrency(savedCardAmountNum)}`}
</Button>
</div>
</div>
{:else if status === 'saved-card-processing' || status === 'saved-card-waiting-sca'}
<div class="flex flex-col items-center justify-center py-8">
<div
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
{#if status === 'saved-card-waiting-sca'}
<p class="text-lg font-medium text-gray-700">
Waiting for customer to approve in their banking app…
</p>
<p class="mt-2 text-sm text-gray-500">
The customer may need to approve this payment in their banking app
</p>
{:else}
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
{/if}
</div>
{:else if status === 'error' && error}
<div class="space-y-4">
<div class="rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-800">{error}</p>
</div>
<div class="flex gap-3">
<Button variant="ghost" onclick={handleClose} class="min-h-11 flex-1">Close</Button>
<Button onclick={resetToSelect} class="min-h-11 flex-1">Try Again</Button>
</div>
</div>
{:else if status === 'success' && paymentResult}
<div class="space-y-4">
<div class="flex flex-col items-center justify-center py-4">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-green-600"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900">Payment Successful</h3>
</div>
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Amount</span>
<span class="font-semibold text-gray-900">
{formatCurrency(paymentResult.amount / 100)}
</span>
</div>
{#if paymentResult.card_brand}
<div class="flex justify-between">
<span class="text-sm text-gray-600">Card</span>
<span class="font-medium text-gray-900">
{paymentResult.card_brand} ****{paymentResult.last4}
</span>
</div>
{/if}
<div class="flex justify-between">
<span class="text-sm text-gray-600">Status</span>
<span class="font-medium text-green-600">Completed</span>
</div>
</div>
</div>
<Button onclick={handleSuccessDone} class="min-h-11 w-full">Done</Button>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>