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:
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
overflowPence: number;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
// Optional pre-formatted note describing a discount on the charge (e.g.
|
||||
// a campaign credit applied to a deposit), shown under the main text.
|
||||
discountNote?: string;
|
||||
}
|
||||
|
||||
const { overflowPence, onConfirm, onCancel, loading = false, discountNote }: Props = $props();
|
||||
|
||||
function formatCurrency(pence: number): string {
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
currency: 'GBP'
|
||||
}).format(pence / 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 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 by the customer payment modal and the booking-flow
|
||||
deposit step so the two surfaces can't drift on the markup or the
|
||||
confirm/cancel wiring. -->
|
||||
<div class="rounded-md 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
|
||||
{formatCurrency(overflowPence)} will be recorded as a tip. Confirm to continue?
|
||||
</p>
|
||||
{#if discountNote}
|
||||
<p class="mt-2 text-sm font-medium text-amber-800">{discountNote}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button
|
||||
class="flex-1"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
autofocus
|
||||
onclick={onConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
disabled={loading}
|
||||
onclick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,7 +90,8 @@
|
||||
// irrelevant to the backend gate, so `enabled` is always true.
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled,
|
||||
gateActive: () =>
|
||||
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === 'savedcard',
|
||||
mint: () => {
|
||||
const customerID = booking.user_id ?? booking.user?.id;
|
||||
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
|
||||
@@ -110,7 +111,18 @@
|
||||
// by /api/admin/today/current-next carries no amount_paid/amount_due/
|
||||
// payments, so this is fetched fresh from the admin booking detail endpoint
|
||||
// on mount and subtracted from the charge (see netTotal).
|
||||
//
|
||||
// Money finding 1: `amount_paid` (summed over ALL completed payments) can
|
||||
// include tips — a tip is gratuity, not booking credit, so it must not
|
||||
// reduce what the customer still owes. The booking detail endpoint computes
|
||||
// amount_paid in Go over every completed payment (no payment_type filter),
|
||||
// so the tip-excluded obligation is derived here from the payments list
|
||||
// rather than trusting amount_paid. This stays consistent whether or not
|
||||
// the backend starts excluding tips from amount_paid (idempotent either
|
||||
// way). The tip-INCLUSIVE amount_paid is kept for the "Already paid"
|
||||
// display (mirrors the customer modal's "Amount Paid" row).
|
||||
let amountPaidPence = $state(0);
|
||||
let tipExcludedPaidPence = $state(0);
|
||||
async function fetchAmountPaid() {
|
||||
try {
|
||||
const resp = await apiFetch(`/api/admin/bookings/${booking.id}`);
|
||||
@@ -118,20 +130,29 @@
|
||||
const data = await resp.json();
|
||||
if (typeof data.amount_paid === 'number') {
|
||||
amountPaidPence = Math.round(data.amount_paid * 100);
|
||||
return;
|
||||
}
|
||||
tipExcludedPaidPence = Math.round(
|
||||
(data.payments ?? [])
|
||||
.filter(
|
||||
(p: { status: string; payment_type: string }) =>
|
||||
p.status === 'completed' && p.payment_type !== 'tip'
|
||||
)
|
||||
.reduce((sum: number, p: { amount: number }) => sum + (p.amount || 0), 0) * 100
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (_err) {
|
||||
// fall through to the booking prop below
|
||||
}
|
||||
amountPaidPence = Math.round((booking.amount_paid ?? 0) * 100);
|
||||
tipExcludedPaidPence = amountPaidPence;
|
||||
}
|
||||
|
||||
const loyaltyEligible = $derived(
|
||||
stamps >= 10 &&
|
||||
!(booking.discounts ?? []).some((d: BookingDiscount) => d.discount_source === 'loyalty') &&
|
||||
booking.total_amount > 0 &&
|
||||
amountPaidPence === 0
|
||||
tipExcludedPaidPence === 0
|
||||
);
|
||||
|
||||
const loyaltyDiscount = $derived(
|
||||
@@ -283,7 +304,10 @@
|
||||
// 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) - tipExcludedPaidPence
|
||||
)
|
||||
);
|
||||
|
||||
const tipPercentages = $derived.by(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import type { Booking } from '$lib/types/booking';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
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 { authStore } from '$lib/stores/auth.svelte';
|
||||
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
|
||||
@@ -17,6 +18,7 @@
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import {
|
||||
campaignDiscountPence,
|
||||
depositChargePence,
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
@@ -723,58 +725,21 @@
|
||||
<!-- 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
|
||||
the appointment has started (B12). -->
|
||||
<div class="rounded-md 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
|
||||
{formatCurrency(overflowConfirm.overflowPence)} will be recorded as a tip. Confirm to
|
||||
continue?
|
||||
</p>
|
||||
{#if overflowConfirm.paymentType === 'deposit' && overflowConfirm.chargePence !== undefined}
|
||||
<p class="mt-2 text-sm font-medium text-amber-800">
|
||||
An eligible campaign discount of
|
||||
{formatCurrency(
|
||||
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
|
||||
)}
|
||||
applies — you'll be charged {formatCurrency(overflowConfirm.chargePence)}.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button
|
||||
class="flex-1"
|
||||
loading={status === 'processing'}
|
||||
disabled={status === 'processing'}
|
||||
autofocus
|
||||
onclick={confirmOverflowPayment}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
disabled={status === 'processing'}
|
||||
onclick={cancelOverflowConfirmation}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
recorded as a tip once confirmed. Shared markup with the
|
||||
booking-flow deposit step (OverflowTipConfirm) so the two surfaces
|
||||
can't drift. -->
|
||||
<OverflowTipConfirm
|
||||
overflowPence={overflowConfirm.overflowPence}
|
||||
discountNote={overflowConfirm.paymentType === 'deposit' &&
|
||||
overflowConfirm.chargePence !== undefined
|
||||
? `An eligible campaign discount of ${formatCurrency(
|
||||
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
|
||||
)} applies — you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
|
||||
: undefined}
|
||||
loading={status === 'processing'}
|
||||
onConfirm={confirmOverflowPayment}
|
||||
onCancel={cancelOverflowConfirmation}
|
||||
/>
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1049,9 +1014,12 @@
|
||||
>
|
||||
{#if paymentType === 'deposit'}
|
||||
Pay Deposit ({formatCurrency(
|
||||
booking.deposit_amount
|
||||
? Math.round(booking.deposit_amount * 100)
|
||||
: Math.round(booking.total_amount * 0.2 * 100)
|
||||
depositChargePence(
|
||||
booking.deposit_amount
|
||||
? Math.round(booking.deposit_amount * 100)
|
||||
: Math.round(booking.total_amount * 0.2 * 100),
|
||||
campaignDiscountPence(discountPreview)
|
||||
)
|
||||
)})
|
||||
{:else}
|
||||
Pay {formatCurrency(
|
||||
|
||||
@@ -3,8 +3,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
NONCE_STALENESS_MS,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
adminRequestNewTwoFactorCode,
|
||||
campaignDiscountPence,
|
||||
canSaveCardsForRole,
|
||||
depositChargePence,
|
||||
isAmbiguousPaymentFailure,
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
@@ -420,3 +422,75 @@ describe('requestNewTwoFactorCode', () => {
|
||||
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer abc.def.ghi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adminRequestNewTwoFactorCode', () => {
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('POSTs to the admin customer-scoped mint URL', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const result = await adminRequestNewTwoFactorCode('usr_abc');
|
||||
expect(result.ok).toBe(true);
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('/api/admin/users/usr_abc/2fa/code');
|
||||
});
|
||||
|
||||
it('URL-encodes the customer userID', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await adminRequestNewTwoFactorCode('usr a/b');
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('/api/admin/users/usr%20a%2Fb/2fa/code');
|
||||
});
|
||||
|
||||
it('surfaces the 429 mint-cooldown error message', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(jsonResponse({ error: 'Too many requests. Wait before requesting.' }, 429))
|
||||
);
|
||||
const result = await adminRequestNewTwoFactorCode('usr_abc');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.status).toBe(429);
|
||||
expect(result.message).toContain('Too many requests');
|
||||
});
|
||||
|
||||
it('attaches the Bearer token from localStorage and POSTs', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => (key === 'authToken' ? 'abc.def.ghi' : null)
|
||||
});
|
||||
await adminRequestNewTwoFactorCode('usr_abc');
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('/api/admin/users/usr_abc/2fa/code');
|
||||
expect(init.method).toBe('POST');
|
||||
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer abc.def.ghi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('depositChargePence', () => {
|
||||
it('subtracts the eligible campaign credit when it is smaller than the deposit', () => {
|
||||
expect(depositChargePence(2000, 500)).toBe(1500);
|
||||
});
|
||||
|
||||
it('charges the full deposit when the credit equals the deposit (A6 clamp-up)', () => {
|
||||
expect(depositChargePence(2000, 2000)).toBe(2000);
|
||||
});
|
||||
|
||||
it('charges the full deposit when the credit exceeds the deposit (A6 clamp-up)', () => {
|
||||
expect(depositChargePence(2000, 2500)).toBe(2000);
|
||||
});
|
||||
|
||||
it('is unchanged when no campaign credit applies', () => {
|
||||
expect(depositChargePence(2000, 0)).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +73,19 @@ export function campaignDiscountPence(discountPreview: DiscountPreview | null):
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The deposit charge as the backend computes it (A4/A6 in
|
||||
* backend/handlers/payments/handlers.go): deposits are sent RAW by the
|
||||
* frontend and charged at `deposit − eligible campaign credit`, clamping UP
|
||||
* to the full deposit when the credit ≥ the deposit (the credit then covers
|
||||
* the residual balance via the discount row). Payment surfaces must display
|
||||
* this same amount so the customer never sees a higher deposit than the card
|
||||
* is actually charged.
|
||||
*/
|
||||
export function depositChargePence(depositPence: number, discountPence: number): number {
|
||||
return discountPence >= depositPence ? depositPence : depositPence - discountPence;
|
||||
}
|
||||
|
||||
/** True when a cached card nonce can no longer be reused: it was tokenized for a
|
||||
* different amount than `amount`, or it is older than NONCE_STALENESS_MS. */
|
||||
export function isNonceStale(
|
||||
@@ -154,14 +167,16 @@ export interface TwoFactorCodeRequestResult {
|
||||
* `$lib` imports and the pure-logic vitest suite can exercise it without a
|
||||
* SvelteKit plugin resolving the `$lib` alias.
|
||||
*/
|
||||
export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestResult> {
|
||||
// Shared 2FA code-mint POST — the session and admin variants differ only in
|
||||
// the URL, so keeping one body stops the two copies drifting apart.
|
||||
async function requestTwoFactorCode(path: string): Promise<TwoFactorCodeRequestResult> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/user/2fa/code', { method: 'POST', headers });
|
||||
const response = await fetch(path, { method: 'POST', headers });
|
||||
if (response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as { message?: unknown } | null;
|
||||
const message =
|
||||
@@ -179,34 +194,17 @@ export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestRes
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestResult> {
|
||||
return requestTwoFactorCode('/api/user/2fa/code');
|
||||
}
|
||||
|
||||
/** Admin-scoped 2FA mint: requests a fresh code FOR the given customer (the
|
||||
* card owner) at the till/admin payment modal. The backend keys the mint to
|
||||
* the CUSTOMER's userID, so the code is delivered to the customer and can
|
||||
* satisfy the card-owner gate — the admin's session never receives or
|
||||
* authenticates the customer's card. */
|
||||
export async function adminRequestNewTwoFactorCode(userID: string): Promise<TwoFactorCodeRequestResult> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`, { method: 'POST', headers });
|
||||
if (response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as { message?: unknown } | null;
|
||||
const message =
|
||||
typeof data?.message === 'string' ? data.message : 'A new verification code has been sent.';
|
||||
return { status: response.status, ok: true, message };
|
||||
}
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: response.status,
|
||||
ok: false,
|
||||
message: extractServerErrorMessage(body) || 'Failed to request a new verification code'
|
||||
};
|
||||
} catch {
|
||||
return { status: 0, ok: false, message: 'Network error requesting a new code' };
|
||||
}
|
||||
return requestTwoFactorCode(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`);
|
||||
}
|
||||
|
||||
/** Minimal `{"error"|"message": "..."}` extractor for the 2FA code-request
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// Cross-tab refresh-token coordination (LOW-MEDIUM 4): every tab shares ONE
|
||||
// opaque refresh token in localStorage (`authRefreshToken`) and every refresh
|
||||
// ROTATES it server-side. Without coordination, two tabs refreshing on load
|
||||
// would both present the same token: the first rotation consumes it, and the
|
||||
// second replay either 401s that tab (inside the server's 60s reuse grace) or —
|
||||
// beyond the grace — kills the ENTIRE rotation family with a false theft alert.
|
||||
// The lock below guarantees only ONE tab performs the rotation; the others
|
||||
// adopt the rotated pair from the BroadcastChannel broadcast (or, on a
|
||||
// stale-lock timeout, from localStorage).
|
||||
const REFRESH_LOCK_KEY = 'authRefreshInProgress';
|
||||
const REFRESH_LOCK_TTL_MS = 15_000;
|
||||
const REFRESH_CHANNEL_NAME = 'crussell-auth-refresh';
|
||||
|
||||
type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest' | 'affiliate';
|
||||
|
||||
interface DecodedToken {
|
||||
@@ -40,10 +53,14 @@ class AuthStore {
|
||||
private refreshToken = $state<string | null>(null);
|
||||
private user = $state<User | null>(null);
|
||||
private loading = $state(true);
|
||||
// Broadcast channel used to announce a completed rotation to sibling tabs
|
||||
// (finding 4). Null when the platform has no BroadcastChannel support.
|
||||
private refreshChannel: BroadcastChannel | null = null;
|
||||
|
||||
constructor() {
|
||||
if (browser) {
|
||||
this.initializeAuth();
|
||||
this.setupCrossTabRefresh();
|
||||
// Check token refresh every 2 minutes
|
||||
// (must be shorter than the 5-minute threshold so the
|
||||
// refresh check fires before the token actually expires)
|
||||
@@ -230,6 +247,135 @@ class AuthStore {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cross-tab refresh coordination (finding 4) ---
|
||||
|
||||
// Sets up the two coordination channels for the shared refresh token: a
|
||||
// `storage` listener (fires in sibling tabs when this tab writes
|
||||
// localStorage) and a BroadcastChannel for announcing rotations.
|
||||
private setupCrossTabRefresh() {
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === 'authToken' || e.key === 'authRefreshToken') {
|
||||
this.adoptFromLocalStorage();
|
||||
}
|
||||
});
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
this.refreshChannel = new BroadcastChannel(REFRESH_CHANNEL_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
// Adopts the shared session's current localStorage state. Only acts when
|
||||
// this tab is ALREADY authenticated: a rotation keeps the same account, so
|
||||
// only an existing session adopts a changed token — a logged-out sibling
|
||||
// tab must NOT silently log in via another tab's login. A cleared
|
||||
// authToken (logout or failed refresh in a sibling) clears this tab too.
|
||||
private adoptFromLocalStorage() {
|
||||
const storedToken = localStorage.getItem('authToken');
|
||||
if (storedToken && storedToken !== this.token && this.token) {
|
||||
this.adoptTokens(storedToken, localStorage.getItem('authRefreshToken'));
|
||||
} else if (!storedToken && this.token) {
|
||||
this.clearAuth();
|
||||
}
|
||||
}
|
||||
|
||||
// Swaps in a rotated token pair without re-writing localStorage (that would
|
||||
// re-trigger sibling storage events pointlessly) and without re-fetching the
|
||||
// profile (the rotating tab already did; profile data is account-wide).
|
||||
private adoptTokens(token: string, refreshToken: string | null) {
|
||||
const decoded = this.decodeToken(token);
|
||||
if (!decoded) return;
|
||||
this.token = token;
|
||||
if (refreshToken) this.refreshToken = refreshToken;
|
||||
if (!this.user || this.user.id !== decoded.user_id) {
|
||||
this.user = {
|
||||
id: decoded.user_id,
|
||||
role: decoded.role,
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
twoFactorEnabled: false,
|
||||
twoFactorRequired: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// tryAcquireRefreshLock atomically-ish claims the single-rotator lock. A
|
||||
// fresh lock held by another tab means it is mid-rotation — back off. The
|
||||
// post-write re-read closes the cross-tab check-then-set race: if two tabs
|
||||
// set the flag in the same instant, the loser's re-read sees the winner's
|
||||
// timestamp. A stale lock (crash / suspended tab) is stolen after
|
||||
// REFRESH_LOCK_TTL_MS.
|
||||
private tryAcquireRefreshLock(): boolean {
|
||||
const now = Date.now();
|
||||
const existing = localStorage.getItem(REFRESH_LOCK_KEY);
|
||||
if (existing) {
|
||||
const ts = Number(existing);
|
||||
if (Number.isFinite(ts) && now - ts < REFRESH_LOCK_TTL_MS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
localStorage.setItem(REFRESH_LOCK_KEY, String(now));
|
||||
return localStorage.getItem(REFRESH_LOCK_KEY) === String(now);
|
||||
}
|
||||
|
||||
private releaseRefreshLock() {
|
||||
localStorage.removeItem(REFRESH_LOCK_KEY);
|
||||
}
|
||||
|
||||
private broadcastRefresh(token: string, refreshToken: string | null) {
|
||||
this.refreshChannel?.postMessage({ type: 'auth-refreshed', token, refreshToken });
|
||||
}
|
||||
|
||||
private broadcastRefreshFailure() {
|
||||
this.refreshChannel?.postMessage({ type: 'auth-refresh-failed' });
|
||||
}
|
||||
|
||||
// Waits for the sibling tab that holds the rotation lock to finish, then
|
||||
// adopts the outcome. With BroadcastChannel support it waits for the
|
||||
// `auth-refreshed` / `auth-refresh-failed` announcement; without it, it
|
||||
// polls for the lock to clear. Either way it falls through to adopting
|
||||
// whatever the rotating tab wrote to localStorage (the rotating tab
|
||||
// persists the pair before broadcasting), so a channel-less sibling still
|
||||
// converges. A 401 failure in the rotating tab clears auth in this tab too.
|
||||
private async waitForAnotherTabRefresh(): Promise<void> {
|
||||
const result = await new Promise<'ok' | 'failed' | 'timeout'>((resolve) => {
|
||||
if (!this.refreshChannel) {
|
||||
const deadline = Date.now() + REFRESH_LOCK_TTL_MS;
|
||||
const poll = setInterval(() => {
|
||||
if (localStorage.getItem(REFRESH_LOCK_KEY) === null || Date.now() >= deadline) {
|
||||
clearInterval(poll);
|
||||
resolve('timeout');
|
||||
}
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
if (!ev.data) return;
|
||||
if (ev.data.type === 'auth-refreshed') {
|
||||
cleanup();
|
||||
resolve('ok');
|
||||
} else if (ev.data.type === 'auth-refresh-failed') {
|
||||
cleanup();
|
||||
resolve('failed');
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve('timeout');
|
||||
}, REFRESH_LOCK_TTL_MS + 5_000);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
this.refreshChannel?.removeEventListener('message', onMessage);
|
||||
};
|
||||
this.refreshChannel.addEventListener('message', onMessage);
|
||||
});
|
||||
|
||||
if (result === 'failed') {
|
||||
this.clearAuth();
|
||||
return;
|
||||
}
|
||||
this.adoptFromLocalStorage();
|
||||
}
|
||||
|
||||
hasRole(requiredRole: UserRole | UserRole[]): boolean {
|
||||
if (!this.user) return false;
|
||||
|
||||
@@ -265,37 +411,53 @@ class AuthStore {
|
||||
// Refresh if token expires in less than 5 minutes
|
||||
// (1-hour token lifetime from backend)
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
if (decoded.exp * 1000 - Date.now() < fiveMinutes) {
|
||||
// B5: without the opaque refresh token we cannot refresh — the
|
||||
// access token is not an accepted credential here. Clear auth
|
||||
// rather than send a guaranteed-401 request.
|
||||
if (!this.refreshToken) {
|
||||
this.clearAuth();
|
||||
return;
|
||||
}
|
||||
if (decoded.exp * 1000 - Date.now() >= fiveMinutes) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/refresh-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.refreshToken}`
|
||||
}
|
||||
});
|
||||
// B5: without the opaque refresh token we cannot refresh — the access
|
||||
// token is not an accepted credential here. Clear auth rather than send
|
||||
// a guaranteed-401 request.
|
||||
if (!this.refreshToken) {
|
||||
this.clearAuth();
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// B5 contract: `{token, refreshToken}` — every refresh ROTATES
|
||||
// both tokens, so store the fresh pair. (The snake_case
|
||||
// `refresh_token` key is handled for parity with login.)
|
||||
this.setToken(data.token, data.refreshToken ?? data.refresh_token ?? null);
|
||||
} else {
|
||||
// Refresh failed (revoked/expired refresh token → 401, etc.).
|
||||
// Clear auth — never retry with the access token.
|
||||
this.clearAuth();
|
||||
// Cross-tab dedup (finding 4): only ONE tab may present the shared
|
||||
// refresh token to /api/refresh-token — a concurrent sibling would
|
||||
// replay the just-rotated token and trigger the server's reuse/theft
|
||||
// handling. If another tab holds the lock, wait for its rotation and
|
||||
// adopt the result.
|
||||
if (!this.tryAcquireRefreshLock()) {
|
||||
await this.waitForAnotherTabRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/refresh-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.refreshToken}`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// B5 contract: `{token, refreshToken}` — every refresh ROTATES
|
||||
// both tokens, so store the fresh pair. (The snake_case
|
||||
// `refresh_token` key is handled for parity with login.)
|
||||
const newRefreshToken = data.refreshToken ?? data.refresh_token ?? null;
|
||||
this.setToken(data.token, newRefreshToken);
|
||||
this.broadcastRefresh(data.token, newRefreshToken);
|
||||
} else {
|
||||
// Refresh failed (revoked/expired refresh token → 401, etc.).
|
||||
// Clear auth — never retry with the access token. Tell sibling
|
||||
// tabs the shared session died so they clear too.
|
||||
this.clearAuth();
|
||||
this.broadcastRefreshFailure();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
} finally {
|
||||
this.releaseRefreshLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user