Apply second-round review fixes: idempotency-key length caps, stable-sentinel card keys, test-isolation, naming
Money-safety idempotency hardening (I1, wide): - validate:"max=45" on CreateTerminalPayment/BookingPayment/Refund/Tip/ BuyGiftCard idempotency keys (all feed Square's 45-char /v2/payments, /v2/refunds, /v2/cards caps); BuyGiftCard corrected from a wrongly-loose max=64. Till keeps max=64 (its key also feeds the 64-char terminal-checkout endpoint). - Explicit 45-char guard in RefundPayment: the one handler that decodes RefundRequest without running the struct validator, so the tag alone was inert; a longer key would 400 at Square and be misclassified as a definitive refund decline. - New TestIdempotencyKey_OverLength_RejectedAcrossPaymentHandlers covers all six endpoints (terminal saved-card, booking, tip, gift-card, till, refund). Stable-sentinel card identity in idempotency keys (C1, wide): - BookingFlow deposit key now uses the 'new-card' sentinel instead of embedding the cnon: nonce (matches UserPaymentModal/account). A re-tokenize after a spent nonce no longer regenerates the key, closing a lost-response double-charge window. - TipPayment + UserBookingModal tip keys now include card identity (selectedCardId || 'new-card'); previously keyed on amount only, so a same-amount tip on a DIFFERENT card reused the key and deduped a distinct charge. Resets cleared in every success/close path. Test isolation (R1): TestRefund_PendingResume_NewKeyAfterModalReopen no longer t.Parallel — it swaps the package-level SquareClient mid-test and a concurrent parallel test could observe the swapped instance. Naming/quality (M1/M2/M4): resolveChargeSource local renamed savedRowID (was shadowing the cardID *string parameter); BuyGiftCard fallback prefix "till-" -> "gc-"; saved-card terminal response key "checkout_id" -> "payment_id" (it holds a DB payment row, not a Square checkout) with matching frontend fallback. README maintenance-job count corrected 24 -> 25. Full suite 25/25 + race clean via run-tests.sh lockfile; svelte-check 0 errors/warnings; production build succeeds.
This commit is contained in:
@@ -143,6 +143,7 @@
|
||||
// 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[]>([]);
|
||||
@@ -272,9 +273,15 @@
|
||||
tipProcessing = true;
|
||||
|
||||
try {
|
||||
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
|
||||
// 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),
|
||||
@@ -296,6 +303,7 @@
|
||||
toast.success('Thank you for your tip!');
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipKeyedCard = '';
|
||||
tipNonce = '';
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
@@ -1143,6 +1151,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
||||
customTipInput = '';
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipKeyedCard = '';
|
||||
tipSelectedCardId = '';
|
||||
tipSaveCard = false;
|
||||
tipNonce = '';
|
||||
|
||||
@@ -353,8 +353,11 @@
|
||||
}
|
||||
|
||||
// Cache the idempotency key per amount+card so a lost-response retry
|
||||
// reuses it (backend dedups) instead of double-charging.
|
||||
const cardKey = selectedPaymentMethod || `new:${newCardToken ?? ''}`;
|
||||
// reuses it (backend dedups) instead of double-charging. The new-card
|
||||
// identity is a STABLE sentinel, NOT the cnon: nonce: the nonce is
|
||||
// one-shot (cleared once spent), so keying on it would regenerate the
|
||||
// key on re-tokenize and a lost-response retry could double-charge.
|
||||
const cardKey = selectedPaymentMethod || 'new-card';
|
||||
if (
|
||||
!depositIdempotencyKey ||
|
||||
depositKeyedAmount !== amountCents ||
|
||||
|
||||
@@ -702,7 +702,10 @@
|
||||
const data = await response.json();
|
||||
status = 'success';
|
||||
paymentResult = {
|
||||
checkout_id: data.checkout_id || data.id || '',
|
||||
// Saved-card charges return payment_id (a DB payment row, not a
|
||||
// Square checkout) — fall back to the other keys for the
|
||||
// terminal/checkout responses.
|
||||
checkout_id: data.payment_id || data.checkout_id || data.id || '',
|
||||
status: 'COMPLETED',
|
||||
card_brand: data.card_brand,
|
||||
last4: data.card_last4,
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
// 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 — delegated to CardSelection.svelte (saved-card list,
|
||||
// "Use a new card" toggle, SquareCardInput tokenization, consent checkbox).
|
||||
@@ -239,9 +240,15 @@
|
||||
paymentState = 'processing';
|
||||
|
||||
try {
|
||||
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount) {
|
||||
// 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 = selectedCardId || 'new-card';
|
||||
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
|
||||
tipIdempotencyKey = crypto.randomUUID();
|
||||
tipKeyedAmount = tipAmount;
|
||||
tipKeyedCard = cardKey;
|
||||
}
|
||||
const amountInPence = Math.round(tipAmount * 100);
|
||||
const body: Record<string, unknown> = {
|
||||
@@ -266,6 +273,7 @@
|
||||
paymentState = 'success';
|
||||
tipIdempotencyKey = '';
|
||||
tipKeyedAmount = 0;
|
||||
tipKeyedCard = '';
|
||||
tipNonce = '';
|
||||
tipVerificationToken = '';
|
||||
tipTokenAmount = 0;
|
||||
|
||||
Reference in New Issue
Block a user