fix: full-scope review — tip-inclusive amount_due, sweep deposit-strand, A6 clamp cap, B13 clawback, 2FA single-use, mint audit, account-deletion re-auth, refresh dedup

Full-scope Loop A restart review (18 findings across money/security/dup-mod):

MONEY:
- HIGH: amount_paid/amount_due CTEs now exclude payment_type='tip' (bookings.go x6, today.go) — a tip before the final balance no longer undercharges the booking
- MEDIUM-HIGH: pending payment row stores the actual chargeAmount (not req.Amount) so the sweep replay amount-match rescues deposit-with-discount rows instead of auto-refunding them; refundSweepDuplicateCharge refunds the replayed payment's actual amount
- MEDIUM: A6 deposit clamp-up now caps at the discounted obligation (remainingPence - eligibleDiscountPence) — no more silent overcharge when a campaign discount >= deposit
- MEDIUM: B13 campaign-loss balance credits are clawed back on cancellation (clawbackB13CampaignCredit in ProcessCancellationRefundTx)
- LOW: replayLegitimateRetryWindow extended 22h->24h so a legitimate same-key retry in the retry-eligible window is rescued, not auto-refunded

SECURITY:
- 2FA single-use strengthened (consume-at-gate for fresh charges, re-issue on failure)
- Admin 2FA mint now writes admin_audit_log + logs code reuse
- Account deletion requires current password (and 2FA when enforced) — stolen token can no longer destroy the account
- Multi-tab refresh-token replay deduped via cross-tab lock (no false family-kill alerts)
- family-alive cache invalidated on password change / GDPR erasure
- Login lockout keyed per user+IP with a capped ceiling

FRONTEND/DUP-MOD:
- OverflowTipConfirm shared component (UserPaymentModal + BookingFlow); overflow computation aligned (deposit-discount-aware)
- PaymentModal admin 2FA gate now method-conditioned (no over-reveal on cash/giftcard)
- requestTwoFactorCode shared helper (requestNewTwoFactorCode + adminRequestNewTwoFactorCode)
- BookingFlow deposit display aligned to the discounted amount; formatCurrency used consistently

26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent b46927336b
commit 9a182db932
27 changed files with 1279 additions and 300 deletions
@@ -90,7 +90,8 @@
// irrelevant to the backend gate, so `enabled` is always true.
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled,
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === 'savedcard',
mint: () => {
const customerID = booking.user_id ?? booking.user?.id;
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
@@ -110,7 +111,18 @@
// by /api/admin/today/current-next carries no amount_paid/amount_due/
// payments, so this is fetched fresh from the admin booking detail endpoint
// on mount and subtracted from the charge (see netTotal).
//
// Money finding 1: `amount_paid` (summed over ALL completed payments) can
// include tips — a tip is gratuity, not booking credit, so it must not
// reduce what the customer still owes. The booking detail endpoint computes
// amount_paid in Go over every completed payment (no payment_type filter),
// so the tip-excluded obligation is derived here from the payments list
// rather than trusting amount_paid. This stays consistent whether or not
// the backend starts excluding tips from amount_paid (idempotent either
// way). The tip-INCLUSIVE amount_paid is kept for the "Already paid"
// display (mirrors the customer modal's "Amount Paid" row).
let amountPaidPence = $state(0);
let tipExcludedPaidPence = $state(0);
async function fetchAmountPaid() {
try {
const resp = await apiFetch(`/api/admin/bookings/${booking.id}`);
@@ -118,20 +130,29 @@
const data = await resp.json();
if (typeof data.amount_paid === 'number') {
amountPaidPence = Math.round(data.amount_paid * 100);
return;
}
tipExcludedPaidPence = Math.round(
(data.payments ?? [])
.filter(
(p: { status: string; payment_type: string }) =>
p.status === 'completed' && p.payment_type !== 'tip'
)
.reduce((sum: number, p: { amount: number }) => sum + (p.amount || 0), 0) * 100
);
return;
}
} catch (_err) {
// fall through to the booking prop below
}
amountPaidPence = Math.round((booking.amount_paid ?? 0) * 100);
tipExcludedPaidPence = amountPaidPence;
}
const loyaltyEligible = $derived(
stamps >= 10 &&
!(booking.discounts ?? []).some((d: BookingDiscount) => d.discount_source === 'loyalty') &&
booking.total_amount > 0 &&
amountPaidPence === 0
tipExcludedPaidPence === 0
);
const loyaltyDiscount = $derived(
@@ -283,7 +304,10 @@
// remaining value, so the frontend charge and the backend record now agree
// and a prior deposit can no longer land as an unintended tip.
const netTotal = $derived(
Math.max(0, subtotal - discountSum - campaignDiscountPence(discountPreview) - amountPaidPence)
Math.max(
0,
subtotal - discountSum - campaignDiscountPence(discountPreview) - tipExcludedPaidPence
)
);
const tipPercentages = $derived.by(() => {