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:
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
@@ -12,6 +12,7 @@
|
||||
campaignDiscountPence,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
requestNewTwoFactorCode,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
@@ -67,9 +68,14 @@
|
||||
|
||||
// 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 (not the admin), so the input is surfaced whenever the
|
||||
// gate is enforced — the operator relays the customer's code.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
// on the CARD OWNER (the booking's user), so the input is surfaced whenever
|
||||
// the customer has 2FA enabled in an enforced environment — the operator
|
||||
// relays the customer's code. `twoFactorRequired` is env-wide enforcement
|
||||
// (true for every session user when the gate is on); the CUSTOMER's setup
|
||||
// flag is not carried by the admin booking payload, so it is fetched from
|
||||
// GET /api/admin/users/{id} on mount (see fetchCustomerTwoFactor).
|
||||
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
|
||||
let customerTwoFactorEnabled = $state(false);
|
||||
|
||||
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
|
||||
let useLoyalty = $state(false);
|
||||
@@ -81,11 +87,51 @@
|
||||
// irrelevant to the backend gate.
|
||||
let twoFactorCode = $state('');
|
||||
// Set true when a charge 403s for a missing code — reveals the input even
|
||||
// if the session user's flag is unset.
|
||||
// if the customer's 2FA flag is unknown/unset.
|
||||
let reveal2FACodeInput = $state(false);
|
||||
const show2FACodeInput = $derived(reveal2FACodeInput || savedCardChargeRequires2FACode);
|
||||
const show2FACodeInput = $derived(
|
||||
reveal2FACodeInput || (twoFactorEnforced && customerTwoFactorEnabled)
|
||||
);
|
||||
const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === '');
|
||||
|
||||
// POST /api/user/2fa/code mint state for the "Request a new code" button on
|
||||
// the saved-card screen. NOTE: this mints for the SIGNED-IN session (the
|
||||
// admin), which cannot authorize the customer's charge — the backend agent
|
||||
// coordinating POST /api/user/2fa/code should also add an admin-scoped mint
|
||||
// (e.g. /api/admin/users/{id}/2fa/code) for the operator to mint the
|
||||
// customer's code; until then the button exercises the cooldown/429/503 UX.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Focus the verification-code input whenever the saved-card screen shows it
|
||||
// (auto-show for a 2FA-enabled customer, or the 403 self-heal reveal) so the
|
||||
// operator can type the customer's code without an extra click.
|
||||
$effect(() => {
|
||||
if (status === 'saved-card-selecting' && show2FACodeInput) {
|
||||
tick().then(() => document.getElementById('two-factor-code')?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
// B3: pence already paid against this booking. The AppointmentInfo handed in
|
||||
// by /api/admin/today/current-next carries no amount_paid/amount_due/
|
||||
// payments, so this is fetched fresh from the admin booking detail endpoint
|
||||
@@ -158,11 +204,30 @@
|
||||
|
||||
let serviceOverrides = $state<Record<string, ServiceOverride>>({});
|
||||
|
||||
// B6/B10: the admin booking payload carries no 2FA state for the owner, so
|
||||
// the customer's flag is fetched from the admin user detail endpoint (the
|
||||
// same source the customer-flag fix keys on). A failure leaves the flag
|
||||
// false — the charge 403 self-heal still reveals the input.
|
||||
async function fetchCustomerTwoFactor() {
|
||||
const targetUserId = booking.user_id ?? booking.user?.id;
|
||||
if (!targetUserId) return;
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/users/${targetUserId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
customerTwoFactorEnabled = data?.twoFactorEnabled === true;
|
||||
}
|
||||
} catch {
|
||||
customerTwoFactorEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const uid = booking.user_id ?? booking.user?.id;
|
||||
if (uid) {
|
||||
fetchCustomerGiftCardBalance();
|
||||
fetchSavedCards();
|
||||
fetchCustomerTwoFactor();
|
||||
}
|
||||
const services = booking.services ?? [];
|
||||
const overrides: Record<string, ServiceOverride> = {};
|
||||
@@ -244,10 +309,7 @@
|
||||
// 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) - amountPaidPence)
|
||||
);
|
||||
|
||||
const tipPercentages = $derived.by(() => {
|
||||
@@ -974,7 +1036,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)}</span>
|
||||
<span class="text-base font-bold text-green-800">{formatCurrency(amountPaidPence)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1160,6 +1223,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if campaignDiscountPence(discountPreview) > 0}
|
||||
<p class="rounded-md border border-green-200 bg-green-50 p-2.5 text-xs text-green-800">
|
||||
Discount {formatCurrency(campaignDiscountPence(discountPreview) / 100)} pending — tip will
|
||||
be calculated on the discounted amount.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
<span class="text-sm font-medium text-gray-700">Add a Tip</span>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
@@ -1467,6 +1537,22 @@
|
||||
<!-- B6/B10: saved-card charges require the customer's current 2FA
|
||||
verification code when the backend enforces the gate. -->
|
||||
<TwoFactorCodeInput bind:code={twoFactorCode} showInput={show2FACodeInput} enabled={true} />
|
||||
{#if show2FACodeInput}
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Enter the customer's verification code — not your own. The customer can request a fresh
|
||||
code from their account.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
loading={requesting2FACode}
|
||||
disabled={requesting2FACode}
|
||||
onclick={handleRequestNew2FACode}
|
||||
>
|
||||
Request a new code
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
isNonceStale,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
requestNewTwoFactorCode,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
@@ -128,6 +129,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;
|
||||
}
|
||||
}
|
||||
|
||||
const isCardValid = $derived(cardSelectionValid);
|
||||
|
||||
let selectedTip = $state<number | null>(null);
|
||||
@@ -243,129 +269,129 @@
|
||||
return;
|
||||
}
|
||||
|
||||
let newCardToken: string | undefined;
|
||||
let verificationToken: string | undefined;
|
||||
if (selectedCardId) {
|
||||
// saved card — nothing to tokenize; B6/B10 requires the customer's
|
||||
// current 2FA verification code (collected in the charge form) when
|
||||
// the backend enforces the gate.
|
||||
} else if (cardSelection) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
|
||||
// verification token on retry (tokenization is one-shot; the backend
|
||||
// idempotency key dedups). The verification token is amount-bound, so
|
||||
// a changed tip amount forces a fresh tokenization.
|
||||
if (
|
||||
!tipNonce ||
|
||||
tipTokenizedForSaveCard !== saveCard ||
|
||||
isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await cardSelection.tokenizeWithVerification(
|
||||
Math.round(tipAmount * 100),
|
||||
{
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
},
|
||||
saveCard
|
||||
);
|
||||
tipNonce = tokenized.nonce;
|
||||
tipVerificationToken = tokenized.verificationToken ?? '';
|
||||
tipTokenAmount = tipAmount;
|
||||
tipTokenizedAt = Date.now();
|
||||
tipTokenizedForSaveCard = saveCard;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
let newCardToken: string | undefined;
|
||||
let verificationToken: string | undefined;
|
||||
if (selectedCardId) {
|
||||
// saved card — nothing to tokenize; B6/B10 requires the customer's
|
||||
// current 2FA verification code (collected in the charge form) when
|
||||
// the backend enforces the gate.
|
||||
} else if (cardSelection) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
|
||||
// verification token on retry (tokenization is one-shot; the backend
|
||||
// idempotency key dedups). The verification token is amount-bound, so
|
||||
// a changed tip amount forces a fresh tokenization.
|
||||
if (
|
||||
!tipNonce ||
|
||||
tipTokenizedForSaveCard !== saveCard ||
|
||||
isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await cardSelection.tokenizeWithVerification(
|
||||
Math.round(tipAmount * 100),
|
||||
{
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
},
|
||||
saveCard
|
||||
);
|
||||
tipNonce = tokenized.nonce;
|
||||
tipVerificationToken = tokenized.verificationToken ?? '';
|
||||
tipTokenAmount = tipAmount;
|
||||
tipTokenizedAt = Date.now();
|
||||
tipTokenizedForSaveCard = saveCard;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
newCardToken = tipNonce;
|
||||
verificationToken = tipVerificationToken || undefined;
|
||||
} else {
|
||||
toast.error('Please select a payment method');
|
||||
return;
|
||||
}
|
||||
|
||||
paymentState = 'processing';
|
||||
|
||||
const usedSavedCard = !!selectedCardId;
|
||||
let responseStatus = 0;
|
||||
|
||||
try {
|
||||
// New-card identity is a STABLE sentinel, NOT the cnon: nonce (same
|
||||
// rationale as the booking/account flows). Include the card so a
|
||||
// same-amount tip on a DIFFERENT card gets a fresh key instead of
|
||||
// deduping against the previous card's charge.
|
||||
const cardKey = selectedCardId || 'new-card';
|
||||
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
|
||||
tipIdempotencyKey = generateUUID();
|
||||
tipKeyedAmount = tipAmount;
|
||||
tipKeyedCard = cardKey;
|
||||
}
|
||||
const amountInPence = Math.round(tipAmount * 100);
|
||||
const body: Record<string, unknown> = {
|
||||
amount: amountInPence,
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(show2FACodeInput ? { verification_code: twoFactorCode } : {})
|
||||
};
|
||||
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${booking.id}/tip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
responseStatus = response.status;
|
||||
const errorText = await response.text();
|
||||
throw new Error(extractErrorMessage(errorText) || 'Payment failed');
|
||||
newCardToken = tipNonce;
|
||||
verificationToken = tipVerificationToken || undefined;
|
||||
} else {
|
||||
toast.error('Please select a payment method');
|
||||
return;
|
||||
}
|
||||
|
||||
paymentState = 'success';
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipKeyedCard = '';
|
||||
tipNonce = '';
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
twoFactorCode = '';
|
||||
reveal2FACodeInput = false;
|
||||
toast.success('Thank you for your tip!');
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
paymentState = 'error';
|
||||
let errorMessage = err instanceof Error ? err.message : 'Payment failed';
|
||||
// Saved-card (ccof) charges skip the client-side SCA step, so a
|
||||
// definitive 402 on the saved-card path means the issuer still
|
||||
// requires verification — retrying the same saved card can never
|
||||
// succeed. Surface the fix instead of the generic backend text.
|
||||
if (isSavedCardVerificationRequired(responseStatus, usedSavedCard)) {
|
||||
errorMessage = SAVED_CARD_VERIFICATION_MESSAGE;
|
||||
paymentState = 'processing';
|
||||
|
||||
const usedSavedCard = !!selectedCardId;
|
||||
let responseStatus = 0;
|
||||
|
||||
try {
|
||||
// New-card identity is a STABLE sentinel, NOT the cnon: nonce (same
|
||||
// rationale as the booking/account flows). Include the card so a
|
||||
// same-amount tip on a DIFFERENT card gets a fresh key instead of
|
||||
// deduping against the previous card's charge.
|
||||
const cardKey = selectedCardId || 'new-card';
|
||||
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
|
||||
tipIdempotencyKey = generateUUID();
|
||||
tipKeyedAmount = tipAmount;
|
||||
tipKeyedCard = cardKey;
|
||||
}
|
||||
const amountInPence = Math.round(tipAmount * 100);
|
||||
const body: Record<string, unknown> = {
|
||||
amount: amountInPence,
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(show2FACodeInput ? { verification_code: twoFactorCode } : {})
|
||||
};
|
||||
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${booking.id}/tip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
responseStatus = response.status;
|
||||
const errorText = await response.text();
|
||||
throw new Error(extractErrorMessage(errorText) || 'Payment failed');
|
||||
}
|
||||
|
||||
paymentState = 'success';
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipKeyedCard = '';
|
||||
tipNonce = '';
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
twoFactorCode = '';
|
||||
reveal2FACodeInput = false;
|
||||
toast.success('Thank you for your tip!');
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
paymentState = 'error';
|
||||
let errorMessage = err instanceof Error ? err.message : 'Payment failed';
|
||||
// Saved-card (ccof) charges skip the client-side SCA step, so a
|
||||
// definitive 402 on the saved-card path means the issuer still
|
||||
// requires verification — retrying the same saved card can never
|
||||
// succeed. Surface the fix instead of the generic backend text.
|
||||
if (isSavedCardVerificationRequired(responseStatus, usedSavedCard)) {
|
||||
errorMessage = SAVED_CARD_VERIFICATION_MESSAGE;
|
||||
}
|
||||
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||||
// code, brute-force lockout) is recoverable — keep the code populated
|
||||
// and reveal the input so the tip can be retried with a fresh code.
|
||||
if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) {
|
||||
reveal2FACodeInput = true;
|
||||
}
|
||||
toast.error(errorMessage);
|
||||
// A definitive charge failure (e.g. declined card) consumes the nonce
|
||||
// and SCA verification token — they can never succeed again. Clear the
|
||||
// cached pair so the next retry re-tokenizes fresh. The idempotency
|
||||
// key is kept: it's still correct for network-timeout dedup.
|
||||
tipNonce = '';
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
}
|
||||
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||||
// code, brute-force lockout) is recoverable — keep the code populated
|
||||
// and reveal the input so the tip can be retried with a fresh code.
|
||||
if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) {
|
||||
reveal2FACodeInput = true;
|
||||
}
|
||||
toast.error(errorMessage);
|
||||
// A definitive charge failure (e.g. declined card) consumes the nonce
|
||||
// and SCA verification token — they can never succeed again. Clear the
|
||||
// cached pair so the next retry re-tokenizes fresh. The idempotency
|
||||
// key is kept: it's still correct for network-timeout dedup.
|
||||
tipNonce = '';
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
}
|
||||
} finally {
|
||||
isSubmittingTipSync = false;
|
||||
}
|
||||
@@ -517,6 +543,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}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
requestNewTwoFactorCode,
|
||||
requires2FACodeForSavedCard,
|
||||
sanitizeDecimalInput,
|
||||
submitPaymentWithRetry
|
||||
@@ -154,9 +155,9 @@ describe('campaignDiscountPence', () => {
|
||||
});
|
||||
|
||||
it('rounds each discount amount to pence before summing', () => {
|
||||
expect(campaignDiscountPence({ ...base, discounts: [{ ...base.discounts[0], amount: 5.005 }] })).toBe(
|
||||
501
|
||||
);
|
||||
expect(
|
||||
campaignDiscountPence({ ...base, discounts: [{ ...base.discounts[0], amount: 5.005 }] })
|
||||
).toBe(501);
|
||||
});
|
||||
|
||||
it('is 0 when no preview', () => {
|
||||
@@ -364,3 +365,84 @@ describe('submitPaymentWithRetry', () => {
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1234);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestNewTwoFactorCode', () => {
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns ok with the server message on a 200 mint', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(jsonResponse({ message: 'Verification code sent' }))
|
||||
);
|
||||
const result = await requestNewTwoFactorCode();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.message).toBe('Verification code sent');
|
||||
});
|
||||
|
||||
it('falls back to a default message when the 200 body has none', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}, 200)));
|
||||
const result = await requestNewTwoFactorCode();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.message).toContain('verification code');
|
||||
});
|
||||
|
||||
it('surfaces the 429 mint-cooldown error message', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ error: 'Too many requests. Wait before requesting a new code.' }, 429)
|
||||
)
|
||||
);
|
||||
const result = await requestNewTwoFactorCode();
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.status).toBe(429);
|
||||
expect(result.message).toContain('Too many requests');
|
||||
});
|
||||
|
||||
it('surfaces the 503 delivery-unavailable error message', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ error: 'Verification code delivery unavailable' }, 503))
|
||||
);
|
||||
const result = await requestNewTwoFactorCode();
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.status).toBe(503);
|
||||
expect(result.message).toContain('delivery');
|
||||
});
|
||||
|
||||
it('reports a network failure with status 0', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')));
|
||||
const result = await requestNewTwoFactorCode();
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.message).toContain('Network error');
|
||||
});
|
||||
|
||||
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)
|
||||
});
|
||||
const result = await requestNewTwoFactorCode();
|
||||
expect(result.ok).toBe(true);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('/api/user/2fa/code');
|
||||
expect(init.method).toBe('POST');
|
||||
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer abc.def.ghi');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +149,68 @@ export function isTwoFactorVerificationGateFailure(status: number, message: stri
|
||||
return /invalid verification code|verification code expired/i.test(message);
|
||||
}
|
||||
|
||||
/** Shape returned by requestNewTwoFactorCode: the HTTP status (0 for a
|
||||
* network error before any response), whether the mint succeeded, and a
|
||||
* user-facing message extracted from the server body or a sensible fallback. */
|
||||
export interface TwoFactorCodeRequestResult {
|
||||
status: number;
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a fresh 2FA verification code for the signed-in account via
|
||||
* POST /api/user/2fa/code (RequireAuth + RequireNonGuest). The backend applies
|
||||
* a per-user mint cooldown, so a too-fast re-request returns 429; production
|
||||
* with no delivery channel configured fails closed with 503. A successful mint
|
||||
* delivers the code through the build-dependent channel ([2FA] server log in
|
||||
* dev/test builds), so this surfaces the server's `message` — never the code
|
||||
* itself. The Authorization header is read from localStorage (authToken) —
|
||||
* exactly where the auth store persists it — so this helper stays free of
|
||||
* `$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> {
|
||||
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 });
|
||||
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' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal `{"error"|"message": "..."}` extractor for the 2FA code-request
|
||||
* endpoint bodies (429/503), kept inline so square.ts stays import-free for
|
||||
* the vitest suite. */
|
||||
function extractServerErrorMessage(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as { error?: unknown; message?: unknown };
|
||||
if (typeof parsed.error === 'string') return parsed.error;
|
||||
if (typeof parsed.message === 'string') return parsed.message;
|
||||
} catch {
|
||||
// Not JSON — use the raw body below.
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error code the booking-payment endpoint (POST /api/bookings/{id}/payment)
|
||||
* returns with a 400 when a payment would exceed the booking's remaining
|
||||
|
||||
Reference in New Issue
Block a user