Files
Crussell/frontend/src/lib/components/payments/UserPaymentModal.svelte
T
popertots 1543160f6a fix: loop-B full-scope adversarial findings — tip-excluded detail endpoints, £0-charge guard, 24h window, 2FA single-use everywhere
Loop B full-scope red-team (money/security/dup-mod) findings:
- CRITICAL: booking detail handlers (GetBookingHandler/GetAdminBookingHandler) now exclude payment_type='tip' from amount_paid — a tip before the final balance no longer undercharges the booking (bookings.go x3 sites)
- HIGH: A6 deposit clamp adds a zero-guard — when the eligible discount covers the entire deposit, the flow returns deposit_covered_by_discount instead of charging £0 at Square (real Square rejects £0; the mock accepted it); square_dev CreatePayment + CreateRefund now reject Amount <= 0 (mock/prod parity)
- HIGH: replayLegitimateRetryWindow restored to 22h (== stalePendingKeyedAge) so sweep-produced duplicate charges are still auto-refunded, not rescued-and-hidden
- HIGH: 2FA single-use consume-at-gate applied to ALL saved-card charge gates (booking 2263, admin saved-card 960, tip 4483, till 967, gift-card purchase 1482) with re-issue-on-failed-charge on each; pending-reuse retries keep their code
- MEDIUM: 2FA re-issue now fires only when the gate actually consumed a code (fresh saved-card path) — new-card failures no longer silently burn a standing code
- MEDIUM: pre_start tip-exclusion consistent across admin lists + detail handlers (bookings.go)
- MEDIUM: remaining-balance counts pending refunds (service.go) — capacity consistent with GetBookingPaymentInfo
- Mock CreatePayment/CreateRefund reject £0 amounts (INVALID_REQUEST_ERROR) for dev/prod parity

26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
2026-08-22 00:34:50 +01:00

