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:
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
@@ -13,23 +13,16 @@
|
||||
CARD_VERIFICATION_RETRY_MESSAGE,
|
||||
campaignDiscountPence,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
isVerificationRequiredSignal,
|
||||
PAYMENT_METHOD_SAVED_CARD,
|
||||
runSavedCardSCAProactively,
|
||||
sanitizeDecimalInput,
|
||||
scaFallbackConsentFields,
|
||||
shouldShowSCARefusal,
|
||||
submitPaymentWithRetry,
|
||||
VERIFICATION_REQUIRED_MESSAGE,
|
||||
adminRequestNewTwoFactorCode,
|
||||
requestNewTwoFactorCode
|
||||
VERIFICATION_REQUIRED_MESSAGE
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
|
||||
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
|
||||
@@ -136,16 +129,6 @@
|
||||
selectedMethod = null;
|
||||
}
|
||||
|
||||
// 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 (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);
|
||||
// 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.
|
||||
@@ -154,34 +137,6 @@
|
||||
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
|
||||
let useLoyalty = $state(false);
|
||||
|
||||
// B6/B10: charging a customer's saved card requires the customer's current
|
||||
// 2FA verification code when the backend enforces the gate. Shared
|
||||
// verification-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 twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () =>
|
||||
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === 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: () => {
|
||||
const customerID = booking.user_id ?? booking.user?.id;
|
||||
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
|
||||
}
|
||||
});
|
||||
|
||||
// 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' && twoFactor.showInput) {
|
||||
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
|
||||
@@ -274,30 +229,11 @@
|
||||
|
||||
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> = {};
|
||||
@@ -946,8 +882,6 @@
|
||||
// ccof is never sent — and surface the refusal notice; there
|
||||
// is NO 2FA fallback. The operator taps OK to close, or Back
|
||||
// to pick a different payment method / retry SCA.
|
||||
twoFactor.declineConsent();
|
||||
twoFactor.reveal = false;
|
||||
status = 'saved-card-selecting';
|
||||
return;
|
||||
}
|
||||
@@ -970,16 +904,9 @@
|
||||
// (new_card_token) alongside the saved-card ref — never
|
||||
// the legacy verification_token.
|
||||
...(verificationToken ? { new_card_token: verificationToken } : {}),
|
||||
...(twoFactor.showInput && !verificationToken
|
||||
? { verification_code: twoFactor.code }
|
||||
: {}),
|
||||
...scaFallbackConsentFields(twoFactor.consentAccepted),
|
||||
idempotency_key: savedCardIdempotencyKey
|
||||
})
|
||||
}),
|
||||
// Finding 4: a 2FA-gated charge consumed its code at the backend
|
||||
// gate — a 503 auto-retry would re-send a dead code and self-defeat.
|
||||
{ verificationCodeGated: twoFactor.showInput }
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -1000,10 +927,6 @@
|
||||
payment_method: 'saved_card',
|
||||
saved_card_id: selectedSavedCardId,
|
||||
...(verificationToken ? { new_card_token: verificationToken } : {}),
|
||||
...(twoFactor.showInput && !verificationToken
|
||||
? { verification_code: twoFactor.code }
|
||||
: {}),
|
||||
...scaFallbackConsentFields(twoFactor.consentAccepted),
|
||||
idempotency_key: savedCardIdempotencyKey
|
||||
}
|
||||
};
|
||||
@@ -1037,8 +960,6 @@
|
||||
// charge gets a fresh UUID and can't be deduped against this one.
|
||||
savedCardIdempotencyKey = '';
|
||||
savedCardKeyedAmount = 0;
|
||||
twoFactor.setCode('');
|
||||
twoFactor.reveal = false;
|
||||
toast.success('Saved card payment successful');
|
||||
onComplete(paymentResult);
|
||||
} catch (_err) {
|
||||
@@ -1050,17 +971,11 @@
|
||||
let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
||||
const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
|
||||
if (isVerificationRequiredSignal(responseStatus, bodyText)) {
|
||||
// M13: a verification-required 402 means the backend did NOT
|
||||
// accept the fallback code (SCA-only posture / invalid token) —
|
||||
// withdraw consent so the code input never reappears and the
|
||||
// modal shows the SCA guidance instead of looping on 2FA.
|
||||
twoFactor.declineConsent();
|
||||
// A verification-required 402 means the backend did NOT accept
|
||||
// the token — surface the SCA-first guidance and let the
|
||||
// operator retry.
|
||||
msg = VERIFICATION_REQUIRED_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)) twoFactor.reveal = true;
|
||||
// A DEFINITIVE 402 (declined card / stale token) means the charge did
|
||||
// NOT land — Square's idempotency key would otherwise reject a retry
|
||||
// that re-runs SCA and mints a fresh token. Regenerate the key on 402
|
||||
@@ -1109,7 +1024,7 @@
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-w-lg">
|
||||
<Dialog.Content class="sm:max-w-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
@@ -1149,7 +1064,6 @@
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
tabindex={-1}
|
||||
class="flex h-10 w-24 min-w-0 rounded-md border border-input bg-background px-2 py-1 text-base ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none md:text-sm"
|
||||
value={serviceOverrides[service.service_id]?.price ??
|
||||
service.price?.toFixed(2) ??
|
||||
@@ -1316,7 +1230,7 @@
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
|
||||
class="rounded-lg border py-6 text-center text-sm font-semibold 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 {selectedMethod ===
|
||||
'card'
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-input hover:bg-fuchsia-50'}"
|
||||
@@ -1340,7 +1254,7 @@
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
|
||||
class="rounded-lg border py-6 text-center text-sm font-semibold 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 {selectedMethod ===
|
||||
'cash'
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-input hover:bg-fuchsia-50'}"
|
||||
@@ -1365,7 +1279,7 @@
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
|
||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold 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 sm:block {selectedMethod ===
|
||||
PAYMENT_METHOD_SAVED_CARD
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-input hover:bg-fuchsia-50'}"
|
||||
@@ -1391,7 +1305,7 @@
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
|
||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold 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 sm:block {selectedMethod ===
|
||||
'giftcard'
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-input hover:bg-fuchsia-50'}"
|
||||
@@ -1422,7 +1336,7 @@
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
class="text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="min-h-11 w-full text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => {
|
||||
selectedMethod = PAYMENT_METHOD_SAVED_CARD;
|
||||
status = 'saved-card-selecting';
|
||||
@@ -1434,7 +1348,7 @@
|
||||
<button
|
||||
type="button"
|
||||
disabled={nothingToCharge}
|
||||
class="text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="min-h-11 w-full text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => {
|
||||
selectedMethod = 'giftcard';
|
||||
status = 'gift-entering';
|
||||
@@ -1445,7 +1359,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={handleClose} class="flex-1">Cancel</Button>
|
||||
<Button variant="ghost" onclick={handleClose} class="min-h-11 flex-1">Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if status === 'selecting'}
|
||||
@@ -1473,7 +1387,7 @@
|
||||
{#each tipPercentages as tip (tip.pct)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPercent ===
|
||||
class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none hover:bg-fuchsia-50 {selectedTipPercent ===
|
||||
tip.pct
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-input'}"
|
||||
@@ -1489,7 +1403,6 @@
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
tabindex={-1}
|
||||
placeholder="Custom tip amount"
|
||||
value={customTipAmount}
|
||||
oninput={handleCustomTipInput}
|
||||
@@ -1516,8 +1429,8 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button onclick={handleCardPayment} class="flex-1" disabled={nothingToCharge}>
|
||||
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
|
||||
<Button onclick={handleCardPayment} class="min-h-11 flex-1" disabled={nothingToCharge}>
|
||||
Charge Card
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1545,7 +1458,6 @@
|
||||
id="cash-amount"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
tabindex={-1}
|
||||
value={cashAmount}
|
||||
oninput={handleCashInput}
|
||||
class="pl-7 text-lg font-semibold"
|
||||
@@ -1571,10 +1483,10 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
|
||||
<Button
|
||||
onclick={handleCashPayment}
|
||||
class="flex-1"
|
||||
class="min-h-11 flex-1"
|
||||
disabled={cashAmountNum < totalDue || nothingToCharge}
|
||||
>
|
||||
Confirm Cash
|
||||
@@ -1662,7 +1574,6 @@
|
||||
id="gift-card-id"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
tabindex={-1}
|
||||
value={giftCardId}
|
||||
oninput={handleGiftCardInput}
|
||||
placeholder="XXXX-XXXX-XXXX"
|
||||
@@ -1676,10 +1587,10 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
|
||||
<Button
|
||||
onclick={handleGiftCardPayment}
|
||||
class="flex-1"
|
||||
class="min-h-11 flex-1"
|
||||
disabled={!giftCardValid || nothingToCharge}
|
||||
>
|
||||
Apply Gift Card
|
||||
@@ -1721,7 +1632,7 @@
|
||||
{#each savedCards 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'}"
|
||||
@@ -1781,35 +1692,12 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- B6/B10: saved-card 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}
|
||||
<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"
|
||||
class="min-h-11 w-full"
|
||||
loading={twoFactor.requesting}
|
||||
disabled={twoFactor.requesting}
|
||||
onclick={twoFactor.requestNewCode}
|
||||
>
|
||||
Request a new code
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
|
||||
<Button
|
||||
onclick={handleSavedCardPayment}
|
||||
class="min-h-11 flex-1"
|
||||
disabled={!selectedSavedCardId || nothingToCharge || twoFactor.missing}
|
||||
disabled={!selectedSavedCardId || nothingToCharge}
|
||||
>
|
||||
Charge Saved Card
|
||||
</Button>
|
||||
@@ -1837,8 +1725,8 @@
|
||||
<p class="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={handleClose} class="flex-1">Close</Button>
|
||||
<Button onclick={resetToSelect} class="flex-1">Try Again</Button>
|
||||
<Button variant="ghost" onclick={handleClose} class="min-h-11 flex-1">Close</Button>
|
||||
<Button onclick={resetToSelect} class="min-h-11 flex-1">Try Again</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if status === 'success' && paymentResult}
|
||||
@@ -1884,7 +1772,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onclick={handleSuccessDone} class="w-full">Done</Button>
|
||||
<Button onclick={handleSuccessDone} class="min-h-11 w-full">Done</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
|
||||
Reference in New Issue
Block a user