fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore

Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:

MONEY:
- HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges
- HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining
- MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row
- MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking)
- MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal
- MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited
- MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency

SECURITY:
- HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts
- MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records
- MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded
- MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep)
- MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert
- LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed

DUP/MOD:
- Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named

Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 3866cc5963
commit 4e398a7a2b
31 changed files with 1703 additions and 342 deletions
@@ -856,9 +856,13 @@
{apptHours > 72
? "Over 72 hours' notice means no deposit protection applies — a full refund is given regardless."
: apptHours >= 24
? "Between 2472 hours' notice, up to 50% of the total (" +
? "Between 2472 hours' notice, up to " +
POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100 +
'% of the total (' +
'£' +
(selectedBooking.total_amount * 0.5).toFixed(2) +
(selectedBooking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT).toFixed(
2
) +
') is treated as a protected deposit to cover the lost slot. The remaining balance above that is refunded.'
: "Under 24 hours' notice, the full amount paid (" +
'£' +
@@ -5,6 +5,7 @@
import { formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { generateUUID } from '$lib/utils/uuid';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
@@ -353,7 +354,7 @@
// Unique per refund attempt so two equal partial refunds of the same
// payment don't collide on the backend's amount-derived key; reused on
// retry (the backend dedups on it) so a timeout can't double-refund.
refundIdempotencyKey = crypto.randomUUID();
refundIdempotencyKey = generateUUID();
showRefundModal = true;
// Pre-fill the refund with the RESIDUAL (payment.amount already
@@ -7,6 +7,7 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { formatUserName } from '$lib/utils/nameDisplay';
import { POLICY } from '$lib/constants/policy';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
@@ -446,8 +447,9 @@
<summary class="cursor-pointer hover:text-gray-700">What this means</summary>
<p class="mt-1">
"Rescheduling within {Math.round(hoursUntilAppointment)}h of the original time with
payments present means deposit protection applies — up to 50% of the total (up to £{(
booking.total_amount * 0.5
payments present means deposit protection applies — up to {POLICY.PROTECTED_DEPOSIT_MAX_PCT *
100}% of the total (up to £{(
booking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT
).toFixed(2)}) could be retained depending on notice period."
</p>
</details>
@@ -327,7 +327,7 @@
}
function calculateDepositAmount(): number {
return Math.round(getTotalPrice() * 0.2 * 100) / 100;
return Math.round(getTotalPrice() * POLICY.REQUIRED_DEPOSIT_PCT * 100) / 100;
}
async function fetchDiscountPreview() {
@@ -2607,8 +2607,8 @@
</div>
<h3 class="text-lg font-semibold text-emerald-800">Deposit Paid</h3>
<p class="mt-1 text-emerald-700">
Your deposit of <strong>{formatCurrency(calculateDepositAmount())}</strong> has been
paid successfully. See you at your appointment!
Your deposit of <strong>{formatCurrency(calculateDepositAmount())}</strong> has
been paid successfully. See you at your appointment!
</p>
</div>
{:else if depositRequired}
@@ -12,6 +12,7 @@
import {
CARD_VERIFICATION_RETRY_MESSAGE,
campaignDiscountPence,
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
isVerificationRequiredSignal,
PAYMENT_METHOD_SAVED_CARD,
@@ -26,10 +27,10 @@
} from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { generateUUID } from '$lib/utils/uuid';
const LOYALTY_DISCOUNT_RATE = 0.1;
import { POLICY } from '$lib/constants/policy';
interface Props {
booking: Booking;
@@ -62,7 +63,7 @@
amount: number;
};
type PaymentMethod = 'card' | 'cash' | 'giftcard' | (typeof PAYMENT_METHOD_SAVED_CARD) | null;
type PaymentMethod = 'card' | 'cash' | 'giftcard' | typeof PAYMENT_METHOD_SAVED_CARD | null;
let status = $state<PaymentStatus>('idle');
let selectedMethod = $state<PaymentMethod>(null);
@@ -75,6 +76,65 @@
// reactive flag is checked synchronously at the start of every handler.
let isProcessingPaymentSync = false;
// Overpayment confirmation (mirrors UserPaymentModal/BookingFlow). The
// backend rejects a payment that exceeds the booking's remaining balance
// unless the request carries `confirm_overflow_tip: true` — a tip is
// gratuity for service already rendered. The guard fires on STALE booking
// data (multi-tab, admin-changed totals) where the operator would otherwise
// be stuck with an unresolvable 400; the rejected request body is parked
// here and a Confirm/Cancel prompt is shown, with Confirm resending the SAME
// body plus the flag.
let overflowConfirm = $state<{
amountPence: number;
overflowPence: number;
body: Record<string, unknown>;
} | null>(null);
function confirmOverflowPayment() {
const pending = overflowConfirm;
if (!pending || status === 'saved-card-processing') return;
status = 'saved-card-processing';
error = null;
isProcessingPaymentSync = true;
apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...pending.body, confirm_overflow_tip: true })
})
.then(async (response) => {
if (!response.ok) {
const errData = await response.text();
throw new Error(extractErrorMessage(errData) || 'Failed to process payment');
}
const data = await response.json();
status = 'success';
paymentResult = {
checkout_id: data.payment_id || data.checkout_id || data.id || '',
status: 'COMPLETED',
card_brand: data.card_brand,
last4: data.card_last4,
amount: data.amount
};
overflowConfirm = null;
toast.success('Payment successful');
onComplete(paymentResult);
})
.catch((_err) => {
status = 'error';
error = _err instanceof Error ? _err.message : 'Failed to process payment';
toast.error(error ?? 'Unknown error');
})
.finally(() => {
isProcessingPaymentSync = false;
});
}
function cancelOverflowConfirmation() {
overflowConfirm = null;
status = 'idle';
selectedMethod = null;
}
// 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 (the booking's user), so the input is surfaced whenever
@@ -168,7 +228,7 @@
);
const loyaltyDiscount = $derived(
useLoyalty ? Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) : 0
useLoyalty ? Math.round(booking.total_amount * 100 * POLICY.LOYALTY_DISCOUNT_RATE) : 0
);
// Campaign discount preview — fetched on mount, mirroring the customer flow
@@ -324,11 +384,10 @@
const tipPercentages = $derived.by(() => {
if (netTotal <= 0) return [];
return [
{ pct: 10, amount: Math.round(netTotal * 0.1 * 100) / 100 },
{ pct: 15, amount: Math.round(netTotal * 0.15 * 100) / 100 },
{ pct: 20, amount: Math.round(netTotal * 0.2 * 100) / 100 }
];
return POLICY.TIP_PRESET_PCTS.map((pct) => ({
pct,
amount: Math.round(netTotal * (pct / 100) * 100) / 100
}));
});
const tipMultiplier = $derived(
@@ -342,7 +401,7 @@
const totalWithTip = $derived(tipEnabled ? netTotal * tipMultiplier : netTotal);
const tipDisplay = $derived(
selectedTipPercent !== null
? `${selectedTipPercent}%`
? `${selectedTipPercent}%`
: customTipAmount && parseFloat(customTipAmount) > 0
? `${formatCurrency(parseFloat(customTipAmount))}`
: ''
@@ -404,6 +463,24 @@
if (!response.ok) {
const errData = await response.text();
// Overflow guard (defensive — the admin terminal path currently
// clamps instead, but a 400 carrying the code must surface the
// Confirm/Cancel prompt like the customer modal, not a dead-end).
if (isOverflowTipConfirmationRequired(errData)) {
overflowConfirm = {
amountPence: Math.round(finalAmount * 100) - loyaltyDiscount,
overflowPence: Math.max(
0,
Math.round(finalAmount * 100) - loyaltyDiscount - Math.round(netTotal * 100)
),
body: {
amount: Math.round(finalAmount * 100) - loyaltyDiscount,
payment_type: 'full',
tip_enabled: tipEnabled
}
};
return;
}
throw new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
}
@@ -841,9 +918,7 @@
let verificationToken = '';
status = 'saved-card-waiting-sca';
try {
const squareCardId = savedCards.find(
(c) => c.id === selectedSavedCardId
)?.square_card_id;
const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
const proactive = await runSavedCardSCAProactively({
amountPence: chargeAmount,
squareCardId: squareCardId ?? '',
@@ -898,6 +973,27 @@
if (!response.ok) {
responseStatus = response.status;
const errData = await response.text();
// Overflow guard: a 400 carrying the backend's
// `overflow_tip_confirmation_required` code means the charge
// exceeds the booking's remaining balance (stale data). Park the
// rejected request and surface the Confirm/Cancel prompt instead
// of a dead-end 400; Confirm resends the SAME body with the flag.
if (isOverflowTipConfirmationRequired(errData)) {
overflowConfirm = {
amountPence: chargeAmount,
overflowPence: Math.max(0, chargeAmount - Math.round(netTotal * 100)),
body: {
amount: chargeAmount,
payment_type: 'full',
payment_method: 'saved_card',
saved_card_id: selectedSavedCardId,
...(verificationToken ? { verification_token: verificationToken } : {}),
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
idempotency_key: savedCardIdempotencyKey
}
};
return;
}
// A 402 verification-required here means the fresh proactive
// token was stale/expired at Square — the charge did NOT land.
// Surface the SCA-first guidance; the operator taps Pay again and
@@ -981,7 +1077,15 @@
open={true}
onOpenChange={(open) => {
if (open) return;
// ESC/overlay while a charge is in flight must not close the modal — the
// charge may still land. ESC while the overflow-confirm prompt is showing
// dismisses the prompt (back to the amount-editing form), mirroring the
// customer modal, instead of closing the whole flow.
if (isChargeInFlight(status)) return;
if (overflowConfirm) {
cancelOverflowConfirmation();
return;
}
handleClose();
}}
>
@@ -990,7 +1094,23 @@
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
</Dialog.Header>
{#if status === 'idle'}
{#if overflowConfirm}
<div class="space-y-4">
<!-- 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. Shared component with the
customer payment modal and the booking-flow deposit step so the
admin surface can't drift. -->
<OverflowTipConfirm
overflowPence={overflowConfirm.overflowPence}
loading={status === 'saved-card-processing'}
onConfirm={confirmOverflowPayment}
onCancel={cancelOverflowConfirmation}
/>
<Button variant="ghost" onclick={handleClose} class="w-full">Close</Button>
</div>
{:else if status === 'idle'}
<div class="space-y-4">
<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
@@ -1039,8 +1159,8 @@
<div class="text-sm font-medium text-fuchsia-900">Use Loyalty Stamp Card</div>
<div class="mt-0.5 text-xs text-fuchsia-700">
{Math.floor(stamps / 10)} full card{Math.floor(stamps / 10) === 1 ? '' : 's'} available
&middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(
Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) / 100
&middot; {Math.round(POLICY.LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(
Math.round(booking.total_amount * 100 * POLICY.LOYALTY_DISCOUNT_RATE) / 100
)})
</div>
</label>
@@ -1132,7 +1252,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 / 100)}</span
<span class="text-base font-bold text-green-800"
>{formatCurrency(amountPaidPence / 100)}</span
>
</div>
{/if}
@@ -1224,14 +1345,14 @@
<button
type="button"
disabled={nothingToCharge}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
PAYMENT_METHOD_SAVED_CARD
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = PAYMENT_METHOD_SAVED_CARD;
status = 'saved-card-selecting';
}}
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
PAYMENT_METHOD_SAVED_CARD
? 'border-input bg-fuchsia-100 text-foreground'
: 'border-input hover:bg-fuchsia-50'}"
onclick={() => {
selectedMethod = PAYMENT_METHOD_SAVED_CARD;
status = 'saved-card-selecting';
}}
>
<svg
class="mx-auto mb-2 h-8 w-8"
@@ -14,6 +14,7 @@
import { onMount } from 'svelte';
import { generateUUID } from '$lib/utils/uuid';
import { formatCurrency } from '$lib/utils/format';
import { POLICY } from '$lib/constants/policy';
import {
CARD_VERIFICATION_RETRY_MESSAGE,
canSaveCardsForRole,
@@ -155,11 +156,10 @@
const tipPercentages = $derived.by(() => {
if (subtotal <= 0) return [];
return [
{ pct: 10, amount: Math.round(subtotal * 0.1 * 100) / 100 },
{ pct: 15, amount: Math.round(subtotal * 0.15 * 100) / 100 },
{ pct: 20, amount: Math.round(subtotal * 0.2 * 100) / 100 }
];
return POLICY.TIP_PRESET_PCTS.map((pct) => ({
pct,
amount: Math.round(subtotal * (pct / 100) * 100) / 100
}));
});
function formatDate(dateStr: string): string {
@@ -11,6 +11,7 @@
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import { authStore } from '$lib/stores/auth.svelte';
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
@@ -32,8 +33,6 @@
VERIFICATION_REQUIRED_MESSAGE
} from '$lib/square/square';
const LOYALTY_DISCOUNT_RATE = 0.1;
interface Props {
booking: Booking;
onClose: () => void;
@@ -223,25 +222,27 @@
);
const loyaltyDiscount = $derived(
useLoyalty ? Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) : 0
useLoyalty ? Math.round(booking.total_amount * 100 * POLICY.LOYALTY_DISCOUNT_RATE) : 0
);
// Deposit policy warning text — dynamic based on booking state
const expectedDepositPercent = $derived(booking.deposit_required ? 20 : 0);
const expectedDepositPercent = $derived(
booking.deposit_required ? POLICY.REQUIRED_DEPOSIT_PCT * 100 : 0
);
const depositPolicyWarning = $derived<string | null>(
{
get text(): string | null {
if (!booking.deposit_required && totalPaid === 0 && booking.amount_due <= 0) return null;
if (booking.deposit_required) {
return `A ${expectedDepositPercent}% deposit (at least ${formatCurrency(
booking.total_amount * 0.2
)}) is required. Any payments up to 50% of total (${formatCurrency(
booking.total_amount * 0.5
booking.total_amount * POLICY.REQUIRED_DEPOSIT_PCT
)}) is required. Any payments up to ${POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100}% of total (${formatCurrency(
booking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT
)}) are treated as deposit for cancellations.`;
}
if (totalPaid > 0 || booking.amount_due > 0) {
return `Any payment up to 50% of total (${formatCurrency(
booking.total_amount * 0.5
return `Any payment up to ${POLICY.PROTECTED_DEPOSIT_MAX_PCT * 100}% of total (${formatCurrency(
booking.total_amount * POLICY.PROTECTED_DEPOSIT_MAX_PCT
)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
}
return null;
@@ -729,7 +730,7 @@
// pre-discounted deposit here would double-discount (HIGH-2).
const depositPence = booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100);
: Math.round(booking.total_amount * POLICY.REQUIRED_DEPOSIT_PCT * 100);
makePayment('deposit', depositPence);
}
@@ -947,8 +948,11 @@
<label for="use-loyalty" class="cursor-pointer select-none">
<div class="text-sm font-medium text-fuchsia-900">Use my Loyalty Stamp Card</div>
<div class="mt-0.5 text-xs text-fuchsia-700">
{stamps} stamps available &middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off
({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) / 100)})
{stamps} stamps available &middot; {Math.round(
POLICY.LOYALTY_DISCOUNT_RATE * 100
)}% off ({formatCurrency(
Math.round(booking.total_amount * 100 * POLICY.LOYALTY_DISCOUNT_RATE) / 100
)})
</div>
</label>
</div>
@@ -1009,7 +1013,8 @@
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
<div class="flex justify-between text-sm">
<span class="text-gray-600">Total</span>
<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100) / 100)}</span
<span class="font-medium"
>{formatCurrency(Math.round(booking.total_amount * 100) / 100)}</span
>
</div>
{#if discountPreview?.eligible}
@@ -1029,7 +1034,9 @@
{#if useLoyalty && loyaltyDiscount > 0}
<div class="flex justify-between text-sm">
<span class="text-gray-600">Loyalty Stamp Card (10% Off)</span>
<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount / 100)}</span>
<span class="font-medium text-green-700"
>-{formatCurrency(loyaltyDiscount / 100)}</span
>
</div>
{/if}
<div class="flex justify-between border-t border-gray-200 pt-2">
@@ -1141,7 +1148,7 @@
depositChargePence(
booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100),
: Math.round(booking.total_amount * POLICY.REQUIRED_DEPOSIT_PCT * 100),
campaignDiscountPence(discountPreview)
) / 100
)})
+10 -1
View File
@@ -7,5 +7,14 @@ export const POLICY = {
RESCHEDULE_BLOCK_HOURS_WITH_PAYMENTS: 72,
RESCHEDULE_BLOCK_HOURS_NO_PAYMENTS: 24,
PROTECTED_DEPOSIT_MAX_PCT: 0.5,
REQUIRED_DEPOSIT_PCT: 0.2
REQUIRED_DEPOSIT_PCT: 0.2,
// Loyalty redemption: 10 stamps → 10% off the booking total. Shared by the
// admin and customer payment modals so the discount rate can't drift.
LOYALTY_DISCOUNT_RATE: 0.1,
// Gratuity suggestion presets offered by the tip surfaces (admin payment
// modal + customer tip page). Deliberately SEPARATE from the deposit policy
// constants above — a tip suggestion is gratuity, not a deposit percentage,
// and coupling them would silently change the tip buttons if the deposit
// rate ever changed.
TIP_PRESET_PCTS: [10, 15, 20]
} as const;