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:
@@ -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())}
|
||||
|
||||
Reference in New Issue
Block a user