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