fix: comprehensive payment system hardening (4 review passes)
CRITICAL fixes: - C1: JWT exp claim now validated via jwtauth.VerifyToken (was Decode) - C2: OverrideAmount validated post-substitution (prevents negative money minting) - C3: Terminal gift-card payments store gift_card_id; refund credits user balance - C4: Refund dedup returns stored amount, not req.Amount (prevents admin mislead) - C5: Booking recheck uses FOR UPDATE (prevents TOCTOU with cancellation) - C6: processChargeGroup idempotency key stable (charge-only, prevents double-refund) MAJOR fixes: - M2: Gift-card refund UPDATE checks RowsAffected; 0 rows -> failed - M3: ProcessCancellationRefund returns commit error (was swallowed) - M5: Dispute webhook handling (created + state.updated + disputes table) MEDIUM fixes: - ME1: CORS restricted to FRONTEND_ORIGIN env var (was reflect-any) - ME2: anonymize_user() scrubs users.notes, bookings.notes, name_history, refresh_tokens - ME3: Webhook handlers now mutate state (payment.updated, refund.updated) Frontend fixes: - Same-key retry on 503 (ambiguous failure) wired to all 8 payment flows - CHARGE_AND_STORE intent for save-card flows (SCA compliance) - Nonce staleness check verified across all flows Additional fixes from adversarial re-review: - F1: Till-sale completed dedup echoes stored amount (C4-class) - F2: Cash/giftcard terminal path uses FOR UPDATE (C5-class) - F3: Square-success UPDATE checks RowsAffected (till sales) - F4: Dispute reason truncated to 192 chars (prevents INSERT failure) - F5: Booking-user lookup failure marks refund failed (prevents silent money loss) - F6: Saved-card/tip rechecks wrapped in transaction (C5 residual) Tests: - 15 adversarial attack tests (negative override, zero override, terminal gift card, refund dedup, TOCTOU, deleted gift card, advisory lock, overcharge, zero/negative/huge amount, raw PAN, missing auth, gift card balance, concurrent refunds) - 14 webhook state tests (dispute created/state, payment/refund updated) - 3 CORS tests, 3 GDPR tests, 1 HTTP timeout test - Full suite passes with -race (25 packages, 0 failures) 25 files changed, +1532/-275 lines
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
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 } from '$lib/square/square';
|
||||
import { canSaveCardsForRole, isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
|
||||
interface Props {
|
||||
open: boolean;
|
||||
bookingId: string;
|
||||
@@ -164,6 +164,11 @@
|
||||
// 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);
|
||||
|
||||
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
|
||||
|
||||
@@ -242,7 +247,11 @@
|
||||
// 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 || isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount)) {
|
||||
if (
|
||||
!tipNonce ||
|
||||
tipTokenizedForSaveCard !== tipSaveCard ||
|
||||
isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await tipCardSelection.tokenizeWithVerification(
|
||||
Math.round(tipAmount * 100),
|
||||
@@ -250,12 +259,14 @@
|
||||
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;
|
||||
@@ -271,6 +282,7 @@
|
||||
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
|
||||
@@ -289,11 +301,13 @@
|
||||
...(verificationToken ? { verification_token: verificationToken } : {})
|
||||
};
|
||||
|
||||
const response = await apiFetch(`/api/bookings/${selectedBooking.id}/tip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
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');
|
||||
@@ -306,6 +320,7 @@
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
showTipModal = false;
|
||||
fetchBookingDetails();
|
||||
} catch (err) {
|
||||
@@ -317,6 +332,7 @@
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
} finally {
|
||||
tipProcessing = false;
|
||||
}
|
||||
@@ -1156,6 +1172,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { isSquareConfigured } from '$lib/square/square';
|
||||
import { isSquareConfigured, submitPaymentWithRetry } from '$lib/square/square';
|
||||
import { range } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
@@ -646,13 +646,15 @@
|
||||
if (actionType === 'create' && generateType === 'account' && selectedCustomer)
|
||||
body.redeem_to_user_id = selectedCustomer.id;
|
||||
|
||||
const res = await apiFetch('/api/admin/till/sale', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const res = await submitPaymentWithRetry(() =>
|
||||
apiFetch('/api/admin/till/sale', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
paymentResult = { ...data };
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import { isSquareConfigured } from '$lib/square/square';
|
||||
import { isSquareConfigured, submitPaymentWithRetry } from '$lib/square/square';
|
||||
|
||||
type CartItem = {
|
||||
id: string;
|
||||
@@ -290,11 +290,13 @@
|
||||
}
|
||||
|
||||
for (const body of saleBodies) {
|
||||
const res = await apiFetch('/api/admin/till/sale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const res = await submitPaymentWithRetry(() =>
|
||||
apiFetch('/api/admin/till/sale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { POLICY } from '$lib/constants/policy';
|
||||
import { canSaveCardsForRole, isNonceStale } from '$lib/square/square';
|
||||
import { canSaveCardsForRole, isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
||||
import {
|
||||
@@ -123,6 +123,11 @@
|
||||
// verification tokens expire after ~5 minutes, so a stale pair is discarded
|
||||
// on late retries and re-tokenized instead of rejected by Square.
|
||||
let depositTokenizedAt = $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 depositTokenizedForSaveCard = $state(false);
|
||||
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
|
||||
// on the next microtask), so `isProcessingPayment` may not propagate to the
|
||||
// button's `disabled` binding before a fast second click fires. This non-
|
||||
@@ -331,17 +336,26 @@
|
||||
// created so the SCA verification amount matches the exact charge.
|
||||
// The verification token is amount-bound, so a changed deposit
|
||||
// amount forces a fresh tokenization.
|
||||
if (!depositNonce || isNonceStale(depositTokenizedAt, depositTokenAmount, amountCents)) {
|
||||
if (
|
||||
!depositNonce ||
|
||||
depositTokenizedForSaveCard !== depositSaveCard ||
|
||||
isNonceStale(depositTokenizedAt, depositTokenAmount, amountCents)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await paymentCardSelection.tokenizeWithVerification(amountCents, {
|
||||
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
|
||||
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
|
||||
email: customerInfo.email || authStore.currentUser?.email
|
||||
});
|
||||
const tokenized = await paymentCardSelection.tokenizeWithVerification(
|
||||
amountCents,
|
||||
{
|
||||
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
|
||||
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
|
||||
email: customerInfo.email || authStore.currentUser?.email
|
||||
},
|
||||
depositSaveCard
|
||||
);
|
||||
depositNonce = tokenized.nonce;
|
||||
depositVerificationToken = tokenized.verificationToken ?? '';
|
||||
depositTokenAmount = amountCents;
|
||||
depositTokenizedAt = Date.now();
|
||||
depositTokenizedForSaveCard = depositSaveCard;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
@@ -381,14 +395,16 @@
|
||||
|
||||
paymentAttempted = true;
|
||||
|
||||
const response = await apiFetch(`/api/bookings/${bookingId}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${bookingId}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
depositPaid = true;
|
||||
@@ -399,6 +415,7 @@
|
||||
depositVerificationToken = '';
|
||||
depositTokenAmount = 0;
|
||||
depositTokenizedAt = 0;
|
||||
depositTokenizedForSaveCard = false;
|
||||
depositSaveCard = false;
|
||||
// Immutable update — avoid mutating the existing object so
|
||||
// concurrent renders (e.g. a stale fetch) can't observe partial
|
||||
|
||||
@@ -75,16 +75,19 @@
|
||||
* Tokenizes the new-card form with SCA verification details (SCA-mandated
|
||||
* for UK card-not-present charges). Returns the nonce AND the verification
|
||||
* token, which the caller must send to the backend as `verification_token`
|
||||
* alongside the nonce so the charge completes.
|
||||
* alongside the nonce so the charge completes. Pass `saveCard=true` when the
|
||||
* card will ALSO be saved for reuse — the SCA intent becomes
|
||||
* `CHARGE_AND_STORE` (Square requires it for charge-and-store flows).
|
||||
*/
|
||||
export async function tokenizeWithVerification(
|
||||
amount: number,
|
||||
contact?: SquareVerificationContact
|
||||
contact?: SquareVerificationContact,
|
||||
saveCard: boolean = false
|
||||
): Promise<{ nonce: string; verificationToken: string | null }> {
|
||||
if (!newCardMode || !squareCardInput) {
|
||||
throw new Error('No new card form is open');
|
||||
}
|
||||
return squareCardInput.tokenizeWithVerification(amount, contact);
|
||||
return squareCardInput.tokenizeWithVerification(amount, contact, saveCard);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -184,7 +184,8 @@
|
||||
givenName?: string;
|
||||
familyName?: string;
|
||||
email?: string;
|
||||
}
|
||||
},
|
||||
_saveCard: boolean = false
|
||||
): Promise<{ nonce: string; verificationToken: string | null }> {
|
||||
if (!complete) {
|
||||
throw new Error('Card details are incomplete');
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { submitPaymentWithRetry } from '$lib/square/square';
|
||||
|
||||
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||
|
||||
@@ -682,17 +683,19 @@
|
||||
try {
|
||||
await applyLoyaltyRedemption();
|
||||
|
||||
const response = await apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: chargeAmount,
|
||||
payment_type: 'full',
|
||||
payment_method: 'saved_card',
|
||||
saved_card_id: selectedSavedCardId,
|
||||
idempotency_key: savedCardIdempotencyKey
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: chargeAmount,
|
||||
payment_type: 'full',
|
||||
payment_method: 'saved_card',
|
||||
saved_card_id: selectedSavedCardId,
|
||||
idempotency_key: savedCardIdempotencyKey
|
||||
})
|
||||
})
|
||||
});
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.text();
|
||||
|
||||
@@ -195,16 +195,23 @@
|
||||
* integer string ("5000") would make Square's 3DS bind a
|
||||
* 100×-too-large amount and fail SCA.
|
||||
* @param contact Optional billing contact (name/email we already hold).
|
||||
* @param saveCard When true the SCA verification is bound with intent
|
||||
* `CHARGE_AND_STORE` (charge + save to Square for reuse).
|
||||
* Square requires this intent — not bare `CHARGE` — for a
|
||||
* card that will be both charged and stored; the SCA
|
||||
* challenge must cover the store operation. When false the
|
||||
* intent stays `CHARGE`.
|
||||
*/
|
||||
export async function tokenizeWithVerification(
|
||||
amount: number,
|
||||
contact?: SquareVerificationContact
|
||||
contact?: SquareVerificationContact,
|
||||
saveCard: boolean = false
|
||||
): Promise<TokenizeWithVerificationResult> {
|
||||
if (isSquareMock()) {
|
||||
if (!mockForm) {
|
||||
throw new Error('Card form is not ready — please wait a moment and try again');
|
||||
}
|
||||
return mockForm.tokenizeWithVerification(amount, contact);
|
||||
return mockForm.tokenizeWithVerification(amount, contact, saveCard);
|
||||
}
|
||||
const card = cardInstance as {
|
||||
tokenize: (
|
||||
@@ -224,7 +231,11 @@
|
||||
// monetary-value), e.g. "50.00" for £50.00 — NOT the minor-unit
|
||||
// integer ("5000"), which would bind a 100×-too-large 3DS amount.
|
||||
amount: (amount / 100).toFixed(2),
|
||||
intent: 'CHARGE',
|
||||
// CHARGE_AND_STORE binds the SCA challenge to BOTH the charge and
|
||||
// the store, so the resulting verification token legally permits
|
||||
// saving the card (SCA compliance). Bare CHARGE would only cover
|
||||
// the charge.
|
||||
intent: saveCard ? 'CHARGE_AND_STORE' : 'CHARGE',
|
||||
currencyCode: 'GBP',
|
||||
customerInitiated: true,
|
||||
sellerKeyedIn: false
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { onMount } from 'svelte';
|
||||
import { canSaveCardsForRole, isNonceStale } from '$lib/square/square';
|
||||
import { canSaveCardsForRole, isNonceStale, 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
|
||||
@@ -77,6 +77,11 @@
|
||||
// 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);
|
||||
|
||||
const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role));
|
||||
|
||||
@@ -210,7 +215,11 @@
|
||||
// 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 || isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount)) {
|
||||
if (
|
||||
!tipNonce ||
|
||||
tipTokenizedForSaveCard !== saveCard ||
|
||||
isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await cardSelection.tokenizeWithVerification(
|
||||
Math.round(tipAmount * 100),
|
||||
@@ -218,12 +227,14 @@
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
}
|
||||
},
|
||||
saveCard
|
||||
);
|
||||
tipNonce = tokenized.nonce;
|
||||
tipVerificationToken = tokenized.verificationToken ?? '';
|
||||
tipTokenAmount = tipAmount;
|
||||
tipTokenizedAt = Date.now();
|
||||
tipTokenizedForSaveCard = saveCard;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||
return;
|
||||
@@ -258,11 +269,13 @@
|
||||
...(verificationToken ? { verification_token: verificationToken } : {})
|
||||
};
|
||||
|
||||
const response = await apiFetch(`/api/bookings/${booking.id}/tip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${booking.id}/tip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
@@ -277,6 +290,7 @@
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
toast.success('Thank you for your tip!');
|
||||
} catch (err) {
|
||||
paymentState = 'error';
|
||||
@@ -290,6 +304,7 @@
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { isNonceStale } from '$lib/square/square';
|
||||
import { isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
|
||||
|
||||
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||
|
||||
@@ -60,6 +60,11 @@
|
||||
// verification tokens expire after ~5 minutes, so a stale pair is discarded
|
||||
// on late retries and re-tokenized instead of rejected by Square.
|
||||
let newCardTokenizedAt = $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 newCardTokenizedForSaveCard = $state(false);
|
||||
let paymentResult = $state<{
|
||||
id: string;
|
||||
amount: number;
|
||||
@@ -374,17 +379,26 @@
|
||||
// is one-shot; the backend idempotency key dedups). The verification
|
||||
// token is amount-bound, so a changed amount forces a fresh
|
||||
// tokenization.
|
||||
if (!newCardNonce || isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountCents)) {
|
||||
if (
|
||||
!newCardNonce ||
|
||||
newCardTokenizedForSaveCard !== saveCard ||
|
||||
isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountCents)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await cardSelection.tokenizeWithVerification(amountCents, {
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
});
|
||||
const tokenized = await cardSelection.tokenizeWithVerification(
|
||||
amountCents,
|
||||
{
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
},
|
||||
saveCard
|
||||
);
|
||||
newCardNonce = tokenized.nonce;
|
||||
newCardVerificationToken = tokenized.verificationToken ?? '';
|
||||
newCardTokenAmount = amountCents;
|
||||
newCardTokenizedAt = Date.now();
|
||||
newCardTokenizedForSaveCard = saveCard;
|
||||
} catch (_err) {
|
||||
status = 'error';
|
||||
const msg = _err instanceof Error ? _err.message : 'Card entry failed';
|
||||
@@ -423,18 +437,20 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/api/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: amountCents,
|
||||
payment_type: paymentType,
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
idempotency_key: payIdempotencyKey
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: amountCents,
|
||||
payment_type: paymentType,
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
idempotency_key: payIdempotencyKey
|
||||
})
|
||||
})
|
||||
});
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.text();
|
||||
@@ -452,6 +468,7 @@
|
||||
newCardVerificationToken = '';
|
||||
newCardTokenAmount = 0;
|
||||
newCardTokenizedAt = 0;
|
||||
newCardTokenizedForSaveCard = false;
|
||||
paymentResult = {
|
||||
id: data.id,
|
||||
amount: data.amount,
|
||||
@@ -477,6 +494,7 @@
|
||||
newCardVerificationToken = '';
|
||||
newCardTokenAmount = 0;
|
||||
newCardTokenizedAt = 0;
|
||||
newCardTokenizedForSaveCard = false;
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user