fix: dup/mod secondary round — till 2FA gate parity, mint-cooldown single source, notification parity, pence comments

Loop B dup/mod attack findings:
- TillPurchases admin 2FA gate now mirrors PaymentModal and the backend: gateActive = twoFactorEnforced && customerTwoFactorEnabled && paymentMethod='saved_card'; the customer's setup flag is fetched from GET /api/admin/users/{id} on selection. A 2FA-disabled customer in an enforced env no longer hits a dead-end blocked input — the charge 403 surfaces the actionable message via the existing self-heal.
- Extracted twoFAMintThrottled helper shared by SetupTwoFAHandler and ensurePendingTwoFACode — mint-cooldown rule can no longer drift between setup and disable-flow paths
- Notification-helper drift documented: sweep copy states the intentional booking+user scoping vs the canonical webhook copy (cross-referenced); auth refresh_token_reuse insert verified to carry the same NOT EXISTS acknowledged_at IS NULL guard; no import cycle (webhooks→payments one-way)
- Pence convention: 'rounded to the cent' corrected to 'pence' (handlers.go:2726)

26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 7c424b28b8
commit b46927336b
5 changed files with 74 additions and 22 deletions
@@ -112,18 +112,26 @@
// B6/B10: charging a customer's saved card via the till requires the
// customer's current 2FA verification code when the backend enforces the
// gate. The backend keys on the CARD OWNER (not the admin), so the input is
// surfaced whenever the gate is enforced — the operator relays the
// customer's code. Cash, card machine, and online (new-card nonce) payments
// are unaffected. Shared two-factor-code state (code, reveal, show/missing
// derivations, "Request a new code" handler) — see
// $lib/stores/twoFactorCode.svelte.ts. The admin always supplies the
// CUSTOMER's code — the admin's own 2FA flag is irrelevant to the backend
// gate, so `enabled` is always true.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
// gate. The backend keys on the CARD OWNER (not the admin) and only gates
// customers who have actually ENABLED 2FA (requireTwoFactorForCardAccess:
// twoFactorEnforced() && UserTwoFactorEnabled(cardUserID)), so the input is
// surfaced only when BOTH hold — mirroring PaymentModal. The customer's
// setup flag is not carried by the till customer search, so it is fetched
// from GET /api/admin/users/{id} when a customer is selected (see
// fetchCustomerTwoFactor). For a 2FA-disabled customer in an enforced
// environment the input stays hidden so the charge can be attempted; the
// backend then returns the clear "Enable it in your account settings" 403,
// which the isTwoFactorVerificationGateFailure self-heal surfaces. Cash,
// card machine, and online (new-card nonce) payments are unaffected. Shared
// two-factor-code state (code, reveal, show/missing derivations, "Request a
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts. The admin
// always supplies the CUSTOMER's code — the admin's own 2FA flag is
// irrelevant to the backend gate, so `enabled` is always true.
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
let customerTwoFactorEnabled = $state(false);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => savedCardChargeRequires2FACode && paymentMethod === 'saved_card',
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === 'saved_card',
mint: () =>
selectedCustomer?.id ? adminRequestNewTwoFactorCode(selectedCustomer.id) : requestNewTwoFactorCode()
});
@@ -178,6 +186,7 @@
customerResults = [];
showCustomerResults = false;
fetchSavedCards(customer.id);
fetchCustomerTwoFactor(customer.id);
}
function clearSelectedCustomer() {
@@ -187,6 +196,7 @@
selectedSavedCardId = null;
customerResults = [];
showCustomerResults = false;
customerTwoFactorEnabled = false;
}
async function fetchSavedCards(userId: string) {
@@ -208,6 +218,25 @@
}
}
// B6/B10: the till customer search (GET /api/admin/users) carries no 2FA
// state, so the selected customer's setup flag is fetched from the admin
// user detail endpoint — the same source PaymentModal's fetchCustomerTwoFactor
// keys on. A failure leaves the flag false; the charge 403 self-heal still
// reveals the input.
async function fetchCustomerTwoFactor(userId: string) {
try {
const res = await apiFetch(`/api/admin/users/${userId}`);
if (res.ok) {
const data = await res.json();
customerTwoFactorEnabled = data?.twoFactorEnabled === true;
} else {
customerTwoFactorEnabled = false;
}
} catch {
customerTwoFactorEnabled = false;
}
}
const subtotal = $derived(cart.reduce((sum, item) => sum + item.price * item.qty, 0));
const itemCount = $derived(cart.reduce((sum, item) => sum + item.qty, 0));