fix: review-loop B — adversarial findings (sweep auto-refund, admin clamp, 2FA real challenge, opaque refresh tokens, gated client IP, GBP pence)
Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests
All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
This commit is contained in:
@@ -7,9 +7,13 @@
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { isSquareConfigured, submitPaymentWithRetry } from '$lib/square/square';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import {
|
||||
isSquareConfigured,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
type CartItem = {
|
||||
id: string;
|
||||
@@ -103,19 +107,31 @@
|
||||
})
|
||||
);
|
||||
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks charging a
|
||||
// customer's saved card online. Cash, card machine, and online (new-card
|
||||
// nonce) payments are unaffected.
|
||||
const twoFactorBlocksSavedCards = $derived(
|
||||
!!authStore.currentUser?.twoFactorRequired && !authStore.currentUser?.twoFactorEnabled
|
||||
// B6/B10: charging a customer's saved card via the till 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. Cash, card machine, and online (new-card nonce) payments
|
||||
// are unaffected.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
|
||||
// B6/B10: verification code for the customer's saved-card till charge,
|
||||
// collected on the saved-card payment screen. Kept populated across retries
|
||||
// so an invalid/expired code can be corrected without re-typing it. The
|
||||
// admin always supplies the CUSTOMER's code — the admin's own 2FA flag is
|
||||
// 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.
|
||||
let reveal2FACodeInput = $state(false);
|
||||
const show2FACodeInput = $derived(
|
||||
reveal2FACodeInput || (savedCardChargeRequires2FACode && paymentMethod === 'saved_card')
|
||||
);
|
||||
const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === '');
|
||||
|
||||
// The saved-card option is hidden outright unless a customer is selected
|
||||
// AND has at least one currently-valid card on file AND 2FA gating is not
|
||||
// active.
|
||||
const showSavedCardOption = $derived(
|
||||
selectedCustomer !== null && validCards.length > 0 && !twoFactorBlocksSavedCards
|
||||
);
|
||||
// AND has at least one currently-valid card on file.
|
||||
const showSavedCardOption = $derived(selectedCustomer !== null && validCards.length > 0);
|
||||
|
||||
const availablePaymentMethods = $derived(
|
||||
PAYMENT_METHODS.filter((m) => m.key !== 'saved_card' || showSavedCardOption)
|
||||
@@ -285,10 +301,6 @@
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (paymentMethod === 'saved_card' && twoFactorBlocksSavedCards) {
|
||||
toast.error('Two-factor authentication is required to use online card payments');
|
||||
return;
|
||||
}
|
||||
if (paymentMethod === 'saved_card' && (!selectedCustomer || !selectedSavedCardId)) {
|
||||
toast.error('Select a customer and a saved card before charging');
|
||||
return;
|
||||
@@ -296,6 +308,7 @@
|
||||
isProcessingPaymentSync = true;
|
||||
processing = true;
|
||||
paymentError = null;
|
||||
let responseStatus = 0;
|
||||
try {
|
||||
// One sale per cart line × quantity — each till sale funds its own
|
||||
// gift card (the backend only accepts item_type 'gift_card').
|
||||
@@ -312,6 +325,9 @@
|
||||
if (paymentMethod === 'saved_card') {
|
||||
body.user_id = selectedCustomer?.id;
|
||||
body.user_saved_card_id = selectedSavedCardId;
|
||||
// B6/B10: the backend requires the CARD OWNER's current 2FA
|
||||
// verification code when the gate is enforced.
|
||||
if (show2FACodeInput) body.verification_code = twoFactorCode;
|
||||
} else if (paymentMethod === 'online_square') {
|
||||
if (!onlineSquareCardInput) {
|
||||
throw new Error('Card form is not ready — please wait a moment and try again');
|
||||
@@ -338,6 +354,7 @@
|
||||
})
|
||||
);
|
||||
if (!res.ok) {
|
||||
responseStatus = res.status;
|
||||
const errText = await res.text();
|
||||
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
|
||||
}
|
||||
@@ -350,8 +367,14 @@
|
||||
toast.success('Sale complete');
|
||||
cart = [];
|
||||
idempotencyKeys.clear();
|
||||
twoFactorCode = '';
|
||||
reveal2FACodeInput = false;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Sale failed';
|
||||
// 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 sale can be retried with a fresh code.
|
||||
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) reveal2FACodeInput = true;
|
||||
paymentError = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
@@ -666,13 +689,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if twoFactorBlocksSavedCards}
|
||||
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
|
||||
Two-factor authentication is required to use online card payments.
|
||||
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if paymentMethod === 'online_square'}
|
||||
<div class="mt-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
|
||||
{#if isSquareConfigured()}
|
||||
@@ -755,6 +771,11 @@
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- B6/B10: saved-card till charges require the customer's
|
||||
current 2FA verification code when the backend enforces
|
||||
the gate. -->
|
||||
<TwoFactorCodeInput bind:code={twoFactorCode} showInput={show2FACodeInput} enabled={true} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -777,6 +798,7 @@
|
||||
loading={processing}
|
||||
disabled={!canCharge ||
|
||||
processing ||
|
||||
missing2FACode ||
|
||||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
||||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
|
||||
>
|
||||
|
||||
@@ -36,12 +36,14 @@
|
||||
import TimeSlotPicker from '$lib/components/booking/TimeSlotPicker.svelte';
|
||||
import ServiceSelector from '$lib/components/booking/ServiceSelector.svelte';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import {
|
||||
canSaveCardsForRole,
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
@@ -144,10 +146,30 @@
|
||||
|
||||
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
|
||||
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks saved-card use
|
||||
// and saving new cards for reuse. The new-card (nonce) path has its own
|
||||
// SCA via Square tokenizeWithVerification.
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
// B6/B10: saved-card deposits (and saving a new card for reuse) require the
|
||||
// customer's current 2FA verification code whenever the backend enforces the
|
||||
// gate. The input is surfaced at the charge step; the new-card (nonce) path
|
||||
// keeps its own SCA via Square tokenizeWithVerification.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
|
||||
// B6/B10: verification code for a saved-card deposit / new-card save. The
|
||||
// backend requires the card owner's CURRENT one-time code when 2FA is
|
||||
// enforced (delivered via the server log / email-SMS channel and relayed by
|
||||
// the operator). Kept populated across retries so an invalid/expired code can
|
||||
// be corrected without re-typing it.
|
||||
let depositTwoFactorCode = $state('');
|
||||
// Set true when a charge 403s for a missing code: the backend keys on the
|
||||
// CARD OWNER, so even a session user whose own flag is unset must be able to
|
||||
// enter the code.
|
||||
let reveal2FACodeInput = $state(false);
|
||||
const show2FACodeInput = $derived(
|
||||
reveal2FACodeInput ||
|
||||
(savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard))
|
||||
);
|
||||
const missing2FACode = $derived(
|
||||
show2FACodeInput && twoFactorEnabled && depositTwoFactorCode.trim() === ''
|
||||
);
|
||||
|
||||
const depositCardFormValid = $derived(paymentCardSelectionValid);
|
||||
|
||||
@@ -319,12 +341,6 @@
|
||||
isProcessingPayment = true;
|
||||
paymentAttempted = false;
|
||||
try {
|
||||
// PSD2 SCA stand-in: never charge a saved card while 2FA is required
|
||||
// but not enabled — clear any stale selection so the new-card
|
||||
// (nonce) path is used instead.
|
||||
if (twoFactorBlocksSavedCards && selectedPaymentMethod) {
|
||||
selectedPaymentMethod = '';
|
||||
}
|
||||
// Create the booking only if one does not already exist. A retry after
|
||||
// a failed deposit charge (or a lost response) must NOT re-create a
|
||||
// booking — the existing confirmedBooking is the one to charge, and
|
||||
@@ -349,8 +365,10 @@
|
||||
|
||||
let newCardToken: string | undefined;
|
||||
let verificationToken: string | undefined;
|
||||
if (selectedPaymentMethod && !twoFactorBlocksSavedCards) {
|
||||
// saved card — nothing to tokenize
|
||||
if (selectedPaymentMethod) {
|
||||
// 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 (paymentCardSelection) {
|
||||
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
|
||||
// verification token on retry (tokenization is one-shot; the
|
||||
@@ -412,7 +430,8 @@
|
||||
idempotency_key: depositIdempotencyKey,
|
||||
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {})
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(show2FACodeInput ? { verification_code: depositTwoFactorCode } : {})
|
||||
};
|
||||
|
||||
paymentAttempted = true;
|
||||
@@ -483,6 +502,8 @@
|
||||
depositTokenizedAt = 0;
|
||||
depositTokenizedForSaveCard = false;
|
||||
depositSaveCard = false;
|
||||
depositTwoFactorCode = '';
|
||||
reveal2FACodeInput = false;
|
||||
overflowConfirm = null;
|
||||
// Immutable update — avoid mutating the existing object so
|
||||
// concurrent renders (e.g. a stale fetch) can't observe partial
|
||||
@@ -499,6 +520,12 @@
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
// 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 deposit can be retried with a fresh code.
|
||||
if (isTwoFactorVerificationGateFailure(response.status, extractErrorMessage(text))) {
|
||||
reveal2FACodeInput = true;
|
||||
}
|
||||
// Pre-start overpayment guard on stale booking data: park the rejected
|
||||
// request (body + amount) and surface the Confirm/Cancel prompt instead
|
||||
// of a dead-end 400. The cached nonce + SCA verification token +
|
||||
@@ -2634,33 +2661,44 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<CardSelection
|
||||
bind:this={paymentCardSelection}
|
||||
cards={[]}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={selectedPaymentMethod}
|
||||
bind:saveCard={depositSaveCard}
|
||||
onValidityChange={(v) => (paymentCardSelectionValid = v)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between border-t pt-4">
|
||||
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isProcessingPayment || !depositCardFormValid}
|
||||
onclick={() => processPayment(calculateDepositAmount())}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isProcessingPayment
|
||||
? 'Processing...'
|
||||
: `Pay Deposit £${calculateDepositAmount()}`}
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<CardSelection
|
||||
bind:this={paymentCardSelection}
|
||||
cards={[]}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={selectedPaymentMethod}
|
||||
bind:saveCard={depositSaveCard}
|
||||
onValidityChange={(v) => (paymentCardSelectionValid = v)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- B6/B10: saved-card deposits require the customer's
|
||||
current 2FA verification code when the backend
|
||||
enforces the gate. -->
|
||||
<div class="mb-6">
|
||||
<TwoFactorCodeInput
|
||||
bind:code={depositTwoFactorCode}
|
||||
showInput={show2FACodeInput}
|
||||
enabled={twoFactorEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t pt-4">
|
||||
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isProcessingPayment || !depositCardFormValid || missing2FACode}
|
||||
onclick={() => processPayment(calculateDepositAmount())}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isProcessingPayment
|
||||
? 'Processing...'
|
||||
: `Pay Deposit £${calculateDepositAmount()}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-gray-500">
|
||||
Secure payment powered by Square
|
||||
|
||||
@@ -41,40 +41,24 @@
|
||||
// would collide on the same checkbox id. Pure SPA, so no SSR concern.
|
||||
const consentId = `save-card-consent-${crypto.randomUUID()}`;
|
||||
|
||||
// PSD2 SCA stand-in: when 2FA is required but not yet enabled, saved-card
|
||||
// selection and save-for-later are blocked. The new-card (nonce) path has
|
||||
// its own SCA via Square tokenizeWithVerification, so only the saved-card
|
||||
// list and the save toggle are gated here.
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
// B6/B10: saved-card charges require the customer's current 2FA verification
|
||||
// code. This no longer BLOCKS saved-card selection — the code is collected
|
||||
// at the charge step (the parent charge forms show the input). The new-card
|
||||
// (nonce) path keeps its own SCA via Square tokenizeWithVerification.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
|
||||
// Auto-select the default saved card when cards first load. Guarded by
|
||||
// !showNewCardForm so the "Use a new card" click (selectedCardId = '') is
|
||||
// NOT immediately overridden back to the default card — which would
|
||||
// silently charge the wrong card on submit. Also skipped while 2FA gating
|
||||
// is active so a saved card is never selected by default.
|
||||
// silently charge the wrong card on submit.
|
||||
$effect(() => {
|
||||
if (
|
||||
cards.length > 0 &&
|
||||
!selectedCardId &&
|
||||
!showNewCardForm &&
|
||||
!twoFactorBlocksSavedCards
|
||||
) {
|
||||
if (cards.length > 0 && !selectedCardId && !showNewCardForm) {
|
||||
const defaultCard = cards.find((c) => c.is_default) ?? cards[0];
|
||||
selectedCardId = defaultCard.id;
|
||||
}
|
||||
});
|
||||
|
||||
// While 2FA gating is active, keep the shared component self-consistent:
|
||||
// never allow a saved card to stay selected or the save-card checkbox to
|
||||
// remain checked (the parents' submit paths also guard, this is belt-and-
|
||||
// braces for pre-selected state from a previous session).
|
||||
$effect(() => {
|
||||
if (twoFactorBlocksSavedCards && (selectedCardId !== '' || saveCard)) {
|
||||
selectedCardId = '';
|
||||
saveCard = false;
|
||||
}
|
||||
});
|
||||
|
||||
// When no saved cards exist the new-card form shows by default (no toggle).
|
||||
const newCardMode = $derived(showNewCardForm || cards.length === 0);
|
||||
|
||||
@@ -116,43 +100,51 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if savedCardChargeRequires2FACode}
|
||||
{#if twoFactorEnabled}
|
||||
<div class="rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||
<p class="text-sm text-blue-800">
|
||||
A verification code is required to use a saved card — you'll be asked for it at checkout.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<p class="text-sm text-amber-800">
|
||||
Two-factor authentication is required to use online card payments.
|
||||
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if cards.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#if twoFactorBlocksSavedCards}
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<p class="text-sm text-amber-800">
|
||||
Two-factor authentication is required to use online card payments.
|
||||
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each cards as card (card.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
|
||||
card.id && !showNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
selectedCardId = card.id;
|
||||
showNewCardForm = false;
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<CardBrandIcon brand={card.brand} />
|
||||
<div class="text-sm">
|
||||
<span class="font-mono">**** {card.last_4}</span>
|
||||
<span class="ml-2 text-xs text-gray-400"
|
||||
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
|
||||
>
|
||||
</div>
|
||||
{#each cards as card (card.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border p-3 text-left {selectedCardId ===
|
||||
card.id && !showNewCardForm
|
||||
? 'border-input bg-accent'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => {
|
||||
selectedCardId = card.id;
|
||||
showNewCardForm = false;
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<CardBrandIcon brand={card.brand} />
|
||||
<div class="text-sm">
|
||||
<span class="font-mono">**** {card.last_4}</span>
|
||||
<span class="ml-2 text-xs text-gray-400"
|
||||
>Exp {String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
|
||||
>
|
||||
</div>
|
||||
{#if selectedCardId === card.id && !showNewCardForm}
|
||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedCardId === card.id && !showNewCardForm}
|
||||
<span class="text-xs font-semibold text-primary">Selected</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -177,13 +169,6 @@
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{:else if twoFactorBlocksSavedCards}
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<p class="text-sm text-amber-800">
|
||||
Two-factor authentication is required to use online card payments.
|
||||
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if newCardMode}
|
||||
@@ -194,7 +179,7 @@
|
||||
<CardEntryUnavailable />
|
||||
{/if}
|
||||
|
||||
{#if canSaveCards && squareCardReady && !twoFactorBlocksSavedCards}
|
||||
{#if canSaveCards && squareCardReady && !(savedCardChargeRequires2FACode && !twoFactorEnabled)}
|
||||
<label
|
||||
class="mt-3 flex cursor-pointer items-start gap-2 text-sm text-gray-600"
|
||||
for={consentId}
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
import {
|
||||
campaignDiscountPence,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
|
||||
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||
|
||||
@@ -63,19 +65,53 @@
|
||||
// reactive flag is checked synchronously at the start of every handler.
|
||||
let isProcessingPaymentSync = false;
|
||||
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks charging a
|
||||
// customer's saved card online (the admin's own 2FA status gates it). The
|
||||
// card-machine and new-card paths have their own SCA.
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
// 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);
|
||||
|
||||
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
|
||||
let useLoyalty = $state(false);
|
||||
|
||||
// B6/B10: verification code for the customer's saved-card charge, collected
|
||||
// on the saved-card screen. Kept populated across retries so an
|
||||
// invalid/expired code can be corrected without re-typing it. The admin
|
||||
// always supplies the CUSTOMER's code — the admin's own 2FA flag is
|
||||
// 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.
|
||||
let reveal2FACodeInput = $state(false);
|
||||
const show2FACodeInput = $derived(reveal2FACodeInput || savedCardChargeRequires2FACode);
|
||||
const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === '');
|
||||
|
||||
// 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
|
||||
// on mount and subtracted from the charge (see netTotal).
|
||||
let amountPaidPence = $state(0);
|
||||
async function fetchAmountPaid() {
|
||||
try {
|
||||
const resp = await apiFetch(`/api/admin/bookings/${booking.id}`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (typeof data.amount_paid === 'number') {
|
||||
amountPaidPence = Math.round(data.amount_paid * 100);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// fall through to the booking prop below
|
||||
}
|
||||
amountPaidPence = Math.round((booking.amount_paid ?? 0) * 100);
|
||||
}
|
||||
|
||||
const loyaltyEligible = $derived(
|
||||
stamps >= 10 &&
|
||||
!(booking.discounts ?? []).some((d: BookingDiscount) => d.discount_source === 'loyalty') &&
|
||||
booking.total_amount > 0 &&
|
||||
booking.amount_paid === 0
|
||||
amountPaidPence === 0
|
||||
);
|
||||
|
||||
const loyaltyDiscount = $derived(
|
||||
@@ -199,22 +235,19 @@
|
||||
// so the charge must be the subtotal minus already-applied discounts minus
|
||||
// the eligible campaign credit — otherwise the customer is overcharged.
|
||||
//
|
||||
// NOTE (round-A-4 UX residual, deliberately NOT "fixed"): this ignores
|
||||
// payments already made against the booking. The admin backend path
|
||||
// (CreateTerminalPayment) treats the amount it receives as the charge to
|
||||
// record verbatim — it does NOT compute "remaining due" and subtract prior
|
||||
// payments server-side — and the booking object handed to this modal (from
|
||||
// /api/admin/today/current-next, AppointmentInfo) carries no amount_paid /
|
||||
// amount_due / payments fields to derive them client-side. Subtracting an
|
||||
// unverifiable prior-paid total would risk under-collecting. When a deposit
|
||||
// was already paid, charging the full subtotal here is money-safe server-side
|
||||
// (buildSplitRecords/buildTerminalSplitRecords carve any excess beyond the
|
||||
// remaining booking value into a payment_type='tip' record, so the ledger
|
||||
// still closes exactly at the booking total) but the excess lands as an
|
||||
// UNINTENDED tip. Revisit when the today endpoint exposes the booking's paid
|
||||
// total: netTotal = max(0, subtotal − discountSum − campaignDiscountPence − amountPaidPence).
|
||||
// B3: prior payments are also subtracted. The booking object handed to this
|
||||
// modal (from /api/admin/today/current-next, AppointmentInfo) carries no
|
||||
// amount_paid/amount_due/payments, so on mount the modal fetches the
|
||||
// authoritative paid total from GET /api/admin/bookings/{id} (full Booking
|
||||
// shape, admin-accessible) and charges only the remaining obligation. The
|
||||
// backend money agent clamps the booking portion of a payment to the
|
||||
// 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))
|
||||
Math.max(
|
||||
0,
|
||||
subtotal - discountSum - campaignDiscountPence(discountPreview) - amountPaidPence
|
||||
)
|
||||
);
|
||||
|
||||
const tipPercentages = $derived.by(() => {
|
||||
@@ -414,6 +447,7 @@
|
||||
// customer flow (UserPaymentModal) so the admin modal charges the same
|
||||
// discounted amount the backend will auto-apply.
|
||||
onMount(async () => {
|
||||
fetchAmountPaid();
|
||||
try {
|
||||
const resp = await apiFetch(`/api/bookings/${booking.id}/discount-preview`);
|
||||
if (resp.ok) {
|
||||
@@ -680,10 +714,6 @@
|
||||
|
||||
async function handleSavedCardPayment() {
|
||||
if (isProcessingPaymentSync) return;
|
||||
if (twoFactorBlocksSavedCards) {
|
||||
toast.error('Two-factor authentication is required to use online card payments');
|
||||
return;
|
||||
}
|
||||
if (!selectedSavedCardId) {
|
||||
toast.error('Please select a saved card');
|
||||
return;
|
||||
@@ -707,7 +737,7 @@
|
||||
savedCardKeyedCardId !== selectedSavedCardId ||
|
||||
savedCardKeyedAmount !== chargeAmount
|
||||
) {
|
||||
savedCardIdempotencyKey = crypto.randomUUID();
|
||||
savedCardIdempotencyKey = generateUUID();
|
||||
savedCardKeyedBookingId = booking.id;
|
||||
savedCardKeyedCardId = selectedSavedCardId;
|
||||
savedCardKeyedAmount = chargeAmount;
|
||||
@@ -730,6 +760,7 @@
|
||||
payment_type: 'full',
|
||||
payment_method: 'saved_card',
|
||||
saved_card_id: selectedSavedCardId,
|
||||
...(show2FACodeInput ? { verification_code: twoFactorCode } : {}),
|
||||
idempotency_key: savedCardIdempotencyKey
|
||||
})
|
||||
})
|
||||
@@ -757,6 +788,8 @@
|
||||
// charge gets a fresh UUID and can't be deduped against this one.
|
||||
savedCardIdempotencyKey = '';
|
||||
savedCardKeyedAmount = 0;
|
||||
twoFactorCode = '';
|
||||
reveal2FACodeInput = false;
|
||||
toast.success('Saved card payment successful');
|
||||
onComplete(paymentResult);
|
||||
} catch (_err) {
|
||||
@@ -768,6 +801,10 @@
|
||||
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
||||
if (isSavedCardVerificationRequired(responseStatus, true))
|
||||
msg = 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 charge can be retried with a fresh code.
|
||||
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) reveal2FACodeInput = true;
|
||||
error = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
@@ -776,13 +813,6 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// PSD2 SCA stand-in: if 2FA gating becomes active mid-modal, bail out
|
||||
// of the saved-card screen back to method selection.
|
||||
if (selectedMethod === 'savedcard' && twoFactorBlocksSavedCards) {
|
||||
selectedMethod = null;
|
||||
status = 'idle';
|
||||
return;
|
||||
}
|
||||
if (selectedMethod === 'cash') {
|
||||
cashAmount = totalDue.toFixed(2);
|
||||
extraAsTip = false;
|
||||
@@ -935,10 +965,19 @@
|
||||
|
||||
{#if nothingToCharge}
|
||||
<p class="rounded-md border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
The booking is fully covered by discounts — nothing to charge.
|
||||
Nothing to charge — the booking is fully covered by discounts or prior payments.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if amountPaidPence > 0}
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if discountPreview?.eligible && discountPreview.discounts.length > 0}
|
||||
<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
|
||||
{#each discountPreview.discounts as d (d.name)}
|
||||
@@ -1022,7 +1061,7 @@
|
||||
</svg>
|
||||
Cash
|
||||
</button>
|
||||
{#if savedCards.length > 0 && !twoFactorBlocksSavedCards}
|
||||
{#if savedCards.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
@@ -1078,19 +1117,8 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if twoFactorBlocksSavedCards && savedCards.length > 0}
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<p class="text-sm text-amber-800">
|
||||
Two-factor authentication is required to use online card payments.
|
||||
<a href={resolve('/account')} class="font-medium underline"
|
||||
>Enable it in your account settings</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-3 sm:hidden">
|
||||
{#if savedCards.length > 0 && !twoFactorBlocksSavedCards}
|
||||
{#if savedCards.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
@@ -1436,12 +1464,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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} />
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button
|
||||
onclick={handleSavedCardPayment}
|
||||
class="flex-1"
|
||||
disabled={!selectedSavedCardId || nothingToCharge}
|
||||
disabled={!selectedSavedCardId || nothingToCharge || missing2FACode}
|
||||
>
|
||||
Charge Saved Card
|
||||
</Button>
|
||||
|
||||
@@ -11,14 +11,17 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { onMount } from 'svelte';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import {
|
||||
canSaveCardsForRole,
|
||||
isNonceStale,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
|
||||
// Shared tip-payment UI used by /tip, /pay-tip/[id] and the account
|
||||
// booking-modal tip dialog. The routes resolve the booking (most-recent past
|
||||
@@ -101,10 +104,29 @@
|
||||
|
||||
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
|
||||
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks saved-card use
|
||||
// and saving new cards for reuse. The new-card (nonce) path has its own
|
||||
// SCA via Square tokenizeWithVerification.
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
// B6/B10: saved-card tips (and saving a new card for reuse) require the
|
||||
// customer's current 2FA verification code whenever the backend enforces the
|
||||
// gate. The input is surfaced at the charge step; the new-card (nonce) path
|
||||
// keeps its own SCA via Square tokenizeWithVerification.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
|
||||
// B6/B10: verification code for a saved-card tip / new-card save. The backend
|
||||
// requires the card owner's CURRENT one-time code when 2FA is enforced
|
||||
// (delivered via the server log / email-SMS channel and relayed by the
|
||||
// operator). Kept populated across retries so an invalid/expired code can be
|
||||
// corrected without re-typing it.
|
||||
let twoFactorCode = $state('');
|
||||
// Set true when a charge 403s for a missing code: the backend keys on the
|
||||
// CARD OWNER, so even a session user whose own flag is unset must be able to
|
||||
// enter the code.
|
||||
let reveal2FACodeInput = $state(false);
|
||||
const show2FACodeInput = $derived(
|
||||
reveal2FACodeInput || (savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard))
|
||||
);
|
||||
const missing2FACode = $derived(
|
||||
show2FACodeInput && twoFactorEnabled && twoFactorCode.trim() === ''
|
||||
);
|
||||
|
||||
const isCardValid = $derived(cardSelectionValid);
|
||||
|
||||
@@ -192,7 +214,7 @@
|
||||
async function loadSavedCards() {
|
||||
if (savedCardsStore.loaded) {
|
||||
savedCards = savedCardsStore.cards;
|
||||
if (savedCards.length > 0 && !selectedCardId && !twoFactorBlocksSavedCards) {
|
||||
if (savedCards.length > 0 && !selectedCardId) {
|
||||
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
|
||||
}
|
||||
return;
|
||||
@@ -200,7 +222,7 @@
|
||||
try {
|
||||
await savedCardsStore.fetch();
|
||||
savedCards = savedCardsStore.cards;
|
||||
if (savedCards.length > 0 && !selectedCardId && !twoFactorBlocksSavedCards) {
|
||||
if (savedCards.length > 0 && !selectedCardId) {
|
||||
selectedCardId = savedCards.find((c) => c.is_default)?.id || savedCards[0].id;
|
||||
}
|
||||
} catch {
|
||||
@@ -221,17 +243,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// PSD2 SCA stand-in: never charge a saved card while 2FA is required
|
||||
// but not enabled — clear any stale selection so the new-card (nonce)
|
||||
// path is used instead.
|
||||
if (twoFactorBlocksSavedCards && selectedCardId) {
|
||||
selectedCardId = '';
|
||||
}
|
||||
|
||||
let newCardToken: string | undefined;
|
||||
let verificationToken: string | undefined;
|
||||
if (selectedCardId && !twoFactorBlocksSavedCards) {
|
||||
// saved card — nothing to tokenize
|
||||
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
|
||||
@@ -271,7 +288,7 @@
|
||||
|
||||
paymentState = 'processing';
|
||||
|
||||
const usedSavedCard = !!(selectedCardId && !twoFactorBlocksSavedCards);
|
||||
const usedSavedCard = !!selectedCardId;
|
||||
let responseStatus = 0;
|
||||
|
||||
try {
|
||||
@@ -281,7 +298,7 @@
|
||||
// deduping against the previous card's charge.
|
||||
const cardKey = selectedCardId || 'new-card';
|
||||
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
|
||||
tipIdempotencyKey = crypto.randomUUID();
|
||||
tipIdempotencyKey = generateUUID();
|
||||
tipKeyedAmount = tipAmount;
|
||||
tipKeyedCard = cardKey;
|
||||
}
|
||||
@@ -291,7 +308,8 @@
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {})
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(show2FACodeInput ? { verification_code: twoFactorCode } : {})
|
||||
};
|
||||
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
@@ -317,6 +335,8 @@
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
twoFactorCode = '';
|
||||
reveal2FACodeInput = false;
|
||||
toast.success('Thank you for your tip!');
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
@@ -329,6 +349,12 @@
|
||||
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
|
||||
@@ -483,6 +509,14 @@
|
||||
bind:saveCard
|
||||
onValidityChange={(v) => (cardSelectionValid = v)}
|
||||
/>
|
||||
|
||||
<!-- B6/B10: saved-card tips require the customer's current 2FA
|
||||
verification code when the backend enforces the gate. -->
|
||||
<TwoFactorCodeInput
|
||||
bind:code={twoFactorCode}
|
||||
showInput={show2FACodeInput}
|
||||
enabled={twoFactorEnabled}
|
||||
/>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -497,7 +531,7 @@
|
||||
<Button
|
||||
class="w-full"
|
||||
size="lg"
|
||||
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing'}
|
||||
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing' || missing2FACode}
|
||||
loading={paymentState === 'processing'}
|
||||
onclick={submitTip}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<!--
|
||||
TwoFactorCodeInput.svelte — B6/B10 2FA verification-code input for saved-card
|
||||
charges. The backend's requireTwoFactorForCardAccess gate requires the CARD
|
||||
OWNER's current one-time code on every saved-card charge in an enforced
|
||||
environment; this input collects it so the charge body carries
|
||||
`verification_code`. Shared by the customer booking, tip, admin booking and
|
||||
till saved-card surfaces so the field name, hint copy and the
|
||||
enabled/not-enabled presentation can't drift between them.
|
||||
|
||||
When `enabled` is false (the session user has not completed 2FA setup) the
|
||||
editable input is replaced by an "enable 2FA in your account settings" hint
|
||||
— the charge cannot succeed without a setup code. Admin surfaces pass
|
||||
`enabled` regardless of the admin's own flag: the operator relays the
|
||||
CUSTOMER's code.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let {
|
||||
code = $bindable(''),
|
||||
showInput = false,
|
||||
enabled = false
|
||||
}: {
|
||||
code?: string;
|
||||
showInput?: boolean;
|
||||
enabled?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if showInput}
|
||||
{#if enabled}
|
||||
<div>
|
||||
<label for="two-factor-code" class="text-sm font-medium text-gray-700">
|
||||
2FA Verification Code
|
||||
</label>
|
||||
<Input
|
||||
id="two-factor-code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
pattern="[0-9]*"
|
||||
maxlength={6}
|
||||
placeholder="Enter the current code"
|
||||
value={code}
|
||||
oninput={(e) => (code = (e.target as HTMLInputElement).value)}
|
||||
class="mt-1 font-mono tracking-widest"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
A current 2FA verification code is required for this saved-card charge. Ask the customer
|
||||
for their code, or retrieve it from the server log.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<p class="text-sm text-amber-800">
|
||||
Two-factor authentication is required to use online card payments.
|
||||
<a href={resolve('/account')} class="font-medium underline">Enable it in your account settings</a>.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -8,6 +8,7 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import type { Booking } from '$lib/types/booking';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
|
||||
@@ -18,6 +19,7 @@
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
@@ -45,16 +47,38 @@
|
||||
// the checkbox inside CardSelection; defaults to false (opt-in).
|
||||
let saveCard = $state(false);
|
||||
|
||||
// PSD2 SCA stand-in: 2FA required but not enabled blocks saved-card use
|
||||
// and saving new cards for reuse. The new-card (nonce) path has its own
|
||||
// SCA via Square tokenizeWithVerification.
|
||||
const twoFactorBlocksSavedCards = $derived(authStore.twoFactorBlocksSavedCards);
|
||||
// B6/B10: saved-card charges (and saving a new card for reuse) require the
|
||||
// customer's current 2FA verification code whenever the backend enforces the
|
||||
// gate. The input is surfaced at the charge step; the new-card (nonce) path
|
||||
// keeps its own SCA via Square tokenizeWithVerification.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
|
||||
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
|
||||
|
||||
let status = $state<PaymentStatus>('idle');
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// B6/B10: the verification code for a saved-card charge / new-card save. The
|
||||
// backend requires the card owner's CURRENT one-time code when 2FA is
|
||||
// enforced (delivered via the server log / email-SMS channel and relayed by
|
||||
// the operator). Kept populated across retries so an invalid/expired code can
|
||||
// be corrected without re-typing it.
|
||||
let twoFactorCode = $state('');
|
||||
// Set true when a charge 403s for a missing code: the backend keys on the
|
||||
// CARD OWNER, so even a session user whose own flag is unset must be able to
|
||||
// enter the code. Revealing the input makes the failure recoverable.
|
||||
let reveal2FACodeInput = $state(false);
|
||||
|
||||
// Show the code input whenever the pending charge hits the backend's 2FA
|
||||
// gate: charging a saved card OR saving the new card for reuse.
|
||||
const show2FACodeInput = $derived(
|
||||
reveal2FACodeInput || (savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard))
|
||||
);
|
||||
const missing2FACode = $derived(
|
||||
show2FACodeInput && twoFactorEnabled && twoFactorCode.trim() === ''
|
||||
);
|
||||
|
||||
// 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.
|
||||
@@ -163,11 +187,13 @@
|
||||
|
||||
const remainingBalancePence = $derived(Math.round(remainingBalance * 100));
|
||||
|
||||
// Pre-start overpayment confirmation. The backend rejects a payment that
|
||||
// exceeds the booking's remaining balance before the appointment has
|
||||
// started unless the request carries `confirm_overflow_tip: true` — a tip
|
||||
// is gratuity for service already rendered. The frontend caps amounts at
|
||||
// the remaining balance in normal flows, so this fires on STALE booking data
|
||||
// Overpayment confirmation. 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. This guard now applies both before AND after the appointment
|
||||
// has started (B12), so the prompt must fire in both states — it is keyed
|
||||
// purely off the backend's `overflow_tip_confirmation_required` error code,
|
||||
// never off booking state. It typically fires on STALE booking data
|
||||
// (multi-tab, admin-changed totals, refunds that reopened capacity) where
|
||||
// the user would otherwise be stuck with an unresolvable 400. On the guard
|
||||
// firing, the rejected request (amount, type, cached card tokens) is parked
|
||||
@@ -384,9 +410,10 @@
|
||||
let newCardToken: string | undefined;
|
||||
let verificationToken: string | undefined;
|
||||
|
||||
// PSD2 SCA stand-in: never charge a saved card while 2FA is required
|
||||
// but not enabled — fall back to the new-card (nonce) path.
|
||||
if (selectedCardId && !twoFactorBlocksSavedCards) {
|
||||
// Saved-card charge: the card is used directly — B6/B10 requires the
|
||||
// customer's current 2FA verification code (collected in the charge form)
|
||||
// when the backend enforces the gate, but never blocks the selection.
|
||||
if (selectedCardId) {
|
||||
cardId = selectedCardId;
|
||||
} else if (cardSelection) {
|
||||
// New-card mode: tokenize once per attempt WITH SCA verification, then
|
||||
@@ -489,6 +516,7 @@
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(show2FACodeInput ? { verification_code: twoFactorCode } : {}),
|
||||
idempotency_key: payIdempotencyKey
|
||||
})
|
||||
})
|
||||
@@ -539,6 +567,8 @@
|
||||
newCardTokenAmount = 0;
|
||||
newCardTokenizedAt = 0;
|
||||
newCardTokenizedForSaveCard = false;
|
||||
twoFactorCode = '';
|
||||
reveal2FACodeInput = false;
|
||||
paymentResult = {
|
||||
id: data.id,
|
||||
amount: data.amount,
|
||||
@@ -560,6 +590,10 @@
|
||||
// succeed. Surface the fix instead of the generic backend text.
|
||||
const verificationFailure = isSavedCardVerificationRequired(responseStatus, !!cardId);
|
||||
if (verificationFailure) msg = 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 charge can be retried with a fresh code.
|
||||
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) reveal2FACodeInput = true;
|
||||
error = msg;
|
||||
toast.error(verificationFailure ? msg : `${msg}. Please try again or use another card.`);
|
||||
// A definitive charge failure consumes the nonce + SCA verification
|
||||
@@ -576,8 +610,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm the pre-start overpayment: resend the SAME rejected request with
|
||||
// confirm_overflow_tip: true so the excess is recorded as a tip.
|
||||
// Confirm the overpayment: resend the SAME rejected request with
|
||||
// confirm_overflow_tip: true so the excess is recorded as a tip. Works for
|
||||
// both pre-start and post-start overflows (B12).
|
||||
async function confirmOverflowPayment() {
|
||||
const pending = overflowConfirm;
|
||||
if (!pending || status === 'processing') return;
|
||||
@@ -698,10 +733,11 @@
|
||||
|
||||
{#if overflowConfirm}
|
||||
<div class="space-y-4">
|
||||
<!-- 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. -->
|
||||
<!-- 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
|
||||
@@ -958,6 +994,14 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- 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={twoFactorEnabled}
|
||||
/>
|
||||
|
||||
{#if depositPolicyWarning}
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
|
||||
<p class="font-semibold text-amber-900">Cancellation & Deposit Policy</p>
|
||||
@@ -1002,7 +1046,7 @@
|
||||
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
|
||||
class="w-full"
|
||||
loading={status === 'processing'}
|
||||
disabled={payButtonDisabled}
|
||||
disabled={payButtonDisabled || missing2FACode}
|
||||
>
|
||||
{#if paymentType === 'deposit'}
|
||||
Pay Deposit ({formatCurrency(
|
||||
@@ -1083,7 +1127,7 @@
|
||||
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
|
||||
class="w-full"
|
||||
loading={status === 'processing'}
|
||||
disabled={payButtonDisabled}
|
||||
disabled={payButtonDisabled || missing2FACode}
|
||||
>
|
||||
{#if paymentType === 'partial'}
|
||||
Pay {partialAmountValid
|
||||
|
||||
Reference in New Issue
Block a user