fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs
Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes: - CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back - A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit) - A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds - A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows - A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs - A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point) - A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface - A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction - M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test - Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status) All 25 backend packages pass; frontend 41/41; build + env-docs green.
This commit is contained in:
@@ -8,15 +8,13 @@
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
import TipPayment from '$lib/components/payments/TipPayment.svelte';
|
||||
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
|
||||
import { computeBalanceDue } from '$lib/utils/booking';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
import { canSaveCardsForRole, isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
|
||||
import { canSaveCardsForRole } from '$lib/square/square';
|
||||
interface Props {
|
||||
open: boolean;
|
||||
bookingId: string;
|
||||
@@ -132,210 +130,17 @@
|
||||
let showPaymentModal = $state(false);
|
||||
|
||||
let showTipModal = $state(false);
|
||||
let tipAmount = $state<number>(0);
|
||||
let selectedTipPreset = $state<number | null>(null);
|
||||
let customTipInput = $state('');
|
||||
let tipProcessing = $state(false);
|
||||
|
||||
// Cached idempotency key: generated once per tip attempt, reused on retry
|
||||
// (so a network-timeout retry dedupes instead of double-charging), cleared on
|
||||
// success. Reset when the tip amount changes so an amount change after a
|
||||
// failed attempt gets a fresh key instead of a false dedup (under-charge).
|
||||
let tipIdempotencyKey = $state('');
|
||||
let tipKeyedAmount = $state(0);
|
||||
let tipKeyedCard = $state('');
|
||||
|
||||
// Card selection for tips — delegated to CardSelection.svelte.
|
||||
let tipSavedCards = $state<SavedCard[]>([]);
|
||||
let tipLoadingCards = $state(false);
|
||||
let tipCardSelection = $state<CardSelection | null>(null);
|
||||
let tipSelectedCardId = $state('');
|
||||
let tipCardSelectionValid = $state(false);
|
||||
let tipSaveCard = $state(false);
|
||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||
// of re-tokenizing (the backend idempotency key dedups).
|
||||
let tipNonce = $state('');
|
||||
// Cached SCA verification token paired with tipNonce (both one-shot, reused
|
||||
// together on retry). The verification token is amount-bound, so changing
|
||||
// the tip invalidates the cached pair.
|
||||
let tipVerificationToken = $state('');
|
||||
let tipTokenAmount = $state(0);
|
||||
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
|
||||
// verification tokens expire after ~5 minutes, so a stale pair is discarded
|
||||
// on late retries and re-tokenized instead of rejected by Square.
|
||||
let tipTokenizedAt = $state(0);
|
||||
// Save intent at tokenization time: the SCA verification token is bound to
|
||||
// CHARGE vs CHARGE_AND_STORE, so toggling the save-card checkbox after a
|
||||
// tokenize must force a fresh tokenization rather than reuse a token minted
|
||||
// with the wrong intent.
|
||||
let tipTokenizedForSaveCard = $state(false);
|
||||
|
||||
// Tip payment is delegated to the shared TipPayment component (see the tip
|
||||
// modal below) so the preset/custom-amount selection, 2FA gating, SCA
|
||||
// verification-token handling, idempotency-key derivation and retry logic
|
||||
// all live in ONE place instead of diverging between this modal and the
|
||||
// /tip and /pay-tip/[id] pages.
|
||||
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
|
||||
|
||||
const isTipCardValid = $derived(tipCardSelectionValid);
|
||||
|
||||
const tipPresets = $derived(
|
||||
selectedBooking
|
||||
? [
|
||||
{ pct: 10, amount: Math.round(selectedBooking.total_amount * 0.1 * 100) / 100 },
|
||||
{ pct: 15, amount: Math.round(selectedBooking.total_amount * 0.15 * 100) / 100 },
|
||||
{ pct: 20, amount: Math.round(selectedBooking.total_amount * 0.2 * 100) / 100 }
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
function selectTipPreset(amount: number) {
|
||||
selectedTipPreset = amount;
|
||||
customTipInput = '';
|
||||
tipAmount = amount;
|
||||
}
|
||||
|
||||
function handleCustomTip(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const cleaned = input.value.replace(/[^0-9.]/g, '');
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
let sanitized: string;
|
||||
if (firstDot !== -1) {
|
||||
const integerPart = cleaned.substring(0, firstDot);
|
||||
const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, '');
|
||||
sanitized = integerPart + '.' + decimalPart;
|
||||
} else {
|
||||
sanitized = cleaned;
|
||||
}
|
||||
if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
|
||||
customTipInput = sanitized;
|
||||
selectedTipPreset = null;
|
||||
tipAmount = parseFloat(sanitized) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTipSavedCards() {
|
||||
if (savedCardsStore.loaded) {
|
||||
tipSavedCards = savedCardsStore.cards;
|
||||
if (tipSavedCards.length > 0 && !tipSelectedCardId) {
|
||||
tipSelectedCardId = tipSavedCards.find((c) => c.is_default)?.id || tipSavedCards[0].id;
|
||||
}
|
||||
return;
|
||||
}
|
||||
tipLoadingCards = true;
|
||||
try {
|
||||
await savedCardsStore.fetch();
|
||||
tipSavedCards = savedCardsStore.cards;
|
||||
if (tipSavedCards.length > 0 && !tipSelectedCardId) {
|
||||
tipSelectedCardId = tipSavedCards.find((c) => c.is_default)?.id || tipSavedCards[0].id;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
tipLoadingCards = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTip() {
|
||||
if (!selectedBooking) return;
|
||||
if (tipAmount <= 0) {
|
||||
toast.error('Please select a tip amount');
|
||||
return;
|
||||
}
|
||||
|
||||
let newCardToken: string | undefined;
|
||||
let verificationToken: string | undefined;
|
||||
if (tipSelectedCardId) {
|
||||
// saved card — nothing to tokenize
|
||||
} else if (tipCardSelection) {
|
||||
// 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 !== tipSaveCard ||
|
||||
isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await tipCardSelection.tokenizeWithVerification(
|
||||
Math.round(tipAmount * 100),
|
||||
{
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
},
|
||||
tipSaveCard
|
||||
);
|
||||
tipNonce = tokenized.nonce;
|
||||
tipVerificationToken = tokenized.verificationToken ?? '';
|
||||
tipTokenAmount = tipAmount;
|
||||
tipTokenizedAt = Date.now();
|
||||
tipTokenizedForSaveCard = tipSaveCard;
|
||||
} 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;
|
||||
}
|
||||
|
||||
tipProcessing = true;
|
||||
|
||||
try {
|
||||
const bookingId = selectedBooking.id;
|
||||
// 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 = tipSelectedCardId || 'new-card';
|
||||
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
|
||||
tipIdempotencyKey = crypto.randomUUID();
|
||||
tipKeyedAmount = tipAmount;
|
||||
tipKeyedCard = cardKey;
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
amount: Math.round(tipAmount * 100),
|
||||
idempotency_key: tipIdempotencyKey,
|
||||
...(tipSelectedCardId ? { card_id: tipSelectedCardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: tipSaveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {})
|
||||
};
|
||||
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${bookingId}/tip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Tip payment failed');
|
||||
}
|
||||
toast.success('Thank you for your tip!');
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipKeyedCard = '';
|
||||
tipNonce = '';
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
showTipModal = false;
|
||||
fetchBookingDetails();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Tip payment failed');
|
||||
// A definitive charge failure consumes the nonce + SCA verification
|
||||
// token — clear the cached pair so retries re-tokenize fresh. The
|
||||
// idempotency key stays for network-timeout dedup.
|
||||
tipNonce = '';
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
} finally {
|
||||
tipProcessing = false;
|
||||
}
|
||||
function handleTipSuccess() {
|
||||
showTipModal = false;
|
||||
fetchBookingDetails();
|
||||
}
|
||||
|
||||
function handlePaymentComplete() {
|
||||
@@ -392,12 +197,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (showTipModal) {
|
||||
loadTipSavedCards();
|
||||
}
|
||||
});
|
||||
|
||||
function printReceipt() {
|
||||
if (!selectedBooking) {
|
||||
toast.error('No booking data to print');
|
||||
@@ -1155,103 +954,23 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<Modal.Root
|
||||
open={showTipModal}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) {
|
||||
showTipModal = false;
|
||||
tipAmount = 0;
|
||||
selectedTipPreset = null;
|
||||
customTipInput = '';
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipKeyedCard = '';
|
||||
tipSelectedCardId = '';
|
||||
tipSaveCard = false;
|
||||
tipNonce = '';
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Modal.Content class="max-w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Leave a Tip</Modal.Title>
|
||||
<Modal.Description>Show your appreciation for great service</Modal.Description>
|
||||
</Modal.Header>
|
||||
{#if showTipModal && selectedBooking}
|
||||
<Modal.Root
|
||||
open={showTipModal}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) showTipModal = false;
|
||||
}}
|
||||
>
|
||||
<Modal.Content class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md">
|
||||
<Modal.Header>
|
||||
<Modal.Title>Leave a Tip</Modal.Title>
|
||||
<Modal.Description>Show your appreciation for great service</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-4 px-4 pb-4">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{#each tipPresets as preset (preset.pct)}
|
||||
<button
|
||||
class="rounded-lg border border-input bg-background py-3 text-center font-semibold transition-colors hover:bg-fuchsia-50 {selectedTipPreset ===
|
||||
preset.amount
|
||||
? 'bg-fuchsia-100'
|
||||
: ''}"
|
||||
onclick={() => selectTipPreset(preset.amount)}
|
||||
type="button"
|
||||
>
|
||||
<div>£{preset.amount.toFixed(2)}</div>
|
||||
<div class="text-xs font-normal text-gray-500">{preset.pct}%</div>
|
||||
</button>
|
||||
{/each}
|
||||
<div class="px-4 pb-4">
|
||||
<!-- Shared with /tip and /pay-tip/[id] so preset/custom/2FA/SCA/retry logic can't diverge. -->
|
||||
<TipPayment booking={selectedBooking} onSuccess={handleTipSuccess} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="custom-tip" class="text-sm font-medium text-gray-700"
|
||||
>Or enter custom amount</label
|
||||
>
|
||||
<div class="relative mt-1">
|
||||
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
|
||||
<Input
|
||||
id="custom-tip"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
class="pl-7"
|
||||
value={customTipInput}
|
||||
oninput={handleCustomTip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Selection for Tip -->
|
||||
<div class="space-y-3">
|
||||
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
|
||||
>Payment Method</span
|
||||
>
|
||||
|
||||
{#if tipLoadingCards}
|
||||
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
|
||||
{:else}
|
||||
<CardSelection
|
||||
bind:this={tipCardSelection}
|
||||
cards={tipSavedCards}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={tipSelectedCardId}
|
||||
bind:saveCard={tipSaveCard}
|
||||
onValidityChange={(v) => (tipCardSelectionValid = v)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant="outline" onclick={() => (showTipModal = false)}>Cancel</Button>
|
||||
<Button
|
||||
class="hover:bg-fuchsia-50"
|
||||
onclick={submitTip}
|
||||
disabled={tipAmount <= 0 || !isTipCardValid || tipProcessing}
|
||||
loading={tipProcessing}
|
||||
>
|
||||
{tipProcessing ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
{/if}
|
||||
|
||||
@@ -168,29 +168,14 @@
|
||||
let onlineSquareCardInput = $state<SquareCardInput | null>(null);
|
||||
let onlineSquareProcessing = $state(false);
|
||||
|
||||
// Idempotency Key
|
||||
let idempotencyKey = $state('');
|
||||
|
||||
function getIdempotencyKey(): string {
|
||||
if (!idempotencyKey) {
|
||||
const array = new Uint8Array(16);
|
||||
if (typeof window !== 'undefined' && window.crypto) {
|
||||
window.crypto.getRandomValues(array);
|
||||
} else {
|
||||
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
array[6] = (array[6] & 0x0f) | 0x40;
|
||||
array[8] = (array[8] & 0x3f) | 0x80;
|
||||
idempotencyKey = [...array]
|
||||
.map((b, i) => {
|
||||
const hex = b.toString(16).padStart(2, '0');
|
||||
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
|
||||
return hex;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
return idempotencyKey;
|
||||
}
|
||||
// Idempotency: the client deliberately sends NO idempotency_key. The backend
|
||||
// (CreateTillSale) derives a DETERMINISTIC key server-side from the canonical
|
||||
// request fields (action + admin + amount + gift-card/user/card targets),
|
||||
// so a lost-response retry with the SAME amount reuses the same key and the
|
||||
// pending till_sale row (dedup — no double charge), while ANY amount change
|
||||
// derives a fresh key. A client-generated key cached for the modal's lifetime
|
||||
// (as before) is keyed by nothing and would be reused across an amount change
|
||||
// after a failed attempt, making Square return the PRIOR request's result.
|
||||
|
||||
let topUpStep = $state<
|
||||
'choice' | 'amount' | 'payment' | 'cash_entry' | 'processing' | 'success' | 'error'
|
||||
@@ -530,7 +515,6 @@
|
||||
paymentError = '';
|
||||
paymentResult = null;
|
||||
cardMachineItemID = null;
|
||||
idempotencyKey = '';
|
||||
onlineSquareAction = null;
|
||||
onlineSquareProcessing = false;
|
||||
}
|
||||
@@ -557,8 +541,7 @@
|
||||
item_type: 'gift_card',
|
||||
action: actionType,
|
||||
amount: amt,
|
||||
payment_method: 'cash',
|
||||
idempotency_key: getIdempotencyKey()
|
||||
payment_method: 'cash'
|
||||
};
|
||||
if (gcId) body.gift_card_id = gcId;
|
||||
if (selectedCustomer) body.user_id = selectedCustomer.id;
|
||||
@@ -596,8 +579,7 @@
|
||||
item_type: 'gift_card',
|
||||
action: actionType,
|
||||
amount: amt,
|
||||
payment_method: 'card_machine',
|
||||
idempotency_key: getIdempotencyKey()
|
||||
payment_method: 'card_machine'
|
||||
};
|
||||
if (gcId) body.gift_card_id = gcId;
|
||||
if (selectedCustomer) body.user_id = selectedCustomer.id;
|
||||
@@ -695,8 +677,7 @@
|
||||
action: actionType,
|
||||
amount: amt,
|
||||
payment_method: 'online_square',
|
||||
card_token: token,
|
||||
idempotency_key: getIdempotencyKey()
|
||||
card_token: token
|
||||
};
|
||||
if (verificationToken) body.verification_token = verificationToken;
|
||||
if (gcId) body.gift_card_id = gcId;
|
||||
@@ -740,8 +721,7 @@
|
||||
action: 'topup',
|
||||
amount: Number(topUpAmount),
|
||||
payment_method: 'on_the_house',
|
||||
gift_card_id: gcId,
|
||||
idempotency_key: getIdempotencyKey()
|
||||
gift_card_id: gcId
|
||||
};
|
||||
|
||||
const res = await apiFetch('/api/admin/till/sale', {
|
||||
|
||||
@@ -453,17 +453,14 @@
|
||||
</details>
|
||||
<label class="mt-2 flex cursor-pointer items-center gap-2">
|
||||
<Checkbox bind:checked={forgiveFees} />
|
||||
<span class="text-xs">Forgive fees — refund fully (overrides deposit protection)</span
|
||||
>
|
||||
<span class="text-xs">Forgive cancellation fees — full refund</span>
|
||||
</label>
|
||||
<details class="ml-6 text-xs text-gray-500">
|
||||
<summary class="cursor-pointer hover:text-gray-700"
|
||||
>What happens with forgiveness</summary
|
||||
>
|
||||
<summary class="cursor-pointer hover:text-gray-700">When to use this</summary>
|
||||
<p class="mt-1">
|
||||
"We've waived deposit protection on this reschedule as a goodwill gesture. The full
|
||||
amount moves to the new appointment instead of having up to 50% retained as
|
||||
deposit."
|
||||
Use for genuinely excusable cancellations, or when the salon cancels and chooses
|
||||
not to keep the money. When unchecked, standard notice-period fees apply (e.g. a
|
||||
customer who calls up to cancel).
|
||||
</p>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
@@ -11,19 +11,28 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { onMount } from 'svelte';
|
||||
import { canSaveCardsForRole, isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
|
||||
import {
|
||||
canSaveCardsForRole,
|
||||
isNonceStale,
|
||||
isSavedCardVerificationRequired,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
|
||||
// Shared tip-payment UI used by /tip and /pay-tip/[id]. The routes resolve
|
||||
// the booking (most-recent past booking vs. booking by URL id) and hand it
|
||||
// here; everything else — tip selection, CardSelection wiring, nonce + SCA
|
||||
// verification caching, idempotency-key derivation, submitTip, success and
|
||||
// error handling — lives in ONE place so the two pages can't diverge.
|
||||
// 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
|
||||
// booking vs. booking by URL id) and hand it here; everything else — tip
|
||||
// selection, CardSelection wiring, nonce + SCA verification caching,
|
||||
// idempotency-key derivation, submitTip, success and error handling — lives
|
||||
// in ONE place so the three surfaces can't diverge. When embedded in a modal
|
||||
// (UserBookingModal), `onSuccess` lets the host close itself and refresh
|
||||
// instead of navigating home; standalone pages omit it.
|
||||
type BookingService = {
|
||||
service_id: string;
|
||||
booking_id: string;
|
||||
service_name: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
service_name?: string;
|
||||
price?: number;
|
||||
duration_minutes?: number;
|
||||
override_price?: number;
|
||||
override_duration_minutes?: number;
|
||||
};
|
||||
@@ -40,13 +49,13 @@
|
||||
type Booking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
services: BookingService[];
|
||||
services?: BookingService[];
|
||||
total_amount: number;
|
||||
duration_minutes: number;
|
||||
payments?: Payment[];
|
||||
};
|
||||
|
||||
const { booking }: { booking: Booking } = $props();
|
||||
const { booking, onSuccess }: { booking: Booking; onSuccess?: () => void } = $props();
|
||||
|
||||
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||
// Synchronous double-click guard for submitTip. paymentState is only set to
|
||||
@@ -272,6 +281,9 @@
|
||||
|
||||
paymentState = 'processing';
|
||||
|
||||
const usedSavedCard = !!(selectedCardId && !twoFactorBlocksSavedCards);
|
||||
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
|
||||
@@ -301,6 +313,7 @@
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
responseStatus = response.status;
|
||||
const errorText = await response.text();
|
||||
throw new Error(extractErrorMessage(errorText) || 'Payment failed');
|
||||
}
|
||||
@@ -315,9 +328,17 @@
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
toast.success('Thank you for your tip!');
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
paymentState = 'error';
|
||||
const errorMessage = err instanceof Error ? err.message : 'Payment failed';
|
||||
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;
|
||||
}
|
||||
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
|
||||
@@ -357,8 +378,12 @@
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">Thank you!</h2>
|
||||
<p class="mt-2 text-gray-600">Your generosity is greatly appreciated.</p>
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<Button class="mt-6" onclick={() => goto('/')}>Go Home</Button>
|
||||
{#if onSuccess}
|
||||
<Button class="mt-6" onclick={onSuccess}>Done</Button>
|
||||
{:else}
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<Button class="mt-6" onclick={() => goto('/')}>Go Home</Button>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
@@ -376,7 +401,7 @@
|
||||
<span class="font-medium"
|
||||
>{formatTimeRange(
|
||||
booking.start_time,
|
||||
booking.services,
|
||||
booking.services ?? [],
|
||||
booking.duration_minutes ?? 0
|
||||
)}</span
|
||||
>
|
||||
@@ -397,9 +422,9 @@
|
||||
<div class="mt-2 space-y-1">
|
||||
{#each booking.services as service (service.service_id || service.booking_id)}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-700">{service.service_name}</span>
|
||||
<span class="text-gray-700">{service.service_name || '—'}</span>
|
||||
<span class="text-gray-500"
|
||||
>{formatPrice(service.override_price ?? service.price)}</span
|
||||
>{formatPrice(service.override_price ?? service.price ?? 0)}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -12,7 +12,13 @@
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
|
||||
import {
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
|
||||
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||
|
||||
@@ -136,6 +142,42 @@
|
||||
|
||||
const amountRemaining = $derived(booking.total_amount - totalPaid);
|
||||
|
||||
// Mirror of the backend's GetBookingRemainingBalanceCents (see
|
||||
// backend/handlers/payments/service.go): total − completed non-tip payments
|
||||
// + completed refunds, clamped to the booking total and floored at 0. The
|
||||
// backend rejects an unconfirmed pre-start overpayment when req.Amount >
|
||||
// this value, and records the excess (req.Amount − remainingCents) as a tip
|
||||
// once confirmed — so the overflow-confirmation prompt shows exactly that.
|
||||
const remainingBalanceCents = $derived.by(() => {
|
||||
const total = booking.total_amount ?? 0;
|
||||
const paid = (booking.payments ?? [])
|
||||
.filter((p) => p.status === 'completed' && p.payment_type !== 'tip')
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
const refunded = (booking.refunds ?? [])
|
||||
.filter((r) => r.status === 'completed')
|
||||
.reduce((sum, r) => sum + r.amount, 0);
|
||||
return Math.round(Math.max(0, Math.min(total - paid + refunded, total)) * 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
|
||||
// amountRemaining in normal flows, so this 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
|
||||
// here and a Confirm/Cancel prompt is shown; Confirm resends the SAME
|
||||
// request with the flag, Cancel returns to the amount-editing form.
|
||||
let overflowConfirm = $state<{
|
||||
amountCents: number;
|
||||
paymentType: string;
|
||||
overflowCents: number;
|
||||
cardId?: string;
|
||||
newCardToken?: string;
|
||||
verificationToken?: string;
|
||||
} | null>(null);
|
||||
|
||||
const loyaltyEligible = $derived(
|
||||
stamps >= 10 &&
|
||||
!(booking.discounts ?? []).some((d) => d.discount_source === 'loyalty') &&
|
||||
@@ -445,6 +487,25 @@
|
||||
payKeyedCard = cardKey;
|
||||
}
|
||||
|
||||
await submitBookingPayment(paymentType, amountCents, cardId, newCardToken, verificationToken, false);
|
||||
}
|
||||
|
||||
// Submits a booking-payment request and processes the outcome. Shared by
|
||||
// the initial attempt and the overflow-tip confirm resend so both use the
|
||||
// exact same success/error handling. `confirmOverflowTip` adds the backend's
|
||||
// opt-in flag for a pre-start overpayment; the resend reuses the SAME
|
||||
// cached nonce/verification token/idempotency key as the rejected attempt
|
||||
// (the guard fired before any Square call, so the tokens are unconsumed and
|
||||
// the key is still the correct dedup identity for this amount+type+card).
|
||||
async function submitBookingPayment(
|
||||
paymentType: string,
|
||||
amountCents: number,
|
||||
cardId: string | undefined,
|
||||
newCardToken: string | undefined,
|
||||
verificationToken: string | undefined,
|
||||
confirmOverflowTip: boolean
|
||||
): Promise<void> {
|
||||
let responseStatus = 0;
|
||||
try {
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${booking.id}/payment`, {
|
||||
@@ -453,6 +514,7 @@
|
||||
body: JSON.stringify({
|
||||
amount: amountCents,
|
||||
payment_type: paymentType,
|
||||
...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
@@ -462,13 +524,32 @@
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
responseStatus = response.status;
|
||||
const errData = await response.text();
|
||||
// Pre-start overpayment on stale booking data: park the rejected
|
||||
// request (amount, type, cached tokens) and surface the
|
||||
// Confirm/Cancel prompt instead of a dead-end 400. The cached
|
||||
// nonce + SCA verification token + idempotency key are NOT
|
||||
// cleared here — the confirm resend is the same logical charge.
|
||||
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(errData)) {
|
||||
overflowConfirm = {
|
||||
amountCents,
|
||||
paymentType,
|
||||
overflowCents: Math.max(0, amountCents - remainingBalanceCents),
|
||||
cardId,
|
||||
newCardToken,
|
||||
verificationToken
|
||||
};
|
||||
status = 'idle';
|
||||
return;
|
||||
}
|
||||
throw new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Payment is synchronous (completed immediately)
|
||||
status = 'success';
|
||||
overflowConfirm = null;
|
||||
payIdempotencyKey = '';
|
||||
payKeyedAmount = 0;
|
||||
payKeyedType = '';
|
||||
@@ -491,9 +572,16 @@
|
||||
releaseLock();
|
||||
} catch (_err) {
|
||||
status = 'error';
|
||||
const msg = _err instanceof Error ? _err.message : 'Payment declined';
|
||||
overflowConfirm = null;
|
||||
let msg = _err instanceof Error ? _err.message : 'Payment declined';
|
||||
// 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.
|
||||
const verificationFailure = isSavedCardVerificationRequired(responseStatus, !!cardId);
|
||||
if (verificationFailure) msg = SAVED_CARD_VERIFICATION_MESSAGE;
|
||||
error = msg;
|
||||
toast.error(`${msg}. Please try again or use another card.`);
|
||||
toast.error(verificationFailure ? msg : `${msg}. Please try again or use another card.`);
|
||||
// A definitive charge failure consumes the nonce + SCA verification
|
||||
// token — clear the cached pair so retries re-tokenize fresh. The
|
||||
// idempotency key stays for network-timeout dedup. CardSelection stays
|
||||
@@ -508,6 +596,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm the pre-start overpayment: resend the SAME rejected request with
|
||||
// confirm_overflow_tip: true so the excess is recorded as a tip.
|
||||
async function confirmOverflowPayment() {
|
||||
const pending = overflowConfirm;
|
||||
if (!pending || status === 'processing') return;
|
||||
status = 'processing';
|
||||
error = null;
|
||||
await submitBookingPayment(
|
||||
pending.paymentType,
|
||||
pending.amountCents,
|
||||
pending.cardId,
|
||||
pending.newCardToken,
|
||||
pending.verificationToken,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// Revert to the amount-editing form. The cached nonce/tokens/idempotency key
|
||||
// stay: a resubmit with the SAME amount+type+card reuses them (no charge was
|
||||
// made — the guard fired before Square), and a changed amount forces a fresh
|
||||
// tokenization + key.
|
||||
function cancelOverflowConfirmation() {
|
||||
overflowConfirm = null;
|
||||
status = 'idle';
|
||||
}
|
||||
|
||||
function handlePayDeposit() {
|
||||
const depositCents = booking.deposit_amount
|
||||
? Math.round(booking.deposit_amount * 100)
|
||||
@@ -585,7 +699,63 @@
|
||||
{/if}
|
||||
</Dialog.Header>
|
||||
|
||||
{#if status === 'idle' || status === 'processing' || status === 'error'}
|
||||
{#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. -->
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 shrink-0 text-amber-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
|
||||
<p class="mt-1 text-sm text-amber-800">
|
||||
The balance for this booking has changed since it was last loaded. The extra
|
||||
{formatCurrency(overflowConfirm.overflowCents)} will be recorded as a tip. Confirm
|
||||
to continue?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button
|
||||
class="flex-1"
|
||||
loading={status === 'processing'}
|
||||
disabled={status === 'processing'}
|
||||
onclick={confirmOverflowPayment}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
disabled={status === 'processing'}
|
||||
onclick={cancelOverflowConfirmation}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={handleClose}
|
||||
class="w-full"
|
||||
disabled={status === 'processing'}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
{:else if status === 'idle' || status === 'processing' || status === 'error'}
|
||||
<div class="space-y-4">
|
||||
<!-- Payment lock countdown banner — only for pending_release (vulnerable slot) -->
|
||||
{#if booking.status === 'pending_release' && lockAcquired && lockTimer > 0}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
NONCE_STALENESS_MS,
|
||||
OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE,
|
||||
PAYMENT_AMBIGUOUS_STATUS,
|
||||
PAYMENT_DEFINITIVE_STATUS,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
canSaveCardsForRole,
|
||||
isAmbiguousPaymentFailure,
|
||||
isNonceStale,
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
submitPaymentWithRetry
|
||||
} from './square';
|
||||
import type * as SquareModule from './square';
|
||||
|
||||
describe('isNonceStale', () => {
|
||||
const tokenizedFor = 2500;
|
||||
|
||||
it('is false for a fresh nonce (under the staleness limit)', () => {
|
||||
const tokenizedAt = 1_000_000;
|
||||
const now = tokenizedAt + NONCE_STALENESS_MS - 1;
|
||||
expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false exactly at the staleness boundary (limit is exclusive, > not >=)', () => {
|
||||
const tokenizedAt = 1_000_000;
|
||||
const now = tokenizedAt + NONCE_STALENESS_MS;
|
||||
expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true just past the staleness boundary', () => {
|
||||
const tokenizedAt = 1_000_000;
|
||||
const now = tokenizedAt + NONCE_STALENESS_MS + 1;
|
||||
expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(true);
|
||||
});
|
||||
|
||||
it('is true when the nonce was tokenized for a different amount than now', () => {
|
||||
expect(isNonceStale(1_000_000, tokenizedFor, tokenizedFor + 1, Date.now())).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for a very old nonce', () => {
|
||||
expect(isNonceStale(1, tokenizedFor, tokenizedFor, Date.now())).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults `now` to Date.now() when omitted', () => {
|
||||
expect(isNonceStale(Date.now(), tokenizedFor, tokenizedFor)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSquareMock / isSquareConfigured / getSquareConfig', () => {
|
||||
type SquareEnv = {
|
||||
VITE_SQUARE_ENVIRONMENT?: string;
|
||||
VITE_SQUARE_APPLICATION_ID?: string;
|
||||
VITE_SQUARE_LOCATION_ID?: string;
|
||||
DEV?: boolean;
|
||||
};
|
||||
|
||||
// square.ts captures APP_ID/LOCATION_ID/SQUARE_ENV at module load, so each
|
||||
// env combination must reload the module with vi.resetModules + vi.stubEnv.
|
||||
// All three VITE_* keys are stubbed explicitly ('' when omitted) so local
|
||||
// .env files can never leak into the suite.
|
||||
async function loadSquareWithEnv(env: SquareEnv): Promise<typeof SquareModule> {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('VITE_SQUARE_ENVIRONMENT', env.VITE_SQUARE_ENVIRONMENT ?? '');
|
||||
vi.stubEnv('VITE_SQUARE_APPLICATION_ID', env.VITE_SQUARE_APPLICATION_ID ?? '');
|
||||
vi.stubEnv('VITE_SQUARE_LOCATION_ID', env.VITE_SQUARE_LOCATION_ID ?? '');
|
||||
if (env.DEV !== undefined) {
|
||||
vi.stubEnv('DEV', env.DEV);
|
||||
}
|
||||
return await import('./square');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('mock env in a dev build → isSquareMock true, isSquareConfigured true', async () => {
|
||||
const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock', DEV: true });
|
||||
expect(mod.isSquareMock()).toBe(true);
|
||||
expect(mod.isSquareConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('mock env in a production build → isSquareMock false (DEV gate)', async () => {
|
||||
const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock', DEV: false });
|
||||
expect(mod.isSquareMock()).toBe(false);
|
||||
});
|
||||
|
||||
it('mock env without credentials → isSquareConfigured true (mock form needs no keys)', async () => {
|
||||
const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock' });
|
||||
expect(mod.isSquareConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('sandbox env with credentials → not mock, configured, config returned', async () => {
|
||||
const mod = await loadSquareWithEnv({
|
||||
VITE_SQUARE_ENVIRONMENT: 'sandbox',
|
||||
VITE_SQUARE_APPLICATION_ID: 'sandbox-sq0idb-abc123',
|
||||
VITE_SQUARE_LOCATION_ID: 'L0MOCK123'
|
||||
});
|
||||
expect(mod.isSquareMock()).toBe(false);
|
||||
expect(mod.isSquareConfigured()).toBe(true);
|
||||
expect(mod.getSquareConfig()).toEqual({
|
||||
appId: 'sandbox-sq0idb-abc123',
|
||||
locationId: 'L0MOCK123'
|
||||
});
|
||||
});
|
||||
|
||||
it('sandbox env without credentials → not configured, config null', async () => {
|
||||
const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'sandbox' });
|
||||
expect(mod.isSquareMock()).toBe(false);
|
||||
expect(mod.isSquareConfigured()).toBe(false);
|
||||
expect(mod.getSquareConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('empty env → neither mock nor configured', async () => {
|
||||
const mod = await loadSquareWithEnv({});
|
||||
expect(mod.isSquareMock()).toBe(false);
|
||||
expect(mod.isSquareConfigured()).toBe(false);
|
||||
expect(mod.getSquareConfig()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canSaveCardsForRole', () => {
|
||||
it.each([
|
||||
['admin', true],
|
||||
['verified_email', true],
|
||||
['unverified_email', false],
|
||||
['guest', false],
|
||||
['affiliate', false],
|
||||
['user', false],
|
||||
['', false],
|
||||
[undefined, false]
|
||||
])('role %s → %s', (role, expected) => {
|
||||
expect(canSaveCardsForRole(role)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('payment failure classification', () => {
|
||||
it('PAYMENT_DEFINITIVE_STATUS is 402', () => {
|
||||
expect(PAYMENT_DEFINITIVE_STATUS).toBe(402);
|
||||
});
|
||||
|
||||
it('PAYMENT_AMBIGUOUS_STATUS is 503', () => {
|
||||
expect(PAYMENT_AMBIGUOUS_STATUS).toBe(503);
|
||||
});
|
||||
|
||||
it('isAmbiguousPaymentFailure matches only 503', () => {
|
||||
expect(isAmbiguousPaymentFailure(503)).toBe(true);
|
||||
expect(isAmbiguousPaymentFailure(402)).toBe(false);
|
||||
expect(isAmbiguousPaymentFailure(200)).toBe(false);
|
||||
expect(isAmbiguousPaymentFailure(500)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, 402, true],
|
||||
[false, 402, false],
|
||||
[true, 503, false],
|
||||
[true, 200, false],
|
||||
[false, 200, false]
|
||||
])('isSavedCardVerificationRequired(%s, %d) → %s', (usedSavedCard, status, expected) => {
|
||||
expect(isSavedCardVerificationRequired(status, usedSavedCard)).toBe(expected);
|
||||
});
|
||||
|
||||
it('SAVED_CARD_VERIFICATION_MESSAGE is non-empty and mentions verification', () => {
|
||||
expect(SAVED_CARD_VERIFICATION_MESSAGE.length).toBeGreaterThan(0);
|
||||
expect(SAVED_CARD_VERIFICATION_MESSAGE.toLowerCase()).toContain('verification');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOverflowTipConfirmationRequired', () => {
|
||||
it('matches the backend overflow-guard error body by its code', () => {
|
||||
const body = JSON.stringify({
|
||||
error: 'The extra amount will be recorded as a tip. Confirm to continue.',
|
||||
code: OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE
|
||||
});
|
||||
expect(isOverflowTipConfirmationRequired(body)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a 400 body with a different code', () => {
|
||||
expect(
|
||||
isOverflowTipConfirmationRequired(JSON.stringify({ error: 'Bad amount', code: 'invalid_amount' }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a body with only the error text and no code', () => {
|
||||
expect(isOverflowTipConfirmationRequired('The extra amount will be recorded as a tip.')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('is false for a non-JSON body', () => {
|
||||
expect(isOverflowTipConfirmationRequired('Payment declined')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for an empty body', () => {
|
||||
expect(isOverflowTipConfirmationRequired('')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the code is not an exact match (guards against prefix drift)', () => {
|
||||
expect(
|
||||
isOverflowTipConfirmationRequired(
|
||||
JSON.stringify({ error: 'x', code: 'overflow_tip_confirmation_required_extra' })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitPaymentWithRetry', () => {
|
||||
function jsonResponse(status: number): Response {
|
||||
return new Response(null, { status });
|
||||
}
|
||||
|
||||
it('retries on a 503 and resolves the follow-up 200', async () => {
|
||||
const submit = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(503))
|
||||
.mockResolvedValueOnce(jsonResponse(200));
|
||||
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.ok).toBe(true);
|
||||
expect(submit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns a 402 immediately without retrying', async () => {
|
||||
const submit = vi.fn().mockResolvedValue(jsonResponse(402));
|
||||
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 });
|
||||
expect(response.status).toBe(402);
|
||||
expect(submit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('succeeds after two 503s followed by a 200', async () => {
|
||||
const submit = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(503))
|
||||
.mockResolvedValueOnce(jsonResponse(503))
|
||||
.mockResolvedValueOnce(jsonResponse(200));
|
||||
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 });
|
||||
expect(response.status).toBe(200);
|
||||
expect(submit).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('gives up after maxRetries and returns the last 503', async () => {
|
||||
const submit = vi.fn().mockResolvedValue(jsonResponse(503));
|
||||
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 });
|
||||
expect(response.status).toBe(503);
|
||||
expect(submit).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('honours a custom maxRetries', async () => {
|
||||
const submit = vi.fn().mockResolvedValue(jsonResponse(503));
|
||||
const response = await submitPaymentWithRetry(submit, { maxRetries: 1, retryDelayMs: 1 });
|
||||
expect(response.status).toBe(503);
|
||||
expect(submit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('backs off with retryDelayMs between retries', async () => {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||||
const submit = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(503))
|
||||
.mockResolvedValueOnce(jsonResponse(200));
|
||||
const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1234 });
|
||||
expect(response.status).toBe(200);
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1234);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,64 @@ export function isNonceStale(
|
||||
export const PAYMENT_AMBIGUOUS_STATUS = 503;
|
||||
export const PAYMENT_DEFINITIVE_STATUS = 402;
|
||||
|
||||
/**
|
||||
* True when a definitive (402) charge failure on a SAVED CARD should be
|
||||
* surfaced as a card-issuer verification problem rather than a plain decline.
|
||||
* The backend sets customer_details.customer_initiated=true on saved-card
|
||||
* (ccof) charges and classifies issuer-verification rejections — Square's
|
||||
* CARD_DECLINED_VERIFICATION_REQUIRED and friends — as definitive 402s, but the
|
||||
* response body is the generic "Payment failed" text with no distinguishing
|
||||
* code. Saved cards skip the client-side tokenizeWithVerification SCA step, so
|
||||
* a 402 on the saved-card path means the issuer still requires verification:
|
||||
* retrying the same saved card can never succeed, and the buyer must pay with
|
||||
* a freshly tokenized card or re-add theirs. New-card (cnon) charges carry
|
||||
* their own SCA verification token, so they are never classified this way.
|
||||
*/
|
||||
export function isSavedCardVerificationRequired(status: number, usedSavedCard: boolean): boolean {
|
||||
return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS;
|
||||
}
|
||||
|
||||
/** User-facing guidance for a saved-card charge the issuer requires
|
||||
* verification to complete. Retrying the same saved card is pointless — the
|
||||
* buyer must pay with a new card or re-add their card. */
|
||||
export const SAVED_CARD_VERIFICATION_MESSAGE =
|
||||
'Your card issuer requires verification. Please pay with a new card or re-add your card.';
|
||||
|
||||
/**
|
||||
* Error code the booking-payment endpoint (POST /api/bookings/{id}/payment)
|
||||
* returns with a 400 when a payment would exceed the booking's remaining
|
||||
* balance BEFORE the appointment has started. buildSplitRecords records any
|
||||
* overflow beyond the booking total as a tip — but a tip is gratuity for
|
||||
* service already rendered, so the backend refuses to silently convert an
|
||||
* unconfirmed pre-start overpayment into a tip (see CreateBookingPayment).
|
||||
* The frontend must surface a Confirm/Cancel prompt and resend the SAME
|
||||
* request with `confirm_overflow_tip: true` on confirm. This fires mainly on
|
||||
* stale booking data (multi-tab, admin-changed totals, refunds that reopened
|
||||
* capacity), so the response body carries no amount — the caller computes the
|
||||
* overflow as `req.Amount - remainingCents` from its booking data.
|
||||
*/
|
||||
export const OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE = 'overflow_tip_confirmation_required';
|
||||
|
||||
/**
|
||||
* True when an API error body is the backend's overflow-tip confirmation guard
|
||||
* (a 400 JSON body of the form `{"error": "...", "code":
|
||||
* "overflow_tip_confirmation_required"}`). The shared error-parsing helper
|
||||
* (`extractErrorMessage`) surfaces only the human-readable message text, not
|
||||
* the machine-readable `code` field, so this checks the raw response body
|
||||
* directly. Returns false for any non-JSON body or any other error.
|
||||
*/
|
||||
export function isOverflowTipConfirmationRequired(errorText: string): boolean {
|
||||
const trimmed = errorText.trim();
|
||||
if (!trimmed) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
return parsed?.code === OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE;
|
||||
} catch {
|
||||
// Not JSON — cannot be the overflow guard
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a payment submission response is an ambiguous failure (503) that
|
||||
* must be retried with the SAME idempotency key so the backend resumes the
|
||||
* pending record. */
|
||||
|
||||
Reference in New Issue
Block a user