Fix payment review round: till integrity, HTTP client tests, concurrency tests, card-selection consolidation

Addresses the payment review (all 10 blocking + 2 minor findings):

Till money-integrity (CreateTillSale):
- Add pg_advisory_lock on the idempotency key (concurrent same-key double-funding race)
- Guard amount on pending-reuse retry (mirrors tip/gift-card guards)
- Explicitly complete the row for cash/on_the_house pending-reuse
- Reject method-switch on a live card-machine checkout (double-charge guard)
- 3 regression tests (amount-mismatch, cash-completes-row, method-switch)

BookingFlow:
- Fetch saved cards at the deposit step (was dead code)
- Charge the server-computed deposit_amount, not the client estimate

HTTP client tests (was untested): doJSON error parsing, refund sentinel
classification, payment/refund/card wire shapes, checkout polling states,
list-refunds pagination + 20-page guard, sha256 card idempotency key

Concurrency regression tests: real two-goroutine races for BuyGiftCard,
tip, and booking-payment locks asserting exactly-one record each

Frontend:
- Fix CRIT-1: zero-saved-card users blocked (all flows now handle it)
- Consolidate tip/deposit/Buy-Gift-Card card UI onto CardSelection
- Explicit save-card consent checkbox (was silent/inconsistent)
- Fix stale saved-card field names in BookingFlow (last4 -> last_4)
- Unique instance ids (crypto.randomUUID) in CardSelection/SquareCardInput
- UserPaymentModal: keep card form mounted on error + Try Again button

