diff --git a/README.md b/README.md index 0e55440..28e5796 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL ## 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. diff --git a/backend/handlers/payments/charge_helpers.go b/backend/handlers/payments/charge_helpers.go index 635a8be..4f0ec28 100644 --- a/backend/handlers/payments/charge_helpers.go +++ b/backend/handlers/payments/charge_helpers.go @@ -52,11 +52,11 @@ func resolveChargeSource(ctx context.Context, w http.ResponseWriter, svc *Paymen // record's retry re-creates it via the deterministic sha256 // idempotency key (the SAVE path), and Square returns the same card — // 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) - if err != nil { - log.Printf("Failed to save card: %v", err) + savedRowID, saveErr := svc.SaveCardForUser(ctx, userID, sqCustomerID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint) + if saveErr != nil { + log.Printf("Failed to save card: %v", saveErr) } else { - savedCardID = &cardID + savedCardID = &savedRowID } } else { // One-off new-card charge: use the cnon: nonce DIRECTLY as the diff --git a/backend/handlers/payments/giftcards.go b/backend/handlers/payments/giftcards.go index faf4dc4..c84a6ef 100644 --- a/backend/handlers/payments/giftcards.go +++ b/backend/handlers/payments/giftcards.go @@ -83,8 +83,8 @@ type BuyGiftCardRequest struct { // 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 UNIQUE(payments.idempotency_key) constraint. The frontend always - // sends a per-purchase UUID; the max=64 matches the column width. - IdempotencyKey string `json:"idempotency_key" validate:"required,max=64"` + // sends a per-purchase UUID; max=45 matches Square's /v2/payments limit. + IdempotencyKey string `json:"idempotency_key" validate:"required,max=45"` VerificationToken *string `json:"verification_token,omitempty"` } @@ -915,7 +915,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { 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 // empty-key purchase would 500 on the UNIQUE(payments.idempotency_key) // constraint. The frontend always sends a per-purchase UUID. The fallback @@ -928,7 +928,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { return } if req.IdempotencyKey == "" { - req.IdempotencyKey = uniqueChargeKey("till-") + req.IdempotencyKey = uniqueChargeKey("gc-") } allowedAmounts := map[int64]bool{1000: true, 2000: true, 5000: true} diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index 39becf4..ba0140e 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -42,8 +42,9 @@ type CreateTerminalPaymentRequest struct { // one booking (e.g. a second £50 'full' charge for a second service) get // different keys and never collapse on the deterministic fallback key. // When absent, the handler falls back to the deterministic booking+type+ - // amount+card key for no-client-key retry safety. - IdempotencyKey string `json:"idempotency_key,omitempty"` + // amount+card key for no-client-key retry safety. Cap ≤45 (Square's + // idempotency-key limit for /v2/payments — this key feeds CreatePayment). + IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"` } type CreateBookingPaymentRequest struct { @@ -52,7 +53,7 @@ type CreateBookingPaymentRequest struct { CardID *string `json:"card_id,omitempty"` NewCardToken *string `json:"new_card_token,omitempty"` 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"` } @@ -63,8 +64,8 @@ type RefundRequest struct { // same amount against the same payment must not collide on the default // amount-derived key (the dedup lookup would swallow the second refund). // The frontend sends a UUID generated per refund attempt and reuses it on - // retry, mirroring the tip-flow pattern. - IdempotencyKey string `json:"idempotency_key,omitempty"` + // retry, mirroring the tip-flow pattern. Cap ≤45 (Square's /v2/refunds limit). + IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"` } type CreateTipPaymentRequest struct { @@ -72,7 +73,7 @@ type CreateTipPaymentRequest struct { CardID *string `json:"card_id,omitempty"` NewCardToken *string `json:"new_card_token,omitempty"` 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"` } @@ -684,7 +685,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { // Return the card details the frontend reads for the success state // (MINOR-R2) — CheckoutResponse alone leaves card_brand/card_last4 blank. if err := json.NewEncoder(w).Encode(map[string]any{ - "checkout_id": paymentID, + "payment_id": paymentID, "status": "COMPLETED", "card_brand": paymentResult.CardBrand, "card_last4": paymentResult.CardLast4, @@ -1983,6 +1984,15 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) { 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() payment, err := service.GetPaymentByID(r.Context(), paymentID) diff --git a/backend/handlers/payments/payments_r6_review_test.go b/backend/handlers/payments/payments_r6_review_test.go index de26cbb..0de1750 100644 --- a/backend/handlers/payments/payments_r6_review_test.go +++ b/backend/handlers/payments/payments_r6_review_test.go @@ -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.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()) + }) + } +} + diff --git a/backend/handlers/payments/payments_test.go b/backend/handlers/payments/payments_test.go index b477178..b7e18fc 100644 --- a/backend/handlers/payments/payments_test.go +++ b/backend/handlers/payments/payments_test.go @@ -807,7 +807,9 @@ func TestRefund_TwoEqualPartialRefunds_ClientKeyDisambiguates(t *testing.T) { // a second pending row. Without the fallback, the sweep would process both // pending rows and move twice the intended money. 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) _, bookingID, _ := setupTestData(t, ctx, tx) diff --git a/backend/handlers/payments/till.go b/backend/handlers/payments/till.go index d6939b1..5a99659 100644 --- a/backend/handlers/payments/till.go +++ b/backend/handlers/payments/till.go @@ -30,7 +30,10 @@ type TillSaleRequest struct { PaymentMethod string `json:"payment_method" validate:"required"` UserSavedCardID *string `json:"user_saved_card_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"` RedeemToUserID *string `json:"redeem_to_user_id,omitempty"` VerificationToken *string `json:"verification_token,omitempty"` diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 2e02f61..f6de0a5 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -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([]); @@ -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 = { 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 ? `

VAT is included at ${biz?.default_vat_rate ?? 20} customTipInput = ''; tipIdempotencyKey = ''; tipKeyedAmount = 0; + tipKeyedCard = ''; tipSelectedCardId = ''; tipSaveCard = false; tipNonce = ''; diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index 5114de2..44d65a1 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -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 || diff --git a/frontend/src/lib/components/payments/PaymentModal.svelte b/frontend/src/lib/components/payments/PaymentModal.svelte index ed4e92d..b44e1c1 100644 --- a/frontend/src/lib/components/payments/PaymentModal.svelte +++ b/frontend/src/lib/components/payments/PaymentModal.svelte @@ -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, diff --git a/frontend/src/lib/components/payments/TipPayment.svelte b/frontend/src/lib/components/payments/TipPayment.svelte index b11d82d..b5dea1c 100644 --- a/frontend/src/lib/components/payments/TipPayment.svelte +++ b/frontend/src/lib/components/payments/TipPayment.svelte @@ -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 = { @@ -266,6 +273,7 @@ paymentState = 'success'; tipIdempotencyKey = ''; tipKeyedAmount = 0; + tipKeyedCard = ''; tipNonce = ''; tipVerificationToken = ''; tipTokenAmount = 0;