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:
@@ -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 · {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off
|
||||
({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE) / 100)})
|
||||
{stamps} stamps available · {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
|
||||
)})
|
||||
|
||||
Reference in New Issue
Block a user