Health/docs: /api/health reports square state (mock/ok, was not_implemented),
close P1 backlog, correct stale webhook and env-var claims
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 64d4b65083
commit 53ca89603d
18 changed files with 1330 additions and 480 deletions
@@ -36,9 +36,7 @@
import DatePicker from '$lib/components/booking/DatePicker.svelte';
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
import CardEntryUnavailable from '$lib/components/payments/CardEntryUnavailable.svelte';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import { isSquareConfigured } from '$lib/square/square';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
@@ -84,16 +82,24 @@
// =============== Payment State ===============
let userDepositsRequired = $state<number>(0);
let hasActiveBooking = $state<boolean>(false);
// Saved cards, in the API shape (last_4/exp_month/exp_year) — consumed by
// CardSelection.svelte which renders the list, new-card toggle, and consent.
let paymentMethods = $state<
Array<{ id: string; brand: string; last4: string; expiry_month: number; expiry_year: number }>
Array<{
id: string;
brand: string;
last_4: string;
exp_month: number;
exp_year: number;
is_default?: boolean;
}>
>([]);
let paymentMethodsLoading = $state(false);
let selectedPaymentMethod = $state<string | null>(null);
let selectedPaymentMethod = $state('');
let paymentCardSelection = $state<CardSelection | null>(null);
let paymentCardSelectionValid = $state(false);
let depositSaveCard = $state(false);
let isProcessingPayment = $state(false);
// New-card mode (Square Web Payments tokenization)
let showNewCardForm = $state(false);
let squareCardReady = $state(false);
let squareCardInput = $state<SquareCardInput | null>(null);
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
// of re-tokenizing (the backend idempotency key dedups).
let depositNonce = $state('');
@@ -106,15 +112,11 @@
// Payment flow state
let depositPaid = $state(false);
// New-card form is active when toggled, or implicitly when there is no saved
// card to pick (guest flow / no saved cards yet).
const newCardMode = $derived(
showNewCardForm || !authStore.isAuthenticated || paymentMethods.length === 0
const canSaveCards = $derived(
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
);
const depositCardFormValid = $derived(
selectedPaymentMethod !== null || (newCardMode && squareCardReady)
);
const depositCardFormValid = $derived(paymentCardSelectionValid);
// VAT registration status from public business info (via shared store)
const vatRegistered = $derived(getBusinessInfo()?.is_vat_registered ?? false);
@@ -275,7 +277,7 @@
}
}
async function processPayment(amount: number) {
async function processPayment(_amount: number) {
// Synchronous double-click guard — set BEFORE any await so a rapid second
// click is rejected immediately, even before the reactive `disabled` has
// propagated to the button.
@@ -287,11 +289,11 @@
let newCardToken: string | undefined;
if (selectedPaymentMethod) {
// saved card — nothing to tokenize
} else if (newCardMode && squareCardInput) {
} else if (paymentCardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
if (!depositNonce) {
try {
depositNonce = await squareCardInput.tokenize();
depositNonce = await paymentCardSelection.tokenize();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed');
return;
@@ -308,11 +310,19 @@
return;
}
const bookingId = confirmedBooking.id;
const amountCents = Math.round(amount * 100);
// 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
// getTotalPrice()*0.2 estimate can diverge and under-charge.
const depositAmount =
confirmedBooking.deposit_amount && confirmedBooking.deposit_amount > 0
? confirmedBooking.deposit_amount
: _amount;
const amountCents = Math.round(depositAmount * 100);
// Cache the idempotency key per amount+card so a lost-response retry
// reuses it (backend dedups) instead of double-charging.
const cardKey = selectedPaymentMethod ?? `new:${newCardToken ?? ''}`;
const cardKey = selectedPaymentMethod || `new:${newCardToken ?? ''}`;
if (
!depositIdempotencyKey ||
depositKeyedAmount !== amountCents ||
@@ -328,7 +338,7 @@
amount: amountCents,
idempotency_key: depositIdempotencyKey,
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: false } : {})
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {})
};
paymentAttempted = true;
@@ -348,6 +358,7 @@
depositKeyedAmount = 0;
depositKeyedCard = '';
depositNonce = '';
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
@@ -355,8 +366,8 @@
confirmedBooking = {
...confirmedBooking,
deposit_paid: true,
amount_paid: (confirmedBooking.amount_paid || 0) + amount,
amount_due: Math.max(0, (confirmedBooking.amount_due || 0) - amount)
amount_paid: (confirmedBooking.amount_paid || 0) + depositAmount,
amount_due: Math.max(0, (confirmedBooking.amount_due || 0) - depositAmount)
};
toast.success('Payment successful!');
} else {
@@ -382,8 +393,21 @@
let depositKeyedAmount = $state(0);
let depositKeyedCard = $state('');
function formatCardExpiry(month: number, year: number): string {
return `${String(month).padStart(2, '0')}/${year.toString().slice(-2)}`;
async function fetchPaymentMethods() {
if (paymentMethodsLoading || paymentMethods.length > 0) return;
if (!authStore.isAuthenticated) return;
paymentMethodsLoading = true;
try {
const response = await apiFetch('/api/user/payment-methods');
if (response.ok) {
// CardSelection auto-selects the default card once cards load.
paymentMethods = await response.json();
}
} catch {
// non-fatal — the new-card form remains available
} finally {
paymentMethodsLoading = false;
}
}
// Fetch user deposit and active booking status when step 1 is reached
@@ -394,6 +418,13 @@
}
});
// Fetch saved cards when the deposit payment step is shown.
$effect(() => {
if (currentStep === finalStep && depositRequired && authStore.isAuthenticated) {
fetchPaymentMethods();
}
});
// =============== Slot Reservation System ===============
let _reservationId = $state<string | null>(null);
let reservationExpiresAt = $state<Date | null>(null);
@@ -2296,88 +2327,28 @@
{#if authStore.isAuthenticated}
{#if paymentMethodsLoading}
<div class="mb-6 py-4 text-center text-gray-500">Loading payment methods...</div>
{:else if paymentMethods.length > 0}
{:else}
<div class="mb-6">
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
<div class="space-y-3">
{#each paymentMethods as method (method.id)}
<div
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
method.id && !showNewCardForm
? 'border-primary bg-primary/5'
: ''}"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium"
>
{method.brand}
</div>
<div class="text-sm">
<span class="font-mono">**** {method.last4}</span>
<span class="ml-2 text-gray-500">
{formatCardExpiry(method.expiry_month, method.expiry_year)}
</span>
</div>
</div>
<Button
size="sm"
variant={selectedPaymentMethod === method.id && !showNewCardForm
? 'default'
: 'outline'}
onclick={() => {
selectedPaymentMethod = method.id;
showNewCardForm = false;
depositNonce = '';
}}
>
{selectedPaymentMethod === method.id && !showNewCardForm
? 'Selected'
: 'Use this card'}
</Button>
</div>
{/each}
<Button
size="sm"
variant={showNewCardForm ? 'default' : 'outline'}
onclick={() => {
selectedPaymentMethod = null;
depositNonce = '';
showNewCardForm = !showNewCardForm;
}}
>
Use a new card
</Button>
</div>
</div>
{/if}
{#if newCardMode}
<div class="mb-6">
{#if isSquareConfigured()}
<SquareCardInput
bind:this={squareCardInput}
onReady={(r) => (squareCardReady = r)}
/>
{:else}
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please use a saved card, or contact the salon to pay by another method."
/>
{/if}
<CardSelection
bind:this={paymentCardSelection}
cards={paymentMethods}
{canSaveCards}
bind:selectedCardId={selectedPaymentMethod}
bind:saveCard={depositSaveCard}
onValidityChange={(v) => (paymentCardSelectionValid = v)}
/>
</div>
{/if}
{:else}
<div class="mb-6">
{#if isSquareConfigured()}
<SquareCardInput
bind:this={squareCardInput}
onReady={(r) => (squareCardReady = r)}
/>
{:else}
<CardEntryUnavailable
message="Online card entry is temporarily unavailable. Please contact the salon to pay by another method."
/>
{/if}
<CardSelection
bind:this={paymentCardSelection}
cards={[]}
{canSaveCards}
bind:selectedCardId={selectedPaymentMethod}
bind:saveCard={depositSaveCard}
onValidityChange={(v) => (paymentCardSelectionValid = v)}
/>
</div>
{/if}