fix: review round 7 — fresh-eyes audit fixes (6 agents) + full test suites for every backend change

Fresh-eyes review round with 6 independent agents (money-safety, concurrency,
Square wire parity, security, frontend flow, testing-gaps). Every finding was
independently verified against the code before fixing. All backend changes
now carry full test suites (10+ new tests, each verified to FAIL without its
guard). All 20 packages green, race detector clean.

Money-safety:
- Gift-card purchase refunds no longer create money: manual refunds of a
  no-booking (gift-card purchase) payment are rejected with a clear message
  in the direct handler AND never re-issued by the sweep-resume path
  (processManualPaymentGroup skips them; reconcile-then-fail, no re-issue).
- BuyGiftCard no-client-key fallback: derived deterministically under the
  advisory lock (pending-row reuse fixes lost-response double-charge;
  completed-row sequence advance preserves distinct-purchase collapse fix).
- Terminal completion is never unrecorded: activeTerminalCheckoutID now calls
  recordUntrackedTerminalPayment when a provisional (tmp-) checkout is found
  COMPLETED at Square (previously only marked the row COMPLETED — a lost poll
  left the payment invisible and unrefundable).
- Sweep: provisional tmp- checkout rows are resolved against Square first
  (COMPLETED → record; live → keep guard; NOT_FOUND/CANCELED → fail;
  ambiguous → leave pending) instead of blind-failing a possibly-live
  checkout. recordUntrackedTerminalPayment re-checks the booking status
  (FOR UPDATE) and refuses to record on a cancelled booking, inserting a
  critical_payment_log admin notification instead. Till-sale post-charge
  UPDATE now requires status='pending' (no resurrection of a clawed-back sale).

Frontend (Svelte 5):
- UserPaymentModal keeps CardSelection mounted through processing (bind:this
  ref + Square iframe survive the loyalty/tokenize awaits) — new-card
  payments work again.
- BookingFlow clears the cached nonce/verification pair on any failure (retry
  re-tokenizes fresh; idempotency key retained for dedup); 409 'already paid'
  refetches the booking and reconciles depositPaid so the confirmation gate
  opens; Back button disabled during processing.
- Synchronous double-submit guards on buyGiftCard/redeemGiftCard/submitTip.

Square wire parity (mock vs real):
- processing_fee sign unified (negated at paymentFromSquare; mock agrees).
- SimulateSourceUsed (SOURCE_USED, 400) matches real CreateCard.
- GetCardsOnFile excludes disabled cards (matches ListCards).
- ForcePaymentStatus toggle + tests prove the charge path can't be status-blind.
- CreateCheckout rejects empty device_id (env fallback SQUARE_TERMINAL_DEVICE_ID);
  completed terminal checkout's payment resolvable by id.

Security:
- 2FA attempt-map data race fixed: lastAt is atomic.Int64 (nanos) — eviction
  scan reads race-free; concurrent verify+evict tests under -race.
- Backend refuses to start on weak/placeholder JWT_SECRET_KEY (<32 chars or
  known public placeholders) with openssl rand -hex 32 guidance.
- Dockerfile no longer COPYs .env (secrets injected via compose env_file).
- SabreDAV requires DAV_ADMIN_PASSWORD (no admin/admin default); compose
  fails at config time when missing.

Testing gaps closed (each verified to FAIL without its guard):
- refunded-dedup 409 (CreateBookingPayment), keyed sweep past-retention
  blind-fail, reconcile status-switch (CANCELED/FAILED/APPROVED/PENDING/unknown
  in both by-key and by-id paths), resolveChargeSource Square-failure branches,
  structured 500 / CARD_DECLINED / cancelled-context E2E (row stays pending),
  deriveBookingPaymentIdempotencyKey >45-char truncation, webhook
  findPaymentByDisputeID fallback, clawbackOneTillSale non-gift-card branch,
  dispute.evidence / terminal.checkout dispatch.

Infra:
- local-dev-2.sh fails loudly on port-5432 squatters / docker compose failures
  (previously died silently under ERR_EXIT with hidden output).
- Test harness defaults SQUARE_TERMINAL_DEVICE_ID; money_safety_fixes_test.go
  gained the missing build tag.

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
-race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet
clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose
config valid.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 67cf5b9a45
commit 5cc5a7f6d2
28 changed files with 3328 additions and 208 deletions
@@ -450,12 +450,70 @@
toast.success('Payment successful!');
} else {
const text = await response.text();
toast.warning(text || 'Payment failed — you can pay again from your booking details.');
// 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;
}
} catch {
toast.error(
'An error occurred. Your booking may still be confirmed — check your appointments.'
);
// A thrown error (network / malformed response) means the nonce + SCA
// verification token are unreliable — clear them so a retry
// re-tokenizes fresh. The idempotency key stays for dedup.
depositNonce = '';
depositVerificationToken = '';
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
} finally {
isProcessingPayment = false;
isProcessingPaymentSync = false;
@@ -2441,7 +2499,13 @@
{/if}
<div class="flex items-center justify-between border-t pt-4">
<Button variant="ghost" onclick={prevStep}>Back</Button>
<Button
variant="ghost"
onclick={prevStep}
disabled={isProcessingPayment}
>
Back
</Button>
<Button
disabled={isProcessingPayment || !depositCardFormValid}
onclick={() => processPayment(calculateDepositAmount())}
@@ -49,6 +49,12 @@
const { booking }: { booking: Booking } = $props();
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
// Synchronous double-click guard for submitTip. paymentState is only set to
// 'processing' AFTER tokenization, so during the tokenize await the reactive
// `disabled` on the Pay button is not yet active and a rapid second click
// would tokenize twice (minting a second nonce). This non-reactive flag is
// checked at entry before any await and cleared in finally.
let isSubmittingTipSync = false;
// Cached idempotency key: generated once per payment attempt, reused on retry
// (so a network-timeout retry dedupes instead of double-charging), cleared on
@@ -208,10 +214,13 @@
});
async function submitTip() {
if (tipAmount <= 0) {
toast.error('Please select a tip amount');
return;
}
if (isSubmittingTipSync) return;
isSubmittingTipSync = true;
try {
if (tipAmount <= 0) {
toast.error('Please select a tip amount');
return;
}
// PSD2 SCA stand-in: never charge a saved card while 2FA is required
// but not enabled — clear any stale selection so the new-card (nonce)
@@ -320,6 +329,9 @@
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
}
} finally {
isSubmittingTipSync = false;
}
}
function retryPayment() {
@@ -756,9 +756,17 @@
</div>
</div>
<!-- Card Selection (mounted while idle OR error so a declined card
can be retried/swapped without closing the modal) -->
{#if (status === 'idle' || status === 'error') && authStore.isAuthenticated}
<!-- Card Selection. Mounted for idle/error so a declined card can be
retried/swapped without closing the modal, AND for processing:
makePayment() sets status='processing' FIRST, then awaits the
loyalty redemption and tokenizeWithVerification. Svelte 5 flushes
on the next microtask after the await yields, so a status gate
that unmounts CardSelection mid-payment would null the bind:this
ref AND destroy the Square iframe mid-tokenization, making the
new-card path fail with "Please select a payment method" /
"Card entry failed". Staying mounted keeps both alive for the
full duration of makePayment. -->
{#if authStore.isAuthenticated}
{#if paymentMethodsLoading}
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
{:else}