fix: restart-loop-A findings — pending sweep refunds, tip carve on discounts, TOCTOU redemption, single-use 2FA code + mint endpoint, refresh-token family revocation, admin 2FA code UX

Restart of Loop A (fresh review -> fix -> verify) findings from commit 5e967fa:
- B1: sweep auto-refund treats Square PENDING refunds as NON-terminal (row stays pending, no gift-card clawback, refunds row inserted for payments AND till_sales, re-polls the deterministic sweepdup- key); Square-less pre-pass exempts square_refund_id IS NOT NULL rows
- M4: terminal tip carve accounts for pending campaign discounts (headroom = total - pending - paid) so explicit tips aren't absorbed as service revenue; no-tip case stays a single record
- max_redemptions TOCTOU closed with atomic conditional UPDATE ... RETURNING; exhausted-at-apply surfaces campaign_fully_redeemed
- 2FA: verification code is single-use on the saved-card gate (VerifyForUser consume=true, interactive flows unaffected); new POST /api/user/2fa/code mints a fresh code for enabled users (RequireAuth + RequireNonGuest + mint cooldown + per-user limiter)
- Refresh tokens: family_id + used_at columns; reuse of an already-rotated token revokes the ENTIRE family and inserts a refresh_token_reuse admin alert; rotation mints descendants in the same family
- Frontend: 2FA code input + Request-a-new-code on all saved-card surfaces; admin modal keys code input to customer 2FA + 403 self-heal; tip-display note for pending discounts; 76 frontend tests
- Verified: all 26 backend packages pass, frontend build+tests green, env-docs 41/41
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent fe88f2084d
commit 4d5d2cd381
28 changed files with 2047 additions and 341 deletions
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Dialog from '$lib/components/ui/dialog';
@@ -12,6 +12,7 @@
campaignDiscountPence,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
requestNewTwoFactorCode,
sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
@@ -67,9 +68,14 @@
// B6/B10: charging a customer's saved card 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.
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
// on the CARD OWNER (the booking's user), so the input is surfaced whenever
// the customer has 2FA enabled in an enforced environment — the operator
// relays the customer's code. `twoFactorRequired` is env-wide enforcement
// (true for every session user when the gate is on); the CUSTOMER's setup
// flag is not carried by the admin booking payload, so it is fetched from
// GET /api/admin/users/{id} on mount (see fetchCustomerTwoFactor).
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
let customerTwoFactorEnabled = $state(false);
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
let useLoyalty = $state(false);
@@ -81,11 +87,51 @@
// irrelevant to the backend gate.
let twoFactorCode = $state('');
// Set true when a charge 403s for a missing code — reveals the input even
// if the session user's flag is unset.
// if the customer's 2FA flag is unknown/unset.
let reveal2FACodeInput = $state(false);
const show2FACodeInput = $derived(reveal2FACodeInput || savedCardChargeRequires2FACode);
const show2FACodeInput = $derived(
reveal2FACodeInput || (twoFactorEnforced && customerTwoFactorEnabled)
);
const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === '');
// POST /api/user/2fa/code mint state for the "Request a new code" button on
// the saved-card screen. NOTE: this mints for the SIGNED-IN session (the
// admin), which cannot authorize the customer's charge — the backend agent
// coordinating POST /api/user/2fa/code should also add an admin-scoped mint
// (e.g. /api/admin/users/{id}/2fa/code) for the operator to mint the
// customer's code; until then the button exercises the cooldown/429/503 UX.
let requesting2FACode = $state(false);
async function handleRequestNew2FACode() {
if (requesting2FACode) return;
requesting2FACode = true;
try {
const result = await requestNewTwoFactorCode();
if (result.ok) {
twoFactorCode = '';
toast.success(result.message);
} else if (result.status === 429) {
toast.error(result.message || 'Too many requests. Wait before requesting a new code.');
} else if (result.status === 503) {
toast.error(
result.message || 'Verification codes are unavailable right now. Try again later.'
);
} else {
toast.error(result.message);
}
} finally {
requesting2FACode = false;
}
}
// Focus the verification-code input whenever the saved-card screen shows it
// (auto-show for a 2FA-enabled customer, or the 403 self-heal reveal) so the
// operator can type the customer's code without an extra click.
$effect(() => {
if (status === 'saved-card-selecting' && show2FACodeInput) {
tick().then(() => document.getElementById('two-factor-code')?.focus());
}
});
// B3: pence already paid against this booking. The AppointmentInfo handed in
// by /api/admin/today/current-next carries no amount_paid/amount_due/
// payments, so this is fetched fresh from the admin booking detail endpoint
@@ -158,11 +204,30 @@
let serviceOverrides = $state<Record<string, ServiceOverride>>({});
// B6/B10: the admin booking payload carries no 2FA state for the owner, so
// the customer's flag is fetched from the admin user detail endpoint (the
// same source the customer-flag fix keys on). A failure leaves the flag
// false — the charge 403 self-heal still reveals the input.
async function fetchCustomerTwoFactor() {
const targetUserId = booking.user_id ?? booking.user?.id;
if (!targetUserId) return;
try {
const res = await apiFetch(`/api/admin/users/${targetUserId}`);
if (res.ok) {
const data = await res.json();
customerTwoFactorEnabled = data?.twoFactorEnabled === true;
}
} catch {
customerTwoFactorEnabled = false;
}
}
$effect(() => {
const uid = booking.user_id ?? booking.user?.id;
if (uid) {
fetchCustomerGiftCardBalance();
fetchSavedCards();
fetchCustomerTwoFactor();
}
const services = booking.services ?? [];
const overrides: Record<string, ServiceOverride> = {};
@@ -244,10 +309,7 @@
// 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) - amountPaidPence)
);
const tipPercentages = $derived.by(() => {
@@ -974,7 +1036,8 @@
class="flex items-center justify-between rounded-md border border-green-200 bg-green-50 p-3"
>
<span class="text-sm font-medium text-green-800">Already paid</span>
<span class="text-base font-bold text-green-800">{formatCurrency(amountPaidPence)}</span>
<span class="text-base font-bold text-green-800">{formatCurrency(amountPaidPence)}</span
>
</div>
{/if}
@@ -1160,6 +1223,13 @@
{/if}
</div>
{#if campaignDiscountPence(discountPreview) > 0}
<p class="rounded-md border border-green-200 bg-green-50 p-2.5 text-xs text-green-800">
Discount {formatCurrency(campaignDiscountPence(discountPreview) / 100)} pending — tip will
be calculated on the discounted amount.
</p>
{/if}
<div class="space-y-3">
<span class="text-sm font-medium text-gray-700">Add a Tip</span>
<div class="grid grid-cols-3 gap-2">
@@ -1467,6 +1537,22 @@
<!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. -->
<TwoFactorCodeInput bind:code={twoFactorCode} showInput={show2FACodeInput} enabled={true} />
{#if show2FACodeInput}
<p class="mt-1 text-xs text-gray-500">
Enter the customer's verification code — not your own. The customer can request a fresh
code from their account.
</p>
<Button
variant="outline"
size="sm"
class="w-full"
loading={requesting2FACode}
disabled={requesting2FACode}
onclick={handleRequestNew2FACode}
>
Request a new code
</Button>
{/if}
<div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>