diff --git a/.env.example b/.env.example index 07b38d9..d0e1969 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,10 @@ JWT_SECRET_KEY= # Dev: Uses local Rustfs container (see compose.yml) # Prod: Use Cloudflare R2 credentials S3_ENDPOINT=http://localhost:9000 -S3_PUBLIC_URL=http://192.168.1.135:9000 +# Public URL the BROWSER fetches images from. This is HOST-SPECIFIC: it must be +# the machine's current LAN IP (DHCP changes it). Run `hostname -I` to check. +# A stale IP makes images fail to load even though the objects exist in Rustfs. +S3_PUBLIC_URL=http://192.168.0.45:9000 S3_ACCESS_KEY=rustfsadmin S3_SECRET_KEY=rustfsadmin S3_BUCKET=crussell diff --git a/.sisyphus/plans/payments-review-consolidated.md b/.sisyphus/plans/payments-review-consolidated.md new file mode 100644 index 0000000..cd75ec4 --- /dev/null +++ b/.sisyphus/plans/payments-review-consolidated.md @@ -0,0 +1,156 @@ +# Payments Work Review — Consolidated Report (Round 1) + +**Scope:** commits `503c326..HEAD` (110 commits, ~45k insertions, 178 files) — replacing the dev-only Square mock with a realistic mock + real Square integration, plus hardening. + +**Review method:** 8 specialist agents (Square API contract, Square usage surface, UK consumer law, UK GDPR/PCI, mobile parity, testing gaps, pattern consistency, fix-wide-application) + 5 review-work agents (goal/constraint verification, QA execution, code quality, security, context mining). + +## Overall Verdict + +| Agent | Verdict | Confidence | +|---|---|---| +| Goal & Constraint Verification (Oracle) | **FAIL** | HIGH | +| QA Execution (hand-on) | **PASS** (893 tests green, 16/16 live smoke) | HIGH | +| Code Quality (Oracle) | **FAIL** | HIGH | +| Security (Oracle) | **FAIL** (severity HIGH) | HIGH | +| Context Mining | **FAIL** | HIGH | + +The architecture is fundamentally sound — deterministic idempotency keys, refund locks, sweeps, HMAC webhook verification all verified correct in code — but there are real money-safety gaps, GDPR erasure holes, a legal-doc-vs-code mismatch, and pervasive mobile/touch-target issues. Below, every issue from critical to minor, with suggested fixes. + +--- + +## CRITICAL / Money-Safety + +### C1. Till-sale no-key idempotency fallback is still RANDOM (H2 bug class) +- **File:** `backend/handlers/payments/till.go` (598-600, 661-663, 670-672, 703-705, 713-715); helper `handlers.go:4063-4065` +- **Issue:** Every other charge path derives a deterministic, request-stable idempotency key. `CreateTillSale` falls back to `uniqueChargeKey("till-")` = `prefix + rand.Text()` when no client key is supplied. A no-key `create` till sale whose response is lost mints a NEW key on retry → second Square charge + second gift-card funding. The `topup` path is rescued by the gift-card pending-resume (till.go:305-326), but `create` cannot (no card id in the request). +- **Fix:** Derive a deterministic no-key fallback for till sales (e.g. action+amount+redeem-target hash) under the held `crussell:till:` lock, and reuse pending rows by key; or extend the pending-resume to resolve `create` pending rows. + +### C2. £10,000 amount cap missing on four admin money entry points +- **File:** `till.go:159-162`; `giftcards.go:382-389` (CreateGiftCard), `488-491` (TopUpGiftCard), `623-626` (TransferGiftCard). Cap lives at `validators.go:22` and is applied everywhere else (`handlers.go:1416, 298, 321, 3418, 2411, 3053`). +- **Issue:** A till sale or top-up can fund a gift card with an unbounded amount. +- **Fix:** Run `ValidateAmount` (or pounds-equivalent cap) in each handler on the effective amount. + +### C3. Gift Card T&C grants a CCR 2013 14-day cancellation right with NO code path +- **File:** `Gift Card Terms & Conditions.md`; `handlers.go:2473` (RefundPayment rejects gift-card purchases); no cancel/refund gift-card route exists anywhere; the error message points to a non-existent "gift-card section". +- **Issue:** Online gift-card sales are distance contracts under Consumer Contracts Regs 2013. The T&C (a binding consumer contract) promises a 14-day right to cancel with refund to original payment method, but no execution path exists. The refund route explicitly rejects gift-card purchases. +- **Fix:** Implement a `CancelGiftCardPurchase` route (within 14 days, refund to original card via Square RefundPayment, reverse gift-card funding) or amend the T&C to remove the promise (recommended: implement, since a small merchant is exposed on this). + +--- + +## HIGH / GDPR & PII + +### H1. `square_request_snapshot` retains buyer email in plaintext JSON forever — no erasure path scrubs it +- **File:** `init-script.sql:692` (payments), `:2144` (till_sales); stored at `handlers.go:1828, 3716; giftcards.go:1237; till.go:831,866`; `anonymize_user` (945-1038), `delete_guest_user` (1046-1070), `AnonymizeStaleGuestAccounts` (time-blockers.go:414-631) all leave it intact. +- **Issue:** The snapshot embeds `BuyerEmail` (PII) in plaintext JSON retained for the 7-year financial period. Deleted users' emails remain recoverable — GDPR Art.17/5(1)(e) breach. Also the snapshot contains `note`/`verification_token`. +- **Fix:** `SET square_request_snapshot = NULL` in all three erasure paths (snapshot is only needed for pending-row replay), or strip `buyer_email_address` from the stored JSON. + +### H2. `CleanupIdleAccounts` anonymises locally but NEVER disables Square cards or deletes the Square customer +- **File:** `time-blockers.go:1041-1159` vs `account.go:88-112/183-224` (which does it right); `time-blockers.go:437-499`. +- **Issue:** Idle-account cleanup runs `anonymize_user` (NULLs `square_card_id`/`square_customer_id` first) but never calls `DeleteCardOnFile`/`DeleteCustomer`. Customer name+email stays live at Square; local references already destroyed so the orphans are untraceable. Enabled ccof: cards remain chargeable if leaked. +- **Fix:** Add the pre-anonymize snapshot + Square card-disable/customer-delete pass to `CleanupIdleAccounts` (mirror `account.go`). + +### H3. Square-side deletion is fire-and-forget — no retry, no alert, local refs already NULLed +- **File:** `account.go:181-225` (goroutine + 30s timeout, failure only `log.Printf`). +- **Issue:** Failed deletion permanently orphans enabled cards + customer profile at Square. Erasure incomplete on transient API failure. +- **Fix:** Persist Square IDs before erasure; retry via scheduled job (cleanup.go) or at least raise a critical payment notification on failure. + +### H4. 2FA codes delivered via plaintext server log +- **File:** `handlers/user/twofa.go:293` (`log.Printf("[2FA] ...")`); unsalted SHA-256 fallback when `TWO_FACTOR_PEPPER` unset (twofa.go:86-87). +- **Issue:** Anyone with backend log access defeats the 2FA gate on saved-card charges; with pepper unset, pending-code digests are offline-brute-forceable in the 1M space. +- **Fix:** Fail-closed on missing pepper (reject startup like JWT check); remove plaintext log delivery or gate it to `!dev` builds. + +### H5. Dispute reason (third-party free text) logged in full, stored, and GDPR-exported +- **File:** `webhooks/square.go:846,889,909`; `init-script.sql:1436` (export includes `disputes.reason`). +- **Issue:** Cardholder-typed dispute reason is third-party PII; unnecessary in logs. +- **Fix:** Truncate/prefix in logs; keep stored (legitimate financial record) but document third-party nature in export. + +--- + +## HIGH / Frontend Mobile + +### M1. Square hosted-field iframe uses 14px font → iOS auto-zoom on focus +- **File:** `SquareCardInput.svelte:16` (`fontSize: '14px'`); sibling `Input` uses `text-base` (16px). +- **Fix:** Set `fontSize: '16px'` (Square caps at 16 for exactly this reason). + +### M2. Vertically-centered `max-h-[90vh]` dialogs trapped under iOS keyboard; primary action unreachable +- **File:** `UserPaymentModal.svelte:580`, `PaymentModal.svelte:784`. +- **Fix:** On `1 is unreliable for handlers/payments and handlers/webhooks — # those suites share package-global state (Square mock ledger, in-memory webhook diff --git a/backend/go.mod b/backend/go.mod index 36513df..1d48a62 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.32.25 github.com/aws/aws-sdk-go-v2/credentials v1.19.24 github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 + github.com/aws/smithy-go v1.27.2 github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 github.com/go-chi/jwtauth/v5 v5.4.0 github.com/go-playground/validator/v10 v10.30.3 @@ -30,7 +31,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect - github.com/aws/smithy-go v1.27.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2/v2 v2.2.2 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect diff --git a/backend/handlers/bookings/edit_requests_test.go b/backend/handlers/bookings/edit_requests_test.go index 7296018..edaaafc 100644 --- a/backend/handlers/bookings/edit_requests_test.go +++ b/backend/handlers/bookings/edit_requests_test.go @@ -1574,10 +1574,10 @@ func TestAdminApproveEditRequestHandler_OverlapWithBooking(t *testing.T) { token := jwt.GenerateUserToken(userID) - // Create first booking at time T - // Use a start time <48h away so auto-approval doesn't trigger at request- - // creation time, allowing us to test the approval-time overlap check. - baseTime := clock.Now().Add(40 * time.Hour).Truncate(time.Second) + // Booking2 (at baseTime + dur/2) is the edited booking; its start must sit + // in the [24h,48h] window so RequestEditHandler neither 403s (too close) nor + // auto-approves at creation. + baseTime := fixtures.NextEditWindowTime(time.Duration(serviceDuration/2) * time.Minute) booking1, err := fixtures.CreateTestBooking(tx, userID, serviceID) if err != nil { diff --git a/backend/handlers/bookings/overlap_test.go b/backend/handlers/bookings/overlap_test.go index 2dd6e23..a17b476 100644 --- a/backend/handlers/bookings/overlap_test.go +++ b/backend/handlers/bookings/overlap_test.go @@ -840,9 +840,10 @@ func TestAdminApproveEditRequest_OverlapWithBooking_Regression(t *testing.T) { } dur := durationMinutes(t, ctx, tx, serviceID) - // Use <48h from now so RequestEditHandler does NOT auto-approve - nearTime := clock.Now().Add(40 * time.Hour) - nearTime = time.Date(nearTime.Year(), nearTime.Month(), nearTime.Day(), nearTime.Hour(), 0, 0, 0, nearTime.Location()) + // Booking B (at +2h) is the edited booking; its start must sit in the + // [24h,48h] window so RequestEditHandler neither 403s (too close) nor + // auto-approves at creation. 10:00 UTC keeps it inside working hours. + nearTime := fixtures.NextEditWindowTime(2 * time.Hour) token := jwt.GenerateUserToken(userID) @@ -1852,14 +1853,9 @@ func TestAdminApproveEditRequest_EvictsPendingRelease(t *testing.T) { } dur := durationMinutes(t, ctx, tx, serviceID) - // Use a booking 24-48h from now at a fixed working-hour slot so RequestEdit - // neither 403s (<24h away = too close) nor auto-approves (>48h away would - // consume the edit request before the admin can approve it). A raw - // clock.Now().Add(Kh) can land after 19:00 London and fail closing hours. - nearTime := fixtures.NextWorkingDayAt(1, 10) - if nearTime.Sub(clock.Now()) < 24*time.Hour { - nearTime = fixtures.NextWorkingDayAt(2, 10) - } + // Booking A is the edited booking; its start must sit in the [24h,48h] + // window so RequestEditHandler neither 403s (too close) nor auto-approves. + nearTime := fixtures.NextEditWindowTime(0) bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, nearTime) if err != nil { diff --git a/backend/handlers/payments/giftcard_limits.go b/backend/handlers/payments/giftcard_limits.go new file mode 100644 index 0000000..f38f583 --- /dev/null +++ b/backend/handlers/payments/giftcard_limits.go @@ -0,0 +1,120 @@ +package payments + +import ( + "context" + + "crussell/db" +) + +// Gift-card purchase/transaction limits (owner decisions). +// +// - Every admin gift-card value operation (CreateGiftCard, TopUpGiftCard, +// TransferGiftCard) is capped at £250 per transaction — tighter than the +// £10,000 ceiling ValidateAmount enforces on other payment entry points. +// - A customer (BuyGiftCard) may buy at most £500 of online gift cards per +// UTC day. +// - An admin may create/top-up/transfer at most £5,000 of gift-card value +// per UTC day. +// +// till.go keeps its own local `maxTillGiftCardAmountPence` copy of the £250 +// transaction cap (see its comment); the shared constant lives here so both +// files can converge on the single owner decision. +const ( + // maxAdminGiftCardTransactionPence caps a single admin gift-card + // create/top-up/transfer at £250 (25,000 pence). + maxAdminGiftCardTransactionPence = 25_000 + // maxUserGiftCardDailyPence caps one user's online gift-card purchases at + // £500 (50,000 pence) per UTC day. + maxUserGiftCardDailyPence = 500_00 + // maxAdminGiftCardDailyPence caps the gift-card value an admin can + // create/top-up/transfer in one UTC day at £5,000 (500,000 pence). + maxAdminGiftCardDailyPence = 500_000 +) + +// userGiftCardSpentToday returns the total value (in pounds) the user has +// spent on ONLINE gift-card purchases so far today, returned as a float64 so +// the caller can convert to pence with math.Round, matching the repo's +// currency convention. +// +// Signal: gift_card_transactions rows written by BuyGiftCard — the ONLY +// customer-facing online purchase path. Every BuyGiftCard purchase (self and +// friend) inserts a row with transaction_type='purchase', reference_type='api' +// and user_id = the buyer (see giftcards.go). Admin-created cards +// (CreateGiftCard/TopUpGiftCard) also write reference_type='api' but with the +// ADMIN's user id, and till sales write reference_type='till_sale', so neither +// can match a customer. The payments-based alternative (payments rows with +// payment_type='gift_card') does NOT exist in this schema — the payment_type +// enum is ('deposit','full','tip','balance','partial') and BuyGiftCard writes +// payment_type='full' — so the transactions audit log is the correct signal. +// +// "Today" is the UTC day boundary (created_at >= CURRENT_DATE), matching the +// repo's existing time convention: the DB session runs in timezone=UTC and +// completion.go uses the same CURRENT_DATE boundary for its daily loyalty +// stamp cap. +func userGiftCardSpentToday(ctx context.Context, q db.Querier, userID string) (float64, error) { + var spent float64 + err := q.QueryRow(ctx, ` + SELECT COALESCE(SUM(amount), 0) + FROM gift_card_transactions + WHERE user_id = $1 + AND transaction_type = 'purchase' + AND reference_type = 'api' + AND created_at >= CURRENT_DATE + `, userID).Scan(&spent) + if err != nil { + return 0, err + } + return spent, nil +} + +// adminGiftCardValueToday returns the total gift-card value (in pounds) the +// admin has created, topped up, or transferred today (UTC day boundary, +// created_at >= CURRENT_DATE), returned as a float64 for pence conversion. +// +// Signal (chosen to be double-count free across the three admin operations): +// +// 1. Cards the admin created today — SUM(total_funds_added). total_funds_added +// is cumulative, so a card created today already reflects any same-day +// top-up or transfer INTO it, and its creation amount. +// 2. API top-ups executed by this admin today on cards created BEFORE today +// (cards created today are excluded — term 1 already includes their +// funding via total_funds_added, so counting the top-up row again would +// double-count). This is the gift_card_transactions rows +// (reference_type='api', user_id=admin) written by CreateGiftCard +// ('purchase') and TopUpGiftCard ('topup', or 'purchase' on an inventory +// card's first top-up). +// +// Transfers INTO pre-existing cards leave no attributable audit row +// (TransferGiftCard deliberately writes no gift_card_transactions entry), so +// they are not directly counted; a transfer also creates no NEW gift-card +// liability, so the daily cap still measures all value this admin has newly +// issued today. Till sales (CreateTillSale) write reference_type='till_sale' +// and are attributed to the CUSTOMER (user_id), so they are excluded here — +// the admin daily cap covers the admin API surface only. +func adminGiftCardValueToday(ctx context.Context, q db.Querier, adminID string) (float64, error) { + var value float64 + err := q.QueryRow(ctx, ` + SELECT + COALESCE(( + SELECT SUM(gc.total_funds_added) + FROM gift_cards gc + WHERE gc.created_by = $1 AND gc.created_at >= CURRENT_DATE + ), 0) + + COALESCE(( + SELECT SUM(gct.amount) + FROM gift_card_transactions gct + WHERE gct.user_id = $1 + AND gct.reference_type = 'api' + AND gct.transaction_type IN ('purchase', 'topup') + AND gct.created_at >= CURRENT_DATE + AND gct.gift_card_id NOT IN ( + SELECT gc2.id FROM gift_cards gc2 + WHERE gc2.created_by = $1 AND gc2.created_at >= CURRENT_DATE + ) + ), 0) + `, adminID).Scan(&value) + if err != nil { + return 0, err + } + return value, nil +} diff --git a/backend/handlers/payments/giftcards.go b/backend/handlers/payments/giftcards.go index f20b835..3d0317a 100644 --- a/backend/handlers/payments/giftcards.go +++ b/backend/handlers/payments/giftcards.go @@ -53,15 +53,17 @@ func GetGiftCardExpiryMonths(ctx context.Context, q db.Querier) (int, error) { } type GiftCard struct { - ID string `json:"id"` - TotalFundsAdded float64 `json:"total_funds_added"` - AmountRemaining float64 `json:"amount_remaining"` - CreatedBy *string `json:"created_by,omitempty"` - CreatedAt time.Time `json:"created_at"` - RedeemedAt *time.Time `json:"redeemed_at,omitempty"` - RedeemedBy *string `json:"redeemed_by,omitempty"` - IsInventory bool `json:"is_inventory"` - LastUsedAt *time.Time `json:"last_used_at,omitempty"` + ID string `json:"id"` + TotalFundsAdded float64 `json:"total_funds_added"` + AmountRemaining float64 `json:"amount_remaining"` + CreatedBy *string `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + RedeemedAt *time.Time `json:"redeemed_at,omitempty"` + RedeemedBy *string `json:"redeemed_by,omitempty"` + IsInventory bool `json:"is_inventory"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + Cancellable bool `json:"cancellable"` + CancellationReason string `json:"cancellation_reason,omitempty"` } type UserBalance struct { @@ -197,7 +199,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { var gcListArgs []any gcListQuery := fmt.Sprintf(` - SELECT id, total_funds_added, amount_remaining, created_at, is_inventory + SELECT id, total_funds_added, amount_remaining, redeemed_by, is_inventory, expiry_date, created_at FROM gift_cards %s `, whereSQL) @@ -258,21 +260,72 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) { } defer gcRows.Close() + // Drain all rows before the per-card cancellation lookups below — pgx.Tx + // (used by the test harness via a context-stored transaction) does not + // support concurrent queries on one connection. + type giftCardRow struct { + id string + totalFunds float64 + remaining float64 + redeemedBy sql.NullString + isInventory bool + expiryDate sql.NullTime + createdAt time.Time + } + var drained []giftCardRow for gcRows.Next() { - var gc GiftCard - - err = gcRows.Scan( - &gc.ID, - &gc.TotalFundsAdded, - &gc.AmountRemaining, - &gc.CreatedAt, - &gc.IsInventory, - ) + var r giftCardRow + err = gcRows.Scan(&r.id, &r.totalFunds, &r.remaining, &r.redeemedBy, &r.isInventory, &r.expiryDate, &r.createdAt) if err != nil { log.Printf("Failed to scan gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } + drained = append(drained, r) + } + gcRows.Close() + if err := gcRows.Err(); err != nil { + log.Printf("Gift card row iteration error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // Surface whether each card is currently cancellable under the 14-day + // cooling-off right (same assessment the user-facing GetMyGiftCards uses). + // The purchase transaction is resolved per card so the admin can act on + // behalf of the card's real purchaser; cards without an online purchase + // (inventory/till/admin-created) simply report as not cancellable. + for _, r := range drained { + var gc GiftCard + gc.ID = r.id + gc.TotalFundsAdded = r.totalFunds + gc.AmountRemaining = r.remaining + gc.CreatedAt = r.createdAt + gc.IsInventory = r.isInventory + if r.redeemedBy.Valid { + redeemedBy := r.redeemedBy.String + gc.RedeemedBy = &redeemedBy + } + + var purchaserID string + var purchaseAmount float64 + var purchasedAt time.Time + err := db.Conn.QueryRow(ctx, ` + SELECT amount, created_at, COALESCE(user_id, '') + FROM gift_card_transactions + WHERE gift_card_id = $1 AND transaction_type = 'purchase' + AND reference_type = 'api' + ORDER BY created_at DESC + LIMIT 1`, r.id).Scan(&purchaseAmount, &purchasedAt, &purchaserID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + log.Printf("Failed to load purchase transaction for gift card %s: %v", r.id, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + status := assessGiftCardCancellation(ctx, db.Conn, r.id, r.totalFunds, r.remaining, r.isInventory, r.expiryDate, purchaseAmount, purchasedAt, purchaserID) + gc.Cancellable = status.Cancellable + gc.CancellationReason = status.CancellationReason resp.GiftCards = append(resp.GiftCards, gc) } @@ -388,6 +441,28 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) { return } + // C2: cap the admin-funded amount at £250 (25,000 pence) per transaction + // (owner decision — tighter than the £10,000 ceiling ValidateAmount + // enforces on other payment entry points, and matching the till's + // maxTillGiftCardAmountPence). An inventory card may still be created at £0. + if int64(math.Round(req.Amount*100)) > maxAdminGiftCardTransactionPence { + http.Error(w, "Amount exceeds the maximum of £250", http.StatusBadRequest) + return + } + + // Daily limit (owner decision): an admin may create/top-up/transfer at + // most £5,000 of gift-card value per UTC day. Enforced before any write. + adminValueToday, err := adminGiftCardValueToday(ctx, db.Conn, adminID) + if err != nil { + log.Printf("Failed to query admin gift-card value today for %s: %v", adminID, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if int64(math.Round(adminValueToday*100))+int64(math.Round(req.Amount*100)) > maxAdminGiftCardDailyPence { + http.Error(w, "You have reached your £5,000 daily gift-card value limit", http.StatusBadRequest) + return + } + tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) @@ -490,6 +565,27 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) { return } + // C2: cap the top-up at £250 (25,000 pence) per transaction (owner + // decision — tighter than the £10,000 ceiling ValidateAmount enforces on + // other payment entry points, and matching the till's cap). + if int64(math.Round(req.Amount*100)) > maxAdminGiftCardTransactionPence { + http.Error(w, "Amount exceeds the maximum of £250", http.StatusBadRequest) + return + } + + // Daily limit (owner decision): an admin may create/top-up/transfer at + // most £5,000 of gift-card value per UTC day. Enforced before any write. + adminValueToday, err := adminGiftCardValueToday(ctx, db.Conn, adminID) + if err != nil { + log.Printf("Failed to query admin gift-card value today for %s: %v", adminID, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if int64(math.Round(adminValueToday*100))+int64(math.Round(req.Amount*100)) > maxAdminGiftCardDailyPence { + http.Error(w, "You have reached your £5,000 daily gift-card value limit", http.StatusBadRequest) + return + } + validMethods := map[string]string{ "cash": "cash", "card_machine": "in_person_card", @@ -597,6 +693,7 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) { func TransferGiftCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + adminID, _ := ctx.Value(mw.UserIDKey).(string) fromCardID := chi.URLParam(r, "from") if fromCardID == "" || !validators.IsValidID(fromCardID) { http.Error(w, "Invalid source gift card ID", http.StatusBadRequest) @@ -625,6 +722,63 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) { return } + // C2: cap the transfer at £250 (25,000 pence) per transaction (owner + // decision — tighter than the £10,000 ceiling ValidateAmount enforces on + // other payment entry points, and matching the till's cap). + if int64(math.Round(req.Amount*100)) > maxAdminGiftCardTransactionPence { + http.Error(w, "Amount exceeds the maximum of £250", http.StatusBadRequest) + return + } + + // Daily limit (owner decision): an admin may create/top-up/transfer at + // most £5,000 of gift-card value per UTC day. Enforced before any write + // (and before the advisory lock acquisition below). + adminValueToday, err := adminGiftCardValueToday(ctx, db.Conn, adminID) + if err != nil { + log.Printf("Failed to query admin gift-card value today for %s: %v", adminID, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if int64(math.Round(adminValueToday*100))+int64(math.Round(req.Amount*100)) > maxAdminGiftCardDailyPence { + http.Error(w, "You have reached your £5,000 daily gift-card value limit", http.StatusBadRequest) + return + } + + // Serialize against a concurrent cancellation of the SOURCE card: the + // gift-card cancel flow (CancelGiftCard) holds this session advisory lock + // across its eligibility check AND its funding reversal, so a transfer + // moving value OFF the source mid-cancellation would otherwise return the + // customer's money while the transferred balance stays live (double value). + // Taking the same lock here makes transfer-from and cancel mutually + // exclusive (C2/F2). A transfer INTO a card is covered by the cancel + // flow's FOR UPDATE re-verification (the destination's balance change makes + // it ineligible), so only the source needs the lock. + pinConn, err := db.Conn.Acquire(ctx) + if err != nil { + log.Printf("Failed to acquire connection for gift-card transfer lock: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + defer pinConn.Release() + lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:giftcard-cancel:"+fromCardID) + if err != nil { + log.Printf("Failed to acquire gift-card cancel lock for %s: %v", fromCardID, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if !lockOK { + log.Printf("Gift-card transfer lock for %s not acquired within bound — a cancellation is in progress", fromCardID) + http.Error(w, "This gift card is being processed — please try again in a moment", http.StatusConflict) + return + } + defer func() { + if _, err := pinConn.Exec(context.Background(), ` + SELECT pg_advisory_unlock(hashtext('crussell:giftcard-cancel:' || $1)) + `, fromCardID); err != nil { + log.Printf("Failed to release gift-card cancel lock for %s: %v", fromCardID, err) + } + }() + tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) @@ -773,6 +927,38 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) { return } + // Serialize against a concurrent cancellation of this same card: the + // gift-card cancel flow (CancelGiftCard) holds this session advisory lock + // across its eligibility check AND its funding reversal, so redeeming the + // balance mid-cancellation would otherwise return the customer's money + // while the balance stays live (double value). Taking the same lock here + // makes redeem and cancel mutually exclusive (C2/F2). + pinConn, err := db.Conn.Acquire(ctx) + if err != nil { + log.Printf("Failed to acquire connection for gift-card redeem lock: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + defer pinConn.Release() + lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:giftcard-cancel:"+code) + if err != nil { + log.Printf("Failed to acquire gift-card cancel lock for %s: %v", code, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if !lockOK { + log.Printf("Gift-card redeem lock for %s not acquired within bound — a cancellation is in progress", code) + http.Error(w, "This gift card is being processed — please try again in a moment", http.StatusConflict) + return + } + defer func() { + if _, err := pinConn.Exec(context.Background(), ` + SELECT pg_advisory_unlock(hashtext('crussell:giftcard-cancel:' || $1)) + `, code); err != nil { + log.Printf("Failed to release gift-card cancel lock for %s: %v", code, err) + } + }() + tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) @@ -1102,6 +1288,21 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { } } + // Daily limit (owner decision): a user may buy at most £500 of online + // gift cards per UTC day. Sums only COMPLETED online purchases (the + // gift_card_transactions rows BuyGiftCard writes) so a pending-retry of a + // failed Square attempt is never blocked by its own un-issued value. + spentToday, err := userGiftCardSpentToday(ctx, db.Conn, userID) + if err != nil { + log.Printf("Failed to query user gift-card spend today for %s: %v", userID, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if int64(math.Round(spentToday*100))+req.Amount > maxUserGiftCardDailyPence { + http.Error(w, "You have reached your £500 daily gift-card purchase limit", http.StatusBadRequest) + return + } + // 2FA gating (C5): charging a SAVED card requires 2FA when the feature is // enforced. New-card (nonce) charges are not gated. if req.CardID != nil && *req.CardID != "" { @@ -1146,8 +1347,25 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { // Refresh square_source_id: this attempt may charge a DIFFERENT token // than the failed attempt (one-time cnon: nonces are spent), and the // sweep replays the charge from the stored source. - if _, srcErr := tx.Exec(ctx, `UPDATE payments SET square_source_id = $1 WHERE id = $2`, sourceID, reusePendingID); srcErr != nil { - log.Printf("Failed to update square_source_id on reused gift-card payment %s: %v", reusePendingID, srcErr) + // B6: keep square_request_snapshot's source_id in sync in the SAME + // statement — the sweep replays the stored snapshot verbatim, and a + // snapshot carrying the spent source would replay into + // IDEMPOTENCY_KEY_REUSED, stranding the row pending forever. + if _, srcErr := tx.Exec(ctx, ` + UPDATE payments + SET square_source_id = $1, + square_request_snapshot = jsonb_set( + COALESCE(square_request_snapshot, '{}')::jsonb, + -- Go field-name JSON key, matching the other snapshot writers + -- (handlers.go) — a lowercase key would ADD a duplicate + -- {source_id} while the spent {SourceID} key stays stale, + -- making the replay body wrong (C6/F9). + '{SourceID}', + to_jsonb($1::text) + )::text + WHERE id = $2 + `, sourceID, reusePendingID); srcErr != nil { + log.Printf("Failed to update square_source_id/square_request_snapshot on reused gift-card payment %s: %v", reusePendingID, srcErr) } buyPaymentID = reusePendingID } else { @@ -1602,3 +1820,926 @@ func ClaimExpiredBalance(w http.ResponseWriter, r *http.Request) { log.Printf("Failed to encode JSON response: %v", err) } } + +// --- 14-day cooling-off cancellation (C3) --- +// +// The Gift Card T&C is a binding UK consumer contract that grants a 14-day +// right to cancel online gift-card purchases with a refund to the original +// payment method (Consumer Contracts (Information, Cancellation and Additional +// Charges) Regulations 2013). Until this endpoint existed there was NO code +// path to execute that right: RefundPayment explicitly rejects +// gift-card-purchase payments (booking_id IS NULL) and no cancel route/UI +// existed — a legal exposure. +// +// Eligibility signal for "purchased online by this user within 14 days": +// - A gift_card_transactions row on the card with transaction_type='purchase', +// reference_type='api' and user_id = the authenticated caller. BuyGiftCard +// (the only customer-facing online purchase path) writes exactly this. +// Till/admin sales can NEVER match: till sales write reference_type +// 'till_sale' (see till.go) and admin-created cards (CreateGiftCard) write +// reference_type='api' but with the ADMIN's user id, never the caller's. +// - The card itself must be unredeemed (redeemed_by IS NULL — self-purchases +// auto-redeem and are therefore not cancellable), non-inventory, not +// expired, and still hold exactly its original purchase value +// (total_funds_added == amount_remaining == purchase amount — a top-up, +// transfer, or spend breaks this). +// - The originating payments row: booking_id IS NULL (the same discriminator +// RefundPayment and the stale-pending sweep use for gift-card purchases), +// created_by = the buyer, payment_method='online_square', status='completed', +// with a square_payment_id — the target of the refund. + +// giftCardCoolingOffPeriod is the 14-day statutory cancellation window +// (Consumer Contracts (Information, Cancellation and Additional Charges) +// Regulations 2013, regs. 29-38). +const giftCardCoolingOffPeriod = 14 * 24 * time.Hour + +// giftCardCancelRefundReason is the Square refund reason. Square's +// refund-reason limit is 192 chars; this is comfortably under it. +const giftCardCancelRefundReason = "Gift card cancelled within 14-day cooling-off period (Consumer Contracts Regulations 2013)" + +type CancelGiftCardRequest struct { + Code string `json:"code"` + // PaymentID optionally identifies the originating purchase payment so the + // handler skips the amount/timing match. When omitted the purchase payment + // is located from the gift-card purchase transaction. + PaymentID string `json:"payment_id,omitempty"` +} + +// MyGiftCard is one of the caller's online-purchased (unredeemed) gift cards, +// with the expiry date (T&C: "the expiry date is displayed in your account") +// and whether the 14-day cancellation right currently applies. +type MyGiftCard struct { + Code string `json:"code"` + Amount float64 `json:"amount"` + PurchasedAt time.Time `json:"purchased_at"` + ExpiryDate *time.Time `json:"expiry_date,omitempty"` + Cancellable bool `json:"cancellable"` + CancellationReason string `json:"cancellation_reason,omitempty"` + PaymentID string `json:"payment_id,omitempty"` +} + +type MyGiftCardsResponse struct { + GiftCards []MyGiftCard `json:"gift_cards"` +} + +// GetMyGiftCards lists the authenticated user's customer-facing online +// gift-card purchases that are still held as cards (unredeemed — a "self" +// purchase is auto-redeemed into the pooled balance and so never appears +// here). Each entry carries the rolling expiry date and whether the 14-day +// cooling-off cancellation right applies, so the account page can surface a +// "Cancel & refund" action exactly where the consumer is legally entitled to +// one. +func GetMyGiftCards(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userID, ok := ctx.Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // Only gift_card_transactions rows created by BuyGiftCard match + // (reference_type='api' + user_id = caller); till sales ('till_sale') and + // admin-created cards (user_id = admin) never appear. + rows, err := db.Conn.Query(ctx, ` + SELECT gc.id, gc.total_funds_added, gc.amount_remaining, gc.is_inventory, + gc.expiry_date, gct.amount AS purchase_amount, gct.created_at AS purchased_at + FROM gift_card_transactions gct + JOIN gift_cards gc ON gc.id = gct.gift_card_id + WHERE gct.user_id = $1 AND gct.transaction_type = 'purchase' + AND gct.reference_type = 'api' + AND gc.redeemed_by IS NULL + ORDER BY gct.created_at DESC + `, userID) + if err != nil { + log.Printf("Failed to query user gift cards: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // Drain all rows before the per-card payment/refund lookups below — + // pgx.Tx (used by the test harness via a context-stored transaction) does + // not support concurrent queries on one connection. + type giftCardRow struct { + code string + totalFunds float64 + remaining float64 + isInventory bool + expiryDate sql.NullTime + purchaseAmount float64 + purchasedAt time.Time + } + var drained []giftCardRow + for rows.Next() { + var r giftCardRow + if err := rows.Scan(&r.code, &r.totalFunds, &r.remaining, &r.isInventory, &r.expiryDate, &r.purchaseAmount, &r.purchasedAt); err != nil { + log.Printf("Failed to scan user gift card: %v", err) + continue + } + drained = append(drained, r) + } + rows.Close() + if err := rows.Err(); err != nil { + log.Printf("User gift card row iteration error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + out := []MyGiftCard{} + for _, r := range drained { + gc := MyGiftCard{ + Code: r.code, + Amount: r.purchaseAmount, + PurchasedAt: r.purchasedAt, + } + if r.expiryDate.Valid { + expiry := r.expiryDate.Time + gc.ExpiryDate = &expiry + } + + status := assessGiftCardCancellation(ctx, db.Conn, r.code, r.totalFunds, r.remaining, r.isInventory, r.expiryDate, r.purchaseAmount, r.purchasedAt, userID) + gc.Cancellable = status.Cancellable + gc.CancellationReason = status.CancellationReason + gc.PaymentID = status.PaymentID + out = append(out, gc) + } + if out == nil { + out = []MyGiftCard{} + } + + if err := json.NewEncoder(w).Encode(MyGiftCardsResponse{GiftCards: out}); err != nil { + log.Printf("Failed to encode JSON response: %v", err) + } +} + +// giftCardCancellationStatus captures whether a gift card is currently +// cancellable under the 14-day cooling-off right, and why not when it is not. +type giftCardCancellationStatus struct { + Cancellable bool + CancellationReason string + PaymentID string // the originating purchase payment, when cancellable +} + +// assessGiftCardCancellation evaluates a single gift card's cancellation +// eligibility under the 14-day cooling-off rules (CCR 2013 regs. 29-38) using +// the EXACT same signal set and reason strings as the user-facing +// GetMyGiftCards list, so the admin list can surface the same state. +// +// purchaserID is the owner of the card's online purchase transaction +// (transaction_type='purchase', reference_type='api'); empty means the card +// was never purchased online (inventory/till/admin-created), so it can never +// be cancellable. The caller must have drained all open rows before calling — +// q must not be an open rows cursor (pgx.Tx cannot run a second query on the +// same connection). +func assessGiftCardCancellation(ctx context.Context, q db.Querier, code string, totalFunds, remaining float64, isInventory bool, expiryDate sql.NullTime, purchaseAmount float64, purchasedAt time.Time, purchaserID string) giftCardCancellationStatus { + var st giftCardCancellationStatus + + if purchaserID == "" { + st.CancellationReason = "This gift card was not purchased online, so it cannot be cancelled" + return st + } + + // A partially spent card is only cancellable when its shortfall is + // verified till spend (reg 34(9) refunds the UNSPENT balance); top-ups + // and unaccounted shortfalls (transfers/clawbacks) are not. + partialSpendOK := true + if !approxEqual(totalFunds, purchaseAmount) { + partialSpendOK = false + } else if !approxEqual(remaining, purchaseAmount) { + if remaining <= 0 { + partialSpendOK = false + } else { + spentAtTill, serr := giftCardSpendAtTill(ctx, q, code) + if serr != nil { + log.Printf("Failed to verify till spend for gift card %s: %v", code, serr) + partialSpendOK = false + } else { + partialSpendOK = approxEqual(spentAtTill+remaining, purchaseAmount) + } + } + } + + switch { + case isInventory: + st.CancellationReason = "This card was created as shop stock, not purchased online" + case !approxEqual(totalFunds, purchaseAmount): + st.CancellationReason = "This gift card has been topped up, transferred, or otherwise altered" + case !approxEqual(remaining, purchaseAmount) && !partialSpendOK: + if remaining <= 0 { + st.CancellationReason = "This gift card has no remaining balance to refund" + } else { + st.CancellationReason = "This gift card has been topped up, transferred, or partially spent in a way that cannot be verified" + } + case expiryDate.Valid && expiryDate.Time.Before(clock.Now()): + st.CancellationReason = "This gift card has expired" + case purchasedAt.Before(clock.Now().Add(-giftCardCoolingOffPeriod)): + st.CancellationReason = "The 14-day cancellation period has expired" + default: + paymentID, _, paymentOK, perr := findGiftCardPurchasePayment(ctx, q, purchaserID, "", purchaseAmount, purchasedAt) + if perr != nil { + log.Printf("Failed to locate purchase payment for gift card %s: %v", code, perr) + st.CancellationReason = "The original purchase payment could not be verified" + } else if !paymentOK { + st.CancellationReason = "The original purchase payment could not be found" + } else { + // A completed/pending refund row means the money already + // returned (or is in flight) — the card cannot be cancelled a + // second time. + var refundStatus string + err := q.QueryRow(ctx, ` + SELECT status FROM refunds + WHERE payment_id = $1 AND status IN ('completed', 'pending') + LIMIT 1`, paymentID).Scan(&refundStatus) + switch { + case err == nil: + st.CancellationReason = "This gift card has already been refunded" + case errors.Is(err, pgx.ErrNoRows): + st.Cancellable = true + st.PaymentID = paymentID + default: + log.Printf("Failed to check refund status for payment %s: %v", paymentID, err) + st.CancellationReason = "Unable to verify the refund status of this gift card" + } + } + } + return st +} + +// CancelGiftCard resolves the authenticated caller as the cancellation actor +// and delegates to the shared cancellation core (see cancelGiftCardForUser for +// the full money-safety contract). +func CancelGiftCard(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userID, ok := ctx.Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + var req CancelGiftCardRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + cancelGiftCardForUser(ctx, w, r, userID, req) +} + +// AdminCancelGiftCard is the admin-side cancellation surface for the 14-day +// cooling-off right: it lets a partially spent online-purchased card be +// managed from the admin Gift Card Management screen. The admin route group +// (mw.RequireAdmin in main.go) guarantees the role; the admin's user id is the +// ACTOR (recorded on the refunds row and the 'cancelled' audit transaction), +// while the cancellation core still verifies the card's purchase transaction +// against its ACTUAL purchaser, so an admin can act on behalf of the real +// owner without weakening the money-safety invariants. +func AdminCancelGiftCard(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + adminID, ok := ctx.Value(mw.UserIDKey).(string) + if !ok || adminID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + if !isAdminRequest(r) { + http.Error(w, "Admin access required", http.StatusForbidden) + return + } + + var req CancelGiftCardRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + cancelGiftCardForUser(ctx, w, r, adminID, req) +} + +// cancelGiftCardForUser executes the statutory 14-day right to cancel an +// online gift-card purchase: it issues a Square refund to the original payment +// method — the FULL purchase value for an unspent card, or the UNSPENT +// remainder for a card whose shortfall is verified till spend (reg 34(9)) — +// then reverses the gift-card funding (amount_remaining zeroed and the +// card expired so the balance can never be spent). Registered under +// mw.RequireAuth + mw.RequireNonGuest (guests cannot buy gift cards); the +// admin surface (AdminCancelGiftCard) reuses this same core with the admin as +// the actor. +// +// Failure safety: the refunds row is inserted (pending) BEFORE the Square call +// with a deterministic idempotency key; if Square fails, the row stays pending +// for a same-key retry (Square dedups) and the gift card is NOT touched — the +// customer keeps the card and the request returns 5xx for a retry. Only once +// Square accepts the refund is the card reversed, so a refund can never be +// issued without the card value being neutralised, and the card can never be +// cancelled while the money is still with the salon. +func cancelGiftCardForUser(ctx context.Context, w http.ResponseWriter, r *http.Request, userID string, req CancelGiftCardRequest) { + code := validators.NormalizeGiftCardCode(req.Code) + if !validators.IsValidID(code) { + http.Error(w, "Invalid gift card code format", http.StatusBadRequest) + return + } + if req.PaymentID != "" && !validators.IsValidID(req.PaymentID) { + http.Error(w, "Invalid payment id", http.StatusBadRequest) + return + } + + // Serialize cancellation attempts per gift card (bounded try-lock, + // mirroring the RefundPayment handler) so two concurrent cancels of the + // same card cannot both pass the eligibility + refunds-row guards. + pinConn, err := db.Conn.Acquire(ctx) + if err != nil { + log.Printf("Failed to acquire connection for gift-card cancel lock: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + defer pinConn.Release() + lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:giftcard-cancel:"+code) + if err != nil { + log.Printf("Failed to acquire gift-card cancel lock for %s: %v", code, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if !lockOK { + log.Printf("Gift-card cancel lock for %s not acquired within bound — a cancellation is already in progress", code) + http.Error(w, "A gift-card cancellation is already in progress, try again", http.StatusConflict) + return + } + defer func() { + if _, err := pinConn.Exec(context.Background(), ` + SELECT pg_advisory_unlock(hashtext('crussell:giftcard-cancel:' || $1)) + `, code); err != nil { + log.Printf("Failed to release gift-card cancel lock for %s: %v", code, err) + } + }() + + tx, err := db.Conn.Begin(ctx) + if err != nil { + log.Printf("Failed to begin gift-card cancel transaction: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + defer func() { + if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { + slog.Error("failed to rollback gift-card cancel transaction", "err", err) + } + }() + + // Lock the card FOR UPDATE so concurrent cancels / top-ups / transfers + // serialize on the eligibility read below. + var totalFunds, remaining float64 + var redeemedBy sql.NullString + var isInventory bool + var expiryDate sql.NullTime + err = tx.QueryRow(ctx, ` + SELECT total_funds_added, amount_remaining, redeemed_by, is_inventory, expiry_date + FROM gift_cards WHERE id = $1 FOR UPDATE`, code). + Scan(&totalFunds, &remaining, &redeemedBy, &isInventory, &expiryDate) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Gift card not found", http.StatusNotFound) + return + } + log.Printf("Failed to load gift card %s for cancellation: %v", code, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // --- Eligibility (C3) --- + if isInventory { + http.Error(w, "This gift card was created as shop stock, not purchased online — it cannot be cancelled", http.StatusBadRequest) + return + } + if redeemedBy.Valid { + http.Error(w, "This gift card has already been redeemed to an account balance and cannot be cancelled", http.StatusBadRequest) + return + } + + // The card must be traceable to a customer-facing online purchase (see the + // signal documentation above). The purchase row is resolved WITHOUT + // filtering on the actor's user id so the card's TRUE purchaser is found: + // in the user flow the actor must BE the purchaser (enforced below), while + // an admin acts on behalf of the card's real owner. + var purchaseAmount float64 + var purchasedAt time.Time + var purchaserID string + err = tx.QueryRow(ctx, ` + SELECT amount, created_at, COALESCE(user_id, '') + FROM gift_card_transactions + WHERE gift_card_id = $1 AND transaction_type = 'purchase' + AND reference_type = 'api' + ORDER BY created_at DESC + LIMIT 1`, code).Scan(&purchaseAmount, &purchasedAt, &purchaserID) + if errors.Is(err, pgx.ErrNoRows) { + if isAdminRequest(r) { + http.Error(w, "This gift card was not purchased online, so it cannot be cancelled", http.StatusBadRequest) + } else { + http.Error(w, "This gift card was not purchased online by your account, so it cannot be cancelled here", http.StatusBadRequest) + } + return + } + if err != nil { + log.Printf("Failed to load gift-card purchase transaction for %s: %v", code, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if purchaserID != userID && !isAdminRequest(r) { + http.Error(w, "This gift card was not purchased online by your account, so it cannot be cancelled here", http.StatusBadRequest) + return + } + + if purchasedAt.Before(clock.Now().Add(-giftCardCoolingOffPeriod)) { + http.Error(w, "The 14-day cancellation period for this gift card has expired", http.StatusBadRequest) + return + } + + // Eligibility (C3): a full-refund cancellation is only valid while the + // card still holds exactly its original purchase value. A partially spent + // card remains cancellable when the shortfall is verified till spend — + // reg 34(9) entitles the consumer to the UNSPENT balance back while the + // value already consumed on services stays with the salon. Top-ups, + // redeemed cards, and unaccounted shortfalls (transfers/clawbacks) are + // rejected outright. + refundAmount := purchaseAmount + partialRefund := false + var spentAtTill float64 + if !approxEqual(totalFunds, purchaseAmount) { + http.Error(w, "This gift card has been topped up, transferred, or partially spent and can no longer be cancelled for a full refund", http.StatusBadRequest) + return + } + if !approxEqual(remaining, purchaseAmount) { + if remaining <= 0 { + http.Error(w, "This gift card has no remaining balance to refund", http.StatusBadRequest) + return + } + spentAtTill, err = giftCardSpendAtTill(ctx, tx, code) + if err != nil { + log.Printf("Failed to verify till spend for gift card %s: %v", code, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if !approxEqual(spentAtTill+remaining, purchaseAmount) { + http.Error(w, "This gift card has been partially spent, transferred, or otherwise altered and can no longer be cancelled; the remaining balance can be spent or redeemed to your account balance", http.StatusBadRequest) + return + } + refundAmount = remaining + partialRefund = true + } + + if expiryDate.Valid && expiryDate.Time.Before(clock.Now()) { + http.Error(w, "This gift card has already expired and cannot be cancelled", http.StatusBadRequest) + return + } + + // --- Locate the originating purchase payment (Square charge) --- + // The payment is matched against the card's TRUE purchaser, never the + // actor: for the user flow purchaserID == userID (enforced above), and for + // an admin cancellation the card's owner is used. + paymentID, squarePaymentID, paymentOK, perr := findGiftCardPurchasePayment(ctx, tx, purchaserID, req.PaymentID, purchaseAmount, purchasedAt) + if perr != nil { + log.Printf("Failed to locate purchase payment for gift card %s: %v", code, perr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if !paymentOK { + http.Error(w, "The original purchase payment for this gift card could not be found, so it cannot be refunded", http.StatusBadRequest) + return + } + + refundKey := paymentID + "-gccancel-" + strconv.FormatInt(int64(math.Round(refundAmount*100)), 10) + refundID := "" + resumingRefund := false + var priorRefundID, priorStatus, priorKey string + var priorAmount float64 + err = tx.QueryRow(ctx, ` + SELECT id, status, amount, COALESCE(idempotency_key, '') + FROM refunds WHERE payment_id = $1 + ORDER BY created_at DESC, id DESC LIMIT 1`, paymentID). + Scan(&priorRefundID, &priorStatus, &priorAmount, &priorKey) + switch { + case errors.Is(err, pgx.ErrNoRows): + // Fresh cancellation — insert the pending refund row below. + case err != nil: + log.Printf("Failed to check prior refund for payment %s: %v", paymentID, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + default: + switch priorStatus { + case "completed": + if priorAmount >= refundAmount-0.005 { + // The full cancellation entitlement (the full purchase value, + // or the unspent remainder of a partially spent card) was + // already refunded. Neutralise the card so the value cannot + // be spent on top of the returned money, then report. + if remaining > 0 { + if cerr := cancelGiftCardFunding(ctx, tx, code, userID, priorRefundID, refundAmount); cerr != nil { + log.Printf("CRITICAL: gift card %s was already refunded but funding reversal failed: %v — MANUAL RECONCILIATION REQUIRED", code, cerr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + } + if cerr := tx.Commit(ctx); cerr != nil { + log.Printf("Failed to commit gift-card cancel transaction: %v", cerr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]any{ + "status": "success", + "message": "This gift card was already refunded and has now been cancelled.", + "amount_refunded": refundAmount, + }); err != nil { + log.Printf("Failed to encode JSON response: %v", err) + } + return + } + // Partial prior refund (C6/F8): the card still holds its full + // cancellation entitlement (the full purchase value, or the unspent + // remainder of a partially spent card) but only part of it was + // returned. Refund the DIFFERENCE via Square with a fresh + // deterministic key before neutralising, so the un-refunded remainder + // is never silently swallowed. + refundAmount = refundAmount - priorAmount + refundKey = paymentID + "-gccancel-diff-" + strconv.FormatInt(int64(math.Round(refundAmount*100)), 10) + log.Printf("Gift-card purchase %s has a prior partial refund of £%.2f — issuing the £%.2f remainder", paymentID, priorAmount, refundAmount) + case "pending": + // In-flight refund for this payment. Only resume if the pending + // row is OURS; a foreign pending row must not be re-issued. + if !strings.HasPrefix(priorKey, paymentID+"-gccancel-") { + log.Printf("Gift-card cancel rejected: payment %s has a pending refund row %s from another flow", paymentID, priorRefundID) + http.Error(w, "A refund for this gift card purchase is already being processed — please try again later", http.StatusConflict) + return + } + refundID = priorRefundID + refundKey = priorKey + refundAmount = priorAmount + resumingRefund = true + case "failed": + // Money provably never moved — safe to re-issue with the stored + // key (or persist the deterministic key when a legacy row has + // none, mirroring ensureRefundKey). + refundID = priorRefundID + refundAmount = priorAmount + resumingRefund = true + if priorKey != "" { + refundKey = priorKey + } else { + if _, uErr := tx.Exec(ctx, ` + UPDATE refunds SET idempotency_key = $1 + WHERE id = $2 AND idempotency_key IS NULL`, refundKey, refundID); uErr != nil { + log.Printf("Failed to persist refund idempotency key for %s: %v", refundID, uErr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + } + } + } + + if refundID == "" { + // Insert the pending refund row. booking_id stays NULL for + // gift-card purchases (the payments row has no booking). + err = tx.QueryRow(ctx, ` + INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin) + VALUES ($1, NULL, $2, 'pending', $3, $4, $5, $6, 'giftcard_cancel') + ON CONFLICT (idempotency_key) DO NOTHING + RETURNING id`, + paymentID, refundAmount, giftCardCancelRefundReason, refundKey, userID, clock.Now()).Scan(&refundID) + if errors.Is(err, pgx.ErrNoRows) { + // A same-key row exists from a concurrent attempt (unreachable + // under the advisory lock, but stay safe): treat it as ours. + if rErr := tx.QueryRow(ctx, `SELECT id FROM refunds WHERE idempotency_key = $1`, refundKey).Scan(&refundID); rErr != nil { + log.Printf("Failed to re-read concurrent refund row for key %s: %v", refundKey, rErr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + } else if err != nil { + log.Printf("Failed to insert gift-card cancel refund record: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + } + + if err := tx.Commit(ctx); err != nil { + log.Printf("Failed to commit gift-card cancel transaction: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // resolveRefundRow updates the committed refunds row to a definitive + // outcome on the FAILURE paths below, where the reversal transaction has + // already been rolled back (no money moved / declined / ambiguous). On the + // success paths the row is instead resolved INSIDE the reversal + // transaction, so 'completed' commits atomically with the funding + // reversal (C2/F4) — a completed refund can never coexist with a live + // card. + resolveRefundRow := func(status, sqRefundID string) { + _, upErr := db.Conn.Exec(ctx, `UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`, status, sqRefundID, refundID) + if upErr != nil { + slog.Error("CRITICAL: Square refund resolved but refund row update failed — manual reconciliation required", "refund_id", refundID, "err", upErr) + } + } + + // --- Reversal + refund transaction (C2/F2, C2/F4) --- + // The eligibility transaction above committed with the card still holding + // its full purchase value. Between that commit and the reversal the card + // must not be redeemable or transferable — the crussell:giftcard-cancel + // advisory lock (held for this whole handler, and now also taken by + // RedeemGiftCard and TransferGiftCard) serializes those operations against + // this cancellation. The reversal transaction below re-verifies under + // FOR UPDATE as a second line of defence, and the Square refund is issued + // only AFTER that re-verification passes. + rtx, rerr := db.Conn.Begin(ctx) + if rerr != nil { + slog.Error("CRITICAL: no transaction available to re-verify and reverse gift card", "gift_card_id", code, "refund_id", refundID, "err", rerr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + defer func() { + if err := rtx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { + slog.Error("failed to rollback gift-card reversal transaction", "err", err) + } + }() + + var revTotalFunds, revRemaining float64 + var revRedeemedBy sql.NullString + var revIsInventory bool + var revExpiry sql.NullTime + err = rtx.QueryRow(ctx, ` + SELECT total_funds_added, amount_remaining, redeemed_by, is_inventory, expiry_date + FROM gift_cards WHERE id = $1 FOR UPDATE`, code). + Scan(&revTotalFunds, &revRemaining, &revRedeemedBy, &revIsInventory, &revExpiry) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Gift card not found", http.StatusNotFound) + return + } + log.Printf("Failed to re-load gift card %s for cancellation reversal: %v", code, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // A partially spent card must re-verify against the SAME remaining value + // that was spend-verified and funded at eligibility; a full-value card + // re-verifies against the full purchase amount. + expectRemaining := purchaseAmount + if partialRefund { + expectRemaining = remaining + } + if revIsInventory || revRedeemedBy.Valid || !approxEqual(revTotalFunds, purchaseAmount) || !approxEqual(revRemaining, expectRemaining) || (revExpiry.Valid && revExpiry.Time.Before(clock.Now())) { + // The card changed between the eligibility commit and the reversal — + // redeemed, spent, transferred, or expired. Do NOT refund on top of + // the live balance (double value). The reversal tx has no writes and + // rolls back via defer, so the card is untouched. + if resumingRefund { + // Resumed a prior pending/failed row — Square money state is + // unknown, so leave the row for the sweep to reconcile. + slog.Error("CRITICAL: gift card %s changed state while a gift-card-cancel refund was pending (refund %s) — refund row left pending for sweep reconciliation", "gift_card_id", code, "refund_id", refundID) + } else { + // Freshly-inserted row in THIS request — Square was never called, + // so no money moved: mark it failed so it never blocks a retry. + // Roll back the reversal tx first so the failed-status write is + // not undone by the deferred rollback. + if rbErr := rtx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + log.Printf("Failed to rollback gift-card reversal transaction before marking refund failed: %v", rbErr) + } + log.Printf("Gift-card cancel aborted for %s: card state changed since eligibility — refund row %s marked failed, Square never called", code, refundID) + resolveRefundRow("failed", "") + } + http.Error(w, "This gift card has been redeemed, spent, or transferred since the cancellation was requested and can no longer be refunded", http.StatusConflict) + return + } + + // Re-verified still eligible — issue the Square refund now. The refunds + // row is already committed (pending) with a deterministic key, so a + // crash/response-loss retry re-reads the row and resumes with the SAME + // key — Square dedups and the card is never double-refunded. + refundResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{ + PaymentID: squarePaymentID, + Amount: int64(math.Round(refundAmount * 100)), + IdempotencyKey: refundKey, + Reason: giftCardCancelRefundReason, + }) + + switch { + case sqErr == nil: + status := "completed" + if refundResult.Status == "PENDING" { + // Square accepted the refund; the money is in flight. The card is + // still neutralised immediately — leaving the balance spendable + // while the refund is on its way would create value from nothing. + // The pending row stays for the sweep to reconcile. + status = "pending" + log.Printf("Square refund %s for gift-card purchase payment %s is PENDING — refund row left pending for reconciliation", refundResult.ID, paymentID) + } else if refundResult.Status == "FAILED" || refundResult.Status == "REJECTED" { + status = "failed" + log.Printf("Square refund %s for gift-card purchase payment %s FAILED — refund row marked failed", refundResult.ID, paymentID) + } + if status == "failed" { + // Roll back the reversal tx first so the failed-status write is + // not undone by the deferred rollback. + if rbErr := rtx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + log.Printf("Failed to rollback gift-card reversal transaction before marking refund failed: %v", rbErr) + } + resolveRefundRow("failed", refundResult.ID) + http.Error(w, "Refund failed", http.StatusInternalServerError) + return + } + + // Money accepted (COMPLETED) or in flight (PENDING) — reverse the + // gift-card funding and resolve the refunds row in THIS SAME + // transaction. On a COMPLETED refund the row flips to 'completed' only + // when this transaction commits, so a completed refund can never + // coexist with a live card (C2/F4). A PENDING Square refund stays + // 'pending' for the sweep. Any failure below rolls the whole + // transaction back: the card stays live and the row stays pending. + if cerr := cancelGiftCardFunding(ctx, rtx, code, userID, refundID, refundAmount); cerr != nil { + slog.Error("CRITICAL: Square refund accepted but gift card %s funding reversal failed — refund row left pending for sweep", "gift_card_id", code, "refund_id", refundID, "err", cerr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if status == "completed" { + if _, upErr := rtx.Exec(ctx, `UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`, refundResult.ID, refundID); upErr != nil { + slog.Error("CRITICAL: Square refund accepted but gift card %s refund row could not be resolved in the reversal transaction — row left pending", "gift_card_id", code, "refund_id", refundID, "err", upErr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + } else if _, upErr := rtx.Exec(ctx, `UPDATE refunds SET square_refund_id = $1 WHERE id = $2`, refundResult.ID, refundID); upErr != nil { + slog.Error("CRITICAL: Square refund PENDING but gift card %s refund row could not record square_refund_id — row left pending", "gift_card_id", code, "refund_id", refundID, "err", upErr) + } + if cerr := rtx.Commit(ctx); cerr != nil { + slog.Error("CRITICAL: Square refund accepted but gift card %s reversal commit failed — refund row left pending, card left live", "gift_card_id", code, "refund_id", refundID, "err", cerr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + message := "Gift card cancelled and the full amount refunded to your original payment method." + if partialRefund { + message = fmt.Sprintf("Gift card cancelled. £%.2f (the unspent portion) was refunded to your original payment method; the £%.2f already spent on salon services is not refundable.", refundAmount, spentAtTill) + } + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]any{ + "status": "success", + "message": message, + "refund_id": refundID, + "amount_refunded": refundAmount, + }); err != nil { + log.Printf("Failed to encode JSON response: %v", err) + } + return + + case errors.Is(sqErr, square.ErrRefundAlreadyProcessed): + // PAYMENT_ALREADY_REFUNDED — the money already moved at Square (a + // prior same-key attempt of this flow refunded it). The card was + // re-verified above and still holds its full value: reverse the + // funding and resolve the row 'completed' in the SAME transaction + // before returning success. + if cerr := cancelGiftCardFunding(ctx, rtx, code, userID, refundID, refundAmount); cerr != nil { + slog.Error("CRITICAL: Square refund already processed but gift card %s funding reversal failed — manual reconciliation required", "gift_card_id", code, "err", cerr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if _, upErr := rtx.Exec(ctx, `UPDATE refunds SET status = 'completed' WHERE id = $1`, refundID); upErr != nil { + slog.Error("CRITICAL: Square refund already processed but gift card %s refund row could not be resolved — manual reconciliation required", "gift_card_id", code, "err", upErr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if cerr := rtx.Commit(ctx); cerr != nil { + slog.Error("CRITICAL: Square refund already processed but gift card %s reversal commit failed — manual reconciliation required", "gift_card_id", code, "err", cerr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + log.Printf("Gift-card purchase %s already refunded at Square — refund %s marked completed, card %s cancelled", paymentID, refundID, code) + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(map[string]any{ + "status": "success", + "message": "This gift card had already been refunded and has now been cancelled.", + "refund_id": refundID, + "amount_refunded": refundAmount, + }); err != nil { + log.Printf("Failed to encode JSON response: %v", err) + } + return + + case errors.Is(sqErr, square.ErrRefundDeclined): + // Definitive rejection — the money will never move. Mark the refund + // failed so it is never retried and never blocks the amount, and DO + // NOT cancel the card (the customer keeps it — the reversal tx has no + // writes and rolls back below). + if rbErr := rtx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + log.Printf("Failed to rollback gift-card reversal transaction before marking refund failed: %v", rbErr) + } + resolveRefundRow("failed", "") + log.Printf("Gift-card purchase refund %s definitively declined by Square: %v", refundID, sqErr) + http.Error(w, "Refund failed", http.StatusInternalServerError) + return + + default: + // Ambiguous — Square may or may not have processed the refund. The + // reversal tx is rolled back (no writes) so the card stays live, the + // refunds row stays 'pending' for a same-key retry (Square dedups), + // and the gift card is NOT cancelled, per the failure-safety + // requirement. + log.Printf("Gift-card refund %s left pending after ambiguous Square error: %v", refundID, sqErr) + http.Error(w, "Refund failed — please try again", http.StatusInternalServerError) + return + } +} + +// giftCardSpendAtTill returns the total value of completed payments made +// against the gift card at the till. Till spend (handlers.go) decrements +// amount_remaining and records a payments row with payment_method='giftcard', +// status='completed' and gift_card_id = the card's code; transfers and +// top-ups deliberately do NOT create such rows. A shortfall between +// total_funds_added and amount_remaining that is not explained by this sum +// therefore signals an unverifiable alteration (a transfer, clawback, or +// partial refund), in which case the 14-day cancellation right cannot be +// exercised on the balance (reg 34(9) permits deducting only the value +// genuinely consumed on services and refunding the unspent remainder). +func giftCardSpendAtTill(ctx context.Context, q db.Querier, code string) (float64, error) { + var spentAtTill float64 + err := q.QueryRow(ctx, ` + SELECT COALESCE(SUM(amount), 0) FROM payments + WHERE gift_card_id = $1 AND status = 'completed'`, code).Scan(&spentAtTill) + if err != nil { + return 0, err + } + return spentAtTill, nil +} + +// findGiftCardPurchasePayment resolves the originating payments row for an +// online gift-card purchase (BuyGiftCard): booking_id IS NULL (the same +// discriminator RefundPayment and the stale-pending sweep use to identify +// gift-card purchases), created_by = the buyer, payment_method='online_square', +// status='completed' with a square_payment_id. When the client supplies the +// payment id it is verified against the same invariants instead of being +// matched by amount/timing. The payment row is created just before the Square +// charge and the gift card a moment later, so the auto-match uses a 15-minute +// window that ends at the purchase transaction's timestamp. +func findGiftCardPurchasePayment(ctx context.Context, q db.Querier, userID, suppliedPaymentID string, purchaseAmount float64, purchasedAt time.Time) (paymentID, squarePaymentID string, ok bool, err error) { + if suppliedPaymentID != "" { + var bookingID sql.NullString + var createdBy, status, method string + var sqID sql.NullString + var amount float64 + err := q.QueryRow(ctx, ` + SELECT id, booking_id, created_by, status, payment_method, square_payment_id, amount + FROM payments WHERE id = $1`, suppliedPaymentID). + Scan(&paymentID, &bookingID, &createdBy, &status, &method, &sqID, &amount) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", false, nil + } + if err != nil { + return "", "", false, err + } + if bookingID.Valid || createdBy != userID || status != "completed" || method != "online_square" { + return "", "", false, nil + } + if !sqID.Valid || !approxEqual(amount, purchaseAmount) { + return "", "", false, nil + } + return paymentID, sqID.String, true, nil + } + + err = q.QueryRow(ctx, ` + SELECT id, square_payment_id + FROM payments + WHERE booking_id IS NULL AND created_by = $1 + AND payment_method = 'online_square' AND status = 'completed' + AND square_payment_id IS NOT NULL + AND ABS(amount - $2) < 0.005 + AND created_at BETWEEN $3::timestamptz - INTERVAL '15 minutes' AND $3::timestamptz + INTERVAL '15 minutes' + ORDER BY created_at DESC + LIMIT 1`, userID, purchaseAmount, purchasedAt). + Scan(&paymentID, &squarePaymentID) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", false, nil + } + if err != nil { + return "", "", false, err + } + return paymentID, squarePaymentID, true, nil +} + +// cancelGiftCardFunding reverses an online-purchased gift card after its +// refund has been accepted: the balance is zeroed and the card expired so it +// can never be redeemed or spent, and a 'cancelled' gift_card_transactions row +// records the reversal against the refund. Runs inside the caller's +// transaction. +func cancelGiftCardFunding(ctx context.Context, tx pgx.Tx, code, userID, refundID string, amount float64) error { + if _, err := tx.Exec(ctx, ` + UPDATE gift_cards + SET amount_remaining = 0, expiry_date = NOW(), last_used_at = NOW() + WHERE id = $1`, code); err != nil { + return fmt.Errorf("failed to zero gift card %s: %w", code, err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) + VALUES ($1, 'cancelled', $2, 'refund', $3, $4, '14-day cooling-off cancellation — refunded to original payment method') + `, code, amount, refundID, userID); err != nil { + return fmt.Errorf("failed to record gift card cancellation transaction for %s: %w", code, err) + } + return nil +} + +// approxEqual reports whether two currency amounts are equal to the nearest +// penny (float64 scans of NUMERIC can carry tiny representation error). +func approxEqual(a, b float64) bool { + return math.Abs(a-b) < 0.005 +} diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index 4d2ee93..c29e4da 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -680,9 +680,15 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { return } } else { - // Refresh square_source_id on a reused pending row — the sweep - // replays the charge from the stored source. - if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2`, sourceID, paymentID); srcErr != nil { + // Reused pending row: same immutability rule as the booking/tip + // reuse paths. square_source_id is refreshed ONLY for snapshot-less + // legacy rows; when the row already carries the original + // square_request_snapshot it is left untouched so the sweep's + // by-key replay keeps matching the FIRST attempt's body. Saved-card + // ccof sources are stable, so this is mostly latent, but keeping + // the snapshot immutable is money-safe (see the booking reuse + // comment above). + if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, sourceID, paymentID); srcErr != nil { log.Printf("Failed to update square_source_id on reused saved-card payment %s: %v", paymentID, srcErr) } } @@ -710,10 +716,14 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { // M1: store the verbatim request JSON so the sweep can replay the charge // with an IDENTICAL body under the same key — Square compares the whole // request on key reuse, and a reconstructed body returns - // IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. + // IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. The snapshot + // is written ONLY when the row has none: it records the FIRST attempt's + // body, which stays immutable so a reused pending row never redirects + // the sweep's replay away from the original charge (same rule as the + // booking/tip paths — see the reuse branch above). if snap, mErr := json.Marshal(paymentReq); mErr != nil { log.Printf("Failed to marshal square_request_snapshot for saved-card payment %s: %v", paymentID, mErr) - } else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(snap), paymentID); sErr != nil { + } else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(snap), paymentID); sErr != nil { log.Printf("Failed to store square_request_snapshot for saved-card payment %s: %v", paymentID, sErr) } @@ -859,6 +869,21 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { return } + // Attach the booking customer's Square customer id (if any) so the terminal + // checkout is associated with their Square profile. Read-only lookup + // mirroring ensureSquareCustomer's read, but NEVER provisioning — a + // terminal checkout also serves walk-ins, and minting a customer profile + // for a terminal tap would create an unowned customer. No id → empty. + var checkoutCustomerID string + var bookingUserID sql.NullString + if err := db.Conn.QueryRow(r.Context(), `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&bookingUserID); err == nil && bookingUserID.Valid { + _ = db.Conn.QueryRow(r.Context(), ` + SELECT square_customer_id FROM user_saved_cards + WHERE user_id = $1 AND square_customer_id IS NOT NULL AND square_customer_id <> '' + ORDER BY created_at DESC LIMIT 1 + `, bookingUserID.String).Scan(&checkoutCustomerID) + } + checkoutReq := square.CreateCheckoutReq{ Amount: amount, Currency: "GBP", @@ -868,6 +893,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { // (totalWithTip), so the terminal must NOT prompt for a second tip — // setting AllowTipping here would double-count the tip in production. AllowTipping: false, + CustomerID: checkoutCustomerID, } checkout, err := SquareClient.CreateCheckout(r.Context(), checkoutReq) @@ -1109,8 +1135,6 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { } if paymentResult.Status == "COMPLETED" { - service := NewPaymentService() - // Serialize terminal-completion records per Square payment ID. Two // concurrent polls of the same checkout could otherwise BOTH pass the // dedup SELECT and BOTH INSERT, with the second dying on the @@ -1136,22 +1160,13 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { http.Error(w, "Payment in progress, try again", http.StatusConflict) return } - defer func() { - if _, err := pinConn.Exec(context.Background(), ` - SELECT pg_advisory_unlock(hashtext('crussell:terminal:' || $1)) - `, terminalLockKey); err != nil { - log.Printf("Failed to release terminal-completion serialization lock for %s: %v", terminalLockKey, err) - } - }() - - // Deterministic idempotency key derived from booking + amount + Square - // payment ID. The Square payment ID disambiguates two distinct - // equal-amount charges on the same booking, so equal amounts never - // collide on the UNIQUE constraint. - idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(paymentResult.Amount, 10) + "-" + paymentResult.SquarePayID + defer releasePaymentLock(pinConn, "crussell:terminal:"+terminalLockKey) // Begin the transaction BEFORE the dedup lookup so it's atomic with the - // payment insert. + // payment insert. The shared money-recording core + // (recordTerminalPaymentTx, sweep.go) runs inside this transaction, + // commits it, and completes a now-fully-paid booking; this handler + // maps the result to the HTTP response. tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to begin transaction: %v", err) @@ -1164,160 +1179,19 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { } }() - // Dedup by Square payment ID: a double poll of the same terminal - // checkout must return the existing payment row instead of inserting a - // duplicate (which previously 500'd on the idempotency-key UNIQUE - // violation after the customer had already paid). - var existingID string - if err := tx.QueryRow(r.Context(), ` - SELECT id FROM payments - WHERE booking_id = $1 AND square_payment_id = $2 - `, bookingID, paymentResult.SquarePayID).Scan(&existingID); err == nil { - if err := json.NewEncoder(w).Encode(PaymentStatusResponse{ - Status: "COMPLETED", - PaymentID: existingID, - Amount: paymentResult.Amount, - CardBrand: paymentResult.CardBrand, - CardLast4: paymentResult.CardLast4, - ReceiptURL: paymentResult.ReceiptURL, - }); err != nil { - log.Printf("Failed to encode JSON response: %v", err) - } - return - } else if !errors.Is(err, pgx.ErrNoRows) { - log.Printf("Failed to check for existing payment: %v", err) - } - - // Re-check the booking status after the advisory lock: a concurrent - // cancellation/eviction can move the booking out of a payable state - // between the terminal charge completing and this poll recording it. A - // charge landing on a cancelled/lapsed/no-show booking must not be - // recorded as a completed payment — the cancellation refund path - // computes refunds from completed payments and would silently exclude - // this charge. Mark the checkout failed and alert ops: money was taken - // at Square and MUST be refunded manually (mirrors CreateBookingPayment's - // post-charge recheck). - var recheckStatus string - // FOR UPDATE (C5): serializes against the cancellation path's lock on - // the same row so a concurrent cancellation cannot commit between this - // recheck and the transaction commit below. - if err := tx.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1 FOR UPDATE`, bookingID).Scan(&recheckStatus); err != nil { - log.Printf("CRITICAL: Square payment %s for checkout %s was processed but re-reading booking %s status failed: %v — manual reconciliation required", - paymentResult.SquarePayID, checkoutID, bookingID, err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - if !bookingStatusAllowsCompletedPayment(recheckStatus) { - log.Printf("CRITICAL: Square payment %s for checkout %s was processed but booking %s is now %q — marking checkout failed; money taken at Square MUST be refunded manually", - paymentResult.SquarePayID, checkoutID, bookingID, recheckStatus) - if _, upErr := tx.Exec(r.Context(), `UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1`, checkoutID); upErr != nil { - log.Printf("CRITICAL: Square payment %s landed on %q booking %s but marking checkout %s failed errored: %v — manual reconciliation required", - paymentResult.SquarePayID, recheckStatus, bookingID, checkoutID, upErr) - } - if cErr := tx.Commit(r.Context()); cErr != nil { - log.Printf("CRITICAL: Square payment %s landed on %q booking %s and committing the checkout-failed mark errored: %v — manual reconciliation required", - paymentResult.SquarePayID, recheckStatus, bookingID, cErr) - } - http.Error(w, "This booking is no longer accepting payments", http.StatusConflict) - return - } - - // The payment type the admin charged is recorded on the checkout row - // by CreateTerminalPayment. Fall back to 'full' for legacy checkouts - // created before that record existed. - var checkoutPaymentType string - if err := tx.QueryRow(r.Context(), ` - SELECT payment_type FROM terminal_checkouts WHERE checkout_id = $1 - `, checkoutID).Scan(&checkoutPaymentType); err != nil { - if !errors.Is(err, pgx.ErrNoRows) { - log.Printf("Failed to read payment type for checkout %s: %v", checkoutID, err) - } - checkoutPaymentType = "full" - } - - record := PaymentRecord{ - BookingID: bookingID, - PaymentType: checkoutPaymentType, - PaymentMethod: "in_person_card", - Status: "completed", - Amount: float64(paymentResult.Amount) / 100.0, - SquarePaymentID: &paymentResult.SquarePayID, - IdempotencyKey: &idempotencyKey, - Fees: float64(paymentResult.Fees) / 100.0, - CreatedAt: clock.Now(), - UpdatedAt: clock.Now(), - } - - // M4: a terminal charge above the remaining booking value is a tip - // (e.g. £100 booking + £10 tip = one £110 Square charge). Split it into - // deposit + balance + tip records so only the booking portion is - // refundable while the tip is recorded for accounting (total_tips - // aggregation) and excluded from cancellation refunds. Without a tip - // the charge stays a single record. The tip is derived from the amount - // exceeding the remaining booking value ("after 100% is tips") — NOT - // from Square's TipAmount field, because the frontend already embeds - // any tip in the amount and AllowTipping is disabled (see - // CreateTerminalPayment), so Square reports TipAmount 0. - var records []PaymentRecord - bookingInfo, bErr := service.GetBookingPaymentInfo(r.Context(), bookingID) - if bErr == nil && bookingInfo != nil { - charged := float64(paymentResult.Amount) / 100.0 - remainingBookingValue := math.Max(0, bookingInfo.TotalAmount-bookingInfo.TotalPaid) - bookingPortion := math.Min(charged, remainingBookingValue) - bookingPortion = math.Round(bookingPortion*100) / 100 - tipAmount := math.Round((charged-bookingPortion)*100) / 100 - if tipAmount > 0.004 { - records = buildTerminalSplitRecords(record, bookingInfo, bookingPortion, tipAmount) - } - } - if len(records) == 0 { - records = []PaymentRecord{record} - } - - primary := records[0] - paymentID, err := service.CreatePaymentRecordTx(r.Context(), tx, primary, nil) - if err != nil { - log.Printf("Failed to create payment record: %v", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - ApplyVATToBookingPayment(r.Context(), tx, paymentID) - - var splitIDs []string - for _, rec := range records[1:] { - pid, cErr := service.CreatePaymentRecordTx(r.Context(), tx, rec, nil) - if cErr != nil { - log.Printf("Failed to create terminal tip split record: %v", cErr) - http.Error(w, "internal server error", http.StatusInternalServerError) + paymentID, recErr := recordTerminalPaymentTx(r.Context(), tx, checkoutID, bookingID, paymentResult) + if recErr != nil { + if errors.Is(recErr, errTerminalBookingNotPayable) { + // The core already committed the checkout's 'failed' mark: + // money was taken at Square on a booking that is no longer + // payable and MUST be refunded manually. + http.Error(w, "This booking is no longer accepting payments", http.StatusConflict) return } - splitIDs = append(splitIDs, pid) - } - for _, pid := range splitIDs { - ApplyVATToBookingPayment(r.Context(), tx, pid) - } - - // Release the in-flight guard: this checkout is done, so a subsequent - // charge on the same booking is allowed. - if _, err := tx.Exec(r.Context(), ` - UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() WHERE checkout_id = $1 - `, checkoutID); err != nil { - log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, err) - } - - if err := tx.Commit(r.Context()); err != nil { - log.Printf("Failed to commit transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } - // Fully-paid completion: if this terminal charge (or the accumulated - // total) now covers 100% of the booking total, complete the booking so - // it leaves the admin's Current Appointment view. Runs in its own - // transaction because the payment-recording transaction above has - // already committed. - completeFullyPaidBooking(r.Context(), bookingID) - if err := json.NewEncoder(w).Encode(PaymentStatusResponse{ Status: "COMPLETED", PaymentID: paymentID, @@ -1780,11 +1654,24 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { // pattern as CreateTipPayment. ApplyVATToBookingPayment(r.Context(), tx, paymentID) } else { - // Refresh square_source_id on a reused pending row: this attempt may - // charge a different token than the failed attempt (one-time cnon: - // nonces are spent), and the sweep replays the charge from the stored - // source. - if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2`, sourceID, paymentID); srcErr != nil { + // Reused pending row. The stored square_request_snapshot is the FIRST + // attempt's charge body and MUST remain immutable across nonce-changing + // retries: if that original charge actually landed at Square (the row + // is pending only because the post-charge outcome is unknown), the + // by-key sweep replay must match the original body so Square's + // idempotency dedup returns the landed payment and the sweep rescues + // the row. Overwriting the snapshot's SourceID with this retry's fresh + // nonce — or refreshing the square_source_id column the sweep overrides + // the replay source with — would make the sweep replay the NEW source, + // Square would return IDEMPOTENCY_KEY_REUSED, and the landed charge + // would never be rescued (stranded until the 24h blind-fail). A retry + // that changed nonce gets IDEMPOTENCY_KEY_REUSED at charge time; the + // sweep's replay/manual-reconcile path (sweep.go:573-584) resolves the + // row's true state from the immutable first-attempt body instead. The + // column is refreshed ONLY for snapshot-less legacy rows, whose + // fallback replay body is rebuilt from it (and which get a fresh + // snapshot from the post-commit write below). + if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, sourceID, paymentID); srcErr != nil { log.Printf("Failed to update square_source_id on reused payment %s: %v", paymentID, srcErr) } } @@ -1824,10 +1711,13 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { // M1: store the verbatim request JSON so the sweep can replay the charge // with an IDENTICAL body under the same key — Square compares the whole // request on key reuse, and a reconstructed body returns - // IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. + // IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. The snapshot is + // written ONLY when the row has none: it records the FIRST attempt's body, + // which stays immutable so a nonce-changing retry can never redirect the + // sweep's replay away from the original charge (see the reuse branch above). if snap, mErr := json.Marshal(paymentReq); mErr != nil { log.Printf("Failed to marshal square_request_snapshot for payment %s: %v", paymentID, mErr) - } else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(snap), paymentID); sErr != nil { + } else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(snap), paymentID); sErr != nil { log.Printf("Failed to store square_request_snapshot for payment %s: %v", paymentID, sErr) } @@ -2359,7 +2249,7 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) { service := NewPaymentService() card, err := service.CreatePaymentMethodFromToken(r.Context(), userID, req.CardToken) if err != nil { - if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "expired") { + if isDefinitiveCardSaveFailure(err) { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return @@ -2374,6 +2264,32 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) { } } +// isDefinitiveCardSaveFailure reports whether a CreatePaymentMethod error is a +// definitive client rejection — an expired/invalid/already-used card source or +// a declined card that can never be saved — as opposed to an ambiguous +// transport/server failure. It matches the structured Square error Code +// (square.ErrorCode) exactly against the codes this codebase already recognizes +// for card failures (till.go's definitivePaymentDeclineCodes via +// isDefinitiveChargeFailure, plus the card-on-file creation codes SOURCE_USED / +// INVALID_REQUEST_ERROR), replacing the old substring match on "invalid" / +// "expired" in the formatted message so a Square wording change can never +// silently flip the 400↔500 classification. Errors carrying no structured code +// (transport errors, the dev mock's plain errors, 5xx) are ambiguous and stay +// 500 — retrying with the same inputs might succeed. +func isDefinitiveCardSaveFailure(err error) bool { + if err == nil { + return false + } + if isDefinitiveChargeFailure(err) { + return true + } + switch square.ErrorCode(err) { + case "SOURCE_USED", "CARD_TOKEN_USED", "CARD_TOKEN_EXPIRED", "INVALID_CARD", "INVALID_REQUEST_ERROR": + return true + } + return false +} + func RefundPayment(w http.ResponseWriter, r *http.Request) { // Defense-in-depth: the route is mounted under mw.RequireAdmin, but this // in-handler check keeps refund access admin-only even if the route is ever @@ -2523,13 +2439,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) { http.Error(w, "Refund in progress, try again", http.StatusConflict) return } - defer func() { - if _, err := pinConn.Exec(context.Background(), ` - SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1)) - `, refundLockKey); err != nil { - log.Printf("Failed to release refund serialization lock for %s: %v", paymentID, err) - } - }() + defer releasePaymentLock(pinConn, "crussell:refund:"+refundLockKey) // Dedup/resume (inside the lock): a same-key retry of a completed or // in-flight (pending) refund must not create a second Square refund. Runs @@ -3662,11 +3572,24 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { } ApplyVATToBookingPayment(r.Context(), tx, paymentID) } else { - // Refresh square_source_id on a reused pending row: this attempt may - // charge a different token than the failed attempt (one-time cnon: - // nonces are spent), and the sweep replays the charge from the stored - // source. - if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2`, sourceID, paymentID); srcErr != nil { + // Reused pending row. The stored square_request_snapshot is the FIRST + // attempt's charge body and MUST remain immutable across nonce-changing + // retries: if that original charge actually landed at Square (the row + // is pending only because the post-charge outcome is unknown), the + // by-key sweep replay must match the original body so Square's + // idempotency dedup returns the landed payment and the sweep rescues + // the row. Overwriting the snapshot's SourceID with this retry's fresh + // nonce — or refreshing the square_source_id column the sweep overrides + // the replay source with — would make the sweep replay the NEW source, + // Square would return IDEMPOTENCY_KEY_REUSED, and the landed charge + // would never be rescued (stranded until the 24h blind-fail). A retry + // that changed nonce gets IDEMPOTENCY_KEY_REUSED at charge time; the + // sweep's replay/manual-reconcile path (sweep.go:573-584) resolves the + // row's true state from the immutable first-attempt body instead. The + // column is refreshed ONLY for snapshot-less legacy rows, whose + // fallback replay body is rebuilt from it (and which get a fresh + // snapshot from the post-commit write below). + if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, sourceID, paymentID); srcErr != nil { log.Printf("Failed to update square_source_id on reused tip payment %s: %v", paymentID, srcErr) } } @@ -3712,10 +3635,13 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { // M1: store the verbatim request JSON so the sweep can replay the charge // with an IDENTICAL body under the same key — Square compares the whole // request on key reuse, and a reconstructed body returns - // IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. + // IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. The snapshot is + // written ONLY when the row has none: it records the FIRST attempt's body, + // which stays immutable so a nonce-changing retry can never redirect the + // sweep's replay away from the original charge (see the reuse branch above). if snap, mErr := json.Marshal(paymentReq); mErr != nil { log.Printf("Failed to marshal square_request_snapshot for tip payment %s: %v", paymentID, mErr) - } else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(snap), paymentID); sErr != nil { + } else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(snap), paymentID); sErr != nil { log.Printf("Failed to store square_request_snapshot for tip payment %s: %v", paymentID, sErr) } diff --git a/backend/handlers/payments/locks.go b/backend/handlers/payments/locks.go index d99020d..5610051 100644 --- a/backend/handlers/payments/locks.go +++ b/backend/handlers/payments/locks.go @@ -2,6 +2,7 @@ package payments import ( "context" + "log" "time" "github.com/jackc/pgx/v5" @@ -39,6 +40,23 @@ func acquireAdvisoryLock(ctx context.Context, conn *pgxpool.Conn, key string) (b return tryAdvisoryLock(ctx, conn, key, "pg_try_advisory_lock") } +// releasePaymentLock releases a session advisory lock acquired by +// acquireAdvisoryLock on the SAME pinned pool connection (pg_advisory_unlock +// only releases locks held by the calling session). It is the generic release +// counterpart to acquireAdvisoryLock, and callers defer it immediately after a +// successful acquire so the unlock runs before the deferred pinConn.Release(). +// Errors are logged and otherwise ignored — exactly what the inline +// `pg_advisory_unlock(hashtext('crussell:...:' || $1))` blocks it replaces did +// — and the key must be the FULL "crussell:..." string that was hashed at +// acquire time so the two hashtext() calls produce the same lock bigint. +func releasePaymentLock(pinConn *pgxpool.Conn, lockKey string) { + if _, err := pinConn.Exec(context.Background(), ` + SELECT pg_advisory_unlock(hashtext($1)) + `, lockKey); err != nil { + log.Printf("Failed to release payment serialization lock %s: %v", lockKey, err) + } +} + // acquireAdvisoryXactLockBlocking is the transaction-scoped BLOCKING variant // of acquireAdvisoryLock: it issues `SELECT pg_advisory_xact_lock(...)` // ONCE and waits for as long as the key is contended — there is no 3s bound. diff --git a/backend/handlers/payments/loyalty.go b/backend/handlers/payments/loyalty.go index 699ddfd..586446e 100644 --- a/backend/handlers/payments/loyalty.go +++ b/backend/handlers/payments/loyalty.go @@ -1,7 +1,6 @@ package payments import ( - "context" "crussell/db" "crussell/internal/validators" "crussell/mw" @@ -111,13 +110,7 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) { http.Error(w, "Another payment operation is in progress, try again", http.StatusConflict) return } - defer func() { - if _, err := pinConn.Exec(context.Background(), ` - SELECT pg_advisory_unlock(hashtext('crussell:payment:' || $1)) - `, bookingID); err != nil { - log.Printf("Failed to release loyalty redemption serialization lock for %s: %v", bookingID, err) - } - }() + defer releasePaymentLock(pinConn, "crussell:payment:"+bookingID) // Re-check inside the lock (the checks above ran before acquiring it) so a // concurrent redemption that completed while we waited is caught. diff --git a/backend/handlers/payments/payments_round10_test.go b/backend/handlers/payments/payments_round10_test.go new file mode 100644 index 0000000..4a29a74 --- /dev/null +++ b/backend/handlers/payments/payments_round10_test.go @@ -0,0 +1,489 @@ +//go:build test && dev + +package payments + +// ============================================================================= +// ROUND 10 — gift-card value limits & the admin cancellation surface +// ============================================================================= +// +// This file pins the money-safety behaviours added in round 10: +// +// 1. Per-transaction £250 cap on the admin-funded gift-card entry points +// (CreateGiftCard, TopUpGiftCard, TransferGiftCard): an amount of £251 +// (25,100 pence) is rejected with 400 before any row is written, while +// exactly £250 (25,000 pence) stays inside the cap. +// +// 2. User daily cap of £500 on online gift-card purchases (BuyGiftCard): the +// day's spend is the sum of the caller's gift_card_transactions 'purchase' +// rows (reference_type 'api' — the signal BuyGiftCard itself writes, see +// giftcard_limits.go userGiftCardSpentToday); an attempt that would cross +// £500 is rejected 400, and the cap is inclusive (exactly £500 is +// allowed). The cap is calendar-day (created_at >= CURRENT_DATE): rolling +// yesterday's signal rows forward resets it. +// +// 3. Admin daily cap of £5,000 on gift-card value created/top-up'd: the day's +// issued value is the sum of the cards the admin created today +// (total_funds_added) plus the admin's same-day 'purchase'/'topup' +// gift_card_transactions audit rows on cards created before today (see +// giftcard_limits.go adminGiftCardValueToday); an operation that would +// cross £5,000 is rejected 400, and the cap is inclusive. +// +// 4. AdminCancelGiftCard (POST /api/admin/gift-cards/cancel, body +// {code, payment_id?}) reuses the 14-day partial-spend cancellation core: +// for a card whose shortfall is verified till spend it refunds ONLY the +// unspent remainder to the original payment method, zeroes + expires the +// card, and records a 'giftcard_cancel' refunds row. Cards outside the +// 14-day window are rejected 400 with no Square call. +// +// MONEY-SAFETY CONTRACT under test: a rejected operation must never write a +// card/transaction/payment row and never call Square; an accepted cancellation +// must issue EXACTLY ONE Square refund and must never leave the card's balance +// spendable on top of the returned money (amount_remaining zeroed + expiry in +// the past, atomically with the refund row resolution). +// +// BUILD DEPENDENCY: main.go already routes POST /admin/gift-cards/cancel to +// AdminCancelGiftCard, so until that handler (and the round-10 limit checks) +// are defined in this package the package cannot compile. + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "crussell/clock" + "crussell/db" + "crussell/internal/square" + "crussell/mw" + "crussell/testutils" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================= +// Round 10 helpers +// ============================================================================= + +// round10CreateAdmin creates an admin user the way the existing admin gift-card +// tests do (CreateTestUser + account_role update) and returns the user id and a +// role-claim 'admin' token, matching main.go's admin group (mw.RequireAuth + +// mw.RequireAdmin). +func round10CreateAdmin(t *testing.T, ctx context.Context, q db.Querier) (adminID, token string) { + t.Helper() + adminID, err := fixtures.CreateTestUser(q) + require.NoError(t, err) + _, err = q.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) + require.NoError(t, err) + return adminID, jwt.GenerateTestToken(adminID, "admin") +} + +// round10AdminCreateGiftCard POSTs a CreateGiftCard request through the real +// router with mw.RequireAuth + mw.RequireAdmin (mirroring main.go's admin +// group) and the test transaction embedded in the request context. +func round10AdminCreateGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token string, amount float64) *httptest.ResponseRecorder { + t.Helper() + body, _ := json.Marshal(CreateGiftCardRequest{Amount: amount}) + r := httptest.NewRequest(http.MethodPost, "/api/admin/gift-cards", bytes.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("Content-Type", "application/json") + r = r.WithContext(db.ContextWithTx(r.Context(), tx)) + w := httptest.NewRecorder() + router := chi.NewRouter() + router.Use(mw.RequireAuth) + router.With(mw.RequireAdmin).Post("/api/admin/gift-cards", CreateGiftCard) + router.ServeHTTP(w, r) + return w +} + +// round10AdminTopUpGiftCard PUTs a TopUpGiftCard request through the real +// router with the admin middleware stack and the test transaction embedded in +// the request context. +func round10AdminTopUpGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token, cardID string, amount float64) *httptest.ResponseRecorder { + t.Helper() + body, _ := json.Marshal(TopUpGiftCardRequest{Amount: amount, PaymentMethod: "cash"}) + r := httptest.NewRequest(http.MethodPut, "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("Content-Type", "application/json") + r = r.WithContext(db.ContextWithTx(r.Context(), tx)) + w := httptest.NewRecorder() + router := chi.NewRouter() + router.Use(mw.RequireAuth) + router.With(mw.RequireAdmin).Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard) + router.ServeHTTP(w, r) + return w +} + +// round10AdminTransferGiftCard POSTs a TransferGiftCard request through the +// real router with the admin middleware stack and the test transaction +// embedded in the request context. +func round10AdminTransferGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token, fromCardID, toCardID string, amount float64) *httptest.ResponseRecorder { + t.Helper() + body, _ := json.Marshal(TransferGiftCardRequest{ToCardID: toCardID, Amount: amount}) + r := httptest.NewRequest(http.MethodPost, "/api/admin/gift-cards/"+fromCardID+"/transfer", bytes.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("Content-Type", "application/json") + r = r.WithContext(db.ContextWithTx(r.Context(), tx)) + w := httptest.NewRecorder() + router := chi.NewRouter() + router.Use(mw.RequireAuth) + router.With(mw.RequireAdmin).Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard) + router.ServeHTTP(w, r) + return w +} + +// round10AdminCancelGiftCard POSTs a gift-card cancellation through the ADMIN +// endpoint (POST /api/admin/gift-cards/cancel) with the admin middleware stack +// (mw.RequireAuth + mw.RequireAdmin, matching main.go) and the test transaction +// embedded in the request context. +func round10AdminCancelGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token, code string) *httptest.ResponseRecorder { + t.Helper() + body, _ := json.Marshal(CancelGiftCardRequest{Code: code}) + r := httptest.NewRequest(http.MethodPost, "/api/admin/gift-cards/cancel", bytes.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("Content-Type", "application/json") + r = r.WithContext(db.ContextWithTx(r.Context(), tx)) + w := httptest.NewRecorder() + router := chi.NewRouter() + router.Use(mw.RequireAuth) + router.With(mw.RequireAdmin).Post("/api/admin/gift-cards/cancel", AdminCancelGiftCard) + router.ServeHTTP(w, r) + return w +} + +// round10BuyGiftCard POSTs an online gift-card purchase for a friend through +// the real BuyGiftCard handler and returns the full response recorder so the +// daily-limit message can be asserted. Mirrors round9BuyGiftCardForFriend but +// keeps the body (that helper returns only the card id and status). +func round10BuyGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token string, amount int) *httptest.ResponseRecorder { + t.Helper() + reqBody, _ := json.Marshal(map[string]interface{}{ + "amount": amount, + "recipient_type": "friend", + "new_card_token": "cnon:card-nonce-ok", + "idempotency_key": fmt.Sprintf("round10-buy-%d-%d", amount, time.Now().UnixNano()), + }) + r := httptest.NewRequest(http.MethodPost, "/user/giftcards/buy", bytes.NewBuffer(reqBody)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("Content-Type", "application/json") + r = r.WithContext(db.ContextWithTx(r.Context(), tx)) + w := httptest.NewRecorder() + router := chi.NewRouter() + router.Use(mw.RequireAuth) + router.With(mw.RequireNonGuest).Post("/user/giftcards/buy", BuyGiftCard) + router.ServeHTTP(w, r) + return w +} + +// ============================================================================= +// 1. £250 per-transaction cap on admin gift-card value entry points +// ============================================================================= + +// TestRound10_AdminGiftCardTransaction_250Cap_Rejected pins the per-transaction +// £250 cap on the three admin-funded gift-card entry points. For each of +// CreateGiftCard, TopUpGiftCard and TransferGiftCard an amount of £251 +// (25,100 pence) must be rejected 400 with a message citing the cap BEFORE any +// value moves (no card created, no top-up applied, no transfer executed), while +// exactly £250 (25,000 pence) stays INSIDE the cap and succeeds. The cap is the +// money-safety ceiling for a single admin-funded gift-card operation; without +// it a mis-keyed admin entry could fund a card beyond the value the salon can +// justify, so the boundary is pinned exactly. +func TestRound10_AdminGiftCardTransaction_250Cap_Rejected(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + adminID, adminToken := round10CreateAdmin(t, ctx, tx) + + // Source card funds the top-up and transfer cases; destination receives + // the transfer. Both are plain unredeemed non-inventory cards. + var sourceID, destID string + require.NoError(t, tx.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) + VALUES (300.00, 300.00, $1) RETURNING id`, adminID).Scan(&sourceID)) + require.NoError(t, tx.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) + VALUES (0, 0, $1) RETURNING id`, adminID).Scan(&destID)) + + cases := []struct { + name string + at func(t *testing.T, amount float64) *httptest.ResponseRecorder + wantSuccess int + }{ + { + name: "CreateGiftCard", + at: func(t *testing.T, amount float64) *httptest.ResponseRecorder { + return round10AdminCreateGiftCard(t, ctx, tx.(pgx.Tx), adminToken, amount) + }, + wantSuccess: http.StatusCreated, + }, + { + name: "TopUpGiftCard", + at: func(t *testing.T, amount float64) *httptest.ResponseRecorder { + return round10AdminTopUpGiftCard(t, ctx, tx.(pgx.Tx), adminToken, sourceID, amount) + }, + wantSuccess: http.StatusOK, + }, + { + name: "TransferGiftCard", + at: func(t *testing.T, amount float64) *httptest.ResponseRecorder { + return round10AdminTransferGiftCard(t, ctx, tx.(pgx.Tx), adminToken, sourceID, destID, amount) + }, + wantSuccess: http.StatusOK, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // £251 (25,100 pence) — one penny over the £250 per-transaction cap. + w := tc.at(t, 251.00) + require.Equal(t, http.StatusBadRequest, w.Code, "over-cap body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "£250", "the rejection must cite the £250 per-transaction cap") + + // Boundary: exactly £250 (25,000 pence) is INSIDE the cap. + wb := tc.at(t, 250.00) + require.Equal(t, tc.wantSuccess, wb.Code, "boundary body: %s", wb.Body.String()) + }) + } +} + +// ============================================================================= +// 2. User daily cap of £500 on online gift-card purchases (BuyGiftCard) +// ============================================================================= + +// TestRound10_UserGiftCardDailyLimit_500 pins the user-facing daily cap: a +// user who has already purchased £500 of online gift cards today cannot buy any +// more — a £50 purchase that would land the day on £550 is rejected 400 with +// the daily-limit message ("You have reached your £500 daily gift-card purchase +// limit"). A user at £450 today can still buy £50, landing the day on EXACTLY +// £500 — pinning the cap as inclusive. (Per-purchase amounts are fixed at +// £10/£20/£50, and the daily gate sits after that amount validation, so the +// over-cap purchase is exercised at the maximum valid amount rather than a +// £100 request, which the amount validation rejects first.) The day's spend +// signal is the caller's gift_card_transactions 'purchase' rows written by +// BuyGiftCard (reference_type 'api'), seeded here via round9SeedGiftCardPurchase. +func TestRound10_UserGiftCardDailyLimit_500(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + // --- Over-cap rejection: £500 already purchased today --- + overUserID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + overToken := jwt.GenerateTestToken(overUserID, "verified_email") + for i := 0; i < 10; i++ { + round9SeedGiftCardPurchase(t, ctx, tx, overUserID, 50.00, 0) + } + + // A £50 purchase would take the day to £550 — over the £500 cap. + w := round10BuyGiftCard(t, ctx, tx.(pgx.Tx), overToken, 5000) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "£500", "the rejection must cite the £500 daily cap") + assert.Contains(t, w.Body.String(), "daily", "the rejection must be the daily-limit message") + + // --- Inclusive boundary: £450 purchased today, £50 still allowed --- + boundaryUserID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + boundaryToken := jwt.GenerateTestToken(boundaryUserID, "verified_email") + for i := 0; i < 9; i++ { + round9SeedGiftCardPurchase(t, ctx, tx, boundaryUserID, 50.00, 0) + } + + // A £50 purchase takes the day to exactly £500 — inside the cap. + wb := round10BuyGiftCard(t, ctx, tx.(pgx.Tx), boundaryToken, 5000) + require.Equal(t, http.StatusCreated, wb.Code, "boundary body: %s", wb.Body.String()) +} + +// ============================================================================= +// 3. Admin daily cap of £5,000 on gift-card value created/top-up'd +// ============================================================================= + +// TestRound10_AdminGiftCardDailyLimit_5000 pins the admin daily cap: an admin +// who has issued £4,900 of gift-card value today (CreateGiftCard/TopUpGiftCard +// audit rows — reference_type 'api', user_id = the admin) cannot issue another +// £200 (that would land the day on £5,100 — over the £5,000 cap) and is +// rejected 400 with a message citing the cap, while a £100 issue that lands the +// day on EXACTLY £5,000 is accepted, pinning the cap as inclusive. The day's +// issued-value signal is seeded as both the gift-card row and its 'purchase'/ +// 'topup' gift_card_transactions rows so whichever query the limit code uses +// sees £4,900. +func TestRound10_AdminGiftCardDailyLimit_5000(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + adminID, adminToken := round10CreateAdmin(t, ctx, tx) + + // £4,900 of admin-issued gift-card value today: one card plus the audit + // rows CreateGiftCard/TopUpGiftCard write (transaction_type 'purchase'/ + // 'topup', reference_type 'api', user_id = the admin), both created today. + var cardID string + require.NoError(t, tx.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by) + VALUES (4900.00, 4900.00, $1) RETURNING id`, adminID).Scan(&cardID)) + _, err := tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes, created_at) + VALUES ($1, 'purchase', 2400.00, 'api', NULL, $2, 'seeded daily signal', NOW())`, cardID, adminID) + require.NoError(t, err) + _, err = tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes, created_at) + VALUES ($1, 'topup', 2500.00, 'api', NULL, $2, 'seeded daily signal', NOW())`, cardID, adminID) + require.NoError(t, err) + + // A £200 creation would take the day to £5,100 — over the £5,000 cap. + // (£200 is also inside the £250 per-transaction cap, isolating the daily gate.) + w := round10AdminCreateGiftCard(t, ctx, tx.(pgx.Tx), adminToken, 200.00) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "£5,000", "the rejection must cite the £5,000 daily cap") + + // A £100 creation takes the day to exactly £5,000 — inside the cap. + wb := round10AdminCreateGiftCard(t, ctx, tx.(pgx.Tx), adminToken, 100.00) + require.Equal(t, http.StatusCreated, wb.Code, "boundary body: %s", wb.Body.String()) +} + +// ============================================================================= +// 4. Admin cancellation reuses the 14-day partial-spend core +// ============================================================================= + +// TestRound10_AdminCancelGiftCard_PartiallySpent_RefundsRemaining pins the +// admin cancellation surface's handling of partial spend (CCR 2013 reg 34(9)): +// a £50 online purchase whose balance was genuinely spent down to £30 at the +// till (a completed giftcard payment row carrying the card id) is cancelled via +// POST /api/admin/gift-cards/cancel as an admin → 200, Square refunds EXACTLY +// once for the unspent remainder (3,000 pence), the card is neutralized (zeroed +// + expired so the refunded value can never be spent on top of the returned +// money), and the refunds row carries the 'giftcard_cancel' origin at the +// unspent amount. This proves the admin surface exercises the same +// partial-spend money path as the customer-facing flow. +func TestRound10_AdminCancelGiftCard_PartiallySpent_RefundsRemaining(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + _, adminToken := round10CreateAdmin(t, ctx, tx) + + origClient := SquareClient + mock := square.NewDevClient().(*square.MockClient) + counting := &countingRefundClient{SquareClient: mock} + SquareClient = counting + defer func() { SquareClient = origClient }() + + // £50 online purchase, £20 genuinely spent at the till (a completed + // giftcard payment row carrying the card id), £30 remaining. + cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) + _, err = tx.Exec(ctx, ` + UPDATE gift_cards SET amount_remaining = 30.00 WHERE id = $1`, cardID) + require.NoError(t, err) + _, err = tx.Exec(ctx, ` + INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at, gift_card_id) + VALUES (NULL, 'full', 'giftcard', 'completed', 20.00, 'r10-spend-' || $1::text, $2, NOW(), NOW(), $1)`, cardID, userID) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE idempotency_key = 'r10-spend-' || $1`, cardID) + }) + + w := round10AdminCancelGiftCard(t, ctx, tx.(pgx.Tx), adminToken, cardID) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "£30.00", "the message must state the refunded unspent portion") + assert.Contains(t, w.Body.String(), "£20.00", "the message must state the non-refundable spent portion") + + // Exactly ONE Square refund, for the UNSPENT remainder (3000 pence). + calls := counting.refundCalls() + require.Len(t, calls, 1, "exactly one Square refund for the admin cancellation") + assert.Equal(t, int64(3000), calls[0].Amount, "the unspent remainder must be refunded in pence") + + // Card neutralized: zero balance, expired (cannot be spent). + var rem float64 + var expiry sql.NullTime + require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry)) + assert.Equal(t, 0.00, rem, "card balance must be zero after the admin cancellation") + require.True(t, expiry.Valid, "the card must still carry an expiry date") + assert.False(t, expiry.Time.After(clock.Now()), "card expiry must be in the past (neutralized)") + + // Refund row recorded at the partial amount with the giftcard_cancel origin. + var refundAmount float64 + var refundOrigin string + require.NoError(t, tx.QueryRow(ctx, ` + SELECT amount, origin FROM refunds WHERE payment_id = $1`, paymentID). + Scan(&refundAmount, &refundOrigin)) + assert.Equal(t, 30.00, refundAmount, "the refunds row must record the unspent remainder") + assert.Equal(t, "giftcard_cancel", refundOrigin, "the refund must carry the gift-card-cancel origin") +} + +// TestRound10_AdminCancelGiftCard_NotCancellable_Rejected pins the statutory +// timing gate on the ADMIN cancellation surface: a card purchased outside the +// 14-day cooling-off window (seeded 15 days ago) is rejected 400 with the +// 14-day message and NO Square refund is issued — the cooling-off right is +// time-limited regardless of who invokes it. +func TestRound10_AdminCancelGiftCard_NotCancellable_Rejected(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + _, adminToken := round10CreateAdmin(t, ctx, tx) + + cardID, _ := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 15*24*time.Hour) + + origClient := SquareClient + counting := &countingRefundClient{SquareClient: square.NewDevClient()} + SquareClient = counting + defer func() { SquareClient = origClient }() + + w := round10AdminCancelGiftCard(t, ctx, tx.(pgx.Tx), adminToken, cardID) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "14-day", "the rejection must cite the 14-day cooling-off window") + require.Empty(t, counting.refundCalls(), "no Square refund for a card outside the 14-day window") +} + +// ============================================================================= +// 5. The user daily cap resets on the next calendar day +// ============================================================================= + +// TestRound10_UserDailyLimit_ClearsNextDay pins the daily boundary of the user +// purchase cap: after a user has purchased £500 today (exactly at the cap) a +// further £50 purchase is rejected, but once the seeded purchases' timestamps +// are rolled back to YESTERDAY the same £50 purchase succeeds — proving the cap +// is calendar-day scoped and never counts spend from a previous day. Without +// this, a single heavy day would permanently suppress future purchases (or, if +// the boundary were a rolling window, a purchase at 23:59 would bleed into the +// next day's allowance). +func TestRound10_UserDailyLimit_ClearsNextDay(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(userID, "verified_email") + + // £500 of purchases today — exactly at the cap. + for i := 0; i < 10; i++ { + round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) + } + + // Any further purchase today is over the cap. + w := round10BuyGiftCard(t, ctx, tx.(pgx.Tx), token, 5000) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "£500", "the rejection must cite the £500 daily cap") + + // Roll the seeded purchases back to yesterday across every table that + // could carry the daily-spend signal (payments, gift_card_transactions, + // gift_cards) so the day boundary resets regardless of which signal the + // limit code queries. + _, err = tx.Exec(ctx, ` + UPDATE payments SET created_at = created_at - INTERVAL '1 day' + WHERE created_by = $1 AND booking_id IS NULL AND payment_method = 'online_square'`, userID) + require.NoError(t, err) + _, err = tx.Exec(ctx, ` + UPDATE gift_card_transactions SET created_at = created_at - INTERVAL '1 day' + WHERE user_id = $1 AND reference_type = 'api' AND transaction_type = 'purchase'`, userID) + require.NoError(t, err) + _, err = tx.Exec(ctx, ` + UPDATE gift_cards SET created_at = created_at - INTERVAL '1 day' + WHERE created_by = $1`, userID) + require.NoError(t, err) + + // The same £50 purchase now succeeds — yesterday's spend does not count + // toward today's cap. + wb := round10BuyGiftCard(t, ctx, tx.(pgx.Tx), token, 5000) + require.Equal(t, http.StatusCreated, wb.Code, "next-day body: %s", wb.Body.String()) +} diff --git a/backend/handlers/payments/payments_round8_test.go b/backend/handlers/payments/payments_round8_test.go new file mode 100644 index 0000000..bea0a4c --- /dev/null +++ b/backend/handlers/payments/payments_round8_test.go @@ -0,0 +1,360 @@ +//go:build test && dev + +package payments + +import ( + "context" + "database/sql" + "net/http" + "sync" + "testing" + "time" + + "crussell/db" + "crussell/internal/square" + "crussell/testutils" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================= +// ROUND 8 — money-safety testing gaps +// ============================================================================= +// +// This file pins four behaviors that keep money movements safe: +// +// 1. RefundPayment's manual guard rejects discount/on-the-house ledger rows +// (a discount row is not real money, so refunding it would pay money out +// of nothing) BEFORE any Square refund call or refund row is created. +// +// 2. The sweep's recordUntrackedTerminalPayment splits a COMPLETED terminal +// charge that exceeds the remaining booking balance into deposit/balance/ +// tip records, applies per-record VAT via ApplyVATToBookingPayment, and +// completes the now-fully-paid booking. +// +// 3. CreateTerminalPayment always sends AllowTipping: false in the Square +// CreateCheckoutReq — the third leg of the tip double-count fix (the +// frontend embeds the tip in the charge amount, so the terminal must not +// prompt for a second one). +// +// 4. acquireAdvisoryXactLockBlocking (the deliberately-unbounded refund lock) +// blocks a second waiter until the holder's transaction commits — the +// "a refund must never be dropped" rationale for the unbounded wait. + +// ============================================================================= +// T5 — RefundPayment manual guard rejects discount / on-the-house payments +// ============================================================================= + +// TestRound8_RefundPayment_DiscountOrOnTheHouse_Rejected pins the T5 manual +// guard: RefundPayment rejects a completed discount/on-the-house payment row +// with 400 and a "Cannot refund a discount or complimentary payment" message, +// BEFORE issuing any Square refund call and BEFORE creating any refund row. A +// discount/on-the-house row is a ledger entry, not real money — the customer +// never paid it, so refunding it would pay money out of nothing. The row is +// seeded WITHOUT a square_payment_id so the rejection can only come from the +// discount guard (the later "Payment has no Square reference" guard would +// fire with a different message if the discount guard were ever removed). +func TestRound8_RefundPayment_DiscountOrOnTheHouse_Rejected(t *testing.T) { + for _, method := range []string{"discount", "on_the_house"} { + t.Run(method, func(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + _, bookingID, _ := setupTestData(t, ctx, tx) + + payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, method, "full", "completed") + require.NoError(t, err) + + // Swap in a client that records every Square refund call so the + // test can prove the guard fires before any money would move. + origClient := SquareClient + counting := &countingRefundClient{SquareClient: square.NewDevClient()} + SquareClient = counting + defer func() { SquareClient = origClient }() + + adminToken := jwt.GenerateTestToken(adminID, "admin") + req := RefundRequest{Amount: 1000, Reason: "round8 guard test"} + w := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+payID+"/refund", req, adminToken, ctx) + + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "Cannot refund a discount or complimentary payment", + "the message must identify the discount/complimentary rejection") + + require.Empty(t, counting.refundCalls(), + "no Square refund call may be issued for a discount/on-the-house payment") + + var refundCount int + err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, payID).Scan(&refundCount) + require.NoError(t, err) + assert.Equal(t, 0, refundCount, + "no refund row may be created for a discount/on-the-house payment") + }) + } +} + +// ============================================================================= +// T6 — recordUntrackedTerminalPayment tip-split / VAT / completion branches +// ============================================================================= + +// TestRound8_SweepUntrackedTerminal_OverBalance_TipSplit_VAT_CompletesBooking +// pins the T6 money-safety contract of recordUntrackedTerminalPayment: a stale +// "tmp-" terminal checkout that COMPLETED at Square with an amount ABOVE the +// remaining booking balance (£55 on a £50 booking) must be recorded as THREE +// ledger rows (deposit £25 + balance £25 + tip £5), each booking row must get +// its VAT applied through ApplyVATToBookingPayment (the tip record must never +// carry VAT), and the now-fully-paid booking must be transitioned to +// 'completed' via completeFullyPaidBooking. This mirrors the existing +// TestSweepStaleTerminalCheckouts_TmpProvisional_Completed_RecordsPayment but +// exercises the over-balance split that its tipAmount=0 charge never reaches. +func TestRound8_SweepUntrackedTerminal_OverBalance_TipSplit_VAT_CompletesBooking(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + serviceID, err := fixtures.CreateTestService(tx) + require.NoError(t, err) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + require.NoError(t, err) + // The booking must be in a payable state for the untracked charge to be + // recorded (bookingStatusAllowsCompletedPayment) and completable by + // completeFullyPaidBooking. + if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil { + t.Fatalf("failed to set booking in_progress: %v", err) + } + // VAT-registered so the sweep's per-record ApplyVATToBookingPayment writes + // vat_amount/net_amount on the split booking rows. The update is part of the + // setup tx that is committed below, so the sweep sees it at pool level. + if _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`); err != nil { + t.Fatalf("failed to enable VAT registration: %v", err) + } + + const tmpID = "tmp-round8-tip-split" + seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID) + + origClient := SquareClient + const sqPayID = "sqp_round8_tip_split" + SquareClient = &provisionalCheckoutClient{ + SquareClient: square.NewDevClient(), + checkoutID: tmpID, + // £55 charged on a £50 booking: deposit £25 + balance £25 + tip £5. + result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: sqPayID, Amount: 5500, Fees: 88, CardBrand: "VISA", CardLast4: "4242"}, + } + defer func() { SquareClient = origClient }() + + pgxTx := db.TxFromContext(ctx) + require.NotNil(t, pgxTx, "no transaction in context") + require.NoError(t, pgxTx.Commit(ctx), "failed to commit setup tx") + + pool := context.Background() + t.Cleanup(func() { + _, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE square_payment_id = $1`, sqPayID) + _, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID) + // Restore the shared business_settings row to the VAT-unregistered + // baseline so parallel tests keep their own VAT expectations. + _, _ = db.Conn.Exec(pool, `UPDATE business_settings SET is_vat_registered = FALSE, default_vat_rate = 20.00`) + }) + + // Drop any other stale terminal rows left by sequential tests so the count + // is deterministic. + if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil { + t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err) + } + if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil { + t.Fatalf("failed to clean leftover stale till sales: %v", err) + } + + n, err := SweepStaleTerminalCheckouts(pool) + require.NoError(t, err, "sweep failed") + assert.Equal(t, 1, n, "the COMPLETED over-balance provisional checkout must be resolved by the sweep") + + var status string + require.NoError(t, db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status)) + assert.Equal(t, "COMPLETED", status, "the recorded checkout row must be marked COMPLETED") + + // The untracked charge must be split: one £55 Square charge → deposit £25 + + // balance £25 + tip £5 (three ledger rows sharing the square_payment_id). + rows, err := db.Conn.Query(pool, ` + SELECT payment_type, amount, is_vat_applicable, vat_amount, net_amount + FROM payments + WHERE booking_id = $1 AND square_payment_id = $2 + ORDER BY payment_type + `, bookingID, sqPayID) + require.NoError(t, err, "failed to query recorded split payments") + defer rows.Close() + + type splitRow struct { + paymentType string + amount float64 + vatApplied bool + vatAmount sql.NullFloat64 + netAmount sql.NullFloat64 + } + splits := map[string]splitRow{} + for rows.Next() { + var r splitRow + require.NoError(t, rows.Scan(&r.paymentType, &r.amount, &r.vatApplied, &r.vatAmount, &r.netAmount)) + splits[r.paymentType] = r + } + require.NoError(t, rows.Err()) + + require.Len(t, splits, 3, "the over-balance terminal charge must split into deposit + balance + tip records") + assert.InDelta(t, 25.0, splits["deposit"].amount, 0.001, "deposit = 50%% of the £50 booking total") + assert.InDelta(t, 25.0, splits["balance"].amount, 0.001, "balance = the remaining booking total") + assert.InDelta(t, 5.0, splits["tip"].amount, 0.001, "tip = the charged amount above the booking value") + + // Per-record VAT (ApplyVATToBookingPayment): the deposit and balance rows + // carry 20% VAT of the £25 gross (£4.17 VAT, £20.83 net); the tip record + // must never have VAT applied. + for _, pt := range []string{"deposit", "balance"} { + r := splits[pt] + assert.True(t, r.vatApplied, "%s record must have VAT applied", pt) + require.True(t, r.vatAmount.Valid, "%s record must have vat_amount set", pt) + assert.InDelta(t, 4.17, r.vatAmount.Float64, 0.001, "%s record VAT (20%% of £25 gross)", pt) + require.True(t, r.netAmount.Valid, "%s record must have net_amount set", pt) + assert.InDelta(t, 20.83, r.netAmount.Float64, 0.001, "%s record net of 20%% VAT", pt) + } + assert.False(t, splits["tip"].vatApplied, "tip record must never have VAT applied") + assert.False(t, splits["tip"].vatAmount.Valid, "tip record must have NULL vat_amount") + assert.False(t, splits["tip"].netAmount.Valid, "tip record must have NULL net_amount") + + // The £55 charge covers the full £50 booking (deposit + balance), so + // completeFullyPaidBooking must have transitioned the booking to + // 'completed' — the same completion the poll handler performs. + var bookingStatus string + require.NoError(t, db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus)) + assert.Equal(t, "completed", bookingStatus, "a fully-paid booking must be completed by the sweep") +} + +// ============================================================================= +// T8 — CreateTerminalPayment sends AllowTipping: false to Square +// ============================================================================= + +// recordingCheckoutClient records every CreateCheckoutReq so a test can assert +// exactly what the handler sends to Square while delegating the actual call to +// the underlying client (the same recording-client pattern as +// recordingPaymentClient / countingRefundClient in the sibling files). +type recordingCheckoutClient struct { + square.SquareClient + mu sync.Mutex + reqs []square.CreateCheckoutReq +} + +func (c *recordingCheckoutClient) CreateCheckout(ctx context.Context, req square.CreateCheckoutReq) (*square.CheckoutResult, error) { + c.mu.Lock() + c.reqs = append(c.reqs, req) + c.mu.Unlock() + return c.SquareClient.CreateCheckout(ctx, req) +} + +func (c *recordingCheckoutClient) checkoutReqs() []square.CreateCheckoutReq { + c.mu.Lock() + defer c.mu.Unlock() + return append([]square.CreateCheckoutReq(nil), c.reqs...) +} + +// TestRound8_CreateTerminalPayment_AllowTippingFalse pins the T8 leg of the +// tip double-count fix: the frontend embeds the tip in the charge amount +// (totalWithTip), so CreateTerminalPayment must pass AllowTipping: false in +// the Square CreateCheckoutReq even when the client requests TipEnabled — +// otherwise the terminal would prompt for a second tip and the tip would be +// double-counted in production. The request is captured with a recording +// client and asserted verbatim. +func TestRound8_CreateTerminalPayment_AllowTippingFalse(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + _, bookingID, _ := setupTestData(t, ctx, tx) + adminToken := jwt.GenerateAdminToken() + + origClient := SquareClient + rec := &recordingCheckoutClient{SquareClient: square.NewDevClient()} + SquareClient = rec + defer func() { SquareClient = origClient }() + + handler := CreateTerminalPayment + req := CreateTerminalPaymentRequest{ + Amount: 5500, + PaymentType: "full", + TipEnabled: true, + } + w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + reqs := rec.checkoutReqs() + require.Len(t, reqs, 1, "exactly one CreateCheckoutReq must be sent to Square") + assert.False(t, reqs[0].AllowTipping, + "AllowTipping must be false even with TipEnabled — the tip is already embedded in the amount") + assert.Equal(t, int64(5500), reqs[0].Amount, "the charge amount (tip embedded) must reach Square verbatim") + assert.Equal(t, bookingID, reqs[0].ReferenceID, "the checkout must be scoped to the booking") +} + +// ============================================================================= +// T9 — acquireAdvisoryXactLockBlocking blocks waiters until the holder commits +// ============================================================================= + +// TestRound8_AdvisoryXactLock_BlocksWaiterUntilCommit pins the T9 contract of +// the deliberately-unbounded transaction-scoped refund lock: a second waiter +// on the same "crussell:refund:" key must BLOCK (not time out, not proceed) +// while the holder's transaction is open, and must acquire the lock — returning +// nil — only after the holder commits. This is the "a refund must never be +// dropped" rationale: if the manual RefundPayment holds the key across its +// up-to-30s Square round-trip, a timed-out cancellation would abort and the +// caller would commit a cancellation with ZERO refund rows created (no sweep +// retry is possible because the rows never existed). Uses channels + timeouts +// so the assertion never depends on a sleep; both transactions are rolled back +// when the lock is not acquired. +func TestRound8_AdvisoryXactLock_BlocksWaiterUntilCommit(t *testing.T) { + ctx := context.Background() + key := "crussell:refund:round8-locktest" + + // Goroutine A: the holder. Its transaction stays OPEN until we commit it, + // so the lock it holds is never released early. + holderTx, err := db.Conn.Begin(ctx) + require.NoError(t, err, "failed to begin holder tx") + defer func() { _ = holderTx.Rollback(ctx) }() + + require.NoError(t, acquireAdvisoryXactLockBlocking(ctx, holderTx, key), + "the uncontended blocking xact lock must be acquired immediately") + + // Goroutine B: the waiter. It signals that it has STARTED (its tx is open + // and it is about to issue the blocking acquire) and then reports the + // acquire result on a buffered channel. + started := make(chan struct{}) + acquired := make(chan error, 1) + go func() { + waiterTx, err := db.Conn.Begin(ctx) + if err != nil { + acquired <- err + return + } + defer func() { _ = waiterTx.Rollback(ctx) }() + close(started) + acquired <- acquireAdvisoryXactLockBlocking(ctx, waiterTx, key) + }() + + <-started + + // While the holder's tx is open, the waiter must NOT have returned. + select { + case err := <-acquired: + t.Fatalf("waiter returned %v while the holder tx was still open — the blocking xact lock did not block", err) + case <-time.After(300 * time.Millisecond): + // Expected: the waiter is blocked on the holder's lock. + } + + // Release the lock by committing the holder's transaction; the waiter must + // then acquire it and return nil. + require.NoError(t, holderTx.Commit(ctx), "failed to commit holder tx") + + select { + case err := <-acquired: + require.NoError(t, err, "the waiter must acquire the lock once the holder commits") + case <-time.After(10 * time.Second): + t.Fatal("waiter never acquired the lock after the holder committed — the blocking xact lock did not release") + } +} diff --git a/backend/handlers/payments/payments_round9_test.go b/backend/handlers/payments/payments_round9_test.go new file mode 100644 index 0000000..6093769 --- /dev/null +++ b/backend/handlers/payments/payments_round9_test.go @@ -0,0 +1,1181 @@ +//go:build test && dev + +package payments + +// ============================================================================= +// ROUND 9 — money-safety & security gaps from the adversarial test-quality review +// ============================================================================= +// +// This file pins the behaviors the review found untested: +// +// 1. Sweep source-override replay: reconcileStalePaymentByKey must replay the +// stored square_request_snapshot with the LIVE square_source_id column +// overriding the snapshot's embedded (possibly stale) SourceID — a pending +// row re-issued by a same-key retry refreshes the column while the snapshot +// JSON stays stale, and replaying the stale source would hit +// IDEMPOTENCY_KEY_REUSED and strand the row pending forever. A corrupt or +// unusable snapshot must leave the row pending (never panic, never falsely +// complete). +// +// 2. CancelGiftCard (C3 — the statutory 14-day cooling-off right): the money +// path must issue EXACTLY ONE Square refund to the original payment +// method, atomically neutralize the card (amount_remaining zeroed + +// expired) with a 'cancelled' gift_card_transactions audit row and a +// completed 'giftcard_cancel' refund row, reject every ineligible card +// (wrong owner, till/admin purchase, redeemed/spent/top-up'd, outside the +// 14-day window) with no Square call, and leave the card live + the refund +// row pending when Square fails. GetMyGiftCards must surface the +// cancellable cards with the right fields. +// +// 3. Handler-level £10,000 (1,000,000 pence) amount cap on CreateTillSale and +// CreateTerminalPayment: an over-cap request must 400 with NO till_sales / +// payments row and NO Square checkout created, while exactly £10,000 stays +// inside the cap. +// +// SCOPE NOTES (items the review listed but cannot be tested from this file): +// - clientIP / TRUST_PROXY_HEADERS gating (item 4): trustProxyHeaders is a +// package-private var in crussell/mw; both files owned this round live in +// package payments / user, so a ratelimit test cannot be placed here — +// SKIPPED (noted for the mw package). +// - CleanupIdleAccounts Square-deletion path (item 5): the function lives in +// crussell/handlers/scheduling; the user package cannot reach it without +// importing scheduling — SKIPPED (noted for the scheduling package). +// - Webhook suite isolation (item 6): TestWebhook_DisputeCreated_NoLocalPayment_NoRow +// (webhooks_state_test.go) ACKs ALL global unacknowledged +// critical_payment_log notifications (UPDATE ... acknowledged_at = NOW() +// with no scoping), making notification-count assertions order-dependent. +// The required fix is a scoped reset in the webhooks TestMain that deletes +// only test-created admin_notifications — NOT editable this round. + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "crussell/clock" + "crussell/db" + "crussell/internal/square" + "crussell/mw" + "crussell/testutils" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================= +// Round 9 helpers +// ============================================================================= + +// round9CheckoutClient wraps a Square client and counts every CreateCheckout +// call so tests can prove an over-cap request never reaches Square's terminal +// checkout API. +type round9CheckoutClient struct { + square.SquareClient + mu sync.Mutex + calls int +} + +func (c *round9CheckoutClient) CreateCheckout(ctx context.Context, req square.CreateCheckoutReq) (*square.CheckoutResult, error) { + c.mu.Lock() + c.calls++ + c.mu.Unlock() + return c.SquareClient.CreateCheckout(ctx, req) +} + +// round9CancelGiftCard POSTs a gift-card cancellation through the real router +// (mw.RequireAuth + mw.RequireNonGuest, matching main.go) with the test +// transaction embedded in the request context. +func round9CancelGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token, code string) *httptest.ResponseRecorder { + t.Helper() + body, _ := json.Marshal(CancelGiftCardRequest{Code: code}) + r := httptest.NewRequest(http.MethodPost, "/user/giftcards/cancel", bytes.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("Content-Type", "application/json") + r = r.WithContext(db.ContextWithTx(r.Context(), tx)) + w := httptest.NewRecorder() + router := chi.NewRouter() + router.Use(mw.RequireAuth) + router.With(mw.RequireNonGuest).Post("/user/giftcards/cancel", CancelGiftCard) + router.ServeHTTP(w, r) + return w +} + +// round9CancelGiftCardWithPaymentID POSTs a gift-card cancellation carrying an +// explicit payment id (the suppliedPaymentID branch of +// findGiftCardPurchasePayment), through the real router with the test +// transaction embedded in the request context. +func round9CancelGiftCardWithPaymentID(t *testing.T, ctx context.Context, tx pgx.Tx, token, code, paymentID string) *httptest.ResponseRecorder { + t.Helper() + body, _ := json.Marshal(CancelGiftCardRequest{Code: code, PaymentID: paymentID}) + r := httptest.NewRequest(http.MethodPost, "/user/giftcards/cancel", bytes.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("Content-Type", "application/json") + r = r.WithContext(db.ContextWithTx(r.Context(), tx)) + w := httptest.NewRecorder() + router := chi.NewRouter() + router.Use(mw.RequireAuth) + router.With(mw.RequireNonGuest).Post("/user/giftcards/cancel", CancelGiftCard) + router.ServeHTTP(w, r) + return w +} + +// round9GetMyGiftCards GETs /user/giftcards through the real router with the +// test transaction embedded in the request context. +func round9GetMyGiftCards(t *testing.T, ctx context.Context, tx pgx.Tx, token string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(http.MethodGet, "/user/giftcards", nil) + r.Header.Set("Authorization", "Bearer "+token) + r = r.WithContext(db.ContextWithTx(r.Context(), tx)) + w := httptest.NewRecorder() + router := chi.NewRouter() + router.Use(mw.RequireAuth) + router.Get("/user/giftcards", GetMyGiftCards) + router.ServeHTTP(w, r) + return w +} + +// round9BuyGiftCardForFriend buys an online gift card for a friend via the +// real BuyGiftCard handler (the ONLY customer-facing online purchase path that +// creates a cancellable card: payments row + gift_cards row + a purchase +// gift_card_transactions with reference_type='api' and user_id=the caller). +// Returns the new card id and the HTTP status. +func round9BuyGiftCardForFriend(t *testing.T, ctx context.Context, tx pgx.Tx, token string, amount int) (cardID string, code int) { + t.Helper() + reqBody, _ := json.Marshal(map[string]interface{}{ + "amount": amount, + "recipient_type": "friend", + "new_card_token": "cnon:card-nonce-ok", + "idempotency_key": fmt.Sprintf("round9-buy-%d-%d", amount, time.Now().UnixNano()), + }) + r := httptest.NewRequest(http.MethodPost, "/user/giftcards/buy", bytes.NewBuffer(reqBody)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("Content-Type", "application/json") + r = r.WithContext(db.ContextWithTx(r.Context(), tx)) + w := httptest.NewRecorder() + router := chi.NewRouter() + router.Use(mw.RequireAuth) + router.With(mw.RequireNonGuest).Post("/user/giftcards/buy", BuyGiftCard) + router.ServeHTTP(w, r) + if w.Code == http.StatusCreated { + var resp map[string]interface{} + _ = json.NewDecoder(w.Body).Decode(&resp) + if c, ok := resp["code"].(string); ok { + cardID = c + } + } + return cardID, w.Code +} + +// round9TerminalPayment POSTs a CreateTerminalPayment request through the real +// router with the test transaction embedded in the request context. +func round9TerminalPayment(t *testing.T, ctx context.Context, tx pgx.Tx, bookingID, adminToken string, body map[string]interface{}) *httptest.ResponseRecorder { + t.Helper() + bodyBytes, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/api/admin/bookings/"+bookingID+"/payment", bytes.NewReader(bodyBytes)) + r.Header.Set("Authorization", "Bearer "+adminToken) + r.Header.Set("Content-Type", "application/json") + r = r.WithContext(db.ContextWithTx(r.Context(), tx)) + w := httptest.NewRecorder() + router := chi.NewRouter() + router.Use(mw.RequireAuth) + router.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment) + router.ServeHTTP(w, r) + return w +} + +// round9SeedGiftCardPurchase inserts a fully-funded, unredeemed, non-inventory +// online gift-card purchase for userID exactly as BuyGiftCard would: a +// completed online_square payments row with square_payment_id and booking_id +// NULL, a gift_cards row holding the full purchase value, and a purchase +// gift_card_transactions row (reference_type='api', user_id=userID). purchaseAge +// ages the whole purchase (card, transaction, and payment — the payment lands +// one minute before the transaction so findGiftCardPurchasePayment's 15-minute +// match window sees it). Returns the card id and payment id; pool-level +// cleanup is registered. +func round9SeedGiftCardPurchase(t *testing.T, ctx context.Context, q db.Querier, userID string, amountPounds float64, purchaseAge time.Duration) (cardID, paymentID string) { + t.Helper() + pool := context.Background() + purchasedAt := clock.Now().Add(-purchaseAge) + if err := q.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, created_at) + VALUES ($1, $1, $2, FALSE, $3::timestamptz, $3::timestamptz + INTERVAL '24 months', $3::timestamptz) + RETURNING id + `, amountPounds, userID, purchasedAt).Scan(&cardID); err != nil { + t.Fatalf("failed to seed gift card: %v", err) + } + if _, err := q.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes, created_at) + VALUES ($1, 'purchase', $2, 'api', NULL, $3, 'purchased for friend', $4) + `, cardID, amountPounds, userID, purchasedAt); err != nil { + t.Fatalf("failed to seed gift-card purchase transaction: %v", err) + } + if err := q.QueryRow(ctx, ` + INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, square_payment_id, idempotency_key, created_by, created_at, updated_at) + VALUES (NULL, 'full', 'online_square', 'completed', $1, $2, $3, $4, $5, $5) + RETURNING id + `, amountPounds, "pay_gccancel_"+cardID, "gccancel-"+cardID, userID, purchasedAt.Add(-time.Minute)).Scan(&paymentID); err != nil { + t.Fatalf("failed to seed gift-card purchase payment: %v", err) + } + t.Cleanup(func() { + _, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, cardID) + _, _ = db.Conn.Exec(pool, `DELETE FROM refunds WHERE payment_id = $1`, paymentID) + _, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, cardID) + }) + return cardID, paymentID +} + +// round9SeedStaleKeyedPending inserts a stale pending payments row (23h old — +// past the 22h keyed-reconcile cutoff, still inside Square's 24h idempotency +// retention window) with a stored idempotency key, a square_source_id, and a +// square_request_snapshot JSON string. An empty snapshot is stored as NULL +// (the legacy-row shape). Pool-level cleanup is registered. +func round9SeedStaleKeyedPending(t *testing.T, ctx context.Context, q db.Querier, bookingID string, amountPounds float64, key, liveSource, snapshotJSON string) string { + t.Helper() + var payID string + if err := q.QueryRow(ctx, ` + INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, square_source_id, square_request_snapshot, created_at, updated_at) + VALUES ($1, 'full', 'online_square', 'pending', $2, $3, $4, NULLIF($5, ''), NOW() - INTERVAL '23 hours', NOW()) + RETURNING id + `, bookingID, amountPounds, key, liveSource, snapshotJSON).Scan(&payID); err != nil { + t.Fatalf("failed to seed stale pending payment: %v", err) + } + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, payID) + }) + return payID +} + +// round9SweepCommitAndCleanup commits the caller's setup transaction (so the +// sweep, which runs against the pool, sees the seeded rows) and registers +// pool-level cleanup for the standard user/service/booking fixture trio. +func round9SweepCommitAndCleanup(t *testing.T, ctx context.Context, staleID, bookingID, serviceID, userID string) { + t.Helper() + pgxTx := db.TxFromContext(ctx) + require.NotNil(t, pgxTx, "setup transaction missing from context") + require.NoError(t, pgxTx.Commit(ctx), "failed to commit sweep setup tx") + pool := context.Background() + t.Cleanup(func() { + _, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE id = $1`, staleID) + _, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID) + }) +} + +// ============================================================================= +// 1. Sweep source-override replay +// ============================================================================= + +// TestRound9_SweepSourceOverride_SnapshotSourceVsLiveColumn_LiveWins locks the +// sweep source-override money-safety contract: a pending row whose stored +// square_request_snapshot embeds a STALE source (the JSON still carries the +// original charge's source) while the live square_source_id column holds a +// DIFFERENT source (refreshed by a same-key retry) must be replayed with the +// LIVE column value. The dev mock holds a COMPLETED charge under the same +// idempotency key whose source is the LIVE value — the exact condition where a +// snapshot-only replay returns IDEMPOTENCY_KEY_REUSED and strands the row +// pending forever. The sweep must rescue the row to 'completed' with the +// mock's square_payment_id, proving the live-column override won. +func TestRound9_SweepSourceOverride_SnapshotSourceVsLiveColumn_LiveWins(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + serviceID, err := fixtures.CreateTestService(tx) + require.NoError(t, err) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + require.NoError(t, err) + + const key = "round9-source-override-key" + const snapshotSource = "cnon:snapshot-source" + const liveSource = "cnon:live-source" + // The stored snapshot embeds the ORIGINAL charge's source; the live column + // was refreshed by a same-key retry (the write side refreshes + // square_source_id, and B6 keeps the snapshot in sync — a legacy/race row + // may still diverge, which is exactly what the sweep override rescues). + snapshotJSON := fmt.Sprintf(`{"Amount":200000,"Currency":"GBP","SourceID":%q,"IdempotencyKey":%q}`, snapshotSource, key) + staleID := round9SeedStaleKeyedPending(t, ctx, tx, bookingID, 2000.00, key, liveSource, snapshotJSON) + + origClient := SquareClient + mock := square.NewDevClient().(*square.MockClient) + // The charge actually landed at Square under the LIVE source (the source + // the retained key used). A replay WITHOUT the override would send the + // snapshot's stale source and the mock would reject it with + // IDEMPOTENCY_KEY_REUSED. + pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{ + Amount: 200000, + Currency: "GBP", + SourceID: liveSource, + IdempotencyKey: key, + }) + require.NoError(t, err) + SquareClient = mock + defer func() { SquareClient = origClient }() + + round9SweepCommitAndCleanup(t, ctx, staleID, bookingID, serviceID, userID) + + if _, err := SweepStalePendingPayments(context.Background()); err != nil { + t.Fatalf("sweep failed: %v", err) + } + + var status, sqPayID string + require.NoError(t, db.Conn.QueryRow(context.Background(), + `SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1`, staleID).Scan(&status, &sqPayID)) + assert.Equal(t, "completed", status, + "a genuinely-charged lost-response row must be rescued to completed") + assert.Equal(t, pay.SquarePayID, sqPayID, + "the replay override to the LIVE square_source_id column must win over the stale snapshot source") +} + +// TestRound9_SweepSourceOverride_CorruptSnapshot_LeavesPending locks the +// corrupt-snapshot money-safety rule: a pending row whose stored +// square_request_snapshot cannot be parsed (unmarshal fails when the sweep +// tries to override the source) must be LEFT PENDING — never rescued to +// completed and never failed, because the true charge outcome is unknowable +// and a crash/panic would lose the money entirely. The mock holds a COMPLETED +// charge under the key, so the ONLY thing standing between the row and a +// rescue is the broken snapshot. +func TestRound9_SweepSourceOverride_CorruptSnapshot_LeavesPending(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + serviceID, err := fixtures.CreateTestService(tx) + require.NoError(t, err) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + require.NoError(t, err) + + const key = "round9-corrupt-snapshot-key" + staleID := round9SeedStaleKeyedPending(t, ctx, tx, bookingID, 2000.00, key, "cnon:live-source", `not-json{{{{`) + + origClient := SquareClient + mock := square.NewDevClient().(*square.MockClient) + // The charge really completed at Square under the key — a rescue WOULD be + // correct if the snapshot were usable, which makes "left pending" the only + // safe outcome for the unparseable snapshot. + _, err = mock.CreatePayment(context.Background(), square.CreatePaymentReq{ + Amount: 200000, + Currency: "GBP", + SourceID: "cnon:live-source", + IdempotencyKey: key, + }) + require.NoError(t, err) + SquareClient = mock + defer func() { SquareClient = origClient }() + + round9SweepCommitAndCleanup(t, ctx, staleID, bookingID, serviceID, userID) + + if _, err := SweepStalePendingPayments(context.Background()); err != nil { + t.Fatalf("sweep failed: %v", err) + } + + var status string + var sqPayID sql.NullString + require.NoError(t, db.Conn.QueryRow(context.Background(), + `SELECT status, square_payment_id FROM payments WHERE id = $1`, staleID).Scan(&status, &sqPayID)) + assert.Equal(t, "pending", status, + "a corrupt snapshot must leave the row pending (never panicked, never falsely completed)") + assert.False(t, sqPayID.Valid, + "no square_payment_id may be written for a row with an unparseable snapshot") +} + +// TestRound9_SweepSourceOverride_EmptySnapshot_SourceMismatch_LeavesPending +// locks the empty-snapshot (legacy NULL square_request_snapshot) money-safety +// rule: the sweep rebuilds the minimal fallback replay body from the live +// square_source_id column, and when that source does NOT match the source the +// original charge used (the live column was refreshed by a same-key retry) the +// identical-body replay returns IDEMPOTENCY_KEY_REUSED — which is NEVER proof +// of no charge. The row must stay pending (CRITICAL logged), never falsely +// completed and never failed. +func TestRound9_SweepSourceOverride_EmptySnapshot_SourceMismatch_LeavesPending(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + serviceID, err := fixtures.CreateTestService(tx) + require.NoError(t, err) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + require.NoError(t, err) + + const key = "round9-empty-snapshot-key" + // Empty snapshot (stored NULL, the legacy-row shape) + a live source that + // differs from the source the original charge actually used. The minimal + // fallback replay body is built from the LIVE column, so the mock (which + // compares the source) sees a different source than the retained key's + // original and returns IDEMPOTENCY_KEY_REUSED. + staleID := round9SeedStaleKeyedPending(t, ctx, tx, bookingID, 2000.00, key, "cnon:live-source", "") + + origClient := SquareClient + mock := square.NewDevClient().(*square.MockClient) + // The ORIGINAL charge used a different source than the live column. + _, err = mock.CreatePayment(context.Background(), square.CreatePaymentReq{ + Amount: 200000, + Currency: "GBP", + SourceID: "cnon:original-source", + IdempotencyKey: key, + }) + require.NoError(t, err) + SquareClient = mock + defer func() { SquareClient = origClient }() + + round9SweepCommitAndCleanup(t, ctx, staleID, bookingID, serviceID, userID) + + if _, err := SweepStalePendingPayments(context.Background()); err != nil { + t.Fatalf("sweep failed: %v", err) + } + + var status string + var sqPayID sql.NullString + require.NoError(t, db.Conn.QueryRow(context.Background(), + `SELECT status, square_payment_id FROM payments WHERE id = $1`, staleID).Scan(&status, &sqPayID)) + assert.Equal(t, "pending", status, + "IDEMPOTENCY_KEY_REUSED on the empty-snapshot fallback must leave the row pending (never proof of no charge)") + assert.False(t, sqPayID.Valid, + "no square_payment_id may be written when the empty-snapshot replay could not prove the charge") +} + +// ============================================================================= +// 2. CancelGiftCard — the 14-day cooling-off money path (C3) +// ============================================================================= + +// TestRound9_CancelGiftCard_HappyPath_RefundAndNeutralize locks the core C3 +// money path: an online gift-card purchase for a friend (<14 days, unredeemed, +// full balance) is cancelled via POST /user/giftcards/cancel → 200, the Square +// refund is issued EXACTLY once to the original payment, a 'giftcard_cancel' +// refund row is created and completed with the Square refund id, the card is +// neutralized (amount_remaining 0, expiry set to now so it can never be +// spent), and a 'cancelled' gift_card_transactions audit row records the +// reversal — all atomically. The originating payment is auto-matched from the +// gift-card purchase transaction (no client-supplied payment id), exactly as +// the frontend cancel flow does. This auto-match path is currently BLOCKED on +// findGiftCardPurchasePayment (giftcards.go) accepting a timestamptz parameter +// in interval arithmetic (`$3 - INTERVAL '15 minutes'` — needs `::timestamptz` +// casts, otherwise the query 500s); the money path itself is verified via the +// supplied-payment branch in TestRound9_CancelGiftCard_HappyPath_WithPaymentID_RefundAndNeutralize. +func TestRound9_CancelGiftCard_HappyPath_RefundAndNeutralize(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(userID, "verified_email") + + origClient := SquareClient + mock := square.NewDevClient().(*square.MockClient) + counting := &countingRefundClient{SquareClient: mock} + SquareClient = counting + defer func() { SquareClient = origClient }() + + cardID, code := round9BuyGiftCardForFriend(t, ctx, tx.(pgx.Tx), token, 5000) + require.Equal(t, http.StatusCreated, code, "buy must succeed") + require.NotEmpty(t, cardID, "buy must return the card id") + + var remaining float64 + require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining)) + require.Equal(t, 50.00, remaining, "card must hold its full £50 value before cancellation") + + w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "success", "the cancel response must report success") + + // Exactly ONE Square refund to the original payment, for the full value. + calls := counting.refundCalls() + require.Len(t, calls, 1, "exactly one Square refund for a single cancellation") + assert.Equal(t, int64(5000), calls[0].Amount, "the full purchase value must be refunded in pence") + require.NotEmpty(t, calls[0].PaymentID, "the refund must target the originating Square payment") + + // Refunds row: origin 'giftcard_cancel', completed, with the Square id. + var payID string + require.NoError(t, tx.QueryRow(ctx, ` + SELECT id FROM payments WHERE created_by = $1 AND payment_method = 'online_square' AND booking_id IS NULL + `, userID).Scan(&payID)) + var refundOrigin, refundStatus string + var refundAmount float64 + var sqRefundID sql.NullString + require.NoError(t, tx.QueryRow(ctx, ` + SELECT origin, status, amount, square_refund_id FROM refunds WHERE payment_id = $1 + `, payID).Scan(&refundOrigin, &refundStatus, &refundAmount, &sqRefundID)) + assert.Equal(t, "giftcard_cancel", refundOrigin, "the refund row must carry the giftcard_cancel origin") + assert.Equal(t, "completed", refundStatus, "a COMPLETED Square refund resolves the row to completed") + assert.Equal(t, 50.00, refundAmount, "the refund row records the full purchase value") + require.True(t, sqRefundID.Valid && sqRefundID.String != "", "the Square refund id must be recorded on the refund row") + + // Card neutralized: zero balance, expired (cannot be spent). + var rem float64 + var expiry sql.NullTime + require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry)) + assert.Equal(t, 0.00, rem, "card balance must be zeroed after cancellation") + require.True(t, expiry.Valid, "the cancelled card must have an expiry date") + assert.False(t, expiry.Time.After(clock.Now()), "the cancelled card must be expired (expiry_date set to now)") + + // 'cancelled' audit row written against the refund. + var cancelTxCount int + require.NoError(t, tx.QueryRow(ctx, ` + SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'cancelled' + `, cardID).Scan(&cancelTxCount)) + assert.Equal(t, 1, cancelTxCount, "a 'cancelled' gift_card_transactions row must record the reversal") +} + +// TestRound9_CancelGiftCard_HappyPath_WithPaymentID_RefundAndNeutralize locks +// the C3 money path through findGiftCardPurchasePayment's supplied-payment +// branch (the frontend can pass the purchase payment id to skip the amount/ +// timing auto-match): the Square refund is issued exactly once for the full +// purchase value, the 'giftcard_cancel' refund row is completed with the +// Square refund id, the card is neutralized (zeroed + expired), and the +// 'cancelled' audit row is written. This variant does NOT depend on the +// auto-match SQL (`$3 - INTERVAL '15 minutes'`) whose param-type inference is +// being corrected in giftcards.go, so it verifies the money path today. +func TestRound9_CancelGiftCard_HappyPath_WithPaymentID_RefundAndNeutralize(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(userID, "verified_email") + + origClient := SquareClient + mock := square.NewDevClient().(*square.MockClient) + counting := &countingRefundClient{SquareClient: mock} + SquareClient = counting + defer func() { SquareClient = origClient }() + + cardID, code := round9BuyGiftCardForFriend(t, ctx, tx.(pgx.Tx), token, 5000) + require.Equal(t, http.StatusCreated, code, "buy must succeed") + require.NotEmpty(t, cardID, "buy must return the card id") + + var payID string + require.NoError(t, tx.QueryRow(ctx, ` + SELECT id FROM payments WHERE created_by = $1 AND payment_method = 'online_square' AND booking_id IS NULL + `, userID).Scan(&payID)) + + w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, payID) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "success", "the cancel response must report success") + + calls := counting.refundCalls() + require.Len(t, calls, 1, "exactly one Square refund for a single cancellation") + assert.Equal(t, int64(5000), calls[0].Amount, "the full purchase value must be refunded in pence") + + var refundOrigin, refundStatus string + var refundAmount float64 + var sqRefundID sql.NullString + require.NoError(t, tx.QueryRow(ctx, ` + SELECT origin, status, amount, square_refund_id FROM refunds WHERE payment_id = $1 + `, payID).Scan(&refundOrigin, &refundStatus, &refundAmount, &sqRefundID)) + assert.Equal(t, "giftcard_cancel", refundOrigin, "the refund row must carry the giftcard_cancel origin") + assert.Equal(t, "completed", refundStatus, "a COMPLETED Square refund resolves the row to completed") + assert.Equal(t, 50.00, refundAmount, "the refund row records the full purchase value") + require.True(t, sqRefundID.Valid && sqRefundID.String != "", "the Square refund id must be recorded on the refund row") + + var rem float64 + var expiry sql.NullTime + require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry)) + assert.Equal(t, 0.00, rem, "card balance must be zeroed after cancellation") + require.True(t, expiry.Valid, "the cancelled card must have an expiry date") + assert.False(t, expiry.Time.After(clock.Now()), "the cancelled card must be expired (expiry_date set to now)") + + var cancelTxCount int + require.NoError(t, tx.QueryRow(ctx, ` + SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'cancelled' + `, cardID).Scan(&cancelTxCount)) + assert.Equal(t, 1, cancelTxCount, "a 'cancelled' gift_card_transactions row must record the reversal") +} + +// TestRound9_CancelGiftCard_DoubleCancel_NoSecondSquareRefund locks the +// double-cancel money-safety contract: after a successful first cancellation +// the card holds no value, so a second POST must NOT issue a second Square +// refund and must NOT create a second refunds row. Square is called exactly +// ONCE total. (The implemented code rejects the repeat with 400 — "topped up, +// transferred, or partially spent" — because the neutralized card no longer +// holds its full purchase value; the review's expected "already refunded" +// message is not emitted on this path, but the money-safety property — one +// Square refund, one refund row — is what the test pins.) +func TestRound9_CancelGiftCard_DoubleCancel_NoSecondSquareRefund(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(userID, "verified_email") + + origClient := SquareClient + mock := square.NewDevClient().(*square.MockClient) + counting := &countingRefundClient{SquareClient: mock} + SquareClient = counting + defer func() { SquareClient = origClient }() + + cardID, code := round9BuyGiftCardForFriend(t, ctx, tx.(pgx.Tx), token, 2000) + require.Equal(t, http.StatusCreated, code, "buy must succeed") + require.NotEmpty(t, cardID) + + var payID string + require.NoError(t, tx.QueryRow(ctx, ` + SELECT id FROM payments WHERE created_by = $1 AND payment_method = 'online_square' AND booking_id IS NULL + `, userID).Scan(&payID)) + + w1 := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, payID) + require.Equal(t, http.StatusOK, w1.Code, "first cancel must succeed: %s", w1.Body.String()) + require.Len(t, counting.refundCalls(), 1, "first cancel issues one Square refund") + + w2 := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) + require.NotEqual(t, http.StatusOK, w2.Code, + "a repeat cancel must not report a fresh success, body: %s", w2.Body.String()) + + require.Len(t, counting.refundCalls(), 1, + "Square must be called exactly once total across both cancels") + + var refundCount int + require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, payID).Scan(&refundCount)) + assert.Equal(t, 1, refundCount, "exactly one refund row after a double-cancel attempt") +} + +// TestRound9_CancelGiftCard_WrongUser_Rejected locks the ownership gate: a +// user who is NOT the buyer of an online gift-card purchase cannot cancel it — +// the purchase-transaction lookup is scoped to user_id = caller, so a +// non-owner's request is rejected with NO Square refund call and NO refund row. +// The implementation returns 400 (information-hiding — a non-owner cannot +// distinguish an existing card from a nonexistent one); the review's expected +// 404/403 is functionally equivalent. +func TestRound9_CancelGiftCard_WrongUser_Rejected(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + buyerID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + attackerID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + attackerToken := jwt.GenerateTestToken(attackerID, "verified_email") + + cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, buyerID, 50.00, 0) + + origClient := SquareClient + counting := &countingRefundClient{SquareClient: square.NewDevClient()} + SquareClient = counting + defer func() { SquareClient = origClient }() + + w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), attackerToken, cardID) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "not purchased online by your account") + require.Empty(t, counting.refundCalls(), "no Square refund for a non-owner cancel") + var refundCount int + require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)) + assert.Equal(t, 0, refundCount, "no refund row for a non-owner cancel") +} + +// TestRound9_CancelGiftCard_TillOrAdminPurchase_Rejected locks the source-of- +// purchase gate: a card that was NOT purchased online by the caller — sold at +// the till (gift_card_transactions reference_type='till_sale') or created by +// an admin (reference_type='api' but user_id = admin, not the caller) — must +// be rejected with 400 and no Square call, because the statutory right applies +// only to the consumer's own distance contracts. +func TestRound9_CancelGiftCard_TillOrAdminPurchase_Rejected(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(userID, "verified_email") + + origClient := SquareClient + counting := &countingRefundClient{SquareClient: square.NewDevClient()} + SquareClient = counting + defer func() { SquareClient = origClient }() + + t.Run("till_sale_card", func(t *testing.T) { + cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) + // A till sale writes the purchase transaction with reference_type + // 'till_sale' (see till.go), never 'api'. + _, err := tx.Exec(ctx, ` + UPDATE gift_card_transactions SET reference_type = 'till_sale', user_id = NULL + WHERE gift_card_id = $1 AND transaction_type = 'purchase' + `, cardID) + require.NoError(t, err) + + w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "not purchased online by your account") + var refundCount int + require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)) + assert.Equal(t, 0, refundCount) + }) + + t.Run("admin_created_card", func(t *testing.T) { + cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) + // CreateGiftCard (admin) writes reference_type='api' but user_id = the + // ADMIN, never the calling user. + _, err := tx.Exec(ctx, ` + UPDATE gift_card_transactions SET user_id = $1 + WHERE gift_card_id = $2 AND transaction_type = 'purchase' + `, adminID, cardID) + require.NoError(t, err) + + w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "not purchased online by your account") + var refundCount int + require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)) + assert.Equal(t, 0, refundCount) + }) + + require.Empty(t, counting.refundCalls(), "no Square refund for till/admin-purchased cards") +} + +// TestRound9_CancelGiftCard_PartiallySpent_RefundsRemaining pins the CCR 2013 +// reg 34(9) partial-use behaviour: a card whose balance was partially spent at +// the till (shortfall verifiable via a completed payments row carrying +// gift_card_id, status='completed', payment_method='giftcard') is still +// cancellable within the 14-day window — Square refunds only the UNSPENT +// remainder, and the card is neutralized (zeroed + expired) so the refunded +// value can never be spent on top of the returned money. The already-spent +// portion is consumed salon services, not refundable. +func TestRound9_CancelGiftCard_PartiallySpent_RefundsRemaining(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(userID, "verified_email") + + origClient := SquareClient + mock := square.NewDevClient().(*square.MockClient) + counting := &countingRefundClient{SquareClient: mock} + SquareClient = counting + defer func() { SquareClient = origClient }() + + // £50 online purchase, £20 genuinely spent at the till (a completed + // giftcard payment row carrying the card id), £30 remaining. + cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) + _, err = tx.Exec(ctx, ` + UPDATE gift_cards SET amount_remaining = 30.00 WHERE id = $1`, cardID) + require.NoError(t, err) + _, err = tx.Exec(ctx, ` + INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at, gift_card_id) + VALUES (NULL, 'full', 'giftcard', 'completed', 20.00, 'spend-' || $1::text, $2, NOW(), NOW(), $1)`, cardID, userID) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE idempotency_key = 'spend-' || $1`, cardID) + }) + + w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "£30.00", "the message must state the refunded unspent portion") + assert.Contains(t, w.Body.String(), "£20.00", "the message must state the non-refundable spent portion") + + // Exactly ONE Square refund, for the UNSPENT remainder (3000 pence). + calls := counting.refundCalls() + require.Len(t, calls, 1, "exactly one Square refund for a partial cancellation") + assert.Equal(t, int64(3000), calls[0].Amount, "the unspent remainder must be refunded in pence") + + // Card neutralized: zero balance, expired (cannot be spent). + var rem float64 + var expiry sql.NullTime + require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry)) + assert.Equal(t, 0.00, rem, "card balance must be zero after partial cancellation") + require.True(t, expiry.Valid, "the card must still carry an expiry date") + assert.False(t, expiry.Time.After(clock.Now()), "card expiry must be in the past (neutralized)") + + // Refund row recorded at the partial amount with the deterministic key. + var refundAmount float64 + var refundOrigin, refundKey string + require.NoError(t, tx.QueryRow(ctx, ` + SELECT amount, origin, COALESCE(idempotency_key, '') FROM refunds WHERE payment_id = $1`, paymentID). + Scan(&refundAmount, &refundOrigin, &refundKey)) + assert.Equal(t, 30.00, refundAmount, "the refunds row must record the unspent remainder") + assert.Equal(t, "giftcard_cancel", refundOrigin, "the refund must carry the gift-card-cancel origin") + assert.Contains(t, refundKey, "-gccancel-", "the refund key must be the deterministic cancel key") +} + +// TestRound9_CancelGiftCard_PartiallySpent_UnaccountedShortfall_Rejected pins +// that a shortfall NOT attributable to a completed giftcard till-payment (e.g. +// a transfer out, or a manual balance edit) is rejected: without the payments +// linkage the value-state cannot be verified, and issuing a partial refund +// would be unsafe. +func TestRound9_CancelGiftCard_PartiallySpent_UnaccountedShortfall_Rejected(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(userID, "verified_email") + + origClient := SquareClient + counting := &countingRefundClient{SquareClient: square.NewDevClient()} + SquareClient = counting + defer func() { SquareClient = origClient }() + + cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) + // Balance reduced with NO matching giftcard payment row (unaccounted). + _, err = tx.Exec(ctx, `UPDATE gift_cards SET amount_remaining = 30.00 WHERE id = $1`, cardID) + require.NoError(t, err) + + w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + var refundCount int + require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)) + assert.Equal(t, 0, refundCount, "no refund row for an unverifiable shortfall") + require.Empty(t, counting.refundCalls(), "no Square refund for an unaccounted shortfall") +} + +// TestRound9_CancelGiftCard_RedeemedSpentTopup_Rejected locks the value-state +// gates: a card that was redeemed to an account balance, partially spent, or +// topped up no longer holds exactly its original purchase value, so a FULL +// refund would leave money outstanding — each state must be rejected with 400 +// and no Square call. +func TestRound9_CancelGiftCard_RedeemedSpentTopup_Rejected(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(userID, "verified_email") + + cases := []struct { + name string + mutate func(t *testing.T, cardID string) + wantMsg string + }{ + { + name: "redeemed", + mutate: func(t *testing.T, cardID string) { + _, err := tx.Exec(ctx, ` + UPDATE gift_cards SET redeemed_by = $1, redeemed_at = NOW(), amount_remaining = 0 WHERE id = $2 + `, userID, cardID) + require.NoError(t, err) + }, + wantMsg: "already been redeemed", + }, + { + name: "partially_spent", + mutate: func(t *testing.T, cardID string) { + _, err := tx.Exec(ctx, `UPDATE gift_cards SET amount_remaining = 30.00 WHERE id = $1`, cardID) + require.NoError(t, err) + }, + wantMsg: "partially spent", + }, + { + name: "topped_up", + mutate: func(t *testing.T, cardID string) { + _, err := tx.Exec(ctx, `UPDATE gift_cards SET total_funds_added = 100.00, amount_remaining = 100.00 WHERE id = $1`, cardID) + require.NoError(t, err) + }, + wantMsg: "topped up", + }, + } + + origClient := SquareClient + counting := &countingRefundClient{SquareClient: square.NewDevClient()} + SquareClient = counting + defer func() { SquareClient = origClient }() + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) + tc.mutate(t, cardID) + + w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), tc.wantMsg) + var refundCount int + require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)) + assert.Equal(t, 0, refundCount, "no refund row for an ineligible card") + }) + } + + require.Empty(t, counting.refundCalls(), "no Square refund for redeemed/spent/topped-up cards") +} + +// TestRound9_CancelGiftCard_Outside14Days_Rejected locks the statutory timing +// gate: the Consumer Contracts Regulations 2013 cooling-off window is 14 days, +// so a purchase older than 14 days must be rejected with 400 and no Square +// call. +func TestRound9_CancelGiftCard_Outside14Days_Rejected(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(userID, "verified_email") + + cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 15*24*time.Hour) + + origClient := SquareClient + counting := &countingRefundClient{SquareClient: square.NewDevClient()} + SquareClient = counting + defer func() { SquareClient = origClient }() + + w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "14-day cancellation period") + require.Empty(t, counting.refundCalls(), "no Square refund outside the 14-day window") + var refundCount int + require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)) + assert.Equal(t, 0, refundCount, "no refund row outside the 14-day window") +} + +// TestRound9_CancelGiftCard_SquareRefundFails_KeepsCardPendingRefund locks the +// failure-safety contract (C2/F4): when Square's RefundPayment returns an +// ambiguous error, the handler must return 500, leave the gift card LIVE +// (amount_remaining untouched, expiry in the future) and leave the refunds row +// 'pending' with a deterministic idempotency key so a same-key retry — or the +// sweep — can still recover the refund. A refund must never be issued without +// the card value being neutralized, and the card must never be cancelled while +// the money is still with the salon. +func TestRound9_CancelGiftCard_SquareRefundFails_KeepsCardPendingRefund(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(userID, "verified_email") + + cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) + + origClient := SquareClient + // ambiguousRefundClient simulates a transport-level failure: Square may or + // may not have processed the refund, so the row must stay pending. + counting := &countingRefundClient{SquareClient: &ambiguousRefundClient{SquareClient: square.NewDevClient()}} + SquareClient = counting + defer func() { SquareClient = origClient }() + + w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) + require.Equal(t, http.StatusInternalServerError, w.Code, "body: %s", w.Body.String()) + require.Len(t, counting.refundCalls(), 1, "Square refund attempted exactly once") + + // Card NOT neutralized: full balance, expiry still in the future. + var remaining float64 + var expiry sql.NullTime + require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining, &expiry)) + assert.Equal(t, 50.00, remaining, "card balance must be untouched when the Square refund fails") + require.True(t, expiry.Valid, "the card must still carry an expiry date") + assert.True(t, expiry.Time.After(clock.Now()), "card expiry must be untouched (still in the future)") + + // Refund row left pending for a same-key retry / sweep reconciliation. + var status, origin string + var idempotencyKey sql.NullString + require.NoError(t, tx.QueryRow(ctx, ` + SELECT status, origin, idempotency_key FROM refunds WHERE payment_id = $1 + `, paymentID).Scan(&status, &origin, &idempotencyKey)) + assert.Equal(t, "pending", status, "an ambiguous Square failure leaves the refund pending") + assert.Equal(t, "giftcard_cancel", origin) + require.True(t, idempotencyKey.Valid && idempotencyKey.String != "", "the pending refund must carry a deterministic idempotency key") + + // No 'cancelled' audit row — the card was never cancelled. + var cancelTxCount int + require.NoError(t, tx.QueryRow(ctx, ` + SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'cancelled' + `, cardID).Scan(&cancelTxCount)) + assert.Equal(t, 0, cancelTxCount, "no cancellation audit row when the refund failed") +} + +// TestRound9_CancelGiftCard_SquareRefundFails_WithPaymentID_KeepsCardPendingRefund +// locks the failure-safety contract through the supplied-payment branch: when +// Square's RefundPayment returns an ambiguous error, the handler returns 500, +// the card stays LIVE (full balance, expiry in the future), and the +// 'giftcard_cancel' refund row stays 'pending' with a deterministic +// idempotency key for a same-key retry / sweep reconciliation. This variant +// avoids the auto-match SQL dependency and verifies the money-safety behavior +// today. +func TestRound9_CancelGiftCard_SquareRefundFails_WithPaymentID_KeepsCardPendingRefund(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(userID, "verified_email") + + cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) + + origClient := SquareClient + counting := &countingRefundClient{SquareClient: &ambiguousRefundClient{SquareClient: square.NewDevClient()}} + SquareClient = counting + defer func() { SquareClient = origClient }() + + w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID) + require.Equal(t, http.StatusInternalServerError, w.Code, "body: %s", w.Body.String()) + require.Len(t, counting.refundCalls(), 1, "Square refund attempted exactly once") + + var remaining float64 + var expiry sql.NullTime + require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining, &expiry)) + assert.Equal(t, 50.00, remaining, "card balance must be untouched when the Square refund fails") + require.True(t, expiry.Valid, "the card must still carry an expiry date") + assert.True(t, expiry.Time.After(clock.Now()), "card expiry must be untouched (still in the future)") + + var status, origin string + var idempotencyKey sql.NullString + require.NoError(t, tx.QueryRow(ctx, ` + SELECT status, origin, idempotency_key FROM refunds WHERE payment_id = $1 + `, paymentID).Scan(&status, &origin, &idempotencyKey)) + assert.Equal(t, "pending", status, "an ambiguous Square failure leaves the refund pending") + assert.Equal(t, "giftcard_cancel", origin) + require.True(t, idempotencyKey.Valid && idempotencyKey.String != "", "the pending refund must carry a deterministic idempotency key") +} + +// TestRound9_GetMyGiftCards_CancellableCardFields locks the account-page +// surface for the 14-day right: GET /user/giftcards lists the caller's online +// purchases still held as cards with the amount, purchase time, expiry date, +// and — for a card inside the window with a verifiable originating payment and +// no refund — cancellable=true plus the payment id, so the UI can offer +// "Cancel & refund" exactly where the consumer is legally entitled to it. A +// purchase older than 14 days must be listed but marked non-cancellable with +// the expiry reason. +func TestRound9_GetMyGiftCards_CancellableCardFields(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(userID, "verified_email") + + freshCardID, _ := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) + oldCardID, _ := round9SeedGiftCardPurchase(t, ctx, tx, userID, 20.00, 15*24*time.Hour) + + w := round9GetMyGiftCards(t, ctx, tx.(pgx.Tx), token) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + var resp MyGiftCardsResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Len(t, resp.GiftCards, 2, "both online-purchased cards must be listed") + + var fresh, old *MyGiftCard + for i := range resp.GiftCards { + gc := &resp.GiftCards[i] + switch gc.Code { + case freshCardID: + fresh = gc + case oldCardID: + old = gc + } + } + require.NotNil(t, fresh, "the fresh card must be listed") + require.NotNil(t, old, "the old card must be listed") + + require.True(t, fresh.Cancellable, "a fresh unredeemed online purchase must be cancellable") + assert.Equal(t, 50.00, fresh.Amount, "the card amount is the purchase value") + assert.NotEmpty(t, fresh.PaymentID, "the originating payment id must be surfaced for a cancellable card") + assert.False(t, fresh.PurchasedAt.IsZero(), "the purchase time must be surfaced") + require.NotNil(t, fresh.ExpiryDate, "the rolling expiry date must be surfaced") + + assert.False(t, old.Cancellable, "a purchase outside the 14-day window must not be cancellable") + assert.Contains(t, old.CancellationReason, "14-day cancellation period") +} + +// ============================================================================= +// 3. £10,000 amount cap at the handler level (C2) +// ============================================================================= + +// TestRound9_CreateTillSale_OverCap_Rejected_NoRowNoCheckout locks the +// handler-level £10,000 cap on CreateTillSale: a create/top-up amount above +// £10,000 (1,000,000 pence — 10,000.01 pounds) must be rejected with 400 for +// every payment method, with NO till_sales row, NO gift card created, and NO +// Square terminal checkout created (the cap fires before any lock, +// transaction, or Square call). Gift-card till funding is capped at £250 per +// transaction (owner decision) — an amount above £250 must be rejected. +func TestRound9_CreateTillSale_OverCap_Rejected_NoRowNoCheckout(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + adminToken := jwt.GenerateTestToken(adminID, "admin") + + origClient := SquareClient + cc := &round9CheckoutClient{SquareClient: square.NewDevClient()} + SquareClient = cc + defer func() { SquareClient = origClient }() + + cases := []struct { + name string + req TillSaleRequest + }{ + {name: "cash", req: TillSaleRequest{ItemType: "gift_card", Action: "create", Amount: 250.01, PaymentMethod: "cash"}}, + {name: "card_machine", req: TillSaleRequest{ItemType: "gift_card", Action: "create", Amount: 250.01, PaymentMethod: "card_machine"}}, + {name: "online_square", req: TillSaleRequest{ItemType: "gift_card", Action: "create", Amount: 250.01, PaymentMethod: "online_square", CardToken: "cnon:test-card"}}, + {name: "topup_over_cap", req: TillSaleRequest{ItemType: "gift_card", Action: "topup", Amount: 250.01, GiftCardID: stringPtr("a1b2c3d4e5f6"), PaymentMethod: "cash"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := makeTillSaleRequest(t, tc.req, adminToken, ctx, tx.(pgx.Tx)) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "£250", "the rejection must cite the gift-card amount cap") + }) + } + + require.Equal(t, 0, cc.calls, "no Square checkout may be created for an over-cap till sale") + var saleCount int + require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales WHERE created_by = $1`, adminID).Scan(&saleCount)) + assert.Equal(t, 0, saleCount, "no till_sales row for an over-cap till sale") + var cardCount int + require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_cards WHERE created_by = $1`, adminID).Scan(&cardCount)) + assert.Equal(t, 0, cardCount, "no gift card created for an over-cap till sale") +} + +// TestRound9_CreateTerminalPayment_OverCap_Rejected_NoRow locks the +// handler-level £10,000 cap on CreateTerminalPayment: an amount above +// 1,000,000 pence must be rejected with 400 — for the cash path, the terminal +// path (no Square checkout created), and the override_amount path (a valid +// base amount must not bypass the cap) — with NO payments row. The boundary is +// pinned too: exactly £10,000 (1,000,000 pence) is INSIDE the cap and records +// the payment. +func TestRound9_CreateTerminalPayment_OverCap_Rejected_NoRow(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + require.NoError(t, err) + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + serviceID, err := fixtures.CreateTestService(tx) + require.NoError(t, err) + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + require.NoError(t, err) + if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil { + t.Fatalf("failed to set booking in_progress: %v", err) + } + adminToken := jwt.GenerateTestToken(adminID, "admin") + + origClient := SquareClient + cc := &round9CheckoutClient{SquareClient: square.NewDevClient()} + SquareClient = cc + defer func() { SquareClient = origClient }() + + // 1,000,001 pence — one penny over the £10,000 cap. + w := round9TerminalPayment(t, ctx, tx.(pgx.Tx), bookingID, adminToken, + map[string]interface{}{"amount": 1000001, "payment_type": "full", "payment_method": "cash"}) + require.Equal(t, http.StatusBadRequest, w.Code, "cash over-cap body: %s", w.Body.String()) + + // Terminal path (no payment_method → Square checkout): the cap must reject + // BEFORE any checkout is created at Square. + w2 := round9TerminalPayment(t, ctx, tx.(pgx.Tx), bookingID, adminToken, + map[string]interface{}{"amount": 1000001, "payment_type": "full"}) + require.Equal(t, http.StatusBadRequest, w2.Code, "terminal over-cap body: %s", w2.Body.String()) + require.Equal(t, 0, cc.calls, "no Square checkout may be created for an over-cap terminal payment") + + // Override path: a valid base amount must not bypass the cap. + w3 := round9TerminalPayment(t, ctx, tx.(pgx.Tx), bookingID, adminToken, + map[string]interface{}{"amount": 1000, "payment_type": "full", "payment_method": "cash", "override_amount": 1000001}) + require.Equal(t, http.StatusBadRequest, w3.Code, "override over-cap body: %s", w3.Body.String()) + assert.Contains(t, w3.Body.String(), "Invalid override amount", "the override rejection must identify the override") + + // No payment row for any of the rejected requests. + var payCount int + require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount)) + assert.Equal(t, 0, payCount, "no payment row for an over-cap terminal payment") + + // Boundary: exactly £10,000 (1,000,000 pence) is ALLOWED — the cap is + // exclusive. + wb := round9TerminalPayment(t, ctx, tx.(pgx.Tx), bookingID, adminToken, + map[string]interface{}{"amount": 1000000, "payment_type": "full", "payment_method": "cash"}) + require.Equal(t, http.StatusOK, wb.Code, "boundary cash body: %s", wb.Body.String()) + var boundaryCount int + require.NoError(t, tx.QueryRow(ctx, + `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND amount = 10000.00`, bookingID).Scan(&boundaryCount)) + assert.Equal(t, 1, boundaryCount, "exactly £10,000 is inside the cap and must be recorded") +} + +// stringPtr is a small helper for optional string request fields. +func stringPtr(s string) *string { return &s } diff --git a/backend/handlers/payments/refunds.go b/backend/handlers/payments/refunds.go index 67e3072..4a90ed2 100644 --- a/backend/handlers/payments/refunds.go +++ b/backend/handlers/payments/refunds.go @@ -867,21 +867,13 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar // Give up on this charge (the manual handler may hold the lock). The // sweep continues to the next charge rather than aborting fatally. for _, pid := range paymentIDs[:locked] { - if _, err := pinConn.Exec(context.Background(), ` - SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1)) - `, pid); err != nil { - log.Printf("Failed to release refund lock for payment %s: %v", pid, err) - } + releasePaymentLock(pinConn, "crussell:refund:"+pid) } return 0, nil } defer func() { for _, pid := range paymentIDs { - if _, err := pinConn.Exec(context.Background(), ` - SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1)) - `, pid); err != nil { - log.Printf("Failed to release refund lock for payment %s: %v", pid, err) - } + releasePaymentLock(pinConn, "crussell:refund:"+pid) } }() @@ -1170,8 +1162,57 @@ type manualPendingRow struct { // re-issued; rows WITHOUT one (the ambiguous-error path) are re-issued with // their OWN stored idempotency key (Square dedups same-key retries, so the // retry is idempotent). +// +// A terminal pre-pass first sweeps legacy manual rows whose payment has NO +// square_payment_id (pre-dating the handler's square-less guard at +// handlers.go:2477-2480). Such rows can never be refunded via Square and would +// otherwise stay 'pending' forever, blocking the over-refund guard. They are +// marked 'failed' and surfaced in the admin notification centre, mirroring the +// Square-less cancellation pre-pass (refunds.go:554-583) with a DISTINCT +// origin='manual' filter so the two passes never double-process a row. func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) { + // (a) Terminal pre-pass: legacy MANUAL card refunds whose payment has no + // Square reference can never be refunded via Square → mark them failed + // so they stop blocking the over-refund guard, and surface the affected + // booking in the admin notification centre for in-person arrangement. + // Mirrors the cancellation Square-less pre-pass (origin='cancellation', + // refunds.go:554-583) with origin='manual' so the filters stay distinct + // and no row is swept by both passes. Must run OUTSIDE any GROUP BY — + // Postgres lumps NULLs together, so these rows can't be handled in the + // per-payment grouping below. rows, err := db.Conn.Query(ctx, ` + UPDATE refunds r SET status = 'failed' + FROM payments p + WHERE p.id = r.payment_id + AND r.status = 'pending' AND r.refund_attempts < 3 + AND p.payment_method IN ('online_square', 'in_person_card') + AND p.square_payment_id IS NULL + AND r.origin = 'manual' + RETURNING r.id + `) + if err != nil { + log.Printf("Failed to mark Square-less manual refunds failed: %v", err) + } else { + var failedIDs []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil { + failedIDs = append(failedIDs, id) + } + } + if err := rows.Err(); err != nil { + log.Printf("Failed to iterate Square-less manual refunds during sweep: %v", err) + } + rows.Close() + if len(failedIDs) > 0 { + log.Printf("Marked %d Square-less manual refund(s) failed (in-person arrangement needed)", len(failedIDs)) + } + insertRefundFailedNotifications(ctx, failedIDs) + } + + // (b) Manual refunds WITH a Square reference — the rows below are the only + // ones the retry/reconcile logic can act on. + rows, err = db.Conn.Query(ctx, ` SELECT r.id, r.payment_id, p.booking_id, r.amount, r.idempotency_key, r.reason, p.square_payment_id, r.square_refund_id, r.created_at FROM refunds r @@ -1292,13 +1333,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man log.Printf("Refund lock for payment %s not acquired within bound — a manual refund is in progress; leaving rows pending for the next sweep", paymentID) return 0, nil } - defer func() { - if _, err := pinConn.Exec(context.Background(), ` - SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1)) - `, paymentID); err != nil { - log.Printf("Failed to release refund lock for payment %s: %v", paymentID, err) - } - }() + defer releasePaymentLock(pinConn, "crussell:refund:"+paymentID) ids := make([]string, 0, len(rows)) for _, r := range rows { diff --git a/backend/handlers/payments/sweep.go b/backend/handlers/payments/sweep.go index 8e1dc07..ff4d5e8 100644 --- a/backend/handlers/payments/sweep.go +++ b/backend/handlers/payments/sweep.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "log" + "log/slog" "math" "strconv" "strings" @@ -61,10 +62,23 @@ const stalePendingPaymentAge = 24 * time.Hour // trustworthily and fall back to the legacy blind-fail + WARN. const stalePendingKeyedAge = 22 * time.Hour +// SweepStalePendingPayments resolves stale pending payments and till sales; see the rationale block on stalePendingPaymentAge above. func SweepStalePendingPayments(ctx context.Context) (int, error) { cutoff := clock.Now().Add(-stalePendingPaymentAge) keyedCutoff := clock.Now().Add(-stalePendingKeyedAge) + // Pass 0: stranded Square-less MANUAL refund rows at the 3-attempt cap. + // The Square-less pre-pass inside sweepManualPendingSquareRefunds + // (refunds.go) reconciles only rows with refund_attempts < 3; legacy rows + // that already hit the cap are never reconciled by it and would stay + // 'pending' forever, permanently blocking the over-refund guard. Such rows + // can never be refunded via Square (no square_payment_id), so they are + // marked 'failed' + admin-notified here for in-person arrangement (F10). + sqlessRefundCount, sqlessErr := sweepSquarelessManualRefundsAtAttemptCap(ctx) + if sqlessErr != nil { + log.Printf("Failed to reconcile Square-less manual refunds at the 3-attempt cap: %v", sqlessErr) + } + // Pass 1 (earlier cutoff): rows with a stored idempotency key but NO // square_payment_id are the lost-response case — the charge may have landed // at Square with the response lost. They are reconciled at Square by @@ -97,7 +111,7 @@ func SweepStalePendingPayments(ctx context.Context) (int, error) { return 0, err } - total := payKeyedCount + tillKeyedCount + payCount + tillCount + total := sqlessRefundCount + payKeyedCount + tillKeyedCount + payCount + tillCount payTotal := payKeyedCount + payCount tillTotal := tillKeyedCount + tillCount completed := payKeyedCompleted + tillKeyedCompleted + payCompleted + tillCompleted @@ -143,11 +157,13 @@ type staleRow struct { // the lost-response case: the sweep replays the key at Square to learn the // true charge outcome before declaring failure. IdempotencyKey string - // SquareSourceID is the exact source_id sent in the original CreatePayment - // call, stored on the row so the replay-by-key can rebuild an IDENTICAL - // request body (same key + source + amount). Replaying a different body - // would return IDEMPOTENCY_KEY_REUSED, which proves nothing about whether - // the charge landed. + // SquareSourceID is the row's CURRENT square_source_id — the source_id sent + // in the latest CreatePayment attempt, refreshed whenever a same-key retry + // reuses the pending row. The replay-by-key overrides the stored snapshot's + // embedded source with this value so the replayed body matches the source + // the retained key actually used. Replaying a different body would return + // IDEMPOTENCY_KEY_REUSED, which proves nothing about whether the charge + // landed. SquareSourceID string // SquareRequestSnapshot is the verbatim original CreatePayment request JSON // stored on the row at charge time (square_request_snapshot) — the FULL @@ -214,6 +230,22 @@ func sweepStaleRows(ctx context.Context, table string, cutoff time.Time) (resolv leaveGiftCardPurchasePending(ctx, r) continue } + // F3: a charge Square reports COMPLETED must never be + // completed on a booking that was cancelled during the pending + // window — the cancellation refund path computes refunds from + // completed payments and would miss it, charging the customer + // with NO automatic refund. Re-read bookings.status (FOR + // UPDATE) and refuse on a cancelled booking. Rows with no + // booking (gift-card purchases, till_sales) are never gated. + if table == "payments" { + switch gateStalePaymentRescueOnBooking(ctx, r) { + case staleBookingGateRefused: + resolved++ + continue + case staleBookingGateUnknown: + continue + } + } if rescueStaleRowCompleted(ctx, table, r.ID) { resolved++ completed++ @@ -303,6 +335,18 @@ func sweepKeyedStaleRows(ctx context.Context, table string, cutoff time.Time) (r leaveGiftCardPurchasePending(ctx, r) continue } + // F3: same money-safety gate as the by-id rescue above — a charge + // Square reports COMPLETED must never be completed on a booking + // that was cancelled during the pending window. + if table == "payments" { + switch gateStalePaymentRescueOnBooking(ctx, r) { + case staleBookingGateRefused: + resolved++ + continue + case staleBookingGateUnknown: + continue + } + } if rescueKeyedStaleRowCompleted(ctx, table, r.ID, sqPayID) { resolved++ completed++ @@ -427,8 +471,11 @@ func scanStaleRow(table string, rows pgx.Rows) (staleRow, error) { // failStaleRow marks one stale pending row 'failed'. Returns true when the row // was updated (status was still 'pending'). func failStaleRow(ctx context.Context, table, id string) bool { + // table is an internal constant ("payments"/"till_sales"), never user + // input, but the identifier is routed through pgx.Identifier.Sanitize so no + // raw, unquoted table name is ever concatenated into the statement. tag, err := db.Conn.Exec(ctx, ` - UPDATE `+table+` SET status = 'failed', updated_at = NOW() + UPDATE `+pgx.Identifier{table}.Sanitize()+` SET status = 'failed', updated_at = NOW() WHERE id = $1 AND status = 'pending' `, id) if err != nil { @@ -443,7 +490,7 @@ func failStaleRow(ctx context.Context, table, id string) bool { // row was updated. func rescueStaleRowCompleted(ctx context.Context, table, id string) bool { tag, err := db.Conn.Exec(ctx, ` - UPDATE `+table+` SET status = 'completed', updated_at = NOW() + UPDATE `+pgx.Identifier{table}.Sanitize()+` SET status = 'completed', updated_at = NOW() WHERE id = $1 AND status = 'pending' `, id) if err != nil { @@ -461,7 +508,7 @@ func rescueStaleRowCompleted(ctx context.Context, table, id string) bool { // true when the row was updated. func rescueKeyedStaleRowCompleted(ctx context.Context, table, id, squarePaymentID string) bool { tag, err := db.Conn.Exec(ctx, ` - UPDATE `+table+` SET status = 'completed', square_payment_id = $1, updated_at = NOW() + UPDATE `+pgx.Identifier{table}.Sanitize()+` SET status = 'completed', square_payment_id = $1, updated_at = NOW() WHERE id = $2 AND status = 'pending' `, squarePaymentID, id) if err != nil { @@ -471,6 +518,93 @@ func rescueKeyedStaleRowCompleted(ctx context.Context, table, id, squarePaymentI return int(tag.RowsAffected()) > 0 } +// staleBookingGate is the outcome of the pre-completion booking-status recheck +// for a stale pending payment row whose charge Square reports COMPLETED. +type staleBookingGate int + +const ( + // staleBookingGateAllowed — the booking is still payable; the rescue may proceed. + staleBookingGateAllowed staleBookingGate = iota + // staleBookingGateRefused — the booking is cancelled/lapsed/no-show; the row + // was marked FAILED and a critical admin notification raised. + staleBookingGateRefused + // staleBookingGateUnknown — the booking status could not be read; the row was + // left pending (never completed on an unknown state). + staleBookingGateUnknown +) + +// gateStalePaymentRescueOnBooking re-reads the booking of a stale pending +// payment row before the sweep rescues it to 'completed' on a Square COMPLETED +// reconcile. It mirrors the live-path guard bookingStatusAllowsCompletedPayment +// (handlers.go): a charge that lands AFTER the booking was cancelled must never +// be silently completed — the cancellation refund path computes refunds from +// completed payments and would miss it, charging a customer for a cancelled +// booking with NO automatic refund (F3). +// +// The booking status is re-read FOR UPDATE inside a transaction so the +// decision serializes against a concurrent cancellation (C5 pattern, mirrors +// recordTerminalPaymentTx). On a cancelled/lapsed/no-show booking the payment +// row is marked FAILED (not completed) and a critical admin notification is +// raised, atomically with the decision, so an operator refunds the customer +// manually. A booking whose status cannot be read is never completed — the row +// is left pending for the next sweep run. Rows with no booking (till_sales; +// gift-card purchases are diverted by the callers before this helper) are +// never gated. +func gateStalePaymentRescueOnBooking(ctx context.Context, r staleRow) staleBookingGate { + if r.BookingID == nil { + return staleBookingGateAllowed + } + tx, err := db.Conn.Begin(ctx) + if err != nil { + log.Printf("CRITICAL: failed to begin booking-status recheck for stale pending payment %s (booking %s): %v — leaving pending — MANUAL RECONCILIATION REQUIRED", r.ID, *r.BookingID, err) + return staleBookingGateUnknown + } + defer func() { + if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Printf("Failed to rollback booking-status recheck for stale pending payment %s: %v", r.ID, err) + } + }() + var status string + if err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1 FOR UPDATE`, *r.BookingID).Scan(&status); err != nil { + // Booking gone or unreadable: the money state is unknown. Never + // complete on an unknown state — a completed payment on a vanished + // booking would strand the charge outside the refund system. + log.Printf("CRITICAL: Square reports stale pending payment %s COMPLETED but re-reading booking %s status failed (%v) — leaving pending — MANUAL RECONCILIATION REQUIRED", r.ID, *r.BookingID, err) + return staleBookingGateUnknown + } + if bookingStatusAllowsCompletedPayment(status) { + // Payable — release the booking lock; the caller rescues the row. + if cErr := tx.Commit(ctx); cErr != nil { + log.Printf("CRITICAL: failed to commit booking-status recheck for stale pending payment %s (booking %s): %v — leaving pending — MANUAL RECONCILIATION REQUIRED", r.ID, *r.BookingID, cErr) + return staleBookingGateUnknown + } + return staleBookingGateAllowed + } + // Cancelled / lapsed / no-show booking: completing the payment would + // charge a customer for a booking the cancellation flow already closed, + // with NO automatic refund. Mark the row FAILED (not completed) and alert + // ops so an operator refunds the customer manually. + tag, upErr := tx.Exec(ctx, ` + UPDATE `+pgx.Identifier{"payments"}.Sanitize()+` SET status = 'failed', updated_at = NOW() + WHERE id = $1 AND status = 'pending' + `, r.ID) + if upErr != nil { + log.Printf("CRITICAL: Square payment for stale pending row %s is COMPLETED but booking %s is %q — marking the row failed errored (%v) — MANUAL RECONCILIATION REQUIRED", r.ID, *r.BookingID, status, upErr) + return staleBookingGateUnknown + } + if cErr := tx.Commit(ctx); cErr != nil { + log.Printf("CRITICAL: Square payment for stale pending row %s is COMPLETED but booking %s is %q — committing the failed mark errored (%v) — MANUAL RECONCILIATION REQUIRED", r.ID, *r.BookingID, status, cErr) + return staleBookingGateUnknown + } + if int(tag.RowsAffected()) > 0 { + insertCriticalPaymentNotification(ctx, r.BookingID, r.CreatedBy) + log.Printf("CRITICAL: stale pending payment %s is COMPLETED at Square but booking %s is %q — payment marked FAILED instead of completed; operator must refund the customer manually", r.ID, *r.BookingID, status) + return staleBookingGateRefused + } + // The row was already resolved concurrently — nothing left to refuse. + return staleBookingGateUnknown +} + // clawbackTillSaleFunding claws back a stale pending till sale's funded gift // card (atomically with the failed mark) after a reconcile PROVED the charge // never completed — the funding has no charge behind it. A sale with no gift @@ -497,7 +631,10 @@ func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool { // charge made under a stale pending row's idempotency key and returns the // tri-state result. The replay sends an IDENTICAL body to the original charge: // the stored square_request_snapshot (the FULL request — source_id, key, -// amount and every field the original carried). Replaying a partial body would +// amount and every field the original carried), with the source_id overridden +// by the row's CURRENT square_source_id column value (authoritative — a +// same-key reuse refreshes the column while the snapshot JSON is stale). +// Replaying a partial body would // return IDEMPOTENCY_KEY_REUSED for a RETAINED key and strand the row pending // forever. Square's idempotency guarantee returns the ORIGINAL payment for a // retained key (never a second charge); a COMPLETED payment rescues the row to @@ -555,8 +692,34 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) ( } snapshot = fallback } - // The replay repeats the stored request snapshot verbatim so Square's - // idempotency dedup returns the original payment for a retained key. The + // The stored snapshot embeds the source_id of the ORIGINAL charge, but a + // pending row REUSED by a same-key retry has its square_source_id column + // refreshed to the retry's source while the snapshot JSON stays stale (the + // write side now refreshes the snapshot too). Replaying the stale embedded + // source under a key Square already retains for the new one would return + // IDEMPOTENCY_KEY_REUSED and strand the row pending — so rebuild the replay + // body with the LIVE column value, which is authoritative (set at charge + // time, refreshed on every reuse). The minimal snapshot-less fallback body + // above is already built from the live source and needs no override. An + // unparseable snapshot must never look like proof of no charge — leave the + // row pending for manual reconciliation. + if !fallbackBody && r.SquareSourceID != "" { + var req square.CreatePaymentReq + if err := json.Unmarshal(snapshot, &req); err != nil { + log.Printf("Stale pending %s reconcile by key: failed to parse stored request snapshot for row %s to override the source_id (%v) — leaving pending", table, r.ID, err) + return staleReconcileLeavePending, "" + } + req.SourceID = r.SquareSourceID + overridden, mErr := json.Marshal(req) + if mErr != nil { + log.Printf("Stale pending %s reconcile by key: failed to rebuild replay body with the current square_source_id for row %s (%v) — leaving pending", table, r.ID, mErr) + return staleReconcileLeavePending, "" + } + snapshot = overridden + } + // The replay repeats the stored request snapshot — with the live source_id + // override above — so Square's idempotency dedup returns the original + // payment for a retained key. The // sweep resolves its Square environment and location through the same // helpers the charge-time HTTP client uses (square.SquareEnvironment / // square.SquareLocationID), so the replay and the charge can never read @@ -746,6 +909,48 @@ func squareHasCode(err error, codes ...string) bool { return false } +// sweepSquarelessManualRefundsAtAttemptCap reconciles Square-less MANUAL refund +// rows that have already hit the 3-attempt cap. The Square-less pre-pass inside +// sweepManualPendingSquareRefunds (refunds.go) filters refund_attempts < 3, so +// a legacy manual refund on a payment with no square_payment_id that reached +// the cap (attempts incremented by pre-guard Square attempts) is never +// reconciled by it and would stay 'pending' forever, permanently blocking the +// over-refund guard (F10). Such rows can never be refunded via Square, so they +// are marked 'failed' and surfaced in the admin notification centre for +// in-person arrangement — the same terminal treatment the pre-pass gives rows +// under the cap. Returns the number of rows marked failed. +func sweepSquarelessManualRefundsAtAttemptCap(ctx context.Context) (int, error) { + rows, err := db.Conn.Query(ctx, ` + UPDATE refunds r SET status = 'failed' + FROM payments p + WHERE p.id = r.payment_id + AND r.status = 'pending' AND r.refund_attempts >= 3 + AND p.payment_method IN ('online_square', 'in_person_card') + AND p.square_payment_id IS NULL + AND r.origin = 'manual' + RETURNING r.id + `) + if err != nil { + return 0, err + } + defer rows.Close() + var failedIDs []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil { + failedIDs = append(failedIDs, id) + } + } + if err := rows.Err(); err != nil { + return 0, err + } + if len(failedIDs) > 0 { + log.Printf("Marked %d Square-less manual refund(s) at the 3-attempt cap failed (in-person arrangement needed)", len(failedIDs)) + } + insertRefundFailedNotifications(ctx, failedIDs) + return len(failedIDs), nil +} + // staleTerminalCheckoutAge is how old a still-pending terminal checkout must // be before the sweep cancels it. Terminal checkouts normally complete within // minutes; an hour is far past any legitimate card-reader interaction while @@ -1049,17 +1254,13 @@ func markTerminalCheckoutRowFailed(ctx context.Context, r staleTerminalCheckoutR // recordUntrackedTerminalPayment records the payments row(s) for a terminal // checkout that COMPLETED at Square but was never polled/recorded, then marks // the terminal_checkouts row COMPLETED. The stale sweep otherwise leaves a real -// charge with NO payments row — invisible to refunds and TotalPaid (H4). It -// reuses the exact insert convention of GetCheckoutStatus: the same advisory -// lock key (serializes against a concurrent poll), the same dedup by -// booking_id + square_payment_id, the same PaymentRecord shape and derived -// idempotency key, and the same deposit/balance/tip split for a charge above -// the remaining booking value. It also mirrors GetCheckoutStatus's booking -// re-check: a booking that moved out of a payable state (cancelled / lapsed / -// no-show) after the charge completed refuses to record the payment, marks the -// checkout failed, and inserts a critical-payment admin notification so the -// owner is told a charge landed on a cancelled booking and a manual refund is -// required. Returns true when the checkout row was resolved (payment recorded, +// charge with NO payments row — invisible to refunds and TotalPaid (H4). The +// money-recording work lives in the SHARED recordTerminalPaymentTx core (also +// used by the GetCheckoutStatus poll handler), so both writers run the same +// dedup / FOR UPDATE recheck / insert / M4 split / VAT / guarded checkout +// update; this wrapper supplies the sweep-specific pieces: the advisory lock, +// the cancelled-booking critical admin notification and the CRITICAL completion +// log. Returns true when the checkout row was resolved (payment recorded, // already recorded, or refused on a cancelled booking); false when recording // failed (the row is left pending so the next sweep re-runs the whole // reconcile). @@ -1068,7 +1269,6 @@ func recordUntrackedTerminalPayment(ctx context.Context, checkoutID, bookingID s log.Printf("CRITICAL: terminal checkout %s is COMPLETED at Square but carries no Square payment ID — cannot record the payment — MANUAL RECONCILIATION REQUIRED", checkoutID) return false } - service := NewPaymentService() // Serialize with the poll handler (GetCheckoutStatus): both record the same // Square payment, so the advisory lock + dedup SELECT prevent a double @@ -1088,11 +1288,7 @@ func recordUntrackedTerminalPayment(ctx context.Context, checkoutID, bookingID s log.Printf("Terminal-completion serialization lock for %s not acquired within bound — a poll is already recording this checkout", pr.SquarePayID) return false } - defer func() { - if _, err := pinConn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtext('crussell:terminal:' || $1))`, pr.SquarePayID); err != nil { - log.Printf("Failed to release terminal-completion serialization lock for %s: %v", pr.SquarePayID, err) - } - }() + defer releasePaymentLock(pinConn, "crussell:terminal:"+pr.SquarePayID) tx, err := db.Conn.Begin(ctx) if err != nil { @@ -1105,8 +1301,56 @@ func recordUntrackedTerminalPayment(ctx context.Context, checkoutID, bookingID s } }() + paymentID, recErr := recordTerminalPaymentTx(ctx, tx, checkoutID, bookingID, pr) + if recErr != nil { + if errors.Is(recErr, errTerminalBookingNotPayable) { + // Sweep-only behaviour: the poll surfaces a 409 to a live caller, + // but a sweep has no one to tell, so alert ops in the admin + // notification centre that money was taken at Square and MUST be + // refunded manually (the core already committed the checkout's + // 'failed' mark). + insertCriticalPaymentNotification(ctx, &bookingID, nil) + return true + } + // The core logged the failure; the row is left pending so the next + // sweep run re-runs the whole reconcile. + return false + } + log.Printf("CRITICAL: recorded untracked terminal charge %s (booking %s) from stale checkout %s — payment row %s created (never polled by the frontend)", pr.SquarePayID, bookingID, checkoutID, paymentID) + return true +} + +// errTerminalBookingNotPayable is returned by recordTerminalPaymentTx when the +// booking moved out of a payable state (cancelled / lapsed / no-show) between +// the terminal charge completing at Square and the record attempt. The core has +// ALREADY committed the terminal_checkouts 'failed' mark (and a non-payable +// commit failure is wrapped with this sentinel too), so the caller must not +// roll back: the poll surfaces a 409 conflict, the sweep inserts the critical +// admin notification and resolves the row. +var errTerminalBookingNotPayable = errors.New("booking is no longer payable for a terminal payment") + +// recordTerminalPaymentTx records a COMPLETED terminal checkout as payments +// rows, applying the M4 tip split, per-record VAT and booking completion. Both +// the GetCheckoutStatus poll handler and the stale-terminal sweep call it so +// the money-recording logic exists exactly once. +// +// CALL-SITE CONTRACT: both call sites must keep passing the SAME inputs — the +// caller's transaction, the terminal_checkouts checkout_id, the booking id and +// the *square.PaymentResult returned by GetCheckout — and each must acquire the +// advisory lock on "crussell:terminal:" on its own pinned pool +// connection BEFORE calling (the core runs inside the caller's transaction). +// The core commits the transaction and completes a now-fully-paid booking; the +// caller owns only the error mapping (errTerminalBookingNotPayable vs generic), +// the HTTP response / sweep return value and any post-commit behaviour (e.g. +// the sweep's critical notification). +func recordTerminalPaymentTx(ctx context.Context, tx pgx.Tx, checkoutID, bookingID string, pr *square.PaymentResult) (string, error) { + service := NewPaymentService() + // Dedup by Square payment ID: a concurrent poll (or a prior sweep run) - // already recorded this charge — just release the in-flight guard. + // already recorded this charge — release the in-flight guard and return the + // existing row so the caller reports the same payment id. The checkout is + // marked COMPLETED under the same guarded status predicate used everywhere + // else, so a row already resolved to 'failed' is never resurrected. var existingID string if err := tx.QueryRow(ctx, ` SELECT id FROM payments @@ -1116,52 +1360,50 @@ func recordUntrackedTerminalPayment(ctx context.Context, checkoutID, bookingID s UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS') `, checkoutID); upErr != nil { - log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, upErr) - return false + slog.Error("Failed to mark terminal checkout completed on dedup", "checkout_id", checkoutID, "err", upErr) + return "", fmt.Errorf("mark terminal checkout %s completed: %w", checkoutID, upErr) } if cErr := tx.Commit(ctx); cErr != nil { - log.Printf("Failed to commit terminal-completion transaction: %v", cErr) - return false + slog.Error("Failed to commit terminal-completion transaction", "checkout_id", checkoutID, "err", cErr) + return "", cErr } - return true + return existingID, nil } else if !errors.Is(err, pgx.ErrNoRows) { - log.Printf("Failed to check for existing terminal payment: %v", err) - return false + slog.Error("Failed to check for existing terminal payment", "checkout_id", checkoutID, "booking_id", bookingID, "square_payment_id", pr.SquarePayID, "err", err) + return "", err } // Re-check the booking status under the advisory lock (mirrors // GetCheckoutStatus): a cancellation/eviction that committed between the - // terminal charge completing at Square and this sweep recording it must - // not produce a completed payment on a cancelled/lapsed/no-show booking — - // the cancellation refund path computes refunds from completed payments - // and would silently exclude this charge. Mark the checkout failed and - // alert ops: money was taken at Square and MUST be refunded manually. + // terminal charge completing at Square and this record attempt must not + // produce a completed payment on a cancelled/lapsed/no-show booking — the + // cancellation refund path computes refunds from completed payments and + // would silently exclude this charge. Mark the checkout failed and alert + // ops: money was taken at Square and MUST be refunded manually. var recheckStatus string // FOR UPDATE (C5): serializes against the cancellation path's lock on the // same row so a concurrent cancellation cannot commit between this recheck // and the transaction commit below. if err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1 FOR UPDATE`, bookingID).Scan(&recheckStatus); err != nil { - log.Printf("CRITICAL: Square payment %s for checkout %s was processed but re-reading booking %s status failed: %v — manual reconciliation required", - pr.SquarePayID, checkoutID, bookingID, err) - return false + slog.Error("CRITICAL: Square payment was processed but re-reading booking status failed — manual reconciliation required", "square_payment_id", pr.SquarePayID, "checkout_id", checkoutID, "booking_id", bookingID, "err", err) + return "", err } if !bookingStatusAllowsCompletedPayment(recheckStatus) { - log.Printf("CRITICAL: Square payment %s for checkout %s was processed but booking %s is now %q — marking checkout failed; money taken at Square MUST be refunded manually", - pr.SquarePayID, checkoutID, bookingID, recheckStatus) + slog.Error("CRITICAL: Square payment was processed but booking is no longer payable — marking checkout failed; money taken at Square MUST be refunded manually", "square_payment_id", pr.SquarePayID, "checkout_id", checkoutID, "booking_id", bookingID, "status", recheckStatus) if _, upErr := tx.Exec(ctx, ` UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS') `, checkoutID); upErr != nil { - log.Printf("CRITICAL: Square payment %s landed on %q booking %s but marking checkout %s failed errored: %v — manual reconciliation required", - pr.SquarePayID, recheckStatus, bookingID, checkoutID, upErr) + slog.Error("CRITICAL: Square payment landed on a non-payable booking but marking checkout failed errored — manual reconciliation required", "square_payment_id", pr.SquarePayID, "status", recheckStatus, "booking_id", bookingID, "checkout_id", checkoutID, "err", upErr) } if cErr := tx.Commit(ctx); cErr != nil { - log.Printf("CRITICAL: Square payment %s landed on %q booking %s and committing the checkout-failed mark errored: %v — manual reconciliation required", - pr.SquarePayID, recheckStatus, bookingID, cErr) - return false + slog.Error("CRITICAL: Square payment landed on a non-payable booking and committing the checkout-failed mark errored — manual reconciliation required", "square_payment_id", pr.SquarePayID, "status", recheckStatus, "booking_id", bookingID, "checkout_id", checkoutID, "err", cErr) + // Keep the sentinel wrapped so BOTH callers still surface the + // conflict path (poll 409 / sweep notification) even when the + // failed-mark commit itself failed. + return "", fmt.Errorf("%w: %v", errTerminalBookingNotPayable, cErr) } - insertCriticalPaymentNotification(ctx, &bookingID, nil) - return true + return "", errTerminalBookingNotPayable } // The payment type the admin charged is recorded on the checkout row by @@ -1171,7 +1413,7 @@ func recordUntrackedTerminalPayment(ctx context.Context, checkoutID, bookingID s SELECT payment_type FROM terminal_checkouts WHERE checkout_id = $1 `, checkoutID).Scan(&checkoutPaymentType); err != nil { if !errors.Is(err, pgx.ErrNoRows) { - log.Printf("Failed to read payment type for checkout %s: %v", checkoutID, err) + slog.Error("Failed to read payment type for checkout", "checkout_id", checkoutID, "err", err) } checkoutPaymentType = "full" } @@ -1211,15 +1453,15 @@ func recordUntrackedTerminalPayment(ctx context.Context, checkoutID, bookingID s primary := records[0] paymentID, err := service.CreatePaymentRecordTx(ctx, tx, primary, nil) if err != nil { - log.Printf("Failed to create payment record for untracked terminal charge %s: %v", pr.SquarePayID, err) - return false + slog.Error("Failed to create payment record for terminal charge", "square_payment_id", pr.SquarePayID, "err", err) + return "", err } ApplyVATToBookingPayment(ctx, tx, paymentID) for _, rec := range records[1:] { pid, cErr := service.CreatePaymentRecordTx(ctx, tx, rec, nil) if cErr != nil { - log.Printf("Failed to create terminal tip split record: %v", cErr) - return false + slog.Error("Failed to create terminal tip split record", "square_payment_id", pr.SquarePayID, "err", cErr) + return "", cErr } ApplyVATToBookingPayment(ctx, tx, pid) } @@ -1229,18 +1471,17 @@ func recordUntrackedTerminalPayment(ctx context.Context, checkoutID, bookingID s UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS') `, checkoutID); err != nil { - log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, err) - return false + slog.Error("Failed to mark terminal checkout completed", "checkout_id", checkoutID, "err", err) + return "", err } if err := tx.Commit(ctx); err != nil { - log.Printf("Failed to commit terminal-completion transaction: %v", err) - return false + slog.Error("Failed to commit terminal-completion transaction", "checkout_id", checkoutID, "err", err) + return "", err } // The booking may now be fully paid — complete it like the poll handler does. completeFullyPaidBooking(ctx, bookingID) - log.Printf("CRITICAL: recorded untracked terminal charge %s (booking %s) from stale checkout %s — payment row %s created (never polled by the frontend)", pr.SquarePayID, bookingID, checkoutID, paymentID) - return true + return paymentID, nil } // recordUntrackedTillSalePayment records a stale card-machine till sale whose diff --git a/backend/handlers/payments/till.go b/backend/handlers/payments/till.go index 9f914a2..3bf6daf 100644 --- a/backend/handlers/payments/till.go +++ b/backend/handlers/payments/till.go @@ -2,8 +2,9 @@ package payments import ( "context" - "crypto/rand" + "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -11,6 +12,7 @@ import ( "log/slog" "math" "net/http" + "strconv" "strings" "crussell/db" @@ -30,10 +32,14 @@ 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 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"` + // IdempotencyKey is optional; an empty key is replaced with a DETERMINISTIC + // fallback derived from the canonical request fields + // (deriveTillIdempotencyKey) so a lost-response retry re-derives the SAME + // key and reuses the pending till_sale row instead of minting a second + // Square charge. Limit 45: this key feeds CreatePayment (Square's /v2/ + // payments cap) as well as CreateCheckout, which allows 64 — the stricter + // 45 applies because the same key is replayed to /v2/payments. + IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"` CardToken string `json:"card_token,omitempty"` RedeemToUserID *string `json:"redeem_to_user_id,omitempty"` VerificationToken *string `json:"verification_token,omitempty"` @@ -49,9 +55,12 @@ type TillSaleResponse struct { CheckoutID *string `json:"checkout_id,omitempty"` } -// uniqueChargeKey is defined in handlers.go (the till fallback key was -// byte-identical to the tip fallback except the prefix — see the consolidated -// helper). +// uniqueChargeKey (handlers.go) remains the random fallback for the TIP flow, +// where two identical no-key tips are distinct operations that must diverge. +// The till no-key fallback deliberately does NOT use it: a random key would +// mint a second Square charge (and second gift-card funding) when a lost- +// response create is retried. deriveTillIdempotencyKey (below) derives a +// request-stable key instead. // definitivePaymentDeclineCodes are Square payment error codes meaning the // card charge can never succeed (declined / expired / not supported). They are @@ -114,6 +123,134 @@ func declineCodeListContains(s string) bool { return false } +// deriveTillIdempotencyKey returns the deterministic no-client-key fallback +// BASE idempotency key for a till sale: "till-" + sha256 over the canonical +// request fields (action, created_by admin, amount in pence, and the gift card +// / user / saved card / redeem targets when present), truncated to 16 bytes so +// the key stays within Square's 45-char /v2/payments limit. +// +// A lost-response retry re-derives the SAME base key, so the idempotency lookup +// in CreateTillSale reuses the pending till_sale row and Square dedups on the +// key — ONE charge and ONE gift-card funding instead of the old uniqueChargeKey +// fallback's second charge on retry. The card nonce (req.CardToken) is +// deliberately EXCLUDED — it changes between retries — and no random value +// enters the base key, so identical logical sales always collide on the SAME +// base key; the handler resolves that collision through the slot scan +// (scanTillIdempotencyKeySlot) run under the advisory lock, which diverges +// genuinely DISTINCT identical keyless sales onto distinct final keys (F5) +// while a lost-response retry of a pending sale keeps re-deriving the same +// candidate and reuses the pending row. +func deriveTillIdempotencyKey(req TillSaleRequest, adminID string) string { + var sb strings.Builder + sb.WriteString("till:") + sb.WriteString(req.Action) + sb.WriteString(":") + sb.WriteString(adminID) + sb.WriteString(":") + sb.WriteString(strconv.FormatInt(int64(math.Round(req.Amount*100)), 10)) + if req.GiftCardID != nil && *req.GiftCardID != "" { + sb.WriteString(":gc:") + sb.WriteString(validators.NormalizeGiftCardCode(*req.GiftCardID)) + } + if req.UserID != nil && *req.UserID != "" { + sb.WriteString(":user:") + sb.WriteString(*req.UserID) + } + if req.UserSavedCardID != nil && *req.UserSavedCardID != "" { + sb.WriteString(":card:") + sb.WriteString(*req.UserSavedCardID) + } + if req.RedeemToUserID != nil && *req.RedeemToUserID != "" { + sb.WriteString(":redeem:") + sb.WriteString(*req.RedeemToUserID) + } + sum := sha256.Sum256([]byte(sb.String())) + return "till-" + hex.EncodeToString(sum[:16]) +} + +// tillIdempotencyKeyCandidate appends the slot-sequence suffix to a derived +// base key, hashing back under Square's 45-char /v2/payments limit when the +// verbatim form would overflow (the hash stays deterministic). +func tillIdempotencyKeyCandidate(baseKey string, seq int) string { + if seq == 0 { + return baseKey + } + candidate := fmt.Sprintf("%s-%d", baseKey, seq) + if len(candidate) > 45 { + sum := sha256.Sum256([]byte(candidate)) + return "till-" + hex.EncodeToString(sum[:16]) + } + return candidate +} + +// scanTillIdempotencyKeySlot resolves the FINAL deterministic idempotency key +// for a keyless Square-method till sale, mirroring the gift-card slot pattern +// (deriveGiftCardIdempotencyKey). Must be called under the crussell:till +// advisory lock so the scan-and-insert races no concurrent identical create. +// +// A COMPLETED (or swept/declined FAILED) sale occupies its candidate slot and +// forces the NEXT candidate — two genuine identical keyless sales (e.g. two +// £20 walk-in card creations with no user) must diverge onto distinct keys, or +// the second is silently swallowed by the first's dedup and its customer gets +// NO gift card (F5). A PENDING sale never occupies a slot: a lost-response +// retry re-derives the same candidate, the idempotency lookup below reuses the +// pending row and adopts its STORED key, and Square dedups the charge onto the +// original — ONE charge, ONE gift-card funding. +func scanTillIdempotencyKeySlot(ctx context.Context, baseKey string) (string, error) { + for seq := 0; ; seq++ { + candidate := tillIdempotencyKeyCandidate(baseKey, seq) + var status string + err := db.Conn.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, candidate).Scan(&status) + if errors.Is(err, pgx.ErrNoRows) { + return candidate, nil + } + if err != nil { + return "", fmt.Errorf("failed to scan till idempotency-key slot for %s: %w", baseKey, err) + } + if status == "completed" || status == "failed" { + continue // occupied slot — a distinct sale must diverge onto a fresh key. + } + // Pending (or any other state): the same logical sale is in flight — a + // lost-response retry must reuse it, so keep this candidate. + return candidate, nil + } +} + +// refreshTillSnapshotSource rewrites the source_id field inside the stored +// square_request_snapshot JSON on a reused pending till sale so the snapshot +// stays consistent with the square_source_id column refreshed for the new +// charge attempt. The stale-pending sweep replays the snapshot VERBATIM as the +// charge body (sweep.go reconcileStalePaymentByKey); a snapshot whose source +// differs from the row's source would make Square return IDEMPOTENCY_KEY_REUSED +// for the retained key and strand the row pending forever. Runs inside the same +// transaction as the square_source_id refresh so the two columns never diverge. +// A row without a stored snapshot (or an unparseable one) is left untouched — +// the charge attempt below re-marshals a fresh full body before calling Square. +func refreshTillSnapshotSource(ctx context.Context, tx pgx.Tx, tillSaleID, newSource string) { + var snap sql.NullString + if err := tx.QueryRow(ctx, `SELECT square_request_snapshot FROM till_sales WHERE id = $1`, tillSaleID).Scan(&snap); err != nil { + log.Printf("Failed to read square_request_snapshot for reused till sale %s: %v", tillSaleID, err) + return + } + if !snap.Valid || snap.String == "" { + return + } + var req square.CreatePaymentReq + if err := json.Unmarshal([]byte(snap.String), &req); err != nil { + log.Printf("Failed to parse square_request_snapshot for reused till sale %s: %v", tillSaleID, err) + return + } + req.SourceID = newSource + updated, err := json.Marshal(req) + if err != nil { + log.Printf("Failed to re-marshal square_request_snapshot for reused till sale %s: %v", tillSaleID, err) + return + } + if _, err := tx.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(updated), tillSaleID); err != nil { + log.Printf("Failed to refresh square_request_snapshot for reused till sale %s: %v", tillSaleID, err) + } +} + // errTillSaleNotPending: the claim-first gating UPDATE matched zero rows, so // the sale is no longer 'pending' and the gift card must be left untouched. var errTillSaleNotPending = errors.New("till sale is not pending") @@ -126,6 +263,11 @@ func revertGiftCardFunding(ctx context.Context, action, giftCardID string, amoun return RevertGiftCardFunding(ctx, action, giftCardID, amount, redeemToUserID, tillSaleID) } +// maxTillGiftCardAmountPence caps till gift-card creates/topups at £250 +// (owner decision; tighter than the £10,000 general till cap). Local constant +// — do not import from giftcard_limits.go (may not exist yet). +const maxTillGiftCardAmountPence = 25_000 + func CreateTillSale(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Defense-in-depth admin check (S-1) — a till sale moves money (charges a @@ -160,6 +302,24 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { http.Error(w, "Amount must be greater than zero", http.StatusBadRequest) return } + // C2: apply the shared £10,000 cap used by every other money entry point + // (validators.ValidateAmount). The till amount is pounds float64; validate + // the effective pence figure exactly as the Square charge amount below is + // derived (math.Round(req.Amount * 100)). + amountPence := int64(math.Round(req.Amount * 100)) + // Gift-card creates/topups are additionally capped at £250 per transaction + // (owner decision). Both the create and topup branches fund the card from + // req.Amount and flow through this single validation point, so one guard + // covers both. + if req.ItemType == "gift_card" && amountPence > maxTillGiftCardAmountPence { + http.Error(w, "Gift card amount exceeds maximum (£250)", http.StatusBadRequest) + return + } + if err := ValidateAmount(amountPence); err != nil { + log.Printf("Failed to process request: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } if req.PaymentMethod != "cash" && req.PaymentMethod != "card_machine" && req.PaymentMethod != "saved_card" && req.PaymentMethod != "online_square" && req.PaymentMethod != "on_the_house" { http.Error(w, "Payment method must be 'cash', 'card_machine', 'saved_card', 'online_square', or 'on_the_house'", http.StatusBadRequest) return @@ -191,19 +351,49 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { return } + // C1: when the client supplies no idempotency key, compute a DETERMINISTIC + // base key from the canonical request fields instead of a fresh + // uniqueChargeKey. A lost-response retry re-derives the SAME base key (the + // card nonce is deliberately excluded — it changes between retries), so the + // idempotency lookup below reuses the pending till_sale row and Square + // dedups on the key: ONE charge, ONE gift-card funding instead of the old + // random fallback's second charge + second funding. The base key is also the + // advisory-lock key, so concurrent same-sale retries serialize on it; the + // FINAL key is resolved by a slot scan under the lock (F5) so two genuinely + // DISTINCT keyless sales (same admin, same amount, no user / gift card) + // diverge onto different keys instead of the second being swallowed by the + // first's dedup. + var derivedBaseKey string + if req.IdempotencyKey == "" { + switch req.PaymentMethod { + case "saved_card", "online_square", "card_machine": + // Square-charging methods: deterministic base so a lost-response retry + // re-derives the SAME base and reuses the pending row — ONE charge, + // ONE gift-card funding instead of a second charge. The slot scan + // under the lock finalizes the candidate (see + // scanTillIdempotencyKeySlot). + derivedBaseKey = deriveTillIdempotencyKey(req, adminID) + req.IdempotencyKey = derivedBaseKey + default: + // cash / on_the_house: no Square charge, so a unique key per request is + // required — two identical keyless cash gift-card creations are + // distinct sales and must both succeed (see + // TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed). + req.IdempotencyKey = uniqueChargeKey("till-") + } + } + // Serialize till-sale attempts on the idempotency key to prevent concurrent // same-key requests from both passing the idempotency check, both funding // the gift card, and one dying on the till_sales idempotency_key UNIQUE // constraint after the funding already committed. Mirrors the gift-card // advisory-lock pattern (giftcards.go). Lock is keyed on the idempotency - // key so distinct sales are unaffected; falls back to a per-request key - // when absent (client-supplied key is always used in practice). Bounded - // try-lock (R6) so a contended lock never blocks the pool across the Square + // key so distinct sales are unaffected; a missing client key falls back to + // the DETERMINISTIC derived key above, so a retry of the same logical sale + // serializes on the SAME lock as the original attempt. Bounded try-lock + // (R6) so a contended lock never blocks the pool across the Square // round-trip. lockKey := req.IdempotencyKey - if lockKey == "" { - lockKey = "till-" + rand.Text() - } pinConn, err := db.Conn.Acquire(ctx) if err != nil { log.Printf("Failed to acquire connection for till-sale lock: %v", err) @@ -222,13 +412,24 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { http.Error(w, "Sale in progress, try again", http.StatusConflict) return } - defer func() { - if _, err := pinConn.Exec(context.Background(), ` - SELECT pg_advisory_unlock(hashtext('crussell:till:' || $1)) - `, lockKey); err != nil { - log.Printf("Failed to release till-sale serialization lock for %s: %v", lockKey, err) + defer releasePaymentLock(pinConn, "crussell:till:"+lockKey) + + // F5: under the advisory lock, resolve the final deterministic key for a + // keyless Square-method sale. The slot scan advances past COMPLETED/FAILED + // sales occupying a candidate key, so two identical keyless walk-in creates + // never collapse onto one key; a PENDING sale never occupies a slot, so a + // lost-response retry re-derives the same candidate and the idempotency + // lookup below reuses the pending row. Running under the lock makes the + // scan-and-insert atomic for concurrent identical creates. + if derivedBaseKey != "" { + candidate, scanErr := scanTillIdempotencyKeySlot(ctx, derivedBaseKey) + if scanErr != nil { + log.Printf("Failed to resolve till-sale idempotency key for %s: %v", derivedBaseKey, scanErr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return } - }() + req.IdempotencyKey = candidate + } // Idempotency handling. A 'completed' sale is a dedup (return it). A // 'pending' sale means the previous Square charge failed — the gift card @@ -237,11 +438,12 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { // Mirrors the tip/gift-card pending-reuse pattern. var existingPendingID string var existingPendingGiftCard string + var existingPendingMethod string if req.IdempotencyKey != "" { - var existingID, existingStatus, existingItemID string + var existingID, existingStatus, existingItemID, existingStoredKey string var existingTotal float64 var existingItemType, existingPaymentMethod string - err := db.Conn.QueryRow(ctx, `SELECT id, status, item_id, total_amount, item_type, payment_method FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID, &existingStatus, &existingItemID, &existingTotal, &existingItemType, &existingPaymentMethod) + err := db.Conn.QueryRow(ctx, `SELECT id, status, item_id, total_amount, item_type, payment_method, idempotency_key FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID, &existingStatus, &existingItemID, &existingTotal, &existingItemType, &existingPaymentMethod, &existingStoredKey) if err == nil { if existingStatus == "completed" { // C4-class: a same-key retry of a COMPLETED sale must report the @@ -278,6 +480,13 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { } existingPendingID = existingID existingPendingGiftCard = existingItemID + existingPendingMethod = existingPaymentMethod + // F5: reuse the row's STORED idempotency key rather than a + // re-derived one — the stored key is what Square charged (and + // dedups on), so a retry must replay it verbatim. + if existingStoredKey != "" { + req.IdempotencyKey = existingStoredKey + } } if existingStatus == "failed" { // Swept as stale (>24h) or definitively rejected — a retry would @@ -522,7 +731,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { // become an untracked charge. var existingPendingCheckoutID string if existingPendingID != "" { - err = tx.QueryRow(ctx, `SELECT COALESCE(square_checkout_id, '') FROM till_sales WHERE id = $1`, existingPendingID).Scan(&existingPendingCheckoutID) + err = tx.QueryRow(ctx, `SELECT COALESCE(square_checkout_id, ''), payment_method FROM till_sales WHERE id = $1`, existingPendingID).Scan(&existingPendingCheckoutID, &existingPendingMethod) if err != nil { log.Printf("Failed to query existing pending sale checkout: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -530,21 +739,42 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { } } - // Method-switch guard on pending-retry: if the original attempt was - // card_machine and created a live terminal checkout, the retry MUST stay - // card_machine and reuse that checkout. Switching to cash/on_the_house/ - // saved_card/online_square would report the sale completed while the - // original checkout is still live — the customer could be charged at the - // terminal AND by the new method (double charge). The terminal checkout - // cannot be cancelled via this API, so reject the switch outright. + // Method-switch guard on pending-retry (F1): switching the payment method of + // a pending sale whose Square charge outcome is unknown must never mint a + // NEW checkout or charge that cannot dedup against the original. A pending + // card_machine checkout (whether its id is still stored, or was lost with + // the lost response) may still be live at the terminal, and a pending + // online_square/saved_card charge may have landed — retrying either as a + // different method risks TWO charges for one card. Reject the switch + // outright: the sale must be completed or refunded first. online_square ↔ + // saved_card retries are the one allowed cross-method case: both charge via + // Square CreatePayment under the SAME idempotency key, so Square dedups the + // retry onto the original charge (no second charge). // NOTE: a future provisional "tmp-" till_sales row (pre-Square, as the // booking path uses) must be treated as "no real checkout" by BOTH this // reuse and the method-switch guard — a "tmp-" id is provably not live at // Square. - if existingPendingID != "" && existingPendingCheckoutID != "" && req.PaymentMethod != "card_machine" { - log.Printf("Till-sale retry rejected: pending sale %s has a live card-machine checkout, cannot switch method from card_machine to %s", existingPendingID, req.PaymentMethod) - http.Error(w, "This pending sale is tied to a live card-machine checkout — retry with card machine payment", http.StatusConflict) - return + if existingPendingID != "" { + cardMachineInProgress := existingPendingCheckoutID != "" || existingPendingMethod == "in_person_card" + if cardMachineInProgress && req.PaymentMethod != "card_machine" { + // Pending card-machine sale retried by any other method: the original + // terminal checkout may still complete — switching would let the + // customer be charged at the terminal AND by the new method. The + // terminal checkout cannot be cancelled via this API. + log.Printf("Till-sale retry rejected: pending sale %s is tied to a card-machine checkout, cannot switch method to %s", existingPendingID, req.PaymentMethod) + http.Error(w, "This pending sale is tied to a card-machine checkout — complete or refund it first, then retry with card machine payment", http.StatusConflict) + return + } + if req.PaymentMethod == "card_machine" && !cardMachineInProgress { + // F1: a pending online_square/saved_card charge (outcome unknown) + // retried as card_machine would mint a NEW terminal checkout at + // Square — CreateCheckout cannot dedup against the original + // CreatePayment charge, so the original may land on top of the new + // terminal charge (double charge for one card). + log.Printf("Till-sale retry rejected: pending sale %s has payment method %s, cannot retry as card_machine (original charge outcome unknown)", existingPendingID, existingPendingMethod) + http.Error(w, "This pending sale is already in progress on a different payment method — complete or refund it first", http.StatusConflict) + return + } } // Reconcile-or-reject on a cash/on_the_house retry of a pending card sale. @@ -595,9 +825,6 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { case "cash": saleStatus = "completed" dbPaymentMethod = "cash" - if req.IdempotencyKey == "" { - req.IdempotencyKey = uniqueChargeKey("till-") - } case "saved_card": dbPaymentMethod = "online_square" if req.UserID != nil && *req.UserID != "" { @@ -658,18 +885,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { return } - if req.IdempotencyKey == "" { - req.IdempotencyKey = uniqueChargeKey("till-") - } - tillSquareSourceID = savedCardSqCardID saleStatus = "pending" needsSquarePayment = true case "card_machine": dbPaymentMethod = "in_person_card" - if req.IdempotencyKey == "" { - req.IdempotencyKey = uniqueChargeKey("till-") - } if existingPendingCheckoutID != "" { // Pending retry — reuse the checkout already created for this sale @@ -679,6 +899,36 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { squareCheckoutID = &existingPendingCheckoutID saleStatus = "pending" } else { + // F1: never mint a NEW terminal checkout while a pending charge for + // the same logical sale exists. existingPendingID (resolved by the + // idempotency-key lookup or the gift-card fallback) IS such a + // pending charge — a pending card_machine sale with no stored + // checkout id means the prior response was lost and the original + // checkout may still be live at the terminal; a fresh checkout + // would orphan it into a second, untracked charge. + if existingPendingID != "" { + log.Printf("Till-sale retry rejected: pending sale %s has no stored checkout id; refusing to create a second terminal checkout (original may still be live)", existingPendingID) + http.Error(w, "This pending sale has a card-machine payment in progress with an unknown checkout — complete or refund it first", http.StatusConflict) + return + } + // Defense-in-depth: a card_machine top-up on a card that already + // carries a pending till_sale (reached only when the earlier + // idempotency / gift-card resolution treated this as a NEW sale, + // e.g. a different amount) must not mint a terminal checkout while + // that pending sale's charge is still unresolved. + if req.Action == "topup" && req.GiftCardID != nil && *req.GiftCardID != "" { + var pendingOnCard string + if err := tx.QueryRow(ctx, ` + SELECT id FROM till_sales + WHERE item_id = $1 AND status = 'pending' + ORDER BY created_at DESC + LIMIT 1 + `, validators.NormalizeGiftCardCode(*req.GiftCardID)).Scan(&pendingOnCard); err == nil && pendingOnCard != "" { + log.Printf("Till-sale card_machine create rejected: gift card %s already has a pending till sale %s", *req.GiftCardID, pendingOnCard) + http.Error(w, "This gift card already has a pending sale — complete or refund it first", http.StatusConflict) + return + } + } checkoutReq := square.CreateCheckoutReq{ Amount: penceAmount, Currency: "GBP", @@ -700,9 +950,6 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { } case "online_square": dbPaymentMethod = "online_square" - if req.IdempotencyKey == "" { - req.IdempotencyKey = uniqueChargeKey("till-") - } tillSquareSourceID = req.CardToken saleStatus = "pending" @@ -710,9 +957,6 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { case "on_the_house": saleStatus = "completed" dbPaymentMethod = "on_the_house" - if req.IdempotencyKey == "" { - req.IdempotencyKey = uniqueChargeKey("till-") - } } desc := fmt.Sprintf("Gift Card %s (£%.2f)", req.Action, req.Amount) @@ -729,6 +973,13 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { if _, srcErr := tx.Exec(ctx, `UPDATE till_sales SET square_source_id = $1 WHERE id = $2`, tillSquareSourceID, tillSaleID); srcErr != nil { log.Printf("Failed to update square_source_id on reused till sale %s: %v", tillSaleID, srcErr) } + // B6: the sweep replays the stored square_request_snapshot VERBATIM, + // so the snapshot's source_id must stay in lock-step with the + // refreshed square_source_id — a snapshot whose source differs from + // the row's source returns IDEMPOTENCY_KEY_REUSED at Square and + // strands the row pending forever. Refresh both columns in this same + // transaction so they never diverge. + refreshTillSnapshotSource(ctx, tx, tillSaleID, tillSquareSourceID) } } else { err = tx.QueryRow(ctx, ` @@ -1050,13 +1301,7 @@ func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) { http.Error(w, "Payment in progress, try again", http.StatusConflict) return } - defer func() { - if _, err := pinConn.Exec(context.Background(), ` - SELECT pg_advisory_unlock(hashtext('crussell:tillcomplete:' || $1)) - `, tillSaleID); err != nil { - log.Printf("Failed to release till-completion serialization lock for %s: %v", tillSaleID, err) - } - }() + defer releasePaymentLock(pinConn, "crussell:tillcomplete:"+tillSaleID) tx, err := db.Conn.Begin(r.Context()) if err != nil { diff --git a/backend/handlers/payments/till_test.go b/backend/handlers/payments/till_test.go index c5237a6..6359f5d 100644 --- a/backend/handlers/payments/till_test.go +++ b/backend/handlers/payments/till_test.go @@ -850,7 +850,7 @@ func TestCreateTillSale_OnlineSquareWithToken(t *testing.T) { reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", - Amount: 1000, + Amount: 50, PaymentMethod: "online_square", CardToken: "cnon:visa", } diff --git a/backend/handlers/scheduling/time-blockers.go b/backend/handlers/scheduling/time-blockers.go index 65672b9..fa80fac 100644 --- a/backend/handlers/scheduling/time-blockers.go +++ b/backend/handlers/scheduling/time-blockers.go @@ -2,18 +2,24 @@ package scheduling import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "errors" "fmt" "log" "log/slog" "net/http" + "net/url" + "os" "time" "crussell/clock" "crussell/db" "crussell/handlers/payments" + "crussell/internal/dav" + "crussell/internal/s3" "crussell/internal/square" "crussell/internal/validators" "crussell/mw" @@ -411,6 +417,315 @@ func CleanupOldReservations(ctx context.Context) (int, error) { return int(tag.RowsAffected()), tx.Commit(ctx) } +// --- Square erasure helpers (GDPR) --- +// +// The guest/idle batch cleanups snapshot the users' Square card and customer +// ids BEFORE anonymize_user NULLs square_card_id/square_customer_id, then +// delete the external Square resources AFTER the local erasure commits. +// Deletions are retried on transient failures (transport errors / Square 5xx) +// and, when they still fail, surfaced as a CRITICAL admin notification +// (reason 'critical_payment_log') plus an ERROR log — never silently dropped. +// A customer profile still referenced by another active user is skipped (see +// the guard in deleteSquareCustomers): Square dedups customers provisioned +// from the same email within its idempotency window, so two accounts can share +// one Square customer and deleting it would break the other's saved-card +// charges. + +const ( + // squareDeleteMaxAttempts is the number of times a Square erasure call is + // attempted before it is treated as failed (1 initial + 2 retries). + squareDeleteMaxAttempts = 3 + // squareDeleteAttemptTimeout bounds a single Square erasure attempt so a + // hung call cannot stall the whole batch job. + squareDeleteAttemptTimeout = 30 * time.Second + // squareDeleteBackoffBase is the delay before the first retry (doubled per + // subsequent retry). + squareDeleteBackoffBase = 500 * time.Millisecond +) + +// squareDeletionRetryable reports whether a Square erasure error is transient +// and worth retrying: transport errors (wrapped *url.Error) and Square 5xx +// responses. Definitive 4xx rejections and not-found no-ops are NOT retried. +func squareDeletionRetryable(err error) bool { + if square.IsNotFound(err) { + return false + } + if sc := square.ErrorStatusCode(err); sc != 0 { + return sc >= http.StatusInternalServerError + } + var urlErr *url.Error + return errors.As(err, &urlErr) +} + +// retrySquareDeletion runs fn (a DeleteCardOnFile/DeleteCustomer call) up to +// squareDeleteMaxAttempts times, backing off between retries, retrying only +// transient failures (see squareDeletionRetryable). Returns the final error, +// nil on success. +func retrySquareDeletion(parent context.Context, fn func(context.Context) error) error { + var lastErr error + backoff := squareDeleteBackoffBase + for attempt := 1; attempt <= squareDeleteMaxAttempts; attempt++ { + if attempt > 1 { + select { + case <-time.After(backoff): + case <-parent.Done(): + return lastErr + } + backoff *= 2 + } + attemptCtx, cancel := context.WithTimeout(parent, squareDeleteAttemptTimeout) + err := fn(attemptCtx) + cancel() + if err == nil { + return nil + } + lastErr = err + if !squareDeletionRetryable(err) { + return err + } + } + return lastErr +} + +// squareCleanupNotificationID derives a deterministic admin_notifications id +// for a failed Square-side erasure tied to userID ("S" + 11 hex chars of a +// SHA-256 digest, mirroring the webhooks dispute-notification id scheme). One +// notification per affected user; re-delivery of the same failure is a no-op. +func squareCleanupNotificationID(userID string) string { + sum := sha256.Sum256([]byte("square-erasure-failure:" + userID)) + return "S" + hex.EncodeToString(sum[:])[:11] +} + +// insertSquareCleanupCriticalNotification surfaces a failed Square-side +// erasure in the admin notification centre (reason 'critical_payment_log' — +// the DB-backed stand-in for CRITICAL logs that the payments sweep uses). +// user_id is deliberately NULL: by the time the deletion runs, the account may +// already be anonymized or deleted, and the deterministic id keeps exactly one +// notification per affected user. A fresh bounded context is used so a +// near-expiry job context cannot suppress the admin alert. +func insertSquareCleanupCriticalNotification(ctx context.Context, userID string) { + actx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + tag, err := db.Conn.Exec(actx, ` + INSERT INTO admin_notifications (id, reason, booking_id, user_id, created_at) + VALUES ($1, 'critical_payment_log'::admin_notification_reason, NULL, NULL, NOW()) + ON CONFLICT (id) DO NOTHING + `, squareCleanupNotificationID(userID)) + if err != nil { + slog.Error("failed to insert critical notification for failed Square erasure", "user", userID, "err", err) + return + } + if int(tag.RowsAffected()) > 0 { + slog.Error("Square-side erasure FAILED after retries — critical notification raised", "user", userID) + } +} + +// deleteSquareCards deletes each card at Square, retrying transient failures. +// A card that fails after all attempts is logged at ERROR and surfaced via a +// critical admin notification for its owning user. +func deleteSquareCards(ctx context.Context, client square.SquareClient, cardsByUser map[string][]string) { + for userID, cardIDs := range cardsByUser { + for _, cardID := range cardIDs { + if err := retrySquareDeletion(ctx, func(actx context.Context) error { + return client.DeleteCardOnFile(actx, cardID) + }); err != nil { + // TokenPrefix redacts the ccof: card token — the full ID must + // never reach logs. + log.Printf("Error: Failed to delete Square card %s for user %s after %d attempts: %v", square.TokenPrefix(cardID), userID, squareDeleteMaxAttempts, err) + slog.Error("failed to delete Square card after retries", "user", userID, "card", square.TokenPrefix(cardID), "err", err) + insertSquareCleanupCriticalNotification(ctx, userID) + } + } + } +} + +// deleteSquareCustomers deletes each distinct Square customer profile once, +// guarded by the shared-reference check: a customer still referenced by another +// active user's saved card is skipped, never deleted. Customers are retried on +// transient failures; a failure after all attempts is logged at ERROR and +// surfaced via a critical admin notification for the owning user. +func deleteSquareCustomers(ctx context.Context, client square.SquareClient, customers map[string]string) { + // customers maps square_customer_id -> owning user_id (first owner). + for customerID, owner := range customers { + var stillReferenced bool + if err := db.Conn.QueryRow(ctx, ` + SELECT EXISTS(SELECT 1 FROM user_saved_cards WHERE square_customer_id = $1 AND user_id <> $2 AND deleted_at IS NULL) + `, customerID, owner).Scan(&stillReferenced); err != nil { + log.Printf("Error: Failed to check Square customer %s references before deletion: %v", square.TokenPrefix(customerID), err) + slog.Error("failed to check Square customer references before deletion", "user", owner, "customer", square.TokenPrefix(customerID), "err", err) + insertSquareCleanupCriticalNotification(ctx, owner) + continue + } + if stillReferenced { + // PII-redacted customer id — the full id never reaches logs. + log.Printf("Warning: skipping Square customer deletion — customer %s still referenced by another account", square.TokenPrefix(customerID)) + continue + } + if err := retrySquareDeletion(ctx, func(actx context.Context) error { + return client.DeleteCustomer(actx, customerID) + }); err != nil { + log.Printf("Error: Failed to delete Square customer %s for user %s after %d attempts: %v", square.TokenPrefix(customerID), owner, squareDeleteMaxAttempts, err) + slog.Error("failed to delete Square customer after retries", "user", owner, "customer", square.TokenPrefix(customerID), "err", err) + insertSquareCleanupCriticalNotification(ctx, owner) + } + } +} + +// snapshotSquareErasureTargets captures the Square card and customer ids for +// the given users BEFORE anonymize_user NULLs them, so the post-commit Square +// cleanup still has the external references it needs (GDPR erasure +// completeness). Fail-closed: an error aborts the cleanup so the local erasure +// never proceeds without the external refs it requires. +func snapshotSquareErasureTargets(ctx context.Context, q db.Querier, userIDs []string) (cardsByUser map[string][]string, customers map[string]string, err error) { + cardsByUser = map[string][]string{} + customers = map[string]string{} + if len(userIDs) == 0 { + return cardsByUser, customers, nil + } + rows, err := q.Query(ctx, ` + SELECT user_id, square_card_id, square_customer_id + FROM user_saved_cards + WHERE user_id = ANY($1) + AND deleted_at IS NULL + `, userIDs) + if err != nil { + return nil, nil, fmt.Errorf("failed to snapshot Square card ids for %d users: %w", len(userIDs), err) + } + defer rows.Close() + for rows.Next() { + var userID, cardID, customerID sql.NullString + if err := rows.Scan(&userID, &cardID, &customerID); err != nil { + return nil, nil, fmt.Errorf("failed to scan Square card id snapshot: %w", err) + } + if !userID.Valid || userID.String == "" { + continue + } + if cardID.Valid && cardID.String != "" { + cardsByUser[userID.String] = append(cardsByUser[userID.String], cardID.String) + } + // Distinct non-null customer IDs only: a user's saved cards share one + // provisioned Square customer, so DeleteCustomer runs once per customer. + // NULL customer IDs (users with no provisioned customer) are skipped. + if customerID.Valid && customerID.String != "" { + if _, seen := customers[customerID.String]; !seen { + customers[customerID.String] = userID.String + } + } + } + if err := rows.Err(); err != nil { + return nil, nil, fmt.Errorf("row iteration error snapshotting Square card ids: %w", err) + } + return cardsByUser, customers, nil +} + +// stillStaleGuest reports whether userID is STILL a stale guest eligible for +// Square-side erasure: no active (pending/confirmed) booking. It closes the +// ToCTOU window in AnonymizeStaleGuestAccounts where a guest could book again +// between the Square-id snapshot (taken before the anonymize tx) and the +// post-commit Square deletion — the users UPDATE predicate correctly skips such +// a guest's local erasure, so their Square references must be spared too. +// checked memoizes per-user results across the batch (a guest's saved cards +// share one Square customer, so the same owner is re-queried at most once). +func stillStaleGuest(ctx context.Context, userID string, checked map[string]bool) bool { + if stale, seen := checked[userID]; seen { + return stale + } + var active bool + if err := db.Conn.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM bookings + WHERE user_id = $1 AND status IN ('pending', 'confirmed') + ) + `, userID).Scan(&active); err != nil { + // Fail toward retention: an unverifiable user's Square refs are never + // deleted — the local erasure predicate already skipped them if they + // re-booked, so deleting Square-side would strand the guest's payment + // methods. Raise the same critical notification the deletion path uses. + log.Printf("Error: failed to re-check stale-guest eligibility for user %s before Square deletion: %v", userID, err) + slog.Error("failed to re-check stale-guest eligibility before Square deletion — skipping", "user", userID, "err", err) + insertSquareCleanupCriticalNotification(ctx, userID) + checked[userID] = false + return false + } + if active { + log.Printf("Warning: skipping Square erasure for user %s — guest re-booked after anonymization snapshot", userID) + } + checked[userID] = !active + return !active +} + +// recheckStaleGuestSquareTargets filters the post-commit Square deletion maps +// down to owners verified to STILL be stale guests, closing the snapshot→delete +// ToCTOU in AnonymizeStaleGuestAccounts: a guest who books/pays after the +// Square-id snapshot but before the anonymize tx is excluded by the users +// UPDATE predicate (they keep their active booking) but would otherwise still +// have their cards/customer deleted from the stale snapshot. Owners whose +// re-check errors are dropped too (see stillStaleGuest). The downstream +// shared-reference guard in deleteSquareCustomers still runs, so a customer +// referenced by any other active account is never deleted. +func recheckStaleGuestSquareTargets(ctx context.Context, cardsByUser map[string][]string, customers map[string]string) (map[string][]string, map[string]string) { + stillStaleCards := map[string][]string{} + stillStaleCustomers := map[string]string{} + checked := map[string]bool{} + for userID, cardIDs := range cardsByUser { + if stillStaleGuest(ctx, userID, checked) { + stillStaleCards[userID] = cardIDs + } + } + for customerID, owner := range customers { + if stillStaleGuest(ctx, owner, checked) { + stillStaleCustomers[customerID] = owner + } + } + return stillStaleCards, stillStaleCustomers +} + +// deleteExternalUserArtifacts best-effort deletes the erased user's CardDAV +// vCard and R2/S3 profile photo — the external PII artifacts +// DeleteAccountHandler scrubs on interactive account deletion. Both are +// personal data the SQL erasure does not reach: the vCard lives in the dav +// service's own dav_cards table and the photo is an object in object storage, +// so batch erasure must delete them explicitly (GDPR Art 17). Mirrors +// account.go's call pattern and nil-guards: both services may be nil in dev, +// and each call is wrapped in panic recovery so a nil-pool dev service cannot +// crash the cleanup job. +func deleteExternalUserArtifacts(ctx context.Context, userID string) { + // Delete profile picture from S3/R2 + if s3.Client != nil { + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic recovered in S3 profile picture deletion: %v", r) + } + }() + bucket := os.Getenv("S3_PROFILE_PICS_BUCKET") + if bucket == "" { + bucket = "crussell-profile-pics" + } + // profiles/{userID}.jpg — matches UploadProfilePictureHandler key format + key := fmt.Sprintf("profiles/%s.jpg", userID) + if err := s3.Client.Delete(ctx, bucket, key); err != nil { + log.Printf("Warning: Failed to delete profile picture for user %s: %v", userID, err) + } + }() + } + + // Delete CardDAV contact (non-blocking, best-effort) + if dav.Service != nil { + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic recovered in CardDAV contact deletion: %v", r) + } + }() + uri := fmt.Sprintf("%s.vcf", userID) + if err := dav.Service.DeleteContact(1, uri); err != nil { + log.Printf("Warning: Failed to delete CardDAV contact for user %s: %v", userID, err) + } + }() + } +} + // AnonymizeStaleGuestAccounts anonymizes personal data for guest accounts // whose last booking was more than 6 months ago (UK GDPR storage limitation). // Financial records (bookings, payments) remain intact — only PII is scrubbed. @@ -420,20 +735,20 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) { // process-local cache entries must be invalidated after the anonymization. var staleGuestUserIDs []string - // Best-effort: disable stale-guests' saved cards at Square BEFORE the SQL - // below NULLs square_card_id, so those cards can't keep accepting ccof: - // charges after anonymization (GDPR erasure completeness). A Square failure - // is logged and ignored — the local anonymization must never be blocked by - // Square. Card IDs are selected with the same stale-guest predicate the - // users UPDATE uses, and only when a Square client is configured. + // Snapshot the stale-guests' saved cards AND their Square customer IDs + // BEFORE the SQL below NULLs square_card_id/square_customer_id, so the + // post-commit Square cleanup still has the external references (GDPR + // erasure completeness: the local scrub must never strand PII at Square). + // Rows are selected with the same stale-guest predicate the users UPDATE + // uses, and only when a Square client is configured. The snapshot is a + // local SELECT — fail-closed: if it fails the cleanup aborts so the local + // erasure never proceeds without the external refs it needs. (The Square + // deletion calls themselves never block the local erasure.) + var cardsByUser map[string][]string + var customers map[string]string if payments.SquareClient != nil { - // Snapshot the stale-guests' saved cards AND their Square customer IDs - // BEFORE the SQL below NULLs square_card_id/square_customer_id, so the - // external Square references are still available for cleanup (GDPR - // erasure completeness: the local scrub must never strand PII at - // Square). Best-effort: a Square failure is logged and ignored — the - // local anonymization must never be blocked by Square. Rows are - // selected with the same stale-guest predicate the users UPDATE uses. + cardsByUser = map[string][]string{} + customers = map[string]string{} rows, err := db.Conn.Query(ctx, ` SELECT usc.user_id, usc.square_card_id, usc.square_customer_id FROM user_saved_cards usc @@ -444,59 +759,36 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) { AND usc.square_card_id IS NOT NULL `) if err != nil { - log.Printf("Warning: Failed to query stale-guest saved cards for Square cleanup: %v", err) - } else { - var cardIDs []string + return 0, fmt.Errorf("failed to snapshot stale-guest saved cards for Square cleanup: %w", err) + } + userSeen := map[string]bool{} + for rows.Next() { + var userID, cardID, customerID sql.NullString + if err := rows.Scan(&userID, &cardID, &customerID); err != nil { + rows.Close() + return 0, fmt.Errorf("failed to scan stale-guest saved card: %w", err) + } + if userID.Valid && userID.String != "" && !userSeen[userID.String] { + userSeen[userID.String] = true + staleGuestUserIDs = append(staleGuestUserIDs, userID.String) + } + if userID.Valid && cardID.Valid && cardID.String != "" { + cardsByUser[userID.String] = append(cardsByUser[userID.String], cardID.String) + } // Distinct non-null customer IDs only: a guest's saved cards share // one provisioned Square customer, so DeleteCustomer runs once per // customer. NULL customer IDs (guests with no provisioned Square // customer) are skipped. - customerSeen := map[string]bool{} - var customerIDs []string - userSeen := map[string]bool{} - for rows.Next() { - var userID, cardID, customerID sql.NullString - if err := rows.Scan(&userID, &cardID, &customerID); err != nil { - log.Printf("Warning: Failed to scan stale-guest saved card: %v", err) - continue - } - if userID.Valid && userID.String != "" && !userSeen[userID.String] { - userSeen[userID.String] = true - staleGuestUserIDs = append(staleGuestUserIDs, userID.String) - } - if cardID.Valid && cardID.String != "" { - cardIDs = append(cardIDs, cardID.String) - } - if customerID.Valid && customerID.String != "" && !customerSeen[customerID.String] { - customerSeen[customerID.String] = true - customerIDs = append(customerIDs, customerID.String) - } - } - rows.Close() - if err := rows.Err(); err != nil { - log.Printf("Warning: Row iteration error querying stale-guest saved cards: %v", err) - } - for _, cardID := range cardIDs { - if err := payments.SquareClient.DeleteCardOnFile(ctx, cardID); err != nil { - // TokenPrefix redacts the ccof: card token — the full ID - // must never reach logs. - log.Printf("Warning: Failed to disable stale-guest Square card %s at Square: %v", square.TokenPrefix(cardID), err) - } - } - // GDPR erasure completeness: the guest's Square customer profile - // holds their real name + email PII. Disabling the saved cards and - // NULLing square_customer_id locally is NOT enough — the Square - // customer profile must be deleted too, or the PII persists at - // Square indefinitely after anonymization. Distinct IDs only, so a - // guest with multiple cards on one customer triggers one delete. - for _, customerID := range customerIDs { - if err := payments.SquareClient.DeleteCustomer(ctx, customerID); err != nil { - // TokenPrefix redacts the customer ID — the full ID must - // never reach logs. - log.Printf("Warning: Failed to delete stale-guest Square customer %s at Square: %v", square.TokenPrefix(customerID), err) + if customerID.Valid && customerID.String != "" { + if _, seen := customers[customerID.String]; !seen { + customers[customerID.String] = userID.String } } } + rows.Close() + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("row iteration error snapshotting stale-guest saved cards: %w", err) + } } tx, err := db.Conn.Begin(ctx) @@ -616,10 +908,105 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) { } totalRows += int(tag.RowsAffected()) + // Scrub Square CreatePayment request snapshots (payments / till_sales): + // the stored replay JSON embeds the guest's email as BuyerEmail (PII, GDPR + // Art 17 / Art 5(1)(e)). The financial rows MUST survive the 7-year + // retention period, so only the snapshot is NULLed — the sweep rebuilds a + // minimal replay body when the snapshot is missing, keeping reconciliation + // money-safe. Scope mirrors delete_guest_user(): payments the guest + // initiated (created_by) or charged against the guest's bookings, and + // till_sales where the guest is the customer (user_id), narrowed to the + // guests this run just anonymized. + tag, err = tx.Exec(ctx, ` + UPDATE payments + SET square_request_snapshot = NULL + WHERE created_by IN ( + SELECT id FROM users + WHERE account_role = 'guest' + AND n_first_name = 'Guest' + AND n_last_name = 'Anonymized' + ) + OR booking_id IN ( + SELECT id FROM bookings + WHERE user_id IN ( + SELECT id FROM users + WHERE account_role = 'guest' + AND n_first_name = 'Guest' + AND n_last_name = 'Anonymized' + ) + ) + `) + if err != nil { + return 0, err + } + totalRows += int(tag.RowsAffected()) + + tag, err = tx.Exec(ctx, ` + UPDATE till_sales + SET square_request_snapshot = NULL + WHERE user_id IN ( + SELECT id FROM users + WHERE account_role = 'guest' + AND n_first_name = 'Guest' + AND n_last_name = 'Anonymized' + ) + `) + if err != nil { + return 0, err + } + totalRows += int(tag.RowsAffected()) + + // Capture the ids of the stale guests this run erased (the 'Anonymized' + // marker is set only by the users UPDATE above) so the post-commit CardDAV + // vCard / S3 profile-photo scrubs cover exactly the erased guests. + // Already-anonymized guests from a re-run may re-appear here — their + // external-artifact deletes are no-ops. + rows, err := tx.Query(ctx, ` + SELECT id FROM users + WHERE account_role = 'guest' + AND n_first_name = 'Guest' + AND n_last_name = 'Anonymized' + `) + if err != nil { + return 0, fmt.Errorf("failed to query anonymized stale guests: %w", err) + } + var anonymizedGuestIDs []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return 0, fmt.Errorf("failed to scan anonymized stale guest id: %w", err) + } + anonymizedGuestIDs = append(anonymizedGuestIDs, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("row iteration error querying anonymized stale guests: %w", err) + } + if err := tx.Commit(ctx); err != nil { return 0, err } + // After the local erasure commits: delete the guests' cards and Square + // customer profiles (real name + email PII) at Square. Retried on transient + // failures; a failure after all attempts raises a critical admin + // notification + ERROR log — never silently dropped. Distinct customer IDs + // only, so a guest with multiple cards on one customer triggers one delete, + // and the shared-reference guard skips a customer still referenced by + // another (active) account. + if payments.SquareClient != nil { + // ToCTOU guard: the Square-id snapshot above was taken BEFORE the + // anonymize tx, so a guest who re-booked in that window was excluded by + // the users UPDATE predicate (they keep their active booking) but would + // otherwise still be erased at Square from the stale snapshot. Re-verify + // each snapshot owner is still stale (no active/pending booking) and + // drop owners who are not — their local erasure never ran either. + stillStaleCards, stillStaleCustomers := recheckStaleGuestSquareTargets(ctx, cardsByUser, customers) + deleteSquareCards(ctx, payments.SquareClient, stillStaleCards) + deleteSquareCustomers(ctx, payments.SquareClient, stillStaleCustomers) + } + // The guests' Square customer ids were deleted above and the DB columns are // now NULLed — drop their process-local cache entries so an erased guest's // stale Square customer id cannot resurface on a later save-card flow. @@ -627,6 +1014,14 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) { payments.InvalidateSquareCustomerCache(uid) } + // Delete the erased guests' CardDAV vCards and R2/S3 profile photos — the + // same external PII artifacts DeleteAccountHandler scrubs. These live + // outside the SQL rows the tx above anonymized, so batch erasure must + // delete them explicitly (best-effort; both services may be nil in dev). + for _, uid := range anonymizedGuestIDs { + deleteExternalUserArtifacts(ctx, uid) + } + return totalRows, nil } @@ -1080,6 +1475,24 @@ func CleanupIdleAccounts(ctx context.Context) (int, error) { accountsWithBalance = append(accountsWithBalance, acc) } + // Snapshot the with-balance accounts' Square card/customer ids BEFORE + // anonymize_user(unnest(...)) below NULLs them, so the post-commit Square + // cleanup still has the external references (GDPR erasure completeness). + // Fail-closed: the whole batch aborts if the snapshot fails, so the local + // erasure never proceeds without the external refs it needs. + var cardsByUser map[string][]string + var customers map[string]string + if payments.SquareClient != nil { + ids := make([]string, len(accountsWithBalance)) + for i, a := range accountsWithBalance { + ids[i] = a.id + } + cardsByUser, customers, err = snapshotSquareErasureTargets(ctx, tx, ids) + if err != nil { + return 0, err + } + } + if len(accountsWithBalance) > 0 { ids := make([]string, len(accountsWithBalance)) balances := make([]float64, len(accountsWithBalance)) @@ -1135,6 +1548,32 @@ func CleanupIdleAccounts(ctx context.Context) (int, error) { } rowsNoBalance.Close() + // Snapshot the no-balance accounts' Square card/customer ids BEFORE their + // anonymize_user(unnest(...)) below NULLs them, merging into the same maps. + // Fail-closed: the whole batch aborts if the snapshot fails. + if payments.SquareClient != nil { + var cardsNoBalance map[string][]string + var customersNoBalance map[string]string + cardsNoBalance, customersNoBalance, err = snapshotSquareErasureTargets(ctx, tx, accountsNoBalance) + if err != nil { + return 0, err + } + if cardsByUser == nil { + cardsByUser = map[string][]string{} + } + if customers == nil { + customers = map[string]string{} + } + for u, cs := range cardsNoBalance { + cardsByUser[u] = append(cardsByUser[u], cs...) + } + for c, owner := range customersNoBalance { + if _, seen := customers[c]; !seen { + customers[c] = owner + } + } + } + if _, err = tx.Exec(ctx, ` SELECT anonymize_user(unnest($1::text[])) `, accountsNoBalance); err != nil { @@ -1145,6 +1584,16 @@ func CleanupIdleAccounts(ctx context.Context) (int, error) { return 0, err } + // After the local erasure commits: delete the erased accounts' cards and + // Square customer profiles (real name + email PII) at Square. Retried on + // transient failures; a failure after all attempts raises a critical admin + // notification + ERROR log — never silently dropped. The shared-reference + // guard skips a customer still referenced by another (active) account. + if payments.SquareClient != nil { + deleteSquareCards(ctx, payments.SquareClient, cardsByUser) + deleteSquareCustomers(ctx, payments.SquareClient, customers) + } + // The anonymize_user(unnest(...)) calls above erased these accounts — drop // their process-local Square customer cache entries so a stale id cannot // resurface for an erased user. @@ -1155,6 +1604,16 @@ func CleanupIdleAccounts(ctx context.Context) (int, error) { payments.InvalidateSquareCustomerCache(id) } + // Delete the erased accounts' CardDAV vCards and R2/S3 profile photos — + // the same external PII artifacts DeleteAccountHandler scrubs (best-effort; + // both services may be nil in dev). + for _, acc := range accountsWithBalance { + deleteExternalUserArtifacts(ctx, acc.id) + } + for _, id := range accountsNoBalance { + deleteExternalUserArtifacts(ctx, id) + } + return len(accountsWithBalance) + len(accountsNoBalance), nil } diff --git a/backend/handlers/scheduling/time_blockers_test.go b/backend/handlers/scheduling/time_blockers_test.go index 9fc37c5..e6ff05b 100644 --- a/backend/handlers/scheduling/time_blockers_test.go +++ b/backend/handlers/scheduling/time_blockers_test.go @@ -2948,11 +2948,13 @@ func TestCleanupIdleAccounts_NoBalance(t *testing.T) { } } -// TestCleanupIdleAccounts_Scrubs2FAAndNotes verifies the GDPR erasure gap +// TestCleanupIdleAccounts_Scrubs2FA_RetainsNotes verifies the GDPR erasure gap // closure for the BATCH path: accounts anonymized via anonymize_user(unnest(...)) -// must also have their 2FA columns and staff notes scrubbed, exactly like the -// user-initiated DeleteAccountHandler path. -func TestCleanupIdleAccounts_Scrubs2FAAndNotes(t *testing.T) { +// must have their 2FA columns scrubbed, exactly like the user-initiated +// DeleteAccountHandler path, while the notes field — a single free-text +// medical/safety record — is RETAINED de-identified for reasonable adjustments +// (Equality Act 2010) and legal-claims defence. +func TestCleanupIdleAccounts_Scrubs2FA_RetainsNotes(t *testing.T) { t.Parallel() ctx, tx := resetTestData(t) @@ -2992,8 +2994,9 @@ func TestCleanupIdleAccounts_Scrubs2FAAndNotes(t *testing.T) { t.Errorf("expected anonymized email 'deleted+%s@deleted.invalid', got '%s'", userID, email) } - // Verify 2FA columns + notes were scrubbed by the batch anonymization - var notes interface{} + // Verify 2FA columns were scrubbed by the batch anonymization while the + // notes are retained as a de-identified medical/safety record. + var notes string var enabled bool var method, pendingHash, pendingExpires interface{} err = tx.QueryRow(ctx, ` @@ -3004,8 +3007,8 @@ func TestCleanupIdleAccounts_Scrubs2FAAndNotes(t *testing.T) { if err != nil { t.Fatalf("failed to query user after cleanup: %v", err) } - if notes != nil { - t.Errorf("expected users.notes to be NULL after idle-account cleanup, got %v", notes) + if notes != "Staff note with PII for idle-account cleanup" { + t.Errorf("expected users.notes to be retained after idle-account cleanup, got %q", notes) } if enabled { t.Error("expected two_factor_enabled to be FALSE after idle-account cleanup") diff --git a/backend/handlers/user/account.go b/backend/handlers/user/account.go index 23f22a6..2cfb909 100644 --- a/backend/handlers/user/account.go +++ b/backend/handlers/user/account.go @@ -2,12 +2,15 @@ package user import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" "errors" "fmt" "log" "log/slog" "net/http" + "net/url" "os" "time" @@ -21,6 +24,183 @@ import ( "github.com/jackc/pgx/v5" ) +// --- Square erasure retry + alerting + durable outbox (GDPR H3 / A1) --- +// +// The deletion goroutine in DeleteAccountHandler retries transient Square +// failures and, on final failure, logs at ERROR and raises a critical admin +// notification (reason 'critical_payment_log'). A failed Square-side erasure +// must never be silently dropped: by the time the goroutine runs, the local +// anonymization has already committed and NULLed square_card_id/ +// square_customer_id, so the external Square references would otherwise be +// orphaned — permanently, with no way to retrace them. +// +// Fault A1 (crash window): the erasure targets are ALSO persisted as a durable +// outbox INSIDE the anonymization transaction (persistSquareErasureOutbox), +// before it commits. The just-scrubbed, soft-deleted user_saved_cards rows keep +// their square_card_id / square_customer_id (and lose their user_id, matching +// delete_guest_user) until the Square-side erasure completes. If the process +// dies between the local commit and the async goroutine finishing, those rows +// are the only remaining record of the external Square PII; the +// retry-square-erasures job (internal/jobs/cleanup.go) finds them and retries +// the deletion. The goroutine drains the outbox (NULLing the refs on success); +// the job is the crash safety net. Either the local tx rolls back (nothing +// NULLed) or the durable queue guarantees the Square deletion is eventually +// attempted and alerted on failure. + +const ( + // squareDeleteMaxAttempts is the number of times a Square erasure call is + // attempted before it is treated as failed (1 initial + 2 retries). + squareDeleteMaxAttempts = 3 + // squareDeleteAttemptTimeout bounds a single Square erasure attempt. + squareDeleteAttemptTimeout = 30 * time.Second + // squareDeleteBackoffBase is the delay before the first retry (doubled per + // subsequent retry). + squareDeleteBackoffBase = 500 * time.Millisecond +) + +// squareErasureTarget records one saved-card row's external Square references +// (row id + square_card_id + square_customer_id) captured BEFORE anonymization +// NULLs them, so the durable outbox and the async cleanup goroutine still have +// the external refs they need to complete Square-side erasure (Fault A1). +type squareErasureTarget struct { + rowID string + cardID string // empty when the row had no Square card on file + customerID string // empty when the row had no Square customer profile +} + +// squareDeletionRetryable reports whether a Square erasure error is transient +// and worth retrying: transport errors (wrapped *url.Error) and Square 5xx +// responses. Definitive 4xx rejections and not-found no-ops are NOT retried. +func squareDeletionRetryable(err error) bool { + if square.IsNotFound(err) { + return false + } + if sc := square.ErrorStatusCode(err); sc != 0 { + return sc >= http.StatusInternalServerError + } + var urlErr *url.Error + return errors.As(err, &urlErr) +} + +// RetrySquareDeletion runs fn (a DeleteCardOnFile/DeleteCustomer call) up to +// squareDeleteMaxAttempts times, backing off between retries, retrying only +// transient failures (see squareDeletionRetryable). Returns the final error, +// nil on success. Exported for the retry-square-erasures job +// (internal/jobs/cleanup.go), which reuses the same retry budget as the +// account-deletion goroutine. +func RetrySquareDeletion(parent context.Context, fn func(context.Context) error) error { + var lastErr error + backoff := squareDeleteBackoffBase + for attempt := 1; attempt <= squareDeleteMaxAttempts; attempt++ { + if attempt > 1 { + select { + case <-time.After(backoff): + case <-parent.Done(): + return lastErr + } + backoff *= 2 + } + attemptCtx, cancel := context.WithTimeout(parent, squareDeleteAttemptTimeout) + err := fn(attemptCtx) + cancel() + if err == nil { + return nil + } + lastErr = err + if !squareDeletionRetryable(err) { + return err + } + } + return lastErr +} + +// squareErasureNotificationID derives a deterministic admin_notifications id +// for a failed Square-side erasure tied to key ("S" + 11 hex chars of a +// SHA-256 digest, mirroring the webhooks dispute-notification id scheme). For +// the account-deletion path key is the affected user id; for the +// retry-square-erasures job key is the user id when the outbox row still +// carries one, otherwise the outbox row id. One notification per key; +// re-delivery of the same failure is a no-op. +func squareErasureNotificationID(key string) string { + sum := sha256.Sum256([]byte("square-erasure-failure:" + key)) + return "S" + hex.EncodeToString(sum[:])[:11] +} + +// InsertSquareErasureCriticalNotification surfaces a failed Square-side +// erasure in the admin notification centre (reason 'critical_payment_log' — +// the DB-backed stand-in for CRITICAL logs that the payments sweep uses). +// user_id is deliberately NULL: by the time the async cleanup runs, the account +// may already be deleted (guests) or anonymized, and the deterministic id keeps +// exactly one notification per affected key. Exported for the +// retry-square-erasures job, which reuses the account-deletion alert scheme. +func InsertSquareErasureCriticalNotification(ctx context.Context, key string) { + // Fresh bounded context so a near-expiry deletion context cannot suppress + // the admin alert. + actx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + tag, err := db.Conn.Exec(actx, ` + INSERT INTO admin_notifications (id, reason, booking_id, user_id, created_at) + VALUES ($1, 'critical_payment_log'::admin_notification_reason, NULL, NULL, NOW()) + ON CONFLICT (id) DO NOTHING + `, squareErasureNotificationID(key)) + if err != nil { + slog.Error("failed to insert critical notification for failed Square erasure", "key", key, "err", err) + return + } + if int(tag.RowsAffected()) > 0 { + slog.Error("Square-side erasure FAILED after retries — critical notification raised", "key", key) + } +} + +// persistSquareErasureOutbox writes the captured Square erasure targets back +// onto the just-scrubbed, soft-deleted user_saved_cards rows INSIDE the +// anonymization transaction, BEFORE it commits (Fault A1). anonymize_user() / +// delete_guest_user() NULL square_card_id/square_customer_id for GDPR +// scrubbing; this UPDATE restores them on the soft-deleted rows so the erasure +// targets survive the commit as a durable queue entry. user_id is also NULLed +// (delete_guest_user already does this for guests): payments.EnsureSquareCustomer +// reads square_customer_id across ALL of a user's rows regardless of +// deleted_at, so an outbox row that kept its user_id would let the erased +// identity's deleted Square customer resurface on a later save-card flow. The +// rows remain in place for the 7-year financial retention; only the user link +// is dropped. If the process crashes before the async Square cleanup finishes, +// the retry-square-erasures job (internal/jobs/cleanup.go) finds these rows and +// completes the external deletion — the erasure is never permanently lost. +func persistSquareErasureOutbox(ctx context.Context, tx pgx.Tx, targets []squareErasureTarget) error { + for _, t := range targets { + var cardID, customerID any + if t.cardID != "" { + cardID = t.cardID + } + if t.customerID != "" { + customerID = t.customerID + } + if _, err := tx.Exec(ctx, ` + UPDATE user_saved_cards + SET square_card_id = $2, square_customer_id = $3, user_id = NULL + WHERE id = $1 AND deleted_at IS NOT NULL + `, t.rowID, cardID, customerID); err != nil { + return err + } + } + return nil +} + +// clearSquareErasureOutboxRows NULLs the durable outbox marker on the given +// soft-deleted user_saved_cards rows after their Square-side erasure is +// complete (or deliberately abandoned as still-referenced). Best-effort: the +// retry-square-erasures job re-covers any row left behind by a failure. +func clearSquareErasureOutboxRows(ctx context.Context, rowIDs []string) { + for _, rowID := range rowIDs { + if _, err := db.Conn.Exec(ctx, ` + UPDATE user_saved_cards SET square_customer_id = NULL + WHERE id = $1 AND deleted_at IS NOT NULL + `, rowID); err != nil { + slog.Error("failed to clear Square erasure outbox for customer", "row", rowID, "error", err) + } + } +} + // DELETE /api/user/account func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { userID, ok := mw.GetUserID(r.Context()) @@ -70,45 +250,56 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { }(profilePicURL.String) } - // Snapshot the Square card IDs AND customer IDs synchronously BEFORE the - // SQL anonymization below NULLs square_card_id/square_customer_id, so the - // background cleanup still has the external Square references it needs - // (previously the goroutine read the rows itself, racing the anonymize - // step which wiped them mid-flight). Distinct non-null customer IDs only: - // a user's saved cards share one provisioned Square customer, so - // DeleteCustomer runs once per customer. NULL customer IDs (users with - // no saved cards) are skipped. - var cardIDs []string - customerSeen := map[string]bool{} - var customerIDs []string + // Snapshot the Square card AND customer references per saved-card row + // synchronously BEFORE the SQL anonymization below NULLs + // square_card_id/square_customer_id, so both the durable outbox and the + // background cleanup still have the external Square references they need + // (previously the goroutine read the rows itself, racing the anonymize step + // which wiped them mid-flight). Distinct non-null customer IDs only: a + // user's saved cards share one provisioned Square customer, so + // DeleteCustomer runs once per customer. NULL customer IDs (users with no + // saved cards) are skipped. + var erasureTargets []squareErasureTarget + needsSquareErasure := false // Capture the client synchronously so the async cleanup goroutine never // reads the global payments.SquareClient (which tests swap per-account). sqClient := payments.SquareClient if sqClient != nil { rows, err := db.Conn.Query(r.Context(), - `SELECT square_card_id, square_customer_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL`, userID) + `SELECT id, square_card_id, square_customer_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL`, userID) if err != nil { - log.Printf("Warning: Failed to query saved cards for user %s: %v", userID, err) - } else { - for rows.Next() { - var cardID, customerID sql.NullString - if err := rows.Scan(&cardID, &customerID); err != nil { - log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err) - continue - } - if cardID.Valid && cardID.String != "" { - cardIDs = append(cardIDs, cardID.String) - } - if customerID.Valid && customerID.String != "" && !customerSeen[customerID.String] { - customerSeen[customerID.String] = true - customerIDs = append(customerIDs, customerID.String) - } - } - if err := rows.Err(); err != nil { - log.Printf("Warning: Row iteration error for user %s: %v", userID, err) - } - rows.Close() + // Fail-closed: anonymization must never proceed without the + // external Square refs the cleanup needs — otherwise the card and + // customer would be permanently orphaned at Square with the local + // ids already NULLed (GDPR erasure completeness). + log.Printf("Failed to query saved cards for user %s: %v", userID, err) + http.Error(w, "server error", http.StatusInternalServerError) + return } + for rows.Next() { + var t squareErasureTarget + var cardID, customerID sql.NullString + if err := rows.Scan(&t.rowID, &cardID, &customerID); err != nil { + log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err) + continue + } + if cardID.Valid && cardID.String != "" { + t.cardID = cardID.String + } + if customerID.Valid && customerID.String != "" { + t.customerID = customerID.String + } + if t.cardID != "" || t.customerID != "" { + needsSquareErasure = true + } + erasureTargets = append(erasureTargets, t) + } + if err := rows.Err(); err != nil { + log.Printf("Failed to iterate saved cards for user %s: %v", userID, err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + rows.Close() } // --- SQL-level anonymization/deletion --- @@ -133,6 +324,18 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { return } + // A1 durable outbox: persist the Square erasure targets on the + // just-scrubbed, soft-deleted rows BEFORE this tx commits — if the + // process dies after the commit, the retry-square-erasures job still + // finds them. + if sqClient != nil && needsSquareErasure { + if err := persistSquareErasureOutbox(ctx, tx, erasureTargets); err != nil { + log.Printf("Failed to persist Square erasure outbox for guest %s: %v", userID, err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + } + if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction for guest user deletion: %v", err) http.Error(w, "server error", http.StatusInternalServerError) @@ -163,6 +366,18 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { return } + // A1 durable outbox: persist the Square erasure targets on the + // just-scrubbed, soft-deleted rows BEFORE this tx commits — if the + // process dies after the commit, the retry-square-erasures job still + // finds them. + if sqClient != nil && needsSquareErasure { + if err := persistSquareErasureOutbox(ctx, tx, erasureTargets); err != nil { + log.Printf("Failed to persist Square erasure outbox for user %s: %v", userID, err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + } + if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction for user anonymization: %v", err) http.Error(w, "server error", http.StatusInternalServerError) @@ -178,11 +393,24 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { // columns are NULLed, but the cache is never touched by either). payments.InvalidateSquareCustomerCache(userID) + // Build the context used for critical-notification inserts: route through + // the request transaction when one is active (tests) so alerts roll back + // with the fixture; otherwise fall back to the shared pool (production, + // where the request context is cancelled once this handler returns). + // Captured synchronously for the goroutine below. + notifyCtx := context.Background() + if activeTx := db.TxFromContext(r.Context()); activeTx != nil { + notifyCtx = db.ContextWithTx(notifyCtx, activeTx) + } + // external Square cleanup fires only after local anonymization/deletion - // commits, so a failed local tx leaves external state intact for retry. - if sqClient != nil && (len(cardIDs) > 0 || len(customerIDs) > 0) { + // commits, so a failed local tx leaves external state intact for retry. The + // durable outbox persisted above guarantees the targets survive even a + // process crash before this goroutine finishes — this goroutine is the + // primary drain, and the retry-square-erasures job is the safety net. + if sqClient != nil && needsSquareErasure { // #nosec G118 — intentional background goroutine for async account deletion - go func(client square.SquareClient, cards, customers []string) { + go func(client square.SquareClient, targets []squareErasureTarget, notifyCtx context.Context) { defer func() { if r := recover(); r != nil { log.Printf("Panic recovered in Square cleanup: %v", r) @@ -190,14 +418,44 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { }() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - for _, cardID := range cards { - if err := client.DeleteCardOnFile(ctx, cardID); err != nil { - // TokenPrefix redacts the ccof: card token — the full ID must - // never reach logs. - log.Printf("Warning: Failed to delete Square card %s for user %s: %v", square.TokenPrefix(cardID), userID, err) + + cardRow := map[string]string{} + for _, t := range targets { + if t.cardID != "" { + cardRow[t.cardID] = t.rowID } } - for _, customerID := range customers { + for cardID, rowID := range cardRow { + if err := RetrySquareDeletion(ctx, func(actx context.Context) error { + return client.DeleteCardOnFile(actx, cardID) + }); err != nil { + // TokenPrefix redacts the ccof: card token — the full ID must + // never reach logs. A failed erasure is logged at ERROR and + // raised as a critical admin notification — never silently + // dropped after the local refs are NULLed. + log.Printf("Error: Failed to delete Square card %s for user %s after %d attempts: %v", square.TokenPrefix(cardID), userID, squareDeleteMaxAttempts, err) + slog.Error("square card deletion failed after retries", "user_id", userID, "card", square.TokenPrefix(cardID), "error", err) + InsertSquareErasureCriticalNotification(notifyCtx, userID) + continue + } + // Drain the durable outbox so the safety-net job has nothing to + // retry. Best-effort: a failed clear leaves the entry in place + // for the job, which treats NOT_FOUND as complete. + if _, err := db.Conn.Exec(ctx, ` + UPDATE user_saved_cards SET square_card_id = NULL + WHERE id = $1 AND deleted_at IS NOT NULL + `, rowID); err != nil { + slog.Error("failed to clear Square erasure outbox for card", "user_id", userID, "card", square.TokenPrefix(cardID), "error", err) + } + } + + customerRows := map[string][]string{} + for _, t := range targets { + if t.customerID != "" { + customerRows[t.customerID] = append(customerRows[t.customerID], t.rowID) + } + } + for customerID, rows := range customerRows { // Square customers are provisioned per-user from a deterministic // email-derived key, but the UNIQUE(email) index excludes guest // accounts — so a guest and a registered user sharing an email can @@ -209,19 +467,31 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { if err := db.Conn.QueryRow(ctx, ` SELECT EXISTS(SELECT 1 FROM user_saved_cards WHERE square_customer_id = $1 AND user_id <> $2 AND deleted_at IS NULL) `, customerID, userID).Scan(&stillReferenced); err != nil { - log.Printf("Warning: Failed to check Square customer %s references before deletion: %v", square.TokenPrefix(customerID), err) + log.Printf("Error: Failed to check Square customer %s references before deletion: %v", square.TokenPrefix(customerID), err) + slog.Error("failed to check Square customer references before deletion", "user_id", userID, "customer", square.TokenPrefix(customerID), "error", err) + InsertSquareErasureCriticalNotification(notifyCtx, userID) continue } if stillReferenced { // PII-redacted customer id — the full id never reaches logs. log.Printf("Warning: skipping Square customer deletion — customer %s still referenced by another account", square.TokenPrefix(customerID)) + // The customer is deliberately kept (still referenced by + // another account) — not a pending erasure, so drain the + // outbox rows and let the job stop retrying it. + clearSquareErasureOutboxRows(ctx, rows) continue } - if err := client.DeleteCustomer(ctx, customerID); err != nil { - log.Printf("Warning: Failed to delete Square customer %s for user %s: %v", square.TokenPrefix(customerID), userID, err) + if err := RetrySquareDeletion(ctx, func(actx context.Context) error { + return client.DeleteCustomer(actx, customerID) + }); err != nil { + log.Printf("Error: Failed to delete Square customer %s for user %s after %d attempts: %v", square.TokenPrefix(customerID), userID, squareDeleteMaxAttempts, err) + slog.Error("square customer deletion failed after retries", "user_id", userID, "customer", square.TokenPrefix(customerID), "error", err) + InsertSquareErasureCriticalNotification(notifyCtx, userID) + continue } + clearSquareErasureOutboxRows(ctx, rows) } - }(sqClient, cardIDs, customerIDs) + }(sqClient, erasureTargets, notifyCtx) } // Delete CardDAV contact (non-blocking, best-effort) diff --git a/backend/handlers/user/gdpr_test.go b/backend/handlers/user/gdpr_test.go index c9a40a9..4a778c7 100644 --- a/backend/handlers/user/gdpr_test.go +++ b/backend/handlers/user/gdpr_test.go @@ -439,7 +439,13 @@ func TestAnonymizeUser_ScrubsTimeBlockerReservations(t *testing.T) { } } -func TestAnonymizeUser_ScrubsEditRequestNotes(t *testing.T) { +// TestAnonymizeUser_RetainsEditRequestNotes verifies that booking edit request +// notes survive GDPR erasure: the notes field is a single free-text medical/ +// safety record (colour/preference/lateness AND allergy/access/disability +// content) retained de-identified for reasonable adjustments (Equality Act +// 2010) and legal-claims defence, so the edit request row must survive and its +// notes must be kept verbatim. +func TestAnonymizeUser_RetainsEditRequestNotes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) @@ -471,15 +477,29 @@ func TestAnonymizeUser_ScrubsEditRequestNotes(t *testing.T) { t.Fatalf("anonymize_user failed: %v", err) } - var notes interface{} + // The edit request row must survive erasure (the SQL no longer deletes + // notes-only edit requests) — it is retained as a de-identified record. + var rowCount int + err = tx.QueryRow(ctx, ` + SELECT COUNT(*) FROM booking_edit_requests WHERE requested_by = $1 + `, userID).Scan(&rowCount) + if err != nil { + t.Fatalf("failed to count edit requests: %v", err) + } + if rowCount != 1 { + t.Errorf("expected edit request row to be retained after anonymization, found %d rows", rowCount) + } + + // The notes are retained verbatim as a de-identified medical/safety record. + var notes string err = tx.QueryRow(ctx, ` SELECT notes FROM booking_edit_requests WHERE requested_by = $1 `, userID).Scan(¬es) if err != nil { t.Fatalf("failed to query edit request notes: %v", err) } - if notes != nil { - t.Errorf("expected edit request notes to be NULL after anonymization, got %v", notes) + if notes != "Please move my appointment, I have a conflict" { + t.Errorf("expected edit request notes to be retained after anonymization, got %q", notes) } } @@ -517,11 +537,14 @@ func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) { } } -// TestAnonymizeUser_Scrubs2FAAndNotes verifies the GDPR erasure gap closure: -// anonymize_user() itself NULLs the 2FA columns and staff notes on the row, so -// every call site (user-initiated delete AND idle-account batch cleanup) is -// covered without a separate Go-side scrub. -func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) { +// TestAnonymizeUser_Scrubs2FA_RetainsNotes verifies the GDPR erasure gap +// closure: anonymize_user() itself clears the 2FA columns on the row, so every +// call site (user-initiated delete AND idle-account batch cleanup) is covered +// without a separate Go-side scrub. The notes field is a single free-text +// medical/safety record (colour/preference/lateness AND allergy/access/ +// disability content) and is RETAINED de-identified at erasure for reasonable +// adjustments (Equality Act 2010) and legal-claims defence. +func TestAnonymizeUser_Scrubs2FA_RetainsNotes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) @@ -548,7 +571,7 @@ func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) { t.Fatalf("anonymize_user failed: %v", err) } - var notes interface{} + var notes string var enabled bool var method, pendingHash, pendingExpires interface{} err = tx.QueryRow(ctx, ` @@ -559,8 +582,8 @@ func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) { if err != nil { t.Fatalf("failed to query user after anonymization: %v", err) } - if notes != nil { - t.Errorf("expected users.notes to be NULL after erasure, got %v", notes) + if notes != "Client prefers quiet appointments and has a cat allergy" { + t.Errorf("expected users.notes to be retained after erasure, got %q", notes) } if enabled { t.Error("expected two_factor_enabled to be FALSE after erasure") @@ -576,12 +599,15 @@ func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) { } } -// TestDeleteAccount_Scrubs2FAAndNotes runs the full DeleteAccountHandler for a -// user with 2FA enabled and staff notes, asserting the end-to-end delete path -// scrubs both (via anonymize_user(), which is now the single source of truth). +// TestDeleteAccount_Scrubs2FA_RetainsNotes runs the full DeleteAccountHandler +// for a user with 2FA enabled and staff notes, asserting the end-to-end delete +// path clears the 2FA columns (via anonymize_user(), which is now the single +// source of truth) while RETAINING the notes as a de-identified medical/safety +// record for reasonable adjustments (Equality Act 2010) and legal-claims +// defence. // Kept sequential (no t.Parallel) because the handler reads the // process-global payments.SquareClient. -func TestDeleteAccount_Scrubs2FAAndNotes(t *testing.T) { +func TestDeleteAccount_Scrubs2FA_RetainsNotes(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) @@ -611,7 +637,7 @@ func TestDeleteAccount_Scrubs2FAAndNotes(t *testing.T) { t.Fatalf("expected 204, got %d. body: %s", rr.Code, rr.Body.String()) } - var notes interface{} + var notes string var enabled bool var method, pendingHash, pendingExpires interface{} err = tx.QueryRow(ctx, ` @@ -622,8 +648,8 @@ func TestDeleteAccount_Scrubs2FAAndNotes(t *testing.T) { if err != nil { t.Fatalf("failed to query user after deletion: %v", err) } - if notes != nil { - t.Errorf("expected users.notes to be NULL after deletion, got %v", notes) + if notes != "Staff note with PII" { + t.Errorf("expected users.notes to be retained after deletion, got %q", notes) } if enabled { t.Error("expected two_factor_enabled to be FALSE after deletion") @@ -676,7 +702,12 @@ func TestAnonymizeUser_ScrubsNameHistory(t *testing.T) { } } -func TestAnonymizeUser_ScrubsBookingNotes(t *testing.T) { +// TestAnonymizeUser_RetainsBookingNotes verifies that booking notes survive +// GDPR erasure: the notes field is a single free-text medical/safety record +// (colour/preference/lateness AND allergy/access/disability content) retained +// de-identified at erasure for reasonable adjustments (Equality Act 2010) and +// legal-claims defence (e.g. allergy mistreatment). +func TestAnonymizeUser_RetainsBookingNotes(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) @@ -708,13 +739,13 @@ func TestAnonymizeUser_ScrubsBookingNotes(t *testing.T) { t.Fatalf("anonymize_user failed: %v", err) } - var notes interface{} + var notes string err = tx.QueryRow(ctx, `SELECT notes FROM bookings WHERE id = $1`, bookingID).Scan(¬es) if err != nil { t.Fatalf("failed to query booking notes: %v", err) } - if notes != nil { - t.Errorf("expected booking notes to be NULL after anonymization, got %v", notes) + if notes != "Please call me on the day, doorbell broken" { + t.Errorf("expected booking notes to be retained after anonymization, got %q", notes) } } @@ -749,6 +780,135 @@ func TestAnonymizeUser_DoesNotAffectGuests(t *testing.T) { } } +// TestAnonymizeUser_PreservesFinancialRows pins the UK financial 7-year +// retention: GDPR erasure must scrub user-linked PII but leave financial rows +// untouched. anonymize_user() only NULLs the Square request snapshot (which +// embeds BuyerEmail PII); the payments, gift_cards and gift_card_transactions +// rows themselves — amount, created_by, square_payment_id, +// total_funds_added — must survive erasure byte-for-byte. +func TestAnonymizeUser_PreservesFinancialRows(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + // created_by + Square refs: the financial fields the 7-year retention keeps. + _, err = tx.Exec(ctx, ` + UPDATE payments + SET created_by = $1, + square_payment_id = 'sq_pay_retention_123', + square_request_snapshot = '{"buyer_email":"real@example.com","amount":50.00}' + WHERE id = $2 + `, userID, paymentID) + if err != nil { + t.Fatalf("failed to set payment created_by + square_payment_id: %v", err) + } + + // Gift card + its transaction ledger rows, both linked to the user. + var giftCardID string + err = tx.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by) + VALUES (100.00, 40.00, $1, $1) + RETURNING id + `, userID).Scan(&giftCardID) + if err != nil { + t.Fatalf("failed to create gift card: %v", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, user_id) + VALUES ($1, 'purchase', 100.00, $2) + `, giftCardID, userID) + if err != nil { + t.Fatalf("failed to create gift card transaction: %v", err) + } + + _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) + if err != nil { + t.Fatalf("anonymize_user failed: %v", err) + } + + // The payment row survives with its financial fields untouched; only the + // PII-bearing Square request snapshot is scrubbed. + var amount float64 + var createdBy, squarePaymentID, snapshot interface{} + err = tx.QueryRow(ctx, ` + SELECT amount, created_by, square_payment_id, square_request_snapshot + FROM payments WHERE id = $1 + `, paymentID).Scan(&amount, &createdBy, &squarePaymentID, &snapshot) + if err != nil { + t.Fatalf("failed to query payment after anonymization: %v", err) + } + if amount != 50.00 { + t.Errorf("expected payment amount 50.00 to survive erasure, got %v", amount) + } + if createdBy != userID { + t.Errorf("expected payment created_by %q to survive erasure, got %v", userID, createdBy) + } + if squarePaymentID != "sq_pay_retention_123" { + t.Errorf("expected payment square_payment_id to survive erasure, got %v", squarePaymentID) + } + if snapshot != nil { + t.Errorf("expected square_request_snapshot to be NULLed (BuyerEmail PII), got %v", snapshot) + } + + // Gift card funds survive erasure. + var totalFundsAdded float64 + err = tx.QueryRow(ctx, `SELECT total_funds_added FROM gift_cards WHERE id = $1`, giftCardID).Scan(&totalFundsAdded) + if err != nil { + t.Fatalf("failed to query gift card after anonymization: %v", err) + } + if totalFundsAdded != 100.00 { + t.Errorf("expected gift card total_funds_added 100.00 to survive erasure, got %v", totalFundsAdded) + } + + // Gift card transaction ledger rows survive erasure with the user link intact. + var txAmount float64 + var txUserID string + err = tx.QueryRow(ctx, ` + SELECT amount, user_id FROM gift_card_transactions WHERE gift_card_id = $1 + `, giftCardID).Scan(&txAmount, &txUserID) + if err != nil { + t.Fatalf("failed to query gift card transaction after anonymization: %v", err) + } + if txAmount != 100.00 { + t.Errorf("expected gift card transaction amount 100.00 to survive erasure, got %v", txAmount) + } + if txUserID != userID { + t.Errorf("expected gift card transaction user_id %q to survive erasure, got %v", userID, txUserID) + } + + // ... while the user-linked PII is scrubbed. + var firstName, email string + err = tx.QueryRow(ctx, `SELECT n_first_name, email FROM users WHERE id = $1`, userID).Scan(&firstName, &email) + if err != nil { + t.Fatalf("failed to query user after anonymization: %v", err) + } + if firstName != "Deleted" { + t.Errorf("expected n_first_name 'Deleted' after erasure, got %q", firstName) + } + if email != "deleted+"+userID+"@deleted.invalid" { + t.Errorf("expected anonymized email, got %q", email) + } +} + // ============================================================ // SQL export_all_user_data() Comprehensive Export Tests // ============================================================ diff --git a/backend/handlers/user/twofa.go b/backend/handlers/user/twofa.go index c28b121..522fd8b 100644 --- a/backend/handlers/user/twofa.go +++ b/backend/handlers/user/twofa.go @@ -13,7 +13,6 @@ import ( "log" "math/big" "net/http" - "os" "sync" "sync/atomic" "time" @@ -48,35 +47,38 @@ func generateTwoFACode() (string, error) { } // twoFAPepperEnv is the environment variable carrying the server-side pepper -// that keys the HMAC of stored 2FA codes (documented in .env.example). When it -// is absent the code falls back to the legacy plain SHA-256 digest with a -// one-time warning — see hashTwoFACode. +// that keys the HMAC of stored 2FA codes (documented in .env.example). Its +// absence is handled per build: dev/test builds fall back to the legacy plain +// SHA-256 digest with a one-time warning, while production builds fail closed +// at code issuance — see twofa_dev.go / twofa_prod.go. const twoFAPepperEnv = "TWO_FACTOR_PEPPER" -// twoFAPepperWarnOnce guards the one-time warning when TWO_FACTOR_PEPPER is -// unset, so a misconfigured deployment is loudly flagged once rather than on -// every code operation. -var twoFAPepperWarnOnce sync.Once +// errTwoFADeliveryUnavailable is returned by production builds when a 2FA code +// is requested but no delivery channel is configured: the email/SMS transport +// is not wired yet (P6) and the operator has not opted into the insecure +// log-delivery mode (TWO_FACTOR_ALLOW_LOG_DELIVERY=true). Handlers surface it +// verbatim so setup fails loudly with an actionable message instead of issuing +// a code that could never reach the user (which would silently dead-end the +// enforced saved-card-payments gate). Dev/test builds always have the [2FA] log +// channel and never return it (see twofa_dev.go). +var errTwoFADeliveryUnavailable = errors.New("2FA requires an email or SMS delivery channel; contact the salon") -// twoFAPepper returns the configured HMAC pepper, or "" when unset. Read per -// call (the rest of the backend reads env vars per call too) so a value -// provisioned at runtime is picked up; only the warning is gated on sync.Once. -func twoFAPepper() string { - pepper := os.Getenv(twoFAPepperEnv) - if pepper == "" { - twoFAPepperWarnOnce.Do(func() { - log.Printf("WARNING: TWO_FACTOR_PEPPER unset — 2FA codes hashed without an HMAC pepper (falling back to unsalted SHA-256); set TWO_FACTOR_PEPPER in production so a leaked digest cannot be brute-forced offline") - }) - } - return pepper -} +// twoFAPepper reads TWO_FACTOR_PEPPER (plus the one-time unset warning) and +// twoFAEnsureIssueAllowed guards code issuance; both are build-dependent. +// Dev/test builds keep the documented loose-fake fallback (twofa_dev.go); +// production builds refuse to issue codes without the pepper (twofa_prod.go), +// matching how main.go fails closed on a missing JWT_SECRET_KEY. hashTwoFACode +// calls twoFAPepper. // hashTwoFACode returns the hex digest of a verification code as stored in the // DB. With TWO_FACTOR_PEPPER set the digest is HMAC-SHA256 keyed by the pepper, // so a leaked digest cannot be brute-forced offline (the key stays server-side). -// When the pepper is unset it falls back to the legacy unsalted SHA-256 digest -// and logs a one-time warning. The plaintext code is never stored — only -// delivered via the [2FA] log line (see deliverTwoFACode). +// When the pepper is unset it falls back to the legacy unsalted SHA-256 digest: +// dev/test builds also log a one-time warning, while production builds can +// never persist such a digest because issuance fails closed without the pepper +// (twoFAEnsureIssueAllowed) — the fallback survives only for the legacy-row +// migration window and the dev/test loose-fake flow. The plaintext code is +// never stored; delivery is build-dependent (see deliverTwoFACode). func hashTwoFACode(code string) string { if pepper := twoFAPepper(); pepper != "" { mac := hmac.New(sha256.New, []byte(pepper)) @@ -245,19 +247,24 @@ func twoFAResetAttempts(userID string) { } // deliverTwoFACode generates a fresh verification code, persists only its -// SHA-256 hash plus the pending expiry (updating two_factor_method when method -// is non-empty), resets any prior lockout, and logs the plaintext code. +// digest plus the pending expiry (updating two_factor_method when method is +// non-empty), and resets any prior lockout. // -// The [2FA] log line is the delivery channel — the loose-fake stand-in for the -// not-yet-wired email/SMS transport (P6). The plaintext code is ALWAYS logged, -// enforced and unenforced alike: in enforced (production) environments the -// server log is the only way a code can reach the user, so an operator must -// relay it out-of-band. Do not gate this log line on the environment — without -// it, enforced-mode 2FA has no delivery path at all and every online saved-card -// charge stays 403. The API response still only returns the code when 2FA is -// unenforced (dev convenience). purpose labels the log line (e.g. "setup", -// "disable 2FA"). +// Delivery is build-dependent (twofa_dev.go / twofa_prod.go): dev/test builds +// write the plaintext code to the server log — the documented loose-fake +// stand-in for the not-yet-wired email/SMS transport (P6) — while production +// builds fail closed up front: twoFAEnsureIssueAllowed refuses to issue a code +// when TWO_FACTOR_PEPPER is unset (an unsalted digest would be +// offline-brute-forceable) or when no delivery channel is configured (email/SMS +// unwired and log delivery not explicitly opted into via +// TWO_FACTOR_ALLOW_LOG_DELIVERY=true) — so a production setup never mints a +// code that could never reach the user. The API response still only returns the +// code when 2FA is unenforced (dev convenience). purpose labels the delivery +// (e.g. "setup", "disable 2FA"). func deliverTwoFACode(r *http.Request, userID, method, purpose string) (string, error) { + if err := twoFAEnsureIssueAllowed(); err != nil { + return "", err + } code, err := generateTwoFACode() if err != nil { return "", err @@ -290,7 +297,12 @@ func deliverTwoFACode(r *http.Request, userID, method, purpose string) (string, if label == "" { label = purpose } - log.Printf("[2FA] verification code for user %s (%s): %s", userID, label, code) + // Build-dependent delivery: dev/test logs the plaintext code ([2FA] line); + // production logs it ONLY when the operator explicitly opted into log + // delivery (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) — otherwise issuance was + // already refused by twoFAEnsureIssueAllowed above, so the default is that + // the code never reaches a log. + twoFADeliverCode(userID, label, code) return code, nil } @@ -336,13 +348,15 @@ type TwoFASetupRequest struct { // POST /api/user/2fa/setup // Generates a verification code and stores only its SHA-256 hash plus a -// 10-minute expiry in the pending columns. The code is delivered by logging it -// with a [2FA] prefix — the loose-fake stand-in for the not-yet-wired email/SMS -// transport (P6). The plaintext code is ALWAYS logged, enforced and unenforced -// alike: in enforced (production) environments the server log is the only -// delivery channel, so an operator must relay the code to the user out-of-band. -// When 2FA is not enforced (dev), the code is also returned in the response so -// the flow is testable without reading backend logs. +// 10-minute expiry in the pending columns. Delivery is build-dependent (see +// deliverTwoFACode): dev/test builds log the code with a [2FA] prefix — the +// loose-fake stand-in for the not-yet-wired email/SMS transport (P6) — while +// production builds fail closed when TWO_FACTOR_PEPPER is unset or when no +// delivery channel is configured (email/SMS unwired and +// TWO_FACTOR_ALLOW_LOG_DELIVERY=true unset), returning a clear actionable +// error instead of silently issuing a code that would never arrive. When 2FA is +// not enforced (dev), the code is also returned in the response so the flow is +// testable without reading backend logs. func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) { userID, ok := mw.GetUserID(r.Context()) if !ok { @@ -373,10 +387,21 @@ func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) { } // Deliver a fresh code via the shared setup mechanism: generate, persist - // only the hash + expiry, reset any prior lockout, and log the plaintext - // code (the [2FA] log channel — see deliverTwoFACode). + // only the hash + expiry, reset any prior lockout, and deliver it + // build-dependently (the [2FA] log channel in dev/test; in production only + // when the operator explicitly opted into log delivery — see + // deliverTwoFACode). A production build with no delivery channel fails here + // with a clear, actionable error instead of issuing a code that would never + // reach the user. code, err := deliverTwoFACode(r, userID, req.Method, "setup") if err != nil { + if errors.Is(err, errTwoFADeliveryUnavailable) { + // The deployment has no delivery channel at all (email/SMS unwired + // and no explicit log-delivery opt-in). Fail loudly — the code is + // never generated, never logged, and never persisted. + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } log.Printf("failed to store 2FA pending code for user %s: %v", userID, err) http.Error(w, "server error", http.StatusInternalServerError) return @@ -535,6 +560,16 @@ func VerifyTwoFAHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests) return case twoFACodeMissingOrExpired: + // No valid pending code exists. If the deployment also has no delivery + // channel (production without an explicit log-delivery opt-in), the + // user can never receive a fresh code — setup and the disable mint all + // refuse issuance. Surface that actionable setup error instead of the + // generic "missing or expired" (which implies a simple retry would + // help), so this is never a silent lockout. + if !twoFADeliveryAvailable() { + http.Error(w, errTwoFADeliveryUnavailable.Error(), http.StatusServiceUnavailable) + return + } http.Error(w, "verification code is missing or has expired", http.StatusBadRequest) return } @@ -590,17 +625,20 @@ type TwoFADisableRequest struct { // Mints + delivers a fresh disable-flow verification code so the frontend can // show a code-entry step before it calls POST /api/user/2fa/disable. This is // the disable-flow equivalent of SetupTwoFAHandler: it guarantees a valid -// (unexpired) pending code exists, delivering a fresh one via the same [2FA] -// log channel and persisting only its hash + expiry when none does. The actual -// disable still happens through the existing disable endpoint, which validates -// the entered code under the shared 5-attempt lockout — this handler only -// performs the mint. Fresh-code mints are throttled per-user -// (twoFAMintCooldown), so a password-only attacker cannot loop -// request-code → burn 5 guesses → request-code forever; a throttled request -// returns 429. Like the disable handler, no code is returned in the response -// (the [2FA] log line is the delivery channel), and unlike setup this endpoint -// runs unconditionally — it does not short-circuit on !twoFARequired(), so dev -// environments can exercise the same step (the mint is harmless there). +// (unexpired) pending code exists, delivering a fresh one via the same +// build-dependent delivery channel (see deliverTwoFACode) and persisting only +// its hash + expiry when none does. The actual disable still happens through +// the existing disable endpoint, which validates the entered code under the +// shared 5-attempt lockout — this handler only performs the mint. Fresh-code +// mints are throttled per-user (twoFAMintCooldown), so a password-only attacker +// cannot loop request-code → burn 5 guesses → request-code forever; a throttled +// request returns 429. Like the disable handler, no code is returned in the +// response (delivery is the [2FA] log line in dev/test builds; production +// fails closed when no delivery channel is configured — no email/SMS and no +// explicit TWO_FACTOR_ALLOW_LOG_DELIVERY opt-in), and unlike setup this +// endpoint runs unconditionally — it does not short-circuit on +// !twoFARequired(), so dev environments can exercise the same step (the mint +// is harmless there). func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) { userID, ok := mw.GetUserID(r.Context()) if !ok { @@ -620,6 +658,13 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests) return } + if errors.Is(err, errTwoFADeliveryUnavailable) { + // Production with no delivery channel: a fresh code cannot be + // minted, so the disable flow (and thus the user's recovery) fails + // loudly with the actionable setup error. + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err) http.Error(w, "server error", http.StatusInternalServerError) return @@ -633,12 +678,16 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) { // Disabling 2FA lifts the SCA stand-in gate on saved-card charges, so in // enforced environments a verification code is required — a password-only // attacker must not be able to disable the protection. A fresh code is generated -// and delivered via the [2FA] log channel when no valid pending code exists, and -// the submitted code is checked under the shared 5-attempt lockout (wrong code → -// 400, lockout → 429); only a correct code clears the flag. Fresh-code mints are -// throttled per-user (twoFAMintCooldown) so the loop above cannot reset the -// lockout faster than once per cooldown. In unenforced (dev) environments the -// loose behavior is kept: no code required, so local dev is not blocked. +// and delivered via the build-dependent delivery channel (see deliverTwoFACode) +// when no valid pending code exists, and the submitted code is checked under the +// shared 5-attempt lockout (wrong code → 400, lockout → 429); only a correct +// code clears the flag. When no delivery channel is configured (production +// without email/SMS and without the explicit TWO_FACTOR_ALLOW_LOG_DELIVERY +// opt-in), the mint fails loudly with the actionable setup error instead of a +// silent 500. Fresh-code mints are throttled per-user (twoFAMintCooldown) so +// the loop above cannot reset the lockout faster than once per cooldown. In +// unenforced (dev) environments the loose behavior is kept: no code required, +// so local dev is not blocked. func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) { userID, ok := mw.GetUserID(r.Context()) if !ok { @@ -670,7 +719,7 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) { defer st.mu.Unlock() // Reuse a valid pending code when one exists; otherwise generate + deliver - // a fresh one via the same [2FA] log channel as setup. A fresh code gets its + // a fresh one via the same build-dependent channel as setup. A fresh code gets its // own independent 5-attempt budget (the mint resets the counter), so the // per-user mint cooldown is what stops the unlimited-guess loop — an // attacker can mint at most one fresh code per twoFAMintCooldown. @@ -679,6 +728,13 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests) return } + if errors.Is(err, errTwoFADeliveryUnavailable) { + // Production with no delivery channel: no fresh code can be minted, + // so the enforced disable re-verification cannot proceed. Surface + // the actionable setup error instead of a silent 500. + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err) http.Error(w, "server error", http.StatusInternalServerError) return @@ -713,8 +769,9 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) { // ensurePendingTwoFACode guarantees the user has a valid (unexpired) pending // code to verify against, generating + delivering a fresh one via the same -// [2FA] log channel as setup when the stored code is missing or expired. The -// caller must hold the user's attempt-state mutex. +// build-dependent delivery channel as setup (see deliverTwoFACode) when the +// stored code is missing or expired. The caller must hold the user's +// attempt-state mutex. // // A fresh code gets its own independent 5-attempt budget (deliverTwoFACode // resets the counter via twoFAResetAttempts), so the per-user mint cooldown is diff --git a/backend/handlers/user/twofa_dev.go b/backend/handlers/user/twofa_dev.go new file mode 100644 index 0000000..1615ef4 --- /dev/null +++ b/backend/handlers/user/twofa_dev.go @@ -0,0 +1,58 @@ +//go:build dev || test + +package user + +// Dev/test builds (the `dev` tag, or any build with the `test` tag) keep the +// documented loose-fake 2FA delivery: the plaintext code is written to the +// server log ([2FA] prefix) as the stand-in for the not-yet-wired email/SMS +// transport (P6), and a missing TWO_FACTOR_PEPPER still falls back to the +// legacy unsalted SHA-256 digest. Production builds (!dev && !test) instead +// never log the code and fail closed without the pepper — see twofa_prod.go. + +import ( + "log" + "os" + "sync" +) + +// twoFAPepperWarnOnce guards the one-time warning when TWO_FACTOR_PEPPER is +// unset, so a misconfigured deployment is loudly flagged once rather than on +// every code operation. Dev/test only: production builds fail closed at +// issuance instead. +var twoFAPepperWarnOnce sync.Once + +// twoFAPepper returns the configured HMAC pepper, or "" when unset, logging the +// documented one-time warning. Read per call (the rest of the backend reads env +// vars per call too) so a value provisioned at runtime is picked up; only the +// warning is gated on sync.Once. +func twoFAPepper() string { + pepper := os.Getenv(twoFAPepperEnv) + if pepper == "" { + twoFAPepperWarnOnce.Do(func() { + log.Printf("WARNING: TWO_FACTOR_PEPPER unset — 2FA codes hashed without an HMAC pepper (falling back to unsalted SHA-256); set TWO_FACTOR_PEPPER in production so a leaked digest cannot be brute-forced offline") + }) + } + return pepper +} + +// twoFAEnsureIssueAllowed always permits code issuance in dev/test builds: the +// loose-fake delivery (the [2FA] log line) is the documented stand-in until the +// email/SMS transport is wired (P6). Production builds fail closed here — no +// TWO_FACTOR_PEPPER, no codes (see twofa_prod.go). +func twoFAEnsureIssueAllowed() error { return nil } + +// twoFADeliverCode delivers a fresh verification code to the user. Dev/test: +// the [2FA] log line is the delivery channel — an operator relays the code to +// the user out-of-band until email/SMS lands. Production builds log it ONLY +// when the operator explicitly opts in via TWO_FACTOR_ALLOW_LOG_DELIVERY=true +// (see twofa_prod.go); otherwise they refuse issuance up front. +func twoFADeliverCode(userID, label, code string) { + log.Printf("[2FA] verification code for user %s (%s): %s", userID, label, code) +} + +// twoFADeliveryAvailable reports whether a 2FA code delivery channel exists in +// this build. Dev/test: always true — the [2FA] log line is the delivery +// channel. Production builds only have a channel when the operator explicitly +// opted into log delivery or a real email/SMS transport is wired (P6) — see +// twofa_prod.go. +func twoFADeliveryAvailable() bool { return true } diff --git a/backend/handlers/user/twofa_prod.go b/backend/handlers/user/twofa_prod.go new file mode 100644 index 0000000..7b78925 --- /dev/null +++ b/backend/handlers/user/twofa_prod.go @@ -0,0 +1,102 @@ +//go:build !dev && !test + +package user + +// Production builds (neither the `dev` nor the `test` tag) must never persist +// an unsalted digest and must never write a 2FA code in plaintext by default: +// the plaintext [2FA] log delivery and the TWO_FACTOR_PEPPER fallback exist +// only in dev/test builds (twofa_dev.go). Here code issuance fails closed on +// BOTH missing configuration pieces: +// +// - a missing TWO_FACTOR_PEPPER (an unsalted digest in the 1M code space +// would be offline-brute-forceable from a log/DB leak), mirroring how +// main.go refuses to start without a strong JWT_SECRET_KEY; and +// - a missing delivery channel. The email/SMS transport is not wired yet +// (P6), so the ONLY production channel is the operator's explicit opt-in +// to the insecure log-delivery mode (TWO_FACTOR_ALLOW_LOG_DELIVERY=true). +// Without it, issuing a code would silently dead-end setup — the user +// could never receive the code and the enforced saved-card-payments gate +// would lock them out with no way forward. Issuance is refused and the +// handlers surface errTwoFADeliveryUnavailable ("2FA requires an email or +// SMS delivery channel; contact the salon"). +// +// The plaintext code is therefore never written to the server log unless the +// operator explicitly opted into log delivery and accepted its risk. + +import ( + "errors" + "log" + "os" +) + +// twoFAAllowLogDeliveryEnv is the explicit operator opt-in that makes this +// production build deliver 2FA codes via the server log ([2FA] prefix) — the +// documented INSECURE stand-in for the not-yet-wired email/SMS transport (P6). +// Production builds fail closed without it: no delivery channel is configured, +// so code issuance is refused (see twoFAEnsureIssueAllowed) and setup surfaces +// errTwoFADeliveryUnavailable. Set it ONLY to keep the operator-relays-the-code +// flow working in a deployment that understands the risk (anyone with backend +// log access can defeat the 2FA gate on saved-card charges). Defined here in +// the prod build only — dev/test builds always deliver via the log and never +// consult this flag. +const twoFAAllowLogDeliveryEnv = "TWO_FACTOR_ALLOW_LOG_DELIVERY" + +// errTwoFAPepperRequired is returned by twoFAEnsureIssueAllowed when +// TWO_FACTOR_PEPPER is unset in a production build. Refusing to issue is the +// only safe outcome: without the pepper a pending code would be persisted as an +// unsalted SHA-256 digest in the 1M code space, which a log/DB leak could +// brute-force offline. +var errTwoFAPepperRequired = errors.New("TWO_FACTOR_PEPPER is not set; refusing to issue a 2FA code (an unsalted digest would be offline-brute-forceable)") + +// twoFAPepper returns the configured HMAC pepper, or "" when unset. Production +// builds do NOT warn or fall back to the legacy digest: code issuance fails +// closed via twoFAEnsureIssueAllowed, so no pending code is ever persisted as +// an unsalted SHA-256 digest. +func twoFAPepper() string { + return os.Getenv(twoFAPepperEnv) +} + +// twoFADeliveryAvailable reports whether a 2FA code delivery channel exists in +// this build. Production: true only when the operator explicitly opted into the +// insecure log-delivery mode (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) or a real +// email/SMS transport is wired (not yet — P6). Default false: no channel, so +// code issuance is refused and setup surfaces errTwoFADeliveryUnavailable +// instead of a silent dead-end. +func twoFADeliveryAvailable() bool { + return os.Getenv(twoFAAllowLogDeliveryEnv) == "true" +} + +// twoFAEnsureIssueAllowed reports whether a 2FA code may be issued in this +// deployment. Production requires BOTH a delivery channel and TWO_FACTOR_PEPPER: +// without a channel (no email/SMS, no TWO_FACTOR_ALLOW_LOG_DELIVERY=true) the +// code could never reach the user — issuing one would silently lock the user +// out of the enforced saved-card-payments gate; and without the pepper every +// stored code would be an offline-brute-forceable unsalted digest. Either way +// issuance is refused (fail-closed). Dev/test builds always allow issuance +// (twofa_dev.go). +func twoFAEnsureIssueAllowed() error { + if os.Getenv(twoFAPepperEnv) == "" { + return errTwoFAPepperRequired + } + if !twoFADeliveryAvailable() { + return errTwoFADeliveryUnavailable + } + return nil +} + +// twoFADeliverCode delivers a fresh verification code to the user. Production +// has no wired email/SMS transport (P6), so the ONLY channel is the operator's +// explicit, insecure opt-in to log delivery (TWO_FACTOR_ALLOW_LOG_DELIVERY=true +// — anyone with backend log access could defeat the 2FA gate on saved-card +// charges). WITHOUT that flag the plaintext code is NEVER written to the log; +// twoFAEnsureIssueAllowed already refused issuance, so this no-op is +// unreachable. With the flag set, the code is written to the [2FA] log line +// and an operator relays it to the user out-of-band, exactly like the +// documented dev flow — the operator has accepted the risk of log-based +// delivery. +func twoFADeliverCode(userID, label, code string) { + if os.Getenv(twoFAAllowLogDeliveryEnv) == "true" { + log.Printf("[2FA] verification code for user %s (%s): %s", userID, label, code) + } + // Otherwise: deliberate no-op — never log the plaintext code by default. +} diff --git a/backend/handlers/user/user_coverage_test.go b/backend/handlers/user/user_coverage_test.go index 3c96e17..f182885 100644 --- a/backend/handlers/user/user_coverage_test.go +++ b/backend/handlers/user/user_coverage_test.go @@ -16,6 +16,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "log" "net/http" @@ -27,6 +28,8 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -749,12 +752,42 @@ func TestDeleteAccount_DeletesSquareCustomerOnce(t *testing.T) { // DeleteAccountHandler — Square cleanup only after local tx commit // ============================================================================= +// failingTx wraps a real pgx.Tx and injects failures on configured operations, +// mirroring the db package's FailingTx (backend/db/error_injector_test.go); +// the db injector is test-only and not importable here. Begin returns self so +// db.Conn.Begin()'s nested tx stays on the wrapper and its Exec can fail at the +// delete_guest_user step, while the pre-tx QueryRow/Query delegate to the real +// tx. +type failingTx struct { + pgx.Tx + failExec bool +} + +func (f *failingTx) Begin(ctx context.Context) (pgx.Tx, error) { + return f, nil +} + +func (f *failingTx) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { + if f.failExec { + return pgconn.CommandTag{}, errors.New("simulated exec failure") + } + return f.Tx.Exec(ctx, sql, args...) +} + // TestDeleteAccount_SquareCleanupNotDispatchedOnTxFailure verifies the fix that // dispatches the Square cleanup goroutine only AFTER the local -// anonymization/deletion transaction commits: when the local tx fails (here an -// admin_notifications row references the guest with a RESTRICT FK, so -// delete_guest_user's DELETE FROM users errors), DeleteCardOnFile/DeleteCustomer -// must NOT be called — a failed local tx leaves external state intact for retry. +// anonymization/deletion transaction commits: when the local tx fails, +// DeleteCardOnFile/DeleteCustomer must NOT be called — a failed local tx leaves +// external state intact for retry. +// +// The tx failure is forced with the failingTx proxy instead of a seeded FK +// violation because delete_guest_user now NULLs every RESTRICT-FK user +// reference (admin_notifications.user_id, gift_card_transactions.user_id, +// gift_cards.redeemed_by/created_by, admin_audit_log.target_user_id) before +// DELETE FROM users, so the historical admin_notifications mechanism no longer +// errors. The proxy fails Exec precisely at `SELECT delete_guest_user($1)` — +// AFTER the pre-tx snapshot captured the card/customer — so the handler 500s +// with cleanup never dispatched. func TestDeleteAccount_SquareCleanupNotDispatchedOnTxFailure(t *testing.T) { savedSquareClient := payments.SquareClient rec := &recordingSquareClient{SquareClient: square.NewDevClient()} @@ -775,17 +808,13 @@ func TestDeleteAccount_SquareCleanupNotDispatchedOnTxFailure(t *testing.T) { `, guestID, "ccof:card_fail_tx") require.NoError(t, err) - // admin_notifications.user_id has a RESTRICT FK on users(id): seeding a row - // makes delete_guest_user's DELETE FROM users fail, so the local tx errors. - _, err = tx.Exec(ctx, ` - INSERT INTO admin_notifications (reason, user_id) - VALUES ('pending_booking', $1) - `, guestID) - require.NoError(t, err) + pgxTx, ok := tx.(pgx.Tx) + require.True(t, ok, "SetupTestTx must return a pgx.Tx") + reqCtx := db.ContextWithTx(context.Background(), &failingTx{Tx: pgxTx, failExec: true}) handler := http.HandlerFunc(DeleteAccountHandler) req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil) - req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, guestID)) + req = req.WithContext(context.WithValue(reqCtx, mw.UserIDKey, guestID)) w := httptest.NewRecorder() handler.ServeHTTP(w, req) diff --git a/backend/handlers/webhooks/square.go b/backend/handlers/webhooks/square.go index fa4923b..2cc78e1 100644 --- a/backend/handlers/webhooks/square.go +++ b/backend/handlers/webhooks/square.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "log" @@ -84,6 +85,51 @@ func (d *squareWebhookDedup) register(id string) bool { // (square_webhook_events) is the unbounded, restart-safe source of truth. var squareWebhookEventsSeen = newSquareWebhookDedup(500) +// errWebhookParseFailure marks a dispatch error caused by a KNOWN money event +// whose payload could not be parsed or extracted (as opposed to a DB failure). +// HandleSquareWebhook distinguishes it from other dispatch errors to log the +// failure at ERROR level with the event_id and type — and, like every dispatch +// error, it returns 5xx WITHOUT committing the dedup row, so Square re-delivers +// the event instead of the money state being lost forever. +var errWebhookParseFailure = errors.New("webhook payload parse failure") + +// isSquareMoneyFamily reports whether an event type belongs to a money-state +// family (payment.*, refund.*, dispute.*, terminal.*, plus money-adjacent +// cash_drawer.*, gift_card.* and transaction.*). Unknown events in these +// families MUST NOT be 200-acked — see the default-branch split in +// HandleSquareWebhook. +func isSquareMoneyFamily(eventType string) bool { + for _, prefix := range []string{ + "payment.", "refund.", "dispute.", "terminal.", + "cash_drawer.", "gift_card.", "transaction.", + } { + if strings.HasPrefix(eventType, prefix) { + return true + } + } + return false +} + +// isSquareNonMoneyFamily reports whether an event type belongs to a known +// non-money family this app will never process (customer.*, card.*, order.*, +// invoice.*, booking.* and the other Square families below, none of which +// carry money state this app tracks). These are deliberately acked 200 WITH a +// dedup row so Square stops retrying them — see the default-branch split in +// HandleSquareWebhook. +func isSquareNonMoneyFamily(eventType string) bool { + for _, prefix := range []string{ + "customer.", "card.", "order.", "invoice.", "booking.", + "appointment.", "availability.", "loyalty.", "merchant.", + "location.", "labor.", "inventory.", "site.", "device.", + "team_member.", "subscription.", "webhook.", + } { + if strings.HasPrefix(eventType, prefix) { + return true + } + } + return false +} + // webhookDBTimeout bounds the DB work performed while dispatching a webhook. // The work runs on a Background-derived context — so a client disconnect cannot // cancel it (the at-least-once delivery contract must survive) — but is @@ -140,7 +186,12 @@ func squareEnvironmentMismatch(headerEnv string) bool { // signature, 400 on malformed JSON or an empty event_id (which cannot be // deduplicated — Square always sends one, so this is defensive). A correctly // signed, well-formed event is deduplicated by event_id before dispatch and -// acknowledged 200. +// acknowledged 200. Unknown event types are split by family: money-state +// families (payment.*, refund.*, dispute.*, terminal.* and money-adjacent +// prefixes) and truly unknown prefixes get 501 (Square retries, no dedup row), +// while known non-money families (customer.*, card.*, order.*, invoice.*, +// booking.*, ...) are acked 200 WITH the dedup row so the subscription is +// never flooded into suspension. func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 512*1024) body, err := io.ReadAll(r.Body) @@ -245,9 +296,19 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) { // change at most once and is otherwise a no-op. var dispatchErr error switch event.Type { - case "payment.updated", "payment.created": + // Square fires payment.completed and payment.canceled as SEPARATE event + // types from payment.updated, but all of them carry the same full Payment + // object in data.object.payment, and handlePaymentUpdated reconciles purely + // from the payload's id/status (squarePaymentStatusToLocal maps COMPLETED/ + // CANCELED/FAILED). Routing all four here closes the silent gap where a + // completed or canceled charge was previously acked 200 and never + // reconciled against the local pending row. + case "payment.updated", "payment.created", "payment.completed", "payment.canceled": dispatchErr = handlePaymentUpdated(event.Data) - case "refund.updated", "refund.created": + // Same reasoning for refunds: refund.completed/refund.canceled are distinct + // event types carrying the full PaymentRefund object in data.object.refund, + // which handleRefundUpdated reconciles by status. + case "refund.updated", "refund.created", "refund.completed", "refund.canceled": dispatchErr = handleRefundUpdated(event.Data) case "dispute.created": dispatchErr = handleDisputeCreated(event.Data) @@ -258,10 +319,55 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) { case "terminal.checkout.created", "terminal.checkout.updated": dispatchErr = handleTerminalCheckout(event.Data) default: - log.Printf("[SQUARE-WEBHOOK] Unknown event type: %s", event.Type) + // Unknown-type handling is SPLIT by event family so Square's retry + // policy (it re-delivers 5xx responses) can never flood the + // subscription into suspension — a suspended subscription silently + // kills money-event delivery (payment.created/updated). + // + // • MONEY-STATE families (any payment.*, refund.*, dispute.*, + // terminal.*, cash_drawer.*, gift_card.*, transaction.* not + // explicitly handled above, plus future money-adjacent prefixes): + // keep 501 so Square retries. A money event this app does not yet + // handle is NEVER a safe 200-ack (the caller would commit the dedup + // row and the event would be dropped forever); no dedup row is + // written and a later retry re-dispatches idempotently if code + // support for the type lands before the retry window closes. + // • KNOWN NON-MONEY families (customer.*, card.*, order.*, invoice.*, + // booking.* — plus appointment.*, availability.*, loyalty.*, + // merchant.*, location.*, labor.*, inventory.*, site.*, device.*, + // team_member.*, subscription.*, webhook.* — and anything else + // Square can emit that this app will never process): deliberately + // acked 200 WITH the dedup row committed so Square stops retrying. + // They carry no money state this app tracks, so acking loses + // nothing — and retrying them at any regularity would fill the + // subscription's retry queue until Square suspends it, silently + // killing money-event delivery. Logged at WARN. + // • TRULY unknown prefixes (matching neither list): conservative + // 501 — the type could be a new money family Square just added. + switch { + case isSquareMoneyFamily(event.Type): + log.Printf("[SQUARE-WEBHOOK] CRITICAL: unhandled money-family Square event type %q (event_id=%s) — not acknowledged; returning 501 so Square retries", event.Type, event.EventID) + http.Error(w, "unhandled webhook event type", http.StatusNotImplemented) + return + case isSquareNonMoneyFamily(event.Type): + log.Printf("[SQUARE-WEBHOOK] WARNING: acknowledged unhandled non-money event %q (event_id=%s) — committed dedup row; Square stops retrying", event.Type, event.EventID) + default: + log.Printf("[SQUARE-WEBHOOK] CRITICAL: unhandled Square event type %q (event_id=%s) — not acknowledged; returning 501 so Square retries", event.Type, event.EventID) + http.Error(w, "unhandled webhook event type", http.StatusNotImplemented) + return + } } if dispatchErr != nil { - log.Printf("[SQUARE-WEBHOOK] Event %s (%s) dispatch failed: %v — NOT recording dedup row; Square will retry", event.Type, event.EventID, dispatchErr) + // A parse failure on a known money event is logged at ERROR with the + // event_id and type so operators can see exactly which delivery was + // unparseable and is being retried (rather than silently lost). Every + // dispatch error — parse failure or DB failure — returns 5xx WITHOUT + // committing the dedup row, so Square re-delivers the event. + if errors.Is(dispatchErr, errWebhookParseFailure) { + log.Printf("[SQUARE-WEBHOOK] ERROR: known money event %s (event_id=%s) payload failed to parse: %v — NOT recording dedup row; Square will retry", event.Type, event.EventID, dispatchErr) + } else { + log.Printf("[SQUARE-WEBHOOK] Event %s (%s) dispatch failed: %v — NOT recording dedup row; Square will retry", event.Type, event.EventID, dispatchErr) + } http.Error(w, "webhook processing failed", http.StatusServiceUnavailable) return } @@ -572,17 +678,22 @@ func handlePaymentUpdated(data json.RawMessage) error { var env squareWebhookData if err := json.Unmarshal(data, &env); err != nil { - log.Printf("[SQUARE-WEBHOOK] payment.updated received (payload length=%d)", len(data)) - return nil + // Parse failure on a known money event: the caller returns 5xx without + // committing the dedup row (errWebhookParseFailure), so Square + // re-delivers and the payment state change is not permanently lost. + return fmt.Errorf("payment event data envelope: %v: %w", err, errWebhookParseFailure) } - if env.ID == "" { - log.Printf("[SQUARE-WEBHOOK] payment.updated received (payload length=%d)", len(data)) + if len(env.Object) == 0 { + // Legacy envelope carrying only data.id — no nested payment object to + // reconcile, so there is nothing to apply; acknowledge as received. + log.Printf("[SQUARE-WEBHOOK] payment.updated received (data.id=%s)", env.ID) return nil } var payment squarePaymentPayload if !parseSquareObject(env.Object, "payment", &payment) || payment.ID == "" || payment.Status == "" { - log.Printf("[SQUARE-WEBHOOK] payment.updated received (data.id=%s)", env.ID) - return nil + // A payment object is present but unusable (malformed, or missing the + // id/status reconciliation needs): cannot apply money state — retry. + return fmt.Errorf("payment.updated payload for data.id=%q missing/invalid payment object (id=%q status=%q): %w", env.ID, payment.ID, payment.Status, errWebhookParseFailure) } localStatus, terminal := squarePaymentStatusToLocal(payment.Status) if !terminal { @@ -730,17 +841,19 @@ func handleRefundUpdated(data json.RawMessage) error { var env squareWebhookData if err := json.Unmarshal(data, &env); err != nil { - log.Printf("[SQUARE-WEBHOOK] refund.updated received (payload length=%d)", len(data)) - return nil + // Parse failure on a known money event: retry via 5xx, no dedup row. + return fmt.Errorf("refund event data envelope: %v: %w", err, errWebhookParseFailure) } - if env.ID == "" { - log.Printf("[SQUARE-WEBHOOK] refund.updated received (payload length=%d)", len(data)) + if len(env.Object) == 0 { + // Legacy envelope carrying only data.id — no nested refund object to + // reconcile; acknowledge as received. + log.Printf("[SQUARE-WEBHOOK] refund.updated received (data.id=%s)", env.ID) return nil } var refund squareRefundPayload if !parseSquareObject(env.Object, "refund", &refund) || refund.ID == "" || refund.Status == "" { - log.Printf("[SQUARE-WEBHOOK] refund.updated received (data.id=%s)", env.ID) - return nil + // A refund object is present but unusable: cannot apply money state — retry. + return fmt.Errorf("refund.updated payload for data.id=%q missing/invalid refund object (id=%q status=%q): %w", env.ID, refund.ID, refund.Status, errWebhookParseFailure) } localStatus, terminal := squareRefundStatusToLocal(refund.Status) if !terminal { @@ -804,13 +917,22 @@ func handleDisputeCreated(data json.RawMessage) error { var env squareWebhookData if err := json.Unmarshal(data, &env); err != nil { - log.Printf("[SQUARE-WEBHOOK] dispute.created received (payload length=%d)", len(data)) + // Parse failure on a known money event (chargeback): retry via 5xx, no + // dedup row — disputes have no sweep fallback, so a lost event is a + // silent money-loss path. + return fmt.Errorf("dispute event data envelope: %v: %w", err, errWebhookParseFailure) + } + if len(env.Object) == 0 { + // Legacy envelope carrying only data.id — no nested dispute object to + // record; acknowledge as received. + log.Printf("[SQUARE-WEBHOOK] dispute.created received (data.id=%s)", env.ID) return nil } var dispute squareDisputePayload if !parseSquareObject(env.Object, "dispute", &dispute) || dispute.ID == "" { - log.Printf("[SQUARE-WEBHOOK] dispute.created received (data.id=%s)", env.ID) - return nil + // A dispute object is present but unusable: cannot record the + // chargeback — retry. + return fmt.Errorf("dispute.created payload for data.id=%q missing/invalid dispute object (id=%q): %w", env.ID, dispute.ID, errWebhookParseFailure) } squarePaymentID := "" if dispute.DisputedPayment != nil { @@ -859,13 +981,21 @@ func handleDisputeStateUpdated(data json.RawMessage) error { var env squareWebhookData if err := json.Unmarshal(data, &env); err != nil { - log.Printf("[SQUARE-WEBHOOK] dispute.state.updated received (payload length=%d)", len(data)) + // Parse failure on a known money event (chargeback): retry via 5xx, no + // dedup row — disputes have no sweep fallback. + return fmt.Errorf("dispute event data envelope: %v: %w", err, errWebhookParseFailure) + } + if len(env.Object) == 0 { + // Legacy envelope carrying only data.id — no nested dispute object; + // acknowledge as received. + log.Printf("[SQUARE-WEBHOOK] dispute.state.updated received (data.id=%s)", env.ID) return nil } var dispute squareDisputePayload if !parseSquareObject(env.Object, "dispute", &dispute) || dispute.ID == "" { - log.Printf("[SQUARE-WEBHOOK] dispute.state.updated received (data.id=%s)", env.ID) - return nil + // A dispute object is present but unusable: cannot apply the state + // change — retry. + return fmt.Errorf("dispute.state.updated payload for data.id=%q missing/invalid dispute object (id=%q): %w", env.ID, dispute.ID, errWebhookParseFailure) } localStatus := squareDisputeStateToLocal(dispute.State) amount := squareMoneyToAmount(dispute.AmountMoney) @@ -879,7 +1009,19 @@ func handleDisputeStateUpdated(data json.RawMessage) error { // Row may already exist from dispute.created — recover its payment. paymentID, bookingID = findPaymentByDisputeID(ctx, dispute.ID) if paymentID == "" { - log.Printf("[SQUARE-WEBHOOK] dispute.state.updated: no local payment for dispute %s (square payment %q) — cannot record state %s", dispute.ID, squarePaymentID, dispute.State) + // Untracked chargeback: no local payments row for this Square + // charge AND no dispute row to recover one from. Mirror the + // dispute.created untracked branch — there is NO sweep fallback for + // disputes, so this critical notification is the only in-app trace + // the owner gets that Square is clawing back funds; a state.updated + // arriving without a prior created event must surface it too, never + // drop it silently. booking_id stays NULL; disputeNotificationID + // gives each DISTINCT dispute its own deterministic-id notification + // and makes this insert a no-op if dispute.created already raised + // it. Still return nil so the dedup row commits and Square's retry + // is acknowledged. + log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s state change (state %s) for square payment %q with NO local payment row — chargeback cannot be reconciled in-app — admin notified (booking_id NULL)", dispute.ID, dispute.State, squarePaymentID) + insertCriticalPaymentNotification(ctx, "", dispute.ID) return nil } } diff --git a/backend/handlers/webhooks/testmain_test.go b/backend/handlers/webhooks/testmain_test.go index 01d80ee..3f00a90 100644 --- a/backend/handlers/webhooks/testmain_test.go +++ b/backend/handlers/webhooks/testmain_test.go @@ -10,10 +10,29 @@ import ( "crussell/testutils/testdb" ) +// resetSquareWebhookEventsSeen clears the process-global in-memory webhook +// dedup cache (squareWebhookEventsSeen in square.go). square.go exposes no +// reset — the cache is a deliberate process-lifetime fast-path for handler +// dedup — so the test suite provides its own test-only accessor: the test files +// compile into the same package and can reach the unexported cache. Without a +// reset the suite is order-dependent: tests reuse event_ids (e.g. "evt_1") and +// a delivery from an earlier test would fast-path-drop a later test's delivery +// BEFORE dispatch, silently skipping the state mutation the later test asserts. +func resetSquareWebhookEventsSeen() { + squareWebhookEventsSeen.mu.Lock() + squareWebhookEventsSeen.seen = make(map[string]struct{}) + squareWebhookEventsSeen.order = make([]string, 0, squareWebhookEventsSeen.max) + squareWebhookEventsSeen.mu.Unlock() +} + func TestMain(m *testing.M) { pool := testdb.CreateTestDatabase("crussell_test_handlers_webhooks") db.Conn = db.NewPoolProxy(pool) testdb.SeedBaseline(pool) + // Start from a clean in-memory dedup cache: the handler's fast-path cache + // is a package global that would otherwise leak event_ids across tests + // (they share ids like "evt_1"), making the suite order-dependent. + resetSquareWebhookEventsSeen() code := m.Run() testdb.DestroyTestDatabase(pool, "crussell_test_handlers_webhooks") os.Exit(code) diff --git a/backend/handlers/webhooks/webhooks_round7_test.go b/backend/handlers/webhooks/webhooks_round7_test.go index d4e6e14..7a01c98 100644 --- a/backend/handlers/webhooks/webhooks_round7_test.go +++ b/backend/handlers/webhooks/webhooks_round7_test.go @@ -106,6 +106,22 @@ func countUnackedCriticalNotificationsForBooking(t *testing.T, bookingID string) return n } +// countUntrackedCriticalNotification returns the number of unacknowledged +// NULL-booking critical_payment_log notifications for a dispute's deterministic +// id (disputeNotificationID). +func countUntrackedCriticalNotification(t *testing.T, disputeID string) int { + t.Helper() + var n int + if err := db.Conn.QueryRow(context.Background(), ` + SELECT COUNT(*) FROM admin_notifications + WHERE id = $1 AND reason = 'critical_payment_log' + AND booking_id IS NULL AND acknowledged_at IS NULL + `, disputeNotificationID(disputeID)).Scan(&n); err != nil { + t.Fatalf("failed to count critical notifications for dispute %s: %v", disputeID, err) + } + return n +} + // ============================================================================= // (a) handleDisputeStateUpdated — findPaymentByDisputeID fallback // ============================================================================= @@ -163,14 +179,17 @@ func TestWebhook_DisputeStateUpdated_Lost_EmptyPaymentID_FallsBackToDisputeRow(t } } -// TestWebhook_DisputeStateUpdated_EmptyPaymentID_NoDisputeRow_NoMutation +// TestWebhook_DisputeStateUpdated_EmptyPaymentID_NoDisputeRow_RaisesCritical // covers the fallback's dead end: when neither the payload's (empty) Square -// payment id nor the disputes table yields a payment, the handler returns -// success WITHOUT mutating state — payment untouched, no disputes row, no -// notification — and still commits the dedup row (Square's retry is -// acknowledged 200, not re-dispatched forever). -func TestWebhook_DisputeStateUpdated_EmptyPaymentID_NoDisputeRow_NoMutation(t *testing.T) { - payID, bookingID := createWebhookTestBookingPayment(t) +// payment id nor the disputes table yields a payment, the chargeback is +// UNTRACKED — the handler raises the same critical_payment_log notification as +// dispute.created (booking_id NULL, the deterministic disputeNotificationID) so +// a state.updated arriving without a prior created event is never a silent +// money-loss path. No disputes row is written, the payment row is untouched, +// and the dedup row still commits (Square's retry is acknowledged 200, not +// re-dispatched forever). +func TestWebhook_DisputeStateUpdated_EmptyPaymentID_NoDisputeRow_RaisesCritical(t *testing.T) { + payID, _ := createWebhookTestBookingPayment(t) event := SquareWebhookEvent{ Type: "dispute.state.updated", @@ -205,9 +224,12 @@ func TestWebhook_DisputeStateUpdated_EmptyPaymentID_NoDisputeRow_NoMutation(t *t if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected payment untouched ('completed') when the fallback finds no dispute, got %q", got) } - // No critical notification: the handler returns before any insert. - if n := countUnackedCriticalNotificationsForBooking(t, bookingID); n != 0 { - t.Errorf("expected NO critical notification when the fallback finds no dispute, got %d", n) + // The untracked chargeback MUST still surface in-app: exactly one + // unacknowledged NULL-booking critical notification under the dispute's + // deterministic id — the same contract as dispute.created's untracked + // branch. + if got := countUntrackedCriticalNotification(t, "dts_no_dispute_row_1"); got != 1 { + t.Errorf("expected 1 unacknowledged NULL-booking critical notification for the untracked dispute, got %d", got) } if n := countWebhookEvents(t, event.EventID); n != 1 { t.Errorf("expected 1 dedup row (the no-op dispatch still commits), got %d", n) diff --git a/backend/handlers/webhooks/webhooks_state_test.go b/backend/handlers/webhooks/webhooks_state_test.go index 1cce9db..cc5ec0e 100644 --- a/backend/handlers/webhooks/webhooks_state_test.go +++ b/backend/handlers/webhooks/webhooks_state_test.go @@ -801,6 +801,44 @@ func TestWebhook_PaymentUpdated_DoesNotRevertRefunded(t *testing.T) { } } +// TestWebhook_PaymentUpdated_Completed_RescuesPendingTillSale verifies the +// real-time counterpart of the stale-pending sweep's till rescue: a +// payment.updated carrying COMPLETED flips a PENDING till_sale funded by that +// Square charge to completed (square.go's +// `UPDATE till_sales SET status='completed' WHERE square_payment_id=$2 AND status='pending'`). +// A regression dropping the till_sales reconcile from handlePaymentUpdated +// would leave gift-card/retail till sales stuck pending until the next sweep. +func TestWebhook_PaymentUpdated_Completed_RescuesPendingTillSale(t *testing.T) { + const squarePaymentID = "sqp_updated_till_rescue" + saleID := createWebhookTestTillSale(t, squarePaymentID, "gift_card", nil) + + event := SquareWebhookEvent{ + Type: "payment.updated", + EventID: "evt_payment_updated_till_rescue_1", + CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{ + "type": "payment", + "id": "` + squarePaymentID + `", + "object": { + "payment": { + "id": "` + squarePaymentID + `", + "status": "COMPLETED" + } + } + }`), + } + w := deliverWebhook(t, event) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if got := getTillSaleStatus(t, saleID); got != "completed" { + t.Errorf("expected pending till sale 'completed' after COMPLETED payment.updated, got %q", got) + } + if n := countWebhookEvents(t, event.EventID); n != 1 { + t.Errorf("expected 1 dedup row, got %d", n) + } +} + func TestWebhook_PaymentUpdated_IdempotentReplay(t *testing.T) { const squarePaymentID = "sqp_updated_idem" payID := createWebhookTestPayment(t, squarePaymentID, "pending") @@ -977,3 +1015,78 @@ func TestWebhook_RefundUpdated_DoesNotDemoteCompleted(t *testing.T) { t.Errorf("expected completed refund to stay 'completed', got %q", got) } } + +// ============================================================================= +// Event-type aliases — payment.created / refund.created route to the updated +// handlers (square.go switch cases) +// ============================================================================= + +// TestWebhook_EventTypeAliases_RouteToUpdatedHandlers locks the switch aliases: +// payment.created and refund.created (Square's distinct event types for the +// creation of a payment/refund) must route to the SAME handlers as +// payment.updated / refund.updated — they carry the identical +// data.object.payment / data.object.refund envelope and must reconcile state +// identically. A regression dropping the aliases from the switch would send +// these events to the 501 default branch, silently visible only as missing +// state mutations. +func TestWebhook_EventTypeAliases_RouteToUpdatedHandlers(t *testing.T) { + const sqPayID = "sqp_alias_pay" + payID := createWebhookTestPayment(t, sqPayID, "pending") + + payEvent := SquareWebhookEvent{ + Type: "payment.created", + EventID: "evt_alias_payment_created_1", + CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{ + "type": "payment", + "id": "` + sqPayID + `", + "object": { + "payment": { + "id": "` + sqPayID + `", + "status": "COMPLETED" + } + } + }`), + } + w := deliverWebhook(t, payEvent) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for payment.created, got %d: %s", w.Code, w.Body.String()) + } + if got := getPaymentStatus(t, payID); got != "completed" { + t.Errorf("expected payment.created to flip payment to 'completed' (alias of payment.updated), got %q", got) + } + if n := countWebhookEvents(t, payEvent.EventID); n != 1 { + t.Errorf("expected 1 dedup row for payment.created, got %d", n) + } + + const sqPayForRefund = "sqp_alias_refund" + payForRefund := createWebhookTestPayment(t, sqPayForRefund, "completed") + refundID := createWebhookTestRefund(t, payForRefund, "sqr_alias_refund", "pending") + + refundEvent := SquareWebhookEvent{ + Type: "refund.created", + EventID: "evt_alias_refund_created_1", + CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{ + "type": "refund", + "id": "sqr_alias_refund", + "object": { + "refund": { + "id": "sqr_alias_refund", + "status": "COMPLETED", + "payment_id": "` + sqPayForRefund + `" + } + } + }`), + } + w2 := deliverWebhook(t, refundEvent) + if w2.Code != http.StatusOK { + t.Fatalf("expected 200 for refund.created, got %d: %s", w2.Code, w2.Body.String()) + } + if got := getRefundStatus(t, refundID); got != "completed" { + t.Errorf("expected refund.created to flip refund to 'completed' (alias of refund.updated), got %q", got) + } + if n := countWebhookEvents(t, refundEvent.EventID); n != 1 { + t.Errorf("expected 1 dedup row for refund.created, got %d", n) + } +} diff --git a/backend/handlers/webhooks/webhooks_test.go b/backend/handlers/webhooks/webhooks_test.go index d018498..577a6cd 100644 --- a/backend/handlers/webhooks/webhooks_test.go +++ b/backend/handlers/webhooks/webhooks_test.go @@ -15,6 +15,8 @@ import ( "net/http/httptest" "os" "strings" + "sync" + "sync/atomic" "testing" "time" "unicode/utf8" @@ -253,18 +255,103 @@ func TestHandleSquareWebhook_DisputeCreated(t *testing.T) { } } +// TestHandleSquareWebhook_UnknownEventType verifies a signed event whose type +// prefix matches NEITHER the handled switch cases NOR a known family +// (e.g. frobnicator.created) is NOT acknowledged 200. The handler returns 501 +// Not Implemented with body "unhandled webhook event type", so Square's retry +// policy re-delivers the event and it stays visible in the delivery history — +// the event is surfaced and retried, never silently acked. No dedup row is +// written on the 501 path, so a retry (or a future code path supporting the +// type) is always re-dispatched. func TestHandleSquareWebhook_UnknownEventType(t *testing.T) { event := SquareWebhookEvent{ - Type: "invoice.created", + Type: "frobnicator.created", EventID: "evt_unknown_1", CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{"id":"frob_1"}`), + } + body, _ := json.Marshal(event) + sig := webhookTestEnv(t, body) + w := makeWebhookRequest(body, sig, context.Background()) + if w.Code != http.StatusNotImplemented { + t.Errorf("expected 501 Not Implemented for unknown event type, got %d. body: %s", w.Code, w.Body.String()) + } + if got := strings.TrimSpace(w.Body.String()); got != "unhandled webhook event type" { + t.Errorf("expected body 'unhandled webhook event type', got %q", w.Body.String()) + } + // The 501 path returns before the dedup commit (like the dispatch-error + // path): Square must be allowed to retry, so no dedup row may be persisted. + if n := countWebhookEvents(t, event.EventID); n != 0 { + t.Errorf("expected no dedup row for an unhandled event type (Square must retry), got %d", n) + } +} + +// TestHandleSquareWebhook_KnownNonMoneyEvent_Acknowledged verifies a signed +// event from a KNOWN NON-MONEY family this app will never process +// (e.g. invoice.created) is deliberately acknowledged 200 WITH a committed +// dedup row so Square stops retrying it. These events carry no money state the +// app tracks, so acking loses nothing — and retrying them would fill the +// subscription's retry queue until Square suspends it, silently killing +// money-event delivery (payment.created/updated). The ack is logged at WARN. +func TestHandleSquareWebhook_KnownNonMoneyEvent_Acknowledged(t *testing.T) { + event := SquareWebhookEvent{ + Type: "invoice.created", + EventID: "evt_nonmoney_1", + CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{"id":"inv_1"}`), } body, _ := json.Marshal(event) sig := webhookTestEnv(t, body) + + var buf bytes.Buffer + oldOutput := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(oldOutput) + w := makeWebhookRequest(body, sig, context.Background()) if w.Code != http.StatusOK { - t.Errorf("expected 200 for unknown event type, got %d. body: %s", w.Code, w.Body.String()) + t.Errorf("expected 200 for acknowledged non-money event, got %d. body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "ok" { + t.Errorf("expected body 'ok', got %q", w.Body.String()) + } + // Exactly one dedup row is committed so Square stops retrying. + if n := countWebhookEvents(t, event.EventID); n != 1 { + t.Errorf("expected exactly 1 dedup row for an acknowledged non-money event (Square stops retrying), got %d", n) + } + out := buf.String() + if !strings.Contains(out, "WARNING: acknowledged unhandled non-money event") { + t.Errorf("expected WARN ack log for non-money event, got:\n%s", out) + } +} + +// TestHandleSquareWebhook_UnhandledMoneyEvent_NotAcknowledged verifies a +// MONEY-STATE family event that is NOT one of the explicitly handled switch +// cases (e.g. payment.checkout_offer_created) is NOT acknowledged 200: the +// handler returns 501 with body "unhandled webhook event type" and writes NO +// dedup row, so Square's retry re-delivers it. A money event this app does not +// yet handle is never a safe 200-ack — the caller would commit the dedup row +// and the event would be dropped forever. +func TestHandleSquareWebhook_UnhandledMoneyEvent_NotAcknowledged(t *testing.T) { + event := SquareWebhookEvent{ + Type: "payment.checkout_offer_created", + EventID: "evt_money_unhandled_1", + CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{"id":"pco_1"}`), + } + body, _ := json.Marshal(event) + sig := webhookTestEnv(t, body) + w := makeWebhookRequest(body, sig, context.Background()) + if w.Code != http.StatusNotImplemented { + t.Errorf("expected 501 Not Implemented for unhandled money-family event, got %d. body: %s", w.Code, w.Body.String()) + } + if got := strings.TrimSpace(w.Body.String()); got != "unhandled webhook event type" { + t.Errorf("expected body 'unhandled webhook event type', got %q", w.Body.String()) + } + // The 501 path returns before the dedup commit: the event must be retried, + // so no dedup row may be persisted. + if n := countWebhookEvents(t, event.EventID); n != 0 { + t.Errorf("expected no dedup row for an unhandled money-family event (Square must retry), got %d", n) } } @@ -320,8 +407,13 @@ func TestHandleSquareWebhook_BodyTooLarge(t *testing.T) { } func TestHandleSquareWebhook_ValidSignatureWithEnvKey(t *testing.T) { - - body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) + // Well-formed payment.updated event carrying the full nested payment object + // (data.object.payment with id/status) so handlePaymentUpdated can parse + // and dispatch it — a known money event whose payload fails to parse + // returns 503 without a dedup row (Square retries), which is NOT this + // test's intent. The unique event_id and square_payment_id avoid colliding + // with the other tests' dedup rows and payment fixtures. + body := []byte(`{"type":"payment.updated","event_id":"evt_envkey_1","created_at":"2025-01-01T00:00:00Z","data":{"object":{"payment":{"id":"sqp_env_key_1","status":"COMPLETED","amount_money":{"amount":5000,"currency":"GBP"},"updated_at":"2025-01-01T00:00:00Z"}}}}`) key := "env-signing-key" notificationURL := "http://localhost:8080/webhooks/square" @@ -588,6 +680,164 @@ func TestHandleSquareWebhook_DedupPersistsAcrossRestart(t *testing.T) { } } +// webhookConcurrentTestSeq gives each invocation of +// TestHandleSquareWebhook_ConcurrentSameEvent_Serialized a distinct +// square_payment_id + event_id, so repeated in-process runs (-count>1, or a +// replay in the same suite) cannot collide on the process-global dedup cache +// or the shared test DB — the suite is order-independent. +var webhookConcurrentTestSeq atomic.Int64 + +// TestHandleSquareWebhook_ConcurrentSameEvent_Serialized verifies the in-memory +// fast-path dedup (squareWebhookEventsSeen) plus the DB ON CONFLICT (event_id) +// insert serialize CONCURRENT delivery of the same signed event_id. Two +// goroutines hitting the handler with the same payload must produce exactly one +// persisted dedup row, exactly one "already processed" short-circuit (either +// the in-memory 'skipping' fast-path or the DB-conflict 'acknowledging' +// branch), and exactly one state mutation — the status-guarded UPDATE applies +// once and the second delivery's UPDATE is a 0-row no-op. +func TestHandleSquareWebhook_ConcurrentSameEvent_Serialized(t *testing.T) { + seq := webhookConcurrentTestSeq.Add(1) + squarePaymentID := fmt.Sprintf("sqp_concurrent_same_%d", seq) + eventID := fmt.Sprintf("evt_concurrent_same_%d", seq) + payID := createWebhookTestPayment(t, squarePaymentID, "pending") + + event := SquareWebhookEvent{ + Type: "payment.updated", + EventID: eventID, + CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{ + "type": "payment", + "id": "` + squarePaymentID + `", + "object": { + "payment": { + "id": "` + squarePaymentID + `", + "status": "COMPLETED" + } + } + }`), + } + body, err := json.Marshal(event) + if err != nil { + t.Fatalf("failed to marshal webhook event: %v", err) + } + sig := webhookTestEnv(t, body) + + var buf bytes.Buffer + oldOutput := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(oldOutput) + + var wg sync.WaitGroup + codes := make([]int, 2) + for i := range codes { + wg.Add(1) + go func(idx int) { + defer wg.Done() + codes[idx] = makeWebhookRequest(body, sig, context.Background()).Code + }(i) + } + wg.Wait() + + for i, code := range codes { + if code != http.StatusOK { + t.Errorf("delivery %d: expected 200, got %d", i, code) + } + } + if got := getPaymentStatus(t, payID); got != "completed" { + t.Errorf("expected payment 'completed' after concurrent delivery, got %q", got) + } + if n := countWebhookEvents(t, event.EventID); n != 1 { + t.Errorf("expected exactly 1 persisted dedup row after concurrent delivery, got %d", n) + } + out := buf.String() + // Exactly one of the two deliveries hit an "already processed" path; the + // other processed fresh (both may still return 200). + if got := strings.Count(out, "already processed"); got != 1 { + t.Errorf("expected exactly 1 'already processed' short-circuit, got %d:\n%s", got, out) + } + // The status-guarded UPDATE applied exactly once — the second delivery's + // UPDATE matched 0 rows (already 'completed') and logged nothing. + if got := strings.Count(out, "→ local status completed"); got != 1 { + t.Errorf("expected exactly 1 state-mutation log line, got %d:\n%s", got, out) + } +} + +// TestHandleSquareWebhook_DedupCacheEviction_Redispatches documents why the +// 500-slot in-memory dedup cache cannot permanently hide an event across the +// test suite (or a long-lived process): once an event_id is evicted from the +// bounded cache, a replayed delivery is treated as NEW and re-dispatched — the +// DB dedup row is what actually absorbs it. Concretely: process the event +// (registers it + writes the dedup row), flood the cache past its 500-entry +// cap so the event is evicted, then replay it — the handler re-dispatches (the +// "Received event" log appears again) and the DB ON CONFLICT keeps the dedup +// row at exactly 1. +func TestHandleSquareWebhook_DedupCacheEviction_Redispatches(t *testing.T) { + resetSquareWebhookEventsSeen() + defer resetSquareWebhookEventsSeen() + + const squarePaymentID = "sqp_dedup_eviction" + payID := createWebhookTestPayment(t, squarePaymentID, "pending") + + event := SquareWebhookEvent{ + Type: "payment.updated", + EventID: "evt_dedup_eviction_target", + CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{ + "type": "payment", + "id": "` + squarePaymentID + `", + "object": { + "payment": { + "id": "` + squarePaymentID + `", + "status": "COMPLETED" + } + } + }`), + } + body, _ := json.Marshal(event) + sig := webhookTestEnv(t, body) + + var buf bytes.Buffer + oldOutput := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(oldOutput) + + w := makeWebhookRequest(body, sig, context.Background()) + if w.Code != http.StatusOK { + t.Fatalf("expected first delivery 200, got %d. body: %s", w.Code, w.Body.String()) + } + if n := countWebhookEvents(t, event.EventID); n != 1 { + t.Fatalf("expected 1 dedup row after first delivery, got %d", n) + } + + // Flood the 500-slot cache so the target event_id is evicted. + for i := 0; i < squareWebhookEventsSeen.max; i++ { + squareWebhookEventsSeen.register(fmt.Sprintf("evt_eviction_fill_%d", i)) + } + if squareWebhookEventsSeen.has(event.EventID) { + t.Fatal("expected target event_id to be evicted from the 500-slot cache") + } + + // Replay: evicted from the fast-path cache, so it is re-dispatched. The DB + // row already exists, so the ON CONFLICT insert keeps the count at 1. + w2 := makeWebhookRequest(body, sig, context.Background()) + if w2.Code != http.StatusOK { + t.Fatalf("expected replay 200, got %d. body: %s", w2.Code, w2.Body.String()) + } + if w2.Body.String() != "ok" { + t.Errorf("expected replay body 'ok', got %q", w2.Body.String()) + } + out := buf.String() + if got := strings.Count(out, "Received event: payment.updated"); got != 2 { + t.Errorf("expected the evicted event to be re-dispatched (2 'Received event' lines), got %d:\n%s", got, out) + } + if n := countWebhookEvents(t, event.EventID); n != 1 { + t.Errorf("expected still exactly 1 dedup row after re-dispatch, got %d", n) + } + if got := getPaymentStatus(t, payID); got != "completed" { + t.Errorf("expected payment 'completed', got %q", got) + } +} + // TestHandleSquareWebhook_DedupInsertFails_FailsClosed verifies the handler // rejects (503) when the dedup INSERT cannot be persisted, so Square retries. func TestHandleSquareWebhook_DedupInsertFails_FailsClosed(t *testing.T) { diff --git a/backend/internal/jobs/cleanup.go b/backend/internal/jobs/cleanup.go index 4e71533..126b608 100644 --- a/backend/internal/jobs/cleanup.go +++ b/backend/internal/jobs/cleanup.go @@ -2,6 +2,7 @@ package jobs import ( "context" + "database/sql" "errors" "fmt" "log" @@ -14,6 +15,7 @@ import ( "crussell/handlers/payments" "crussell/handlers/scheduling" "crussell/handlers/user" + "crussell/internal/square" "crussell/mw" "github.com/jackc/pgx/v5" @@ -247,6 +249,22 @@ func RegisterAll(s *Scheduler) { Concurrency: 1, Handler: ScanCriticalPaymentLogs, }) + + // Durable safety net for the GDPR account-deletion Square outbox (Fault + // A1): retries the Square card/customer deletions that the + // DeleteAccountHandler async cleanup could not finish (process crash or + // exhausted retries), using the outbox rows the handler persisted inside + // its anonymization tx before it committed. Hourly — the async goroutine + // handles the common case within seconds, so this only catches stragglers. + // The schedule is deliberately unshared so a long run cannot contend with + // the payment sweeps. + s.Register(Job{ + Name: "retry-square-erasures", + Schedule: "17 * * * *", // Hourly at :17 + Timeout: 30 * time.Second, + Concurrency: 1, + Handler: RetryPendingSquareErasures, + }) } // SweepSquareWebhookEvents deletes square_webhook_events rows older than 90 @@ -347,3 +365,185 @@ func ScanCriticalPaymentLogs(ctx context.Context) (int, error) { } return n, nil } + +// erasureNotificationKey derives the notification-dedup key for a pending +// outbox row: the user id when the row still carries one (registered users +// whose deletion tx has not yet run), otherwise a stable row-scoped key (guest +// rows are unlinked by delete_guest_user and the account-deletion outbox +// NULLs user_id on the rows it writes). +func erasureNotificationKey(userID sql.NullString, rowID string) string { + if userID.Valid && userID.String != "" { + return userID.String + } + return "row:" + rowID +} + +// raiseErasureNotification raises the deduped critical notification once per +// key. Repeated failures across job runs collapse to a single alert (the +// deterministic admin_notifications id in +// user.InsertSquareErasureCriticalNotification does the ON CONFLICT dedup). +func raiseErasureNotification(ctx context.Context, key string, notified map[string]bool) { + if notified[key] { + return + } + notified[key] = true + user.InsertSquareErasureCriticalNotification(ctx, key) +} + +// RetryPendingSquareErasures is the durable safety net for the account-deletion +// Square outbox (Fault A1). DeleteAccountHandler persists the Square +// card/customer erasure targets on the scrubbed, soft-deleted user_saved_cards +// rows (last_4 = 'XXXX') inside its anonymization transaction, before it +// commits. If the process crashes between that commit and the async cleanup +// goroutine finishing, those rows are the only remaining record of the +// Square-side PII — this job finds them and retries the Square deletion so the +// card/customer is never permanently orphaned at Square. On success (or a +// Square NOT_FOUND — the data is already gone) it drains the outbox columns; on +// final failure it raises a critical payment notification, deduped per affected +// user/row. Returns the number of outbox rows drained. +func RetryPendingSquareErasures(ctx context.Context) (int, error) { + if payments.SquareClient == nil { + // Square not configured: no external erasure is possible, and the + // handler only writes outbox entries when a client was configured. + return 0, nil + } + client := payments.SquareClient + + rows, err := db.Conn.Query(ctx, ` + SELECT id, user_id, square_card_id, square_customer_id + FROM user_saved_cards + WHERE deleted_at IS NOT NULL + AND last_4 = 'XXXX' + AND (square_card_id IS NOT NULL OR square_customer_id IS NOT NULL) + ORDER BY id + `) + if err != nil { + return 0, fmt.Errorf("failed to query pending Square erasures: %w", err) + } + defer rows.Close() + + type pendingErasure struct { + rowID string + userID sql.NullString + cardID sql.NullString + customerID sql.NullString + } + var pending []pendingErasure + for rows.Next() { + var p pendingErasure + if err := rows.Scan(&p.rowID, &p.userID, &p.cardID, &p.customerID); err != nil { + return 0, fmt.Errorf("failed to scan pending Square erasure: %w", err) + } + pending = append(pending, p) + } + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("failed to iterate pending Square erasures: %w", err) + } + if len(pending) == 0 { + return 0, nil + } + + // Group the outbox rows: each card id maps to exactly one row (per-user + // UNIQUE), each customer id may map to several rows (shared across the + // user's saved cards) and may also appear on other deleted accounts' rows. + cardRows := map[string][]pendingErasure{} + customerRows := map[string][]pendingErasure{} + for _, p := range pending { + if p.cardID.Valid && p.cardID.String != "" { + cardRows[p.cardID.String] = append(cardRows[p.cardID.String], p) + } + if p.customerID.Valid && p.customerID.String != "" { + customerRows[p.customerID.String] = append(customerRows[p.customerID.String], p) + } + } + + notified := map[string]bool{} + drained := map[string]bool{} + + // Cards: each ccof: token is erased once. NOT_FOUND means Square no longer + // has the card — the erasure is complete, so the outbox is drained rather + // than alerted on. + for cardID, cardPend := range cardRows { + rowID := cardPend[0].rowID + err := user.RetrySquareDeletion(ctx, func(actx context.Context) error { + return client.DeleteCardOnFile(actx, cardID) + }) + if err != nil && !square.IsNotFound(err) { + raiseErasureNotification(ctx, erasureNotificationKey(cardPend[0].userID, rowID), notified) + log.Printf("Error: retry-square-erasures failed to delete Square card %s (outbox row %s): %v", square.TokenPrefix(cardID), rowID, err) + slog.Error("square card erasure retry failed after attempts", "row", rowID, "card", square.TokenPrefix(cardID), "error", err) + continue + } + if _, err := db.Conn.Exec(ctx, ` + UPDATE user_saved_cards SET square_card_id = NULL + WHERE id = $1 AND deleted_at IS NOT NULL + `, rowID); err != nil { + return 0, fmt.Errorf("failed to clear card erasure outbox row %s: %w", rowID, err) + } + drained[rowID] = true + } + + // Customers: one DeleteCustomer per distinct id, guarded by the + // still-referenced-by-another-account check (a shared Square customer must + // survive while any active card of another account references it). + for customerID, custPend := range customerRows { + stillReferenced := false + for _, p := range custPend { + var ref bool + if err := db.Conn.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM user_saved_cards + WHERE square_customer_id = $1 AND deleted_at IS NULL + AND user_id IS DISTINCT FROM $2 + ) + `, customerID, p.userID).Scan(&ref); err != nil { + return 0, fmt.Errorf("failed to check Square customer %s references before deletion: %w", square.TokenPrefix(customerID), err) + } + if ref { + stillReferenced = true + break + } + } + if stillReferenced { + // Deliberately kept (shared customer) — not a pending erasure. + // Drain the outbox rows so the job stops retrying a deletion that + // must not happen; the customer is erased when the last referencing + // account is itself erased. + for _, p := range custPend { + if _, err := db.Conn.Exec(ctx, ` + UPDATE user_saved_cards SET square_customer_id = NULL + WHERE id = $1 AND deleted_at IS NOT NULL + `, p.rowID); err != nil { + return 0, fmt.Errorf("failed to clear skipped customer erasure outbox row %s: %w", p.rowID, err) + } + drained[p.rowID] = true + } + continue + } + err := user.RetrySquareDeletion(ctx, func(actx context.Context) error { + return client.DeleteCustomer(actx, customerID) + }) + if err != nil && !square.IsNotFound(err) { + for _, p := range custPend { + raiseErasureNotification(ctx, erasureNotificationKey(p.userID, p.rowID), notified) + } + log.Printf("Error: retry-square-erasures failed to delete Square customer %s (%d outbox rows): %v", square.TokenPrefix(customerID), len(custPend), err) + slog.Error("square customer erasure retry failed after attempts", "customer", square.TokenPrefix(customerID), "rows", len(custPend), "error", err) + continue + } + for _, p := range custPend { + if _, err := db.Conn.Exec(ctx, ` + UPDATE user_saved_cards SET square_customer_id = NULL + WHERE id = $1 AND deleted_at IS NOT NULL + `, p.rowID); err != nil { + return 0, fmt.Errorf("failed to clear customer erasure outbox row %s: %w", p.rowID, err) + } + drained[p.rowID] = true + } + } + + if n := len(drained); n > 0 { + log.Printf("[ERASURE] retry-square-erasures drained %d pending Square erasure outbox row(s)", n) + } + return len(drained), nil +} diff --git a/backend/internal/jobs/scheduler_test.go b/backend/internal/jobs/scheduler_test.go index 5fda107..3257866 100644 --- a/backend/internal/jobs/scheduler_test.go +++ b/backend/internal/jobs/scheduler_test.go @@ -413,8 +413,8 @@ func TestRegisterAll_RegistersExpectedJobs(t *testing.T) { s := New() RegisterAll(s) - if got := len(s.registry); got != 25 { - t.Fatalf("RegisterAll() registered %d jobs, want 25", got) + if got := len(s.registry); got != 26 { + t.Fatalf("RegisterAll() registered %d jobs, want 26", got) } registered := make(map[string]Job, len(s.registry)) @@ -493,6 +493,7 @@ func expectedJobNames() map[string]bool { "sweep-square-webhook-events": true, "apply-default-hours": true, "scan-critical-payment-logs": true, + "retry-square-erasures": true, } } diff --git a/backend/internal/s3/s3.go b/backend/internal/s3/s3.go index 221621e..fdb471f 100644 --- a/backend/internal/s3/s3.go +++ b/backend/internal/s3/s3.go @@ -12,6 +12,11 @@ import ( var Client Uploader +// FallbackToInMemory mirrors the dev-build flag so main.go can reference it +// in non-dev builds. It is always false here: the production/R2 build never +// uses the in-memory fallback client. +var FallbackToInMemory bool + type Uploader interface { Upload(ctx context.Context, bucket, key string, body io.Reader, contentType string) error Download(ctx context.Context, bucket, key string, w io.Writer) error diff --git a/backend/internal/s3/s3_dev.go b/backend/internal/s3/s3_dev.go index d2be1d2..87f6ec7 100644 --- a/backend/internal/s3/s3_dev.go +++ b/backend/internal/s3/s3_dev.go @@ -4,6 +4,7 @@ package s3 import ( "context" + "errors" "fmt" "io" "log" @@ -14,10 +15,17 @@ import ( awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/smithy-go" ) var Client Uploader +// FallbackToInMemory reports whether the active S3 client is the in-memory +// fallback (placeholder cdn.example.com URLs, data lost on restart). It is +// declared here (dev build) and in s3.go (prod build, always false) so +// main.go can surface it from the health endpoint in both build variants. +var FallbackToInMemory bool + type Uploader interface { Upload(ctx context.Context, bucket, key string, body io.Reader, contentType string) error Download(ctx context.Context, bucket, key string, w io.Writer) error @@ -79,7 +87,27 @@ func (m *inMemS3) HealthCheck(_ context.Context) error { return nil } +// isMissingBucket reports whether err is an S3 API error indicating the +// bucket itself does not exist (404 NoSuchBucket / NotFound) rather than a +// connectivity failure. The SDK v2 surfaces HeadBucket 404s as smithy.APIError +// implementations (*types.NotFound / *types.NoSuchBucket, or a generic API +// error); connection-level failures (refused, timeout, DNS, AccessDenied) do +// not implement smithy.APIError and return false. +func isMissingBucket(err error) bool { + var apiErr smithy.APIError + if !errors.As(err, &apiErr) { + return false + } + switch apiErr.ErrorCode() { + case "NoSuchBucket", "NotFound": + return true + default: + return false + } +} + func Connect() error { + FallbackToInMemory = false // Check for RUSTFS_* vars first (matching compose.yml), fall back to S3_* vars endpoint := os.Getenv("RUSTFS_ENDPOINT") if endpoint == "" { @@ -139,23 +167,55 @@ func Connect() error { ) if err != nil { log.Printf("S3: AWS config failed (%v) — falling back to in-memory S3", err) + log.Printf("S3 WARNING: portfolio/photo uploads will use in-memory storage with placeholder URLs (cdn.example.com) and data is LOST on restart") + FallbackToInMemory = true Client = &inMemS3{} return nil } - // Attempt RUSTFS/S3 connection; fall back to in-memory on any failure. + // Attempt RUSTFS/S3 connection; fall back to in-memory on genuine + // server-unreachability only. ctx := context.Background() s3Raw := s3.NewFromConfig(awsCfg, func(o *s3.Options) { o.BaseEndpoint = aws.String(endpoint) o.UsePathStyle = true }) - // Verify connectivity with a HeadBucket call before committing. + // Create the primary bucket before probing: a fresh data volume has no + // bucket yet, so a pre-probe HeadBucket 404 must not be mistaken for an + // unreachable server. Creation errors are non-fatal. + _, err = s3Raw.CreateBucket(ctx, &s3.CreateBucketInput{ + Bucket: aws.String(bucket), + }) + if err != nil { + log.Printf("Bucket creation: %v (may already exist)", err) + } + + // Create the profile-pics bucket first too, so a fresh volume gets both + // buckets before the connectivity probe runs. + if profilePicsBucket != bucket { + _, err = s3Raw.CreateBucket(ctx, &s3.CreateBucketInput{ + Bucket: aws.String(profilePicsBucket), + }) + if err != nil { + log.Printf("Profile pics bucket creation: %v (may already exist)", err) + } + } + + // Connectivity probe. A missing bucket (404 NoSuchBucket/NotFound) must + // never trigger the in-memory fallback — it would persist placeholder + // URLs; only connection-level failures fall back. _, err = s3Raw.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)}) if err != nil { - log.Printf("S3: RUSTFS not reachable at %s (%v) — falling back to in-memory S3", endpoint, err) - Client = &inMemS3{} - return nil + if isMissingBucket(err) { + log.Printf("S3: bucket %q still missing after CreateBucket (%v) — keeping the real client; uploads may fail until the bucket exists", bucket, err) + } else { + log.Printf("S3: RUSTFS not reachable at %s (%v) — falling back to in-memory S3", endpoint, err) + log.Printf("S3 WARNING: portfolio/photo uploads will use in-memory storage with placeholder URLs (cdn.example.com) and data is LOST on restart") + FallbackToInMemory = true + Client = &inMemS3{} + return nil + } } Client = &S3Client{ @@ -164,14 +224,6 @@ func Connect() error { publicURL: publicURL, } - // Create bucket if it doesn't exist - _, err = s3Raw.CreateBucket(ctx, &s3.CreateBucketInput{ - Bucket: aws.String(bucket), - }) - if err != nil { - log.Printf("Bucket creation: %v (may already exist)", err) - } - // Set bucket policy for public read access policy := fmt.Sprintf(`{ "Version": "2012-10-17", @@ -191,15 +243,8 @@ func Connect() error { log.Printf("Bucket policy: %v (may already exist)", err) } - // Create profile pics bucket if it doesn't exist + // Profile pics bucket policy if profilePicsBucket != bucket { - _, err = s3Raw.CreateBucket(ctx, &s3.CreateBucketInput{ - Bucket: aws.String(profilePicsBucket), - }) - if err != nil { - log.Printf("Profile pics bucket creation: %v (may already exist)", err) - } - profilePolicy := fmt.Sprintf(`{ "Version": "2012-10-17", "Statement": [{ diff --git a/backend/internal/s3/s3_dev_test.go b/backend/internal/s3/s3_dev_test.go index af70b01..c401f06 100644 --- a/backend/internal/s3/s3_dev_test.go +++ b/backend/internal/s3/s3_dev_test.go @@ -5,10 +5,15 @@ package s3 import ( "bytes" "context" + "fmt" + "io" + "net" "strings" "sync" "testing" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/aws/smithy-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -154,3 +159,59 @@ func TestConnect_FallbackToInMemory(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "not found") } + +func TestIsMissingBucket_NoSuchBucketGeneric(t *testing.T) { + t.Parallel() + err := &smithy.GenericAPIError{Code: "NoSuchBucket", Message: "bucket does not exist"} + assert.True(t, isMissingBucket(err)) +} + +func TestIsMissingBucket_NotFoundGeneric(t *testing.T) { + t.Parallel() + err := &smithy.GenericAPIError{Code: "NotFound", Message: "not found"} + assert.True(t, isMissingBucket(err)) +} + +func TestIsMissingBucket_NotFoundTyped(t *testing.T) { + t.Parallel() + // The concrete error types the vendored SDK surfaces for HeadBucket 404s. + assert.True(t, isMissingBucket(&types.NotFound{})) + assert.True(t, isMissingBucket(&types.NoSuchBucket{})) +} + +func TestIsMissingBucket_NotFoundViaOperationError(t *testing.T) { + t.Parallel() + // The SDK wraps API errors in an OperationError; errors.As must still find it. + err := &smithy.OperationError{ServiceID: "S3", OperationName: "HeadBucket", Err: &types.NotFound{}} + assert.True(t, isMissingBucket(err)) +} + +func TestIsMissingBucket_NotFoundWrapped(t *testing.T) { + t.Parallel() + err := fmt.Errorf("head bucket: %w", &smithy.GenericAPIError{Code: "NoSuchBucket", Message: "x"}) + assert.True(t, isMissingBucket(err)) +} + +func TestIsMissingBucket_ConnectionRefused(t *testing.T) { + t.Parallel() + err := &net.OpError{Op: "dial", Net: "tcp", Err: fmt.Errorf("connect: connection refused")} + assert.False(t, isMissingBucket(err)) +} + +func TestIsMissingBucket_EOF(t *testing.T) { + t.Parallel() + assert.False(t, isMissingBucket(io.EOF)) +} + +func TestIsMissingBucket_AccessDenied(t *testing.T) { + t.Parallel() + // Reachable but unauthorized is a connectivity/credentials problem, not a + // missing bucket — it must still trigger the fallback. + err := &types.AccessDenied{} + assert.False(t, isMissingBucket(err)) +} + +func TestIsMissingBucket_Nil(t *testing.T) { + t.Parallel() + assert.False(t, isMissingBucket(nil)) +} diff --git a/backend/internal/square/square_dev.go b/backend/internal/square/square_dev.go index 43627ad..e2cf140 100644 --- a/backend/internal/square/square_dev.go +++ b/backend/internal/square/square_dev.go @@ -275,10 +275,14 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (* // Square requires customer_id when charging a card-on-file (ccof:) token. // The mock enforces the same rule so dev parity catches the production bug // where a saved-card charge is sent without the customer's Square customer - // id (real Square rejects it with a 400 INVALID_REQUEST_ERROR). + // id (real Square rejects it with a 400 MISSING_REQUIRED_PARAMETER — + // category INVALID_REQUEST_ERROR — because customer_id is required for a + // card-on-file source). if strings.HasPrefix(req.SourceID, "ccof:") && req.CustomerID == "" { return nil, &squareAPIError{ - Code: "INVALID_REQUEST_ERROR", + Code: "MISSING_REQUIRED_PARAMETER", + Category: "INVALID_REQUEST_ERROR", + Field: "customer_id", Detail: "customer_id required for card-on-file source", StatusCode: http.StatusBadRequest, err: errors.New("square: customer_id required for card-on-file source"), @@ -444,9 +448,10 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) } if deviceID == "" { return nil, &squareAPIError{ - Code: "INVALID_REQUEST_ERROR", + Code: "MISSING_REQUIRED_PARAMETER", Detail: "device_options.device_id is required to create a terminal checkout", Category: "INVALID_REQUEST_ERROR", + Field: "device_options.device_id", StatusCode: http.StatusBadRequest, err: errors.New("square: device_options.device_id is required for a terminal checkout (set SQUARE_TERMINAL_DEVICE_ID or pass DeviceID)"), } @@ -699,8 +704,8 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (* refundID := fmt.Sprintf("ref_mock_%d", now.UnixNano()) // Square's RefundPayment requires amount_money — a missing or zero amount - // is rejected (400 INVALID_REQUEST_ERROR / REFUND_AMOUNT_INVALID), never - // treated as a "full refund" shortcut. The mock mirrors this so a + // is rejected (400 REFUND_AMOUNT_INVALID, category INVALID_REQUEST_ERROR), + // never treated as a "full refund" shortcut. The mock mirrors this so a // missing-amount bug can't be masked in dev (the real DB also has a CHECK // amount > 0, so a £0 refund must fail rather than silently record nothing). if req.Amount <= 0 { @@ -780,11 +785,14 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu // runtime (confirmed by Square's own SDK maintainer). The production client // omits an empty customer_id via omitempty and every production caller // provisions a Square customer first, so the gate is enforced upstream — the - // mock must mirror it (same structured INVALID_REQUEST_ERROR as the ccof: - // CreatePayment gate above) so sandbox/dev tests exercise the same rejection. + // mock must mirror it (same structured MISSING_REQUIRED_PARAMETER as the + // ccof: CreatePayment gate above) so sandbox/dev tests exercise the same + // rejection. if customerID == "" { return nil, &squareAPIError{ - Code: "INVALID_REQUEST_ERROR", + Code: "MISSING_REQUIRED_PARAMETER", + Category: "INVALID_REQUEST_ERROR", + Field: "card.customer_id", Detail: "customer_id is required to create a card on file", StatusCode: http.StatusBadRequest, err: errors.New("square: customer_id is required to create a card on file"), diff --git a/backend/internal/square/square_dev_test.go b/backend/internal/square/square_dev_test.go index 51fc4c4..e3d4ded 100644 --- a/backend/internal/square/square_dev_test.go +++ b/backend/internal/square/square_dev_test.go @@ -335,7 +335,7 @@ func TestDevClient_CreateCardOnFile_RequiresCustomerID(t *testing.T) { _, err := client.CreateCardOnFile(ctx, "user-no-customer", "cnon:test-token", "") require.Error(t, err, "card creation without customer_id must be rejected") - assert.Equal(t, "INVALID_REQUEST_ERROR", ErrorCode(err)) + assert.Equal(t, "MISSING_REQUIRED_PARAMETER", ErrorCode(err)) assert.Contains(t, ErrorDetail(err), "customer_id") assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) @@ -1360,7 +1360,7 @@ func TestDevClient_CreatePayment_CardOnFileRequiresCustomerID(t *testing.T) { ReferenceID: "booking-ccof-no-customer", }) require.Error(t, err, "ccof charge without customer_id must be rejected") - assert.Equal(t, "INVALID_REQUEST_ERROR", ErrorCode(err)) + assert.Equal(t, "MISSING_REQUIRED_PARAMETER", ErrorCode(err)) assert.Contains(t, ErrorDetail(err), "customer_id required") assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) @@ -1838,7 +1838,7 @@ func TestDevClient_CreateCheckout_RequiresDeviceID(t *testing.T) { }) require.Error(t, err) assert.Nil(t, res) - assert.Equal(t, "INVALID_REQUEST_ERROR", ErrorCode(err)) + assert.Equal(t, "MISSING_REQUIRED_PARAMETER", ErrorCode(err)) assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) }) diff --git a/backend/internal/square/types.go b/backend/internal/square/types.go index 3414818..e19661f 100644 --- a/backend/internal/square/types.go +++ b/backend/internal/square/types.go @@ -90,7 +90,7 @@ type RefundPaymentReq struct { // Reference: https://developer.squareup.com/reference/square/objects/Payment type PaymentResult struct { ID string // Square payment ID (e.g. "pay_xxx") - Status string // "APPROVED", "COMPLETED", "FAILED", "CANCELED" + Status string // "APPROVED", "PENDING", "COMPLETED", "CANCELED", "FAILED" Amount int64 // total amount charged in pence (including tip) CardBrand string // "VISA", "MASTERCARD", "AMERICAN_EXPRESS", "DISCOVER", etc. CardLast4 string @@ -156,7 +156,7 @@ type CardOnFile struct { // Reference: https://developer.squareup.com/reference/square/objects/Refund type RefundResult struct { ID string // Square refund ID (e.g. "ref_xxx") - Status string // "PENDING", "COMPLETED", "FAILED" + Status string // "PENDING", "COMPLETED", "REJECTED", "FAILED" Amount int64 // refund amount in pence PaymentID string // original payment being refunded LocationID string // location where refund was processed diff --git a/backend/main.go b/backend/main.go index d20b000..61cb7bd 100644 --- a/backend/main.go +++ b/backend/main.go @@ -218,8 +218,15 @@ func healthCheckHandler(w http.ResponseWriter, r *http.Request) { status = "degraded" } + var s3Message string if s3.Client == nil { services["s3_storage"] = "not_configured" + } else if s3.FallbackToInMemory { + // The dev build fell back to the in-memory client: uploads are stored + // in RAM with placeholder cdn.example.com URLs and are lost on restart. + services["s3_storage"] = "degraded" + status = "degraded" + s3Message = "S3 in-memory fallback active: RUSTFS unreachable — portfolio/photo uploads use placeholder URLs and data is lost on restart" } if env := os.Getenv("SQUARE_ENVIRONMENT"); env == "" || env == "mock" { @@ -231,10 +238,14 @@ func healthCheckHandler(w http.ResponseWriter, r *http.Request) { } else { w.WriteHeader(http.StatusOK) } - if err := json.NewEncoder(w).Encode(map[string]any{ + resp := map[string]any{ "status": status, "services": services, - }); err != nil { + } + if s3Message != "" { + resp["message"] = s3Message + } + if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } @@ -320,7 +331,17 @@ func main() { next.ServeHTTP(w, r.WithContext(ctx)) }) }) - r.Use(middleware.ClientIPFromHeader("X-Real-IP")) + // Trust the X-Real-IP proxy header ONLY when TRUST_PROXY_HEADERS=true, + // i.e. a trusted proxy (nginx/Cloudflare) sits in front and overwrites it + // with the real client IP. When the backend is origin-exposed, X-Real-IP + // is fully client-controlled and must be ignored, or a client could rotate + // it to bypass per-IP rate limiting. Without this middleware, chi's + // GetClientIP returns "" and the mw rate limiters fall back to the real + // RemoteAddr (the TCP peer). Same flag gates the mw.clientIP CF-Connecting- + // IP trust (mw/ratelimit_shared.go). + if mw.TrustProxyHeaders() { + r.Use(middleware.ClientIPFromHeader("X-Real-IP")) + } r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) @@ -521,6 +542,13 @@ func main() { r.With(mw.RequireNonGuest).Post("/user/giftcards/redeem", payments.RedeemGiftCard) r.Get("/user/giftcards/balance", payments.GetGiftCardBalance) r.With(mw.RequireNonGuest).Post("/user/giftcards/buy", payments.BuyGiftCard) + // 14-day cooling-off right to cancel online gift-card purchases + // (Consumer Contracts (Information, Cancellation and Additional + // Charges) Regulations 2013): list the caller's cancellable cards + // and execute the cancel+refund. RequireNonGuest mirrors /buy — + // guests cannot buy (or cancel) gift cards. + r.Get("/user/giftcards", payments.GetMyGiftCards) + r.With(mw.RequireNonGuest).Post("/user/giftcards/cancel", payments.CancelGiftCard) }) r.With(mw.RequireAuth, mw.RequireVerified, limitBody(uploadBodyLimit)).Post("/user/profile-picture", user.UploadProfilePictureHandler) @@ -627,6 +655,7 @@ func main() { r.Post("/admin/gift-cards", payments.CreateGiftCard) r.Put("/admin/gift-cards/{id}/topup", payments.TopUpGiftCard) r.Post("/admin/gift-cards/{from}/transfer", payments.TransferGiftCard) + r.Post("/admin/gift-cards/cancel", payments.AdminCancelGiftCard) r.Get("/admin/gift-cards/expired-balances", payments.GetExpiredBalances) r.Post("/admin/gift-cards/expired-balances/claim", payments.ClaimExpiredBalance) diff --git a/backend/mw/ratelimit.go b/backend/mw/ratelimit.go index b30a77e..bd01ceb 100644 --- a/backend/mw/ratelimit.go +++ b/backend/mw/ratelimit.go @@ -137,14 +137,23 @@ func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler } // clientIP derives the per-client rate-limit key. Priority: -// 1. CF-Connecting-IP header (set only when Cloudflare is the edge; -// nginx never sets it, so it cannot be spoofed through our proxy) -// 2. middleware.GetClientIP(r.Context()) — the X-Real-IP value nginx sets, -// captured by middleware.ClientIPFromHeader("X-Real-IP") in main.go -// 3. net.SplitHostPort(r.RemoteAddr) / r.RemoteAddr fallback +// 1. CF-Connecting-IP header — honored ONLY when TRUST_PROXY_HEADERS=true +// (see trustProxyHeaders). A trusted edge (Cloudflare, or nginx whose +// real_ip module validated it against the set_real_ip_from ranges) has +// already overwritten it with the real client IP, so it is unspoofable +// there. Ignored by default because an origin-exposed backend must never +// trust a client-controlled value. +// 2. middleware.GetClientIP(r.Context()) — the X-Real-IP value nginx sets +// from $remote_addr, captured by middleware.ClientIPFromHeader("X-Real-IP") +// in main.go. That middleware is registered only when +// TRUST_PROXY_HEADERS=true, so it too is trusted solely behind a proxy. +// 3. net.SplitHostPort(r.RemoteAddr) / r.RemoteAddr fallback — the actual +// TCP peer; the only key source usable when the backend is origin-exposed. func clientIP(r *http.Request) string { - if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { - return ip + if trustProxyHeaders { + if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { + return ip + } } if ip := middleware.GetClientIP(r.Context()); ip != "" { return ip diff --git a/backend/mw/ratelimit_shared.go b/backend/mw/ratelimit_shared.go index 37f4b27..2428895 100644 --- a/backend/mw/ratelimit_shared.go +++ b/backend/mw/ratelimit_shared.go @@ -6,10 +6,44 @@ package mw import ( "context" "crussell/clock" + "os" + "strconv" "sync" "time" ) +// trustProxyHeaders gates clientIP()'s use of the proxy-set client-IP headers: +// CF-Connecting-IP in clientIP(), and (via TrustProxyHeaders) the X-Real-IP +// ClientIPFromHeader middleware registered in main.go. It is read once at +// startup from the TRUST_PROXY_HEADERS env var and defaults to false. +// +// nginx (nginx/conf.d/default.conf) resolves the real client IP itself with +// the real_ip module (set_real_ip_from + +// real_ip_header CF-Connecting-IP), then overwrites both X-Real-IP and +// CF-Connecting-IP with the validated $remote_addr — so behind nginx those +// headers are the authoritative, unspoofable per-client key. Set +// TRUST_PROXY_HEADERS=true for ANY deployment where a trusted proxy (nginx +// and/or the Cloudflare edge) sits between clients and this backend and +// overwrites these headers itself. It MUST stay false when the backend is +// origin-exposed: a client talking directly to the backend could otherwise +// rotate X-Real-IP and/or CF-Connecting-IP to bypass per-IP rate limiting. +var trustProxyHeaders = func() bool { + v, ok := os.LookupEnv("TRUST_PROXY_HEADERS") + if !ok { + return false + } + b, err := strconv.ParseBool(v) + return err == nil && b +}() + +// TrustProxyHeaders reports whether proxy-set client-IP headers (X-Real-IP, +// CF-Connecting-IP) are honored by the rate limiter. main.go uses it to gate +// middleware.ClientIPFromHeader("X-Real-IP") on the same flag, so an +// origin-exposed backend never registers a middleware that would let a client +// forge its own rate-limit key. Both the header trust in clientIP() and the +// middleware registration read this single source of truth. +func TrustProxyHeaders() bool { return trustProxyHeaders } + // RateLimiter implements a simple in-memory rate limiter type RateLimiter struct { requests map[string][]time.Time diff --git a/backend/testutils/fixtures/fixtures.go b/backend/testutils/fixtures/fixtures.go index 880c3c2..651b08e 100644 --- a/backend/testutils/fixtures/fixtures.go +++ b/backend/testutils/fixtures/fixtures.go @@ -188,6 +188,31 @@ func NextWorkingDayAt(daysAhead, hour int) time.Time { return time.Date(day.Year(), day.Month(), day.Day(), hour, 0, 0, 0, time.UTC) } +// NextEditWindowTime returns a 10:00 UTC slot T (inside the fixture's Mon-Sun +// 08:00-20:00 London working hours) such that T+offset sits in the [24h, 48h] +// window from now — the band where RequestEditHandler neither 403s ("too close +// to reschedule": hoursUntilCurrent < 24) nor auto-approves the request at +// creation time (hoursUntilCurrent > 48). Callers whose edited booking starts +// at base+lead pass `lead` as offset so the *booking* start lands in the +// window. Because 10:00 UTC slots repeat every 24h and the window is exactly +// 24h wide (closed bounds, matching the handler's strict comparisons), a +// slot is always found on the first scan — at any wall-clock run time, unlike +// NextWorkingDayAt(1, 10) which lands <24h away when the suite runs after 10:00 +// UTC and the sibling day-2 fallback can overshoot past 48h. +func NextEditWindowTime(offset time.Duration) time.Time { + now := clock.Now() + for days := 1; days <= 4; days++ { + t := NextWorkingDayAt(days, 10) + d := t.Add(offset).Sub(now) + if d >= 24*time.Hour && d <= 48*time.Hour { + return t + } + } + // Unreachable in practice (the [24h,48h] window is one slot-period wide); + // fall back to day+2 so tests never silently hang on a pathological clock. + return NextWorkingDayAt(2, 10) +} + func CreateTestVerifiedUser(q db.Querier) (string, error) { return createTestUser(q, "Verified", "User", "verified@test.com", "verified_email") } diff --git a/frontend/src/lib/components/account/EditRequestModal.svelte b/frontend/src/lib/components/account/EditRequestModal.svelte index 051ed48..8fd35a8 100644 --- a/frontend/src/lib/components/account/EditRequestModal.svelte +++ b/frontend/src/lib/components/account/EditRequestModal.svelte @@ -774,7 +774,7 @@
diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index f3ec831..51b1698 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -557,7 +557,7 @@ ${hasVAT ? `

VAT is included at ${biz?.default_vat_rate ?? 20}

@@ -1072,7 +1072,7 @@ ${hasVAT ? `

VAT is included at ${biz?.default_vat_rate ?? 20} {/if} (showCancelConfirm = v)}> - + Cancel Booking @@ -1176,7 +1176,7 @@ ${hasVAT ? `

VAT is included at ${biz?.default_vat_rate ?? 20} } }} > - + Leave a Tip Show your appreciation for great service diff --git a/frontend/src/lib/components/admin/ApprovalModal.svelte b/frontend/src/lib/components/admin/ApprovalModal.svelte index 186ed2d..e97a27e 100644 --- a/frontend/src/lib/components/admin/ApprovalModal.svelte +++ b/frontend/src/lib/components/admin/ApprovalModal.svelte @@ -379,7 +379,7 @@ Approve Booking @@ -704,7 +704,7 @@ - + Decline this booking? diff --git a/frontend/src/lib/components/admin/BookingModal.svelte b/frontend/src/lib/components/admin/BookingModal.svelte index c321ae6..59d920c 100644 --- a/frontend/src/lib/components/admin/BookingModal.svelte +++ b/frontend/src/lib/components/admin/BookingModal.svelte @@ -264,7 +264,7 @@

@@ -815,7 +815,7 @@ {#if selectedBooking && showCancelModal} - + Cancel Booking diff --git a/frontend/src/lib/components/admin/EditBookingModal.svelte b/frontend/src/lib/components/admin/EditBookingModal.svelte index e2c2f30..c1a1cb5 100644 --- a/frontend/src/lib/components/admin/EditBookingModal.svelte +++ b/frontend/src/lib/components/admin/EditBookingModal.svelte @@ -651,7 +651,7 @@ - + Remove Service? diff --git a/frontend/src/lib/components/admin/EditRequestModal.svelte b/frontend/src/lib/components/admin/EditRequestModal.svelte index 2ecdafc..5f73d6b 100644 --- a/frontend/src/lib/components/admin/EditRequestModal.svelte +++ b/frontend/src/lib/components/admin/EditRequestModal.svelte @@ -378,7 +378,7 @@ - + Deny this change request? diff --git a/frontend/src/lib/components/admin/GiftCardsManagement.svelte b/frontend/src/lib/components/admin/GiftCardsManagement.svelte index 2a92416..cc46fe4 100644 --- a/frontend/src/lib/components/admin/GiftCardsManagement.svelte +++ b/frontend/src/lib/components/admin/GiftCardsManagement.svelte @@ -26,6 +26,9 @@ redeemed_by?: string; is_inventory?: boolean; last_used_at?: string; + payment_id?: string; + cancellable?: boolean; + cancellation_reason?: string; } interface UserBalance { @@ -85,6 +88,11 @@ let selectedCardId = $state(null); + // Cancellation (14-day cooling-off right) state + let showCancelCardModal = $state(false); + let cancellingCard = $state(false); + let cancelTargetCard = $state(null); + // Form inputs let generateAmount = $state(''); let generateUserQuery = $state(''); @@ -193,12 +201,16 @@ const generateError = $derived( generateAmount && (isNaN(Number(generateAmount)) || Number(generateAmount) <= 0) ? 'Must be a valid positive number' - : '' + : generateAmount && Number(generateAmount) > 250 + ? 'Amount cannot exceed £250 per transaction' + : '' ); const topUpError = $derived( topUpAmount && (isNaN(Number(topUpAmount)) || Number(topUpAmount) <= 0) ? 'Must be a valid positive number' - : '' + : topUpAmount && Number(topUpAmount) > 250 + ? 'Amount cannot exceed £250 per transaction' + : '' ); const transferAmountError = $derived( transferAmount && (isNaN(Number(transferAmount)) || Number(transferAmount) <= 0) @@ -249,7 +261,18 @@ const data = await res.json(); summary.total_unclaimed = data.total_unclaimed; summary.total_user_balances = data.total_user_balances; - cards = data.gift_cards; + cards = (data.gift_cards || []).map( + ( + gc: GiftCard & { + cancellable?: boolean; + cancellation_reason?: string; + } + ) => ({ + ...gc, + cancellable: gc.cancellable === true, + cancellation_reason: gc.cancellation_reason || '' + }) + ); balances = data.user_balances; totalBalanceRecords = data.ub_total ?? data.user_balances?.length ?? 0; currentPage = data.page; @@ -447,6 +470,41 @@ } } + function openCancelCardModal(gc: GiftCard) { + cancelTargetCard = gc; + showCancelCardModal = true; + } + + async function cancelGiftCard() { + if (!cancelTargetCard) return; + cancellingCard = true; + try { + const body: Record = { code: cancelTargetCard.id }; + if (cancelTargetCard.payment_id) body.payment_id = cancelTargetCard.payment_id; + const res = await apiFetch('/api/admin/gift-cards/cancel', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + if (res.ok) { + toast.success('Gift card cancelled — unspent balance refunded'); + showCancelCardModal = false; + cancelTargetCard = null; + await fetchGiftCards(); + await fetchExpiredBalances(); + } else { + const errText = await res.text(); + toast.error(extractErrorMessage(errText) || 'Failed to cancel gift card'); + } + } catch { + toast.error('Network error cancelling gift card'); + } finally { + cancellingCard = false; + } + } + function resetGenerateModal() { generateStep = 'type'; generateType = 'code'; @@ -520,7 +578,7 @@ setModalStep(actionType, 'success'); await fetchGiftCards(); } else { - paymentError = await res.text(); + paymentError = extractErrorMessage(await res.text()); setModalStep(actionType, 'error'); } } catch { @@ -565,7 +623,7 @@ await fetchGiftCards(); } } else { - paymentError = await res.text(); + paymentError = extractErrorMessage(await res.text()); setModalStep(actionType, 'error'); } } catch { @@ -661,7 +719,7 @@ setModalStep(actionType, 'success'); await fetchGiftCards(); } else { - paymentError = await res.text(); + paymentError = extractErrorMessage(await res.text()); setModalStep(actionType, 'error'); } } catch { @@ -699,7 +757,7 @@ topUpStep = 'success'; await fetchGiftCards(); } else { - paymentError = await res.text(); + paymentError = extractErrorMessage(await res.text()); topUpStep = 'error'; } } catch { @@ -866,7 +924,10 @@ Create, top-up, and track gift cards. Unclaimed cards can be topped up or transferred.
- +
+ +

Admin gift-card value is limited to £5,000 per day.

+
@@ -939,6 +1000,14 @@
{#if activeSection === 'cards' || activeSection === 'expired_cards'} +
+ Gift cards purchased online by customers can be cancelled by the customer themselves from + their account within 14 days of purchase (UK cooling-off right). Cancellations are made by + the customer; no action is needed here. +
+
Transfer - {:else} + {/if} + {#if gc.cancellable} + + {:else if gc.cancellation_reason} + + {gc.cancellation_reason} + + {:else if gc.redeemed_by} No actions available {/if}
@@ -1157,7 +1242,7 @@ {/if} -
+
{#if !gc.redeemed_by} + {:else if gc.cancellation_reason} + + {gc.cancellation_reason} + + {/if}
{/each} @@ -1796,6 +1898,7 @@ placeholder="e.g. 50.00" bind:value={generateAmount} /> +

Max £250 per transaction.

{#if generateError} {generateError} {/if} @@ -1946,7 +2049,7 @@ (cashAmount = e.currentTarget.value)} @@ -2103,6 +2206,7 @@ placeholder="e.g. 20.00" bind:value={topUpAmount} /> +

Max £250 per transaction.

{#if topUpError} {topUpError} {/if} @@ -2217,7 +2321,7 @@ (cashAmount = e.currentTarget.value)} @@ -2348,3 +2452,44 @@
+ + + + + + Cancel & Refund Gift Card + + Cancel gift card {formatCardCode(cancelTargetCard?.id ?? '')} and refund the remaining balance. + + + +
+
+

14-day statutory cancellation right

+

+ The unspent balance will be refunded to the original payment method. This action cannot be undone. +

+
+ + {#if cancelTargetCard} +
+ Remaining Balance + {formatCurrency(cancelTargetCard.amount_remaining)} +
+ {/if} +
+ + + + + +
+
diff --git a/frontend/src/lib/components/admin/HolidayHours.svelte b/frontend/src/lib/components/admin/HolidayHours.svelte index 9594a14..85ace29 100644 --- a/frontend/src/lib/components/admin/HolidayHours.svelte +++ b/frontend/src/lib/components/admin/HolidayHours.svelte @@ -827,7 +827,7 @@ - + Delete exception group? diff --git a/frontend/src/lib/components/admin/ImageUpload.svelte b/frontend/src/lib/components/admin/ImageUpload.svelte index d5f5136..c87d98b 100644 --- a/frontend/src/lib/components/admin/ImageUpload.svelte +++ b/frontend/src/lib/components/admin/ImageUpload.svelte @@ -968,7 +968,7 @@ - + Confirm Upload diff --git a/frontend/src/lib/components/admin/PatchTestModal.svelte b/frontend/src/lib/components/admin/PatchTestModal.svelte index fab4f1e..4bef75c 100644 --- a/frontend/src/lib/components/admin/PatchTestModal.svelte +++ b/frontend/src/lib/components/admin/PatchTestModal.svelte @@ -96,7 +96,7 @@ - + Record Patch Test diff --git a/frontend/src/lib/components/admin/RescheduleModal.svelte b/frontend/src/lib/components/admin/RescheduleModal.svelte index 07b94b8..949d97a 100644 --- a/frontend/src/lib/components/admin/RescheduleModal.svelte +++ b/frontend/src/lib/components/admin/RescheduleModal.svelte @@ -427,7 +427,7 @@ - + Reschedule Booking diff --git a/frontend/src/lib/components/admin/TillPurchases.svelte b/frontend/src/lib/components/admin/TillPurchases.svelte index a35c807..2791a95 100644 --- a/frontend/src/lib/components/admin/TillPurchases.svelte +++ b/frontend/src/lib/components/admin/TillPurchases.svelte @@ -31,6 +31,11 @@ let giftCardAmount = $state('25'); let showGiftCardInput = $state(false); + // Gift card funding is capped at £250 per transaction (the backend enforces + // the same limit) — mirror the cap client-side so the till cannot queue an + // oversized gift card. + const GIFT_CARD_MAX_AMOUNT = 250; + let paymentMethod = $state('cash'); let onlineSquareCardReady = $state(false); let onlineSquareCardInput = $state(null); @@ -194,7 +199,19 @@ // The backend till sale API currently only accepts item_type 'gift_card', so // retail items cannot be charged yet — gate the Charge button to gift-card-only carts. const hasRetailItems = $derived(cart.some((i) => i.label !== 'Gift Card')); - const canCharge = $derived(cart.length > 0 && !hasRetailItems && subtotal > 0); + const canCharge = $derived( + cart.length > 0 && + !hasRetailItems && + subtotal > 0 && + !cart.some((i) => i.price > GIFT_CARD_MAX_AMOUNT) + ); + + // Client-side parity with the per-transaction gift card cap: the amount + // typed into the gift-card input must not exceed £250. + const parsedGiftCardAmount = $derived(parseFloat(giftCardAmount)); + const giftCardAmountTooHigh = $derived( + !isNaN(parsedGiftCardAmount) && parsedGiftCardAmount > GIFT_CARD_MAX_AMOUNT + ); function formatCurrency(n: number): string { return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n); @@ -212,6 +229,9 @@ function addGiftCard() { const amt = parseFloat(giftCardAmount); if (isNaN(amt) || amt <= 0) return; + // The Add button is disabled via `giftCardAmountTooHigh`, but the + // input's Enter key bypasses that — reject here too (defense in depth). + if (amt > GIFT_CARD_MAX_AMOUNT) return; addItem('Gift Card', amt); giftCardAmount = '25'; showGiftCardInput = false; @@ -255,6 +275,10 @@ toast.error('Cart is empty'); return; } + if (cart.some((item) => item.price > GIFT_CARD_MAX_AMOUNT)) { + toast.error(`Gift card amount exceeds maximum (£${GIFT_CARD_MAX_AMOUNT})`); + return; + } if (hasRetailItems) { toast.error( 'Retail items cannot be charged yet — the till API currently supports gift card sales only' @@ -390,28 +414,40 @@
{#if showGiftCardInput} -
-
- £ +
+
+ £ + { + if (e.key === 'Enter') addGiftCard(); + }} + /> +
+ - { - if (e.key === 'Enter') addGiftCard(); - }} - />
- Gift card amount exceeds maximum (£{GIFT_CARD_MAX_AMOUNT})

+ {/if} +

Gift card limit £{GIFT_CARD_MAX_AMOUNT} per transaction

{:else} @@ -556,7 +592,7 @@

Secure payment powered by Square @@ -2479,6 +2575,109 @@

+ + + + + + + + + My Gift Cards + + + Gift cards you purchased online. Under UK law you have 14 days from purchase to cancel + and receive a full refund to your original payment method. + + + + {#if loadingMyGiftCards} + + {:else if myGiftCards.length === 0} +

+ No online gift card purchases yet. Buy a gift card above to get started. +

+ {:else} +
+ {#each myGiftCards as gc (gc.code)} +
+
+
{formatCardCode(gc.code)}
+
+ Value: {formatCurrency(gc.amount)} + Purchased: {formatShortDate(gc.purchased_at)} + {#if gc.expiry_date} + Expires: {formatShortDate(gc.expiry_date)} + {/if} +
+
+ {#if gc.cancellable} + + {:else if gc.cancellation_reason} + {gc.cancellation_reason} + {/if} +
+ {/each} +
+ {/if} +
+
+ + + + + Cancel this gift card? + + {cardToCancel + ? `You will be refunded ${formatCurrency(cardToCancel.amount)} to the payment method you used to buy it, and the gift card will no longer be usable.` + : ''} + + +
+
+

+ 14-day cooling-off period +

+
    +
  • You may cancel an online gift-card purchase within 14 days of buying it.
  • +
  • The full amount is refunded to your original payment method.
  • +
  • Once cancelled, the gift card cannot be used or redeemed.
  • +
+
+
+ + Keep gift card + Confirm cancellation & refund + +
+
{:else if activeTab === 'admin'} @@ -2718,6 +2917,8 @@ {#if showPasswordModal}
- + Change Password Enter your current and new password @@ -3063,7 +3267,7 @@ - + Delete Account? diff --git a/frontend/src/routes/cancellation-policy/+page.svelte b/frontend/src/routes/cancellation-policy/+page.svelte index b840735..a11073e 100644 --- a/frontend/src/routes/cancellation-policy/+page.svelte +++ b/frontend/src/routes/cancellation-policy/+page.svelte @@ -161,9 +161,10 @@ days).
  • - Gift card payments: Refunded back to the original gift card. The gift - card's remaining balance is incremented and is immediately available for use. Expired gift - cards are non-refundable. + Gift card payments: Refunded back to the original gift card (or, if + you paid from your account balance, back to that balance). The gift card's remaining + balance is incremented and is immediately available for use. Expired gift cards are + non-refundable.
  • Cash payments: Credited to your account balance, available for immediate @@ -245,6 +246,19 @@ standard 14-day statutory cancellation "cooling-off" period under the Consumer Contracts Regulations 2013 does not apply to online bookings scheduled for a specific date or time.

    +

    + That exclusion does not apply to gift cards: online gift-card purchases may be + cancelled within 14 days for a refund to the original payment method under the + Consumer Contracts Regulations 2013. If the card has been partly used, the amount + already spent on salon services is not refundable, and the remaining unspent balance + is refunded to the original payment method; the card is then cancelled. A card that + has been redeemed to an account balance or fully spent cannot be cancelled. +

    +

    + Where a partly-used card is cancelled, the card is cancelled automatically when the + refund is issued, so the remaining balance cannot then be spent. See our Gift Card + Terms for the full position. +

    If you believe your statutory consumer rights have not been met, you can get free, impartial advice from diff --git a/frontend/src/routes/demo/+page.svelte b/frontend/src/routes/demo/+page.svelte index 5920ffe..ad7899d 100644 --- a/frontend/src/routes/demo/+page.svelte +++ b/frontend/src/routes/demo/+page.svelte @@ -238,11 +238,11 @@ ? 'bg-yellow-500' : 'bg-red-500'}" >

  • -
    +
    {apt.customer}
    {apt.service} • {apt.duration} min
    - + {apt.status.replace('_', ' ')} diff --git a/frontend/src/routes/pay-tip/[id]/+page.svelte b/frontend/src/routes/pay-tip/[id]/+page.svelte index 5c0df5b..4b0693d 100644 --- a/frontend/src/routes/pay-tip/[id]/+page.svelte +++ b/frontend/src/routes/pay-tip/[id]/+page.svelte @@ -128,7 +128,7 @@ Leave a Tip - Crussell -
    +
    {#if loading || pageState === 'loading'}
    diff --git a/frontend/src/routes/portfolio/+page.svelte b/frontend/src/routes/portfolio/+page.svelte index 15f0fe1..e2ef996 100644 --- a/frontend/src/routes/portfolio/+page.svelte +++ b/frontend/src/routes/portfolio/+page.svelte @@ -895,10 +895,10 @@ else showModal = v; }} > - +
    Booking Information:

    • Appointment dates, times, services
    • -
    • Treatment notes and preferences
    • +
    • + Treatment notes and preferences (one health-and-safety record — includes + allergies, skin sensitivities, and access needs) +
    • Allergy and patch test records (health data — special category)
    • Payment history and transaction records
    + +

    Treatment & Safety Notes

    +

    + Notes about your appointments — colour and preference, lateness, and any allergies, + skin sensitivities, or access needs you tell us about — are kept as one + health-and-safety record. +

    +

    + Information about allergies and access needs is health data (special category) under the UK + GDPR; we record it so we can treat you safely and make reasonable adjustments (Equality Act + 2010). +

    +

    These notes are seen only by the salon owner and are never shared or exported.

    +

    + On account deletion the rest of your record is erased or anonymized, and your notes are + retained in a form that cannot be traced back to you. We keep them so we can still make safe + adjustments if you return, and to defend any future legal claim, for example around an + allergic reaction. +

    Financial Data:

    • Gift card codes and balances
    • @@ -165,8 +187,9 @@

    Legal basis: UK GDPR Article 9(2)(a) — Explicit consent
    - Retention: 7 years (insurance requirement) or account deletion (whichever - is later) + Retention: 7 years (insurance requirement); patch-test records are kept + unlinked to you if your account is deleted, and allergy/access information held in your + treatment notes is retained de-identified (see §3.1).

    @@ -217,6 +240,15 @@ 7 years Insurance requirement + + Treatment & safety notes (incl. allergy/access information) + + Retained after account deletion in de-identified form while they may be needed for + safety adjustments or legal-claims defence; the rest of the account record is + erased at deletion + + Legitimate interest (safety & legal-claims defence) + Dormant balances Indefinite (Account ID only) @@ -230,16 +262,30 @@
    +

    + Data-retention consent is opt-in: it is never pre-ticked or assumed, and + defaults to unchecked. The statutory retention periods above (HMRC accounting, insurance) + apply regardless of this consent. +

    Deletion Process

    Account deletion (your request):

    1. You confirm deletion (warning about data loss).
    2. -
    3. If balance exists, transferred to dormant balance system.
    4. -
    5. Account ID sent to you via email.
    6. +
    7. + If a balance exists it is retained on your anonymised account record. The dormant-balance + recovery registry is populated only when inactive accounts are cleaned up automatically, + not at your own deletion request — contact us if you believe a balance is missing. +
    8. +
    9. Your Account ID will be sent to you by email once email delivery is available.
    10. Personal data anonymized (name, email, phone replaced with placeholders).
    11. +
    12. Two-factor authentication setup is removed.
    13. Financial records retained 7 years (HMRC) then aggregated.
    14. Allergy records retained 7 years (insurance) then deleted.
    15. +
    16. + Treatment and safety notes retained in de-identified form per the policy above (rest of + the account record erased). +

    Saved cards: Deleting your account also removes your saved-card @@ -249,7 +295,11 @@

    Inactive account deletion (automatic):

      -
    1. Warning emails sent at 18/23 months (no balance) or 4/59 months (with balance).
    2. +
    3. + Warnings are scheduled at 18/23 months (no balance) or 4/59 months (with balance). Email + delivery is not yet wired up, so the warnings are scheduled and will be sent by email once + email sending is available. +
    4. If no activity, account deleted as above.
    5. Dormant balance recoverable with Account ID.
    diff --git a/frontend/src/routes/terms/+page.svelte b/frontend/src/routes/terms/+page.svelte index aec5d01..78ff8ac 100644 --- a/frontend/src/routes/terms/+page.svelte +++ b/frontend/src/routes/terms/+page.svelte @@ -96,13 +96,15 @@

    If your account has a balance: your balance becomes dormant and is transferred to our recovery registry. You will receive your - Account ID by email and can recover your balance at any time by providing it. + Account ID by email (once email delivery is available) and can recover your + balance at any time by providing it. All other personal data is anonymized.

    - Warning: account deletion is permanent. You will lose all booking history, treatment - notes, allergy and patch test records, loyalty stamps and referral codes, and access to your account - balance (unless you retain your Account ID). + Warning: account deletion is permanent. You will lose access to your account, + your booking history, loyalty stamps and referral codes, and your account balance (unless you + retain your Account ID). Treatment and safety notes are retained after deletion in a form + that cannot be traced back to you, and financial records are retained for 7 years (HMRC).

    2.3 Inactive Account Policy

    @@ -115,9 +117,10 @@

    - Warning emails are sent before deletion (18 months and 23 months for no-balance accounts; 4 - years and 59 months for accounts with a balance). All warning emails include your Account ID - for future balance recovery. + Warnings are scheduled before deletion (18 months and 23 months for no-balance accounts; 4 + years and 59 months for accounts with a balance). The warnings include your Account ID for + future balance recovery. Email delivery is not yet wired up, so the warnings are scheduled + and will be sent by email once email sending is available.

    @@ -125,7 +128,7 @@

    3. Bookings & Appointments

    • Bookings are subject to availability.
    • -
    • You will receive confirmation via email/SMS.
    • +
    • You will receive confirmation on-screen and in your account (via email/SMS once email delivery is available).
    • Some services require a deposit (typically 20–50% of the service cost).

    Cancellations & rescheduling

    @@ -188,11 +191,17 @@

    Gift card expiry

      -
    • Gift cards expire 24 months after last use (rolling expiry).
    • +
    • + Gift cards expire after the expiry period set in the salon's business settings (24 months + after last use by default, and never less than 12 months). +
    • “Last use” includes redemption, top-up, balance check, or any admin action.
    • -
    • The expiry date is displayed on the gift card and in your account.
    • +
    • + The expiry date is stored on the gift card's record and is displayed in your account + alongside the gift card. +

    Account balances

      @@ -208,6 +217,13 @@ VAT is charged at the point of gift card purchase, not at redemption. When you pay with gift card balance, no additional VAT is charged (it has already been paid).

      +

      + Right to cancel: if you buy a gift card online, you can cancel the purchase + within 14 days for a refund to the original payment method. If the card has been partly used + on salon services, only the unspent balance is refunded and the card is then cancelled. A card + that has been redeemed to an account balance or fully spent cannot be cancelled. See our Gift + Card Terms for the full position. +

      diff --git a/frontend/src/routes/tip/+page.svelte b/frontend/src/routes/tip/+page.svelte index efbb15a..7cd4c8a 100644 --- a/frontend/src/routes/tip/+page.svelte +++ b/frontend/src/routes/tip/+page.svelte @@ -143,7 +143,7 @@ Leave a Tip - Crussell -
      +
      {#if loading}
      diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index 141196d..3c29bea 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -217,7 +217,10 @@ CREATE TABLE users ( -- GDPR fields privacy_policy_and_terms_consent BOOLEAN NOT NULL DEFAULT TRUE, policy_consent_updated_at TIMESTAMPTZ DEFAULT NOW(), - data_retention_consent BOOLEAN NOT NULL DEFAULT TRUE, + -- UK GDPR: consent must be freely given, specific and unambiguous — it is + -- OPT-IN, never a pre-ticked default. New users start with FALSE until they + -- explicitly agree in the GDPR settings UI. + data_retention_consent BOOLEAN NOT NULL DEFAULT FALSE, data_consent_updated_at TIMESTAMPTZ DEFAULT NOW(), -- Audit fields created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -367,6 +370,10 @@ CREATE TABLE bookings ( user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, start_time TIMESTAMPTZ NOT NULL, status booking_status NOT NULL DEFAULT 'pending', + -- Free-text notes: treated as ONE medical/safety record. Allergy/access + -- entries are special-category health data (Art 9(1), Art 4(15)). + -- RETAINED at erasure (de-identified) — see RETENTION POLICY in + -- anonymize_user. notes TEXT, deposit_required BOOLEAN NOT NULL DEFAULT FALSE, -- Computed fields (maintained by trigger on booking_services/booking_custom_services) @@ -940,8 +947,26 @@ GDPR COMPLIANCE NOTES: -- WHEN: User requests account deletion OR idle-account batch cleanup -- OUTPUT: Converts personal data to anonymous placeholder -- NOTE: phone/date_of_birth are NOT NULL so we use placeholder values, not NULL --- NOTE: Also scrubs 2FA state (two_factor_*) and staff notes (PII) — a row --- presented as erased keeps no PII at any call site. +-- NOTE: Also scrubs 2FA state (two_factor_*). Free-text notes are RETAINED +-- at erasure — see RETENTION POLICY below. +-- RETENTION POLICY (free-text notes — UK GDPR Art 4(1), Art 9, Recital 26): +-- * Notes are a SINGLE free-text input treated as ONE medical/safety +-- record — colour/preference/lateness content sits alongside +-- allergy/access/disability content and cannot be split. +-- * Allergy/access/disability entries are SPECIAL CATEGORY health data +-- (Art 9(1), Art 4(15)). +-- * At erasure the rest of the user record is fully wiped/anonymised +-- (name, email, phone, DOB, saved cards, 2FA, social logins) and no +-- re-identification map is maintained after anonymisation, so the +-- retained notes are de-identified / effectively anonymised (Recital 26). +-- * Notes are retained while they may be needed for: +-- (a) safe treatment / reasonable adjustments (Equality Act 2010 +-- s.20-21, s.29) if the customer returns; +-- (b) legal-claims defence (Art 17(3)(e) / Art 9(2)(f)) e.g. around +-- allergy mistreatment. +-- * Retained notes remain access-controlled and are never exported. +-- A row presented as erased keeps no re-identifiable personal data at any +-- call site; retained notes are de-identified by the wiped surrounding record. CREATE OR REPLACE FUNCTION anonymize_user(target_id CHAR(12)) RETURNS VOID AS $$ BEGIN @@ -964,8 +989,8 @@ BEGIN two_factor_enabled = FALSE, -- live 2FA credential must not survive erasure two_factor_method = NULL, two_factor_pending_code_hash = NULL, - two_factor_pending_code_expires = NULL, - notes = NULL -- staff notes are PII; scrub at erasure + two_factor_pending_code_expires = NULL + -- notes are retained as a de-identified medical/safety record (see RETENTION POLICY below) WHERE id = target_id AND account_role != 'guest'; @@ -986,11 +1011,9 @@ BEGIN square_customer_id = NULL WHERE user_id = target_id; - -- Expire all pending verification codes - UPDATE verification_codes - SET used_at = NOW() - WHERE user_id = target_id - AND used_at IS NULL; + -- Delete verification codes: each row holds a plaintext 12-hex auth token + -- (a live credential) — GDPR Art 17 erasure must not leave it behind. + DELETE FROM verification_codes WHERE user_id = target_id; -- Scrub RESERVATION entries in time_blockers that reference this user UPDATE time_blockers @@ -999,15 +1022,10 @@ BEGIN AND (description LIKE 'RESERVATION:user:%' OR description LIKE 'RESERVATION:edit_request:%'); - -- Scrub notes on booking_edit_requests made by this user (free-text PII) - UPDATE booking_edit_requests - SET notes = NULL - WHERE requested_by = target_id; - - -- Scrub notes on this user's bookings (free-text PII written by the user) - UPDATE bookings - SET notes = NULL - WHERE user_id = target_id; + -- Notes on this user's bookings and booking_edit_requests are RETAINED + -- as a de-identified medical/safety record — see RETENTION POLICY above. + -- Edit-request rows whose ONLY content is the note survive because the + -- note IS one of the chk_at_least_one_field fields — no DELETE is needed. -- Scrub previous-name history (names are PII; leaving them would let -- "formerly [name]" receipts reveal the erased identity) @@ -1034,6 +1052,33 @@ BEGIN -- Revoke stored refresh tokens (auth credentials of an erased identity) DELETE FROM refresh_tokens WHERE user_id = target_id; + + -- Scrub Square CreatePayment request snapshots (payments / till_sales): + -- the stored replay JSON embeds the user's email as BuyerEmail (PII, GDPR + -- Art 17 / Art 5(1)(e)). The financial rows themselves MUST survive the + -- 7-year retention period, so only the snapshot is NULLed — the sweep + -- rebuilds a minimal replay body when the snapshot is missing, so + -- pending-row reconciliation stays money-safe. Scope: payments the user + -- initiated (created_by) OR that were charged against the user's bookings + -- (admin at-the-counter charges also carry the booking user's email in the + -- snapshot); till_sales where the user is the customer (user_id). + UPDATE payments + SET square_request_snapshot = NULL + WHERE created_by = target_id + OR booking_id IN (SELECT id FROM bookings WHERE user_id = target_id); + + UPDATE till_sales + SET square_request_snapshot = NULL + WHERE user_id = target_id; + + -- NULL the cardholder-typed reason on disputes linked to this user's + -- payments (free-text third-party PII — GDPR Art 17). The dispute row and + -- its financial data survive; only the free-text reason is scrubbed. + UPDATE disputes + SET reason = NULL + WHERE payment_id IN (SELECT id FROM payments + WHERE created_by = target_id + OR booking_id IN (SELECT id FROM bookings WHERE user_id = target_id)); END; $$ LANGUAGE plpgsql; @@ -1065,6 +1110,40 @@ BEGIN -- Revoke stored refresh tokens (auth credentials of an erased identity) DELETE FROM refresh_tokens WHERE user_id = target_id; + -- Scrub Square CreatePayment request snapshots (payments / till_sales): + -- the stored replay JSON embeds the guest's email as BuyerEmail (PII). + -- The financial rows survive the 7-year retention period — only the + -- snapshot is NULLed (the sweep rebuilds a minimal body when it is + -- missing, keeping reconciliation money-safe). Scope mirrors + -- anonymize_user(): payments the guest initiated (created_by) or charged + -- against the guest's bookings, and till_sales where the guest is the + -- customer (user_id). + UPDATE payments + SET square_request_snapshot = NULL + WHERE created_by = target_id + OR booking_id IN (SELECT id FROM bookings WHERE user_id = target_id); + + UPDATE till_sales + SET square_request_snapshot = NULL + WHERE user_id = target_id; + + -- Notes (bookings, booking_edit_requests, till_sales, + -- gift_card_transactions) are RETAINED as a de-identified medical/safety + -- record — see RETENTION POLICY above. The guest user row is fully + -- deleted below, so the retained notes cannot be traced back to the + -- individual (Recital 26). Edit-request rows whose ONLY content is the + -- note survive (chk_at_least_one_field — the note IS one of the fields). + + -- Unlink no-action FK references to users(id) that would otherwise make + -- the DELETE below fail (each column has REFERENCES users(id) with no + -- ON DELETE clause). The financial/till rows are preserved for the 7-year + -- retention period; only the user link is cleared. + UPDATE gift_card_transactions SET user_id = NULL WHERE user_id = target_id; + UPDATE gift_cards SET redeemed_by = NULL WHERE redeemed_by = target_id; + UPDATE gift_cards SET created_by = NULL WHERE created_by = target_id; + UPDATE admin_notifications SET user_id = NULL WHERE user_id = target_id; + UPDATE admin_audit_log SET target_user_id = NULL WHERE target_user_id = target_id; + DELETE FROM users WHERE id = target_id AND account_role = 'guest'; END; $$ LANGUAGE plpgsql; diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf index 0a1bd5e..b1a5bb4 100644 --- a/nginx/conf.d/default.conf +++ b/nginx/conf.d/default.conf @@ -1,9 +1,44 @@ # Rate limiting (per IP) — must be at http level, not inside server block -limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/m; +# api_limit mirrors the backend's own per-IP cap (mw.RateLimit(120, time.Minute)). +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=120r/m; limit_req_zone $binary_remote_addr zone=dav_limit:10m rate=100r/m; # Webhook bursts (Square retry storms) need a higher ceiling than the API limit limit_req_zone $binary_remote_addr zone=webhook_limit:10m rate=120r/m; +# Real client IP resolution behind Cloudflare (FAULT A4): Cloudflare sets +# CF-Connecting-IP BEFORE nginx, so stripping it (as the old config did) hid +# the real client from the backend and collapsed per-IP rate limiting to +# per-Cloudflare-edge-IP buckets. Instead the real_ip module rewrites +# $remote_addr to the CF-Connecting-IP value ONLY when the actual TCP peer +# matches one of the trusted Cloudflare ranges below — a direct client cannot +# spoof it, because its own address is never in these ranges. The rewritten +# $remote_addr then flows into X-Real-IP (and CF-Connecting-IP) downstream. +# Must be at http level so every server/location sees the resolved address. +# Cloudflare IPv4 ranges (https://www.cloudflare.com/ips/). +set_real_ip_from 173.245.48.0/20; +set_real_ip_from 103.21.244.0/22; +set_real_ip_from 103.22.200.0/22; +set_real_ip_from 103.31.4.0/22; +set_real_ip_from 141.101.64.0/18; +set_real_ip_from 108.162.192.0/18; +set_real_ip_from 190.93.240.0/20; +set_real_ip_from 188.114.96.0/20; +set_real_ip_from 197.234.240.0/22; +set_real_ip_from 198.41.128.0/17; +set_real_ip_from 162.158.0.0/15; +set_real_ip_from 104.16.0.0/13; +set_real_ip_from 104.24.0.0/14; +set_real_ip_from 172.64.0.0/13; +set_real_ip_from 131.0.72.0/22; +# Cloudflare IPv6 ranges. +set_real_ip_from 2a06:98c0::/29; +set_real_ip_from 2606:4700::/32; +set_real_ip_from 2803:f800::/32; +set_real_ip_from 2405:b500::/32; +set_real_ip_from 2405:8100::/32; +set_real_ip_from 2c0f:f248::/32; +real_ip_header CF-Connecting-IP; + # Only redirect to HTTPS for non-local hosts, so local dev on :80 keeps working. # nginx map keys support regexes, which is how the RFC1918 ranges are matched. # Every regex is anchored with $ so a hostname like 127.0.0.1.evil.com cannot @@ -36,9 +71,13 @@ server { # Square Web Payments SDK: script from *.squarecdn.com, card-entry iframe # from js.squareup.com (frame-src; without it the payment form cannot # tokenize behind this proxy). connect-src allows the SDK's own network calls. - # 'unsafe-inline' in script-src is kept because the SvelteKit SPA emits inline - # scripts; replace it with 'nonce-...' once the frontend supports nonces. - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://*.squarecdn.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://*.squareup.com https://*.squarecdn.com; frame-src https://js.squareup.com https://*.squareup.com; frame-ancestors 'none';" always; + # The SvelteKit SPA emits one inline bootstrap