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
@@ -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}