fix: frontend payment surfaces — SCA wire shapes (explicit token precedence), mock token parity, infinite-loop guard, money display, delete-account re-auth, admin progress UI, mobile touch targets
- new_card_token uses explicit newCardToken ?? verificationToken precedence on every charge surface (BookingFlow, UserPaymentModal, TipPayment, PaymentModal, TillPurchases, account gift-card buy); dead verification_code/consent fields + ScaFallbackConsentDialog removed from payment flows
- mock mints cnon:sca-... tokenize-results and tokenizeWithVerification returns verificationToken:null for new cards (real-SDK parity so save-card works in dev)
- UserPaymentModal infinite /payment-methods fetch loop guarded; formatCurrency(totalPaid) no longer 100x too small
- delete-account dialog collects current_password + fresh 2FA code; admin 'Begin appointment'/'Complete' wired to /admin/bookings/{id}/progress
- mobile: 44px touch targets, active: feedback, TimeSlotPicker 50dvh, dialog close sizing, .no-scrollbar utility, CSP meta, receipt fields escaped
- vitest: policy.ts cross-check + ScaFallbackConsentDialog component tests (svelte project via happy-dom)
This commit is contained in:
@@ -9,26 +9,18 @@
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import {
|
||||
CARD_VERIFICATION_RETRY_MESSAGE,
|
||||
isSquareConfigured,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
runSavedCardSCAProactively,
|
||||
scaFallbackConsentFields,
|
||||
SCA_REFUSAL_MESSAGE_TILL,
|
||||
shouldShowSCARefusal,
|
||||
submitPaymentWithRetry,
|
||||
tokenizeSavedCardWithVerification,
|
||||
adminRequestNewTwoFactorCode,
|
||||
requestNewTwoFactorCode,
|
||||
PAYMENT_METHOD_SAVED_CARD,
|
||||
VERIFICATION_REQUIRED_MESSAGE,
|
||||
type SavedCardVerificationResult
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
|
||||
|
||||
type CartItem = {
|
||||
@@ -127,25 +119,6 @@
|
||||
})
|
||||
);
|
||||
|
||||
// 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) and only gates
|
||||
// customers who have actually ENABLED 2FA (requireTwoFactorForCardAccess:
|
||||
// twoFactorEnforced() && UserTwoFactorEnabled(cardUserID)), so the input is
|
||||
// surfaced only when BOTH hold — mirroring PaymentModal. The customer's
|
||||
// setup flag is not carried by the till customer search, so it is fetched
|
||||
// from GET /api/admin/users/{id} when a customer is selected (see
|
||||
// fetchCustomerTwoFactor). For a 2FA-disabled customer in an enforced
|
||||
// environment the input stays hidden so the charge can be attempted; the
|
||||
// backend then returns the clear "Enable it in your account settings" 403,
|
||||
// which the isTwoFactorVerificationGateFailure self-heal surfaces. Cash,
|
||||
// card machine, and online (new-card nonce) payments are unaffected. Shared
|
||||
// two-factor-code state (code, reveal, show/missing derivations, "Request a
|
||||
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts. The admin
|
||||
// always supplies the CUSTOMER's code — the admin's own 2FA flag is
|
||||
// irrelevant to the backend gate, so `enabled` is always true.
|
||||
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
|
||||
let customerTwoFactorEnabled = $state(false);
|
||||
// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
|
||||
// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
|
||||
// fallback); every other outcome keeps SCA primary for the next retry.
|
||||
@@ -153,18 +126,6 @@
|
||||
// True while the saved-card 3DS challenge is open and the CUSTOMER must
|
||||
// approve it in their banking app — drives the "waiting for approval" panel.
|
||||
let awaitingSCA = $state(false);
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () =>
|
||||
twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === PAYMENT_METHOD_SAVED_CARD,
|
||||
// C6 SCA-only posture: SCA is ALWAYS the authorisation — the code input
|
||||
// only ever surfaces via a backend gate rejection (defensive/opt-in).
|
||||
scaAvailable: () => true,
|
||||
mint: () =>
|
||||
selectedCustomer?.id
|
||||
? adminRequestNewTwoFactorCode(selectedCustomer.id)
|
||||
: requestNewTwoFactorCode()
|
||||
});
|
||||
|
||||
// The saved-card option is hidden outright unless a customer is selected
|
||||
// AND has at least one currently-valid card on file.
|
||||
@@ -216,7 +177,6 @@
|
||||
customerResults = [];
|
||||
showCustomerResults = false;
|
||||
fetchSavedCards(customer.id);
|
||||
fetchCustomerTwoFactor(customer.id);
|
||||
}
|
||||
|
||||
function clearSelectedCustomer() {
|
||||
@@ -226,7 +186,6 @@
|
||||
selectedSavedCardId = null;
|
||||
customerResults = [];
|
||||
showCustomerResults = false;
|
||||
customerTwoFactorEnabled = false;
|
||||
}
|
||||
|
||||
async function fetchSavedCards(userId: string) {
|
||||
@@ -248,25 +207,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// B6/B10: the till customer search (GET /api/admin/users) carries no 2FA
|
||||
// state, so the selected customer's setup flag is fetched from the admin
|
||||
// user detail endpoint — the same source PaymentModal's fetchCustomerTwoFactor
|
||||
// keys on. A failure leaves the flag false; the charge 403 self-heal still
|
||||
// reveals the input.
|
||||
async function fetchCustomerTwoFactor(userId: string) {
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/users/${userId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
customerTwoFactorEnabled = data?.twoFactorEnabled === true;
|
||||
} else {
|
||||
customerTwoFactorEnabled = false;
|
||||
}
|
||||
} catch {
|
||||
customerTwoFactorEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const subtotal = $derived(cart.reduce((sum, item) => sum + item.price * item.qty, 0));
|
||||
const itemCount = $derived(cart.reduce((sum, item) => sum + item.qty, 0));
|
||||
|
||||
@@ -411,8 +351,6 @@
|
||||
// C6: SCA genuinely can't run — abort the whole sale
|
||||
// BEFORE any charge is submitted; the refusal notice
|
||||
// is shown above the Charge button (no 2FA fallback).
|
||||
twoFactor.declineConsent();
|
||||
twoFactor.reveal = false;
|
||||
scaAborted = true;
|
||||
break;
|
||||
}
|
||||
@@ -420,13 +358,6 @@
|
||||
// (new_card_token) alongside the saved-card ref — never
|
||||
// the legacy verification_token.
|
||||
if (sca.verificationToken) body.new_card_token = sca.verificationToken;
|
||||
// B6/B10: the backend requires the CARD OWNER's current 2FA
|
||||
// verification code when the gate is enforced and no SCA
|
||||
// token authorises the charge.
|
||||
if (twoFactor.showInput && !sca.verificationToken) {
|
||||
body.verification_code = twoFactor.code;
|
||||
}
|
||||
Object.assign(body, scaFallbackConsentFields(twoFactor.consentAccepted));
|
||||
} else if (paymentMethod === 'online_square') {
|
||||
if (!onlineSquareCardInput) {
|
||||
throw new Error('Card form is not ready — please wait a moment and try again');
|
||||
@@ -450,20 +381,12 @@
|
||||
if (scaAborted) return;
|
||||
|
||||
for (const body of saleBodies) {
|
||||
const res = await submitPaymentWithRetry(
|
||||
() =>
|
||||
apiFetch('/api/admin/till/sale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}),
|
||||
// Finding 4: a saved-card till line gated on 2FA consumed its
|
||||
// code at the backend gate — a 503 auto-retry would re-send a
|
||||
// dead code and self-defeat.
|
||||
{
|
||||
verificationCodeGated:
|
||||
paymentMethod === PAYMENT_METHOD_SAVED_CARD && twoFactor.showInput
|
||||
}
|
||||
const res = await submitPaymentWithRetry(() =>
|
||||
apiFetch('/api/admin/till/sale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
if (!res.ok) {
|
||||
responseStatus = res.status;
|
||||
@@ -492,8 +415,6 @@
|
||||
toast.success('Sale complete');
|
||||
cart = [];
|
||||
idempotencyKeys.clear();
|
||||
twoFactor.setCode('');
|
||||
twoFactor.reveal = false;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Sale failed';
|
||||
// C6: a sca-unavailable refusal is communicated by the refusal dialog
|
||||
@@ -502,21 +423,6 @@
|
||||
paymentError = null;
|
||||
return;
|
||||
}
|
||||
const bodyText = (err as { bodyText?: string })?.bodyText ?? '';
|
||||
// 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)) twoFactor.reveal = true;
|
||||
// M13: a verification-required rejection means the backend did NOT
|
||||
// accept the fallback code (SCA-only posture / invalid token) —
|
||||
// withdraw consent so the code input never reappears and the error
|
||||
// surfaces clearly instead of looping on 2FA.
|
||||
if (
|
||||
msg === VERIFICATION_REQUIRED_MESSAGE ||
|
||||
isVerificationRequiredSignal(responseStatus, bodyText)
|
||||
) {
|
||||
twoFactor.declineConsent();
|
||||
}
|
||||
paymentError = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
@@ -547,7 +453,6 @@
|
||||
try {
|
||||
if (!squareCardId) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.declineConsent();
|
||||
throw new Error(SCA_REFUSAL_MESSAGE_TILL);
|
||||
}
|
||||
let result: SavedCardVerificationResult;
|
||||
@@ -557,7 +462,6 @@
|
||||
});
|
||||
} catch (err) {
|
||||
lastSCAOutcome = 'sca-unavailable';
|
||||
twoFactor.declineConsent();
|
||||
throw err;
|
||||
}
|
||||
lastSCAOutcome = result.outcome;
|
||||
@@ -581,7 +485,6 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
twoFactor.declineConsent();
|
||||
if (result.outcome === 'sca-unavailable') {
|
||||
throw new Error(SCA_REFUSAL_MESSAGE_TILL);
|
||||
}
|
||||
@@ -884,7 +787,7 @@
|
||||
{#each availablePaymentMethods as m (m.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-3 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
|
||||
class="min-h-11 rounded-lg border py-3 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
|
||||
m.key
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
@@ -933,7 +836,7 @@
|
||||
{#each validCards as card (card.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg border p-3 text-left transition-colors {selectedSavedCardId ===
|
||||
class="w-full rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {selectedSavedCardId ===
|
||||
card.id
|
||||
? 'border-input bg-fuchsia-100'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
@@ -990,26 +893,6 @@
|
||||
paymentError = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- B6/B10: saved-card till charges require the customer's
|
||||
current 2FA verification code when the backend enforces
|
||||
the gate. -->
|
||||
<TwoFactorCodeInput
|
||||
bind:code={twoFactor.code}
|
||||
showInput={twoFactor.showInput}
|
||||
enabled={true}
|
||||
/>
|
||||
{#if twoFactor.showInput}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="min-h-11 w-full"
|
||||
loading={twoFactor.requesting}
|
||||
disabled={twoFactor.requesting}
|
||||
onclick={twoFactor.requestNewCode}
|
||||
>
|
||||
Request a new code
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1041,18 +924,17 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
class="mt-3 min-h-11 w-full"
|
||||
onclick={chargeCart}
|
||||
loading={processing}
|
||||
disabled={!canCharge ||
|
||||
processing ||
|
||||
twoFactor.missing ||
|
||||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
||||
(paymentMethod === PAYMENT_METHOD_SAVED_CARD && !selectedSavedCardId)}
|
||||
>
|
||||
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
|
||||
</Button>
|
||||
<Button
|
||||
class="mt-3 min-h-11 w-full active:bg-primary/85 active:shadow-none"
|
||||
onclick={chargeCart}
|
||||
loading={processing}
|
||||
disabled={!canCharge ||
|
||||
processing ||
|
||||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
||||
(paymentMethod === PAYMENT_METHOD_SAVED_CARD && !selectedSavedCardId)}
|
||||
>
|
||||
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
|
||||
</Button>
|
||||
{/if}
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user