fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs

Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes:
- CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back
- A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit)
- A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds
- A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows
- A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs
- A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point)
- A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface
- A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction
- M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test
- Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status)

All 25 backend packages pass; frontend 41/41; build + env-docs green.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 78e6d00dc5
commit 6d82535780
60 changed files with 6608 additions and 801 deletions
@@ -8,15 +8,13 @@
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import TipPayment from '$lib/components/payments/TipPayment.svelte';
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
import { computeBalanceDue } from '$lib/utils/booking';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
import { canSaveCardsForRole, isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
import { canSaveCardsForRole } from '$lib/square/square';
interface Props {
open: boolean;
bookingId: string;
@@ -132,210 +130,17 @@
let showPaymentModal = $state(false);
let showTipModal = $state(false);
let tipAmount = $state<number>(0);
let selectedTipPreset = $state<number | null>(null);
let customTipInput = $state('');
let tipProcessing = $state(false);
// Cached idempotency key: generated once per tip attempt, reused on retry
// (so a network-timeout retry dedupes instead of double-charging), cleared on
// success. Reset when the tip amount changes so an amount change after a
// failed attempt gets a fresh key instead of a false dedup (under-charge).
let tipIdempotencyKey = $state('');
let tipKeyedAmount = $state(0);
let tipKeyedCard = $state('');
// Card selection for tips — delegated to CardSelection.svelte.
let tipSavedCards = $state<SavedCard[]>([]);
let tipLoadingCards = $state(false);
let tipCardSelection = $state<CardSelection | null>(null);
let tipSelectedCardId = $state('');
let tipCardSelectionValid = $state(false);
let tipSaveCard = $state(false);
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
// of re-tokenizing (the backend idempotency key dedups).
let tipNonce = $state('');
// Cached SCA verification token paired with tipNonce (both one-shot, reused
// together on retry). The verification token is amount-bound, so changing
// the tip invalidates the cached pair.
let tipVerificationToken = $state('');
let tipTokenAmount = $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 tipTokenizedAt = $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 tipTokenizedForSaveCard = $state(false);
// Tip payment is delegated to the shared TipPayment component (see the tip
// modal below) so the preset/custom-amount selection, 2FA gating, SCA
// verification-token handling, idempotency-key derivation and retry logic
// all live in ONE place instead of diverging between this modal and the
// /tip and /pay-tip/[id] pages.
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
const isTipCardValid = $derived(tipCardSelectionValid);
const tipPresets = $derived(
selectedBooking
? [
{ pct: 10, amount: Math.round(selectedBooking.total_amount * 0.1 * 100) / 100 },
{ pct: 15, amount: Math.round(selectedBooking.total_amount * 0.15 * 100) / 100 },
{ pct: 20, amount: Math.round(selectedBooking.total_amount * 0.2 * 100) / 100 }
]
: []
);
function selectTipPreset(amount: number) {
selectedTipPreset = amount;
customTipInput = '';
tipAmount = amount;
}
function handleCustomTip(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;
}
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
customTipInput = sanitized;
selectedTipPreset = null;
tipAmount = parseFloat(sanitized) || 0;
}
}
async function loadTipSavedCards() {
if (savedCardsStore.loaded) {
tipSavedCards = savedCardsStore.cards;
if (tipSavedCards.length > 0 && !tipSelectedCardId) {
tipSelectedCardId = tipSavedCards.find((c) => c.is_default)?.id || tipSavedCards[0].id;
}
return;
}
tipLoadingCards = true;
try {
await savedCardsStore.fetch();
tipSavedCards = savedCardsStore.cards;
if (tipSavedCards.length > 0 && !tipSelectedCardId) {
tipSelectedCardId = tipSavedCards.find((c) => c.is_default)?.id || tipSavedCards[0].id;
}
} catch {
// ignore
} finally {
tipLoadingCards = false;
}
}
async function submitTip() {
if (!selectedBooking) return;
if (tipAmount <= 0) {
toast.error('Please select a tip amount');
return;
}
let newCardToken: string | undefined;
let verificationToken: string | undefined;
if (tipSelectedCardId) {
// saved card — nothing to tokenize
} else if (tipCardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
// verification token on retry (tokenization is one-shot; the backend
// idempotency key dedups). The verification token is amount-bound, so
// a changed tip amount forces a fresh tokenization.
if (
!tipNonce ||
tipTokenizedForSaveCard !== tipSaveCard ||
isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount)
) {
try {
const tokenized = await tipCardSelection.tokenizeWithVerification(
Math.round(tipAmount * 100),
{
givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email
},
tipSaveCard
);
tipNonce = tokenized.nonce;
tipVerificationToken = tokenized.verificationToken ?? '';
tipTokenAmount = tipAmount;
tipTokenizedAt = Date.now();
tipTokenizedForSaveCard = tipSaveCard;
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
}
}
newCardToken = tipNonce;
verificationToken = tipVerificationToken || undefined;
} else {
toast.error('Please select a payment method');
return;
}
tipProcessing = true;
try {
const bookingId = selectedBooking.id;
// New-card identity is a STABLE sentinel, NOT the cnon: nonce (same
// rationale as the booking/account flows). Include the card so a
// same-amount tip on a DIFFERENT card gets a fresh key instead of
// deduping against the previous card's charge.
const cardKey = tipSelectedCardId || 'new-card';
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
tipIdempotencyKey = crypto.randomUUID();
tipKeyedAmount = tipAmount;
tipKeyedCard = cardKey;
}
const body: Record<string, unknown> = {
amount: Math.round(tipAmount * 100),
idempotency_key: tipIdempotencyKey,
...(tipSelectedCardId ? { card_id: tipSelectedCardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: tipSaveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {})
};
const response = await submitPaymentWithRetry(() =>
apiFetch(`/api/bookings/${bookingId}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Tip payment failed');
}
toast.success('Thank you for your tip!');
tipIdempotencyKey = '';
tipKeyedAmount = 0;
tipKeyedCard = '';
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
showTipModal = false;
fetchBookingDetails();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Tip payment failed');
// 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.
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
} finally {
tipProcessing = false;
}
function handleTipSuccess() {
showTipModal = false;
fetchBookingDetails();
}
function handlePaymentComplete() {
@@ -392,12 +197,6 @@
}
});
$effect(() => {
if (showTipModal) {
loadTipSavedCards();
}
});
function printReceipt() {
if (!selectedBooking) {
toast.error('No booking data to print');
@@ -1155,103 +954,23 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
</Modal.Content>
</Modal.Root>
<Modal.Root
open={showTipModal}
onOpenChange={(v) => {
if (!v) {
showTipModal = false;
tipAmount = 0;
selectedTipPreset = null;
customTipInput = '';
tipIdempotencyKey = '';
tipKeyedAmount = 0;
tipKeyedCard = '';
tipSelectedCardId = '';
tipSaveCard = false;
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
}
}}
>
<Modal.Content class="max-w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto">
<Modal.Header>
<Modal.Title>Leave a Tip</Modal.Title>
<Modal.Description>Show your appreciation for great service</Modal.Description>
</Modal.Header>
{#if showTipModal && selectedBooking}
<Modal.Root
open={showTipModal}
onOpenChange={(v) => {
if (!v) showTipModal = false;
}}
>
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md">
<Modal.Header>
<Modal.Title>Leave a Tip</Modal.Title>
<Modal.Description>Show your appreciation for great service</Modal.Description>
</Modal.Header>
<div class="space-y-4 px-4 pb-4">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
{#each tipPresets as preset (preset.pct)}
<button
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPreset ===
preset.amount
? 'bg-fuchsia-100'
: ''}"
onclick={() => selectTipPreset(preset.amount)}
type="button"
>
<div>£{preset.amount.toFixed(2)}</div>
<div class="text-xs font-normal text-gray-500">{preset.pct}%</div>
</button>
{/each}
<div class="px-4 pb-4">
<!-- Shared with /tip and /pay-tip/[id] so preset/custom/2FA/SCA/retry logic can't diverge. -->
<TipPayment booking={selectedBooking} onSuccess={handleTipSuccess} />
</div>
<div>
<label for="custom-tip" class="text-sm font-medium text-gray-700"
>Or enter custom 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="custom-tip"
type="text"
inputmode="decimal"
step="0.01"
min="0"
placeholder="0.00"
class="pl-7"
value={customTipInput}
oninput={handleCustomTip}
/>
</div>
</div>
<!-- Card Selection for Tip -->
<div class="space-y-3">
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
>Payment Method</span
>
{#if tipLoadingCards}
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
{:else}
<CardSelection
bind:this={tipCardSelection}
cards={tipSavedCards}
{canSaveCards}
bind:selectedCardId={tipSelectedCardId}
bind:saveCard={tipSaveCard}
onValidityChange={(v) => (tipCardSelectionValid = v)}
/>
{/if}
</div>
</div>
<Modal.Footer>
<Button variant="outline" onclick={() => (showTipModal = false)}>Cancel</Button>
<Button
class="hover:bg-fuchsia-50"
onclick={submitTip}
disabled={tipAmount <= 0 || !isTipCardValid || tipProcessing}
loading={tipProcessing}
>
{tipProcessing ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
</Button>
</Modal.Footer>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
</Modal.Content>
</Modal.Root>
</Modal.Content>
</Modal.Root>
{/if}
@@ -168,29 +168,14 @@
let onlineSquareCardInput = $state<SquareCardInput | null>(null);
let onlineSquareProcessing = $state(false);
// Idempotency Key
let idempotencyKey = $state('');
function getIdempotencyKey(): string {
if (!idempotencyKey) {
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;
idempotencyKey = [...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('');
}
return idempotencyKey;
}
// Idempotency: the client deliberately sends NO idempotency_key. The backend
// (CreateTillSale) derives a DETERMINISTIC key server-side from the canonical
// request fields (action + admin + amount + gift-card/user/card targets),
// so a lost-response retry with the SAME amount reuses the same key and the
// pending till_sale row (dedup — no double charge), while ANY amount change
// derives a fresh key. A client-generated key cached for the modal's lifetime
// (as before) is keyed by nothing and would be reused across an amount change
// after a failed attempt, making Square return the PRIOR request's result.
let topUpStep = $state<
'choice' | 'amount' | 'payment' | 'cash_entry' | 'processing' | 'success' | 'error'
@@ -530,7 +515,6 @@
paymentError = '';
paymentResult = null;
cardMachineItemID = null;
idempotencyKey = '';
onlineSquareAction = null;
onlineSquareProcessing = false;
}
@@ -557,8 +541,7 @@
item_type: 'gift_card',
action: actionType,
amount: amt,
payment_method: 'cash',
idempotency_key: getIdempotencyKey()
payment_method: 'cash'
};
if (gcId) body.gift_card_id = gcId;
if (selectedCustomer) body.user_id = selectedCustomer.id;
@@ -596,8 +579,7 @@
item_type: 'gift_card',
action: actionType,
amount: amt,
payment_method: 'card_machine',
idempotency_key: getIdempotencyKey()
payment_method: 'card_machine'
};
if (gcId) body.gift_card_id = gcId;
if (selectedCustomer) body.user_id = selectedCustomer.id;
@@ -695,8 +677,7 @@
action: actionType,
amount: amt,
payment_method: 'online_square',
card_token: token,
idempotency_key: getIdempotencyKey()
card_token: token
};
if (verificationToken) body.verification_token = verificationToken;
if (gcId) body.gift_card_id = gcId;
@@ -740,8 +721,7 @@
action: 'topup',
amount: Number(topUpAmount),
payment_method: 'on_the_house',
gift_card_id: gcId,
idempotency_key: getIdempotencyKey()
gift_card_id: gcId
};
const res = await apiFetch('/api/admin/till/sale', {
@@ -453,17 +453,14 @@
</details>
<label class="mt-2 flex cursor-pointer items-center gap-2">
<Checkbox bind:checked={forgiveFees} />
<span class="text-xs">Forgive fees — refund fully (overrides deposit protection)</span
>
<span class="text-xs">Forgive cancellation fees — full refund</span>
</label>
<details class="ml-6 text-xs text-gray-500">
<summary class="cursor-pointer hover:text-gray-700"
>What happens with forgiveness</summary
>
<summary class="cursor-pointer hover:text-gray-700">When to use this</summary>
<p class="mt-1">
"We've waived deposit protection on this reschedule as a goodwill gesture. The full
amount moves to the new appointment instead of having up to 50% retained as
deposit."
Use for genuinely excusable cancellations, or when the salon cancels and chooses
not to keep the money. When unchecked, standard notice-period fees apply (e.g. a
customer who calls up to cancel).
</p>
</details>
{/if}
@@ -11,19 +11,28 @@
import { Input } from '$lib/components/ui/input';
import * as Card from '$lib/components/ui/card';
import { onMount } from 'svelte';
import { canSaveCardsForRole, isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
import {
canSaveCardsForRole,
isNonceStale,
isSavedCardVerificationRequired,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
} from '$lib/square/square';
// Shared tip-payment UI used by /tip and /pay-tip/[id]. The routes resolve
// the booking (most-recent past booking vs. booking by URL id) and hand it
// here; everything else — tip selection, CardSelection wiring, nonce + SCA
// verification caching, idempotency-key derivation, submitTip, success and
// error handling — lives in ONE place so the two pages can't diverge.
// Shared tip-payment UI used by /tip, /pay-tip/[id] and the account
// booking-modal tip dialog. The routes resolve the booking (most-recent past
// booking vs. booking by URL id) and hand it here; everything else — tip
// selection, CardSelection wiring, nonce + SCA verification caching,
// idempotency-key derivation, submitTip, success and error handling — lives
// in ONE place so the three surfaces can't diverge. When embedded in a modal
// (UserBookingModal), `onSuccess` lets the host close itself and refresh
// instead of navigating home; standalone pages omit it.
type BookingService = {
service_id: string;
booking_id: string;
service_name: string;
price: number;
duration_minutes: number;
service_name?: string;
price?: number;
duration_minutes?: number;
override_price?: number;
override_duration_minutes?: number;
};
@@ -40,13 +49,13 @@
type Booking = {
id: string;
start_time: string;
services: BookingService[];
services?: BookingService[];
total_amount: number;
duration_minutes: number;
payments?: Payment[];
};
const { booking }: { booking: Booking } = $props();
const { booking, onSuccess }: { booking: Booking; onSuccess?: () => void } = $props();
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
// Synchronous double-click guard for submitTip. paymentState is only set to
@@ -272,6 +281,9 @@
paymentState = 'processing';
const usedSavedCard = !!(selectedCardId && !twoFactorBlocksSavedCards);
let responseStatus = 0;
try {
// New-card identity is a STABLE sentinel, NOT the cnon: nonce (same
// rationale as the booking/account flows). Include the card so a
@@ -301,6 +313,7 @@
);
if (!response.ok) {
responseStatus = response.status;
const errorText = await response.text();
throw new Error(extractErrorMessage(errorText) || 'Payment failed');
}
@@ -315,9 +328,17 @@
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
toast.success('Thank you for your tip!');
onSuccess?.();
} catch (err) {
paymentState = 'error';
const errorMessage = err instanceof Error ? err.message : 'Payment failed';
let errorMessage = err instanceof Error ? err.message : 'Payment failed';
// 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.
if (isSavedCardVerificationRequired(responseStatus, usedSavedCard)) {
errorMessage = SAVED_CARD_VERIFICATION_MESSAGE;
}
toast.error(errorMessage);
// A definitive charge failure (e.g. declined card) consumes the nonce
// and SCA verification token — they can never succeed again. Clear the
@@ -357,8 +378,12 @@
</div>
<h2 class="text-xl font-semibold text-gray-900">Thank you!</h2>
<p class="mt-2 text-gray-600">Your generosity is greatly appreciated.</p>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<Button class="mt-6" onclick={() => goto('/')}>Go Home</Button>
{#if onSuccess}
<Button class="mt-6" onclick={onSuccess}>Done</Button>
{:else}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<Button class="mt-6" onclick={() => goto('/')}>Go Home</Button>
{/if}
</Card.Content>
</Card.Root>
{:else}
@@ -376,7 +401,7 @@
<span class="font-medium"
>{formatTimeRange(
booking.start_time,
booking.services,
booking.services ?? [],
booking.duration_minutes ?? 0
)}</span
>
@@ -397,9 +422,9 @@
<div class="mt-2 space-y-1">
{#each booking.services as service (service.service_id || service.booking_id)}
<div class="flex justify-between text-sm">
<span class="text-gray-700">{service.service_name}</span>
<span class="text-gray-700">{service.service_name || '—'}</span>
<span class="text-gray-500"
>{formatPrice(service.override_price ?? service.price)}</span
>{formatPrice(service.override_price ?? service.price ?? 0)}</span
>
</div>
{/each}
@@ -12,7 +12,13 @@
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import { isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
import {
isNonceStale,
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
} from '$lib/square/square';
const LOYALTY_DISCOUNT_RATE = 0.1;
@@ -136,6 +142,42 @@
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(() => {
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.round(Math.max(0, Math.min(total - paid + refunded, total)) * 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
// (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;
paymentType: string;
overflowCents: number;
cardId?: string;
newCardToken?: string;
verificationToken?: string;
} | null>(null);
const loyaltyEligible = $derived(
stamps >= 10 &&
!(booking.discounts ?? []).some((d) => d.discount_source === 'loyalty') &&
@@ -445,6 +487,25 @@
payKeyedCard = cardKey;
}
await submitBookingPayment(paymentType, amountCents, 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,
amountCents: 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`, {
@@ -453,6 +514,7 @@
body: JSON.stringify({
amount: amountCents,
payment_type: paymentType,
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
...(cardId ? { card_id: cardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
@@ -462,13 +524,32 @@
);
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)) {
overflowConfirm = {
amountCents,
paymentType,
overflowCents: Math.max(0, amountCents - remainingBalanceCents),
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 = '';
@@ -491,9 +572,16 @@
releaseLock();
} catch (_err) {
status = 'error';
const msg = _err instanceof Error ? _err.message : 'Payment declined';
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;
error = msg;
toast.error(`${msg}. Please try again or use another card.`);
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
@@ -508,6 +596,32 @@
}
}
// 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 || status === 'processing') return;
status = 'processing';
error = null;
await submitBookingPayment(
pending.paymentType,
pending.amountCents,
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 depositCents = booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
@@ -585,7 +699,63 @@
{/if}
</Dialog.Header>
{#if status === 'idle' || status === 'processing' || status === 'error'}
{#if overflowConfirm}
<div class="space-y-4">
<!-- 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-md 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
{formatCurrency(overflowConfirm.overflowCents)} will be recorded as a tip. Confirm
to continue?
</p>
</div>
</div>
<div class="mt-4 flex gap-2">
<Button
class="flex-1"
loading={status === 'processing'}
disabled={status === 'processing'}
onclick={confirmOverflowPayment}
>
Confirm
</Button>
<Button
variant="outline"
class="flex-1"
disabled={status === 'processing'}
onclick={cancelOverflowConfirmation}
>
Cancel
</Button>
</div>
</div>
<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}
+267
View File
@@ -0,0 +1,267 @@
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,
canSaveCardsForRole,
isAmbiguousPaymentFailure,
isNonceStale,
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
submitPaymentWithRetry
} from './square';
import type * as SquareModule from './square';
describe('isNonceStale', () => {
const tokenizedFor = 2500;
it('is false for a fresh nonce (under the staleness limit)', () => {
const tokenizedAt = 1_000_000;
const now = tokenizedAt + NONCE_STALENESS_MS - 1;
expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(false);
});
it('is false exactly at the staleness boundary (limit is exclusive, > not >=)', () => {
const tokenizedAt = 1_000_000;
const now = tokenizedAt + NONCE_STALENESS_MS;
expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(false);
});
it('is true just past the staleness boundary', () => {
const tokenizedAt = 1_000_000;
const now = tokenizedAt + NONCE_STALENESS_MS + 1;
expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(true);
});
it('is true when the nonce was tokenized for a different amount than now', () => {
expect(isNonceStale(1_000_000, tokenizedFor, tokenizedFor + 1, Date.now())).toBe(true);
});
it('is true for a very old nonce', () => {
expect(isNonceStale(1, tokenizedFor, tokenizedFor, Date.now())).toBe(true);
});
it('defaults `now` to Date.now() when omitted', () => {
expect(isNonceStale(Date.now(), tokenizedFor, tokenizedFor)).toBe(false);
});
});
describe('isSquareMock / isSquareConfigured / getSquareConfig', () => {
type SquareEnv = {
VITE_SQUARE_ENVIRONMENT?: string;
VITE_SQUARE_APPLICATION_ID?: string;
VITE_SQUARE_LOCATION_ID?: string;
DEV?: boolean;
};
// square.ts captures APP_ID/LOCATION_ID/SQUARE_ENV at module load, so each
// env combination must reload the module with vi.resetModules + vi.stubEnv.
// All three VITE_* keys are stubbed explicitly ('' when omitted) so local
// .env files can never leak into the suite.
async function loadSquareWithEnv(env: SquareEnv): Promise<typeof SquareModule> {
vi.resetModules();
vi.stubEnv('VITE_SQUARE_ENVIRONMENT', env.VITE_SQUARE_ENVIRONMENT ?? '');
vi.stubEnv('VITE_SQUARE_APPLICATION_ID', env.VITE_SQUARE_APPLICATION_ID ?? '');
vi.stubEnv('VITE_SQUARE_LOCATION_ID', env.VITE_SQUARE_LOCATION_ID ?? '');
if (env.DEV !== undefined) {
vi.stubEnv('DEV', env.DEV);
}
return await import('./square');
}
afterEach(() => {
vi.unstubAllEnvs();
});
it('mock env in a dev build → isSquareMock true, isSquareConfigured true', async () => {
const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock', DEV: true });
expect(mod.isSquareMock()).toBe(true);
expect(mod.isSquareConfigured()).toBe(true);
});
it('mock env in a production build → isSquareMock false (DEV gate)', async () => {
const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock', DEV: false });
expect(mod.isSquareMock()).toBe(false);
});
it('mock env without credentials → isSquareConfigured true (mock form needs no keys)', async () => {
const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock' });
expect(mod.isSquareConfigured()).toBe(true);
});
it('sandbox env with credentials → not mock, configured, config returned', async () => {
const mod = await loadSquareWithEnv({
VITE_SQUARE_ENVIRONMENT: 'sandbox',
VITE_SQUARE_APPLICATION_ID: 'sandbox-sq0idb-abc123',
VITE_SQUARE_LOCATION_ID: 'L0MOCK123'
});
expect(mod.isSquareMock()).toBe(false);
expect(mod.isSquareConfigured()).toBe(true);
expect(mod.getSquareConfig()).toEqual({
appId: 'sandbox-sq0idb-abc123',
locationId: 'L0MOCK123'
});
});
it('sandbox env without credentials → not configured, config null', async () => {
const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'sandbox' });
expect(mod.isSquareMock()).toBe(false);
expect(mod.isSquareConfigured()).toBe(false);
expect(mod.getSquareConfig()).toBeNull();
});
it('empty env → neither mock nor configured', async () => {
const mod = await loadSquareWithEnv({});
expect(mod.isSquareMock()).toBe(false);
expect(mod.isSquareConfigured()).toBe(false);
expect(mod.getSquareConfig()).toBeNull();
});
});
describe('canSaveCardsForRole', () => {
it.each([
['admin', true],
['verified_email', true],
['unverified_email', false],
['guest', false],
['affiliate', false],
['user', false],
['', false],
[undefined, false]
])('role %s → %s', (role, expected) => {
expect(canSaveCardsForRole(role)).toBe(expected);
});
});
describe('payment failure classification', () => {
it('PAYMENT_DEFINITIVE_STATUS is 402', () => {
expect(PAYMENT_DEFINITIVE_STATUS).toBe(402);
});
it('PAYMENT_AMBIGUOUS_STATUS is 503', () => {
expect(PAYMENT_AMBIGUOUS_STATUS).toBe(503);
});
it('isAmbiguousPaymentFailure matches only 503', () => {
expect(isAmbiguousPaymentFailure(503)).toBe(true);
expect(isAmbiguousPaymentFailure(402)).toBe(false);
expect(isAmbiguousPaymentFailure(200)).toBe(false);
expect(isAmbiguousPaymentFailure(500)).toBe(false);
});
it.each([
[true, 402, true],
[false, 402, false],
[true, 503, false],
[true, 200, false],
[false, 200, false]
])('isSavedCardVerificationRequired(%s, %d) → %s', (usedSavedCard, status, expected) => {
expect(isSavedCardVerificationRequired(status, usedSavedCard)).toBe(expected);
});
it('SAVED_CARD_VERIFICATION_MESSAGE is non-empty and mentions verification', () => {
expect(SAVED_CARD_VERIFICATION_MESSAGE.length).toBeGreaterThan(0);
expect(SAVED_CARD_VERIFICATION_MESSAGE.toLowerCase()).toContain('verification');
});
});
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
});
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' }))
).toBe(false);
});
it('is false for a body with only the error text and no code', () => {
expect(isOverflowTipConfirmationRequired('The extra amount will be recorded as a tip.')).toBe(
false
);
});
it('is false for a non-JSON body', () => {
expect(isOverflowTipConfirmationRequired('Payment declined')).toBe(false);
});
it('is false for an empty body', () => {
expect(isOverflowTipConfirmationRequired('')).toBe(false);
});
it('is false when the code is not an exact match (guards against prefix drift)', () => {
expect(
isOverflowTipConfirmationRequired(
JSON.stringify({ error: 'x', code: 'overflow_tip_confirmation_required_extra' })
)
).toBe(false);
});
});
describe('submitPaymentWithRetry', () => {
function jsonResponse(status: number): Response {
return new Response(null, { status });
}
it('retries on a 503 and resolves the follow-up 200', async () => {
const submit = vi
.fn()
.mockResolvedValueOnce(jsonResponse(503))
.mockResolvedValueOnce(jsonResponse(200));
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 });
expect(response.status).toBe(200);
expect(response.ok).toBe(true);
expect(submit).toHaveBeenCalledTimes(2);
});
it('returns a 402 immediately without retrying', async () => {
const submit = vi.fn().mockResolvedValue(jsonResponse(402));
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 });
expect(response.status).toBe(402);
expect(submit).toHaveBeenCalledTimes(1);
});
it('succeeds after two 503s followed by a 200', async () => {
const submit = vi
.fn()
.mockResolvedValueOnce(jsonResponse(503))
.mockResolvedValueOnce(jsonResponse(503))
.mockResolvedValueOnce(jsonResponse(200));
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 });
expect(response.status).toBe(200);
expect(submit).toHaveBeenCalledTimes(3);
});
it('gives up after maxRetries and returns the last 503', async () => {
const submit = vi.fn().mockResolvedValue(jsonResponse(503));
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 });
expect(response.status).toBe(503);
expect(submit).toHaveBeenCalledTimes(4);
});
it('honours a custom maxRetries', async () => {
const submit = vi.fn().mockResolvedValue(jsonResponse(503));
const response = await submitPaymentWithRetry(submit, { maxRetries: 1, retryDelayMs: 1 });
expect(response.status).toBe(503);
expect(submit).toHaveBeenCalledTimes(2);
});
it('backs off with retryDelayMs between retries', async () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const submit = vi
.fn()
.mockResolvedValueOnce(jsonResponse(503))
.mockResolvedValueOnce(jsonResponse(200));
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1234 });
expect(response.status).toBe(200);
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1234);
});
});
+58
View File
@@ -55,6 +55,64 @@ export function isNonceStale(
export const PAYMENT_AMBIGUOUS_STATUS = 503;
export const PAYMENT_DEFINITIVE_STATUS = 402;
/**
* True when a definitive (402) charge failure on a SAVED CARD should be
* surfaced as a card-issuer verification problem rather than a plain decline.
* The backend sets customer_details.customer_initiated=true on saved-card
* (ccof) charges and classifies issuer-verification rejections Square's
* CARD_DECLINED_VERIFICATION_REQUIRED and friends as definitive 402s, but the
* response body is the generic "Payment failed" text with no distinguishing
* code. Saved cards skip the client-side tokenizeWithVerification SCA step, so
* a 402 on the saved-card path means the issuer still requires verification:
* retrying the same saved card can never succeed, and the buyer must pay with
* a freshly tokenized card or re-add theirs. New-card (cnon) charges carry
* their own SCA verification token, so they are never classified this way.
*/
export function isSavedCardVerificationRequired(status: number, usedSavedCard: boolean): boolean {
return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS;
}
/** User-facing guidance for a saved-card charge the issuer requires
* verification to complete. Retrying the same saved card is pointless the
* buyer must pay with a new card or re-add their card. */
export const SAVED_CARD_VERIFICATION_MESSAGE =
'Your card issuer requires verification. Please pay with a new card or re-add your card.';
/**
* Error code the booking-payment endpoint (POST /api/bookings/{id}/payment)
* returns with a 400 when a payment would exceed the booking's remaining
* balance BEFORE the appointment has started. buildSplitRecords records any
* overflow beyond the booking total as a tip but a tip is gratuity for
* service already rendered, so the backend refuses to silently convert an
* unconfirmed pre-start overpayment into a tip (see CreateBookingPayment).
* The frontend must surface a Confirm/Cancel prompt and resend the SAME
* 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.
*/
export const OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE = 'overflow_tip_confirmation_required';
/**
* True when an API error body is the backend's overflow-tip confirmation guard
* (a 400 JSON body of the form `{"error": "...", "code":
* "overflow_tip_confirmation_required"}`). The shared error-parsing helper
* (`extractErrorMessage`) surfaces only the human-readable message text, not
* the machine-readable `code` field, so this checks the raw response body
* directly. Returns false for any non-JSON body or any other error.
*/
export function isOverflowTipConfirmationRequired(errorText: string): boolean {
const trimmed = errorText.trim();
if (!trimmed) return false;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
return parsed?.code === OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE;
} catch {
// Not JSON — cannot be the overflow guard
return false;
}
}
/** True when a payment submission response is an ambiguous failure (503) that
* must be retried with the SAME idempotency key so the backend resumes the
* pending record. */