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
@@ -20,6 +20,7 @@
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure,
requestNewTwoFactorCode,
sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
@@ -79,6 +80,31 @@
show2FACodeInput && twoFactorEnabled && twoFactorCode.trim() === ''
);
// POST /api/user/2fa/code mint state for the "Request a new code" button
// (session user = card owner, so a minted code authorizes their charge).
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;
}
}
// Cached idempotency key per payment attempt (amount + type + card): reused
// on retry so a lost-response retry dedups instead of double-charging,
// regenerated when any of those change. Matches the tip-flow pattern.
@@ -733,7 +759,7 @@
{#if overflowConfirm}
<div class="space-y-4">
<!-- Overpayment confirmation: the backend rejected the payment because
<!-- 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. Applies both before and after
@@ -1001,6 +1027,18 @@
showInput={show2FACodeInput}
enabled={twoFactorEnabled}
/>
{#if show2FACodeInput && twoFactorEnabled}
<Button
variant="outline"
size="sm"
class="w-full"
loading={requesting2FACode}
disabled={requesting2FACode}
onclick={handleRequestNew2FACode}
>
Request a new code
</Button>
{/if}
{#if depositPolicyWarning}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">