1209 lines
42 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, 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 CardSelection from '$lib/components/payments/CardSelection.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { apiFetch } from '$lib/utils/api';
import { generateUUID } from '$lib/utils/uuid';
import {
campaignDiscountPence,
depositChargePence,
isNonceStale,
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
} from '$lib/square/square';
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);
// B6/B10: saved-card charges (and saving a new card for reuse) require the
// customer's current 2FA verification code whenever the backend enforces the
// gate. The input is surfaced at the charge step; the new-card (nonce) path
// keeps its own SCA via Square tokenizeWithVerification. Shared
// two-factor-code state (code, reveal, show/missing derivations, "Request a
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => twoFactorEnabled,
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard)
});
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);
// Save intent at tokenization time: the SCA verification token is bound to
// CHARGE vs CHARGE_AND_STORE, so toggling the save-card checkbox after a
// tokenize must force a fresh tokenization rather than reuse a token minted
// with the wrong intent.
let newCardTokenizedForSaveCard = $state(false);
let paymentResult = $state<{
id: string;
amount: number;
card_brand?: string;
card_last4?: string;
payment_type: string;
} | null>(null);
// Card selection state — cards and loading live in the shared savedCards
// store so all payment surfaces fetch /api/user/payment-methods identically.
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
);
// Tip-excluding remaining balance in pounds, mirroring the backend's
// GetBookingRemainingBalancePence (see backend/handlers/payments/service.go):
// total completed non-tip payments + completed refunds, clamped to the
// booking total and floored at 0. The backend rejects an unconfirmed
// pre-start overpayment when req.Amount > this value, and records the excess
// (req.Amount remainingPence) as a tip once confirmed — so the
// overflow-confirmation prompt shows exactly that. A completed TIP must not
// reduce what the customer can still pay for the booking itself (gratuity,
// not booking credit), so this deliberately differs from `totalPaid` (which
// includes tips and drives the scenario labels / "Amount Paid" row).
const remainingBalance = $derived.by(() => {
const total = booking.total_amount ?? 0;
const paid = (booking.payments ?? [])
.filter((p) => p.status === 'completed' && p.payment_type !== 'tip')
.reduce((sum, p) => sum + p.amount, 0);
const refunded = (booking.refunds ?? [])
.filter((r) => r.status === 'completed')
.reduce((sum, r) => sum + r.amount, 0);
return Math.max(0, Math.min(total - paid + refunded, total));
});
const remainingBalancePence = $derived(Math.round(remainingBalance * 100));
// Overpayment confirmation. 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. This guard now applies both before AND after the appointment
// has started (B12), so the prompt must fire in both states — it is keyed
// purely off the backend's `overflow_tip_confirmation_required` error code,
// never off booking state. It typically fires on STALE booking data
// (multi-tab, admin-changed totals, refunds that reopened capacity) where
// the user would otherwise be stuck with an unresolvable 400. On the guard
// firing, the rejected request (amount, type, cached card tokens) is parked
// here and a Confirm/Cancel prompt is shown; Confirm resends the SAME
// request with the flag, Cancel returns to the amount-editing form.
let overflowConfirm = $state<{
amountPence: number;
paymentType: string;
overflowPence: number;
// Actual amount the backend will charge. For deposits the backend
// charges req.Amount minus the eligible campaign credit (the frontend
// sends deposits RAW), so this can differ from amountPence.
chargePence?: number;
cardId?: string;
newCardToken?: string;
verificationToken?: string;
} | null>(null);
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 <= remainingBalance &&
/^\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 > remainingBalance
? 'Amount exceeds balance'
: 'Invalid amount'
: null
);
const payButtonDisabled = $derived(
status === 'processing' ||
!cardSelectionValid ||
(paymentType === 'partial' && !partialAmountValid) ||
(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
);
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);
}
// Cached payment methods come from the shared savedCards store (single
// fetch of /api/user/payment-methods), so the account, booking and tip
// surfaces can't drift on the API shape or the loading semantics.
async function loadSavedCards() {
if (!authStore.isAuthenticated) return;
await savedCardsStore.fetch();
}
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 handlePartialAmountInput(e: Event) {
const input = e.target as HTMLInputElement;
const sanitized = sanitizeDecimalInput(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, amountPence: 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;
// Saved-card charge: the card is used directly — B6/B10 requires the
// customer's current 2FA verification code (collected in the charge form)
// when the backend enforces the gate, but never blocks the selection.
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 ||
newCardTokenizedForSaveCard !== saveCard ||
isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountPence)
) {
try {
const tokenized = await cardSelection.tokenizeWithVerification(
amountPence,
{
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
},
saveCard
);
newCardNonce = tokenized.nonce;
newCardVerificationToken = tokenized.verificationToken ?? '';
newCardTokenAmount = amountPence;
newCardTokenizedAt = Date.now();
newCardTokenizedForSaveCard = saveCard;
} 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 !== amountPence ||
payKeyedType !== paymentType ||
payKeyedCard !== cardKey
) {
payIdempotencyKey = generateUUID();
payKeyedAmount = amountPence;
payKeyedType = paymentType;
payKeyedCard = cardKey;
}
await submitBookingPayment(
paymentType,
amountPence,
cardId,
newCardToken,
verificationToken,
false
);
}
// Submits a booking-payment request and processes the outcome. Shared by
// the initial attempt and the overflow-tip confirm resend so both use the
// exact same success/error handling. `confirmOverflowTip` adds the backend's
// opt-in flag for a pre-start overpayment; the resend reuses the SAME
// cached nonce/verification token/idempotency key as the rejected attempt
// (the guard fired before any Square call, so the tokens are unconsumed and
// the key is still the correct dedup identity for this amount+type+card).
async function submitBookingPayment(
paymentType: string,
amountPence: number,
cardId: string | undefined,
newCardToken: string | undefined,
verificationToken: string | undefined,
confirmOverflowTip: boolean
): Promise<void> {
let responseStatus = 0;
try {
const response = await submitPaymentWithRetry(() =>
apiFetch(`/api/bookings/${booking.id}/payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: amountPence,
payment_type: paymentType,
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
idempotency_key: payIdempotencyKey
})
})
);
if (!response.ok) {
responseStatus = response.status;
const errData = await response.text();
// Pre-start overpayment on stale booking data: park the rejected
// request (amount, type, cached tokens) and surface the
// Confirm/Cancel prompt instead of a dead-end 400. The cached
// nonce + SCA verification token + idempotency key are NOT
// cleared here — the confirm resend is the same logical charge.
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(errData)) {
// The backend's overflow guard compares against the DISCOUNTED
// remaining (remaining + eligible campaign credit), and for a
// DEPOSIT it charges req.Amount the campaign credit (the
// frontend sends deposits raw). Both the displayed overflow
// and the amount actually charged must therefore account for
// the eligible campaign discount on the deposit path.
const depositDiscountPence =
paymentType === 'deposit' ? campaignDiscountPence(discountPreview) : 0;
overflowConfirm = {
amountPence,
paymentType,
overflowPence: Math.max(0, amountPence - remainingBalancePence - depositDiscountPence),
chargePence: Math.max(0, amountPence - depositDiscountPence),
cardId,
newCardToken,
verificationToken
};
status = 'idle';
return;
}
throw new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
}
const data = await response.json();
// Payment is synchronous (completed immediately)
status = 'success';
overflowConfirm = null;
payIdempotencyKey = '';
payKeyedAmount = 0;
payKeyedType = '';
payKeyedCard = '';
newCardNonce = '';
newCardVerificationToken = '';
newCardTokenAmount = 0;
newCardTokenizedAt = 0;
newCardTokenizedForSaveCard = false;
twoFactor.setCode('');
twoFactor.reveal = false;
// The backend skips the Square charge entirely when an eligible
// campaign discount covers the whole deposit
// (`deposit_covered_by_discount` — a £0 charge is invalid at
// Square). Report it as a completed, nothing-to-pay deposit.
paymentResult = {
id: data.id ?? '',
amount: data.amount ?? 0,
card_brand: data.card_brand,
card_last4: data.card_last4,
payment_type: data.payment_type ?? 'deposit'
};
toast.success(
data.deposit_covered_by_discount
? 'Deposit covered by your discount — nothing to pay'
: 'Payment successful'
);
savedCardsStore.invalidate();
onComplete();
releaseLock();
} catch (_err) {
status = 'error';
overflowConfirm = null;
let msg = _err instanceof Error ? _err.message : 'Payment declined';
// 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.
const verificationFailure = isSavedCardVerificationRequired(responseStatus, !!cardId);
if (verificationFailure) 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)) twoFactor.reveal = true;
error = msg;
toast.error(verificationFailure ? msg : `${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;
newCardTokenizedForSaveCard = false;
releaseLock();
}
}
// Confirm the overpayment: resend the SAME rejected request with
// confirm_overflow_tip: true so the excess is recorded as a tip. Works for
// both pre-start and post-start overflows (B12).
async function confirmOverflowPayment() {
const pending = overflowConfirm;
if (!pending || status === 'processing') return;
status = 'processing';
error = null;
await submitBookingPayment(
pending.paymentType,
pending.amountPence,
pending.cardId,
pending.newCardToken,
pending.verificationToken,
true
);
}
// Revert to the amount-editing form. The cached nonce/tokens/idempotency key
// stay: a resubmit with the SAME amount+type+card reuses them (no charge was
// made — the guard fired before Square), and a changed amount forces a fresh
// tokenization + key.
function cancelOverflowConfirmation() {
overflowConfirm = null;
status = 'idle';
}
function handlePayDeposit() {
const depositPence = booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100);
makePayment('deposit', depositPence);
}
function handlePayFull() {
const fullPence = Math.round(booking.amount_due * 100);
const discountedPence = Math.max(
0,
fullPence - campaignDiscountPence(discountPreview) - loyaltyDiscount
);
const paymentType = booking.amount_paid > 0 ? 'balance' : 'full';
makePayment(paymentType, discountedPence);
}
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 + loyalty on mount if authenticated
$effect(() => {
if (authStore.isAuthenticated) {
loadSavedCards();
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) => {
if (open) return;
// ESC while the overflow-confirm prompt is showing must dismiss the
// prompt (back to the amount-editing form) instead of closing the whole
// modal — the payment was rejected by the guard and the user needs to
// confirm or adjust, not lose the flow entirely.
if (overflowConfirm) {
cancelOverflowConfirmation();
return;
}
handleClose();
}}
>
<Dialog.Content class="max-w-[calc(100%-2rem)] 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 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 markup with the
booking-flow deposit step (OverflowTipConfirm) so the two surfaces
can't drift. -->
<OverflowTipConfirm
overflowPence={overflowConfirm.overflowPence}
discountNote={overflowConfirm.paymentType === 'deposit' &&
overflowConfirm.chargePence !== undefined
? `An eligible campaign discount of ${formatCurrency(
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
)} applies you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
: undefined}
loading={status === 'processing'}
onConfirm={confirmOverflowPayment}
onCancel={cancelOverflowConfirmation}
/>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
<Button
variant="ghost"
onclick={handleClose}
class="w-full"
disabled={status === 'processing'}
>
Close
</Button>
</div>
{:else 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,
remainingBalancePence - campaignDiscountPence(discountPreview) - loyaltyDiscount
)
)}
</span>
</div>
</div>
<!-- Card Selection. Mounted for idle/error so a declined card can be
retried/swapped without closing the modal, AND for processing:
makePayment() sets status='processing' FIRST, then awaits the
loyalty redemption and tokenizeWithVerification. Svelte 5 flushes
on the next microtask after the await yields, so a status gate
that unmounts CardSelection mid-payment would null the bind:this
ref AND destroy the Square iframe mid-tokenization, making the
new-card path fail with "Please select a payment method" /
"Card entry failed". Staying mounted keeps both alive for the
full duration of makePayment. -->
{#if authStore.isAuthenticated}
{#if savedCardsStore.loading}
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
{:else}
<CardSelection
bind:this={cardSelection}
cards={savedCardsStore.cards}
{canSaveCards}
bind:selectedCardId
bind:saveCard
onValidityChange={(v) => (cardSelectionValid = v)}
/>
{/if}
{/if}
<!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. -->
<TwoFactorCodeInput
bind:code={twoFactor.code}
showInput={twoFactor.showInput}
enabled={twoFactorEnabled}
/>
{#if twoFactor.showInput && twoFactorEnabled}
<Button
variant="outline"
size="sm"
class="w-full"
loading={twoFactor.requesting}
disabled={twoFactor.requesting}
onclick={twoFactor.requestNewCode}
>
Request a new code
</Button>
{/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 || twoFactor.missing}
>
{#if paymentType === 'deposit'}
Pay Deposit ({formatCurrency(
depositChargePence(
booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100),
campaignDiscountPence(discountPreview)
)
)})
{:else}
Pay {formatCurrency(
Math.max(
0,
Math.round(booking.amount_due * 100) -
campaignDiscountPence(discountPreview) -
(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 || twoFactor.missing}
>
{#if paymentType === 'partial'}
Pay {partialAmountValid
? formatCurrency(Math.round(partialAmountNum * 100))
: 'Part'}
{:else}
Pay {formatCurrency(
Math.max(
0,
Math.round(booking.amount_due * 100) -
campaignDiscountPence(discountPreview) -
(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 / 100)}
</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>