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:
2026-08-22 00:34:49 +01:00
parent 63226debb7
commit 7f1c649f1e
11 changed files with 149 additions and 23 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL
## Features ## Features
**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: Centralised cron scheduler (`backend/internal/jobs/`) runs 24 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, staged default hours auto-apply, GDPR anonymization, financial aggregation, and token/code cleanup. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation (`closing_time.go`) resolves both current and staged default hours. **Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: Centralised cron scheduler (`backend/internal/jobs/`) runs 25 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, staged default hours auto-apply, GDPR anonymization, financial aggregation, and token/code cleanup. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation (`closing_time.go`) resolves both current and staged default hours.
**Payments**: Square Terminal (in-person, via `CreateTerminalCheckout`) + online card payments via saved cards or new cards tokenized through the Square Web Payments SDK (`cnon:` nonces — new-card entry falls back to `CardEntryUnavailable` only when neither mock mode nor Square credentials are configured). The backend accepts only tokens, never raw PANs (PCI-DSS parity, mirrored in the dev mock). Cash till sales record the gift-card value and are marked completed, with no tendered/change fields. Any change or overpayment is handled manually by the admin at the counter. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment >20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first 50% of each payment is always carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. A bounded PostgreSQL advisory try-lock (`pg_try_advisory_lock`, ~30 × 100ms ≈ 3s bound) serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record (same-key retries reuse it). Three background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds (with a 23h age guard), `sweep-stale-pending-payments` fails stale pending payments/till-sales so a late retry cannot issue a second charge, and `sweep-stale-terminal-checkouts` cancels card-machine checkouts still pending at Square after an hour so a never-polled checkout cannot complete into an invisible, untracked charge. **Payments**: Square Terminal (in-person, via `CreateTerminalCheckout`) + online card payments via saved cards or new cards tokenized through the Square Web Payments SDK (`cnon:` nonces — new-card entry falls back to `CardEntryUnavailable` only when neither mock mode nor Square credentials are configured). The backend accepts only tokens, never raw PANs (PCI-DSS parity, mirrored in the dev mock). Cash till sales record the gift-card value and are marked completed, with no tendered/change fields. Any change or overpayment is handled manually by the admin at the counter. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment >20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first 50% of each payment is always carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. A bounded PostgreSQL advisory try-lock (`pg_try_advisory_lock`, ~30 × 100ms ≈ 3s bound) serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record (same-key retries reuse it). Three background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds (with a 23h age guard), `sweep-stale-pending-payments` fails stale pending payments/till-sales so a late retry cannot issue a second charge, and `sweep-stale-terminal-checkouts` cancels card-machine checkouts still pending at Square after an hour so a never-polled checkout cannot complete into an invisible, untracked charge.
+4 -4
View File
@@ -52,11 +52,11 @@ func resolveChargeSource(ctx context.Context, w http.ResponseWriter, svc *Paymen
// record's retry re-creates it via the deterministic sha256 // record's retry re-creates it via the deterministic sha256
// idempotency key (the SAVE path), and Square returns the same card — // idempotency key (the SAVE path), and Square returns the same card —
// deleting it would break that retry. // deleting it would break that retry.
cardID, err := svc.SaveCardForUser(ctx, userID, sqCustomerID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint) savedRowID, saveErr := svc.SaveCardForUser(ctx, userID, sqCustomerID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint)
if err != nil { if saveErr != nil {
log.Printf("Failed to save card: %v", err) log.Printf("Failed to save card: %v", saveErr)
} else { } else {
savedCardID = &cardID savedCardID = &savedRowID
} }
} else { } else {
// One-off new-card charge: use the cnon: nonce DIRECTLY as the // One-off new-card charge: use the cnon: nonce DIRECTLY as the
+4 -4
View File
@@ -83,8 +83,8 @@ type BuyGiftCardRequest struct {
// IdempotencyKey is required (R2): an empty key would be stored as '' on // IdempotencyKey is required (R2): an empty key would be stored as '' on
// the pending payments row, and a second empty-key purchase would 500 on // the pending payments row, and a second empty-key purchase would 500 on
// the UNIQUE(payments.idempotency_key) constraint. The frontend always // the UNIQUE(payments.idempotency_key) constraint. The frontend always
// sends a per-purchase UUID; the max=64 matches the column width. // sends a per-purchase UUID; max=45 matches Square's /v2/payments limit.
IdempotencyKey string `json:"idempotency_key" validate:"required,max=64"` IdempotencyKey string `json:"idempotency_key" validate:"required,max=45"`
VerificationToken *string `json:"verification_token,omitempty"` VerificationToken *string `json:"verification_token,omitempty"`
} }
@@ -915,7 +915,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
return return
} }
// R2: idempotency_key is required (validate:"required,max=64"). An empty // R2: idempotency_key is required (validate:"required,max=45"). An empty
// key would be stored as '' on the pending payments row and a second // key would be stored as '' on the pending payments row and a second
// empty-key purchase would 500 on the UNIQUE(payments.idempotency_key) // empty-key purchase would 500 on the UNIQUE(payments.idempotency_key)
// constraint. The frontend always sends a per-purchase UUID. The fallback // constraint. The frontend always sends a per-purchase UUID. The fallback
@@ -928,7 +928,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.IdempotencyKey == "" { if req.IdempotencyKey == "" {
req.IdempotencyKey = uniqueChargeKey("till-") req.IdempotencyKey = uniqueChargeKey("gc-")
} }
allowedAmounts := map[int64]bool{1000: true, 2000: true, 5000: true} allowedAmounts := map[int64]bool{1000: true, 2000: true, 5000: true}
+17 -7
View File
@@ -42,8 +42,9 @@ type CreateTerminalPaymentRequest struct {
// one booking (e.g. a second £50 'full' charge for a second service) get // one booking (e.g. a second £50 'full' charge for a second service) get
// different keys and never collapse on the deterministic fallback key. // different keys and never collapse on the deterministic fallback key.
// When absent, the handler falls back to the deterministic booking+type+ // When absent, the handler falls back to the deterministic booking+type+
// amount+card key for no-client-key retry safety. // amount+card key for no-client-key retry safety. Cap ≤45 (Square's
IdempotencyKey string `json:"idempotency_key,omitempty"` // idempotency-key limit for /v2/payments — this key feeds CreatePayment).
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
} }
type CreateBookingPaymentRequest struct { type CreateBookingPaymentRequest struct {
@@ -52,7 +53,7 @@ type CreateBookingPaymentRequest struct {
CardID *string `json:"card_id,omitempty"` CardID *string `json:"card_id,omitempty"`
NewCardToken *string `json:"new_card_token,omitempty"` NewCardToken *string `json:"new_card_token,omitempty"`
SaveCard bool `json:"save_card"` SaveCard bool `json:"save_card"`
IdempotencyKey string `json:"idempotency_key" validate:"required"` IdempotencyKey string `json:"idempotency_key" validate:"required,max=45"`
VerificationToken *string `json:"verification_token,omitempty"` VerificationToken *string `json:"verification_token,omitempty"`
} }
@@ -63,8 +64,8 @@ type RefundRequest struct {
// same amount against the same payment must not collide on the default // same amount against the same payment must not collide on the default
// amount-derived key (the dedup lookup would swallow the second refund). // amount-derived key (the dedup lookup would swallow the second refund).
// The frontend sends a UUID generated per refund attempt and reuses it on // The frontend sends a UUID generated per refund attempt and reuses it on
// retry, mirroring the tip-flow pattern. // retry, mirroring the tip-flow pattern. Cap ≤45 (Square's /v2/refunds limit).
IdempotencyKey string `json:"idempotency_key,omitempty"` IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
} }
type CreateTipPaymentRequest struct { type CreateTipPaymentRequest struct {
@@ -72,7 +73,7 @@ type CreateTipPaymentRequest struct {
CardID *string `json:"card_id,omitempty"` CardID *string `json:"card_id,omitempty"`
NewCardToken *string `json:"new_card_token,omitempty"` NewCardToken *string `json:"new_card_token,omitempty"`
SaveCard bool `json:"save_card"` SaveCard bool `json:"save_card"`
IdempotencyKey string `json:"idempotency_key,omitempty"` IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"`
VerificationToken *string `json:"verification_token,omitempty"` VerificationToken *string `json:"verification_token,omitempty"`
} }
@@ -684,7 +685,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
// Return the card details the frontend reads for the success state // Return the card details the frontend reads for the success state
// (MINOR-R2) — CheckoutResponse alone leaves card_brand/card_last4 blank. // (MINOR-R2) — CheckoutResponse alone leaves card_brand/card_last4 blank.
if err := json.NewEncoder(w).Encode(map[string]any{ if err := json.NewEncoder(w).Encode(map[string]any{
"checkout_id": paymentID, "payment_id": paymentID,
"status": "COMPLETED", "status": "COMPLETED",
"card_brand": paymentResult.CardBrand, "card_brand": paymentResult.CardBrand,
"card_last4": paymentResult.CardLast4, "card_last4": paymentResult.CardLast4,
@@ -1983,6 +1984,15 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
return return
} }
// Client-supplied idempotency key feeds Square /v2/refunds (45-char cap).
// This handler decodes into RefundRequest without running the struct
// validator, so enforce the limit explicitly — a longer key would 400 at
// Square and be misclassified as a definitive refund decline.
if len(req.IdempotencyKey) > 45 {
http.Error(w, "Invalid request: idempotency_key exceeds 45 characters", http.StatusBadRequest)
return
}
service := NewPaymentService() service := NewPaymentService()
payment, err := service.GetPaymentByID(r.Context(), paymentID) payment, err := service.GetPaymentByID(r.Context(), paymentID)
@@ -448,3 +448,91 @@ func TestSaveCardForUser_RevivesSoftDeletedCard(t *testing.T) {
require.NoError(t, tx.QueryRow(ctx, `SELECT deleted_at FROM user_saved_cards WHERE id = $1`, revivedID).Scan(&revivedDeletedAt)) require.NoError(t, tx.QueryRow(ctx, `SELECT deleted_at FROM user_saved_cards WHERE id = $1`, revivedID).Scan(&revivedDeletedAt))
require.False(t, revivedDeletedAt.Valid, "the revived card must have deleted_at cleared") require.False(t, revivedDeletedAt.Valid, "the revived card must have deleted_at cleared")
} }
// TestIdempotencyKey_OverLength_RejectedAcrossPaymentHandlers proves the
// validate:"max=45/64" caps on client-supplied idempotency keys: a key longer
// than Square's per-endpoint limit would otherwise 400 at Square (misclassified
// as a definitive 402 by chargeFailureStatus) with a confusingly worded error.
func TestIdempotencyKey_OverLength_RejectedAcrossPaymentHandlers(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
_, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
userToken := jwt.GenerateUserToken(userID)
adminToken := jwt.GenerateAdminToken()
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
require.NoError(t, err)
// 46 chars — exceeds Square's 45-char /v2/payments, /v2/cards, /v2/refunds cap.
tooLong := strings.Repeat("k", 46)
// 65 chars — exceeds Square's 64-char terminal-checkout cap.
tooLongCheckout := strings.Repeat("c", 65)
cases := []struct {
name string
handler http.HandlerFunc
method string
path string
body any
token string
}{
{
name: "terminal-saved-card",
handler: CreateTerminalPayment,
method: "POST",
path: "/api/admin/bookings/" + bookingID + "/payment",
body: CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full", UserSavedCardID: strPtr("000000000001"), IdempotencyKey: tooLong},
token: adminToken,
},
{
name: "booking-payment",
handler: CreateBookingPayment,
method: "POST",
path: "/api/bookings/" + bookingID + "/payment",
body: CreateBookingPaymentRequest{Amount: 5000, PaymentType: "deposit", IdempotencyKey: tooLong},
token: userToken,
},
{
name: "tip",
handler: CreateTipPayment,
method: "POST",
path: "/api/bookings/" + bookingID + "/tip",
body: CreateTipPaymentRequest{Amount: 500, IdempotencyKey: tooLong},
token: userToken,
},
{
name: "gift-card-buy",
handler: BuyGiftCard,
method: "POST",
path: "/api/gift-cards/buy",
body: BuyGiftCardRequest{Amount: 1000, RecipientType: "self", NewCardToken: strPtr("cnon:test-card"), IdempotencyKey: tooLong},
token: userToken,
},
{
name: "till-terminal",
handler: CreateTillSale,
method: "POST",
path: "/api/admin/till/sale",
body: TillSaleRequest{ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "cash", IdempotencyKey: tooLongCheckout},
token: adminToken,
},
{
name: "refund",
handler: RefundPayment,
method: "POST",
path: "/api/admin/payments/" + paymentID + "/refund",
body: RefundRequest{Amount: 1000, Reason: "customer request", IdempotencyKey: tooLong},
token: adminToken,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := makePaymentRequest(tc.handler, tc.method, tc.path, tc.body, tc.token, ctx)
require.Equal(t, http.StatusBadRequest, w.Code,
"an over-length idempotency key must be rejected before reaching Square (got %d: %s)", w.Code, w.Body.String())
})
}
}
+3 -1
View File
@@ -807,7 +807,9 @@ func TestRefund_TwoEqualPartialRefunds_ClientKeyDisambiguates(t *testing.T) {
// a second pending row. Without the fallback, the sweep would process both // a second pending row. Without the fallback, the sweep would process both
// pending rows and move twice the intended money. // pending rows and move twice the intended money.
func TestRefund_PendingResume_NewKeyAfterModalReopen(t *testing.T) { func TestRefund_PendingResume_NewKeyAfterModalReopen(t *testing.T) {
t.Parallel() // NOT t.Parallel: it swaps the package-level SquareClient (the counting
// client below), and a concurrent parallel test reading SquareClient would
// observe the swapped instance mid-test.
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx) _, bookingID, _ := setupTestData(t, ctx, tx)
+4 -1
View File
@@ -30,7 +30,10 @@ type TillSaleRequest struct {
PaymentMethod string `json:"payment_method" validate:"required"` PaymentMethod string `json:"payment_method" validate:"required"`
UserSavedCardID *string `json:"user_saved_card_id,omitempty"` UserSavedCardID *string `json:"user_saved_card_id,omitempty"`
UserID *string `json:"user_id,omitempty"` UserID *string `json:"user_id,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"` // IdempotencyKey is optional; an empty key is replaced with a fresh
// uniqueChargeKey below. Limit 64: Square's terminal-checkout cap — this
// key feeds CreateCheckout AND the CreatePayment paths.
IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=64"`
CardToken string `json:"card_token,omitempty"` CardToken string `json:"card_token,omitempty"`
RedeemToUserID *string `json:"redeem_to_user_id,omitempty"` RedeemToUserID *string `json:"redeem_to_user_id,omitempty"`
VerificationToken *string `json:"verification_token,omitempty"` VerificationToken *string `json:"verification_token,omitempty"`
@@ -143,6 +143,7 @@
// failed attempt gets a fresh key instead of a false dedup (under-charge). // failed attempt gets a fresh key instead of a false dedup (under-charge).
let tipIdempotencyKey = $state(''); let tipIdempotencyKey = $state('');
let tipKeyedAmount = $state(0); let tipKeyedAmount = $state(0);
let tipKeyedCard = $state('');
// Card selection for tips — delegated to CardSelection.svelte. // Card selection for tips — delegated to CardSelection.svelte.
let tipSavedCards = $state<SavedCard[]>([]); let tipSavedCards = $state<SavedCard[]>([]);
@@ -272,9 +273,15 @@
tipProcessing = true; tipProcessing = true;
try { 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(); tipIdempotencyKey = crypto.randomUUID();
tipKeyedAmount = tipAmount; tipKeyedAmount = tipAmount;
tipKeyedCard = cardKey;
} }
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
amount: Math.round(tipAmount * 100), amount: Math.round(tipAmount * 100),
@@ -296,6 +303,7 @@
toast.success('Thank you for your tip!'); toast.success('Thank you for your tip!');
tipIdempotencyKey = ''; tipIdempotencyKey = '';
tipKeyedAmount = 0; tipKeyedAmount = 0;
tipKeyedCard = '';
tipNonce = ''; tipNonce = '';
tipVerificationToken = ''; tipVerificationToken = '';
tipTokenAmount = 0; tipTokenAmount = 0;
@@ -1143,6 +1151,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
customTipInput = ''; customTipInput = '';
tipIdempotencyKey = ''; tipIdempotencyKey = '';
tipKeyedAmount = 0; tipKeyedAmount = 0;
tipKeyedCard = '';
tipSelectedCardId = ''; tipSelectedCardId = '';
tipSaveCard = false; tipSaveCard = false;
tipNonce = ''; tipNonce = '';
@@ -353,8 +353,11 @@
} }
// Cache the idempotency key per amount+card so a lost-response retry // Cache the idempotency key per amount+card so a lost-response retry
// reuses it (backend dedups) instead of double-charging. // reuses it (backend dedups) instead of double-charging. The new-card
const cardKey = selectedPaymentMethod || `new:${newCardToken ?? ''}`; // 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 ( if (
!depositIdempotencyKey || !depositIdempotencyKey ||
depositKeyedAmount !== amountCents || depositKeyedAmount !== amountCents ||
@@ -702,7 +702,10 @@
const data = await response.json(); const data = await response.json();
status = 'success'; status = 'success';
paymentResult = { 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', status: 'COMPLETED',
card_brand: data.card_brand, card_brand: data.card_brand,
last4: data.card_last4, last4: data.card_last4,
@@ -55,6 +55,7 @@
// failed attempt gets a fresh key instead of a false dedup (under-charge). // failed attempt gets a fresh key instead of a false dedup (under-charge).
let tipIdempotencyKey = $state(''); let tipIdempotencyKey = $state('');
let tipKeyedAmount = $state(0); let tipKeyedAmount = $state(0);
let tipKeyedCard = $state('');
// Card selection — delegated to CardSelection.svelte (saved-card list, // Card selection — delegated to CardSelection.svelte (saved-card list,
// "Use a new card" toggle, SquareCardInput tokenization, consent checkbox). // "Use a new card" toggle, SquareCardInput tokenization, consent checkbox).
@@ -239,9 +240,15 @@
paymentState = 'processing'; paymentState = 'processing';
try { 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(); tipIdempotencyKey = crypto.randomUUID();
tipKeyedAmount = tipAmount; tipKeyedAmount = tipAmount;
tipKeyedCard = cardKey;
} }
const amountInPence = Math.round(tipAmount * 100); const amountInPence = Math.round(tipAmount * 100);
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
@@ -266,6 +273,7 @@
paymentState = 'success'; paymentState = 'success';
tipIdempotencyKey = ''; tipIdempotencyKey = '';
tipKeyedAmount = 0; tipKeyedAmount = 0;
tipKeyedCard = '';
tipNonce = ''; tipNonce = '';
tipVerificationToken = ''; tipVerificationToken = '';
tipTokenAmount = 0; tipTokenAmount = 0;