Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests
All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
1547 lines
50 KiB
Svelte
1547 lines
50 KiB
Svelte
<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 {
|
|
campaignDiscountPence,
|
|
isSavedCardVerificationRequired,
|
|
isTwoFactorVerificationGateFailure,
|
|
sanitizeDecimalInput,
|
|
SAVED_CARD_VERIFICATION_MESSAGE,
|
|
submitPaymentWithRetry
|
|
} from '$lib/square/square';
|
|
import { authStore } from '$lib/stores/auth.svelte';
|
|
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
|
import { generateUUID } from '$lib/utils/uuid';
|
|
|
|
const LOYALTY_DISCOUNT_RATE = 0.1;
|
|
|
|
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'
|
|
| 'success'
|
|
| 'error';
|
|
|
|
type PaymentResult = {
|
|
checkout_id: string;
|
|
status: string;
|
|
card_brand?: string;
|
|
last4?: string;
|
|
amount: number;
|
|
};
|
|
|
|
type PaymentMethod = 'card' | 'cash' | 'giftcard' | 'savedcard' | 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;
|
|
|
|
// B6/B10: charging a customer's saved card requires the customer's current
|
|
// 2FA verification code when the backend enforces the gate. The backend keys
|
|
// on the CARD OWNER (not the admin), so the input is surfaced whenever the
|
|
// gate is enforced — the operator relays the customer's code.
|
|
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
|
|
|
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
|
|
let useLoyalty = $state(false);
|
|
|
|
// B6/B10: verification code for the customer's saved-card charge, collected
|
|
// on the saved-card screen. Kept populated across retries so an
|
|
// invalid/expired code can be corrected without re-typing it. The admin
|
|
// always supplies the CUSTOMER's code — the admin's own 2FA flag is
|
|
// irrelevant to the backend gate.
|
|
let twoFactorCode = $state('');
|
|
// Set true when a charge 403s for a missing code — reveals the input even
|
|
// if the session user's flag is unset.
|
|
let reveal2FACodeInput = $state(false);
|
|
const show2FACodeInput = $derived(reveal2FACodeInput || savedCardChargeRequires2FACode);
|
|
const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === '');
|
|
|
|
// 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).
|
|
let amountPaidPence = $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);
|
|
return;
|
|
}
|
|
}
|
|
} catch (_err) {
|
|
// fall through to the booking prop below
|
|
}
|
|
amountPaidPence = Math.round((booking.amount_paid ?? 0) * 100);
|
|
}
|
|
|
|
const loyaltyEligible = $derived(
|
|
stamps >= 10 &&
|
|
!(booking.discounts ?? []).some((d: BookingDiscount) => d.discount_source === 'loyalty') &&
|
|
booking.total_amount > 0 &&
|
|
amountPaidPence === 0
|
|
);
|
|
|
|
const loyaltyDiscount = $derived(
|
|
useLoyalty ? Math.round(booking.total_amount * 100 * 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>('');
|
|
|
|
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 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.
|
|
const netTotal = $derived(
|
|
Math.max(
|
|
0,
|
|
subtotal - discountSum - campaignDiscountPence(discountPreview) - amountPaidPence
|
|
)
|
|
);
|
|
|
|
const tipPercentages = $derived.by(() => {
|
|
if (netTotal <= 0) return [];
|
|
return [
|
|
{ pct: 10, amount: Math.round(netTotal * 0.1 * 100) / 100 },
|
|
{ pct: 15, amount: Math.round(netTotal * 0.15 * 100) / 100 },
|
|
{ pct: 20, amount: Math.round(netTotal * 0.2 * 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
|
|
? `£${parseFloat(customTipAmount).toFixed(2)}`
|
|
: ''
|
|
);
|
|
|
|
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);
|
|
|
|
function formatCurrency(value: number): string {
|
|
return new Intl.NumberFormat('en-GB', {
|
|
style: 'currency',
|
|
currency: 'GBP'
|
|
}).format(value);
|
|
}
|
|
|
|
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 handleCardPayment() {
|
|
if (isProcessingPaymentSync) return;
|
|
const finalAmount = totalDue;
|
|
|
|
if (isNaN(finalAmount) || finalAmount <= 0) {
|
|
toast.error('Please enter a valid amount');
|
|
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 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();
|
|
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();
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
$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));
|
|
const changeDue = $derived(cashAmountNum > totalDue ? cashAmountNum - totalDue : 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;
|
|
const cashDue = totalDue - loyaltyDiscount / 100;
|
|
|
|
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();
|
|
|
|
const body: Record<string, unknown> = {
|
|
amount: Math.round(cashDue * 100),
|
|
payment_type: 'full',
|
|
payment_method: 'cash'
|
|
};
|
|
if (tipAmount > 0) {
|
|
body.tip_enabled = true;
|
|
body.tip_amount = 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;
|
|
}>
|
|
>([]);
|
|
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(totalDue * 100) - loyaltyDiscount;
|
|
|
|
// 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 applyLoyaltyRedemption();
|
|
|
|
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,
|
|
...(show2FACodeInput ? { verification_code: twoFactorCode } : {}),
|
|
idempotency_key: savedCardIdempotencyKey
|
|
})
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
responseStatus = response.status;
|
|
const errData = await response.text();
|
|
throw new Error(extractErrorMessage(errData) || 'Failed to process saved card payment');
|
|
}
|
|
|
|
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;
|
|
twoFactorCode = '';
|
|
reveal2FACodeInput = false;
|
|
toast.success('Saved card payment successful');
|
|
onComplete(paymentResult);
|
|
} catch (_err) {
|
|
status = 'error';
|
|
// Saved-card (ccof) charges skip the client-side SCA step, so a
|
|
// definitive 402 on the saved-card path means the issuer still
|
|
// requires verification — retrying the same saved card can never
|
|
// succeed. Surface the fix instead of the generic backend text.
|
|
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
|
if (isSavedCardVerificationRequired(responseStatus, true))
|
|
msg = SAVED_CARD_VERIFICATION_MESSAGE;
|
|
// 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 charge can be retried with a fresh code.
|
|
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) reveal2FACodeInput = true;
|
|
error = msg;
|
|
toast.error(msg);
|
|
} finally {
|
|
isProcessingPaymentSync = false;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (selectedMethod === 'cash') {
|
|
cashAmount = totalDue.toFixed(2);
|
|
extraAsTip = false;
|
|
}
|
|
if (selectedMethod === 'giftcard') {
|
|
giftCardId = '';
|
|
}
|
|
if (selectedMethod === 'savedcard') {
|
|
fetchSavedCards();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
|
<Dialog.Content class="max-w-lg">
|
|
<Dialog.Header>
|
|
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
|
|
</Dialog.Header>
|
|
|
|
{#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"
|
|
tabindex={-1}
|
|
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 £{serviceOverrides[service.service_id].originalPrice.toFixed(2)})
|
|
</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
|
|
· {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(
|
|
Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE)
|
|
)})
|
|
</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)}</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))}</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 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 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 sm:block disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
|
|
'savedcard'
|
|
? 'border-input bg-fuchsia-100 text-foreground'
|
|
: 'border-input hover:bg-fuchsia-50'}"
|
|
onclick={() => {
|
|
selectedMethod = 'savedcard';
|
|
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 sm:block disabled:cursor-not-allowed disabled:opacity-50 {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="text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
|
onclick={() => {
|
|
selectedMethod = 'savedcard';
|
|
status = 'saved-card-selecting';
|
|
}}
|
|
>
|
|
Pay with Saved Card
|
|
</button>
|
|
{/if}
|
|
<button
|
|
type="button"
|
|
disabled={nothingToCharge}
|
|
class="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="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>
|
|
|
|
<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 {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">£{tip.amount.toFixed(2)}</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"
|
|
tabindex={-1}
|
|
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="flex gap-3">
|
|
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
|
<Button onclick={handleCardPayment} class="flex-1" disabled={nothingToCharge}>
|
|
Charge Card
|
|
</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(totalDue)}</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"
|
|
tabindex={-1}
|
|
value={cashAmount}
|
|
oninput={handleCashInput}
|
|
class="pl-7 text-lg font-semibold"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{#if cashAmountNum >= totalDue}
|
|
<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="flex-1">Back</Button>
|
|
<Button
|
|
onclick={handleCashPayment}
|
|
class="flex-1"
|
|
disabled={cashAmountNum < totalDue || 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"
|
|
tabindex={-1}
|
|
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="flex-1">Back</Button>
|
|
<Button
|
|
onclick={handleGiftCardPayment}
|
|
class="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 {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">
|
|
This card may require bank app confirmation to complete. Ensure the customer has their
|
|
phone ready.
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- B6/B10: saved-card charges require the customer's current 2FA
|
|
verification code when the backend enforces the gate. -->
|
|
<TwoFactorCodeInput bind:code={twoFactorCode} showInput={show2FACodeInput} enabled={true} />
|
|
|
|
<div class="flex gap-3">
|
|
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
|
<Button
|
|
onclick={handleSavedCardPayment}
|
|
class="flex-1"
|
|
disabled={!selectedSavedCardId || nothingToCharge || missing2FACode}
|
|
>
|
|
Charge Saved Card
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{:else if status === 'saved-card-processing'}
|
|
<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 saved card payment...</p>
|
|
</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="flex-1">Close</Button>
|
|
<Button onclick={resetToSelect} class="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="w-full">Done</Button>
|
|
</div>
|
|
{/if}
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|