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
@@ -41,12 +41,15 @@
import { POLICY } from '$lib/constants/policy';
import {
canSaveCardsForRole,
campaignDiscountPence,
depositChargePence,
isNonceStale,
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
submitPaymentWithRetry
} from '$lib/square/square';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import {
@@ -249,6 +252,13 @@
discounted_total: number;
} | null>(null);
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(pence / 100);
}
// =============== Payment Functions ===============
async function fetchUserDepositsRequired() {
if (!authStore.isAuthenticated) {
@@ -522,12 +532,23 @@
// idempotency key are NOT cleared — the confirm resend is the same
// logical charge.
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(text)) {
// The backend's overflow guard compares against the DISCOUNTED
// remaining (remaining + eligible campaign credit), and for a
// deposit it charges req.Amount the campaign credit (the
// frontend sends deposits raw). Both the displayed overflow and
// the amount actually charged must therefore account for the
// eligible campaign discount — mirroring UserPaymentModal so the
// two surfaces can't show different amounts for the same booking.
const depositDiscountPence = campaignDiscountPence(discountPreview);
overflowConfirm = {
amountPence,
overflowPence: Math.max(
0,
amountPence - Math.round((confirmedBooking?.amount_due ?? 0) * 100)
amountPence -
Math.round((confirmedBooking?.amount_due ?? 0) * 100) -
depositDiscountPence
),
chargePence: Math.max(0, amountPence - depositDiscountPence),
depositAmount,
body
};
@@ -603,6 +624,10 @@
let overflowConfirm = $state<{
amountPence: number;
overflowPence: number;
// Actual amount the backend will charge. Deposits are sent RAW and the
// backend charges amountPence minus the eligible campaign credit, so
// this can differ from amountPence (mirrors UserPaymentModal).
chargePence?: number;
depositAmount: number;
body: Record<string, unknown>;
} | null>(null);
@@ -2573,47 +2598,21 @@
<!-- Pre-start 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. -->
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div class="flex items-start gap-2.5">
<svg
class="mt-0.5 h-5 w-5 shrink-0 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 16v-4M12 8h.01" />
<circle cx="12" cy="12" r="10" />
</svg>
<div>
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
<p class="mt-1 text-sm text-amber-800">
The balance for this booking has changed since it was last loaded. The extra £{(
overflowConfirm.overflowPence / 100
).toFixed(2)} will be recorded as a tip. Confirm to continue?
</p>
</div>
</div>
<div class="mt-4 flex gap-2">
<Button
class="flex-1"
loading={isProcessingPayment}
disabled={isProcessingPayment}
onclick={confirmOverflowPayment}
>
Confirm
</Button>
<Button
variant="outline"
class="flex-1"
disabled={isProcessingPayment}
onclick={cancelOverflowConfirmation}
>
Cancel
</Button>
</div>
</div>
remaining balance will be recorded as a tip once confirmed.
Shared markup with the customer payment modal
(OverflowTipConfirm) so the two surfaces can't drift. -->
<OverflowTipConfirm
overflowPence={overflowConfirm.overflowPence}
discountNote={overflowConfirm.chargePence !== undefined &&
overflowConfirm.chargePence < overflowConfirm.amountPence
? `An eligible campaign discount of ${formatCurrency(
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
)} applies you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
: undefined}
loading={isProcessingPayment}
onConfirm={confirmOverflowPayment}
onCancel={cancelOverflowConfirmation}
/>
{:else}
<BookingSummary
services={selectedServices}
@@ -2698,7 +2697,12 @@
>
{isProcessingPayment
? 'Processing...'
: `Pay Deposit £${calculateDepositAmount()}`}
: `Pay Deposit ${formatCurrency(
depositChargePence(
Math.round(calculateDepositAmount() * 100),
campaignDiscountPence(discountPreview)
)
)}`}
</Button>
</div>