Files
Crussell/frontend/src/lib/components/payments/UserPaymentModal.svelte
T
popertots a8d54f1e2a Fix review findings: aggregated-refund/saved-card/legacy-refund idempotency keys, structured Square error classification, CSP for Square SDK
Money-safety idempotency fixes (external review bugs 1-3):
- processChargeGroup: aggregated refund key now hashes the sorted pending-row
  set (chargeID-square-agg-<sha256 suffix>) so a changed group can never mark
  a new row completed against an old smaller refund; >45-char chargeIDs use a
  hashed prefix instead of verbatim truncation (which would collide charges on
  Square's global key dedup). Same-set crash-retry keeps Square's dedup.
- CreateTerminalPayment saved_card: two-tier idempotency key — client-supplied
  per-attempt UUID preferred (distinct identical charges no longer collapse),
  deterministic booking+type+amount+card fallback for no-key retry safety.
  PaymentModal sends a per-charge UUID cleared after success.
- ensureRefundKey: legacy NULL-key manual refunds persist a generated key to
  the row BEFORE the Square call (race-safe AND idempotency_key IS NULL guard),
  so a lost-response retry reuses the key and never double-refunds. Wired into
  resumeManualPendingRefund and the sweep's manual-retry loop.

Classification + money-safety hardening:
- till.go/sweep.go: structured square.ErrorCode/IsNotFound are authoritative
  when present; message-substring matching only for non-structured errors
  (dev mock, client-side status errors). Fixes fragile string-matching driving
  sweep retries and gift-card clawbacks.
- SaveCardForUser: ON CONFLICT (user_id, square_card_id) DO NOTHING + re-select
  (was a latent UNIQUE-violation 500 on save-card retry).
- CreateBookingPayment: partial payments re-validated against remaining balance
  inside the advisory lock (closes concurrent-overpayment race).
- InvalidateSquareCustomerCache on GDPR erasure paths (account.go,
  time-blockers.go stale-guest anonymization).
- GetUserGiftCardBalanceAdmin: in-handler admin check (defense-in-depth).
- getCheckoutHTTP: warn on multi-payment checkouts instead of dropping
  payments[1:].
- Cash/giftcard terminal branch: removed dead idempotency SELECT, "tip-" ->
  "till-" prefix.
- UserPaymentModal: removed vestigial polling state; proper interval cleanup.
- account/+page.svelte: gift-card redeem dialog links /terms.
- nginx CSP: allow *.squarecdn.com and js.squareup.com so the Square Web
  Payments SDK + card iframe can tokenize behind the proxy.

Tests: +8 regression tests covering changed-set refund keys, legacy NULL-key
single-refund, saved-card client-key dedup/no-dedup, concurrent partials, and
cache invalidation. Full suite + race detector clean via run-tests.sh lockfile.
2026-08-22 00:34:49 +01:00

977 lines
31 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } 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 } from '$lib/types/booking';
import type { UserSavedCard } from '$lib/types';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
const LOYALTY_DISCOUNT_RATE = 0.1;
interface Props {
booking: Booking;
onClose: () => void;
onComplete: () => void;
canSaveCards?: boolean;
defaultPaymentType?: 'full' | 'partial' | 'deposit';
}
const {
booking,
onClose,
onComplete,
canSaveCards = false,
defaultPaymentType
}: Props = $props();
// Explicit consent: whether the new card is saved for next time. Toggled by
// the checkbox inside CardSelection; defaults to false (opt-in).
let saveCard = $state(false);
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
let status = $state<PaymentStatus>('idle');
let error = $state<string | null>(null);
// Cached idempotency key per payment attempt (amount + type + card): reused
// on retry so a lost-response retry dedups instead of double-charging,
// regenerated when any of those change. Matches the tip-flow pattern.
let payIdempotencyKey = $state('');
let payKeyedAmount = $state(0);
let payKeyedType = $state('');
let payKeyedCard = $state('');
// Cached nonce for the new-card form: tokenization is one-shot, so a retry
// reuses this token instead of re-tokenizing (backend idempotency dedups).
let newCardNonce = $state('');
// Cached SCA verification token paired with newCardNonce (both one-shot,
// reused together on retry). The verification token is amount-bound, so a
// changed payment amount invalidates the cached pair.
let newCardVerificationToken = $state('');
let newCardTokenAmount = $state(0);
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
// verification tokens expire after ~5 minutes, so a stale pair is discarded
// on late retries and re-tokenized instead of rejected by Square.
let newCardTokenizedAt = $state(0);
let paymentResult = $state<{
id: string;
amount: number;
card_brand?: string;
card_last4?: string;
payment_type: string;
} | null>(null);
// Card selection state
let paymentMethods = $state<UserSavedCard[]>([]);
let paymentMethodsLoading = $state(false);
let selectedCardId = $state('');
let cardSelectionValid = $state(false);
let cardSelection = $state<CardSelection | null>(null);
let stamps = $state(0);
let useLoyalty = $state(false);
// Partial payment amount (in pounds, user enters)
let partialAmount = $state<string>('');
let lastValidPartialAmount = $state<string>('');
// Campaign discount preview — fetched on mount to show eligible discounts
let discountPreview = $state<{
eligible: boolean;
discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
original_total: number;
discounted_total: number;
} | null>(null);
// Derived values
const depositOutstanding = $derived(booking.deposit_required && !booking.deposit_paid);
// Auto-select a sensible default payment type based on the booking's deposit
// state. The backend will split the charge into deposit + non-deposit records
// when appropriate, so this choice mainly controls the button label and amount.
const defaultType = $derived(defaultPaymentType ?? (depositOutstanding ? 'deposit' : 'full'));
// eslint-disable-next-line svelte/prefer-writable-derived
let paymentType = $state<'full' | 'partial' | 'deposit'>('full');
$effect(() => {
paymentType = defaultType as 'full' | 'partial' | 'deposit';
});
// Payment lock state
let lockTimer = $state(-1);
let lockAcquired = $state(false);
let lockInterval: ReturnType<typeof setInterval> | null = null;
let countdownInterval: ReturnType<typeof setInterval> | null = null;
const servicesSubtotal = $derived(
(booking.services ?? []).reduce((sum, s) => sum + (s.price || 0), 0)
);
const discountSum = $derived(
(booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0)
);
const totalPaid = $derived(
booking.payments
?.filter((p) => p.status === 'completed')
.reduce((sum, p) => sum + p.amount, 0) || 0
);
const amountRemaining = $derived(booking.total_amount - totalPaid);
const loyaltyEligible = $derived(
stamps >= 10 &&
!(booking.discounts ?? []).some((d) => d.discount_source === 'loyalty') &&
booking.total_amount > 0 &&
booking.amount_paid === 0
);
const loyaltyDiscount = $derived(
useLoyalty ? Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) : 0
);
// Deposit policy warning text — dynamic based on booking state
const expectedDepositPercent = $derived(booking.deposit_required ? 20 : 0);
const depositPolicyWarning = $derived<string | null>(
{
get text(): string | null {
if (!booking.deposit_required && totalPaid === 0 && booking.amount_due <= 0) return null;
if (booking.deposit_required) {
return `A ${expectedDepositPercent}% deposit (at least £${(booking.total_amount * 0.2).toFixed(2)}) is required. Any payments up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) are treated as deposit for cancellations.`;
}
if (totalPaid > 0 || booking.amount_due > 0) {
return `Any payment up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
}
return null;
}
}.text
);
const isScenarioA = $derived(depositOutstanding);
const isScenarioB = $derived(!depositOutstanding && totalPaid < booking.total_amount);
const partialAmountNum = $derived(partialAmount === '' ? 0 : parseFloat(partialAmount));
const partialAmountValid = $derived(
paymentType === 'partial' &&
partialAmount !== '' &&
!isNaN(partialAmountNum) &&
partialAmountNum > 0 &&
partialAmountNum <= amountRemaining &&
/^\d+(\.\d{0,2})?$/.test(partialAmount)
);
const partialValidationError = $derived(
paymentType === 'partial' && !partialAmountValid
? partialAmount === ''
? 'Enter an amount'
: !/^\d+(\.\d{0,2})?$/.test(partialAmount)
? 'Invalid amount format'
: partialAmountNum <= 0
? 'Amount must be greater than 0'
: partialAmountNum > amountRemaining
? 'Amount exceeds balance'
: 'Invalid amount'
: null
);
const payButtonDisabled = $derived(
status === 'processing' ||
!cardSelectionValid ||
(paymentType === 'partial' && !partialAmountValid) ||
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
);
function campaignDiscountCents(): number {
return discountPreview?.eligible
? discountPreview.discounts.reduce((sum, d) => sum + Math.round(d.amount * 100), 0)
: 0;
}
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(pence / 100);
}
function formatTimer(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function clearLockIntervals() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
if (lockInterval) {
clearInterval(lockInterval);
lockInterval = null;
}
}
async function acquireLock() {
try {
const response = await apiFetch(`/api/bookings/${booking.id}/payment-lock`, {
method: 'POST'
});
if (response.ok) {
lockAcquired = true;
lockTimer = 300;
}
} catch (_err) {
console.error('Failed to acquire payment lock:', _err);
}
}
async function releaseLock() {
lockAcquired = false;
clearLockIntervals();
try {
await apiFetch(`/api/bookings/${booking.id}/payment-lock`, {
method: 'DELETE'
});
} catch (_err) {
console.error('Failed to release payment lock:', _err);
}
}
function startCountdown() {
countdownInterval = setInterval(() => {
lockTimer = Math.max(0, lockTimer - 1);
}, 1000);
}
function startRenewal() {
lockInterval = setInterval(async () => {
try {
const response = await apiFetch(`/api/bookings/${booking.id}/payment-lock`, {
method: 'POST'
});
if (response.ok) {
lockTimer = 300;
lockAcquired = true;
}
} catch (_err) {
console.error('Failed to renew payment lock:', _err);
}
}, 60000);
}
function generateIdempotencyKey(): string {
const array = new Uint8Array(16);
if (typeof window !== 'undefined' && window.crypto) {
window.crypto.getRandomValues(array);
} else {
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
}
array[6] = (array[6] & 0x0f) | 0x40;
array[8] = (array[8] & 0x3f) | 0x80;
return [...array]
.map((b, i) => {
const hex = b.toString(16).padStart(2, '0');
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
return hex;
})
.join('');
}
async function fetchPaymentMethods() {
if (!authStore.isAuthenticated) return;
paymentMethodsLoading = true;
try {
const response = await apiFetch('/api/user/payment-methods');
if (response.ok) {
paymentMethods = await response.json();
}
} catch (_err) {
console.error('Failed to fetch payment methods:', _err);
} finally {
paymentMethodsLoading = false;
}
}
async function fetchLoyaltyData() {
if (!authStore.isAuthenticated) return;
try {
const response = await apiFetch('/api/user/loyalty');
if (response.ok) {
const data = await response.json();
stamps = data.stamps ?? 0;
}
} catch (_err) {
console.error('Failed to fetch loyalty data:', _err);
}
}
function sanitizeAmountInput(value: string): string {
// Remove all non-numeric chars except .
const cleaned = value.replace(/[^0-9.]/g, '');
// Keep only the first .
const firstDot = cleaned.indexOf('.');
if (firstDot !== -1) {
const integerPart = cleaned.substring(0, firstDot);
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
return integerPart + '.' + decimalPart;
}
return cleaned;
}
function handlePartialAmountInput(e: Event) {
const input = e.target as HTMLInputElement;
const sanitized = sanitizeAmountInput(input.value);
// Only update if the sanitized value passes the regex (max 2 decimal places)
if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
partialAmount = sanitized;
lastValidPartialAmount = sanitized;
} else {
// Reject input with more than 2 decimal places — revert to last valid
partialAmount = lastValidPartialAmount;
input.value = lastValidPartialAmount;
}
}
async function makePayment(paymentType: string, amountCents: number) {
status = 'processing';
error = null;
// Apply loyalty redemption before payment
if (useLoyalty) {
try {
const redemptionResponse = await apiFetch(`/api/bookings/${booking.id}/apply-redemption`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!redemptionResponse.ok) {
const errData = await redemptionResponse.text();
throw new Error(extractErrorMessage(errData) || 'Failed to apply loyalty discount');
}
} catch (_err) {
status = 'error';
const msg = _err instanceof Error ? _err.message : 'Failed to apply loyalty discount';
error = msg;
toast.error(msg);
return;
}
}
let cardId: string | undefined;
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (selectedCardId) {
cardId = selectedCardId;
} else if (cardSelection) {
// New-card mode: tokenize once per attempt WITH SCA verification, then
// reuse the cached nonce + verification token on retry (tokenization
// is one-shot; the backend idempotency key dedups). The verification
// token is amount-bound, so a changed amount forces a fresh
// tokenization.
if (!newCardNonce || newCardTokenAmount !== amountCents || Date.now() - newCardTokenizedAt > 240_000) {
try {
const tokenized = await cardSelection.tokenizeWithVerification(amountCents, {
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
});
newCardNonce = tokenized.nonce;
newCardVerificationToken = tokenized.verificationToken ?? '';
newCardTokenAmount = amountCents;
newCardTokenizedAt = Date.now();
} catch (_err) {
status = 'error';
const msg = _err instanceof Error ? _err.message : 'Card entry failed';
error = msg;
toast.error(msg);
return;
}
}
newCardToken = newCardNonce;
verificationToken = newCardVerificationToken || undefined;
} else {
status = 'error';
error = 'Please select a payment method';
toast.error('Please select a payment method');
return;
}
// Cache the idempotency key per amount+type+card so a lost-response
// retry reuses it (backend dedups) instead of double-charging. The
// new-card identity is a STABLE sentinel, NOT the cnon: nonce: the
// nonce is one-shot and cleared on a failed charge, so keying on it
// would regenerate the key on retry and a network-timeout retry (where
// the charge actually landed) would double-charge. The nonce changes
// per tokenize but represents the same logical card intent.
const cardKey = cardId ?? 'new-card';
if (
!payIdempotencyKey ||
payKeyedAmount !== amountCents ||
payKeyedType !== paymentType ||
payKeyedCard !== cardKey
) {
payIdempotencyKey = generateIdempotencyKey();
payKeyedAmount = amountCents;
payKeyedType = paymentType;
payKeyedCard = cardKey;
}
try {
const response = await apiFetch(`/api/bookings/${booking.id}/payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: amountCents,
payment_type: paymentType,
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
idempotency_key: payIdempotencyKey
})
});
if (!response.ok) {
const errData = await response.text();
throw new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
}
const data = await response.json();
// Payment is synchronous (completed immediately)
status = 'success';
payIdempotencyKey = '';
payKeyedAmount = 0;
payKeyedType = '';
payKeyedCard = '';
newCardNonce = '';
newCardVerificationToken = '';
newCardTokenAmount = 0;
newCardTokenizedAt = 0;
paymentResult = {
id: data.id,
amount: data.amount,
card_brand: data.card_brand,
card_last4: data.card_last4,
payment_type: data.payment_type
};
toast.success('Payment successful');
fetchPaymentMethods();
onComplete();
releaseLock();
} catch (_err) {
status = 'error';
const msg = _err instanceof Error ? _err.message : 'Payment declined';
error = msg;
toast.error(`${msg}. Please try again or use another card.`);
// A definitive charge failure consumes the nonce + SCA verification
// token — clear the cached pair so retries re-tokenize fresh. The
// idempotency key stays for network-timeout dedup. CardSelection stays
// mounted on error, so this also stops a user who corrects their card
// digits from resubmitting the old nonce for the new card.
newCardNonce = '';
newCardVerificationToken = '';
newCardTokenAmount = 0;
newCardTokenizedAt = 0;
releaseLock();
}
}
function handlePayDeposit() {
const depositCents = booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100);
makePayment('deposit', depositCents);
}
function handlePayFull() {
const fullCents = Math.round(booking.amount_due * 100);
const discountedCents = Math.max(0, fullCents - campaignDiscountCents() - loyaltyDiscount);
const paymentType = booking.amount_paid > 0 ? 'balance' : 'full';
makePayment(paymentType, discountedCents);
}
function handlePayPartial() {
if (!partialAmountValid) {
toast.error('Please enter a valid amount');
return;
}
makePayment('partial', Math.round(partialAmountNum * 100));
}
function handleClose() {
releaseLock();
onClose();
}
// Fetch payment methods on mount if authenticated
$effect(() => {
if (authStore.isAuthenticated) {
fetchPaymentMethods();
fetchLoyaltyData();
}
});
// Cleanup on unmount
$effect(() => {
return () => {
if (countdownInterval) clearInterval(countdownInterval);
if (lockInterval) clearInterval(lockInterval);
};
});
onMount(async () => {
if (booking.status === 'pending_release') {
acquireLock();
startCountdown();
startRenewal();
}
// Fetch eligible campaign discounts
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);
}
});
onDestroy(() => {
if (booking.status === 'pending_release' && lockAcquired) {
releaseLock();
}
});
</script>
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
<Dialog.Content class="!z-[70] max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md">
<Dialog.Header>
<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
{#if booking.id}
<div class="mt-1 text-sm text-gray-500">Booking ID: {booking.id}</div>
{/if}
</Dialog.Header>
{#if status === 'idle' || status === 'processing' || status === 'error'}
<div class="space-y-4">
<!-- Payment lock countdown banner — only for pending_release (vulnerable slot) -->
{#if booking.status === 'pending_release' && lockAcquired && lockTimer > 0}
<div
class="flex items-center gap-2 rounded-md border p-3 text-sm {lockTimer <= 60
? 'border-amber-200 bg-amber-50 text-amber-800'
: 'border-blue-200 bg-blue-50 text-blue-800'}"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
<span
>Slot re-secured for <strong>{formatTimer(lockTimer)}</strong> to ensure smooth payment
processing</span
>
</div>
{:else if booking.status === 'pending_release' && (lockTimer === 0 || !lockAcquired)}
<div
class="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0110 0v4" />
</svg>
<span>Slot no longer secured — please close and retry</span>
</div>
{/if}
<!-- Service Breakdown -->
<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="space-y-2">
{#each booking.services ?? [] as service, index (index)}
<div class="flex justify-between text-sm">
<span class="text-gray-600">{service.service_name || 'Unknown Service'}</span>
<span class="font-medium">
{service.override_price
? formatCurrency(Math.round(service.override_price * 100))
: service.price
? formatCurrency(Math.round(service.price * 100))
: '-'}
</span>
</div>
{/each}
</div>
</div>
<!-- Loyalty Redemption Checkbox -->
{#if loyaltyEligible}
<div class="rounded-md border border-fuchsia-100 bg-fuchsia-50 p-4">
<div class="mb-2 text-sm font-semibold text-fuchsia-800">Available Savings</div>
<div class="flex items-start gap-3">
<Checkbox
id="use-loyalty"
bind:checked={useLoyalty}
disabled={status === 'processing'}
/>
<label for="use-loyalty" class="cursor-pointer select-none">
<div class="text-sm font-medium text-fuchsia-900">Use my Loyalty Stamp Card</div>
<div class="mt-0.5 text-xs text-fuchsia-700">
{stamps} stamps available &middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off
({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
</div>
</label>
</div>
</div>
{/if}
<!-- Applied Discounts -->
{#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>
{#if servicesSubtotal > 0}
<span
class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600"
>
{((discountSum / servicesSubtotal) * 100).toFixed(0)}% Off Total
</span>
{/if}
</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}
<!-- Financial Summary -->
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
<div class="flex justify-between text-sm">
<span class="text-gray-600">Total</span>
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span
>
</div>
{#if discountPreview?.eligible}
{#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}
{/if}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Amount Paid</span>
<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
</div>
{#if useLoyalty && loyaltyDiscount > 0}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Loyalty Stamp Card (10% Off)</span>
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount)}</span>
</div>
{/if}
<div class="flex justify-between border-t border-gray-200 pt-2">
<span class="font-semibold text-gray-900">Amount Remaining</span>
<span class="text-lg font-bold text-red-600">
{formatCurrency(
Math.max(
0,
Math.round(amountRemaining * 100) - campaignDiscountCents() - loyaltyDiscount
)
)}
</span>
</div>
</div>
<!-- Card Selection (mounted while idle OR error so a declined card
can be retried/swapped without closing the modal) -->
{#if (status === 'idle' || status === 'error') && authStore.isAuthenticated}
{#if paymentMethodsLoading}
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
{:else}
<CardSelection
bind:this={cardSelection}
cards={paymentMethods}
{canSaveCards}
bind:selectedCardId
bind:saveCard
onValidityChange={(v) => (cardSelectionValid = v)}
/>
{/if}
{/if}
{#if depositPolicyWarning}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<p class="font-semibold text-amber-900">Cancellation &amp; Deposit Policy</p>
<p class="mt-1">{depositPolicyWarning}</p>
<PolicyPopover>
{#snippet trigger()}
<span class="mt-1 inline-block underline">Full cancellation policy →</span>
{/snippet}
</PolicyPopover>
</div>
{/if}
<!-- Scenario A: Deposit needed -->
{#if isScenarioA}
<div class="space-y-3">
<!-- Payment type radio buttons -->
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
'deposit'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'deposit')}
>
Pay Deposit
</button>
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
'full'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'full')}
>
Pay in Full
</button>
</div>
<!-- Pay button -->
<Button
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
class="w-full"
loading={status === 'processing'}
disabled={payButtonDisabled}
>
{#if paymentType === 'deposit'}
Pay Deposit ({formatCurrency(
booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100)
)})
{:else}
Pay {formatCurrency(
Math.max(
0,
Math.round(booking.amount_due * 100) -
campaignDiscountCents() -
(useLoyalty ? loyaltyDiscount : 0)
)
)}
{/if}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</div>
{/if}
<!-- Scenario B: Partial or full payment -->
{#if isScenarioB}
<div class="space-y-3">
<!-- Payment type radio buttons -->
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
'full'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'full')}
>
Pay in Full
</button>
<button
type="button"
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
'partial'
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => (paymentType = 'partial')}
>
Pay Part
</button>
</div>
<!-- Partial amount input (only when partial selected) -->
{#if paymentType === 'partial'}
<div>
<label for="partial-amount" class="text-sm font-medium text-gray-700">
Partial Payment Amount
</label>
<div class="relative mt-1">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
<Input
id="partial-amount"
type="text"
inputmode="decimal"
step="0.01"
placeholder="0.00"
value={partialAmount}
oninput={handlePartialAmountInput}
class="pl-7"
disabled={status !== 'idle'}
/>
</div>
{#if partialValidationError}
<p class="mt-1 text-sm text-red-600">{partialValidationError}</p>
{/if}
</div>
{/if}
<!-- Pay button -->
<Button
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
class="w-full"
loading={status === 'processing'}
disabled={payButtonDisabled}
>
{#if paymentType === 'partial'}
Pay {partialAmountValid
? formatCurrency(Math.round(partialAmountNum * 100))
: 'Part'}
{:else}
Pay {formatCurrency(
Math.max(
0,
Math.round(booking.amount_due * 100) -
campaignDiscountCents() -
(useLoyalty ? loyaltyDiscount : 0)
)
)}
{/if}
</Button>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</div>
{/if}
{#if status === 'error' && error}
<div class="rounded-md border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-800">{error}</p>
<Button
variant="outline"
size="sm"
class="mt-3 w-full"
onclick={() => {
status = 'idle';
error = null;
}}
>
Try Again
</Button>
</div>
{/if}
<!-- Close button -->
<Button
variant="ghost"
onclick={handleClose}
class="w-full"
disabled={status === 'processing'}
>
Close
</Button>
</div>
{:else if status === 'success' && paymentResult}
<!-- Success State -->
<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>
<!-- Receipt -->
<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)}
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">Type</span>
<span class="font-medium text-gray-900 capitalize">
{paymentResult.payment_type}
</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.card_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={handleClose} class="w-full">Done</Button>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>