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.
1209 lines
42 KiB
Svelte
1209 lines
42 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 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 · {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 & 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>
|