fix: review-loop A — discount credit on admin payments, campaign over-credit cap, sweep replay window, dedup refund revalidation, duplication/modularisation, GBP pence naming
Round-A fresh review (6 agents) + fix + secondary cross-cutting + verification rounds: - F1: campaign discounts reduce the charged amount (deposit credit + admin PaymentModal discounted total); capDiscountToRemainingObligation prevents over-credit at completion in all four campaign blocks - F2: sweep replay rescue distinguishes legitimate same-key retries (21h window) from expired-key new charges; ccof blind-fails leave pending + CRITICAL instead of clawing back - F3: post-start online overflow carved as a tip record (mirrors terminal split builder) - A1: single-source Square decline-code classification (till delegates to square.IsDefinitivePaymentError) - A2/A5: refund attempt-cap literals consolidated; refund-failure counter capped + reset on terminal resolutions + admin notifications - A3/A9: idempotency helpers adopted across derivations; IsExplicitDevOrMockEnv relocated + all gates unified (incl. health-check) - A7: 2FA user+IP limiter + TRUST_PROXY_HEADERS startup warning; SNAPSHOT_ENC_KEY startup validation; TWO_FACTOR_PEPPER docs corrected - A8: snapshot encryption on all 6 write sites + marker-aware reuse paths; MPV->SPV effective voucher type (single VAT point) - A10/A11/A12/A16: gift-card slot scan advances past failed; amount-aware refund reconciliation; completed-booking refund re-check; PaymentWasRefunded on SquareClient interface - Dedup refund revalidation on tip/terminal/gift-card paths; sweep acknowledged_at IS NULL parity; refund-notification single source (exported payments.InsertRefundFailedNotifications) - Duplication/modularisation round: shared frontend helpers (sanitizeDecimalInput, campaignDiscountCents, twoFactorBlocksSavedCards getter, generateUUID), single-source MaxIdempotencyKeyLength, notification-helper consolidation, snapshot-guard comments - Cross-cutting GBP rename: Cents->Pence across backend + frontend + tests (26 identifiers, 16 files) - Tests: 11 behavior-change tests updated to new invariants; coverage for fixed functions; frontend vitest 55 tests; docs corrected (test counts, 2FA delivery, pre-launch checklist, resolution status) - gitleaks: allowlist backend/internal/square test fixtures (mock idempotency keys) All 25 backend packages pass; frontend 55/55 + build clean; env-docs 41/41.
This commit is contained in:
@@ -50,6 +50,9 @@
|
||||
let refundReason = $state('');
|
||||
let refundLoading = $state(false);
|
||||
let refundIdempotencyKey = $state('');
|
||||
// Pence of the selected payment already returned via completed refunds —
|
||||
// the refund amount is pre-filled with the residual (amount − this).
|
||||
let refundAlreadyRefundedPence = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (open && bookingId) {
|
||||
@@ -342,15 +345,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openRefundModal(paymentId: string, amountPence: number) {
|
||||
refundPaymentId = paymentId;
|
||||
refundAmount = (amountPence / 100).toFixed(2);
|
||||
async function openRefundModal(payment: Payment) {
|
||||
refundPaymentId = payment.id;
|
||||
refundAmount = (payment.amount / 100).toFixed(2);
|
||||
refundAlreadyRefundedPence = 0;
|
||||
refundReason = '';
|
||||
// Unique per refund attempt so two equal partial refunds of the same
|
||||
// payment don't collide on the backend's amount-derived key; reused on
|
||||
// retry (the backend dedups on it) so a timeout can't double-refund.
|
||||
refundIdempotencyKey = crypto.randomUUID();
|
||||
showRefundModal = true;
|
||||
|
||||
// Pre-fill the refund with the RESIDUAL (payment.amount − already
|
||||
// refunded) and surface the already-refunded total, so a partially
|
||||
// refunded payment doesn't look fully refundable. The admin booking
|
||||
// detail doesn't include refunds, so fetch the payment summary.
|
||||
try {
|
||||
const res = await apiFetch(`/api/bookings/${bookingId}/payment-summary`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const alreadyRefunded = (
|
||||
(data.refunds ?? []) as Array<{
|
||||
payment_id: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
}>
|
||||
)
|
||||
.filter((r) => r.payment_id === payment.id && r.status === 'completed')
|
||||
.reduce((sum, r) => sum + r.amount, 0);
|
||||
if (alreadyRefunded > 0) {
|
||||
refundAlreadyRefundedPence = alreadyRefunded;
|
||||
refundAmount = (Math.max(0, payment.amount - alreadyRefunded) / 100).toFixed(2);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — the modal stays open with the full amount pre-filled.
|
||||
}
|
||||
}
|
||||
|
||||
async function processRefund() {
|
||||
@@ -611,7 +641,7 @@
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onclick={() => openRefundModal(payment.id, payment.amount)}
|
||||
onclick={() => openRefundModal(payment)}
|
||||
>
|
||||
Refund
|
||||
</Button>
|
||||
@@ -837,6 +867,12 @@
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
{#if refundAlreadyRefundedPence > 0}
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Already refunded: £{(refundAlreadyRefundedPence / 100).toFixed(2)} — the amount above is the
|
||||
remaining balance.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
|
||||
import { isSquareConfigured, submitPaymentWithRetry } from '$lib/square/square';
|
||||
import { range } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
@@ -37,7 +38,6 @@
|
||||
email: string;
|
||||
balance: number;
|
||||
updated_at: string;
|
||||
/* TODO: add previousFirstName/previousLastName when backend sends them */
|
||||
}
|
||||
|
||||
interface GiftCardSummary {
|
||||
@@ -167,6 +167,12 @@
|
||||
let onlineSquareCardReady = $state(false);
|
||||
let onlineSquareCardInput = $state<SquareCardInput | null>(null);
|
||||
let onlineSquareProcessing = $state(false);
|
||||
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
|
||||
// on the next microtask), so the reactive `onlineSquareProcessing` may not
|
||||
// propagate to the button's `disabled`/`loading` bindings before a fast
|
||||
// second click fires. This non-reactive flag is checked synchronously at
|
||||
// the start of the handler.
|
||||
let onlineSquareProcessingSync = false;
|
||||
|
||||
// Idempotency: the client deliberately sends NO idempotency_key. The backend
|
||||
// (CreateTillSale) derives a DETERMINISTIC key server-side from the canonical
|
||||
@@ -309,8 +315,8 @@
|
||||
toast.success('Balance claimed successfully');
|
||||
await fetchExpiredBalances();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
toast.error(err.error || 'Failed to claim balance');
|
||||
const err = await res.text();
|
||||
toast.error(extractErrorMessage(err) || 'Failed to claim balance');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error claiming balance');
|
||||
@@ -647,7 +653,9 @@
|
||||
}
|
||||
|
||||
async function handleEmbeddedOnlineSquarePayment(actionType: 'create' | 'topup', gcId?: string) {
|
||||
if (onlineSquareProcessingSync) return;
|
||||
if (!onlineSquareCardInput) return;
|
||||
onlineSquareProcessingSync = true;
|
||||
onlineSquareProcessing = true;
|
||||
paymentError = '';
|
||||
try {
|
||||
@@ -708,7 +716,12 @@
|
||||
setModalStep(actionType, 'error');
|
||||
} finally {
|
||||
onlineSquareProcessing = false;
|
||||
onlineSquareAction = null;
|
||||
onlineSquareProcessingSync = false;
|
||||
// Deliberately keep onlineSquareAction set: the card form stays
|
||||
// mounted for the payment step, so after a failure the "Try Again"
|
||||
// button (error step → payment step) returns to the SAME card form
|
||||
// instead of dropping back to the payment-method grid. It is cleared
|
||||
// by resetGenerateModal/resetTopUpModal when the step is left.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -843,7 +856,9 @@
|
||||
return (a.amount_remaining - b.amount_remaining) * mul;
|
||||
case 'created':
|
||||
return (
|
||||
(parseWallClockDate(a.created_at).getTime() - parseWallClockDate(b.created_at).getTime()) * mul
|
||||
(parseWallClockDate(a.created_at).getTime() -
|
||||
parseWallClockDate(b.created_at).getTime()) *
|
||||
mul
|
||||
);
|
||||
case 'status': {
|
||||
const aVal = a.redeemed_by ? 2 : a.amount_remaining === 0 ? 1 : 0;
|
||||
@@ -874,7 +889,9 @@
|
||||
return (a.balance - b.balance) * mul;
|
||||
case 'updated':
|
||||
return (
|
||||
(parseWallClockDate(a.updated_at).getTime() - parseWallClockDate(b.updated_at).getTime()) * mul
|
||||
(parseWallClockDate(a.updated_at).getTime() -
|
||||
parseWallClockDate(b.updated_at).getTime()) *
|
||||
mul
|
||||
);
|
||||
default:
|
||||
return 0;
|
||||
@@ -1124,11 +1141,7 @@
|
||||
</Button>
|
||||
{/if}
|
||||
{#if gc.cancellable}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openCancelCardModal(gc)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onclick={() => openCancelCardModal(gc)}>
|
||||
Cancel & refund
|
||||
</Button>
|
||||
{:else if gc.cancellation_reason}
|
||||
@@ -1341,9 +1354,7 @@
|
||||
{:else}
|
||||
{#each sortedBalances as ub (ub.user_id)}
|
||||
<tr class="border-b hover:bg-gray-50">
|
||||
<td class="py-3 font-medium text-gray-900"
|
||||
>{ub.name}<!-- TODO: add formerly name when previous name data is available --></td
|
||||
>
|
||||
<td class="py-3 font-medium text-gray-900">{ub.name}</td>
|
||||
<td class="py-3 text-gray-600">{ub.email}</td>
|
||||
<td class="py-3 font-semibold text-primary">{formatCurrency(ub.balance)}</td>
|
||||
<td class="py-3 text-gray-600">{formatDate(ub.updated_at)}</td>
|
||||
@@ -1372,9 +1383,7 @@
|
||||
{#each sortedBalances as ub (ub.user_id)}
|
||||
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-900"
|
||||
>{ub.name}<!-- TODO: add formerly name when previous name data is available --></span
|
||||
>
|
||||
<span class="font-medium text-gray-900">{ub.name}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 border-t border-b py-2 text-xs text-gray-600">
|
||||
<div class="col-span-2">
|
||||
@@ -1972,33 +1981,35 @@
|
||||
</svg>
|
||||
Cash
|
||||
</button>
|
||||
{#if isSquareConfigured()}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
|
||||
onclick={() => (onlineSquareAction = 'create')}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
|
||||
onclick={() => (onlineSquareAction = 'create')}
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-2 h-8 w-8 text-gray-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-2 h-8 w-8 text-gray-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<line x1="2" y1="10" x2="22" y2="10" />
|
||||
</svg>
|
||||
Online Card
|
||||
</button>
|
||||
{/if}
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<line x1="2" y1="10" x2="22" y2="10" />
|
||||
</svg>
|
||||
Online Card
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if onlineSquareAction === 'create'}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<SquareCardInput
|
||||
bind:this={onlineSquareCardInput}
|
||||
onReady={(r) => (onlineSquareCardReady = r)}
|
||||
/>
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={onlineSquareCardInput}
|
||||
onReady={(r) => (onlineSquareCardReady = r)}
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
<Button
|
||||
class="mt-3 w-full"
|
||||
variant="outline"
|
||||
@@ -2245,33 +2256,35 @@
|
||||
</svg>
|
||||
Cash
|
||||
</button>
|
||||
{#if isSquareConfigured()}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
|
||||
onclick={() => (onlineSquareAction = 'topup')}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-input py-6 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50"
|
||||
onclick={() => (onlineSquareAction = 'topup')}
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-2 h-8 w-8 text-gray-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-2 h-8 w-8 text-gray-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<line x1="2" y1="10" x2="22" y2="10" />
|
||||
</svg>
|
||||
Online Card
|
||||
</button>
|
||||
{/if}
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<line x1="2" y1="10" x2="22" y2="10" />
|
||||
</svg>
|
||||
Online Card
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if onlineSquareAction === 'topup'}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<SquareCardInput
|
||||
bind:this={onlineSquareCardInput}
|
||||
onReady={(r) => (onlineSquareCardReady = r)}
|
||||
/>
|
||||
{#if isSquareConfigured()}
|
||||
<SquareCardInput
|
||||
bind:this={onlineSquareCardInput}
|
||||
onReady={(r) => (onlineSquareCardReady = r)}
|
||||
/>
|
||||
{:else}
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
<Button
|
||||
class="mt-3 w-full"
|
||||
variant="outline"
|
||||
@@ -2447,7 +2460,8 @@
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<p class="font-semibold">14-day statutory cancellation right</p>
|
||||
<p class="mt-1 text-xs text-amber-700">
|
||||
The unspent balance will be refunded to the original payment method. This action cannot be undone.
|
||||
The unspent balance will be refunded to the original payment method. This action cannot be
|
||||
undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -38,7 +38,12 @@
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import { canSaveCardsForRole, isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
|
||||
import {
|
||||
canSaveCardsForRole,
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
||||
import {
|
||||
@@ -142,9 +147,7 @@
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks saved-card use
|
||||
// and saving new cards for reuse. The new-card (nonce) path has its own
|
||||
// SCA via Square tokenizeWithVerification.
|
||||
const twoFactorBlocksSavedCards = $derived(
|
||||
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
|
||||
);
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
|
||||
const depositCardFormValid = $derived(paymentCardSelectionValid);
|
||||
|
||||
@@ -334,7 +337,6 @@
|
||||
toast.error('Booking was not created. Please try again.');
|
||||
return;
|
||||
}
|
||||
const bookingId = confirmedBooking.id;
|
||||
// Charge the SERVER-computed deposit: the booking response carries the
|
||||
// authoritative deposit_amount (20% of the server-side total, which
|
||||
// accounts for discounts / admin adjustments). The client-side
|
||||
@@ -343,7 +345,7 @@
|
||||
confirmedBooking.deposit_amount && confirmedBooking.deposit_amount > 0
|
||||
? confirmedBooking.deposit_amount
|
||||
: _amount;
|
||||
const amountCents = Math.round(depositAmount * 100);
|
||||
const amountPence = Math.round(depositAmount * 100);
|
||||
|
||||
let newCardToken: string | undefined;
|
||||
let verificationToken: string | undefined;
|
||||
@@ -359,11 +361,11 @@
|
||||
if (
|
||||
!depositNonce ||
|
||||
depositTokenizedForSaveCard !== depositSaveCard ||
|
||||
isNonceStale(depositTokenizedAt, depositTokenAmount, amountCents)
|
||||
isNonceStale(depositTokenizedAt, depositTokenAmount, amountPence)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await paymentCardSelection.tokenizeWithVerification(
|
||||
amountCents,
|
||||
amountPence,
|
||||
{
|
||||
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
|
||||
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
|
||||
@@ -373,7 +375,7 @@
|
||||
);
|
||||
depositNonce = tokenized.nonce;
|
||||
depositVerificationToken = tokenized.verificationToken ?? '';
|
||||
depositTokenAmount = amountCents;
|
||||
depositTokenAmount = amountPence;
|
||||
depositTokenizedAt = Date.now();
|
||||
depositTokenizedForSaveCard = depositSaveCard;
|
||||
} catch (err) {
|
||||
@@ -396,17 +398,17 @@
|
||||
const cardKey = selectedPaymentMethod || 'new-card';
|
||||
if (
|
||||
!depositIdempotencyKey ||
|
||||
depositKeyedAmount !== amountCents ||
|
||||
depositKeyedAmount !== amountPence ||
|
||||
depositKeyedCard !== cardKey
|
||||
) {
|
||||
depositIdempotencyKey = generateUUID();
|
||||
depositKeyedAmount = amountCents;
|
||||
depositKeyedAmount = amountPence;
|
||||
depositKeyedCard = cardKey;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
payment_type: 'deposit',
|
||||
amount: amountCents,
|
||||
amount: amountPence,
|
||||
idempotency_key: depositIdempotencyKey,
|
||||
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
|
||||
@@ -415,93 +417,12 @@
|
||||
|
||||
paymentAttempted = true;
|
||||
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${bookingId}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
depositPaid = true;
|
||||
depositIdempotencyKey = '';
|
||||
depositKeyedAmount = 0;
|
||||
depositKeyedCard = '';
|
||||
depositNonce = '';
|
||||
depositVerificationToken = '';
|
||||
depositTokenAmount = 0;
|
||||
depositTokenizedAt = 0;
|
||||
depositTokenizedForSaveCard = false;
|
||||
depositSaveCard = false;
|
||||
// Immutable update — avoid mutating the existing object so
|
||||
// concurrent renders (e.g. a stale fetch) can't observe partial
|
||||
// state. (See audit: HIGH issue #3 — confirmedBooking mutated
|
||||
// in place, potential overcharge on double-click race.)
|
||||
confirmedBooking = {
|
||||
...confirmedBooking,
|
||||
deposit_paid: true,
|
||||
amount_paid: (confirmedBooking.amount_paid || 0) + depositAmount,
|
||||
amount_due: Math.max(0, (confirmedBooking.amount_due || 0) - depositAmount)
|
||||
};
|
||||
toast.success('Payment successful!');
|
||||
} else {
|
||||
const text = await response.text();
|
||||
// A 409 "already paid" (double-tab, or a lost-response retry that
|
||||
// actually landed) must not leave the user wedged on the pay form
|
||||
// with a stale deposit_paid=false — money was taken. Reconcile
|
||||
// against the server's truth so the confirmation gate (depositPaid
|
||||
// / confirmedBooking.deposit_paid) opens and the user reaches the
|
||||
// confirmation screen. The body-text match is a belt-and-braces
|
||||
// fallback for 4xx responses that still report the charge as
|
||||
// already processed.
|
||||
if (response.status === 409 || /already|paid|processed/.test(text.toLowerCase())) {
|
||||
try {
|
||||
const bookingResp = await apiFetch(`/api/bookings/${bookingId}`);
|
||||
if (bookingResp.ok) {
|
||||
const serverBooking = await bookingResp.json();
|
||||
// Immutable update — spread, never mutate (see audit note above).
|
||||
confirmedBooking = {
|
||||
...confirmedBooking,
|
||||
status: serverBooking.status ?? confirmedBooking.status,
|
||||
deposit_paid: serverBooking.deposit_paid ?? confirmedBooking.deposit_paid,
|
||||
deposit_amount:
|
||||
serverBooking.deposit_amount ?? confirmedBooking.deposit_amount,
|
||||
amount_paid: serverBooking.amount_paid ?? confirmedBooking.amount_paid,
|
||||
amount_due: serverBooking.amount_due ?? confirmedBooking.amount_due,
|
||||
payments: serverBooking.payments ?? confirmedBooking.payments,
|
||||
total_amount: serverBooking.total_amount ?? confirmedBooking.total_amount
|
||||
};
|
||||
depositPaid = confirmedBooking.deposit_paid;
|
||||
toast.success('Payment successful!');
|
||||
} else {
|
||||
toast.warning(
|
||||
text || 'Payment failed — you can pay again from your booking details.'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.warning(
|
||||
text || 'Payment failed — you can pay again from your booking details.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.warning(text || 'Payment failed — you can pay again from your booking details.');
|
||||
}
|
||||
// A definitive charge failure (declined card, any 4xx) consumes the
|
||||
// nonce + SCA verification token (Square nonces are single-use) —
|
||||
// clear the cached pair so a retry re-tokenizes fresh instead of
|
||||
// resubmitting a spent nonce for up to 240s. The idempotency key
|
||||
// stays so a lost-response retry still dedups against the original
|
||||
// charge (matches the TipPayment pattern).
|
||||
depositNonce = '';
|
||||
depositVerificationToken = '';
|
||||
depositTokenAmount = 0;
|
||||
depositTokenizedAt = 0;
|
||||
depositTokenizedForSaveCard = false;
|
||||
}
|
||||
await submitDepositPayment({
|
||||
body,
|
||||
amountPence,
|
||||
depositAmount,
|
||||
confirmOverflowTip: false
|
||||
});
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred. Your booking may still be confirmed — check your appointments.'
|
||||
@@ -520,8 +441,181 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Submits the deposit 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 body
|
||||
// (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.
|
||||
async function submitDepositPayment(options: {
|
||||
body: Record<string, unknown>;
|
||||
amountPence: number;
|
||||
depositAmount: number;
|
||||
confirmOverflowTip: boolean;
|
||||
}): Promise<void> {
|
||||
const { body, amountPence, depositAmount, confirmOverflowTip } = options;
|
||||
if (!confirmedBooking) return;
|
||||
const bookingId = confirmedBooking.id;
|
||||
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${bookingId}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {})
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
depositPaid = true;
|
||||
depositIdempotencyKey = '';
|
||||
depositKeyedAmount = 0;
|
||||
depositKeyedCard = '';
|
||||
depositNonce = '';
|
||||
depositVerificationToken = '';
|
||||
depositTokenAmount = 0;
|
||||
depositTokenizedAt = 0;
|
||||
depositTokenizedForSaveCard = false;
|
||||
depositSaveCard = false;
|
||||
overflowConfirm = null;
|
||||
// Immutable update — avoid mutating the existing object so
|
||||
// concurrent renders (e.g. a stale fetch) can't observe partial
|
||||
// state. (See audit: HIGH issue #3 — confirmedBooking mutated
|
||||
// in place, potential overcharge on double-click race.)
|
||||
confirmedBooking = {
|
||||
...confirmedBooking,
|
||||
deposit_paid: true,
|
||||
amount_paid: (confirmedBooking.amount_paid || 0) + depositAmount,
|
||||
amount_due: Math.max(0, (confirmedBooking.amount_due || 0) - depositAmount)
|
||||
};
|
||||
toast.success('Payment successful!');
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
// Pre-start overpayment guard on stale booking data: park the rejected
|
||||
// request (body + amount) and surface the Confirm/Cancel prompt instead
|
||||
// of a dead-end 400. The cached nonce + SCA verification token +
|
||||
// idempotency key are NOT cleared — the confirm resend is the same
|
||||
// logical charge.
|
||||
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(text)) {
|
||||
overflowConfirm = {
|
||||
amountPence,
|
||||
overflowPence: Math.max(
|
||||
0,
|
||||
amountPence - Math.round((confirmedBooking?.amount_due ?? 0) * 100)
|
||||
),
|
||||
depositAmount,
|
||||
body
|
||||
};
|
||||
return;
|
||||
}
|
||||
// A 409 "already paid" (double-tab, or a lost-response retry that
|
||||
// actually landed) must not leave the user wedged on the pay form
|
||||
// with a stale deposit_paid=false — money was taken. Reconcile
|
||||
// against the server's truth so the confirmation gate (depositPaid
|
||||
// / confirmedBooking.deposit_paid) opens and the user reaches the
|
||||
// confirmation screen. The body-text match is a belt-and-braces
|
||||
// fallback for 4xx responses that still report the charge as
|
||||
// already processed.
|
||||
if (response.status === 409 || /already|paid|processed/.test(text.toLowerCase())) {
|
||||
try {
|
||||
const bookingResp = await apiFetch(`/api/bookings/${bookingId}`);
|
||||
if (bookingResp.ok) {
|
||||
const serverBooking = await bookingResp.json();
|
||||
// Immutable update — spread, never mutate (see audit note above).
|
||||
confirmedBooking = {
|
||||
...confirmedBooking,
|
||||
status: serverBooking.status ?? confirmedBooking.status,
|
||||
deposit_paid: serverBooking.deposit_paid ?? confirmedBooking.deposit_paid,
|
||||
deposit_amount: serverBooking.deposit_amount ?? confirmedBooking.deposit_amount,
|
||||
amount_paid: serverBooking.amount_paid ?? confirmedBooking.amount_paid,
|
||||
amount_due: serverBooking.amount_due ?? confirmedBooking.amount_due,
|
||||
payments: serverBooking.payments ?? confirmedBooking.payments,
|
||||
total_amount: serverBooking.total_amount ?? confirmedBooking.total_amount
|
||||
};
|
||||
depositPaid = confirmedBooking.deposit_paid;
|
||||
toast.success('Payment successful!');
|
||||
} else {
|
||||
toast.warning(
|
||||
extractErrorMessage(text) ||
|
||||
'Payment failed — you can pay again from your booking details.'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.warning(
|
||||
extractErrorMessage(text) ||
|
||||
'Payment failed — you can pay again from your booking details.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.warning(
|
||||
extractErrorMessage(text) || 'Payment failed — you can pay again from your booking details.'
|
||||
);
|
||||
}
|
||||
// A definitive charge failure (declined card, any 4xx) consumes the
|
||||
// nonce + SCA verification token (Square nonces are single-use) —
|
||||
// clear the cached pair so a retry re-tokenizes fresh instead of
|
||||
// resubmitting a spent nonce for up to 240s. The idempotency key
|
||||
// stays so a lost-response retry still dedups against the original
|
||||
// charge (matches the TipPayment pattern).
|
||||
depositNonce = '';
|
||||
depositVerificationToken = '';
|
||||
depositTokenAmount = 0;
|
||||
depositTokenizedAt = 0;
|
||||
depositTokenizedForSaveCard = false;
|
||||
}
|
||||
|
||||
let paymentAttempted = $state(false);
|
||||
|
||||
// Pre-start overpayment confirmation (mirrors UserPaymentModal). The backend
|
||||
// rejects a deposit payment that exceeds the booking's remaining balance
|
||||
// before the appointment has started unless the request carries
|
||||
// `confirm_overflow_tip: true` (a tip is gratuity for service already
|
||||
// rendered). This fires on STALE booking data where the user would otherwise
|
||||
// be stuck with an unresolvable 400. The rejected request body (including
|
||||
// the cached nonce / SCA token / idempotency key) is parked here and a
|
||||
// Confirm/Cancel prompt is shown; Confirm resends the SAME body with the
|
||||
// flag, Cancel returns to the amount-editing form.
|
||||
let overflowConfirm = $state<{
|
||||
amountPence: number;
|
||||
overflowPence: number;
|
||||
depositAmount: number;
|
||||
body: Record<string, unknown>;
|
||||
} | null>(null);
|
||||
|
||||
function cancelOverflowConfirmation() {
|
||||
overflowConfirm = null;
|
||||
isProcessingPayment = false;
|
||||
isProcessingPaymentSync = false;
|
||||
}
|
||||
|
||||
// Confirm the pre-start overpayment: resend the SAME rejected request with
|
||||
// confirm_overflow_tip: true so the excess is recorded as a tip.
|
||||
async function confirmOverflowPayment() {
|
||||
const pending = overflowConfirm;
|
||||
if (!pending || isProcessingPayment) return;
|
||||
isProcessingPayment = true;
|
||||
isProcessingPaymentSync = true;
|
||||
try {
|
||||
await submitDepositPayment({
|
||||
body: pending.body,
|
||||
amountPence: pending.amountPence,
|
||||
depositAmount: pending.depositAmount,
|
||||
confirmOverflowTip: true
|
||||
});
|
||||
} finally {
|
||||
isProcessingPayment = false;
|
||||
isProcessingPaymentSync = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cached idempotency key per deposit attempt (amount + card): reused on
|
||||
// retry so a lost-response retry dedups instead of double-charging,
|
||||
// regenerated when the amount or card changes. Matches the tip-flow pattern.
|
||||
@@ -2458,33 +2552,93 @@
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={authStore.isAuthenticated
|
||||
? {
|
||||
firstName: authStore.currentUser?.firstName ?? '',
|
||||
lastName: authStore.currentUser?.lastName ?? '',
|
||||
email: authStore.currentUser?.email ?? '',
|
||||
phone: authStore.currentUser?.phone ?? '',
|
||||
specialRequests: customerInfo.specialRequests
|
||||
}
|
||||
: customerInfo}
|
||||
showCustomer={true}
|
||||
/>
|
||||
{#if overflowConfirm}
|
||||
<!-- Pre-start 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. -->
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 shrink-0 text-amber-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
|
||||
<p class="mt-1 text-sm text-amber-800">
|
||||
The balance for this booking has changed since it was last loaded. The extra £{(
|
||||
overflowConfirm.overflowPence / 100
|
||||
).toFixed(2)} will be recorded as a tip. Confirm to continue?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button
|
||||
class="flex-1"
|
||||
loading={isProcessingPayment}
|
||||
disabled={isProcessingPayment}
|
||||
onclick={confirmOverflowPayment}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
disabled={isProcessingPayment}
|
||||
onclick={cancelOverflowConfirmation}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<BookingSummary
|
||||
services={selectedServices}
|
||||
date={selectedDate}
|
||||
time={selectedTime}
|
||||
customer={authStore.isAuthenticated
|
||||
? {
|
||||
firstName: authStore.currentUser?.firstName ?? '',
|
||||
lastName: authStore.currentUser?.lastName ?? '',
|
||||
email: authStore.currentUser?.email ?? '',
|
||||
phone: authStore.currentUser?.phone ?? '',
|
||||
specialRequests: customerInfo.specialRequests
|
||||
}
|
||||
: customerInfo}
|
||||
showCustomer={true}
|
||||
/>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
{#if paymentMethodsLoading}
|
||||
<div class="mb-6 py-4 text-center text-gray-500">Loading payment methods...</div>
|
||||
{#if authStore.isAuthenticated}
|
||||
{#if paymentMethodsLoading}
|
||||
<div class="mb-6 py-4 text-center text-gray-500">
|
||||
Loading payment methods...
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<CardSelection
|
||||
bind:this={paymentCardSelection}
|
||||
cards={paymentMethods}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={selectedPaymentMethod}
|
||||
bind:saveCard={depositSaveCard}
|
||||
onValidityChange={(v) => (paymentCardSelectionValid = v)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<CardSelection
|
||||
bind:this={paymentCardSelection}
|
||||
cards={paymentMethods}
|
||||
cards={[]}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={selectedPaymentMethod}
|
||||
bind:saveCard={depositSaveCard}
|
||||
@@ -2492,40 +2646,27 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<CardSelection
|
||||
bind:this={paymentCardSelection}
|
||||
cards={[]}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={selectedPaymentMethod}
|
||||
bind:saveCard={depositSaveCard}
|
||||
onValidityChange={(v) => (paymentCardSelectionValid = v)}
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between border-t pt-4">
|
||||
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isProcessingPayment || !depositCardFormValid}
|
||||
onclick={() => processPayment(calculateDepositAmount())}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isProcessingPayment
|
||||
? 'Processing...'
|
||||
: `Pay Deposit £${calculateDepositAmount()}`}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between border-t pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={prevStep}
|
||||
disabled={isProcessingPayment}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isProcessingPayment || !depositCardFormValid}
|
||||
onclick={() => processPayment(calculateDepositAmount())}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isProcessingPayment
|
||||
? 'Processing...'
|
||||
: `Pay Deposit £${calculateDepositAmount()}`}
|
||||
</Button>
|
||||
<p class="mt-4 text-center text-xs text-gray-500">
|
||||
Secure payment powered by Square
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
@@ -45,9 +45,7 @@
|
||||
// selection and save-for-later are blocked. The new-card (nonce) path has
|
||||
// its own SCA via Square tokenizeWithVerification, so only the saved-card
|
||||
// list and the save toggle are gated here.
|
||||
const twoFactorBlocksSavedCards = $derived(
|
||||
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
|
||||
);
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
|
||||
// Auto-select the default saved card when cards first load. Guarded by
|
||||
// !showNewCardForm so the "Use a new card" click (selectedCardId = '') is
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
@@ -7,7 +8,13 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { submitPaymentWithRetry } from '$lib/square/square';
|
||||
import {
|
||||
campaignDiscountPence,
|
||||
isSavedCardVerificationRequired,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
@@ -59,9 +66,7 @@
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks charging a
|
||||
// customer's saved card online (the admin's own 2FA status gates it). The
|
||||
// card-machine and new-card paths have their own SCA.
|
||||
const twoFactorBlocksSavedCards = $derived(
|
||||
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
|
||||
);
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
|
||||
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
|
||||
let useLoyalty = $state(false);
|
||||
@@ -77,18 +82,21 @@
|
||||
useLoyalty ? Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) : 0
|
||||
);
|
||||
|
||||
// Campaign discount preview — fetched on mount, mirroring the customer flow
|
||||
// (UserPaymentModal). The backend AUTO-APPLIES eligible campaigns at payment
|
||||
// /completion, so the admin modal must show and charge the DISCOUNTED amount:
|
||||
// charging the pre-campaign total would over-credit the ledger (the backend
|
||||
// records the full payment AND the discount rows). `netTotal` therefore
|
||||
// subtracts these pence, and every charge handler derives from it.
|
||||
let discountPreview = $state<{
|
||||
eligible: boolean;
|
||||
discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
|
||||
original_total: number;
|
||||
discounted_total: number;
|
||||
} | null>(null);
|
||||
|
||||
let customerBalance = $state(0);
|
||||
let giftCardPaymentAmount = $state('');
|
||||
let savedCardList = $state<
|
||||
Array<{
|
||||
id: string;
|
||||
brand: string;
|
||||
last_4: string;
|
||||
exp_month: number;
|
||||
exp_year: number;
|
||||
cardholder_name?: string;
|
||||
}>
|
||||
>([]);
|
||||
async function fetchCustomerGiftCardBalance() {
|
||||
const targetUserId = booking.user_id ?? booking.user?.id;
|
||||
if (!targetUserId) return;
|
||||
@@ -107,19 +115,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSavedCardList() {
|
||||
const targetUserId = booking.user_id ?? booking.user?.id;
|
||||
if (!targetUserId) return;
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/users/${targetUserId}/payment-methods`);
|
||||
if (res.ok) {
|
||||
savedCardList = await res.json();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
type ServiceOverride = {
|
||||
price: string;
|
||||
originalPrice: number;
|
||||
@@ -131,7 +126,7 @@
|
||||
const uid = booking.user_id ?? booking.user?.id;
|
||||
if (uid) {
|
||||
fetchCustomerGiftCardBalance();
|
||||
fetchSavedCardList();
|
||||
fetchSavedCards();
|
||||
}
|
||||
const services = booking.services ?? [];
|
||||
const overrides: Record<string, ServiceOverride> = {};
|
||||
@@ -145,17 +140,13 @@
|
||||
serviceOverrides = overrides;
|
||||
});
|
||||
|
||||
// Single shared sanitizer for all decimal money inputs: strips non-numeric
|
||||
// characters and keeps only the first decimal point (so "1.2.3" → "1.23").
|
||||
// Defined once in square.ts and imported here so the payment surfaces can't
|
||||
// drift.
|
||||
|
||||
function handlePriceInput(serviceId: string, value: string) {
|
||||
const cleaned = value.replace(/[^0-9.]/g, '');
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
let sanitized: string;
|
||||
if (firstDot !== -1) {
|
||||
const integerPart = cleaned.substring(0, firstDot);
|
||||
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
||||
sanitized = integerPart + '.' + decimalPart;
|
||||
} else {
|
||||
sanitized = cleaned;
|
||||
}
|
||||
const sanitized = sanitizeDecimalInput(value);
|
||||
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
|
||||
serviceOverrides = {
|
||||
...serviceOverrides,
|
||||
@@ -181,16 +172,7 @@
|
||||
|
||||
function handleCustomTipInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const cleaned = input.value.replace(/[^0-9.]/g, '');
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
let sanitized: string;
|
||||
if (firstDot !== -1) {
|
||||
const integerPart = cleaned.substring(0, firstDot);
|
||||
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
||||
sanitized = integerPart + '.' + decimalPart;
|
||||
} else {
|
||||
sanitized = cleaned;
|
||||
}
|
||||
const sanitized = sanitizeDecimalInput(input.value);
|
||||
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
|
||||
customTipAmount = sanitized;
|
||||
}
|
||||
@@ -213,7 +195,27 @@
|
||||
const discountSum = $derived(
|
||||
(booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0)
|
||||
);
|
||||
const netTotal = $derived(Math.max(0, subtotal - discountSum));
|
||||
// Campaign discounts apply automatically at payment/completion server-side,
|
||||
// so the charge must be the subtotal minus already-applied discounts minus
|
||||
// the eligible campaign credit — otherwise the customer is overcharged.
|
||||
//
|
||||
// NOTE (round-A-4 UX residual, deliberately NOT "fixed"): this ignores
|
||||
// payments already made against the booking. The admin backend path
|
||||
// (CreateTerminalPayment) treats the amount it receives as the charge to
|
||||
// record verbatim — it does NOT compute "remaining due" and subtract prior
|
||||
// payments server-side — and the booking object handed to this modal (from
|
||||
// /api/admin/today/current-next, AppointmentInfo) carries no amount_paid /
|
||||
// amount_due / payments fields to derive them client-side. Subtracting an
|
||||
// unverifiable prior-paid total would risk under-collecting. When a deposit
|
||||
// was already paid, charging the full subtotal here is money-safe server-side
|
||||
// (buildSplitRecords/buildTerminalSplitRecords carve any excess beyond the
|
||||
// remaining booking value into a payment_type='tip' record, so the ledger
|
||||
// still closes exactly at the booking total) but the excess lands as an
|
||||
// UNINTENDED tip. Revisit when the today endpoint exposes the booking's paid
|
||||
// total: netTotal = max(0, subtotal − discountSum − campaignDiscountPence − amountPaidPence).
|
||||
const netTotal = $derived(
|
||||
Math.max(0, subtotal - discountSum - campaignDiscountPence(discountPreview))
|
||||
);
|
||||
|
||||
const tipPercentages = $derived.by(() => {
|
||||
if (netTotal <= 0) return [];
|
||||
@@ -408,6 +410,20 @@
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch the eligible campaign discount preview once on mount. Mirrors the
|
||||
// customer flow (UserPaymentModal) so the admin modal charges the same
|
||||
// discounted amount the backend will auto-apply.
|
||||
onMount(async () => {
|
||||
try {
|
||||
const resp = await apiFetch(`/api/bookings/${booking.id}/discount-preview`);
|
||||
if (resp.ok) {
|
||||
discountPreview = await resp.json();
|
||||
}
|
||||
} catch (_err) {
|
||||
console.error('Failed to fetch discount preview:', _err);
|
||||
}
|
||||
});
|
||||
|
||||
let cashAmount = $state<string>('');
|
||||
const cashAmountNum = $derived(cashAmount === '' ? 0 : parseFloat(cashAmount));
|
||||
const changeDue = $derived(cashAmountNum > totalDue ? cashAmountNum - totalDue : 0);
|
||||
@@ -415,16 +431,7 @@
|
||||
|
||||
function handleCashInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const cleaned = input.value.replace(/[^0-9.]/g, '');
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
let sanitized: string;
|
||||
if (firstDot !== -1) {
|
||||
const integerPart = cleaned.substring(0, firstDot);
|
||||
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
||||
sanitized = integerPart + '.' + decimalPart;
|
||||
} else {
|
||||
sanitized = cleaned;
|
||||
}
|
||||
const sanitized = sanitizeDecimalInput(input.value);
|
||||
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
|
||||
cashAmount = sanitized;
|
||||
}
|
||||
@@ -563,7 +570,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
let payAmountCents = Math.round(giftDue * 100);
|
||||
let payAmountPence = Math.round(giftDue * 100);
|
||||
if (useAccountBalance) {
|
||||
const parsedAmt = parseFloat(giftCardPaymentAmount);
|
||||
if (isNaN(parsedAmt) || parsedAmt <= 0) {
|
||||
@@ -574,7 +581,7 @@
|
||||
toast.error('Payment amount exceeds available balance');
|
||||
return;
|
||||
}
|
||||
payAmountCents = Math.round(parsedAmt * 100);
|
||||
payAmountPence = Math.round(parsedAmt * 100);
|
||||
}
|
||||
|
||||
isProcessingPaymentSync = true;
|
||||
@@ -590,7 +597,7 @@
|
||||
payment_method: string;
|
||||
gift_card_id?: string;
|
||||
} = {
|
||||
amount: payAmountCents,
|
||||
amount: payAmountPence,
|
||||
payment_type: 'full',
|
||||
payment_method: 'giftcard'
|
||||
};
|
||||
@@ -660,7 +667,7 @@
|
||||
savedCards = [];
|
||||
selectedSavedCardId = null;
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/users/${booking.user_id}/payment-methods`);
|
||||
const res = await apiFetch(`/api/admin/users/${targetUserId}/payment-methods`);
|
||||
if (res.ok) {
|
||||
savedCards = await res.json();
|
||||
}
|
||||
@@ -710,6 +717,7 @@
|
||||
status = 'saved-card-processing';
|
||||
error = null;
|
||||
|
||||
let responseStatus = 0;
|
||||
try {
|
||||
await applyLoyaltyRedemption();
|
||||
|
||||
@@ -728,6 +736,7 @@
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
responseStatus = response.status;
|
||||
const errData = await response.text();
|
||||
throw new Error(extractErrorMessage(errData) || 'Failed to process saved card payment');
|
||||
}
|
||||
@@ -752,8 +761,15 @@
|
||||
onComplete(paymentResult);
|
||||
} catch (_err) {
|
||||
status = 'error';
|
||||
error = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
||||
toast.error(error ?? 'Unknown error');
|
||||
// Saved-card (ccof) charges skip the client-side SCA step, so a
|
||||
// definitive 402 on the saved-card path means the issuer still
|
||||
// requires verification — retrying the same saved card can never
|
||||
// succeed. Surface the fix instead of the generic backend text.
|
||||
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
||||
if (isSavedCardVerificationRequired(responseStatus, true))
|
||||
msg = SAVED_CARD_VERIFICATION_MESSAGE;
|
||||
error = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
isProcessingPaymentSync = false;
|
||||
}
|
||||
@@ -923,6 +939,19 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if discountPreview?.eligible && discountPreview.discounts.length > 0}
|
||||
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
|
||||
{#each discountPreview.discounts as d (d.name)}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">{d.name}</span>
|
||||
<span class="font-medium text-green-700"
|
||||
>-{formatCurrency(Math.round(d.amount * 100))}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tipEnabled}
|
||||
<div class="rounded-md border border-green-200 bg-green-50 p-3">
|
||||
<div class="flex justify-between">
|
||||
@@ -941,7 +970,7 @@
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="grid grid-cols-2 gap-3 {savedCardList.length > 0
|
||||
class="grid grid-cols-2 gap-3 {savedCards.length > 0
|
||||
? 'sm:grid-cols-4'
|
||||
: 'sm:grid-cols-3'}"
|
||||
>
|
||||
@@ -993,7 +1022,7 @@
|
||||
</svg>
|
||||
Cash
|
||||
</button>
|
||||
{#if savedCardList.length > 0 && !twoFactorBlocksSavedCards}
|
||||
{#if savedCards.length > 0 && !twoFactorBlocksSavedCards}
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
@@ -1049,17 +1078,19 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if twoFactorBlocksSavedCards && savedCardList.length > 0}
|
||||
{#if twoFactorBlocksSavedCards && savedCards.length > 0}
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<p class="text-sm text-amber-800">
|
||||
Two-factor authentication is required to use online card payments.
|
||||
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
|
||||
<a href={resolve('/account')} class="font-medium underline"
|
||||
>Enable it in your account settings</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-3 sm:hidden">
|
||||
{#if savedCardList.length > 0 && !twoFactorBlocksSavedCards}
|
||||
{#if savedCards.length > 0 && !twoFactorBlocksSavedCards}
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
@@ -1311,7 +1342,11 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button onclick={handleGiftCardPayment} class="flex-1" disabled={!giftCardValid || nothingToCharge}>
|
||||
<Button
|
||||
onclick={handleGiftCardPayment}
|
||||
class="flex-1"
|
||||
disabled={!giftCardValid || nothingToCharge}
|
||||
>
|
||||
Apply Gift Card
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
canSaveCardsForRole,
|
||||
isNonceStale,
|
||||
isSavedCardVerificationRequired,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
@@ -103,9 +104,7 @@
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks saved-card use
|
||||
// and saving new cards for reuse. The new-card (nonce) path has its own
|
||||
// SCA via Square tokenizeWithVerification.
|
||||
const twoFactorBlocksSavedCards = $derived(
|
||||
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
|
||||
);
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
|
||||
const isCardValid = $derived(cardSelectionValid);
|
||||
|
||||
@@ -183,16 +182,7 @@
|
||||
|
||||
function handleCustomTipInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const cleaned = input.value.replace(/[^0-9.]/g, '');
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
let sanitized: string;
|
||||
if (firstDot !== -1) {
|
||||
const integerPart = cleaned.substring(0, firstDot);
|
||||
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
||||
sanitized = integerPart + '.' + decimalPart;
|
||||
} else {
|
||||
sanitized = cleaned;
|
||||
}
|
||||
const sanitized = sanitizeDecimalInput(input.value);
|
||||
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
|
||||
customTip = sanitized;
|
||||
}
|
||||
|
||||
@@ -7,15 +7,18 @@
|
||||
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 { savedCardsStore } from '$lib/stores/savedCards.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import {
|
||||
campaignDiscountPence,
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
@@ -45,9 +48,7 @@
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks saved-card use
|
||||
// and saving new cards for reuse. The new-card (nonce) path has its own
|
||||
// SCA via Square tokenizeWithVerification.
|
||||
const twoFactorBlocksSavedCards = $derived(
|
||||
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
|
||||
);
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
|
||||
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
|
||||
|
||||
@@ -86,9 +87,8 @@
|
||||
payment_type: string;
|
||||
} | null>(null);
|
||||
|
||||
// Card selection state
|
||||
let paymentMethods = $state<UserSavedCard[]>([]);
|
||||
let paymentMethodsLoading = $state(false);
|
||||
// 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);
|
||||
@@ -140,15 +140,17 @@
|
||||
.reduce((sum, p) => sum + p.amount, 0) || 0
|
||||
);
|
||||
|
||||
const amountRemaining = $derived(booking.total_amount - totalPaid);
|
||||
|
||||
// Mirror of the backend's GetBookingRemainingBalanceCents (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 − remainingCents) as a tip
|
||||
// once confirmed — so the overflow-confirmation prompt shows exactly that.
|
||||
const remainingBalanceCents = $derived.by(() => {
|
||||
// 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')
|
||||
@@ -156,23 +158,29 @@
|
||||
const refunded = (booking.refunds ?? [])
|
||||
.filter((r) => r.status === 'completed')
|
||||
.reduce((sum, r) => sum + r.amount, 0);
|
||||
return Math.round(Math.max(0, Math.min(total - paid + refunded, total)) * 100);
|
||||
return Math.max(0, Math.min(total - paid + refunded, total));
|
||||
});
|
||||
|
||||
const remainingBalancePence = $derived(Math.round(remainingBalance * 100));
|
||||
|
||||
// Pre-start overpayment confirmation. The backend rejects a payment that
|
||||
// exceeds the booking's remaining balance before the appointment has
|
||||
// started unless the request carries `confirm_overflow_tip: true` — a tip
|
||||
// is gratuity for service already rendered. The frontend caps amounts at
|
||||
// amountRemaining in normal flows, so this fires on STALE booking data
|
||||
// the remaining balance in normal flows, so this 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<{
|
||||
amountCents: number;
|
||||
amountPence: number;
|
||||
paymentType: string;
|
||||
overflowCents: number;
|
||||
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;
|
||||
@@ -216,7 +224,7 @@
|
||||
partialAmount !== '' &&
|
||||
!isNaN(partialAmountNum) &&
|
||||
partialAmountNum > 0 &&
|
||||
partialAmountNum <= amountRemaining &&
|
||||
partialAmountNum <= remainingBalance &&
|
||||
/^\d+(\.\d{0,2})?$/.test(partialAmount)
|
||||
);
|
||||
|
||||
@@ -228,7 +236,7 @@
|
||||
? 'Invalid amount format'
|
||||
: partialAmountNum <= 0
|
||||
? 'Amount must be greater than 0'
|
||||
: partialAmountNum > amountRemaining
|
||||
: partialAmountNum > remainingBalance
|
||||
? 'Amount exceeds balance'
|
||||
: 'Invalid amount'
|
||||
: null
|
||||
@@ -241,12 +249,6 @@
|
||||
(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',
|
||||
@@ -319,37 +321,12 @@
|
||||
}, 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() {
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
await savedCardsStore.fetch();
|
||||
}
|
||||
|
||||
async function fetchLoyaltyData() {
|
||||
@@ -365,22 +342,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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;
|
||||
@@ -392,7 +356,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function makePayment(paymentType: string, amountCents: number) {
|
||||
async function makePayment(paymentType: string, amountPence: number) {
|
||||
status = 'processing';
|
||||
error = null;
|
||||
|
||||
@@ -433,11 +397,11 @@
|
||||
if (
|
||||
!newCardNonce ||
|
||||
newCardTokenizedForSaveCard !== saveCard ||
|
||||
isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountCents)
|
||||
isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountPence)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await cardSelection.tokenizeWithVerification(
|
||||
amountCents,
|
||||
amountPence,
|
||||
{
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
@@ -447,7 +411,7 @@
|
||||
);
|
||||
newCardNonce = tokenized.nonce;
|
||||
newCardVerificationToken = tokenized.verificationToken ?? '';
|
||||
newCardTokenAmount = amountCents;
|
||||
newCardTokenAmount = amountPence;
|
||||
newCardTokenizedAt = Date.now();
|
||||
newCardTokenizedForSaveCard = saveCard;
|
||||
} catch (_err) {
|
||||
@@ -477,17 +441,24 @@
|
||||
const cardKey = cardId ?? 'new-card';
|
||||
if (
|
||||
!payIdempotencyKey ||
|
||||
payKeyedAmount !== amountCents ||
|
||||
payKeyedAmount !== amountPence ||
|
||||
payKeyedType !== paymentType ||
|
||||
payKeyedCard !== cardKey
|
||||
) {
|
||||
payIdempotencyKey = generateIdempotencyKey();
|
||||
payKeyedAmount = amountCents;
|
||||
payIdempotencyKey = generateUUID();
|
||||
payKeyedAmount = amountPence;
|
||||
payKeyedType = paymentType;
|
||||
payKeyedCard = cardKey;
|
||||
}
|
||||
|
||||
await submitBookingPayment(paymentType, amountCents, cardId, newCardToken, verificationToken, false);
|
||||
await submitBookingPayment(
|
||||
paymentType,
|
||||
amountPence,
|
||||
cardId,
|
||||
newCardToken,
|
||||
verificationToken,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// Submits a booking-payment request and processes the outcome. Shared by
|
||||
@@ -499,7 +470,7 @@
|
||||
// the key is still the correct dedup identity for this amount+type+card).
|
||||
async function submitBookingPayment(
|
||||
paymentType: string,
|
||||
amountCents: number,
|
||||
amountPence: number,
|
||||
cardId: string | undefined,
|
||||
newCardToken: string | undefined,
|
||||
verificationToken: string | undefined,
|
||||
@@ -512,7 +483,7 @@
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: amountCents,
|
||||
amount: amountPence,
|
||||
payment_type: paymentType,
|
||||
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
@@ -532,10 +503,19 @@
|
||||
// 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 = {
|
||||
amountCents,
|
||||
amountPence,
|
||||
paymentType,
|
||||
overflowCents: Math.max(0, amountCents - remainingBalanceCents),
|
||||
overflowPence: Math.max(0, amountPence - remainingBalancePence - depositDiscountPence),
|
||||
chargePence: Math.max(0, amountPence - depositDiscountPence),
|
||||
cardId,
|
||||
newCardToken,
|
||||
verificationToken
|
||||
@@ -567,7 +547,7 @@
|
||||
payment_type: data.payment_type
|
||||
};
|
||||
toast.success('Payment successful');
|
||||
fetchPaymentMethods();
|
||||
savedCardsStore.invalidate();
|
||||
onComplete();
|
||||
releaseLock();
|
||||
} catch (_err) {
|
||||
@@ -605,7 +585,7 @@
|
||||
error = null;
|
||||
await submitBookingPayment(
|
||||
pending.paymentType,
|
||||
pending.amountCents,
|
||||
pending.amountPence,
|
||||
pending.cardId,
|
||||
pending.newCardToken,
|
||||
pending.verificationToken,
|
||||
@@ -623,17 +603,20 @@
|
||||
}
|
||||
|
||||
function handlePayDeposit() {
|
||||
const depositCents = booking.deposit_amount
|
||||
const depositPence = booking.deposit_amount
|
||||
? Math.round(booking.deposit_amount * 100)
|
||||
: Math.round(booking.total_amount * 0.2 * 100);
|
||||
makePayment('deposit', depositCents);
|
||||
makePayment('deposit', depositPence);
|
||||
}
|
||||
|
||||
function handlePayFull() {
|
||||
const fullCents = Math.round(booking.amount_due * 100);
|
||||
const discountedCents = Math.max(0, fullCents - campaignDiscountCents() - loyaltyDiscount);
|
||||
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, discountedCents);
|
||||
makePayment(paymentType, discountedPence);
|
||||
}
|
||||
|
||||
function handlePayPartial() {
|
||||
@@ -649,10 +632,10 @@
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Fetch payment methods on mount if authenticated
|
||||
// Fetch payment methods + loyalty on mount if authenticated
|
||||
$effect(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
fetchPaymentMethods();
|
||||
loadSavedCards();
|
||||
fetchLoyaltyData();
|
||||
}
|
||||
});
|
||||
@@ -690,7 +673,21 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
<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>
|
||||
@@ -721,9 +718,18 @@
|
||||
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
|
||||
<p class="mt-1 text-sm text-amber-800">
|
||||
The balance for this booking has changed since it was last loaded. The extra
|
||||
{formatCurrency(overflowConfirm.overflowCents)} will be recorded as a tip. Confirm
|
||||
to continue?
|
||||
{formatCurrency(overflowConfirm.overflowPence)} will be recorded as a tip. Confirm to
|
||||
continue?
|
||||
</p>
|
||||
{#if overflowConfirm.paymentType === 'deposit' && overflowConfirm.chargePence !== undefined}
|
||||
<p class="mt-2 text-sm font-medium text-amber-800">
|
||||
An eligible campaign discount of
|
||||
{formatCurrency(
|
||||
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
|
||||
)}
|
||||
applies — you'll be charged {formatCurrency(overflowConfirm.chargePence)}.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
@@ -731,6 +737,7 @@
|
||||
class="flex-1"
|
||||
loading={status === 'processing'}
|
||||
disabled={status === 'processing'}
|
||||
autofocus
|
||||
onclick={confirmOverflowPayment}
|
||||
>
|
||||
Confirm
|
||||
@@ -919,7 +926,7 @@
|
||||
{formatCurrency(
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(amountRemaining * 100) - campaignDiscountCents() - loyaltyDiscount
|
||||
remainingBalancePence - campaignDiscountPence(discountPreview) - loyaltyDiscount
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
@@ -937,12 +944,12 @@
|
||||
"Card entry failed". Staying mounted keeps both alive for the
|
||||
full duration of makePayment. -->
|
||||
{#if authStore.isAuthenticated}
|
||||
{#if paymentMethodsLoading}
|
||||
{#if savedCardsStore.loading}
|
||||
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
|
||||
{:else}
|
||||
<CardSelection
|
||||
bind:this={cardSelection}
|
||||
cards={paymentMethods}
|
||||
cards={savedCardsStore.cards}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId
|
||||
bind:saveCard
|
||||
@@ -1008,7 +1015,7 @@
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(booking.amount_due * 100) -
|
||||
campaignDiscountCents() -
|
||||
campaignDiscountPence(discountPreview) -
|
||||
(useLoyalty ? loyaltyDiscount : 0)
|
||||
)
|
||||
)}
|
||||
@@ -1087,7 +1094,7 @@
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(booking.amount_due * 100) -
|
||||
campaignDiscountCents() -
|
||||
campaignDiscountPence(discountPreview) -
|
||||
(useLoyalty ? loyaltyDiscount : 0)
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -2,15 +2,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
NONCE_STALENESS_MS,
|
||||
OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE,
|
||||
PAYMENT_AMBIGUOUS_STATUS,
|
||||
PAYMENT_DEFINITIVE_STATUS,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
campaignDiscountPence,
|
||||
canSaveCardsForRole,
|
||||
isAmbiguousPaymentFailure,
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
sanitizeDecimalInput,
|
||||
submitPaymentWithRetry
|
||||
} from './square';
|
||||
import type * as SquareModule from './square';
|
||||
@@ -121,6 +120,56 @@ describe('isSquareMock / isSquareConfigured / getSquareConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeDecimalInput', () => {
|
||||
it.each([
|
||||
['', ''],
|
||||
['0', '0'],
|
||||
['12.34', '12.34'],
|
||||
['1.2.3', '1.23'],
|
||||
['£50', '50'],
|
||||
['1,234.56', '1234.56'],
|
||||
['abc', ''],
|
||||
['..', '.'],
|
||||
['1.', '1.']
|
||||
])('sanitizes %s → %s', (input, expected) => {
|
||||
expect(sanitizeDecimalInput(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('campaignDiscountPence', () => {
|
||||
const base = {
|
||||
eligible: true,
|
||||
discounts: [
|
||||
{ source: 'campaign', name: '10% Off', percent: 10, amount: 5 },
|
||||
{ source: 'campaign', name: 'Referral', percent: 5, amount: 2.5 }
|
||||
],
|
||||
original_total: 50,
|
||||
discounted_total: 42.5
|
||||
};
|
||||
|
||||
it('sums eligible discount amounts in pence', () => {
|
||||
expect(campaignDiscountPence(base)).toBe(750);
|
||||
});
|
||||
|
||||
it('rounds each discount amount to pence before summing', () => {
|
||||
expect(campaignDiscountPence({ ...base, discounts: [{ ...base.discounts[0], amount: 5.005 }] })).toBe(
|
||||
501
|
||||
);
|
||||
});
|
||||
|
||||
it('is 0 when no preview', () => {
|
||||
expect(campaignDiscountPence(null)).toBe(0);
|
||||
});
|
||||
|
||||
it('is 0 when the preview is not eligible', () => {
|
||||
expect(campaignDiscountPence({ ...base, eligible: false })).toBe(0);
|
||||
});
|
||||
|
||||
it('is 0 for an empty discount list', () => {
|
||||
expect(campaignDiscountPence({ ...base, discounts: [] })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canSaveCardsForRole', () => {
|
||||
it.each([
|
||||
['admin', true],
|
||||
@@ -137,12 +186,12 @@ describe('canSaveCardsForRole', () => {
|
||||
});
|
||||
|
||||
describe('payment failure classification', () => {
|
||||
it('PAYMENT_DEFINITIVE_STATUS is 402', () => {
|
||||
expect(PAYMENT_DEFINITIVE_STATUS).toBe(402);
|
||||
it('a definitive 402 on a saved-card charge is an issuer verification failure', () => {
|
||||
expect(isSavedCardVerificationRequired(402, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('PAYMENT_AMBIGUOUS_STATUS is 503', () => {
|
||||
expect(PAYMENT_AMBIGUOUS_STATUS).toBe(503);
|
||||
it('a 402 on a new-card charge is a plain decline, not a verification failure', () => {
|
||||
expect(isSavedCardVerificationRequired(402, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('isAmbiguousPaymentFailure matches only 503', () => {
|
||||
@@ -172,14 +221,16 @@ describe('isOverflowTipConfirmationRequired', () => {
|
||||
it('matches the backend overflow-guard error body by its code', () => {
|
||||
const body = JSON.stringify({
|
||||
error: 'The extra amount will be recorded as a tip. Confirm to continue.',
|
||||
code: OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE
|
||||
code: 'overflow_tip_confirmation_required'
|
||||
});
|
||||
expect(isOverflowTipConfirmationRequired(body)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a 400 body with a different code', () => {
|
||||
expect(
|
||||
isOverflowTipConfirmationRequired(JSON.stringify({ error: 'Bad amount', code: 'invalid_amount' }))
|
||||
isOverflowTipConfirmationRequired(
|
||||
JSON.stringify({ error: 'Bad amount', code: 'invalid_amount' })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,46 @@ export function canSaveCardsForRole(role: string | undefined): boolean {
|
||||
return role === 'verified_email' || role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a decimal-money text input: strips every non-numeric character
|
||||
* except the decimal point and keeps only the FIRST dot (so "£1.2.3" → "1.23").
|
||||
* The caller still validates the result against /^\d+(\.\d{0,2})?$/ when a
|
||||
* max-two-decimal rule applies — this only normalizes what the user typed.
|
||||
* Shared by every decimal money input (booking partials, admin service
|
||||
* overrides, tips, cash amounts) so the sanitizer can't drift between them.
|
||||
*/
|
||||
export function sanitizeDecimalInput(value: string): string {
|
||||
const cleaned = value.replace(/[^0-9.]/g, '');
|
||||
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;
|
||||
}
|
||||
|
||||
/** Shape of the `/api/bookings/{id}/discount-preview` response, as consumed by
|
||||
* the payment modals when computing the eligible campaign credit. */
|
||||
export interface DiscountPreview {
|
||||
eligible: boolean;
|
||||
discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
|
||||
original_total: number;
|
||||
discounted_total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total eligible campaign-discount credit in pence. The backend auto-applies
|
||||
* eligible campaigns at payment/completion, so the modals must charge the
|
||||
* DISCOUNTED amount — sharing the computation keeps the customer and admin
|
||||
* modals from drifting on how the preview is reduced to pence.
|
||||
*/
|
||||
export function campaignDiscountPence(discountPreview: DiscountPreview | null): number {
|
||||
return discountPreview?.eligible
|
||||
? discountPreview.discounts.reduce((sum, d) => sum + Math.round(d.amount * 100), 0)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** True when a cached card nonce can no longer be reused: it was tokenized for a
|
||||
* different amount than `amount`, or it is older than NONCE_STALENESS_MS. */
|
||||
export function isNonceStale(
|
||||
@@ -52,8 +92,8 @@ export function isNonceStale(
|
||||
* - 402 (definitive): card declined, expired, AVS/CVV failure — retrying
|
||||
* with the same inputs can never succeed.
|
||||
*/
|
||||
export const PAYMENT_AMBIGUOUS_STATUS = 503;
|
||||
export const PAYMENT_DEFINITIVE_STATUS = 402;
|
||||
const PAYMENT_AMBIGUOUS_STATUS = 503;
|
||||
const PAYMENT_DEFINITIVE_STATUS = 402;
|
||||
|
||||
/**
|
||||
* True when a definitive (402) charge failure on a SAVED CARD should be
|
||||
@@ -89,9 +129,9 @@ export const SAVED_CARD_VERIFICATION_MESSAGE =
|
||||
* request with `confirm_overflow_tip: true` on confirm. This fires mainly on
|
||||
* stale booking data (multi-tab, admin-changed totals, refunds that reopened
|
||||
* capacity), so the response body carries no amount — the caller computes the
|
||||
* overflow as `req.Amount - remainingCents` from its booking data.
|
||||
* overflow as `req.Amount - remainingPence` from its booking data.
|
||||
*/
|
||||
export const OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE = 'overflow_tip_confirmation_required';
|
||||
const OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE = 'overflow_tip_confirmation_required';
|
||||
|
||||
/**
|
||||
* True when an API error body is the backend's overflow-tip confirmation guard
|
||||
|
||||
@@ -60,6 +60,16 @@ class AuthStore {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
// PSD2 SCA stand-in: 2FA required but not yet enabled blocks saved-card
|
||||
// use (charging a saved card, selecting one as default) and saving new
|
||||
// cards for reuse. The new-card (nonce) path has its own SCA via Square
|
||||
// tokenizeWithVerification, so only the saved-card surfaces are gated.
|
||||
// Single source of truth so the predicate can't drift between the booking,
|
||||
// account, tip and admin payment surfaces.
|
||||
get twoFactorBlocksSavedCards() {
|
||||
return !!this.user?.twoFactorRequired && !this.user?.twoFactorEnabled;
|
||||
}
|
||||
|
||||
get currentToken() {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user