diff --git a/.env.example b/.env.example index d0e1969..02700b1 100644 --- a/.env.example +++ b/.env.example @@ -55,7 +55,10 @@ SQUARE_ENVIRONMENT=mock # deliberately route a dev build to the real production API. Never set in a # deployed production build. SQUARE_ALLOW_REAL_API= -# 2FA (PSD2 SCA stand-in) for online card payments. Enforcement is FAIL-CLOSED: +# 2FA — merchant-level authorization gate on saved-card online payments (NOT +# PSD2 SCA; Square buyer verification is the SCA mechanism and is wired for +# new-card charges). Kept as an additional fraud control until Square buyer +# verification is wired for saved-card charges. Enforcement is FAIL-CLOSED: # ON unless REQUIRE_2FA explicitly disables it (false/0/off/no, case-insensitive) # OR SQUARE_ENVIRONMENT explicitly equals one of mock/dev/development/test. # Empty or unknown SQUARE_ENVIRONMENT values are treated as production-enforced @@ -67,9 +70,29 @@ SQUARE_ALLOW_REAL_API= # environments an operator must relay the logged code to the user out-of-band; # the API never returns the code while enforcement is ON. REQUIRE_2FA=true -# TWO_FACTOR_PEPPER — server-side pepper for HMAC-hashing 2FA codes (optional -# pre-launch; if unset, codes are hashed without pepper and a warning is logged) +# TWO_FACTOR_PEPPER — server-side pepper for HMAC-hashing 2FA codes. REQUIRED +# in production builds: code issuance FAILS CLOSED when it is unset (an +# unsalted SHA-256 digest in the 1M code space would be offline-brute-forceable +# from a log/DB leak), mirroring JWT_SECRET_KEY's fail-fast stance. Optional +# only in dev/test builds, where an unset pepper falls back to the legacy +# digest with a one-time warning. Generate with: +# openssl rand -base64 32 TWO_FACTOR_PEPPER= +# TWO_FACTOR_ALLOW_LOG_DELIVERY — defaults false. Production 2FA code issuance +# FAILS CLOSED without a delivery channel: there is no email/SMS transport yet, +# so the ONLY production channel is the operator's explicit opt-in to the +# insecure server-log delivery ([2FA] prefix — anyone with backend log access +# can defeat the gate on saved-card charges). MUST be set to true to deliver +# 2FA codes via the server log in production until email/SMS lands. Dev/test +# builds always deliver via the log and never consult this flag. +TWO_FACTOR_ALLOW_LOG_DELIVERY=false +# SNAPSHOT_ENC_KEY — base64-encoded 32-byte AES-256 key for encrypting stored +# square_request_snapshot rows (buyer PII: email + ccof card tokens) at rest in +# non-mock (production/sandbox) deployments. If unset/invalid, snapshots fall +# back to PLAINTEXT with a one-time CRITICAL log warning (money-safety first: +# the replayable snapshot must not be lost). Generate with: +# openssl rand -base64 32 +SNAPSHOT_ENC_KEY= # Webhook config MUST exactly match the Square Dashboard webhook subscription # (URL + signature key). If SQUARE_WEBHOOK_NOTIFICATION_URL is left unset it # defaults to http://localhost:8080/webhooks/square, which is fail-closed (503 @@ -119,6 +142,18 @@ VITE_BACKEND_URL=http://localhost:8080 # http://localhost:5173 when unset. FRONTEND_ORIGIN=http://localhost:5173 +# TRUST_PROXY_HEADERS — defaults false. Set to true ONLY when a trusted proxy +# (nginx and/or the Cloudflare edge) sits between clients and this backend and +# overwrites X-Real-IP / CF-Connecting-IP with the real client IP. When true, +# the per-IP rate limiter keys requests on those proxy-set headers and main.go +# registers chi's ClientIPFromHeader("X-Real-IP") middleware. MUST be true +# behind nginx/Cloudflare, or every request keyed by IP collapses onto the +# proxy's IP — one client exhausting the limit throttles everyone, and per-IP +# limiter protection is effectively bypassed. MUST stay false when the backend +# is origin-exposed: a client talking directly to the backend could otherwise +# rotate X-Real-IP/CF-Connecting-IP to bypass per-IP rate limiting. +TRUST_PROXY_HEADERS=false + # Local S3 (Rustfs) — requires GO_TESTING=1 or dev build tag # These are dev-only overrides used by the dev S3 implementation RUSTFS_ENDPOINT=http://rustfs:9000 diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 1ff9fbf..466d350 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -899,6 +899,8 @@ jobs: cmd: npm run check - task: lint cmd: npm run lint + - task: test + cmd: npm test - task: audit cmd: npm audit --audit-level=info steps: diff --git a/.sisyphus/plans/payments-review-consolidated.md b/.sisyphus/plans/payments-review-consolidated.md index cd75ec4..bc8a0ee 100644 --- a/.sisyphus/plans/payments-review-consolidated.md +++ b/.sisyphus/plans/payments-review-consolidated.md @@ -4,6 +4,76 @@ **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). +--- + +## Resolution Status (updated 13 Aug 2026) + +The payments overhaul is now complete (batch 1, 88 commits, code fixed). This section records the resolution status of every round-1 finding as verified in the batch-1 review, with spot-checks re-run against the current tree on 13 Aug 2026. The round-1 findings text below is left untouched. + +| ID | Status | Note | +|----|--------|------| +| C1 | RESOLVED | till-sale no-key fallback now derives a deterministic base key (`till-` + sha256) and reuses pending rows | +| C2 | RESOLVED | `ValidateAmount`/£10,000 cap applied on till sales and gift-card create/top-up/transfer | +| C3 | RESOLVED | `CancelGiftCard` / `AdminCancelGiftCard` implemented: in-app 14-day cancel, refund to original payment method, reg 34(9) partial-use handling | +| H1 | RESOLVED | `square_request_snapshot` scrubbed (`NULL`) in all erasure paths; comments/wording per operator intent | +| H2 | RESOLVED | `CleanupIdleAccounts` snapshots Square IDs pre-anonymize and disables cards + deletes the Square customer (with retry) | +| H3 | RESOLVED | Square-side deletion retried via scheduled job and surfaced on failure | +| H4 | RESOLVED | 2FA gate now also on BuyGiftCard SaveCard + CreatePaymentMethod | +| H5 | RESOLVED | env vars documented, checker fixed | +| M1 | RESOLVED | 16px font in Square hosted fields (no iOS auto-zoom) | +| M2 | RESOLVED | dialogs bottom-sheet/max-height 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/handlers/payments/charge_helpers.go b/backend/handlers/payments/charge_helpers.go index aa61afe..98175db 100644 --- a/backend/handlers/payments/charge_helpers.go +++ b/backend/handlers/payments/charge_helpers.go @@ -1,10 +1,20 @@ package payments import ( + "bytes" "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" "errors" + "fmt" + "io" "log" "net/http" + "os" + "strings" + "sync" "crussell/db" "crussell/internal/square" @@ -186,3 +196,116 @@ func recheckBookingPayable(ctx context.Context, q db.Querier, bookingID string) } return status, bookingStatusAllowsCompletedPayment(status), nil } + +// snapshotEncMarker prefixes the at-rest encrypted form of a stored +// square_request_snapshot (PII: buyer email + ccof tokens) so decryptSnapshot +// can distinguish encrypted values from plaintext (dev/mock environments and +// legacy pre-encryption rows). The marker itself is not secret. +const snapshotEncMarker = "enc:v1:" + +// snapshotEncKeyWarningOnce throttles the missing-key CRITICAL log to one line +// per process: a deployment without a usable SNAPSHOT_ENC_KEY falls back to +// plaintext (money-safety first — the replayable snapshot must not be lost), +// and the single loud warning makes the misconfiguration impossible to miss. +var snapshotEncKeyWarningOnce sync.Once + +// snapshotEncKey parses the AES-256-GCM key from the SNAPSHOT_ENC_KEY +// environment variable (base64-encoded 32 bytes). It is read on every call so +// tests can flip the env; the parse is cheap and encryption happens once per +// payment. +func snapshotEncKey() ([]byte, error) { + raw := strings.TrimSpace(os.Getenv("SNAPSHOT_ENC_KEY")) + if raw == "" { + return nil, errors.New("SNAPSHOT_ENC_KEY is not set") + } + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("SNAPSHOT_ENC_KEY is not valid base64: %w", err) + } + if len(decoded) != 32 { + return nil, fmt.Errorf("SNAPSHOT_ENC_KEY must decode to 32 bytes for AES-256, got %d", len(decoded)) + } + return decoded, nil +} + +// encryptSnapshot returns the snapshot body ready for storage. In dev/mock +// environments it returns the body unchanged (no key required, tests keep +// passing); in non-mock environments (SQUARE_ENVIRONMENT production/sandbox — +// the same gate IsExplicitDevOrMockEnv drives) it AES-256-GCM-encrypts the +// body and returns "enc:v1:" + base64(nonce || ciphertext) so the PII at rest +// (buyer email, ccof card tokens) is encrypted. The transformation is +// lossless: decryptSnapshot recovers the ORIGINAL bytes exactly, which Square's +// identical-body idempotency replay depends on. A missing/unusable key in a +// non-mock deployment falls back to plaintext with a one-time CRITICAL log — +// breaking the replayable snapshot to protect PII would strand pending rows, +// so money-safety wins over best-effort hardening. +func encryptSnapshot(body []byte) ([]byte, error) { + if IsExplicitDevOrMockEnv() { + return body, nil + } + key, err := snapshotEncKey() + if err != nil { + snapshotEncKeyWarningOnce.Do(func() { + log.Printf("CRITICAL: %v — storing square_request_snapshot PLAINTEXT; set SNAPSHOT_ENC_KEY to a base64-encoded 32-byte key in non-mock deployments", err) + }) + return body, nil + } + gcm, err := newSnapshotGCM(key) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("failed to read snapshot encryption nonce: %w", err) + } + sealed := gcm.Seal(nonce, nonce, body, nil) + out := append([]byte(snapshotEncMarker), []byte(base64.StdEncoding.EncodeToString(sealed))...) + return out, nil +} + +// decryptSnapshot reverses encryptSnapshot for a stored square_request_snapshot. +// Marker-prefixed values are base64-decoded and AES-256-GCM-decrypted back to +// the byte-identical original request body (the sweep's by-key replay depends +// on this); values without the marker (dev/mock plaintext or legacy +// pre-encryption rows) are returned unchanged. Exporting it lets the sweep's +// stale-pending reconcile decrypt stored snapshots before replay. +func decryptSnapshot(data []byte) ([]byte, error) { + if !bytes.HasPrefix(data, []byte(snapshotEncMarker)) { + return data, nil + } + key, err := snapshotEncKey() + if err != nil { + return nil, fmt.Errorf("cannot decrypt stored square_request_snapshot: %w", err) + } + gcm, err := newSnapshotGCM(key) + if err != nil { + return nil, err + } + sealed, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(string(data), snapshotEncMarker)) + if err != nil { + return nil, fmt.Errorf("stored square_request_snapshot is not valid base64: %w", err) + } + nonceSize := gcm.NonceSize() + if len(sealed) < nonceSize { + return nil, errors.New("stored square_request_snapshot ciphertext is too short") + } + nonce, ciphertext := sealed[:nonceSize], sealed[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("stored square_request_snapshot failed AES-GCM authentication: %w", err) + } + return plaintext, nil +} + +// newSnapshotGCM builds the AES-256-GCM AEAD for the given 32-byte key. +func newSnapshotGCM(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("failed to init snapshot AES cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("failed to init snapshot AES-GCM: %w", err) + } + return gcm, nil +} diff --git a/backend/handlers/payments/charge_helpers_test.go b/backend/handlers/payments/charge_helpers_test.go index 80e2faf..cdde329 100644 --- a/backend/handlers/payments/charge_helpers_test.go +++ b/backend/handlers/payments/charge_helpers_test.go @@ -3,7 +3,9 @@ package payments import ( + "bytes" "context" + "encoding/base64" "errors" "net/http/httptest" "strings" @@ -132,3 +134,73 @@ func TestResolveChargeSource_SaveCard_CleanupFailureStillCharges(t *testing.T) { require.True(t, strings.HasPrefix(sourceID, "ccof:"), "source must be the created card-on-file, got %q", sourceID) require.Equal(t, []string{sourceID}, rec.deletedIDs(), "the disable must be attempted even when it will fail") } + +// snapshotEncKeyForTest returns a deterministic base64-encoded 32-byte +// AES-256 key so encryption tests do not depend on a real env secret. +func snapshotEncKeyForTest() string { + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + return base64.StdEncoding.EncodeToString(key) +} + +// TestEncryptDecryptSnapshot_RoundTrip pins the M9 lossless constraint: in a +// non-mock environment with SNAPSHOT_ENC_KEY set, encryptSnapshot must not +// store plaintext and decryptSnapshot must recover the ORIGINAL bytes exactly +// — Square's identical-body idempotency replay depends on byte-for-byte +// fidelity. It also covers the dev/mock path (plaintext passthrough) and the +// legacy/unmarked plaintext path through decryptSnapshot. +func TestEncryptDecryptSnapshot_RoundTrip(t *testing.T) { + t.Setenv("SQUARE_ENVIRONMENT", "production") + t.Setenv("SNAPSHOT_ENC_KEY", snapshotEncKeyForTest()) + + body := []byte(`{"source_id":"cnon:test-nonce","buyer_email_address":"buyer@example.com","idempotency_key":"test-key"}`) + + enc, err := encryptSnapshot(body) + require.NoError(t, err) + require.False(t, bytes.Equal(enc, body), "production-mode snapshots must not be stored in plaintext") + require.True(t, bytes.HasPrefix(enc, []byte(snapshotEncMarker)), "encrypted snapshot must carry the enc:v1: marker") + + dec, err := decryptSnapshot(enc) + require.NoError(t, err) + require.True(t, bytes.Equal(dec, body), "decrypt must recover the byte-identical original snapshot (Square idempotent replay depends on it)") + + // Plaintext / legacy / dev-mock values pass through decrypt unchanged. + decPlain, err := decryptSnapshot(body) + require.NoError(t, err) + require.True(t, bytes.Equal(decPlain, body), "unmarked snapshot values must pass through unchanged") +} + +// TestEncryptSnapshot_DevMockStoresPlaintext pins the M9 gate: in dev/mock +// environments the snapshot stays plaintext (no key required), so the mock +// test suite keeps working unchanged. +func TestEncryptSnapshot_DevMockStoresPlaintext(t *testing.T) { + t.Setenv("SQUARE_ENVIRONMENT", "mock") + t.Setenv("SNAPSHOT_ENC_KEY", "") + + body := []byte(`{"source_id":"cnon:test-nonce"}`) + enc, err := encryptSnapshot(body) + require.NoError(t, err) + require.True(t, bytes.Equal(enc, body), "mock-mode snapshots must stay plaintext") +} + +// TestEncryptDecryptSnapshot_WrongKeyFails pins the auth failure path: a +// snapshot encrypted with one key must not decrypt (silently or otherwise) +// with a different key — GCM authentication must reject it. +func TestEncryptDecryptSnapshot_WrongKeyFails(t *testing.T) { + t.Setenv("SQUARE_ENVIRONMENT", "production") + t.Setenv("SNAPSHOT_ENC_KEY", snapshotEncKeyForTest()) + + enc, err := encryptSnapshot([]byte(`{"source_id":"cnon:test-nonce"}`)) + require.NoError(t, err) + + // A different valid 32-byte key must fail GCM authentication. + other := make([]byte, 32) + for i := range other { + other[i] = 0xFF + } + t.Setenv("SNAPSHOT_ENC_KEY", base64.StdEncoding.EncodeToString(other)) + _, err = decryptSnapshot(enc) + require.Error(t, err, "a snapshot encrypted with a different key must not decrypt") +} diff --git a/backend/handlers/payments/completion.go b/backend/handlers/payments/completion.go index c93eb60..821c1d7 100644 --- a/backend/handlers/payments/completion.go +++ b/backend/handlers/payments/completion.go @@ -339,8 +339,15 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID } // bookingIsFullyPaid reports whether completed payments toward the booking -// (excluding tips, discounts and on-the-house rows — the same definition as -// GetBookingPaymentInfo.TotalPaid) cover 100% of the booking total. +// (excluding tips and on-the-house rows, but INCLUDING discount rows) cover +// 100% of the booking total. A discount row represents real value applied +// toward the booking: the customer's total obligation is the DISCOUNTED total, +// so a booking is fully paid when real money + applied discounts == total +// (e.g. a 10% campaign on a £50 booking completes once £45 + £5 discount is +// recorded). Tips are excluded (gratuity, not payment toward the booking) as +// are on-the-house rows (no real value moved). This deliberately differs from +// GetBookingPaymentInfo.TotalPaid, which excludes discount rows because the +// deposit/balance SPLIT must run against the full total and real money only. func bookingIsFullyPaid(ctx context.Context, q db.Querier, bookingID string) bool { var fullyPaid bool if err := q.QueryRow(ctx, ` @@ -352,7 +359,7 @@ func bookingIsFullyPaid(ctx context.Context, q db.Querier, bookingID string) boo FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type != 'tip' - AND payment_method NOT IN ('discount', 'on_the_house') + AND payment_method NOT IN ('on_the_house') ) SELECT pt.paid_cents >= bt.total_cents AND bt.total_cents > 0 FROM booking_total bt, paid_total pt diff --git a/backend/handlers/payments/errors_test.go b/backend/handlers/payments/errors_test.go index 214b040..8fa2b3d 100644 --- a/backend/handlers/payments/errors_test.go +++ b/backend/handlers/payments/errors_test.go @@ -4,13 +4,20 @@ package payments import ( "context" + "encoding/json" "errors" "net/http" "reflect" + "strings" "testing" + "crussell/db" "crussell/internal/square" "crussell/testutils" + "crussell/testutils/fixtures" + "crussell/testutils/jwt" + + "github.com/stretchr/testify/require" ) // structuredSquareAPIError returns an error of the SAME concrete type the real @@ -200,3 +207,452 @@ func TestCreateBookingPayment_AmbiguousSquareFailure_Returns503(t *testing.T) { t.Errorf("expected payment status 'pending' after ambiguous failure, got %q", status) } } + +// ============================================================================= +// M3 — till HTTP status classification (till.go CreateTillSale error path) +// ============================================================================= + +// tillChargeFailureClient injects a Square CreatePayment failure into the till +// sale handler. The embedded client carries every other method so the sale +// setup (gift-card create/commit, advisory locks) runs exactly as in +// production; only CreatePayment is overridden to return the fault. +type tillChargeFailureClient struct { + square.SquareClient + createErr error +} + +func (c *tillChargeFailureClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) { + return nil, c.createErr +} + +// structuredSquareErrorFull builds a structured *square.squareAPIError of the +// same concrete type the real client produces, re-stamped with an arbitrary +// HTTP status, Square error code, AND error category. The type is not nameable +// outside internal/square, so the clone-through-reflection technique mirrors +// structuredSquareAPIError above (which rewrites only the status code); here +// the Code and Category are also rewritten so a CARD_DECLINED decline or an +// INVALID_REQUEST_ERROR category can be produced for isDefinitiveCardSaveFailure +// classification. +func structuredSquareErrorFull(t *testing.T, status int, code, category string) error { + t.Helper() + mc := square.NewDevClient().(*square.MockClient) + _, err := mc.CreatePayment(context.Background(), square.CreatePaymentReq{ + Amount: 1000, + Currency: "GBP", + SourceID: "ccof:card_1", + }) + if err == nil { + t.Fatal("expected the mock to reject a ccof charge without a customer") + } + v := reflect.ValueOf(err) + if v.Kind() != reflect.Ptr { + t.Fatalf("expected the structured error to be a pointer, got %v", v.Kind()) + } + clone := reflect.New(v.Elem().Type()) + clone.Elem().Set(v.Elem()) + clone.Elem().FieldByName("StatusCode").SetInt(int64(status)) + if code != "" { + clone.Elem().FieldByName("Code").SetString(code) + } + if category != "" { + clone.Elem().FieldByName("Category").SetString(category) + } + return clone.Interface().(error) +} + +// TestCreateTillSale_DefinitiveDecline_Returns402 covers M3: a definitive +// Square decline (structured CARD_DECLINED) on a fresh online-square till sale +// must surface as 402 (Payment Required) — never 503 — and the funded gift card +// must be clawed back. +func TestCreateTillSale_DefinitiveDecline_Returns402(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + adminToken := jwt.GenerateTestToken(adminID, "admin") + + origClient := SquareClient + SquareClient = &tillChargeFailureClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR")} + defer func() { SquareClient = origClient }() + + req := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 50.00, + PaymentMethod: "online_square", + CardToken: "cnon:till-status-definitive", + IdempotencyKey: "till-status-definitive-key", + } + + w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx) + if w.Code != http.StatusPaymentRequired { + t.Fatalf("expected 402 for a definitive CARD_DECLINED till charge, got %d: %s", w.Code, w.Body.String()) + } + + // Definitive rejection → the sale is marked failed and the funded gift + // card is clawed back (a late retry must not re-complete against it). + var status string + if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&status); err != nil { + t.Fatalf("failed to query till_sales: %v", err) + } + if status != "failed" { + t.Errorf("expected till_sale status 'failed' after a definitive decline, got %q", status) + } + var gcCount int + if err := tx.QueryRow(ctx, ` + SELECT COUNT(*) FROM gift_cards gc + JOIN till_sales ts ON gc.id = ts.item_id + WHERE ts.idempotency_key = $1`, req.IdempotencyKey).Scan(&gcCount); err != nil { + t.Fatalf("failed to count gift cards: %v", err) + } + if gcCount != 0 { + t.Errorf("expected the created gift card to be clawed back after a definitive decline, got %d rows", gcCount) + } +} + +// TestCreateTillSale_AmbiguousFailure_Returns503 covers M3: an ambiguous +// failure (simulated transport error — a plain error with no structured Square +// status) must surface as 503 (Service Unavailable), NEVER 402: the money state +// at Square is unknown, so the pending sale must stay resumable on a same-key +// retry. +func TestCreateTillSale_AmbiguousFailure_Returns503(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + adminToken := jwt.GenerateTestToken(adminID, "admin") + + origClient := SquareClient + SquareClient = &tillChargeFailureClient{SquareClient: square.NewDevClient(), createErr: errors.New("mock: payment declined (simulated failure)")} + defer func() { SquareClient = origClient }() + + req := TillSaleRequest{ + ItemType: "gift_card", + Action: "create", + Amount: 50.00, + PaymentMethod: "online_square", + CardToken: "cnon:till-status-ambiguous", + IdempotencyKey: "till-status-ambiguous-key", + } + + w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503 for an ambiguous till charge failure, got %d: %s", w.Code, w.Body.String()) + } + + // Ambiguous failure → the sale stays pending for the stale-pending sweep + // and the gift card stays funded so a late same-key retry can complete it. + var status string + if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&status); err != nil { + t.Fatalf("failed to query till_sales: %v", err) + } + if status != "pending" { + t.Errorf("expected till_sale status 'pending' after an ambiguous failure, got %q", status) + } + var remaining float64 + if err := tx.QueryRow(ctx, ` + SELECT amount_remaining FROM gift_cards gc + JOIN till_sales ts ON gc.id = ts.item_id + WHERE ts.idempotency_key = $1`, req.IdempotencyKey).Scan(&remaining); err != nil { + t.Fatalf("failed to query gift card balance: %v", err) + } + if remaining != 50.00 { + t.Errorf("expected the gift card to stay funded (£50.00) after an ambiguous failure, got £%.2f", remaining) + } +} + +// ============================================================================= +// H3 — isDefinitiveCardSaveFailure (handlers.go CreatePaymentMethod path) +// ============================================================================= + +// TestIsDefinitiveCardSaveFailure pins the H3 classification: a card-save +// failure carrying Square's INVALID_REQUEST_ERROR category (e.g. +// MISSING_REQUIRED_PARAMETER) is DEFINITIVE — the card can never be saved, so +// the attempt must fail immediately (400) instead of being retried as 500. The +// card-on-file creation codes SOURCE_USED / CARD_TOKEN_USED / +// CARD_TOKEN_EXPIRED / INVALID_CARD are definitive too, as are the shared +// definitive charge-decline codes. Generic structured errors (5xx, unknown +// code/category) and plain transport errors are AMBIGUOUS. +func TestIsDefinitiveCardSaveFailure(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"INVALID_REQUEST_ERROR category (MISSING_REQUIRED_PARAMETER) → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "MISSING_REQUIRED_PARAMETER", "INVALID_REQUEST_ERROR"), true}, + {"SOURCE_USED → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "SOURCE_USED", "INVALID_REQUEST_ERROR"), true}, + {"CARD_TOKEN_USED → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "CARD_TOKEN_USED", "PAYMENT_METHOD_ERROR"), true}, + {"CARD_TOKEN_EXPIRED → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "CARD_TOKEN_EXPIRED", "PAYMENT_METHOD_ERROR"), true}, + {"INVALID_CARD → definitive", structuredSquareErrorFull(t, http.StatusBadRequest, "INVALID_CARD", "PAYMENT_METHOD_ERROR"), true}, + {"CARD_DECLINED charge code → definitive", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR"), true}, + {"generic structured 500 → ambiguous", structuredSquareErrorFull(t, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "API_ERROR"), false}, + {"generic structured 400 unknown code/category → ambiguous", structuredSquareErrorFull(t, http.StatusBadRequest, "SOMETHING_ELSE", "PAYMENT_METHOD_ERROR"), false}, + {"plain transport error → ambiguous", errors.New("network error: connection reset by peer"), false}, + {"nil → ambiguous", nil, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isDefinitiveCardSaveFailure(tt.err); got != tt.want { + t.Errorf("isDefinitiveCardSaveFailure(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +// cardSaveFailClient overrides only CreateCardOnFile so a transport-level +// card-save failure can be injected into CreatePaymentMethod without breaking +// the customer-provisioning call that precedes it. +type cardSaveFailClient struct { + square.SquareClient + createCardErr error +} + +func (c *cardSaveFailClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) { + return nil, c.createCardErr +} + +// TestCreatePaymentMethod_SourceUsed_Definitive400 drives the H3 classification +// end to end through the add-card handler: Square consumes a cnon: nonce on +// card creation, so reusing it is rejected with a structured 400 SOURCE_USED. +// isDefinitiveCardSaveFailure classifies that as definitive → 400 "Invalid +// request" (the save fails immediately, no retry), NOT 500, and no card row is +// persisted by the failed attempt. +func TestCreatePaymentMethod_SourceUsed_Definitive400(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + t.Cleanup(func() { + InvalidateSquareCustomerCache(userID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID) + }) + token := jwt.GenerateUserToken(userID) + + origClient := SquareClient + mc := square.NewDevClient().(*square.MockClient) + mc.SimulateSourceUsed = true + SquareClient = mc + defer func() { SquareClient = origClient }() + + handler := CreatePaymentMethod + cardToken := "cnon:reused-source" + + w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: cardToken}, token, ctx) + if w.Code != http.StatusOK { + t.Fatalf("first save with a fresh nonce must succeed, got %d: %s", w.Code, w.Body.String()) + } + + // Reusing the consumed nonce → SOURCE_USED (INVALID_REQUEST_ERROR) → + // definitive card-save failure → 400, NOT 500. + w = makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: cardToken}, token, ctx) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for a definitive SOURCE_USED card-save failure, got %d: %s", w.Code, w.Body.String()) + } + + var count int + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&count); err != nil { + t.Fatalf("failed to count saved cards: %v", err) + } + if count != 1 { + t.Errorf("expected exactly 1 saved card (the failed re-save must not persist a row), got %d", count) + } +} + +// TestCreatePaymentMethod_AmbiguousCardSaveFailure_500 drives the H3 +// classification the other way: a plain transport error during card tokenization +// carries no structured Square code, so isDefinitiveCardSaveFailure is false and +// the handler returns 500 (retrying with the same inputs might succeed). +func TestCreatePaymentMethod_AmbiguousCardSaveFailure_500(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + t.Cleanup(func() { + InvalidateSquareCustomerCache(userID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID) + }) + token := jwt.GenerateUserToken(userID) + + origClient := SquareClient + SquareClient = &cardSaveFailClient{SquareClient: square.NewDevClient(), createCardErr: errors.New("network error: connection reset by peer")} + defer func() { SquareClient = origClient }() + + handler := CreatePaymentMethod + w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: "cnon:ambiguous-save"}, token, ctx) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 for an ambiguous card-save failure, got %d: %s", w.Code, w.Body.String()) + } + + var count int + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&count); err != nil { + t.Fatalf("failed to count saved cards: %v", err) + } + if count != 0 { + t.Errorf("an ambiguous card-save failure must not persist a card, got %d rows", count) + } +} + +// ============================================================================= +// H4 — 2FA gate on CreatePaymentMethod and BuyGiftCard(SaveCard) +// ============================================================================= + +// TestTwoFactorEnforced_CreatePaymentMethod_Blocked_403 verifies the H4 gate on +// the dedicated add-card endpoint: with REQUIRE_2FA enforced and the user NOT +// having completed 2FA setup, persisting a card is blocked with 403 and no card +// row is created — the save-card endpoint is not an un-gated side door. +func TestTwoFactorEnforced_CreatePaymentMethod_Blocked_403(t *testing.T) { + t.Setenv("REQUIRE_2FA", "true") + t.Setenv("SQUARE_ENVIRONMENT", "production") + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + token := jwt.GenerateUserToken(userID) + + handler := CreatePaymentMethod + w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: "cnon:2fa-blocked"}, token, ctx) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 when 2FA is enforced and the user has not enabled it, got %d: %s", w.Code, w.Body.String()) + } + var body map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Contains(t, body["error"], "Two-factor") + + var cardCount int + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount); err != nil { + t.Fatalf("failed to count saved cards: %v", err) + } + if cardCount != 0 { + t.Errorf("a blocked 2FA save must not persist a card, got %d rows", cardCount) + } +} + +// TestTwoFactorEnforced_CreatePaymentMethod_With2FA_Succeeds verifies the gate +// lets a user WHO HAS enabled 2FA save a card through the add-card endpoint. +func TestTwoFactorEnforced_CreatePaymentMethod_With2FA_Succeeds(t *testing.T) { + t.Setenv("REQUIRE_2FA", "true") + t.Setenv("SQUARE_ENVIRONMENT", "production") + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + if _, err := tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true WHERE id = $1`, userID); err != nil { + t.Fatalf("failed to enable 2FA: %v", err) + } + t.Cleanup(func() { + InvalidateSquareCustomerCache(userID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM user_saved_cards WHERE user_id = $1`, userID) + }) + token := jwt.GenerateUserToken(userID) + + handler := CreatePaymentMethod + w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{CardToken: "cnon:2fa-ok"}, token, ctx) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 when 2FA is enabled, got %d: %s", w.Code, w.Body.String()) + } + + var cardCount int + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount); err != nil { + t.Fatalf("failed to count saved cards: %v", err) + } + if cardCount != 1 { + t.Errorf("expected exactly 1 saved card, got %d", cardCount) + } +} + +// TestTwoFactorEnforced_BuyGiftCard_SaveCard_Blocked_403 verifies the H4 gate +// fires on the gift-card purchase path too: BuyGiftCard with req.SaveCard=true +// requires 2FA when enforced, mirroring CreatePaymentMethod/CreateBookingPayment. +// The purchase is rejected with 403 BEFORE any payment row is inserted. +func TestTwoFactorEnforced_BuyGiftCard_SaveCard_Blocked_403(t *testing.T) { + t.Setenv("REQUIRE_2FA", "true") + t.Setenv("SQUARE_ENVIRONMENT", "production") + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + token := jwt.GenerateUserToken(userID) + + cardToken := "cnon:2fa-buy-gc" + req := BuyGiftCardRequest{ + Amount: 2000, + RecipientType: "self", + NewCardToken: &cardToken, + SaveCard: true, + IdempotencyKey: "2fa-buy-gc-blocked", + } + + w := makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", req, token, ctx) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for BuyGiftCard with SaveCard=true without 2FA, got %d: %s", w.Code, w.Body.String()) + } + var body map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Contains(t, body["error"], "Two-factor") + + var payCount int + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE created_by = $1`, userID).Scan(&payCount); err != nil { + t.Fatalf("failed to count payments: %v", err) + } + if payCount != 0 { + t.Errorf("a blocked gift-card purchase must not create a payment row, got %d", payCount) + } +} + +// ============================================================================= +// M7 — ConfirmOverflowTip (handlers.go CreateBookingPayment overflow gate) +// ============================================================================= + +// TestBookingPayment_Overflow_PostStart_Succeeds covers M7(c): once the booking +// has STARTED, an overpayment without confirm_overflow_tip succeeds (gratuity +// for service rendered is legitimate). The pre-start rejection (400 +// overflow_tip_confirmation_required) and the confirmed pre-start tip path are +// covered in m4_tip_refund_redesign_test.go. +func TestBookingPayment_Overflow_PostStart_Succeeds(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + // setupTestDataPast creates a booking whose start time is 1h ago — a + // post-start booking (the fixture booking total is £50). + userID, bookingID, _ := setupTestDataPast(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + + cardToken := "cnon:overflow-post-start" + req := CreateBookingPaymentRequest{ + Amount: 6000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "overflow-post-start-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for a post-start overflow without confirmation, got %d: %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "overflow_tip_confirmation_required") { + t.Fatalf("a post-start overpayment must not require the overflow confirmation, body: %s", w.Body.String()) + } + + var payCount int + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&payCount); err != nil { + t.Fatalf("failed to count payments: %v", err) + } + if payCount != 1 { + t.Errorf("expected exactly 1 completed payment after the post-start overflow, got %d", payCount) + } +} diff --git a/backend/handlers/payments/giftcard_limits.go b/backend/handlers/payments/giftcard_limits.go index f38f583..dee413a 100644 --- a/backend/handlers/payments/giftcard_limits.go +++ b/backend/handlers/payments/giftcard_limits.go @@ -16,9 +16,9 @@ import ( // - 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. +// till.go uses the same £250 transaction cap (maxAdminGiftCardTransactionPence) +// for its gift-card creates/topups — this shared constant is the single source +// of the owner decision. const ( // maxAdminGiftCardTransactionPence caps a single admin gift-card // create/top-up/transfer at £250 (25,000 pence). diff --git a/backend/handlers/payments/giftcards.go b/backend/handlers/payments/giftcards.go index 3d0317a..93405c8 100644 --- a/backend/handlers/payments/giftcards.go +++ b/backend/handlers/payments/giftcards.go @@ -2,7 +2,6 @@ package payments import ( "context" - "crypto/sha256" "database/sql" "encoding/json" "errors" @@ -443,8 +442,8 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) { // 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. + // enforces on other payment entry points, and matching the till's cap). + // 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 @@ -487,6 +486,12 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) { if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } + // HMRC VAT Notice 700/7: salon-only gift cards are SPV by definition, so + // the EFFECTIVE type is written even when the stored setting is 'MPV' — + // recording raw 'MPV' here would make the redemption path defer VAT a + // second time (VAT is already collected at sale via the GetVATConfig SPV + // override). + purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType) expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx) if err != nil { log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err) @@ -771,13 +776,7 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) { 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) - } - }() + defer releasePaymentLock(pinConn, "crussell:giftcard-cancel:"+fromCardID) tx, err := db.Conn.Begin(ctx) if err != nil { @@ -951,13 +950,7 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) { 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) - } - }() + defer releasePaymentLock(pinConn, "crussell:giftcard-cancel:"+code) tx, err := db.Conn.Begin(ctx) if err != nil { @@ -1259,6 +1252,30 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("Failed to check idempotency: %v", err) } + // A6: CheckIdempotencyByKey matches on the key ALONE (service.go), so a + // client-supplied deterministic/guessable key (e.g. another user's + // "gc----" fallback key) would resolve + // to ANOTHER user's payment row — returning it as the caller's completed + // purchase, reusing it for a charge, or rejecting the caller on it + // (cross-user hijack). Never reuse or return a row the caller does not + // own: a foreign match is treated as a fresh request. + if existing != nil && (existing.CreatedBy == nil || *existing.CreatedBy != userID) { + log.Printf("Gift card idempotency key %q matched payment row %s (status %s) belonging to a different user — treating as a fresh request", req.IdempotencyKey, existing.ID, existing.Status) + if clientSuppliedKey { + // The collided key is occupied (payments.idempotency_key is + // UNIQUE) — derive a fresh deterministic key for THIS user so + // the new purchase inserts a new row instead of 500ing on the + // constraint. + derivedKey, dErr := deriveGiftCardIdempotencyKey(ctx, db.Conn, userID, req.Amount, req.RecipientType, noKeyCardPart) + if dErr != nil { + log.Printf("Failed to derive fresh gift-card idempotency key after foreign-key collision: %v", dErr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + req.IdempotencyKey = derivedKey + } + existing = nil + } if existing != nil { if existing.Status == "completed" { if err := json.NewEncoder(w).Encode(existing); err != nil { @@ -1303,9 +1320,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { 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 != "" { + // 2FA gating (C5): persisting or charging a card requires 2FA when the + // feature is enforced — both paying with an existing saved card (CardID) + // and SAVING a new card during this purchase (SaveCard), mirroring + // CreateBookingPayment/CreateTipPayment. A one-off new-card (nonce) charge + // that is not saved is not gated. + if (req.CardID != nil && *req.CardID != "") || req.SaveCard { if !requireTwoFactorForCardAccess(w, r, paymentService, userID) { return } @@ -1350,22 +1370,65 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { // 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) + // IDEMPOTENCY_KEY_REUSED, stranding the row pending forever. The + // at-rest snapshot is AES-256-GCM-encrypted in non-mock deployments + // (encryptSnapshot's "enc:v1:" marker), so the SourceID refresh must + // run in Go — decrypt → set SourceID → re-encrypt — instead of the + // legacy SQL jsonb_set, whose COALESCE(...,'{}')::jsonb cast cannot + // parse ciphertext. A row with no stored snapshot (legacy) gets just + // the source column refreshed, mirroring the booking reuse path + // (handlers.go). Best-effort: any failure leaves the snapshot + // untouched — the live square_source_id column stays authoritative + // and the sweep overrides the replay source from it. + var snap sql.NullString + if err := tx.QueryRow(ctx, `SELECT square_request_snapshot FROM payments WHERE id = $1`, reusePendingID).Scan(&snap); err != nil { + log.Printf("Failed to read square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, err) + } + var storedSnap *string + if snap.Valid && snap.String != "" { + body := []byte(snap.String) + if !IsExplicitDevOrMockEnv() { + if dec, dErr := decryptSnapshot(body); dErr != nil { + log.Printf("Failed to decrypt square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, dErr) + } else { + body = dec + } + } + var req square.CreatePaymentReq + if uErr := json.Unmarshal(body, &req); uErr != nil { + log.Printf("Failed to parse square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, uErr) + } else { + req.SourceID = sourceID + updated, mErr := json.Marshal(req) + if mErr != nil { + log.Printf("Failed to re-marshal square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, mErr) + } else { + stored := updated + if !IsExplicitDevOrMockEnv() { + if enc, eErr := encryptSnapshot(updated); eErr != nil { + log.Printf("Failed to encrypt square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, eErr) + } else { + stored = enc + } + } + s := string(stored) + storedSnap = &s + } + } + } + if storedSnap != nil { + if _, srcErr := tx.Exec(ctx, ` + UPDATE payments + SET square_source_id = $1, + square_request_snapshot = $2 + WHERE id = $3 + `, sourceID, *storedSnap, reusePendingID); srcErr != nil { + log.Printf("Failed to update square_source_id/square_request_snapshot on reused gift-card payment %s: %v", reusePendingID, srcErr) + } + } else { + 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) + } } buyPaymentID = reusePendingID } else { @@ -1396,7 +1459,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { // Apply VAT to the pending payment vatCfg, vatErr := GetVATConfig(ctx, tx) - if vatErr == nil && vatCfg.IsVATRegistered && vatCfg.VoucherType == "SPV" { + if vatErr == nil && vatAppliesToVoucher(vatCfg) { if _, vatExecErr := tx.Exec(ctx, "SELECT apply_vat_to_payment($1, $2)", buyPaymentID, vatCfg.DefaultVATRate); vatExecErr != nil { log.Printf("Failed to apply VAT to buy gift card payment %s: %v", buyPaymentID, vatExecErr) } @@ -1447,6 +1510,13 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { BuyerEmail: buyerEmail, VerificationToken: verificationToken, } + // C3: a card-on-file (ccof) charge — paying with an existing saved card or + // saving a new card during this purchase — is customer-initiated: Square + // requires customer_details on stored-credential payments. A one-off cnon: + // nonce charge is not a stored credential and needs none. + if req.CardID != nil || req.SaveCard { + paymentReq.CustomerDetails = &square.CreateCustomerDetails{CustomerInitiated: true} + } // 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 @@ -1454,7 +1524,9 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { // IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. if snap, mErr := json.Marshal(paymentReq); mErr != nil { log.Printf("Failed to marshal square_request_snapshot for gift-card payment %s: %v", buyPaymentID, mErr) - } else if _, sErr := db.Conn.Exec(ctx, `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(snap), buyPaymentID); sErr != nil { + } else if stored, eErr := encryptSnapshot(snap); eErr != nil { + log.Printf("Failed to encrypt square_request_snapshot for gift-card payment %s: %v", buyPaymentID, eErr) + } else if _, sErr := db.Conn.Exec(ctx, `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(stored), buyPaymentID); sErr != nil { log.Printf("Failed to store square_request_snapshot for gift-card payment %s: %v", buyPaymentID, sErr) } @@ -1514,6 +1586,10 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } + // HMRC VAT Notice 700/7: write the EFFECTIVE type (SPV) so + // voucher_type_at_purchase never records 'MPV' and redemption never + // applies deferred VAT a second time. + purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType) err = issueTx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase) VALUES ($1, 0, $2, NOW(), $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3) @@ -1558,6 +1634,10 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } + // HMRC VAT Notice 700/7: write the EFFECTIVE type (SPV) so + // voucher_type_at_purchase never records 'MPV' and redemption never + // applies deferred VAT a second time. + purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType) err = issueTx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase) VALUES ($1, $1, $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3) @@ -1622,10 +1702,13 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { // The key must distinguish "same live purchase retried" (dedup) from "new // purchase that happens to be identical" (new charge). The candidate is the // base key (seq 0) then the base key with a "-" suffix (seq >= 1) until a -// slot without a COMPLETED purchase is found. A COMPLETED purchase always -// advances the sequence — two genuine identical no-key purchases (e.g. two -// £20 self cards) are distinct operations and must diverge onto distinct keys -// (the old random-suffix fallback's collapse fix), while a PENDING row never +// slot without a COMPLETED or swept/declined FAILED purchase is found. A +// COMPLETED purchase always advances the sequence — two genuine identical +// no-key purchases (e.g. two £20 self cards) are distinct operations and must +// diverge onto distinct keys (the old random-suffix fallback's collapse fix), +// and a FAILED purchase (swept stale or definitively rejected) occupies its +// slot the same way so the customer's identical repurchase diverges onto a +// fresh key instead of being 409-rejected forever (A10). A PENDING row never // occupies a slot: a lost-response retry re-derives the base key, the // idempotency lookup below reuses the pending row, and Square's same-key dedup // returns the original charge — ONE charge instead of the old double-charge. @@ -1635,19 +1718,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { func deriveGiftCardIdempotencyKey(ctx context.Context, q db.Querier, userID string, amount int64, recipientType, cardPart string) (string, error) { baseKey := fmt.Sprintf("gc-%s-%d-%s-%s", userID, amount, recipientType, cardPart) for seq := 0; ; seq++ { - candidate := baseKey - if seq > 0 { - candidate = fmt.Sprintf("%s-%d", baseKey, seq) - } - if len(candidate) > 45 { - hash := sha256.Sum256([]byte(candidate)) - candidate = fmt.Sprintf("gc-%x", hash[:16]) - } - var completedID string + candidate := nextIdempotencyCandidate(baseKey, seq) + var occupiedID string err := q.QueryRow(ctx, ` SELECT id FROM payments - WHERE created_by = $1 AND idempotency_key = $2 AND status = 'completed' - `, userID, candidate).Scan(&completedID) + WHERE created_by = $1 AND idempotency_key = $2 AND status IN ('completed', 'failed') + `, userID, candidate).Scan(&occupiedID) if errors.Is(err, pgx.ErrNoRows) { return candidate, nil } @@ -2162,13 +2238,7 @@ func cancelGiftCardForUser(ctx context.Context, w http.ResponseWriter, r *http.R 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) - } - }() + defer releasePaymentLock(pinConn, "crussell:giftcard-cancel:"+code) tx, err := db.Conn.Begin(ctx) if err != nil { diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index c29e4da..ce91506 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -55,6 +55,13 @@ type CreateBookingPaymentRequest struct { SaveCard bool `json:"save_card"` IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"` VerificationToken *string `json:"verification_token,omitempty"` + // ConfirmOverflowTip acknowledges that an overpayment beyond the booking's + // remaining balance will be recorded as a tip (M7). Tips cannot be paid in + // advance, so a pre-start overpayment is rejected with 400 + // overflow_tip_confirmation_required unless the client sets this flag; the + // frontend prompts and resends with it. Post-start overpayments are always + // accepted (gratuity for service rendered). + ConfirmOverflowTip bool `json:"confirm_overflow_tip"` } type RefundRequest struct { @@ -669,12 +676,14 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { UpdatedAt: clock.Now(), CreatedBy: &adminID, } - if err := tx.QueryRow(r.Context(), ` - INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, user_saved_card_id, square_source_id, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING id - `, record.BookingID, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.IdempotencyKey, record.UserSavedCardID, record.SquareSourceID, record.CreatedBy, record.CreatedAt, record.UpdatedAt).Scan(&paymentID); err != nil { - log.Printf("Failed to insert pending saved-card payment: %v", err) + // M4: the service's record function sets every column the inline + // INSERT previously left to defaults (fees, VAT fields, etc.), so + // the pending row is created the same way every other flow creates + // its payment records. + var insertErr error + paymentID, insertErr = service.CreatePaymentRecordTx(r.Context(), tx, record, nil) + if insertErr != nil { + log.Printf("Failed to insert pending saved-card payment: %v", insertErr) _ = tx.Rollback(r.Context()) http.Error(w, "internal server error", http.StatusInternalServerError) return @@ -712,6 +721,10 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { ReferenceID: bookingID, Note: req.PaymentType, BuyerEmail: buyerEmail, + // C3: a saved-card (ccof) charge is customer-initiated — Square + // requires customer_details on stored-credential payments, and + // omitting it can fail or silently misclassify the charge. + CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true}, } // 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 @@ -723,7 +736,9 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { // 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 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(snap), paymentID); sErr != nil { + } else if stored, eErr := encryptSnapshot(snap); eErr != nil { + log.Printf("Failed to encrypt square_request_snapshot for saved-card payment %s: %v", paymentID, eErr) + } 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(stored), paymentID); sErr != nil { log.Printf("Failed to store square_request_snapshot for saved-card payment %s: %v", paymentID, sErr) } @@ -1299,6 +1314,19 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { return } + // A3: tips have a dedicated endpoint (POST /api/bookings/{id}/tip, + // CreateTipPayment) which enforces the M4 "tips only after the service + // starts" gate. A 'tip' payment_type on the booking payment endpoint would + // bypass that gate — the overflow/tip guard below explicitly skips tip-type + // requests and buildSplitRecords would carve the charge as a deposit or + // balance (or silently overflow into a tip record) — so it is rejected + // outright here, before any charge source resolution or payment record. + if req.PaymentType == "tip" { + log.Printf("Payment rejected: booking %s payment_type 'tip' is not allowed via /payment — tips use the dedicated /tip endpoint", bookingID) + http.Error(w, "Tips can only be added via the dedicated tip endpoint after the booking has started", http.StatusBadRequest) + return + } + if err := ValidateCardInfo(req.CardID, req.NewCardToken); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) @@ -1437,6 +1465,21 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { FROM payments WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed' `, bookingID, req.IdempotencyKey).Scan(&existingID, &existingBookingID, &existingPaymentType, &existingStatus, &existingAmount, &existingCreatedAt); err == nil { + // A12: re-validate the matched row's refund state exactly like + // the general dedup path below. A refunded payment's money is + // no longer live, so reporting it as success here would let a + // same-key retry claim a payment that was already returned to + // the customer (money collected for the booking was refunded, + // yet the retry shows paid). + if refunded, rErr := paymentHasLiveRefund(r.Context(), tx, existingID.String); rErr != nil { + log.Printf("Failed to re-validate completed-booking dedup hit %s against refunds: %v", existingID.String, rErr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } else if refunded { + log.Printf("Payment retry rejected: completed-booking payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, req.IdempotencyKey) + http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict) + return + } if err := json.NewEncoder(w).Encode(PaymentResponse{ ID: existingID.String, BookingID: existingBookingID.String, @@ -1582,14 +1625,39 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { } } - // M4: cap pay-early at 100% — a payment that exceeds the booking's remaining - // balance is rejected instead of silently becoming a tip via buildSplitRecords. - // A tip is gratuity for service already rendered and must be a deliberate - // separate action (the frontend shows a dedicated "tip" button once 100% is - // paid), so an overpayment is always a mistake. Placed AFTER the idempotency - // dedup: a same-key retry of an already-completed payment short-circuits - // above and must not hit this guard (the booking is fully paid by then). - // 'tip'-type requests are excluded — tips are charged via CreateTipPayment. + // A4: compute the campaign credit this payment will receive. Running the + // read-only ComputeEligibleDiscounts here (before the pending insert, + // under the advisory lock) returns exactly the discounts + // applyEligibleCampaignsAtPayment will create for the booking inside the + // post-charge transaction — no completed payment or booking_discounts row + // exists yet, so both runs see the same state. The credit is used below to + // (a) keep the overflow→tip guard honest about what the customer actually + // owes and (b) reduce the amount charged for a deposit payment, which the + // frontend always sends RAW (no client-side discount). + var bookingTotal float64 + if err := tx.QueryRow(r.Context(), `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal); err != nil { + log.Printf("Failed to load booking total for discount computation: %v", err) + } + var eligibleDiscountCents int64 + for _, d := range ComputeEligibleDiscounts(r.Context(), tx, bookingID, userID, bookingTotal) { + eligibleDiscountCents += int64(math.Round(d.Amount * 100)) + } + + // M4/M7: cap pay-early at 100%. A payment that exceeds the booking's + // remaining balance overflows into a tip record via buildSplitRecords, but a + // tip is gratuity for service already rendered — an unconfirmed pre-start + // overpayment is therefore rejected instead of silently becoming a tip. + // Post-start overpayments proceed (gratuity is legitimate once the service + // has started), and a pre-start overpayment with ConfirmOverflowTip set + // proceeds after the frontend's explicit confirmation prompt. Placed AFTER + // the idempotency dedup: a same-key retry of an already-completed payment + // short-circuits above and must not hit this guard (the booking is fully + // paid by then). 'tip'-type requests are excluded — tips are charged via + // CreateTipPayment (which enforces its own start-time gate). The overflow + // comparison uses the DISCOUNTED remaining (raw remaining + this payment's + // campaign credit): a payment that exceeds the raw remaining but stays + // within the discounted remaining is covered by the discount — it is NOT an + // overflow into tip territory. if req.PaymentType != "tip" { remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID) if err != nil { @@ -1597,10 +1665,42 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal server error", http.StatusInternalServerError) return } - if req.Amount > remainingCents { - log.Printf("Payment rejected: amount %d exceeds remaining balance %d for booking %s", req.Amount, remainingCents, bookingID) - http.Error(w, "Payment amount exceeds the remaining balance", http.StatusBadRequest) - return + discountedRemainingCents := remainingCents + eligibleDiscountCents + if req.Amount > discountedRemainingCents { + var bookingStartTime time.Time + if sErr := db.Conn.QueryRow(r.Context(), `SELECT start_time FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStartTime); sErr != nil { + log.Printf("Failed to get booking start time: %v", sErr) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if !req.ConfirmOverflowTip && !bookingStartTime.Before(clock.Now()) { + log.Printf("Overflow requires confirmation: amount %d exceeds discounted remaining %d for booking %s (not started, not confirmed)", req.Amount, discountedRemainingCents, bookingID) + mw.RespondJSON(w, http.StatusBadRequest, map[string]string{ + "error": "The extra amount will be recorded as a tip. Confirm to continue.", + "code": "overflow_tip_confirmation_required", + }) + return + } + log.Printf("Overflow accepted as tip: amount %d exceeds discounted remaining %d for booking %s (confirmed=%v)", req.Amount, discountedRemainingCents, bookingID, req.ConfirmOverflowTip) + } + } + + // A4: the amount actually charged at Square. The frontend's full/balance + // payments already subtract the campaign credit client-side (handlePayFull + // sends amount_due minus the discount preview), so re-subtracting here + // would double-discount those — and the full discounted payment must keep + // the full record amount so bookingIsFullyPaid (real money + discount + // row == booking total) still completes. A deposit payment, however, is + // charged RAW by the frontend (handlePayDeposit sends the deposit amount + // with no discount), so the campaign credit is applied to the deposit + // charge here: the deposit is charged at req.Amount minus the discount and + // the residual balance payment settles the rest, so the total across the + // deposit→balance flow is the discounted price. + chargeAmount := req.Amount + if req.PaymentType == "deposit" && eligibleDiscountCents > 0 { + chargeAmount = req.Amount - eligibleDiscountCents + if chargeAmount <= 0 { + chargeAmount = req.Amount } } @@ -1697,7 +1797,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { } paymentReq := square.CreatePaymentReq{ - Amount: req.Amount, + Amount: chargeAmount, Currency: "GBP", SourceID: sourceID, CustomerID: savedCardCustomerID, @@ -1706,6 +1806,11 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { Note: req.PaymentType, BuyerEmail: bookingBuyerEmail, VerificationToken: verificationToken, + // C3: every online charge here is cardholder-initiated — a saved-card + // (ccof) source MUST carry customer_details for Square's stored- + // credential rules, and a new-card (cnon) nonce is entered by the + // buyer present at the keyboard, so the flag is true either way. + CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true}, } // M1: store the verbatim request JSON so the sweep can replay the charge @@ -1717,7 +1822,9 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { // 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 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(snap), paymentID); sErr != nil { + } else if stored, eErr := encryptSnapshot(snap); eErr != nil { + log.Printf("Failed to encrypt square_request_snapshot for payment %s: %v", paymentID, eErr) + } 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(stored), paymentID); sErr != nil { log.Printf("Failed to store square_request_snapshot for payment %s: %v", paymentID, sErr) } @@ -1728,7 +1835,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { return } - paymentAmount := float64(req.Amount) / 100.0 + paymentAmount := float64(chargeAmount) / 100.0 // Step 3: Square succeeded — record the completed payment state in a NEW // transaction (split records, VAT, deposit promotion, campaigns). The @@ -1780,12 +1887,24 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { return } + // Apply eligible campaign discounts BEFORE the split records are inserted + // (C1): ComputeEligibleDiscounts refuses to apply NEW discounts once a + // booking has 2+ completed real payments, and the split below would + // otherwise count deposit + balance as exactly those 2 payments — a full + // discounted payment would never get its discount row and the booking + // would never auto-complete. Running the discount first means the guard + // only sees payments that existed before this transaction, so the + // discounted total is applied and bookingIsFullyPaid (which counts + // discount rows) completes the booking. The call is idempotent: discounts + // already recorded for the booking are skipped by the duplicate check. + applyEligibleCampaignsAtPayment(r.Context(), tx2, bookingID, userID) + // Build payment records — may split a single Square charge into // a deposit portion (up to 50% of booking total) plus a balance // portion, so the refund system can correctly track deposit vs // non-deposit money per the deposit protection policy. bookingInfo, bErr := service.GetBookingPaymentInfo(r.Context(), bookingID) - fees := service.CalculateFees(req.Amount, "online") + fees := service.CalculateFees(chargeAmount, "online") primaryRecord := PaymentRecord{ BookingID: bookingID, PaymentType: req.PaymentType, @@ -1903,11 +2022,6 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { completeActiveBookingFromPayment(r.Context(), tx2, bookingID) } - // Apply eligible campaign discounts inside the payment transaction, so - // atomicity with the payment inserts is guaranteed. The call is idempotent - // — if discounts were already applied, the duplicate check skips them. - applyEligibleCampaignsAtPayment(r.Context(), tx2, bookingID, userID) - if cErr := tx2.Commit(r.Context()); cErr != nil { log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but DB transaction commit failed: %v — manual reconciliation required", paymentResult.Status, paymentResult.SquarePayID, cErr) @@ -1920,7 +2034,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { BookingID: bookingID, PaymentType: req.PaymentType, Status: "completed", - Amount: req.Amount, + Amount: chargeAmount, CardBrand: paymentResult.CardBrand, CardLast4: paymentResult.CardLast4, ReceiptURL: paymentResult.ReceiptURL, @@ -2247,6 +2361,16 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) { } service := NewPaymentService() + + // 2FA gating (H4): persisting a card via the account "add card" endpoint + // requires 2FA when the feature is enforced — the same gate the booking + // and tip flows apply to req.SaveCard. Persisting a stored credential is + // exactly what the PSD2 SCA stand-in protects, so the dedicated save-card + // endpoint must not be the un-gated side door. + if !requireTwoFactorForCardAccess(w, r, service, userID) { + return + } + card, err := service.CreatePaymentMethodFromToken(r.Context(), userID, req.CardToken) if err != nil { if isDefinitiveCardSaveFailure(err) { @@ -2271,9 +2395,12 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) { // (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 +// CARD_TOKEN_USED / CARD_TOKEN_EXPIRED / INVALID_CARD), and additionally treats +// any error carrying Square's INVALID_REQUEST_ERROR CATEGORY as definitive — +// real 400 card-save failures (e.g. MISSING_REQUIRED_PARAMETER) arrive with +// that category and a specific code, so checking the category catches them all. +// INVALID_REQUEST_ERROR is a category, NOT a code: it must be matched via +// square.ErrorCategory, never as a code. 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 { @@ -2284,7 +2411,10 @@ func isDefinitiveCardSaveFailure(err error) bool { return true } switch square.ErrorCode(err) { - case "SOURCE_USED", "CARD_TOKEN_USED", "CARD_TOKEN_EXPIRED", "INVALID_CARD", "INVALID_REQUEST_ERROR": + case "SOURCE_USED", "CARD_TOKEN_USED", "CARD_TOKEN_EXPIRED", "INVALID_CARD": + return true + } + if square.ErrorCategory(err) == "INVALID_REQUEST_ERROR" { return true } return false @@ -3630,6 +3760,10 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { Note: "tip", BuyerEmail: buyerEmail, VerificationToken: verificationToken, + // C3: the tip charge is cardholder-initiated whether it uses a saved + // card (ccof — customer_details required) or a freshly entered card + // (cnon — buyer present), so the flag is true either way. + CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true}, } // M1: store the verbatim request JSON so the sweep can replay the charge @@ -3641,7 +3775,9 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { // 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 AND (square_request_snapshot IS NULL OR square_request_snapshot = '')`, string(snap), paymentID); sErr != nil { + } else if stored, eErr := encryptSnapshot(snap); eErr != nil { + log.Printf("Failed to encrypt square_request_snapshot for tip payment %s: %v", paymentID, eErr) + } 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(stored), paymentID); sErr != nil { log.Printf("Failed to store square_request_snapshot for tip payment %s: %v", paymentID, sErr) } diff --git a/backend/handlers/payments/idempotency_helpers.go b/backend/handlers/payments/idempotency_helpers.go new file mode 100644 index 0000000..f9bd2ee --- /dev/null +++ b/backend/handlers/payments/idempotency_helpers.go @@ -0,0 +1,51 @@ +package payments + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// maxIdempotencyKeyLength caps idempotency keys at Square's /v2/payments limit +// (45 chars). The same key is replayed to CreatePayment, so the stricter +// 45-char cap applies even where a destination (e.g. CreateCheckout) allows 64. +// Client-supplied keys are validated against it ("omitempty,max=45") and +// server-derived keys are truncated to it via truncateIdempotencyKey. +const maxIdempotencyKeyLength = 45 + +// truncateIdempotencyKey applies the deterministic >45-char sha256 truncation +// shared by the derive* idempotency-key helpers: a candidate longer than +// maxIdempotencyKeyLength is hashed with SHA-256 and returned as +// "-", which stays within Square's +// 45-char /v2/payments limit. The hash is deterministic, so identical +// candidates always truncate to the same key — a lost-response retry re-derives +// the same truncated key and Square dedups the charge. Candidates at or under +// the limit are returned verbatim. +func truncateIdempotencyKey(prefix, candidate string) string { + if len(candidate) <= maxIdempotencyKeyLength { + return candidate + } + sum := sha256.Sum256([]byte(candidate)) + return prefix + "-" + hex.EncodeToString(sum[:16]) +} + +// nextIdempotencyCandidate returns the idempotency-key candidate for slot +// sequence seq: the base key itself at seq 0, or "base-seq" at seq >= 1, then +// truncated via truncateIdempotencyKey so the final key stays inside Square's +// 45-char /v2/payments limit. The truncation prefix is derived from the base +// key ("gc-..." -> "gc", "till-..." -> "till") so the truncated form keeps the +// caller's namespace prefix. The slot-scan callers (scanTillIdempotencyKeySlot, +// deriveGiftCardIdempotencyKey) use this under their advisory lock so the +// scan-and-insert sequence is stable across retries. +func nextIdempotencyCandidate(base string, seq int) string { + candidate := base + if seq > 0 { + candidate = fmt.Sprintf("%s-%d", base, seq) + } + prefix := base + if i := strings.IndexByte(base, '-'); i > 0 { + prefix = base[:i] + } + return truncateIdempotencyKey(prefix, candidate) +} diff --git a/backend/handlers/payments/m4_tip_refund_redesign_test.go b/backend/handlers/payments/m4_tip_refund_redesign_test.go index cabf16e..404732f 100644 --- a/backend/handlers/payments/m4_tip_refund_redesign_test.go +++ b/backend/handlers/payments/m4_tip_refund_redesign_test.go @@ -85,6 +85,10 @@ func TestTipPayment_AcceptedAfterStart(t *testing.T) { // M4-2: Cap pay-early at 100% — reject overpayment instead of silent tip // ============================================================================= +// TestBookingPayment_OverflowRejected_NoSilentTip verifies the M4/M7 overflow +// gate: a pre-start 'full' payment that exceeds the booking total is rejected +// with 400 overflow_tip_confirmation_required (the tip conversion needs the +// client's explicit confirmation) and creates no payment record. func TestBookingPayment_OverflowRejected_NoSilentTip(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) @@ -105,14 +109,47 @@ func TestBookingPayment_OverflowRejected_NoSilentTip(t *testing.T) { w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) - assert.Contains(t, w.Body.String(), "remaining balance") + assert.Contains(t, w.Body.String(), "The extra amount will be recorded as a tip") + assert.Contains(t, w.Body.String(), "overflow_tip_confirmation_required") // No payment records may be created (the rejection happens before any // pending record insert or Square charge), and no tip may be silently carved. var count int err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&count) require.NoError(t, err) - assert.Equal(t, 0, count, "overpayment must not create any payment record") + assert.Equal(t, 0, count, "unconfirmed overpayment must not create any payment record") +} + +// TestBookingPayment_Overflow_Confirmed_RecordsTip pins the M7 confirmed path: +// the same pre-start overpayment WITH confirm_overflow_tip=true proceeds and +// the overflow beyond the booking total is recorded as a tip record (not +// silently dropped or double-counted). +func TestBookingPayment_Overflow_Confirmed_RecordsTip(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, bookingID, _ := setupTestData(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + + // £60 on a £50 booking with deposit room: deposit £25 + balance £25 + £10 + // tip overflow. + cardToken := "cnon:overflow-confirmed-card" + req := CreateBookingPaymentRequest{ + Amount: 6000, + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "overflow-confirmed-" + bookingID, + ConfirmOverflowTip: true, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + var tipCount int + err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed'`, bookingID).Scan(&tipCount) + require.NoError(t, err) + assert.Equal(t, 1, tipCount, "a confirmed overpayment must be recorded as a tip record") } func TestBookingPayment_FullRemainingBalance_Accepted(t *testing.T) { diff --git a/backend/handlers/payments/money_safety_fixes_test.go b/backend/handlers/payments/money_safety_fixes_test.go index 5637be3..03c4dfe 100644 --- a/backend/handlers/payments/money_safety_fixes_test.go +++ b/backend/handlers/payments/money_safety_fixes_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -313,6 +314,95 @@ func TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges(t *testing. require.Equal(t, 2, cardCount) } +// TestBuyGiftCard_NoClientKey_FailedSlot_AdvancesToFreshKey locks the A10 fix: +// a no-key purchase whose payment row was swept/declined to 'failed' must NOT +// permanently block the identical repurchase. The slot scan advances past BOTH +// COMPLETED and FAILED rows (mirroring the till's scanTillIdempotencyKeySlot), +// so the repurchase derives a FRESH key and charges again instead of +// 409-rejecting forever on the failed row. +func TestBuyGiftCard_NoClientKey_FailedSlot_AdvancesToFreshKey(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(userID, "verified_email") + + // The deterministic fallback key a no-key £20 self purchase would derive. + failedKey := fmt.Sprintf("gc-%s-2000-self-new", userID) + _, err = tx.Exec(ctx, ` + INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at) + VALUES ('full', 'online_square', 'failed', 20.00, $1, $2, NOW(), NOW()) + `, failedKey, userID) + require.NoError(t, err) + + // The identical repurchase must SUCCEED on a fresh key — not 409 forever. + if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code != http.StatusCreated { + t.Fatalf("expected the repurchase after a failed slot to succeed, got %d: %s", code, body) + } + + // Two distinct keys: the failed slot key + the fresh advance key. + var keyCount int + require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount)) + require.Equal(t, 2, keyCount, "the repurchase must diverge onto a fresh key, not reuse the failed slot") +} + +// TestBuyGiftCard_ForeignIdempotencyKey_NotReused locks the A6 fix: a +// client-supplied idempotency key that matches ANOTHER user's payment row must +// never be returned (completed), reused (pending), or rejected on (failed) — +// cross-user hijack. The purchase proceeds as a fresh request on a fresh key. +func TestBuyGiftCard_ForeignIdempotencyKey_NotReused(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + victimID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + attackerID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + token := jwt.GenerateTestToken(attackerID, "verified_email") + + // The victim's COMPLETED payment under a deterministic/guessable key. + victimKey := fmt.Sprintf("gc-%s-2000-self-new", victimID) + _, err = tx.Exec(ctx, ` + INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at) + VALUES ('full', 'online_square', 'completed', 20.00, $1, $2, NOW(), NOW()) + `, victimKey, victimID) + require.NoError(t, err) + + // The attacker supplies the victim's key: must NOT get the victim's + // completed payment back (which would be a false success leaking the + // victim's row) — it must proceed as a fresh charge. + reqBody, _ := json.Marshal(map[string]interface{}{ + "amount": 2000, + "recipient_type": "self", + "new_card_token": "cnon:card-nonce-ok", + "idempotency_key": victimKey, + }) + req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) + w := httptest.NewRecorder() + r := chi.NewRouter() + r.Use(mw.RequireAuth) + r.Post("/api/user/giftcards/buy", BuyGiftCard) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusCreated, w.Code, "a foreign-key purchase must proceed as a fresh charge, body: %s", w.Body.String()) + + // A gift card was issued to the ATTACKER, not the victim. + var cardCount int + require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", attackerID).Scan(&cardCount)) + require.Equal(t, 1, cardCount, "the attacker must receive their own gift card") + // The victim's payment row is untouched and the attacker got their own row. + var victimPayCount int + require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", victimKey).Scan(&victimPayCount)) + require.Equal(t, 1, victimPayCount, "the victim's payment row must not be reused or duplicated") + var attackerPayCount int + require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1 AND status = 'completed'", attackerID).Scan(&attackerPayCount)) + require.Equal(t, 1, attackerPayCount, "the attacker must have exactly one completed payment of their own") +} + // ============================================================================= // H4 — a COMPLETED provisional terminal checkout must be recorded, not just // released diff --git a/backend/handlers/payments/p14_payment_fixes_test.go b/backend/handlers/payments/p14_payment_fixes_test.go index 9a758ab..1fa1297 100644 --- a/backend/handlers/payments/p14_payment_fixes_test.go +++ b/backend/handlers/payments/p14_payment_fixes_test.go @@ -82,23 +82,28 @@ func (c *recordingCustomerClient) customerCalls() []string { } // definitiveChargeClient simulates a Square charge rejection that can never -// succeed (declined) — a definitive failure. +// succeed (declined) — a definitive failure. createErr carries the structured +// CARD_DECLINED error the real client produces (see the construction sites), so +// chargeFailureStatus classifies it as 402 and isDefinitiveChargeFailure claws +// the funded card back. type definitiveChargeClient struct { square.SquareClient + createErr error } func (c *definitiveChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) { - return nil, fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/CARD_DECLINED] card declined") + return nil, c.createErr } // ambiguousChargeClient simulates a transport-level charge failure where Square // may or may not have processed the payment — an ambiguous failure. type ambiguousChargeClient struct { square.SquareClient + createErr error } func (c *ambiguousChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) { - return nil, fmt.Errorf("network error: connection reset by peer") + return nil, c.createErr } // --------------------------------------------------------------------------- @@ -172,7 +177,7 @@ func TestCreateTillSale_DefinitiveFailure_ClawsBackCreatedGiftCard(t *testing.T) adminToken := jwt.GenerateTestToken(adminID, "admin") origClient := SquareClient - SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()} + SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")} defer func() { SquareClient = origClient }() reqBody := TillSaleRequest{ @@ -236,7 +241,7 @@ func TestCreateTillSale_DefinitiveFailure_ClawsBackTopUp(t *testing.T) { `, adminID).Scan(&gcID)) origClient := SquareClient - SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()} + SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")} defer func() { SquareClient = origClient }() reqBody := TillSaleRequest{ @@ -289,7 +294,7 @@ func TestCreateTillSale_DefinitiveFailure_ClawsBackRedeemedCard(t *testing.T) { adminToken := jwt.GenerateTestToken(adminID, "admin") origClient := SquareClient - SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()} + SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")} defer func() { SquareClient = origClient }() reqBody := TillSaleRequest{ @@ -328,7 +333,7 @@ func TestCreateTillSale_AmbiguousFailure_LeavesCardFundedPending(t *testing.T) { adminToken := jwt.GenerateTestToken(adminID, "admin") origClient := SquareClient - SquareClient = &ambiguousChargeClient{SquareClient: square.NewDevClient()} + SquareClient = &ambiguousChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareAPIError(t, http.StatusInternalServerError)} defer func() { SquareClient = origClient }() reqBody := TillSaleRequest{ @@ -351,7 +356,7 @@ func TestCreateTillSale_AmbiguousFailure_LeavesCardFundedPending(t *testing.T) { r.Post("/api/admin/till/sale", CreateTillSale) r.ServeHTTP(w, req) - require.Equal(t, http.StatusPaymentRequired, w.Code) + require.Equal(t, http.StatusServiceUnavailable, w.Code, "an ambiguous charge failure must classify as 503 (not a definitive 402)") // Ambiguous failure — the sale stays pending for the sweep, NOT failed. var status string diff --git a/backend/handlers/payments/payments_review_fixes_test.go b/backend/handlers/payments/payments_review_fixes_test.go index 3990007..afb5e9c 100644 --- a/backend/handlers/payments/payments_review_fixes_test.go +++ b/backend/handlers/payments/payments_review_fixes_test.go @@ -497,7 +497,7 @@ func TestCreateTillSale_PendingRetry_DefinitiveFailure_ClawsBack(t *testing.T) { require.NoError(t, err) origClient := SquareClient - SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()} + SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")} defer func() { SquareClient = origClient }() reqBody := TillSaleRequest{ diff --git a/backend/handlers/payments/payments_test.go b/backend/handlers/payments/payments_test.go index 7500064..215c0e8 100644 --- a/backend/handlers/payments/payments_test.go +++ b/backend/handlers/payments/payments_test.go @@ -4578,3 +4578,66 @@ func TestBuildSplitRecords_TipOverflow_SeparateTipRecord(t *testing.T) { t.Error("tip split must share square_payment_id") } } + +// TestBookingPayment_FullDiscountedAmount_AppliesDiscountAndCompletes is the +// C1 money-bug regression: a user paying the FULL discounted amount on a +// booking with an active time-based campaign must have the campaign discount +// applied (booking_discounts row + discount payment record + campaign +// redemption) and the booking must auto-complete. Previously the deposit+ +// balance split was inserted BEFORE applyEligibleCampaignsAtPayment ran, so +// the split's two just-inserted completed records tripped the +// existingPayment>=2 guard in ComputeEligibleDiscounts and the discount was +// never applied — the booking stayed unpaid on paper and never completed. +func TestBookingPayment_FullDiscountedAmount_AppliesDiscountAndCompletes(t *testing.T) { + t.Parallel() + ctx, tx := testutils.SetupTestTx(t) + + // Fixture booking total is £50 (one test service). A 10% time-based + // campaign makes the discounted total £45. + userID, bookingID, _ := setupTestData(t, ctx, tx) + userToken := jwt.GenerateUserToken(userID) + + now := clock.Now() + var campaignID string + err := tx.QueryRow(ctx, ` + INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed) + VALUES ($1, 'time_based', 10, 'active', $2, $3, 0) + RETURNING id + `, "Summer Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID) + require.NoError(t, err) + + cardToken := "cnon:discounted-full" + req := CreateBookingPaymentRequest{ + Amount: 4500, // £45 = £50 - 10% discount + PaymentType: "full", + NewCardToken: &cardToken, + IdempotencyKey: "discounted-full-" + bookingID, + } + + handler := CreateBookingPayment + w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + // The campaign discount must have been applied for this booking. + var discountCount int + require.NoError(t, tx.QueryRow(ctx, + `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND source_id = $2`, + bookingID, campaignID).Scan(&discountCount)) + assert.Equal(t, 1, discountCount, "the campaign discount must be applied when the full discounted amount is paid") + + // The discount payment record and the campaign redemption counter follow. + var discountPaymentCount int + require.NoError(t, tx.QueryRow(ctx, + `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&discountPaymentCount)) + assert.Equal(t, 1, discountPaymentCount, "the discount payment record must exist") + + var redeemed int + require.NoError(t, tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed)) + assert.Equal(t, 1, redeemed, "the campaign must be redeemed exactly once") + + // The booking must auto-complete: £25 deposit + £20 balance (real money) + // plus the £5 discount covers the £50 total. + var status string + require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)) + assert.Equal(t, "completed", status, "a booking paid to its discounted total must auto-complete") +} diff --git a/backend/handlers/payments/refunds.go b/backend/handlers/payments/refunds.go index 4a90ed2..91490a5 100644 --- a/backend/handlers/payments/refunds.go +++ b/backend/handlers/payments/refunds.go @@ -12,6 +12,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "crussell/clock" @@ -21,6 +22,163 @@ import ( "github.com/jackc/pgx/v5" ) +// Refunds are exempt from buyer verification. Square's RefundPayment endpoint +// does not support 3DS/SCA verification tokens (verification is a charge-time +// concept — PSD2 SCA applies to the original payment capture, never to the +// money flowing back out), so no refund path carries a verification token and +// none ever will. The practical already-refunded signal is Square's +// REFUND_AMOUNT_INVALID error (PAYMENT_ALREADY_REFUNDED is no longer +// documented): on a genuinely-refunded payment that code is NOT a decline — +// the handler reconciles via an EXACT-amount COMPLETED-refund check +// (reconcileRefundAtSquareExact, see the A11 disambiguation in +// processChargeGroup / processManualPaymentGroup) and resolves the refund row +// to 'completed' instead of failing it, so the over-refund guard never +// re-issues money Square already returned. +// +// stalePendingRefundAge is the age guard for pending refunds: Square's +// idempotency-key retention is finite (~24h), so a pending refund older than +// this must be RECONCILED against Square (ListPaymentRefunds) before any +// re-issue — re-issuing with a key Square no longer retains would be treated +// as a NEW refund (double refund). A pending row older than +// stalePendingRefundAge that Square shows no COMPLETED refund for is marked +// 'failed' and surfaced for manual arrangement instead. +const stalePendingRefundAge = 23 * time.Hour + +// maxManualRefundAttempts caps retry attempts for a refund stuck on a +// decline/ambiguous outcome before it is resolved terminally. Both the manual +// sweep (processManualPaymentGroup / resolveManualRefundAtCap) and the +// cancellation charge-group sweeps share the same cap: rows are retried while +// refund_attempts < maxManualRefundAttempts, and at the cap a refund is +// reconciled at Square FIRST (never marked failed while the money state is +// unknown — that would let the over-refund guard exclude money that actually +// left the business), then resolved to 'completed' or 'failed' + admin +// notification. The SQL literals below are formatted with this constant so +// the DB filter and the Go cap can never drift apart. +const maxManualRefundAttempts = 3 + +// maxConsecutiveReconcileFailures is the number of consecutive cap-time +// reconcile failures (each causing a re-arm under the attempt cap) before a +// refund row is surfaced in the admin notification centre. A reconcile failure +// is an UNKNOWN money state — the row is never marked 'failed' on it — but +// silently re-arming forever would oscillate the row between the cap and +// cap-1 indefinitely with zero admin visibility (A5b/A5c). After this many +// consecutive failures a deduped 'critical_payment_log' admin notification is +// inserted so the owner learns the reconcile is hard-failing. +const maxConsecutiveReconcileFailures = 5 + +// manualReconcileFailures counts consecutive cap-time reconcile failures per +// refund row (keyed by refunds.id). resolveManualRefundAtCap and the +// charge-group cap path re-arm a row under the attempt cap on a reconcile +// error so the next sweep re-picks it; without a counter that re-arm loops +// forever with no notification. The counter is in-memory (no schema change — +// the schema is single-source and pre-launch, no ALTERs): it counts +// CONSECUTIVE failures, is reset whenever a reconcile succeeds (the row is +// resolved completed/failed), and after maxConsecutiveReconcileFailures +// failures triggers a deduped critical_payment_log admin notification. A +// process restart merely resets the counter, deferring the notification by a +// few sweeps — never suppressing it (the row stays pending and keeps being +// re-reconciled, so the notification eventually fires). +var ( + manualReconcileFailureMu sync.Mutex + manualReconcileFailures = make(map[string]int) +) + +// trackReconcileFailureReArm records one more consecutive cap-time reconcile +// failure for each refund row and, once a row crosses +// maxConsecutiveReconcileFailures, surfaces a deduped 'critical_payment_log' +// admin notification for the affected booking(s) — the admin MUST learn a +// reconcile is hard-failing instead of the row silently oscillating under the +// attempt cap forever (A5b/A5c). +func trackReconcileFailureReArm(ctx context.Context, ids []string) { + manualReconcileFailureMu.Lock() + notify := false + for _, id := range ids { + manualReconcileFailures[id]++ + if manualReconcileFailures[id] == maxConsecutiveReconcileFailures { + notify = true + } + } + manualReconcileFailureMu.Unlock() + if !notify { + return + } + notifyCriticalReconcileFailure(ctx, ids) +} + +// resetReconcileFailureCount clears a refund row's consecutive-reconcile- +// failure counter. Called whenever a reconcile SUCCEEDS (the row is resolved +// to 'completed' or definitively 'failed'), so the counter reflects +// consecutive failures only — a success in between breaks the streak. +func resetReconcileFailureCount(ids ...string) { + manualReconcileFailureMu.Lock() + for _, id := range ids { + delete(manualReconcileFailures, id) + } + manualReconcileFailureMu.Unlock() +} + +// notifyCriticalReconcileFailure inserts ONE 'critical_payment_log' admin +// notification per affected booking (deduped by insertCriticalPaymentNotification) +// so a hard-failing reconcile surfaces in the admin notification centre. +// Rows without a booking (gift-card purchases) collapse into one booking-less +// notification — the money event is surfaced, not lost. +func notifyCriticalReconcileFailure(ctx context.Context, ids []string) { + rows, err := db.Conn.Query(ctx, ` + SELECT DISTINCT booking_id FROM refunds + WHERE id = ANY($1) AND booking_id IS NOT NULL + `, ids) + if err != nil { + log.Printf("Failed to query bookings for critical reconcile-failure notification: %v", err) + insertCriticalPaymentNotification(ctx, nil, nil) + return + } + var bookingIDs []string + for rows.Next() { + var b string + if err := rows.Scan(&b); err == nil { + bookingIDs = append(bookingIDs, b) + } + } + rows.Close() + if len(bookingIDs) == 0 { + insertCriticalPaymentNotification(ctx, nil, nil) + return + } + for _, b := range bookingIDs { + bid := b + insertCriticalPaymentNotification(ctx, &bid, nil) + } +} + +// reconcileRefundAtSquareExact checks Square for a COMPLETED refund matching +// the EXACT payment+amount, WITHOUT an age bound. Same exact-match semantics +// as reconcileRefundAtSquare (payment_id AND status COMPLETED AND amount), minus +// the begin_time filter: the REFUND_AMOUNT_INVALID disambiguation (A11) must +// not miss a pre-existing refund that predates our refund row. Only an +// exact-amount COMPLETED refund proves the money for THIS amount already moved +// — a smaller partial refund does NOT, and attributing it would mark the row +// completed when only part of the amount was refunded. +// +// Tri-state return matches reconcileRefundAtSquare: +// +// (id, nil) — exact COMPLETED refund found +// (nil, nil) — genuinely no exact match +// (nil, err) — reconcile failed (network/API error) +func reconcileRefundAtSquareExact(ctx context.Context, chargeID string, amountCents int64) (*string, error) { + refunds, err := SquareClient.ListPaymentRefunds(ctx, chargeID, time.Time{}) + if err != nil { + log.Printf("Failed to reconcile charge %s against Square: %v", chargeID, err) + return nil, err + } + for i := range refunds { + r := &refunds[i] + if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == amountCents { + return &r.ID, nil + } + } + return nil, nil +} + type RefundCalculationResult struct { TotalPrePaid float64 `json:"total_pre_paid"` ProtectedDeposit float64 `json:"protected_deposit"` @@ -51,6 +209,13 @@ type paymentRow struct { // The "protected deposit" is defined as min(totalPrePaid, subtotal * 0.50). // This means up to 50% of the subtotal is always treated as a deposit for // refund purposes, regardless of whether deposit_required was set on the booking. +// +// These tiers are the DEFAULT retention for a cancellation (customer calls to +// cancel, or the admin cancels on the customer's behalf without forgiving +// fees). The admin "forgive fees" path (forceFullRefund) overrides the tiers +// entirely so the business keeps nothing — see ProcessCancellationRefundTx for +// the two intents (excusable cancellation vs business-initiated cancellation) +// that share that override. func CalculateRefundForCancellation( subtotal float64, totalPrePaid float64, @@ -143,9 +308,25 @@ func lockCancellationPayments(ctx context.Context, tx pgx.Tx, payments []payment // ProcessCancellationRefundTx is like ProcessCancellationRefund but uses an // externally-provided transaction. The caller owns the transaction lifecycle // (commit/rollback). Pass a non-nil pgx.Tx to share an existing transaction. -// forceFullRefund overrides the notice-tier calculation so the ENTIRE net -// pre-paid amount is refunded (admin "forgive fees" path) regardless of how -// close to the appointment the cancellation happens. +// +// The admin "forgive fees" checkbox (forceFullRefund) serves TWO distinct +// purposes, and both route here identically — it overrides the notice-tier +// calculation so the ENTIRE net pre-paid amount is refunded regardless of how +// close to the appointment the cancellation happens: +// +// - (A) Genuinely excusable cancellation: the customer has a legitimate +// excuse (medical, emergency, technical fault, etc.) and the business +// chooses to waive its notice-period fees as a goodwill gesture. +// - (B) Business-initiated cancellation: the salon had to cancel the +// appointment and chooses NOT to keep the money (the deposit/notice +// retention would be unfair when the cancellation is the business's +// doing). +// +// These two intents are deliberately NOT distinguished in the refund +// calculation — both mean "the business keeps nothing". When the admin cancels +// on a customer's behalf or a customer calls up to cancel without a forgivable +// excuse, the notice tiers below apply (forceFullRefund=false); the checkbox +// is the explicit opt-out from that retention. func ProcessCancellationRefundTx( ctx context.Context, tx pgx.Tx, @@ -238,10 +419,14 @@ func ProcessCancellationRefundTx( // and a cancellation refund cannot be recorded after the manual guard ran // without the two serializing. Square no longer documents // PAYMENT_ALREADY_REFUNDED; the realistic already-refunded response is - // REFUND_AMOUNT_INVALID, which the client maps to ErrRefundDeclined - // (definitive) — the charge-group/manual sweep handlers fail those rows and - // surface them via admin_notification rather than silently blocking the - // amount in the guard. + // REFUND_AMOUNT_INVALID. The square client reconciles that code against + // Square's refund list (PaymentWasRefunded) and maps an already-refunded + // payment to ErrRefundAlreadyProcessed; the charge-group/manual sweep + // handlers additionally reconcile REFUND_AMOUNT_INVALID via an exact-amount + // COMPLETED-refund check (reconcileRefundAtSquareExact, defense-in-depth — + // see the A11 disambiguation) and resolve those rows to 'completed' — never + // 'failed' + admin_notification, which would let the over-refund guard + // re-issue money Square already returned. priorRefunds := make(map[string]float64) prRows, prErr := tx.Query(ctx, ` SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds @@ -551,17 +736,17 @@ func ProcessPendingSquareRefunds(ctx context.Context, bookingID string, reason s // notification centre for in-person arrangement. Must run OUTSIDE any // GROUP BY — Postgres lumps NULLs together, so these rows can't be // handled in the charge grouping below. - rows, err := db.Conn.Query(ctx, ` + rows, err := db.Conn.Query(ctx, fmt.Sprintf(` UPDATE refunds r SET status = 'failed' FROM payments p WHERE p.id = r.payment_id AND r.booking_id = $1 - AND r.status = 'pending' AND r.refund_attempts < 3 + AND r.status = 'pending' AND r.refund_attempts < %d AND p.payment_method IN ('online_square', 'in_person_card') AND p.square_payment_id IS NULL AND r.origin = 'cancellation' RETURNING r.id - `, bookingID) + `, maxManualRefundAttempts), bookingID) if err != nil { log.Printf("Failed to mark Square-less card refunds failed for booking %s: %v", bookingID, err) } else { @@ -600,16 +785,16 @@ func SweepPendingSquareRefunds(ctx context.Context) (int, error) { // for in-person arrangement. Must run OUTSIDE any GROUP BY — Postgres // lumps NULLs together, so these rows can't be handled in the charge // grouping below. - rows, err := db.Conn.Query(ctx, ` + rows, err := db.Conn.Query(ctx, fmt.Sprintf(` 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 r.status = 'pending' AND r.refund_attempts < %d AND p.payment_method IN ('online_square', 'in_person_card') AND p.square_payment_id IS NULL AND r.origin = 'cancellation' RETURNING r.id - `) + `, maxManualRefundAttempts)) if err != nil { log.Printf("Failed to mark Square-less card refunds failed: %v", err) } else { @@ -667,14 +852,14 @@ type pendingChargeRow struct { // have at least one eligible pending cancellation refund row. extraWhere is an // optional extra SQL predicate bound by args (e.g. "r.booking_id = $1"). func queryChargesWithPendingRefunds(ctx context.Context, extraWhere string, args ...any) []string { - q := ` + q := fmt.Sprintf(` SELECT DISTINCT p.square_payment_id FROM refunds r JOIN payments p ON p.id = r.payment_id - WHERE r.status = 'pending' AND r.refund_attempts < 3 + WHERE r.status = 'pending' AND r.refund_attempts < %d AND p.square_payment_id IS NOT NULL AND p.payment_method IN ('online_square', 'in_person_card') - AND r.origin = 'cancellation'` + AND r.origin = 'cancellation'`, maxManualRefundAttempts) if extraWhere != "" { q += " AND " + extraWhere } @@ -702,15 +887,15 @@ func queryChargesWithPendingRefunds(ctx context.Context, extraWhere string, args // fetchPendingChargeRows returns all eligible pending cancellation refund rows // for a single Square charge, ordered by refund id. func fetchPendingChargeRows(ctx context.Context, chargeID string) []pendingChargeRow { - rows, err := db.Conn.Query(ctx, ` + rows, err := db.Conn.Query(ctx, fmt.Sprintf(` SELECT r.id, p.id, r.amount, r.created_at FROM refunds r JOIN payments p ON p.id = r.payment_id WHERE p.square_payment_id = $1 - AND r.status = 'pending' AND r.refund_attempts < 3 + AND r.status = 'pending' AND r.refund_attempts < %d AND r.origin = 'cancellation' ORDER BY r.id - `, chargeID) + `, maxManualRefundAttempts), chargeID) if err != nil { log.Printf("Failed to query pending refunds for charge %s: %v", chargeID, err) return nil @@ -731,6 +916,20 @@ func fetchPendingChargeRows(ctx context.Context, chargeID string) []pendingCharg return out } +// isRefundAmountInvalid reports whether err carries Square's +// REFUND_AMOUNT_INVALID code — structurally (the real HTTP client wraps the +// code in a squareAPIError) or by message (the dev mock embeds the code in its +// simulated error). +func isRefundAmountInvalid(err error) bool { + if err == nil { + return false + } + if square.ErrorCode(err) == "REFUND_AMOUNT_INVALID" { + return true + } + return strings.Contains(err.Error(), "REFUND_AMOUNT_INVALID") +} + // reconcileRefundAtSquare checks Square for a COMPLETED refund matching the // exact charge-level amount before the age guard / attempt cap marks rows // 'failed'. Tri-state return: @@ -788,16 +987,16 @@ func insertRefundFailedNotifications(ctx context.Context, refundIDs []string) { } } -// pendingRowsAtAttemptCap returns the refund ids still pending at the 3-attempt -// cap — the candidates for terminal 'failed' resolution. +// pendingRowsAtAttemptCap returns the refund ids still pending at the +// maxManualRefundAttempts cap — the candidates for terminal 'failed' resolution. func pendingRowsAtAttemptCap(ctx context.Context, ids []string) []string { if len(ids) == 0 { return nil } - rows, err := db.Conn.Query(ctx, ` + rows, err := db.Conn.Query(ctx, fmt.Sprintf(` SELECT id FROM refunds - WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= 3 - `, ids) + WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d + `, maxManualRefundAttempts), ids) if err != nil { log.Printf("Failed to query refunds at attempt cap: %v", err) return nil @@ -883,10 +1082,10 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar for _, r := range rows { ids = append(ids, r.ID) } - pendingRows, err := db.Conn.Query(ctx, ` + pendingRows, err := db.Conn.Query(ctx, fmt.Sprintf(` SELECT id, amount, created_at FROM refunds - WHERE id = ANY($1) AND status = 'pending' AND refund_attempts < 3 - `, ids) + WHERE id = ANY($1) AND status = 'pending' AND refund_attempts < %d + `, maxManualRefundAttempts), ids) if err != nil { log.Printf("Failed to re-read pending refunds under lock (charge %s): %v", chargeID, err) return 0, nil @@ -906,12 +1105,12 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar } // Age guard: Square's idempotency-key retention is finite (~24h). If the - // oldest pending row predates 23 hours, re-issuing with the same charge key - // risks Square treating it as a NEW refund → double refund. Reconcile FIRST: - // money may already have moved at Square (response loss), and failing the - // rows without checking would let the over-refund guard exclude money that - // actually left the business. Only when Square shows no exact COMPLETED - // refund do we mark failed and surface for manual review. + // oldest pending row predates stalePendingRefundAge, re-issuing with the + // same charge key risks Square treating it as a NEW refund → double refund. + // Reconcile FIRST: money may already have moved at Square (response loss), + // and failing the rows without checking would let the over-refund guard + // exclude money that actually left the business. Only when Square shows no + // exact COMPLETED refund do we mark failed and surface for manual review. oldest := pending[0].CreatedAt for _, pr := range pending[1:] { if pr.CreatedAt.Before(oldest) { @@ -922,7 +1121,7 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar for _, pr := range pending { totalCents += int64(math.Round(pr.Amount * 100)) } - if clock.Now().Sub(oldest) > 23*time.Hour { + if clock.Now().Sub(oldest) > stalePendingRefundAge { sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalCents, oldest) switch { case rcErr != nil: @@ -951,7 +1150,7 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar // TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS // system lands; until then the admin_notifications row above is the only // channel. Verify the Square dashboard first. - log.Printf("Card refunds for charge %s are older than 23h and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", chargeID) + log.Printf("Card refunds for charge %s are older than stalePendingRefundAge (23h) and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", chargeID) return len(pending), nil } } @@ -975,14 +1174,48 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar // double-refunding the customer (C6). Deduping on the charge key returns // the original refund instead; the new row resolves against it and any // residual gap is a known, admin-visible shortfall rather than lost - // money. The 23h age-guard reconcile above still protects the cross-sweep - // case where Square's finite (~24h) key retention may have lapsed. + // money. The stalePendingRefundAge age-guard reconcile above still + // protects the cross-sweep case where Square's finite (~24h) key + // retention may have lapsed. sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{ PaymentID: chargeID, Amount: totalCents, IdempotencyKey: chargeAggKey(chargeID), Reason: reason, }) + // REFUND_AMOUNT_INVALID is Square's ambiguous signal for BOTH a genuinely + // invalid refund amount AND an already-refunded payment (Square no longer + // documents PAYMENT_ALREADY_REFUNDED). Disambiguate with the EXACT amount + // BEFORE the classification switch so the reconciliation applies whether + // the client classified the code as a definitive decline or as + // already-processed: only an exact-amount COMPLETED refund at Square proves + // the money for THIS amount already moved (A11). A smaller partial refund + // does NOT — marking the rows completed would claim the full amount was + // refunded when only part of it was. + if isRefundAmountInvalid(sqErr) { + sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, chargeID, totalCents) + switch { + case rcErr != nil: + // Reconcile failed — unknown money state. Keep the client's own + // classification below (the switch on sqErr) rather than making a + // money decision on partial data. + case sqRefundID != nil: + if _, upErr := db.Conn.Exec(ctx, ` + UPDATE refunds SET status = 'completed' + WHERE id = ANY($1) AND status = 'pending' + `, idsOf(pending)); upErr != nil { + log.Printf("Failed to resolve refunds completed after REFUND_AMOUNT_INVALID on already-refunded charge %s: %v", chargeID, upErr) + } + log.Printf("Charge %s already refunded at Square (REFUND_AMOUNT_INVALID + exact-amount COMPLETED refund %s) — marked %d refund row(s) completed, no admin notification", chargeID, *sqRefundID, len(pending)) + return len(pending), nil + default: + // No exact-amount COMPLETED refund exists — REFUND_AMOUNT_INVALID + // here is a genuine decline/invalid amount (the attempt would have + // over-refunded a partially-refunded payment), NOT an + // already-refunded signal. Route through the decline branch. + sqErr = square.ErrRefundDeclined + } + } switch { case sqErr == nil: // Resolve by Square's status: COMPLETED resolves the group; PENDING @@ -1020,8 +1253,8 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar return len(pending), nil case errors.Is(sqErr, square.ErrRefundDeclined): - // Definitive decline — money will never move. Bump attempts; at >=3 - // mark failed and surface for manual arrangement. + // Definitive decline — money will never move. Bump attempts; at + // maxManualRefundAttempts mark failed and surface for manual arrangement. if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET refund_attempts = refund_attempts + 1 WHERE id = ANY($1) AND status = 'pending' @@ -1029,11 +1262,11 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar log.Printf("Failed to increment refund attempts (charge %s): %v", chargeID, upErr) } if capIDs := pendingRowsAtAttemptCap(ctx, idsOf(pending)); len(capIDs) > 0 { - if _, upErr := db.Conn.Exec(ctx, ` + if _, upErr := db.Conn.Exec(ctx, fmt.Sprintf(` UPDATE refunds SET status = 'failed' - WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= 3 - `, capIDs); upErr != nil { - log.Printf("Failed to mark refunds failed after 3 attempts (charge %s): %v", chargeID, upErr) + WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d + `, maxManualRefundAttempts), capIDs); upErr != nil { + log.Printf("Failed to mark refunds failed after %d attempts (charge %s): %v", maxManualRefundAttempts, chargeID, upErr) } insertRefundFailedNotifications(ctx, capIDs) // TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS @@ -1046,7 +1279,7 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar default: // Ambiguous — Square may or may not have processed. Retried by the - // sweep, capped at 3 attempts. + // sweep, capped at maxManualRefundAttempts attempts. if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET refund_attempts = refund_attempts + 1 WHERE id = ANY($1) AND status = 'pending' @@ -1063,9 +1296,26 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar // Reconcile failed — unknown whether Square refunded. Leave // rows pending for the next sweep; NEVER mark failed on an // unknown state (that would let the over-refund guard exclude - // moved money). - log.Printf("Reconcile failed for ambiguous charge %s (%v) — leaving %d refund row(s) pending for the next sweep", chargeID, rcErr, len(capIDs)) + // moved money). But leaving the rows AT the cap strands them: + // the sweep only re-picks rows with refund_attempts < + // maxManualRefundAttempts, so capped rows are never + // re-reconciled and never admin-notified — silently stuck + // money (A5c). Re-arm the capped rows under the cap (mirroring + // resolveManualRefundAtCap) so the next sweep re-picks them, + // and track consecutive reconcile failures: after + // maxConsecutiveReconcileFailures consecutive failures a + // deduped 'critical_payment_log' admin notification surfaces + // the hard-failing reconcile. + if _, upErr := db.Conn.Exec(ctx, fmt.Sprintf(` + UPDATE refunds SET refund_attempts = %d + WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d + `, maxManualRefundAttempts-1, maxManualRefundAttempts), capIDs); upErr != nil { + log.Printf("Failed to re-arm capped refunds under the attempt cap after reconcile error (charge %s): %v", chargeID, upErr) + } + trackReconcileFailureReArm(ctx, capIDs) + log.Printf("Reconcile failed for ambiguous charge %s (%v) — re-armed %d refund row(s) under the attempt cap for the next sweep; never marked failed on an unknown state", chargeID, rcErr, len(capIDs)) case sqRefundID != nil: + resetReconcileFailureCount(capIDs...) if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = ANY($2) AND status = 'pending' @@ -1074,11 +1324,12 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar } log.Printf("Ambiguous card refunds for charge %s reconciled at Square — COMPLETED refund %s found, marked completed", chargeID, *sqRefundID) default: - if _, upErr := db.Conn.Exec(ctx, ` + resetReconcileFailureCount(capIDs...) + if _, upErr := db.Conn.Exec(ctx, fmt.Sprintf(` UPDATE refunds SET status = 'failed' - WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= 3 - `, capIDs); upErr != nil { - log.Printf("Failed to mark refunds failed after 3 attempts (charge %s): %v", chargeID, upErr) + WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d + `, maxManualRefundAttempts), capIDs); upErr != nil { + log.Printf("Failed to mark refunds failed after %d attempts (charge %s): %v", maxManualRefundAttempts, chargeID, upErr) } insertRefundFailedNotifications(ctx, capIDs) // TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS @@ -1180,16 +1431,16 @@ func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) { // 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, ` + rows, err := db.Conn.Query(ctx, fmt.Sprintf(` 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 r.status = 'pending' AND r.refund_attempts < %d AND p.payment_method IN ('online_square', 'in_person_card') AND p.square_payment_id IS NULL AND r.origin = 'manual' RETURNING r.id - `) + `, maxManualRefundAttempts)) if err != nil { log.Printf("Failed to mark Square-less manual refunds failed: %v", err) } else { @@ -1212,16 +1463,16 @@ func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) { // (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, ` + rows, err = db.Conn.Query(ctx, fmt.Sprintf(` 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 JOIN payments p ON p.id = r.payment_id WHERE r.status = 'pending' AND r.origin = 'manual' - AND r.refund_attempts < 3 + AND r.refund_attempts < %d AND p.square_payment_id IS NOT NULL ORDER BY r.payment_id, r.id - `) + `, maxManualRefundAttempts)) if err != nil { log.Printf("Failed to query manual pending refunds for retry: %v", err) return 0, nil @@ -1341,14 +1592,14 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man } // Re-read under the lock — only rows still pending and under the attempt // cap are eligible (a concurrent manual refund may have resolved some). - prRows, err := db.Conn.Query(ctx, ` + prRows, err := db.Conn.Query(ctx, fmt.Sprintf(` SELECT r.id, p.booking_id, r.amount, r.idempotency_key, r.reason, r.created_at, r.payment_id, p.square_payment_id, r.square_refund_id FROM refunds r JOIN payments p ON p.id = r.payment_id - WHERE r.id = ANY($1) AND r.status = 'pending' AND r.refund_attempts < 3 + WHERE r.id = ANY($1) AND r.status = 'pending' AND r.refund_attempts < %d ORDER BY r.id - `, ids) + `, maxManualRefundAttempts), ids) if err != nil { return 0, err } @@ -1385,7 +1636,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man oldest = pr.CreatedAt } } - if clock.Now().Sub(oldest) > 23*time.Hour { + if clock.Now().Sub(oldest) > stalePendingRefundAge { processedAged := 0 for i := range pending { pr := &pending[i] @@ -1416,7 +1667,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man // TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS // system lands; until then the admin_notifications row above is the only // channel. - log.Printf("Manual refund %s is older than 23h and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID) + log.Printf("Manual refund %s is older than stalePendingRefundAge (23h) and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID) } } return processedAged, nil @@ -1470,7 +1721,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man // recorded it → failed + admin notification; a reconcile error is an // UNKNOWN state → leave pending (never mark failed on an unknown state, // that would let the over-refund guard exclude money that may have - // moved). Mirrors the 23h age-guard branch above. + // moved). Mirrors the stalePendingRefundAge age-guard branch above. if pr.SquareRefundID != "" { sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt) switch { @@ -1507,8 +1758,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man if keyErr != nil { // The key could not be persisted — Square must not be called with an // empty/unknown key. Leave the row pending for the next sweep (never - // mark failed on an unknown state); the 23h age guard above will - // eventually reconcile it. + // mark failed on an unknown state); the stalePendingRefundAge age + // guard above will eventually reconcile it. log.Printf("Failed to ensure refund key for manual refund %s before re-issue: %v", pr.ID, keyErr) continue } @@ -1518,6 +1769,39 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man IdempotencyKey: idemKey, Reason: pr.Reason, }) + // REFUND_AMOUNT_INVALID is Square's ambiguous already-refunded-or- + // invalid-amount signal. Disambiguate with the EXACT amount (A11): only + // an exact-amount COMPLETED refund at Square proves THIS amount already + // moved — a smaller partial refund does NOT, and completing the row + // would claim the full amount was refunded when only part of it was. + // Mirrors the processChargeGroup disambiguation so the reconciliation + // applies whether the client classified the code as a definitive + // decline or as already-processed. + if isRefundAmountInvalid(sqErr) { + sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, pr.SquarePaymentID, amountCents) + switch { + case rcErr != nil: + // Reconcile failed — unknown money state. Keep the client's + // own classification below (the switch on sqErr) rather than + // making a money decision on partial data. + case sqRefundID != nil: + if _, upErr := db.Conn.Exec(ctx, ` + UPDATE refunds SET status = 'completed' + WHERE id = $1 AND status = 'pending' + `, pr.ID); upErr != nil { + log.Printf("Failed to resolve manual refund %s completed after REFUND_AMOUNT_INVALID on already-refunded payment: %v", pr.ID, upErr) + } + log.Printf("Manual refund %s: payment already refunded at Square (REFUND_AMOUNT_INVALID + exact-amount COMPLETED refund %s) — marked completed, no admin notification", pr.ID, *sqRefundID) + processed++ + continue + default: + // No exact-amount COMPLETED refund exists — REFUND_AMOUNT_INVALID + // is a genuine decline/invalid amount here (the attempt would + // have over-refunded a partially-refunded payment), NOT an + // already-refunded signal. Route through the decline branch. + sqErr = square.ErrRefundDeclined + } + } switch { case sqErr == nil: if _, upErr := db.Conn.Exec(ctx, ` @@ -1544,20 +1828,20 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man `, pr.ID); upErr != nil { log.Printf("Failed to increment attempts for manual refund %s: %v", pr.ID, upErr) } - if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= 3 { + if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= maxManualRefundAttempts { resolveManualRefundAtCap(ctx, pr, amountCents) } default: // Ambiguous — Square may or may not have processed. Retried by the - // sweep, capped at 3 attempts. + // sweep, capped at maxManualRefundAttempts. if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET refund_attempts = refund_attempts + 1 WHERE id = $1 `, pr.ID); upErr != nil { log.Printf("Failed to increment attempts for manual refund %s: %v", pr.ID, upErr) } - if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= 3 { + if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= maxManualRefundAttempts { resolveManualRefundAtCap(ctx, pr, amountCents) } } @@ -1566,36 +1850,55 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man } // resolveManualRefundAtCap reconciles a manual refund row that just hit the -// 3-attempt cap: money may have moved at Square despite decline/ambiguous -// responses, so reconcile FIRST — an exact COMPLETED refund resolves the row to -// completed; otherwise mark failed and notify the admin. +// maxManualRefundAttempts cap: money may have moved at Square despite +// decline/ambiguous responses, so reconcile FIRST — an exact COMPLETED refund +// resolves the row to completed; otherwise mark failed and notify the admin. +// The row is never re-issued here (reconcile is a read), so the cap cannot +// cause a double refund. func resolveManualRefundAtCap(ctx context.Context, pr *manualPendingRow, amountCents int64) { sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt) switch { case rcErr != nil: - // Reconcile failed — unknown whether Square refunded. Leave the row - // pending for the next sweep; NEVER mark failed on an unknown state - // (that would let the over-refund guard exclude moved money). - log.Printf("Reconcile failed for manual refund %s at attempt cap (%v) — leaving pending for the next sweep", pr.ID, rcErr) + // Reconcile failed — unknown whether Square refunded. NEVER mark failed + // on an unknown state (that would let the over-refund guard exclude + // moved money). Re-arm the row under the cap so the next sweep re-picks + // it, and track consecutive reconcile failures: after + // maxConsecutiveReconcileFailures consecutive failures a deduped + // 'critical_payment_log' admin notification surfaces the hard-failing + // reconcile — never silently forever (A5b). Re-issue stays safe: the + // row's idempotency key is persisted (ensureRefundKey), so Square + // dedups a same-key retry to the original refund — and once the row + // crosses stalePendingRefundAge the sweep's age guard reconciles it + // instead of re-issuing. + if _, upErr := db.Conn.Exec(ctx, ` + UPDATE refunds SET refund_attempts = $1 + WHERE id = $2 AND status = 'pending' AND refund_attempts >= $3 + `, maxManualRefundAttempts-1, pr.ID, maxManualRefundAttempts); upErr != nil { + log.Printf("Failed to re-arm manual refund %s under the attempt cap after reconcile error: %v", pr.ID, upErr) + } + trackReconcileFailureReArm(ctx, []string{pr.ID}) + log.Printf("Reconcile failed for manual refund %s at attempt cap (%v) — re-armed under the cap for the next sweep; never marked failed on an unknown state", pr.ID, rcErr) case sqRefundID != nil: + resetReconcileFailureCount(pr.ID) if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'completed', square_refund_id = $1 - WHERE id = $2 + WHERE id = $2 AND status = 'pending' `, *sqRefundID, pr.ID); upErr != nil { log.Printf("Failed to mark manual refund %s completed after Square reconcile: %v", pr.ID, upErr) } default: + resetReconcileFailureCount(pr.ID) if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'failed' - WHERE id = $1 + WHERE id = $1 AND status = 'pending' `, pr.ID); upErr != nil { - log.Printf("Failed to mark manual refund %s failed after 3 attempts: %v", pr.ID, upErr) + log.Printf("Failed to mark manual refund %s failed after %d attempts: %v", pr.ID, maxManualRefundAttempts, upErr) } insertRefundFailedNotifications(ctx, []string{pr.ID}) // TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS // system lands; until then the admin_notifications row above is the only // channel. - log.Printf("Manual refund %s reached 3 attempts with no COMPLETED refund found at Square — marked 'failed' and admin notified; TODO email user+admin, VERIFY Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID) + log.Printf("Manual refund %s reached %d attempts with no COMPLETED refund found at Square — marked 'failed' and admin notified; TODO email user+admin, VERIFY Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID, maxManualRefundAttempts) } } diff --git a/backend/handlers/payments/refunds_test.go b/backend/handlers/payments/refunds_test.go index 25851bc..82509c2 100644 --- a/backend/handlers/payments/refunds_test.go +++ b/backend/handlers/payments/refunds_test.go @@ -2267,9 +2267,14 @@ func TestProcessPendingSquareRefunds_Declined_ThreeAttempts_Failed(t *testing.T) // verifies the tri-state reconcile under the ambiguous path (plain transport // error): the row is retried up to the 3-attempt cap, and on the run that hits // the cap the reconcile against Square ALSO fails (same transport failure) — an -// unknown state. The row MUST stay 'pending' (NOT 'failed', which would let the +// unknown money state. The row MUST stay 'pending' (NOT 'failed', which would let the // over-refund guard exclude money that may have moved) and NO admin_notification -// is inserted; the next sweep retries the reconcile. +// is inserted. Since the A5c fix the capped rows are re-armed under the cap on a +// reconcile error (mirroring resolveManualRefundAtCap) so the next sweep +// re-picks them — the row ends the third run at maxManualRefundAttempts-1, not +// stranded at the cap — and the consecutive-failure counter is still below +// maxConsecutiveReconcileFailures, so no critical_payment_log notification +// fires yet. func TestProcessPendingSquareRefunds_Ambiguous_ThreeAttempts_StaysPendingOnReconcileError(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) @@ -2332,8 +2337,11 @@ func TestProcessPendingSquareRefunds_Ambiguous_ThreeAttempts_StaysPendingOnRecon if status != "pending" { t.Errorf("expected status 'pending' after 3 ambiguous runs with a failing reconcile, got %q", status) } - if attempts != 3 { - t.Errorf("expected refund_attempts 3 after three ambiguous runs, got %d", attempts) + // A5c: the cap-time reconcile failure re-arms the capped row under the cap + // (instead of stranding it at the cap where the sweep would never re-pick + // it), so the third run ends at maxManualRefundAttempts-1, not at the cap. + if attempts != maxManualRefundAttempts-1 { + t.Errorf("expected refund_attempts re-armed to %d after three ambiguous runs with a failing reconcile, got %d", maxManualRefundAttempts-1, attempts) } // The terminal failure must NOT fire: the reconcile returned a network @@ -2348,6 +2356,18 @@ func TestProcessPendingSquareRefunds_Ambiguous_ThreeAttempts_StaysPendingOnRecon if notifCount != 0 { t.Errorf("expected NO admin_notification with reason 'refund_failed' (reconcile error leaves rows pending), got %d", notifCount) } + // The consecutive-reconcile-failure counter (A5b/A5c) is still below + // maxConsecutiveReconcileFailures after this single cap-time re-arm, so no + // critical_payment_log notification fires either. + var critCount int + err = db.Conn.QueryRow(freshCtx, + `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'critical_payment_log'`, bookingID).Scan(&critCount) + if err != nil { + t.Fatalf("failed to query critical_payment_log notifications: %v", err) + } + if critCount != 0 { + t.Errorf("expected NO critical_payment_log notification after one reconcile-failure re-arm, got %d", critCount) + } } // TestProcessPendingSquareRefunds_PartialManualThenCancel_IssuesResidual @@ -3752,3 +3772,632 @@ func TestSweepManualRetry_NullKey_SingleSquareRefund(t *testing.T) { t.Errorf("expected refund status 'completed', got %q", status) } } + +// ============================================================================= +// H2 — REFUND_AMOUNT_INVALID reconciliation (refunds.go) +// ============================================================================= + +// TestSweepManualRefund_RefundAmountInvalid_AlreadyRefunded_Completed covers +// the H2 reconciliation on the manual-sweep path: when the sweep's re-issue +// attempt hits REFUND_AMOUNT_INVALID on a payment Square HAS already refunded, +// the pending row resolves to 'completed' (never 'failed', no admin +// notification). The mock's RefundPayment reconciles internally: the payment is +// in its ledger with a recorded refund, so the over-refund attempt is answered +// ErrRefundAlreadyProcessed and the handler marks the row completed. +func TestSweepManualRefund_RefundAmountInvalid_AlreadyRefunded_Completed(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + + // Create the charge through the mock so the sweep's refund attempt hits a + // payment KNOWN to its ledger (the mock only reconciles over-refunds for + // payments it holds). + mock := square.NewDevClient().(*square.MockClient) + charge, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{ + Amount: 5000, + Currency: "GBP", + SourceID: "cnon:h2-already-refunded", + IdempotencyKey: "h2-already-refunded-charge", + }) + if err != nil { + t.Fatalf("failed to seed mock payment: %v", err) + } + if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", charge.ID, paymentID); err != nil { + t.Fatalf("failed to set square_payment_id: %v", err) + } + + var refundID string + err = tx.QueryRow(ctx, ` + INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at) + VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', NOW()) + RETURNING id + `, paymentID, bookingID, paymentID+"-h2-refund-5000").Scan(&refundID) + if err != nil { + t.Fatalf("failed to insert pending manual refund: %v", err) + } + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + + // Record a COMPLETED refund at Square BEFORE the sweep so the payment is + // already refunded; the sweep's re-issue attempt then over-refunds. + if _, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{ + PaymentID: charge.ID, + Amount: 5000, + IdempotencyKey: "seed-h2-already-refunded", + Reason: "customer request", + }); err != nil { + t.Fatalf("failed to seed Square refund: %v", err) + } + + origClient := SquareClient + SquareClient = mock + defer func() { SquareClient = origClient }() + + freshCtx := context.Background() + n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}}) + if err != nil { + t.Fatalf("processManualPaymentGroup failed: %v", err) + } + if n != 1 { + t.Fatalf("expected 1 manual refund processed, got %d", n) + } + + var status string + if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status); err != nil { + t.Fatalf("failed to query refund: %v", err) + } + if status != "completed" { + t.Errorf("expected refund 'completed' after REFUND_AMOUNT_INVALID on an already-refunded payment, got %q", status) + } + if n := mock.RefundKeyCount(); n != 1 { + t.Errorf("expected exactly 1 distinct Square refund (the pre-seeded one; the sweep must not re-issue), got %d", n) + } + var notifCount int + if err := db.Conn.QueryRow(freshCtx, + `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(¬ifCount); err != nil { + t.Fatalf("failed to query admin_notifications: %v", err) + } + if notifCount != 0 { + t.Errorf("expected NO admin_notification for an already-refunded payment resolved to completed, got %d", notifCount) + } +} + +// TestSweepManualRefund_RefundAmountInvalid_NotRefunded_DeclinePath covers the +// other H2 branch on the manual-sweep path: REFUND_AMOUNT_INVALID where the +// payment was NOT refunded is a genuine decline, not an already-refunded +// outcome — the row falls through to the decline path (refund_attempts +// incremented, still pending, no completion). +func TestSweepManualRefund_RefundAmountInvalid_NotRefunded_DeclinePath(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + chargeID := "sqp_h2_not_refunded" + if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID); err != nil { + t.Fatalf("failed to set square_payment_id: %v", err) + } + + var refundID string + err = tx.QueryRow(ctx, ` + INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at) + VALUES ($1, $2, 25, 'pending', 'customer request', $3, 'manual', NOW()) + RETURNING id + `, paymentID, bookingID, paymentID+"-h2-refund-2500").Scan(&refundID) + if err != nil { + t.Fatalf("failed to insert pending manual refund: %v", err) + } + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + + origClient := SquareClient + mock := square.NewDevClient().(*square.MockClient) + mock.FailRefundCode = "REFUND_AMOUNT_INVALID" + SquareClient = mock + defer func() { SquareClient = origClient }() + + freshCtx := context.Background() + n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}}) + if err != nil { + t.Fatalf("processManualPaymentGroup failed: %v", err) + } + if n != 0 { + t.Fatalf("expected 0 refunds processed on the decline path, got %d", n) + } + + var status string + var attempts int + if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil { + t.Fatalf("failed to query refund: %v", err) + } + if status != "pending" { + t.Errorf("expected status 'pending' (REFUND_AMOUNT_INVALID on a NOT-refunded payment is a decline, not a completion), got %q", status) + } + if attempts != 1 { + t.Errorf("expected refund_attempts 1 after the decline path, got %d", attempts) + } + if n := mock.RefundKeyCount(); n != 0 { + t.Errorf("expected NO Square refund recorded on the decline path, got %d", n) + } + var notifCount int + if err := db.Conn.QueryRow(freshCtx, + `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(¬ifCount); err != nil { + t.Fatalf("failed to query admin_notifications: %v", err) + } + if notifCount != 0 { + t.Errorf("expected NO admin_notification while the row is still pending, got %d", notifCount) + } +} + +// TestProcessPendingSquareRefunds_RefundAmountInvalid_AlreadyRefunded_Completed +// covers the H2 reconciliation on the aggregated (charge-group) path: the +// cancellation refund row for an already-refunded charge resolves to +// 'completed' — never 'failed', no admin notification — so the amount unblocks +// the over-refund guard without double-refunding. +func TestProcessPendingSquareRefunds_RefundAmountInvalid_AlreadyRefunded_Completed(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + + mock := square.NewDevClient().(*square.MockClient) + charge, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{ + Amount: 2500, + Currency: "GBP", + SourceID: "cnon:h2-agg-refunded", + IdempotencyKey: "h2-agg-refunded-charge", + }) + if err != nil { + t.Fatalf("failed to seed mock payment: %v", err) + } + if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", charge.ID, paymentID); err != nil { + t.Fatalf("failed to set square_payment_id: %v", err) + } + + var refundID string + err = tx.QueryRow(ctx, ` + INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at) + VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', NOW()) + RETURNING id + `, paymentID, bookingID, paymentID+"-h2-square-2500").Scan(&refundID) + if err != nil { + t.Fatalf("failed to insert pending cancellation refund: %v", err) + } + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + + // The charge is already fully refunded at Square before the sweep. + if _, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{ + PaymentID: charge.ID, + Amount: 2500, + IdempotencyKey: "seed-h2-agg-refunded", + Reason: "client_cancelled", + }); err != nil { + t.Fatalf("failed to seed Square refund: %v", err) + } + + origClient := SquareClient + SquareClient = mock + defer func() { SquareClient = origClient }() + + freshCtx := context.Background() + if _, err := db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE status = 'pending' AND id <> $1`, refundID); err != nil { + t.Fatalf("failed to clean leftover pending refunds: %v", err) + } + ProcessPendingSquareRefunds(freshCtx, bookingID, "client_cancelled") + + var status string + if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status); err != nil { + t.Fatalf("failed to query refund: %v", err) + } + if status != "completed" { + t.Errorf("expected refund 'completed' after REFUND_AMOUNT_INVALID on an already-refunded charge, got %q", status) + } + if n := mock.RefundKeyCount(); n != 1 { + t.Errorf("expected exactly 1 distinct Square refund (the pre-seeded one), got %d", n) + } + var notifCount int + if err := db.Conn.QueryRow(freshCtx, + `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(¬ifCount); err != nil { + t.Fatalf("failed to query admin_notifications: %v", err) + } + if notifCount != 0 { + t.Errorf("expected NO admin_notification for an already-refunded charge resolved to completed, got %d", notifCount) + } +} + +// ============================================================================= +// resolveManualRefundAtCap re-arm (refunds.go) +// ============================================================================= + +// TestSweepManualRefund_ReconcileError_AtCap_ReArmed covers the re-arm fix: when +// a manual refund reaches the attempt cap but the cap-time reconcile against +// Square FAILS (unknown money state), the row must NOT be stranded at the cap +// (the sweep only re-picks rows with refund_attempts < maxManualRefundAttempts). +// resolveManualRefundAtCap re-arms the row to maxManualRefundAttempts-1 so the +// next sweep re-picks it — still pending, still under the cap. +func TestSweepManualRefund_ReconcileError_AtCap_ReArmed(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_h2_rearm' WHERE id = $1", paymentID); err != nil { + t.Fatalf("failed to set square_payment_id: %v", err) + } + + var refundID string + err = tx.QueryRow(ctx, ` + INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, refund_attempts, created_at) + VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', $4, NOW()) + RETURNING id + `, paymentID, bookingID, paymentID+"-h2-rearm-refund", maxManualRefundAttempts-1).Scan(&refundID) + if err != nil { + t.Fatalf("failed to insert manual refund at the attempt cap minus one: %v", err) + } + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + + // RefundPayment AND the reconcile (ListPaymentRefunds) both fail with + // plain transport errors → every run ends in an UNKNOWN money state. + origClient := SquareClient + SquareClient = &ambiguousRefundClient{} + defer func() { SquareClient = origClient }() + + freshCtx := context.Background() + for i := 0; i < 2; i++ { + if _, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}}); err != nil { + t.Fatalf("processManualPaymentGroup run %d failed: %v", i+1, err) + } + var status string + var attempts int + if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil { + t.Fatalf("failed to query refund after run %d: %v", i+1, err) + } + if status != "pending" { + t.Fatalf("expected status 'pending' after run %d (reconcile error = unknown state), got %q", i+1, status) + } + if attempts != maxManualRefundAttempts-1 { + t.Errorf("expected refund_attempts re-armed to %d after run %d (not stranded at the cap %d), got %d", + maxManualRefundAttempts-1, i+1, maxManualRefundAttempts, attempts) + } + } + + // The re-arm must not have fired the terminal failure notification. + var notifCount int + if err := db.Conn.QueryRow(freshCtx, + `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(¬ifCount); err != nil { + t.Fatalf("failed to query admin_notifications: %v", err) + } + if notifCount != 0 { + t.Errorf("expected NO admin_notification (unknown money state must never be marked failed), got %d", notifCount) + } +} + +// TestSweepManualRefund_ReconcileError_AtCap_NotifiesAfterNReArms locks the +// A5b fix: a manual refund whose cap-time reconcile keeps FAILING is re-armed +// under the cap on every sweep run (so the row is never stranded) BUT after +// maxConsecutiveReconcileFailures consecutive failures a deduped +// 'critical_payment_log' admin notification surfaces the hard-failing reconcile +// — the audit requirement that an admin is notified after repeated reconcile +// failures, never silently forever. The row stays 'pending' (an unknown money +// state is never marked failed) and keeps being re-armed, so the notification +// (deduped) is the durable admin-visible signal. +func TestSweepManualRefund_ReconcileError_AtCap_NotifiesAfterNReArms(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_a5b_notify' WHERE id = $1", paymentID); err != nil { + t.Fatalf("failed to set square_payment_id: %v", err) + } + + var refundID string + err = tx.QueryRow(ctx, ` + INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, refund_attempts, created_at) + VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', $4, NOW()) + RETURNING id + `, paymentID, bookingID, paymentID+"-a5b-notify-refund", maxManualRefundAttempts-1).Scan(&refundID) + if err != nil { + t.Fatalf("failed to insert manual refund at the attempt cap minus one: %v", err) + } + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + resetReconcileFailureCount(refundID) + }) + + // RefundPayment AND the reconcile (ListPaymentRefunds) both fail with + // plain transport errors → every run ends in an UNKNOWN money state. + origClient := SquareClient + SquareClient = &ambiguousRefundClient{} + defer func() { SquareClient = origClient }() + + freshCtx := context.Background() + for i := 0; i < maxConsecutiveReconcileFailures; i++ { + if _, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}}); err != nil { + t.Fatalf("processManualPaymentGroup run %d failed: %v", i+1, err) + } + var status string + var attempts int + if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil { + t.Fatalf("failed to query refund after run %d: %v", i+1, err) + } + if status != "pending" { + t.Fatalf("expected status 'pending' after run %d (reconcile error = unknown state), got %q", i+1, status) + } + if attempts != maxManualRefundAttempts-1 { + t.Errorf("expected refund_attempts re-armed to %d after run %d, got %d", + maxManualRefundAttempts-1, i+1, attempts) + } + } + + // After N consecutive failures the admin MUST have been notified via a + // deduped 'critical_payment_log' notification — the A5b requirement. + var critCount int + if err := db.Conn.QueryRow(freshCtx, + `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'critical_payment_log'`, bookingID).Scan(&critCount); err != nil { + t.Fatalf("failed to query critical_payment_log notifications: %v", err) + } + if critCount < 1 { + t.Errorf("expected at least 1 critical_payment_log admin notification after %d consecutive reconcile failures, got %d", + maxConsecutiveReconcileFailures, critCount) + } + + // The row must still be pending (never failed on an unknown money state) + // and no 'refund_failed' notification may have fired. + var status string + var attempts int + if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil { + t.Fatalf("failed to query refund after notification: %v", err) + } + if status != "pending" { + t.Errorf("expected status 'pending' (unknown state is never marked failed), got %q", status) + } + if attempts != maxManualRefundAttempts-1 { + t.Errorf("expected refund_attempts %d (still re-armed for the next sweep), got %d", maxManualRefundAttempts-1, attempts) + } + var failedCount int + if err := db.Conn.QueryRow(freshCtx, + `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&failedCount); err != nil { + t.Fatalf("failed to query refund_failed notifications: %v", err) + } + if failedCount != 0 { + t.Errorf("expected NO 'refund_failed' notification (row was never marked failed), got %d", failedCount) + } +} + +// TestProcessChargeGroup_ReconcileError_AtCap_ReArmsAndNotifies locks the A5c +// fix on the cancellation (charge-group) path: when the cap-time reconcile +// fails the capped refund rows are re-armed under the cap (mirroring +// resolveManualRefundAtCap) so the sweep re-picks them instead of stranding +// them pending at the cap forever, and after maxConsecutiveReconcileFailures +// consecutive failures a deduped 'critical_payment_log' admin notification +// surfaces the hard-failing reconcile. +func TestProcessChargeGroup_ReconcileError_AtCap_ReArmsAndNotifies(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + chargeID := "sqp_a5c_notify" + if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID); err != nil { + t.Fatalf("failed to set square_payment_id: %v", err) + } + + var refundID string + err = tx.QueryRow(ctx, ` + INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, refund_attempts, created_at) + VALUES ($1, $2, 50, 'pending', 'client_cancelled', $3, 'cancellation', $4, NOW()) + RETURNING id + `, paymentID, bookingID, paymentID+"-a5c-square-5000", maxManualRefundAttempts-1).Scan(&refundID) + if err != nil { + t.Fatalf("failed to insert cancellation refund at the attempt cap minus one: %v", err) + } + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + resetReconcileFailureCount(refundID) + }) + + origClient := SquareClient + SquareClient = &ambiguousRefundClient{} + defer func() { SquareClient = origClient }() + + freshCtx := context.Background() + for i := 0; i < maxConsecutiveReconcileFailures; i++ { + if _, err := processChargeGroup(freshCtx, chargeID, fetchPendingChargeRows(freshCtx, chargeID), "client_cancelled"); err != nil { + t.Fatalf("processChargeGroup run %d failed: %v", i+1, err) + } + var status string + var attempts int + if err := db.Conn.QueryRow(freshCtx, `SELECT status, refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&status, &attempts); err != nil { + t.Fatalf("failed to query refund after run %d: %v", i+1, err) + } + if status != "pending" { + t.Fatalf("expected status 'pending' after run %d (reconcile error = unknown state), got %q", i+1, status) + } + if attempts != maxManualRefundAttempts-1 { + t.Errorf("expected refund_attempts re-armed to %d after run %d (A5c: not stranded at the cap), got %d", + maxManualRefundAttempts-1, i+1, attempts) + } + } + + var critCount int + if err := db.Conn.QueryRow(freshCtx, + `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'critical_payment_log'`, bookingID).Scan(&critCount); err != nil { + t.Fatalf("failed to query critical_payment_log notifications: %v", err) + } + if critCount < 1 { + t.Errorf("expected at least 1 critical_payment_log admin notification after %d consecutive reconcile failures, got %d", + maxConsecutiveReconcileFailures, critCount) + } +} diff --git a/backend/handlers/payments/sweep.go b/backend/handlers/payments/sweep.go index ff4d5e8..ba2cf53 100644 --- a/backend/handlers/payments/sweep.go +++ b/backend/handlers/payments/sweep.go @@ -275,6 +275,14 @@ func sweepStaleRows(ctx context.Context, table string, cutoff time.Time) (resolv } if failStaleRow(ctx, table, r.ID) { resolved++ + // A5a: blind-failing a row with NO square_payment_id leaves the + // charge outcome unknown (the money may have landed at Square with a + // lost response), so surface it in the admin notification centre. + // Rows that reach this line WITH a square_payment_id were PROVED + // never-charged by the by-id reconcile and need no alert. + if r.SquarePaymentID == "" { + notifyStaleRowCritical(ctx, r) + } } } return resolved, completed, nil @@ -319,9 +327,13 @@ func sweepKeyedStaleRows(ctx context.Context, table string, cutoff time.Time) (r // expired key as "never charged". Blind-fail + WARN exactly as the // legacy sweep did; a till sale's funded gift card is NOT clawed // back (the charge outcome is unknown, the money may have landed). + // A5a: this blind-fail can be hiding a real charge (the lost + // response the stored key was meant to reconcile), so the admin + // notification centre must surface it too. if failStaleRow(ctx, table, r.ID) { resolved++ unverifiable++ + notifyStaleRowCritical(ctx, r) } log.Printf("Stale pending %s row %s has a stored idempotency key but is already past Square's key retention window — marked failed without a replay reconcile (may have been charged with a lost response)", table, r.ID) continue @@ -403,11 +415,15 @@ func fetchStaleRows(ctx context.Context, table string, cutoff time.Time, keyedOn WHERE ts.status = 'pending' AND ts.created_at < $1`+methodFilter+keyedPredicate+` `, cutoff) } else { + // table is an internal constant ("payments"), never user input, but the + // identifier is routed through pgx.Identifier.Sanitize — the same + // treatment failStaleRow / rescueStaleRowCompleted give the table name — + // so no raw, unquoted table name is ever concatenated into the statement. rows, err = db.Conn.Query(ctx, ` SELECT id, COALESCE(square_payment_id, ''), COALESCE(idempotency_key, ''), COALESCE(square_source_id, ''), COALESCE(square_request_snapshot, ''), created_at, amount, booking_id, created_by - FROM `+table+` + FROM `+pgx.Identifier{table}.Sanitize()+` WHERE status = 'pending' AND created_at < $1`+keyedPredicate+` `, cutoff) } @@ -627,6 +643,70 @@ func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool { return true } +// replayRescueClockSkew is the margin by which a replayed payment's CreatedAt +// may lag the pending row's CreatedAt and still be the ORIGINAL charge under a +// retained idempotency key. Square creates the payment at the same instant the +// app creates the pending row (the same transaction), so a replayed payment +// created AFTER the row by more than this margin cannot be the original — it is +// a NEW charge Square made with an expired key (finding A1). +const replayRescueClockSkew = time.Hour + +// isSavedCardSource reports whether a Square source id is a card-on-file +// (saved-card) reference. Only a ccof: source stays valid for recharging long +// after the charge attempt that stored it: a cnon: nonce is single-use, so an +// expired-key replay of a cnon: source is always rejected (proof the charge +// never happened), while a replay of a still-valid ccof: source can land a NEW +// charge under the expired key. +func isSavedCardSource(source string) bool { + return strings.HasPrefix(source, "ccof:") +} + +// parseReplayedCreatedAt parses a PaymentResult's ISO 8601 CreatedAt into the +// instant the replayed payment was created at Square. Real Square always +// returns created_at on a payment, so an empty or unparseable value is a +// client/response anomaly. +func parseReplayedCreatedAt(pr *square.PaymentResult) (time.Time, bool) { + if pr == nil || pr.CreatedAt == "" { + return time.Time{}, false + } + created, err := time.Parse(time.RFC3339, pr.CreatedAt) + if err != nil { + return time.Time{}, false + } + return created, true +} + +// replayRevealsNewCharge reports whether a COMPLETED keyed replay returned a +// NEW charge rather than the ORIGINAL payment under a retained idempotency key +// (finding A1). A retained-key dedup returns the original payment, created at +// the same instant the pending row was created; a NEW charge made by an +// expired-key replay (Square's ~24h key retention is UNVERIFIED — +// square_http_client.go:626) against the still-valid ccof: source is created +// ~22h later. Refusal is money-safe: a replayed payment that cannot be proven +// to be the original is never rescued (the row stays pending, a CRITICAL log is +// raised and an admin notification inserted), so a hidden second charge can +// never masquerade as the original one. +// +// The check runs ONLY against real Square timestamps: it is gated off in an +// explicit dev/mock env because the dev mock returns payments whose CreatedAt +// is the mock's "now" at seed/replay time, uncorrelated with the aged +// created_at the test rows carry (the keyed-reconcile tests age rows 23h while +// seeding the Square payment at test time, and a retained-key dedup returns +// that seeded payment). The gate mirrors the snapshot-decryption gate, which +// also runs only in a non-dev/mock env. +func replayRevealsNewCharge(r staleRow, pr *square.PaymentResult) (newCharge bool, created time.Time, createdOK bool) { + if IsExplicitDevOrMockEnv() { + return false, time.Time{}, false + } + created, createdOK = parseReplayedCreatedAt(pr) + if !createdOK || r.CreatedAt.IsZero() { + // Cannot prove the replayed payment is the original charge — refuse to + // rescue rather than hide a possible second charge. + return true, created, createdOK + } + return created.After(r.CreatedAt.Add(replayRescueClockSkew)), created, true +} + // reconcileStalePaymentByKey asks Square for the authoritative status of the // 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: @@ -692,6 +772,21 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) ( } snapshot = fallback } + // PROD-mode snapshot decryption: since the AES-GCM snapshot work, the stored + // square_request_snapshot is ENCRYPTED in a non-mock deployment + // (SQUARE_ENVIRONMENT production/sandbox — the same gate the 2FA + // enforcement uses, IsExplicitDevOrMockEnv); the dev mock stores plaintext. + // Never replay a corrupt snapshot: a decryption failure is a serious error + // that leaves the row pending with a CRITICAL log for manual reconciliation + // instead of replaying garbage (which could return a misleading answer). + if !fallbackBody && !IsExplicitDevOrMockEnv() { + dec, err := decryptSnapshot(snapshot) + if err != nil { + log.Printf("CRITICAL: stale pending %s reconcile by key: failed to decrypt the stored request snapshot for row %s (%v) — leaving pending — MANUAL RECONCILIATION REQUIRED", table, r.ID, err) + return staleReconcileLeavePending, "" + } + snapshot = dec + } // 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 @@ -730,6 +825,21 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) ( pr, err := SquareClient.ReplayPaymentByKey(ctx, snapshot) if err != nil { if errors.Is(err, square.ErrReplayKeyNotRetained) { + // A1: a rejection of a replayed charge against a SAVED-CARD (ccof:) + // source is NOT proof the original charge never happened — the same + // key-retention race that lets the replay land a NEW charge can + // leave that charge even when the probe returns a rejection. Blindly + // failing (and clawing back a till sale's funded gift card) on a + // ccof source could reverse money a replay-created charge already + // took. Leave the row pending and alert ops. A cnon-source + // (spent-nonce) rejection REMAINS definitive proof of no charge (a + // single-use nonce cannot be recharged), keeping the proven-failed + // path and its clawback. + if isSavedCardSource(r.SquareSourceID) { + log.Printf("CRITICAL: stale pending %s reconcile by key: Square rejected the identical-body replay (no payment under the stored key) but the row's source is a still-valid saved card (ccof:) — the replay may have landed a NEW charge under an expired idempotency key — leaving row %s PENDING without failing/clawing back — MANUAL RECONCILIATION REQUIRED: verify at Square whether a charge exists before re-issuing", table, r.ID) + notifyStaleRowCritical(ctx, r) + return staleReconcileLeavePending, "" + } log.Printf("Stale pending %s reconcile by key: Square has no payment under the stored idempotency key (identical-body replay rejected) — marking failed; the charge provably never happened", table) return staleReconcileDefinitivelyFailed, "" } @@ -758,6 +868,23 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) ( // must match Square's documented state machine. switch pr.Status { case "COMPLETED": + // A1: a replayed COMPLETED payment created long AFTER the pending row + // is a NEW charge Square made against the still-valid source with an + // expired idempotency key (Square's ~24h key retention is UNVERIFIED — + // square_http_client.go:626), NOT the original charge a retained key + // returns. Rescuing the row with the new payment id would hide the + // second charge behind the original. Leave the row pending and alert + // ops so both charges can be reconciled at Square and the duplicate + // refunded. + if newCharge, created, createdOK := replayRevealsNewCharge(r, pr); newCharge { + lag := "unknown" + if createdOK { + lag = created.Sub(r.CreatedAt).Round(time.Minute).String() + } + log.Printf("CRITICAL: stale pending %s reconcile by key: the replayed COMPLETED payment %s was created after the pending row %s (lag %s) — a NEW charge under an expired idempotency key (likely a second charge against a still-valid saved card), NOT the original charge — leaving the row PENDING without rescue — MANUAL RECONCILIATION REQUIRED: check Square for both charges and refund the duplicate", table, pr.ID, r.ID, lag) + notifyStaleRowCritical(ctx, r) + return staleReconcileLeavePending, "" + } return staleReconcileCompleted, pr.ID case "CANCELED", "FAILED": log.Printf("Stale pending %s is %q at Square (replay by key) — marking failed", table, pr.Status) @@ -809,6 +936,25 @@ func insertCriticalPaymentNotification(ctx context.Context, bookingID, userID *s } } +// notifyStaleRowCritical inserts a critical-payment admin notification for a +// stale row via the shared insertCriticalPaymentNotification helper — the +// notification's deterministic dedup key is the user attribution (the NOT +// EXISTS guard keeps ONE notification per user instead of one per sweep run). +// Payments rows are attributed by the payer (created_by); till_sales rows by +// the funded gift card's redeemed-to user when there is one. booking_id is +// deliberately not used: the sweep runs after the charge process and a +// booking-attributed notification would pin the booking row through the +// admin_notifications FK (no cascade) for as long as the notification survives. +func notifyStaleRowCritical(ctx context.Context, r staleRow) { + var userID *string + if r.CreatedBy != nil { + userID = r.CreatedBy + } else { + userID = r.RedeemToUserID + } + insertCriticalPaymentNotification(ctx, nil, userID) +} + // staleReconcileResult is the tri-state outcome of reconciling one stale // pending row against Square. Only a definitively-resolved outcome touches the // row: an ambiguous answer (transport error / 5xx) leaves it pending so a @@ -1241,8 +1387,11 @@ func markTerminalCheckoutRowFailed(ctx context.Context, r staleTerminalCheckoutR table = "terminal_checkouts" where = "checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')" } + // table is an internal constant ("till_sales"/"terminal_checkouts"), 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 `+where, r.RowID) if err != nil { log.Printf("Failed to mark %s %s failed: %v", r.Kind, r.RowID, err) diff --git a/backend/handlers/payments/sweep_test.go b/backend/handlers/payments/sweep_test.go index 88baee0..f6a4eb9 100644 --- a/backend/handlers/payments/sweep_test.go +++ b/backend/handlers/payments/sweep_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "crussell/clock" "crussell/db" "crussell/internal/square" "crussell/testutils" @@ -707,6 +708,320 @@ func TestSweepStalePendingPayments_KeyedSourceMismatch_LeavesPending(t *testing. } } +// TestSweepStalePendingPayments_KeyedReplayNewCharge_LeavesPending locks the A1 +// money-safety cross-check: a replayed COMPLETED payment created long AFTER the +// pending row is a NEW charge Square made with an EXPIRED idempotency key +// against the still-valid ccof source (the ~24h key retention is unverified), +// NOT the original charge a retained key returns. Rescuing the row with the new +// payment id would hide the second charge behind the original — the row must +// stay PENDING, a CRITICAL notification must be raised, and no square_payment_id +// may be written. The cross-check runs only in a non-dev/mock env, so this test +// flips SQUARE_ENVIRONMENT to production (sequential, like the 2FA tests). +func TestSweepStalePendingPayments_KeyedReplayNewCharge_LeavesPending(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") + if err != nil { + t.Fatalf("failed to create stale pending payment: %v", err) + } + // 23h old: past the 22h keyed cutoff (so the keyed pass picks it up) but + // still inside Square's ~24h retention window (so the replay runs). NO + // stored snapshot → the minimal fallback body is rebuilt, which skips the + // production snapshot-decryption gate. created_by carries the payer so the + // admin notification is attributable and assertable. + const key = "key-expired-replay-new-charge" + if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil { + t.Fatalf("failed to age the stale payment: %v", err) + } + + // The replayed COMPLETED payment is created at sweep time (~23h after the + // row) — the expired-key replay landed a NEW charge on the saved card. + // The cross-check runs only in a non-dev/mock env, so the env is flipped to + // production for the sweep (sequential, like the 2FA tests). The dev mock + // is constructed BEFORE the flip (NewDevClient refuses production without + // SQUARE_ALLOW_REAL_API); the mock itself never re-reads the env. + origClient := SquareClient + mock := square.NewDevClient() + t.Setenv("SQUARE_ENVIRONMENT", "production") + SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{ + Status: "COMPLETED", + ID: "pay_expired_key_new_charge", + SquarePayID: "pay_expired_key_new_charge", + CreatedAt: clock.Now().Format(time.RFC3339), + }} + defer func() { SquareClient = origClient }() + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + + freshCtx := context.Background() + if _, err := SweepStalePendingPayments(freshCtx); err != nil { + t.Fatalf("sweep failed: %v", err) + } + + var status string + var sqPayID *string + if err := db.Conn.QueryRow(freshCtx, "SELECT status, square_payment_id FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil { + t.Fatalf("failed to query payment: %v", err) + } + if status != "pending" { + t.Errorf("expected the new-charge replay to leave the row pending (never rescue with the second charge's id), got %q", status) + } + if sqPayID != nil { + t.Errorf("expected NO square_payment_id written on a new-charge replay, got %q", *sqPayID) + } + var notifCount int + if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil { + t.Fatalf("failed to count admin notifications: %v", err) + } + if notifCount < 1 { + t.Errorf("expected a critical-payment admin notification for the suspected second charge, got %d", notifCount) + } +} + +// TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues locks the +// A1 cross-check control: a replayed COMPLETED payment created at the SAME +// instant as the pending row (the retained-key dedup returning the original +// charge) is rescued to 'completed' — the cross-check must never block a +// legitimate lost-response rescue. Production env so the cross-check runs. +func TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") + if err != nil { + t.Fatalf("failed to create stale pending payment: %v", err) + } + const key = "key-retained-original" + if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil { + t.Fatalf("failed to age the stale payment: %v", err) + } + + // The replayed payment is the ORIGINAL — created at the same instant as the + // pending row (~23h ago), as a retained-key dedup returns. The env is + // flipped to production for the sweep so the A1 cross-check runs; the dev + // mock is constructed BEFORE the flip (NewDevClient refuses production + // without SQUARE_ALLOW_REAL_API). + origClient := SquareClient + mock := square.NewDevClient() + t.Setenv("SQUARE_ENVIRONMENT", "production") + SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{ + Status: "COMPLETED", + ID: "pay_original_under_key", + SquarePayID: "pay_original_under_key", + CreatedAt: clock.Now().Add(-23 * time.Hour).Format(time.RFC3339), + }} + defer func() { SquareClient = origClient }() + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + + freshCtx := context.Background() + if _, err := SweepStalePendingPayments(freshCtx); err != nil { + t.Fatalf("sweep failed: %v", err) + } + + var status, sqPayID string + if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil { + t.Fatalf("failed to query payment: %v", err) + } + if status != "completed" { + t.Errorf("expected the original-payment replay rescued to 'completed', got %q", status) + } + if sqPayID != "pay_original_under_key" { + t.Errorf("expected square_payment_id %s written back on the rescue, got %q", "pay_original_under_key", sqPayID) + } +} + +// TestSweepStalePendingPayments_KeyedCCOFRejected_LeavesPendingNoClawback locks +// the A1 ccof-blind-fail rule: a replay rejection (ErrReplayKeyNotRetained) +// against a SAVED-CARD (ccof:) source is NOT proof the original charge never +// happened — the same key-retention race that lets the replay land a NEW charge +// can leave that charge even when the probe is rejected. A till sale with a +// funded gift card must be left PENDING (never failed) and the gift card must +// NOT be clawed back. A cnon-source (spent-nonce) rejection remains definitive +// and keeps the proven-failed clawback (locked by +// TestSweepStalePendingPayments_KeyedTillLostResponse_ProvenFailed_Clawbacks). +func TestSweepStalePendingPayments_KeyedCCOFRejected_LeavesPendingNoClawback(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + pool := context.Background() + + saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "", true) + // 23h old: past the 22h keyed cutoff, still inside the retention window so + // the keyed replay runs (not the past-retention blind-fail). ccof source. + if _, err := tx.Exec(ctx, "UPDATE till_sales SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = 'key-ccof-rejected', square_source_id = 'ccof:test-saved-card' WHERE id = $1", saleID); err != nil { + t.Fatalf("failed to age the till sale: %v", err) + } + if _, err := tx.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil { + t.Fatalf("failed to age the gift card: %v", err) + } + + origClient := SquareClient + SquareClient = &staleReplayClient{SquareClient: square.NewDevClient(), err: square.ErrReplayKeyNotRetained} + defer func() { SquareClient = origClient }() + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit setup tx: %v", err) + } + + if _, err := SweepStalePendingPayments(pool); err != nil { + t.Fatalf("sweep failed: %v", err) + } + + var status string + if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil { + t.Fatalf("failed to query till sale: %v", err) + } + if status != "pending" { + t.Errorf("expected the ccof-source replay rejection to leave the till sale pending (never blindly failed), got %q", status) + } + + // The funded card must be untouched — the blind rejection could mean a + // replay-created charge landed, so the funding must stay put. + var cardCount int + if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil { + t.Fatalf("failed to count gift cards: %v", err) + } + if cardCount != 1 { + t.Errorf("expected the ccof rejection to leave the funded gift card in place (no clawback), got %d cards", cardCount) + } +} + +// TestSweepStalePendingPayments_KeyedBlindFail_Notifies locks A5a: a keyed +// pending row already past Square's key retention window is blind-failed (the +// charge outcome is unknown — the lost response may have landed at Square), so +// a critical-payment admin notification must be raised alongside the failed +// mark. The notification is deduped per payer. +func TestSweepStalePendingPayments_KeyedBlindFail_Notifies(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") + if err != nil { + t.Fatalf("failed to create stale pending payment: %v", err) + } + // 25h old: past both the 22h keyed cutoff and Square's 24h retention window, + // so the keyed pass blind-fails it without a replay. + if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', idempotency_key = 'key-blindfail-notify', square_source_id = 'cnon:test-card', created_by = $1 WHERE id = $2", userID, staleID); err != nil { + t.Fatalf("failed to age the stale payment: %v", err) + } + + origClient := SquareClient + SquareClient = square.NewDevClient() + defer func() { SquareClient = origClient }() + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + + freshCtx := context.Background() + if _, err := SweepStalePendingPayments(freshCtx); err != nil { + t.Fatalf("sweep failed: %v", err) + } + + var status string + if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil { + t.Fatalf("failed to query payment: %v", err) + } + if status != "failed" { + t.Errorf("expected the past-retention keyed row blind-failed, got %q", status) + } + var notifCount int + if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil { + t.Fatalf("failed to count admin notifications: %v", err) + } + if notifCount < 1 { + t.Errorf("expected a critical-payment admin notification for the keyed blind-fail, got %d", notifCount) + } +} + // TestSweepStalePendingPayments_KeyedTillLostResponse_ProvenFailed_Clawbacks // locks the keyed clawback: a stale pending till sale with a stored idempotency // key whose charge Square PROVES never happened (no payment under the key) is diff --git a/backend/handlers/payments/till.go b/backend/handlers/payments/till.go index 3bf6daf..1dd2110 100644 --- a/backend/handlers/payments/till.go +++ b/backend/handlers/payments/till.go @@ -25,13 +25,13 @@ import ( ) type TillSaleRequest struct { - ItemType string `json:"item_type" validate:"required"` - Action string `json:"action" validate:"required"` - Amount float64 `json:"amount" validate:"required,gt=0"` - GiftCardID *string `json:"gift_card_id,omitempty"` - PaymentMethod string `json:"payment_method" validate:"required"` - UserSavedCardID *string `json:"user_saved_card_id,omitempty"` - UserID *string `json:"user_id,omitempty"` + ItemType string `json:"item_type" validate:"required"` + Action string `json:"action" validate:"required"` + Amount float64 `json:"amount" validate:"required,gt=0"` + GiftCardID *string `json:"gift_card_id,omitempty"` + 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 DETERMINISTIC // fallback derived from the canonical request fields // (deriveTillIdempotencyKey) so a lost-response retry re-derives the SAME @@ -39,7 +39,7 @@ type TillSaleRequest struct { // 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"` + 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"` @@ -129,6 +129,14 @@ func declineCodeListContains(s string) bool { // / user / saved card / redeem targets when present), truncated to 16 bytes so // the key stays within Square's 45-char /v2/payments limit. // +// The hash is applied UNCONDITIONALLY (never the verbatim candidate): a row +// created pre-deploy stores the hashed form (CHAR(12) candidates like +// "till:create:...:2500" are ~30 chars and used to derive the hashed key), and +// a lost-response retry must re-derive the SAME key to hit the dedup SELECT +// and slot scan — the length-conditional truncateIdempotencyKey form would +// switch short candidates to a raw key that misses the pre-deploy row and +// mints a SECOND Square charge. +// // 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 @@ -164,23 +172,20 @@ func deriveTillIdempotencyKey(req TillSaleRequest, adminID string) string { sb.WriteString(":redeem:") sb.WriteString(*req.RedeemToUserID) } + // Always-hashed, NEVER truncateIdempotencyKey: a pre-deploy row stores the + // hashed key, so a raw-key re-derivation would miss the dedup and double- + // charge. The slot-scan candidates (tillIdempotencyKeyCandidate) keep their + // own conditional truncation, matching the pre-batch code. 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). +// verbatim form would overflow (the hash stays deterministic). Shared +// implementation: nextIdempotencyCandidate (idempotency_helpers.go). 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 + return nextIdempotencyCandidate(baseKey, seq) } // scanTillIdempotencyKeySlot resolves the FINAL deterministic idempotency key @@ -235,8 +240,22 @@ func refreshTillSnapshotSource(ctx context.Context, tx pgx.Tx, tillSaleID, newSo if !snap.Valid || snap.String == "" { return } + // The stored snapshot is AES-256-GCM-encrypted at rest in non-mock + // deployments (encryptSnapshot's "enc:v1:" marker) and plaintext in + // dev/mock — decrypt it first so the JSON mutation below operates on the + // request body, then re-encrypt on the way out so the stored form stays + // consistent with the other snapshot writes. + body := []byte(snap.String) + if !IsExplicitDevOrMockEnv() { + dec, dErr := decryptSnapshot(body) + if dErr != nil { + log.Printf("Failed to decrypt square_request_snapshot for reused till sale %s: %v", tillSaleID, dErr) + return + } + body = dec + } var req square.CreatePaymentReq - if err := json.Unmarshal([]byte(snap.String), &req); err != nil { + if err := json.Unmarshal(body, &req); err != nil { log.Printf("Failed to parse square_request_snapshot for reused till sale %s: %v", tillSaleID, err) return } @@ -246,7 +265,16 @@ func refreshTillSnapshotSource(ctx context.Context, tx pgx.Tx, tillSaleID, newSo 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 { + stored := updated + if !IsExplicitDevOrMockEnv() { + enc, eErr := encryptSnapshot(updated) + if eErr != nil { + log.Printf("Failed to encrypt square_request_snapshot for reused till sale %s: %v", tillSaleID, eErr) + return + } + stored = enc + } + if _, err := tx.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(stored), tillSaleID); err != nil { log.Printf("Failed to refresh square_request_snapshot for reused till sale %s: %v", tillSaleID, err) } } @@ -263,11 +291,6 @@ 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 @@ -308,10 +331,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { // 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 { + // (owner decision — the shared maxAdminGiftCardTransactionPence from + // giftcard_limits.go, the single source of the £250 cap). 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 > maxAdminGiftCardTransactionPence { http.Error(w, "Gift card amount exceeds maximum (£250)", http.StatusBadRequest) return } @@ -591,6 +615,12 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } + // HMRC VAT Notice 700/7: salon-only gift cards are SPV by + // definition, so the EFFECTIVE type is written even when the + // stored setting is 'MPV' — recording raw 'MPV' here would make + // the redemption path defer VAT a second time (VAT is already + // collected at sale via the GetVATConfig SPV override). + purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType) err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase) VALUES ($1, $1, $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3) @@ -1024,7 +1054,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { if req.PaymentMethod != "on_the_house" && (saleStatus == "completed" || needsSquarePayment) { vatCfg, vatErr := GetVATConfig(ctx, tx) - if vatErr == nil && vatCfg.IsVATRegistered && vatCfg.VoucherType == "SPV" { + if vatErr == nil && vatAppliesToVoucher(vatCfg) { if _, vatExecErr := tx.Exec(ctx, "SELECT apply_vat_to_till_sale($1, $2)", tillSaleID, vatCfg.DefaultVATRate); vatExecErr != nil { log.Printf("Failed to apply VAT to till sale %s: %v", tillSaleID, vatExecErr) } @@ -1074,14 +1104,26 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { IdempotencyKey: req.IdempotencyKey, Note: "Gift Card " + req.Action, BuyerEmail: buyerEmail, + // C3: a saved-card (ccof) charge is customer-initiated — Square + // requires customer_details on stored-credential payments, and + // omitting it can fail/quietly strip the charge. + CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true}, + // Forward the 3DS/SCA verification token when the request + // carries one (the online_square branch already did; the + // saved-card branch did not). + VerificationToken: verificationToken, } // 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. + // The snapshot holds PII (buyer email + ccof token), so it is + // encrypted at rest via encryptSnapshot (plaintext in dev/mock). if snap, mErr := json.Marshal(paymentReq); mErr != nil { log.Printf("Failed to marshal square_request_snapshot for till sale %s: %v", tillSaleID, mErr) - } else if _, sErr := db.Conn.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(snap), tillSaleID); sErr != nil { + } else if stored, eErr := encryptSnapshot(snap); eErr != nil { + log.Printf("Failed to encrypt square_request_snapshot for till sale %s: %v", tillSaleID, eErr) + } else if _, sErr := db.Conn.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(stored), tillSaleID); sErr != nil { log.Printf("Failed to store square_request_snapshot for till sale %s: %v", tillSaleID, sErr) } @@ -1116,7 +1158,9 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { // returns IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. if snap, mErr := json.Marshal(paymentReq); mErr != nil { log.Printf("Failed to marshal square_request_snapshot for till sale %s: %v", tillSaleID, mErr) - } else if _, sErr := db.Conn.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(snap), tillSaleID); sErr != nil { + } else if stored, eErr := encryptSnapshot(snap); eErr != nil { + log.Printf("Failed to encrypt square_request_snapshot for till sale %s: %v", tillSaleID, eErr) + } else if _, sErr := db.Conn.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(stored), tillSaleID); sErr != nil { log.Printf("Failed to store square_request_snapshot for till sale %s: %v", tillSaleID, sErr) } @@ -1142,7 +1186,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { log.Printf("CRITICAL: till sale %s charge definitively failed (%v) but gift-card clawback also failed: %v — MANUAL RECONCILIATION REQUIRED: gift card %s may still be funded", tillSaleID, squareErr, revErr, giftCardID) } } - http.Error(w, "Payment failed", http.StatusPaymentRequired) + // 402 only for definitive declines; ambiguous transport/5xx must be + // 503 so the pending sale stays resumable on a same-key retry + // (M3). The clawback decision above stays keyed on + // isDefinitiveChargeFailure, unchanged. + http.Error(w, "Payment failed", chargeFailureStatus(squareErr)) return } diff --git a/backend/handlers/payments/vat.go b/backend/handlers/payments/vat.go index 0f4f9cb..6a0e1e2 100644 --- a/backend/handlers/payments/vat.go +++ b/backend/handlers/payments/vat.go @@ -28,9 +28,49 @@ func GetVATConfig(ctx context.Context, q db.Querier) (*VATConfig, error) { if err != nil { return nil, err } + // HMRC VAT Notice 700/7: a salon-only gift card is a single-purpose + // voucher (SPV) by definition — MPV is legally unavailable for this + // business. A stored "MPV" would suppress VAT on gift-card purchases AND + // on gift-card-funded service payments (an output tax leak), so it is + // overridden to SPV on this READ path so VAT applies to gift-card + // purchases regardless of the stored setting. + if cfg.VoucherType == "MPV" { + log.Printf("VAT config override: voucher_type is 'MPV', which is unavailable for this salon-only gift-card business — treating it as 'SPV' for VAT application (HMRC VAT Notice 700/7)") + cfg.VoucherType = "SPV" + } return &cfg, nil } +// vatAppliesToVoucher reports whether VAT should be applied to a gift-card +// (voucher) sale or purchase. A gift card is a single-purpose voucher (SPV) +// and VAT applies to SPV sales whenever the business is VAT registered; it is +// not applied when the business is not registered or the config is absent. +// GetVATConfig already normalises "MPV" to "SPV", so this returns true unless +// the business is explicitly not VAT registered. +func vatAppliesToVoucher(cfg *VATConfig) bool { + if cfg == nil { + return false + } + return cfg.IsVATRegistered && cfg.VoucherType == "SPV" +} + +// effectiveVoucherTypeForPurchase returns the voucher type that MUST be +// recorded on a gift card at purchase time. HMRC VAT Notice 700/7: a salon-only +// gift card is a single-purpose voucher (SPV) by definition — MPV is legally +// unavailable for this business, so a stored 'MPV' is overridden to 'SPV' +// everywhere (GetVATConfig does the same on the read path). Writing the +// EFFECTIVE type into voucher_type_at_purchase matters: the redemption path +// (handlers.go) defers VAT to redemption only for cards whose stored +// voucher_type_at_purchase is 'MPV' — recording raw 'MPV' while VAT was +// already collected at sale (via the GetVATConfig SPV override) would apply +// VAT a SECOND time at redemption. +func effectiveVoucherTypeForPurchase(raw string) string { + if raw == "MPV" { + return "SPV" + } + return raw +} + // ApplyVATToBookingPayment reads VAT config and calls apply_vat_to_payment // on a booking payment record. The q parameter is used for both reading the // VAT config and for the defensive payment-method check, ensuring all reads @@ -67,12 +107,10 @@ func ApplyVATToTillSale(ctx context.Context, q db.Querier, saleID string) { log.Printf("Failed to read VAT config for till sale %s: %v", saleID, err) return } - if !vatCfg.IsVATRegistered || vatCfg.VoucherType != "SPV" { + if !vatAppliesToVoucher(vatCfg) { return } if _, execErr := q.Exec(ctx, "SELECT apply_vat_to_till_sale($1, $2)", saleID, vatCfg.DefaultVATRate); execErr != nil { log.Printf("Failed to apply VAT to till sale %s: %v", saleID, execErr) } } - - diff --git a/backend/handlers/payments/vat_test.go b/backend/handlers/payments/vat_test.go index 1535d8c..e6fcf61 100644 --- a/backend/handlers/payments/vat_test.go +++ b/backend/handlers/payments/vat_test.go @@ -91,7 +91,11 @@ func TestSPV_VATAppliedAtTillSale(t *testing.T) { } } -func TestMPV_NoVATAtTillSale(t *testing.T) { +// TestMPV_TreatedAsSPV_AppliesVATAtTillSale verifies that a till sale under +// voucher_type=MPV still gets VAT: per HMRC VAT Notice 700/7 a salon-only gift +// card is an SPV by definition, so MPV is overridden to SPV for VAT +// application (M6). +func TestMPV_TreatedAsSPV_AppliesVATAtTillSale(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`) @@ -142,14 +146,14 @@ func TestMPV_NoVATAtTillSale(t *testing.T) { t.Fatalf("failed to query till_sales: %v", err) } - if isVATApplicable { - t.Error("expected is_vat_applicable to be FALSE for MPV till sale") + if !isVATApplicable { + t.Error("expected is_vat_applicable to be TRUE for MPV (treated as SPV) till sale") } - if vatAmount.Valid { - t.Errorf("expected vat_amount to be NULL for MPV till sale, got %.2f", vatAmount.Float64) + if !vatAmount.Valid || vatAmount.Float64 != 8.33 { + t.Errorf("expected vat_amount 8.33 for MPV (treated as SPV) till sale, got %v", vatAmount) } - if netAmount.Valid { - t.Errorf("expected net_amount to be NULL for MPV till sale, got %.2f", netAmount.Float64) + if !netAmount.Valid || netAmount.Float64 != 41.67 { + t.Errorf("expected net_amount 41.67 for MPV (treated as SPV) till sale, got %v", netAmount) } } @@ -669,8 +673,8 @@ func TestGetVATConfig(t *testing.T) { if cfg.DefaultVATRate != 5.00 { t.Errorf("expected DefaultVATRate 5.00, got %.2f", cfg.DefaultVATRate) } - if cfg.VoucherType != "MPV" { - t.Errorf("expected VoucherType MPV, got %s", cfg.VoucherType) + if cfg.VoucherType != "SPV" { + t.Errorf("expected VoucherType SPV (MPV is overridden to SPV for VAT application), got %s", cfg.VoucherType) } // Test with FALSE @@ -1433,7 +1437,7 @@ func TestSPV_FullLifecycle_BuyAndRedeem(t *testing.T) { } } -func TestMPV_FullLifecycle_BuyAndRedeem(t *testing.T) { +func TestEffectiveSPV_MPVConfig_FullLifecycle_BuyAndRedeem(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`) @@ -1448,7 +1452,9 @@ func TestMPV_FullLifecycle_BuyAndRedeem(t *testing.T) { _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") - // Phase 1: Buy gift card via till sale (cash) — MPV, so NO VAT at sale + // Phase 1: Buy gift card via till sale (cash) — configured MPV is treated + // as SPV for VAT application (HMRC VAT Notice 700/7), so VAT IS applied at + // sale (and the card is stored with the effective voucher type 'SPV') reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", @@ -1473,15 +1479,15 @@ func TestMPV_FullLifecycle_BuyAndRedeem(t *testing.T) { var tsResp TillSaleResponse json.NewDecoder(w.Body).Decode(&tsResp) - // Verify till_sale has NO VAT (MPV) + // Verify till_sale HAS VAT (MPV treated as SPV) var tsVAT sql.NullFloat64 var tsVATApplicable bool tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&tsVATApplicable, &tsVAT) - if tsVATApplicable { - t.Error("MPV: expected NO till_sale VAT") + if !tsVATApplicable { + t.Error("MPV (treated as SPV): expected till_sale VAT") } - if tsVAT.Valid { - t.Errorf("MPV: expected NULL vat_amount at sale, got %.2f", tsVAT.Float64) + if !tsVAT.Valid || tsVAT.Float64 != 16.67 { + t.Errorf("MPV (treated as SPV): expected vat_amount 16.67 at sale, got %v", tsVAT) } // Get the gift card ID @@ -1491,7 +1497,9 @@ func TestMPV_FullLifecycle_BuyAndRedeem(t *testing.T) { t.Fatalf("failed to get gift card ID: %v", err) } - // Phase 2: Create a booking and redeem the gift card — MPV so VAT at redemption + // Phase 2: Create a booking and redeem the gift card — the card is stored + // with the effective voucher type 'SPV', so VAT was already charged at + // purchase; NO VAT is applied at redemption. serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) @@ -1523,25 +1531,20 @@ func TestMPV_FullLifecycle_BuyAndRedeem(t *testing.T) { t.Fatalf("redemption: expected 200, got %d: %s", w2.Code, w2.Body.String()) } - // Verify VAT IS applied at redemption (MPV) + // Verify NO VAT is applied at redemption — the card was stored as SPV + // (effective type), so VAT was already charged at purchase var payVAT sql.NullFloat64 var payNet sql.NullFloat64 var payVATApplicable bool tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT, &payNet) - if !payVATApplicable { - t.Error("MPV redemption: expected VAT at redemption") + if payVATApplicable { + t.Error("SPV redemption: expected NO VAT at redemption (VAT already charged at purchase)") } - if !payVAT.Valid { - t.Fatal("MPV redemption: expected vat_amount to be set") + if payVAT.Valid { + t.Errorf("SPV redemption: expected NULL vat_amount, got %.2f", payVAT.Float64) } - if payVAT.Float64 != 8.33 { - t.Errorf("MPV redemption: expected vat 8.33, got %.2f", payVAT.Float64) - } - if !payNet.Valid { - t.Fatal("MPV redemption: expected net_amount to be set") - } - if payNet.Float64 != 41.67 { - t.Errorf("MPV redemption: expected net 41.67, got %.2f", payNet.Float64) + if payNet.Valid { + t.Errorf("SPV redemption: expected NULL net_amount, got %.2f", payNet.Float64) } } @@ -1821,9 +1824,9 @@ func TestVAT_GiftCardCRUD_DoesNotInterfere(t *testing.T) { // ─── Additional tests ────────────────────────────────────────────────────────── -// TestMPV_Topup_NoVAT verifies a gift card topup with voucher_type=MPV does not -// apply VAT at sale. -func TestMPV_Topup_NoVAT(t *testing.T) { +// TestMPV_Topup_AppliesVAT verifies a gift card topup with voucher_type=MPV +// still applies VAT (MPV is overridden to SPV for VAT application, M6). +func TestMPV_Topup_AppliesVAT(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`) @@ -1847,7 +1850,7 @@ func TestMPV_Topup_NoVAT(t *testing.T) { t.Fatalf("failed to create gift card: %v", err) } - // Top up via till sale (cash) — MPV, so no VAT + // Top up via till sale (cash) — MPV treated as SPV, so VAT applies reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "topup", @@ -1873,15 +1876,15 @@ func TestMPV_Topup_NoVAT(t *testing.T) { var tsResp TillSaleResponse json.NewDecoder(w.Body).Decode(&tsResp) - // Verify NO VAT on MPV topup + // Verify VAT IS applied on MPV (treated as SPV) topup var vatAmount sql.NullFloat64 var isVATApplicable bool tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&isVATApplicable, &vatAmount) - if isVATApplicable { - t.Error("expected no VAT on MPV topup") + if !isVATApplicable { + t.Error("expected VAT on MPV (treated as SPV) topup") } - if vatAmount.Valid { - t.Errorf("expected NULL vat_amount on MPV topup, got %.2f", vatAmount.Float64) + if !vatAmount.Valid || vatAmount.Float64 != 4.17 { + t.Errorf("expected vat_amount 4.17 on MPV (treated as SPV) topup, got %v", vatAmount) } // Verify gift card balance still increased @@ -2154,8 +2157,8 @@ func TestVoucherToggle_SPVPurchase_MPVRedeem(t *testing.T) { } } -// TestApplyVATToTillSale_MPV verifies ApplyVATToTillSale has no effect when -// voucher_type is MPV. +// TestApplyVATToTillSale_MPV verifies ApplyVATToTillSale applies VAT even when +// voucher_type is MPV (MPV is overridden to SPV for VAT application, M6). func TestApplyVATToTillSale_MPV(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) @@ -2188,21 +2191,23 @@ func TestApplyVATToTillSale_MPV(t *testing.T) { t.Fatalf("failed to query till_sale: %v", err) } - if isVATApplicable { - t.Error("expected is_vat_applicable to be FALSE for MPV") + if !isVATApplicable { + t.Error("expected is_vat_applicable to be TRUE for MPV (treated as SPV)") } - if vatAmount.Valid { - t.Errorf("expected vat_amount to be NULL for MPV, got %.2f", vatAmount.Float64) + if !vatAmount.Valid || vatAmount.Float64 != 8.33 { + t.Errorf("expected vat_amount 8.33 for MPV (treated as SPV), got %v", vatAmount) } } // ─── Exhaustive voucher_type toggle + legacy tests ───────────────────────── -// TestVoucherToggle_MPVPurchase_SPVRedeem verifies that a gift card bought -// as MPV (no VAT at sale, deferred to redemption) and then redeemed after -// switching to SPV still gets VAT at redemption — because the stored -// voucher_type_at_purchase is MPV, overriding the current business_settings. -func TestVoucherToggle_MPVPurchase_SPVRedeem(t *testing.T) { +// TestVoucherToggle_MPVPurchase_SingleVATAtSale verifies that a gift card +// bought under voucher_type=MPV is recorded with the effective voucher type +// 'SPV' (HMRC VAT Notice 700/7 — MPV is unavailable for a salon-only gift-card +// business) and gets VAT at sale only. Because the card is stored as SPV, +// redeeming it later — even after toggling business_settings back to SPV — +// applies NO VAT at redemption. +func TestVoucherToggle_MPVPurchase_SingleVATAtSale(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'MPV'`) @@ -2217,7 +2222,7 @@ func TestVoucherToggle_MPVPurchase_SPVRedeem(t *testing.T) { _, _ = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID) token := jwt.GenerateTestToken(adminID, "admin") - // Phase 1: Buy gift card as MPV — no VAT at sale + // Phase 1: Buy gift card under MPV — MPV treated as SPV, so VAT at sale reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", @@ -2245,8 +2250,11 @@ func TestVoucherToggle_MPVPurchase_SPVRedeem(t *testing.T) { var tsVAT sql.NullFloat64 var tsVATApplicable bool tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM till_sales WHERE id = $1`, tsResp.ID).Scan(&tsVATApplicable, &tsVAT) - if tsVATApplicable { - t.Error("MPV purchase: expected NO VAT at sale") + if !tsVATApplicable { + t.Error("MPV purchase (treated as SPV): expected VAT at sale") + } + if !tsVAT.Valid || tsVAT.Float64 != 16.67 { + t.Errorf("MPV purchase (treated as SPV): expected vat_amount 16.67 at sale, got %v", tsVAT) } var cardID string @@ -2256,17 +2264,19 @@ func TestVoucherToggle_MPVPurchase_SPVRedeem(t *testing.T) { t.Fatalf("failed to get card ID: %v", err) } tx.QueryRow(ctx, "SELECT voucher_type_at_purchase FROM gift_cards WHERE id = $1", cardID).Scan(&storedVTP) - if !storedVTP.Valid || storedVTP.String != "MPV" { - t.Errorf("expected voucher_type_at_purchase 'MPV', got %v", storedVTP) + if !storedVTP.Valid || storedVTP.String != "SPV" { + t.Errorf("expected voucher_type_at_purchase 'SPV' (MPV overridden to the effective SPV), got %v", storedVTP) } - // Phase 2: Toggle to SPV — should NOT affect already-purchased cards + // Phase 2: Toggle to SPV — the card already stores the effective SPV type, + // so redemption is unaffected by the toggle _, err = tx.Exec(ctx, `UPDATE business_settings SET voucher_type = 'SPV'`) if err != nil { t.Fatalf("failed to toggle to SPV: %v", err) } - // Phase 3: Redeem — stored voucher_type is MPV, so VAT IS applied at redemption + // Phase 3: Redeem — the card is stored as SPV, so VAT was already charged + // at purchase and NO VAT is applied at redemption serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) @@ -2301,14 +2311,11 @@ func TestVoucherToggle_MPVPurchase_SPVRedeem(t *testing.T) { var payVAT sql.NullFloat64 var payVATApplicable bool tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&payVATApplicable, &payVAT) - if !payVATApplicable { - t.Error("expected VAT at redemption (card stored MPV)") + if payVATApplicable { + t.Error("expected NO VAT at redemption (card stored as SPV; VAT charged at purchase)") } - if !payVAT.Valid { - t.Fatal("expected vat_amount to be set at MPV-stored redemption") - } - if payVAT.Float64 != 8.33 { - t.Errorf("expected vat 8.33, got %.2f", payVAT.Float64) + if payVAT.Valid { + t.Errorf("expected NULL vat_amount at SPV-stored redemption, got %.2f", payVAT.Float64) } } @@ -2761,8 +2768,8 @@ func TestGetVATConfig_WithTxQuerier(t *testing.T) { if cfg.DefaultVATRate != 5.00 { t.Errorf("expected DefaultVATRate 5.00, got %.2f", cfg.DefaultVATRate) } - if cfg.VoucherType != "MPV" { - t.Errorf("expected VoucherType MPV, got %s", cfg.VoucherType) + if cfg.VoucherType != "SPV" { + t.Errorf("expected VoucherType SPV (MPV is overridden to SPV for VAT application), got %s", cfg.VoucherType) } } @@ -3287,7 +3294,7 @@ func TestVAT_RoundingConsistency(t *testing.T) { for _, tt := range edgeCases { t.Run(tt.name, func(t *testing.T) { - ctx, tx := testutils.SetupTestTx(t) + ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`) if err != nil { @@ -3383,9 +3390,9 @@ func TestVAT_RoundingConsistency(t *testing.T) { // ─── T8: SPV vs MPV Lifecycle Tests ───────────────────────────────────────── // NOTE: SPV and MPV lifecycle tests already exist above: // - TestSPV_FullLifecycle_BuyAndRedeem (line ~1334) -// - TestMPV_FullLifecycle_BuyAndRedeem (line ~1446) +// - TestEffectiveSPV_MPVConfig_FullLifecycle_BuyAndRedeem (line ~1446) // - TestVoucherToggle_SPVPurchase_MPVRedeem (line ~2059) -// - TestVoucherToggle_MPVPurchase_SPVRedeem (line ~2220) +// - TestVoucherToggle_MPVPurchase_SingleVATAtSale (line ~2220) // These comprehensively cover the full lifecycle of both voucher types // including purchase, redemption, and toggle scenarios. diff --git a/backend/handlers/webhooks/square.go b/backend/handlers/webhooks/square.go index 2cc78e1..fb83583 100644 --- a/backend/handlers/webhooks/square.go +++ b/backend/handlers/webhooks/square.go @@ -20,6 +20,8 @@ import ( "crussell/db" "crussell/handlers/payments" + + "github.com/jackc/pgx/v5" ) type SquareWebhookEvent struct { @@ -95,13 +97,17 @@ 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. +// cash_drawer.*, gift_card.*, transaction.* and invoice.*). invoice.* is +// deliberately included even though this app does not use Square Invoices: an +// invoice event is money-adjacent (Invoice carries amounts and payment state), +// so if Square Invoices are ever used the funds must surface as unknown-money +// and stay retried rather than being acked 200 + dedup'd permanently (M11). +// 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.", + "cash_drawer.", "gift_card.", "transaction.", "invoice.", } { if strings.HasPrefix(eventType, prefix) { return true @@ -112,13 +118,13 @@ func isSquareMoneyFamily(eventType string) bool { // 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 +// 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.", + "customer.", "card.", "order.", "booking.", "appointment.", "availability.", "loyalty.", "merchant.", "location.", "labor.", "inventory.", "site.", "device.", "team_member.", "subscription.", "webhook.", @@ -160,11 +166,14 @@ func webhookDBContext() (context.Context, context.CancelFunc) { // An absent header is allowed through: real Square deliveries always send it, // so a missing header in an enforced deployment is a non-Square client (which // already failed signature verification) or a local mock/dev poster — both -// safely handled downstream. Rejection is 403 (non-retryable for Square): -// a square-environment mismatch is a PERMANENT configuration error that a -// retry could never resolve, so a 5xx would make Square retry forever; a 4xx -// stops the retry loop and forces the operator to fix the subscription or the -// environment setting. +// safely handled downstream. Rejection is 403. NOTE: Square retries ANY +// non-2xx response (4xx included) with exponential backoff for up to ~24h, so +// the 403 does not by itself stop the retry loop — but a square-environment +// mismatch is a PERMANENT configuration error that no retry can resolve, the +// handler is intentionally fail-closed (every replay is rejected identically +// before any state change), and the DB-level dedup (square_webhook_events + +// ON CONFLICT) makes the replayed deliveries replay-safe. The 403 forces the +// operator to fix the subscription or the environment setting. func squareEnvironmentMismatch(headerEnv string) bool { headerEnv = strings.ToLower(strings.TrimSpace(headerEnv)) if headerEnv == "" { @@ -188,10 +197,10 @@ func squareEnvironmentMismatch(headerEnv string) bool { // signed, well-formed event is deduplicated by event_id before dispatch and // 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. +// prefixes including invoice.*) and truly unknown prefixes get 501 (Square +// retries, no dedup row), while known non-money families (customer.*, card.*, +// order.*, 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) @@ -235,9 +244,12 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) { // Fail-closed environment check: a sandbox subscription mis-pointed at the // production URL + key would otherwise process sandbox events against - // production state. 403 (non-retryable for Square) is correct because a - // square-environment mismatch is a permanent config error — a 5xx would - // make Square retry a condition no retry can fix. See + // production state. 403 is correct because a square-environment mismatch is + // a permanent config error no retry can fix — note that Square retries ANY + // non-2xx (4xx included) for up to ~24h, so the 403 does not stop the retry + // loop by itself; the handler rejects every replay identically (fail-closed, + // before any state change) and the DB-level dedup (square_webhook_events + + // ON CONFLICT) makes the replayed deliveries replay-safe. See // squareEnvironmentMismatch for the exact enforcement conditions. if squareEnvironmentMismatch(r.Header.Get("square-environment")) { log.Printf("[SQUARE-WEBHOOK] Rejecting event: square-environment header %q does not match configured SQUARE_ENVIRONMENT %q (403)", @@ -256,9 +268,13 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) { // An empty event_id cannot be deduplicated. Square always sends event_id, // so this is defensive — but once handlers mutate state, a duplicate // empty-ID event would double-apply. Reject with 400 (fail-safe, no - // dispatch, no dedup row): Square's retry policy retries on 5xx/timeouts - // but treats 4xx as non-retryable, so the malformed event is dropped - // without side effects. + // dispatch, no dedup row). NOTE: Square retries ANY non-2xx response (4xx + // included) with exponential backoff for up to ~24h, so the 400 does NOT by + // itself stop the retry loop — but the handler is fail-closed: every replay + // is rejected identically BEFORE any state change or dedup insert, so no + // side effect can ever be applied, and the DB-level dedup + // (square_webhook_events + ON CONFLICT) keeps the repeated deliveries + // replay-safe. if event.EventID == "" { log.Printf("[SQUARE-WEBHOOK] Rejecting event with empty event_id (400)") http.Error(w, "Invalid event", http.StatusBadRequest) @@ -325,14 +341,17 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) { // 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 + // terminal.*, cash_drawer.*, gift_card.*, transaction.*, invoice.* + // 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.*, + // invoice.* is treated as money-adjacent even though Square Invoices + // are unused today: an invoice carries amounts and payment state, so + // acked invoice funds would be invisible to the app (M11). + // • KNOWN NON-MONEY families (customer.*, card.*, order.*, // booking.* — plus appointment.*, availability.*, loyalty.*, // merchant.*, location.*, labor.*, inventory.*, site.*, device.*, // team_member.*, subscription.*, webhook.* — and anything else @@ -347,12 +366,23 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) { 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) + // A19: raise the operator-facing admin notification BEFORE the 501. + // Without it the event is retried by Square for ~24h and then + // silently dropped with only a log line. The notification is a + // SEPARATE table — no square_webhook_events dedup row is written + // here, so Square keeps retrying the event exactly as before. + notifCtx, notifCancel := webhookDBContext() + insertUnknownEventNotification(notifCtx, event.Type, event.EventID) + notifCancel() 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) + notifCtx, notifCancel := webhookDBContext() + insertUnknownEventNotification(notifCtx, event.Type, event.EventID) + notifCancel() http.Error(w, "unhandled webhook event type", http.StatusNotImplemented) return } @@ -505,12 +535,19 @@ func squarePaymentStatusToLocal(status string) (string, bool) { } // squareRefundStatusToLocal maps Square's refund status to the local -// payment_status enum. PENDING is non-terminal. +// payment_status enum. Square's PaymentRefund states are PENDING, APPROVED, +// COMPLETED, CANCELED, FAILED and REJECTED (developer.squareup.com/reference/ +// square/objects/PaymentRefund). COMPLETED/FAILED/REJECTED are TERMINAL — +// REJECTED (Square declined the refund) is a definitive failure and must be +// surfaced as local 'failed' instead of leaving the row pending until the slow +// sweep; PENDING and APPROVED are NON-terminal (the refund may still complete +// or be rejected) and map to a zero local status so the caller leaves the row +// untouched. func squareRefundStatusToLocal(status string) (string, bool) { switch status { case "COMPLETED": return "completed", true - case "FAILED": + case "FAILED", "REJECTED": return "failed", true default: return "", false @@ -646,6 +683,78 @@ func insertCriticalPaymentNotification(ctx context.Context, bookingID, disputeID } } +// unknownEventNotificationID derives the deterministic admin_notifications id +// for an unhandled money-family/truly-unknown webhook event's +// critical_payment_log notification: 'U' + 11 lowercase hex chars of a SHA-256 +// over 'unknown-event-'. Mirrors disputeNotificationID (which uses an +// uppercase 'D' prefix): generate_*_id (init-script.sql) only ever emits 12 +// lowercase hex chars, so the uppercase 'U' prefix guarantees no collision with +// a DB-generated id. The id is stable per event_id, giving +// ON CONFLICT (id) DO NOTHING per-event idempotency across redeliveries. +func unknownEventNotificationID(eventID string) string { + sum := sha256.Sum256([]byte("unknown-event-" + eventID)) + return "U" + hex.EncodeToString(sum[:])[:11] +} + +// insertUnknownEventNotification raises a critical_payment_log admin +// notification for a money-family/truly-unknown webhook event the handler +// refuses to ack (501). Without it the event is retried by Square for ~24h and +// then silently dropped with only a log line. The notification is deduped by +// its deterministic id (unknownEventNotificationID) and lives in a SEPARATE +// table — no square_webhook_events dedup row is written on the 501 path, so +// Square keeps retrying the event exactly as before. Best-effort: an insert +// failure is logged, never a dispatch error (the 501 is already the response). +// The caller supplies a bounded context (webhookDBContext). +func insertUnknownEventNotification(ctx context.Context, eventType, eventID string) { + id := unknownEventNotificationID(eventID) + tag, err := db.Conn.Exec(ctx, ` + INSERT INTO admin_notifications (id, reason, booking_id, created_at) + VALUES ($1, 'critical_payment_log'::admin_notification_reason, NULL, NOW()) + ON CONFLICT (id) DO NOTHING + `, id) + if err != nil { + log.Printf("[SQUARE-WEBHOOK] Failed to insert critical_payment_log admin notification for unhandled event %q (event_id=%s): %v", eventType, eventID, err) + return + } + if tag.RowsAffected() > 0 { + log.Printf("[SQUARE-WEBHOOK] Inserted critical_payment_log admin notification for unhandled event type %q (event_id=%s)", eventType, eventID) + } +} + +// insertRefundFailedNotification surfaces a webhook-demoted FAILED refund in +// the admin notification centre, replicating payments.insertRefundFailedNotifications +// (handlers/payments/refunds.go) — the same 'refund_failed' row and the same +// per-booking dedup. The payments helper is unexported (different package) and +// the sweep-path notification it backs is permanently lost once THIS webhook +// demotes pending→failed (the sweep only processes 'pending' rows), so the +// webhook must raise the notification itself. Dedup: one row per +// (reason='refund_failed', booking_id), matching the sweep's guard so a later +// sweep run can never duplicate it. Best-effort: a failed insert is logged, +// never a dispatch error. The caller supplies a bounded context +// (webhookDBContext). +func insertRefundFailedNotification(ctx context.Context, refundID string) { + if refundID == "" { + return + } + tag, err := db.Conn.Exec(ctx, ` + INSERT INTO admin_notifications (reason, booking_id, created_at) + SELECT DISTINCT 'refund_failed'::admin_notification_reason, booking_id, NOW() + FROM refunds + WHERE id = ANY($1) AND status = 'failed' + AND NOT EXISTS ( + SELECT 1 FROM admin_notifications an + WHERE an.reason = 'refund_failed' AND an.booking_id = refunds.booking_id + ) + `, []string{refundID}) + if err != nil { + log.Printf("[SQUARE-WEBHOOK] Failed to insert refund_failed admin notification for refund %s: %v", refundID, err) + return + } + if tag.RowsAffected() > 0 { + log.Printf("[SQUARE-WEBHOOK] Inserted refund_failed admin notification (refund_id=%s)", refundID) + } +} + // markPaymentFailed flips a payment to 'failed' after a lost dispute — the // money was charged back, so the row must not read as collected. 'refunded' // rows are left alone (the money was returned by refund, not charged back). @@ -865,20 +974,39 @@ func handleRefundUpdated(data json.RawMessage) error { // refunds, so this only tightens it. FAILED only demotes a 'pending' row: // demoting 'completed' would let the guard exclude money that already moved // (the exact risk refunds.go documents for failed refunds). - var upd string switch localStatus { case "completed": - upd = `UPDATE refunds SET status = 'completed' WHERE square_refund_id = $1 AND status <> 'completed'` + tag, err := db.Conn.Exec(ctx, `UPDATE refunds SET status = 'completed' WHERE square_refund_id = $1 AND status <> 'completed'`, refund.ID) + if err != nil { + log.Printf("[SQUARE-WEBHOOK] Failed to update refund %s to status %s: %v", refund.ID, localStatus, err) + return err + } + if tag.RowsAffected() > 0 { + log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s → local status %s", refund.ID, localStatus) + } + return nil case "failed": - upd = `UPDATE refunds SET status = 'failed' WHERE square_refund_id = $1 AND status = 'pending'` - } - tag, err := db.Conn.Exec(ctx, upd, refund.ID) - if err != nil { - log.Printf("[SQUARE-WEBHOOK] Failed to update refund %s to status %s: %v", refund.ID, localStatus, err) - return err - } - if tag.RowsAffected() > 0 { - log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s → local status %s", refund.ID, localStatus) + // A5d: this webhook demotes a 'pending' row to 'failed' BEFORE the sweep + // ever sees it — the sweep only processes 'pending' rows, so its + // failed-refund admin notification (insertRefundFailedNotifications in + // refunds.go) would be permanently lost. Raise the same 'refund_failed' + // notification here, guarded to the actually-demoted row. + var rowID string + err := db.Conn.QueryRow(ctx, + `UPDATE refunds SET status = 'failed' WHERE square_refund_id = $1 AND status = 'pending' RETURNING id`, + refund.ID).Scan(&rowID) + if errors.Is(err, pgx.ErrNoRows) { + // No pending row matched — the refund is already resolved; a late + // FAILED/REJECTED replay must not demote or notify anything. + return nil + } + if err != nil { + log.Printf("[SQUARE-WEBHOOK] Failed to update refund %s to status %s: %v", refund.ID, localStatus, err) + return err + } + log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s → local status failed (row %s)", refund.ID, rowID) + insertRefundFailedNotification(ctx, rowID) + return nil } return nil } @@ -965,7 +1093,7 @@ func handleDisputeCreated(data json.RawMessage) error { } _ = tag insertCriticalPaymentNotification(ctx, bookingID, "") - log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s created (amount %s, reason %q) for square payment %s — admin notified", dispute.ID, amount, dispute.Reason, squarePaymentID) + log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s created (amount %s, reason %q) for square payment %s — admin notified", dispute.ID, amount, truncateDisputeReason(dispute.Reason), squarePaymentID) return nil } diff --git a/backend/handlers/webhooks/webhooks_state_test.go b/backend/handlers/webhooks/webhooks_state_test.go index cc5ec0e..9f9b5a6 100644 --- a/backend/handlers/webhooks/webhooks_state_test.go +++ b/backend/handlers/webhooks/webhooks_state_test.go @@ -91,6 +91,16 @@ func countCriticalNotifications(t *testing.T) int { return n } +func countRefundFailedNotifications(t *testing.T) int { + t.Helper() + var n int + if err := db.Conn.QueryRow(context.Background(), + "SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refund_failed'").Scan(&n); err != nil { + t.Fatalf("failed to count refund_failed notifications: %v", err) + } + return n +} + // deliverWebhook signs and dispatches a Square event through the full handler. func deliverWebhook(t *testing.T, event SquareWebhookEvent) *httptest.ResponseRecorder { t.Helper() @@ -946,6 +956,96 @@ func TestWebhook_RefundUpdated_FailedStatus(t *testing.T) { } } +// TestWebhook_RefundUpdated_RejectedStatus locks the REJECTED mapping: Square +// rejects a refund (REJECTED is a terminal state — Square declined to process +// it), so the local refund must be marked 'failed' and surfaced immediately +// instead of staying pending until the slow sweep notices. +func TestWebhook_RefundUpdated_RejectedStatus(t *testing.T) { + const ( + squarePaymentID = "sqp_refund_pay_reject" + squareRefundID = "sqr_updated_rejected" + ) + payID := createWebhookTestPayment(t, squarePaymentID, "completed") + refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending") + + event := SquareWebhookEvent{ + Type: "refund.updated", + EventID: "evt_refund_updated_rejected_1", + CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{ + "type": "refund", + "id": "` + squareRefundID + `", + "object": { + "refund": { + "id": "` + squareRefundID + `", + "status": "REJECTED", + "payment_id": "` + squarePaymentID + `" + } + } + }`), + } + w := deliverWebhook(t, event) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if got := getRefundStatus(t, refundID); got != "failed" { + t.Errorf("expected refund status 'failed' on REJECTED, got %q", got) + } +} + +// TestWebhook_RefundUpdated_Failed_RaisesAdminNotification locks the A5d fix: +// a FAILED (or REJECTED) refund.updated event demotes the pending refund row +// BEFORE the sweep ever sees it, so the sweep-path refund_failed admin +// notification would be permanently lost — the webhook path must raise the +// notification itself, and only once. +func TestWebhook_RefundUpdated_Failed_RaisesAdminNotification(t *testing.T) { + const ( + squarePaymentID = "sqp_refund_pay_notify" + squareRefundID = "sqr_updated_failed_notify" + ) + payID := createWebhookTestPayment(t, squarePaymentID, "completed") + refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending") + + countBefore := countRefundFailedNotifications(t) + + event := SquareWebhookEvent{ + Type: "refund.updated", + EventID: "evt_refund_updated_failed_notify_1", + CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{ + "type": "refund", + "id": "` + squareRefundID + `", + "object": { + "refund": { + "id": "` + squareRefundID + `", + "status": "FAILED", + "payment_id": "` + squarePaymentID + `" + } + } + }`), + } + w := deliverWebhook(t, event) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if got := getRefundStatus(t, refundID); got != "failed" { + t.Fatalf("expected refund status 'failed', got %q", got) + } + if n := countRefundFailedNotifications(t) - countBefore; n != 1 { + t.Errorf("expected exactly 1 refund_failed admin notification after webhook demotion, got %d", n) + } + + // Re-delivery of the SAME event must not add a second notification (the + // handler's event_id dedup drops the replay before dispatch). + w2 := deliverWebhook(t, event) + if w2.Code != http.StatusOK { + t.Fatalf("expected 200 on re-delivery, got %d: %s", w2.Code, w2.Body.String()) + } + if n := countRefundFailedNotifications(t) - countBefore; n != 1 { + t.Errorf("expected the notification count to stay at 1 after re-delivery, got %d", n) + } +} + func TestWebhook_RefundUpdated_NonTerminal_LeavesPending(t *testing.T) { const ( squarePaymentID = "sqp_refund_pay_pending" diff --git a/backend/handlers/webhooks/webhooks_test.go b/backend/handlers/webhooks/webhooks_test.go index 577a6cd..5351b55 100644 --- a/backend/handlers/webhooks/webhooks_test.go +++ b/backend/handlers/webhooks/webhooks_test.go @@ -272,6 +272,7 @@ func TestHandleSquareWebhook_UnknownEventType(t *testing.T) { } body, _ := json.Marshal(event) sig := webhookTestEnv(t, body) + notifBefore := countCriticalNotifications(t) 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()) @@ -284,21 +285,28 @@ func TestHandleSquareWebhook_UnknownEventType(t *testing.T) { 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) } + // A19: the event must still surface in the admin notification centre — + // without it, after Square's ~24h retry window the event is silently gone. + // The notification is a SEPARATE table, so the no-dedup-row assertion above + // still holds. + if n := countCriticalNotifications(t) - notifBefore; n != 1 { + t.Errorf("expected exactly 1 critical_payment_log admin notification for the unknown event (A19), 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 +// (e.g. customer.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", + Type: "customer.created", EventID: "evt_nonmoney_1", CreatedAt: "2025-01-01T00:00:00Z", - Data: json.RawMessage(`{"id":"inv_1"}`), + Data: json.RawMessage(`{"id":"cust_1"}`), } body, _ := json.Marshal(event) sig := webhookTestEnv(t, body) @@ -341,6 +349,7 @@ func TestHandleSquareWebhook_UnhandledMoneyEvent_NotAcknowledged(t *testing.T) { } body, _ := json.Marshal(event) sig := webhookTestEnv(t, body) + notifBefore := countCriticalNotifications(t) 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()) @@ -353,6 +362,11 @@ func TestHandleSquareWebhook_UnhandledMoneyEvent_NotAcknowledged(t *testing.T) { 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) } + // A19: the unhandled money event must still surface in the admin + // notification centre before the retry window closes. + if n := countCriticalNotifications(t) - notifBefore; n != 1 { + t.Errorf("expected exactly 1 critical_payment_log admin notification for the unhandled money-family event (A19), got %d", n) + } } func TestHandleSquareWebhook_InvalidJSON(t *testing.T) { diff --git a/backend/internal/jobs/cleanup_test.go b/backend/internal/jobs/cleanup_test.go index 4d47643..5b0ff38 100644 --- a/backend/internal/jobs/cleanup_test.go +++ b/backend/internal/jobs/cleanup_test.go @@ -4,10 +4,16 @@ package jobs import ( "context" + "crypto/sha256" + "encoding/hex" + "fmt" "os" + "sync" "testing" "crussell/db" + "crussell/handlers/payments" + "crussell/internal/square" "crussell/testutils/testdb" ) @@ -323,3 +329,296 @@ func TestScanCriticalPaymentLogs_RefundBelowCapNotNotified(t *testing.T) { t.Errorf("expected 0 notifications for a refund below the attempt cap, got %d", n) } } + +// ============================================================ +// RetryPendingSquareErasures — GDPR Square outbox job (batch-1 fix) +// ============================================================ + +// recordingErasureClient embeds the dev mock and records every Square erasure +// call, with opt-in failure injection, so tests can assert exactly what the +// retry-square-erasures job calls (and doesn't call) at Square. +type recordingErasureClient struct { + square.SquareClient + mu sync.Mutex + deletedCards []string + deletedCustomers []string + failCards bool + failCustomers bool +} + +func (c *recordingErasureClient) DeleteCardOnFile(ctx context.Context, cardID string) error { + c.mu.Lock() + c.deletedCards = append(c.deletedCards, cardID) + c.mu.Unlock() + if c.failCards { + return fmt.Errorf("square: simulated card erasure failure") + } + return c.SquareClient.DeleteCardOnFile(ctx, cardID) +} + +func (c *recordingErasureClient) DeleteCustomer(ctx context.Context, customerID string) error { + c.mu.Lock() + c.deletedCustomers = append(c.deletedCustomers, customerID) + c.mu.Unlock() + if c.failCustomers { + return fmt.Errorf("square: simulated customer erasure failure") + } + return c.SquareClient.DeleteCustomer(ctx, customerID) +} + +func (c *recordingErasureClient) cardDeletes() []string { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string(nil), c.deletedCards...) +} + +func (c *recordingErasureClient) customerDeletes() []string { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string(nil), c.deletedCustomers...) +} + +// newErasureTestClient builds a recording client over the dev mock, forcing +// SQUARE_ENVIRONMENT=mock so a developer's production env var can never panic +// NewDevClient mid-test. +func newErasureTestClient(t *testing.T) *recordingErasureClient { + t.Helper() + t.Setenv("SQUARE_ENVIRONMENT", "mock") + return &recordingErasureClient{SquareClient: square.NewDevClient()} +} + +// seedErasureOutboxRow inserts a soft-deleted user_saved_cards row that the +// retry-square-erasures job treats as a pending Square erasure outbox entry +// (deleted_at set + last_4 = 'XXXX' + at least one Square reference). Cleanup +// removes the row and any critical notifications the job raised. +func seedErasureOutboxRow(t *testing.T, squareCardID, squareCustomerID *string) string { + t.Helper() + ctx := context.Background() + var cardID, customerID any + if squareCardID != nil { + cardID = *squareCardID + } + if squareCustomerID != nil { + customerID = *squareCustomerID + } + var id string + if err := db.Conn.QueryRow(ctx, ` + INSERT INTO user_saved_cards (square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, deleted_at) + VALUES ($1, $2, 'VISA', 'XXXX', 12, 2030, NOW()) + RETURNING id + `, cardID, customerID).Scan(&id); err != nil { + t.Fatalf("failed to seed erasure outbox row: %v", err) + } + t.Cleanup(func() { + _, _ = db.Conn.Exec(ctx, "DELETE FROM user_saved_cards WHERE id = $1", id) + _, _ = db.Conn.Exec(ctx, "DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'") + }) + return id +} + +// querySquareCardID returns the square_card_id of a row, or nil when NULL. +func querySquareCardID(ctx context.Context, t *testing.T, rowID string) *string { + t.Helper() + var id *string + if err := db.Conn.QueryRow(ctx, "SELECT square_card_id FROM user_saved_cards WHERE id = $1", rowID).Scan(&id); err != nil { + t.Fatalf("failed to query square_card_id for row %s: %v", rowID, err) + } + return id +} + +func querySquareCustomerID(ctx context.Context, t *testing.T, rowID string) *string { + t.Helper() + var id *string + if err := db.Conn.QueryRow(ctx, "SELECT square_customer_id FROM user_saved_cards WHERE id = $1", rowID).Scan(&id); err != nil { + t.Fatalf("failed to query square_customer_id for row %s: %v", rowID, err) + } + return id +} + +// erasureNotificationID mirrors handlers/user's deterministic notification id +// scheme so the test can assert the exact alert row the job raised. +func erasureNotificationID(key string) string { + sum := sha256.Sum256([]byte("square-erasure-failure:" + key)) + return "S" + hex.EncodeToString(sum[:])[:11] +} + +// TestRetryPendingSquareErasures_DrainsCardOutboxRow verifies a pending card +// erasure is retried at Square and, on success, the outbox row is drained +// (square_card_id NULLed) and reported in the drained count. +func TestRetryPendingSquareErasures_DrainsCardOutboxRow(t *testing.T) { + ctx := context.Background() + client := newErasureTestClient(t) + + card, err := client.CreateCardOnFile(ctx, "user-delete-me", "cnon:test-card", "cus_mock_seed") + if err != nil { + t.Fatalf("failed to seed mock card: %v", err) + } + cardID := card.CardID + rowID := seedErasureOutboxRow(t, &cardID, nil) + + orig := payments.SquareClient + payments.SquareClient = client + t.Cleanup(func() { payments.SquareClient = orig }) + + n, err := RetryPendingSquareErasures(ctx) + if err != nil { + t.Fatalf("RetryPendingSquareErasures failed: %v", err) + } + if n != 1 { + t.Errorf("expected 1 drained row, got %d", n) + } + if got := client.cardDeletes(); len(got) != 1 || got[0] != cardID { + t.Errorf("expected exactly 1 card deletion call for %q, got %v", cardID, got) + } + if id := querySquareCardID(ctx, t, rowID); id != nil { + t.Errorf("expected square_card_id to be NULL after drain, got %q", *id) + } +} + +// TestRetryPendingSquareErasures_DrainsCustomerOutboxRow verifies the same +// drain for a customer-only outbox row. +func TestRetryPendingSquareErasures_DrainsCustomerOutboxRow(t *testing.T) { + ctx := context.Background() + client := newErasureTestClient(t) + + cust, err := client.CreateCustomer(ctx, "Erasure Test", "erasure-test@example.com") + if err != nil { + t.Fatalf("failed to seed mock customer: %v", err) + } + customerID := cust.ID + rowID := seedErasureOutboxRow(t, nil, &customerID) + + orig := payments.SquareClient + payments.SquareClient = client + t.Cleanup(func() { payments.SquareClient = orig }) + + n, err := RetryPendingSquareErasures(ctx) + if err != nil { + t.Fatalf("RetryPendingSquareErasures failed: %v", err) + } + if n != 1 { + t.Errorf("expected 1 drained row, got %d", n) + } + if got := client.customerDeletes(); len(got) != 1 || got[0] != customerID { + t.Errorf("expected exactly 1 customer deletion call for %q, got %v", customerID, got) + } + if id := querySquareCustomerID(ctx, t, rowID); id != nil { + t.Errorf("expected square_customer_id to be NULL after drain, got %q", *id) + } +} + +// TestRetryPendingSquareErasures_KeepsFailedCardRowForRetry verifies a failed +// Square deletion leaves the outbox row armed for the next run and raises a +// deduped critical notification (row-scoped key). +func TestRetryPendingSquareErasures_KeepsFailedCardRowForRetry(t *testing.T) { + ctx := context.Background() + client := newErasureTestClient(t) + client.failCards = true + + cardID := "ccof:mock_missing_card" + rowID := seedErasureOutboxRow(t, &cardID, nil) + + orig := payments.SquareClient + payments.SquareClient = client + t.Cleanup(func() { payments.SquareClient = orig }) + + n, err := RetryPendingSquareErasures(ctx) + if err != nil { + t.Fatalf("RetryPendingSquareErasures failed: %v", err) + } + if n != 0 { + t.Errorf("expected 0 drained rows on failure, got %d", n) + } + if id := querySquareCardID(ctx, t, rowID); id == nil || *id != cardID { + t.Errorf("expected square_card_id %q to be retained for retry, got %v", cardID, id) + } + + var gotID string + if err := db.Conn.QueryRow(ctx, "SELECT id FROM admin_notifications WHERE reason = 'critical_payment_log'").Scan(&gotID); err != nil { + t.Fatalf("expected a critical_payment_log notification to be raised: %v", err) + } + if want := erasureNotificationID("row:" + rowID); gotID != want { + t.Errorf("expected notification id %q, got %q", want, gotID) + } +} + +// TestRetryPendingSquareErasures_NoOpWithoutSquareClient verifies the job is a +// no-op when no Square client is configured: no external call, no drain, no +// error. +func TestRetryPendingSquareErasures_NoOpWithoutSquareClient(t *testing.T) { + ctx := context.Background() + cardID := "ccof:mock_card" + rowID := seedErasureOutboxRow(t, &cardID, nil) + + orig := payments.SquareClient + payments.SquareClient = nil + t.Cleanup(func() { payments.SquareClient = orig }) + + n, err := RetryPendingSquareErasures(ctx) + if err != nil { + t.Fatalf("RetryPendingSquareErasures failed: %v", err) + } + if n != 0 { + t.Errorf("expected 0 drained rows without a Square client, got %d", n) + } + if id := querySquareCardID(ctx, t, rowID); id == nil || *id != cardID { + t.Errorf("expected outbox row to be untouched, got square_card_id %v", id) + } +} + +// TestRetryPendingSquareErasures_KeepsSharedCustomerReferencedByActiveCard +// verifies a shared Square customer is NOT deleted (and its outbox row is +// drained as deliberately-skipped) while any active card of another account +// still references it. +func TestRetryPendingSquareErasures_KeepsSharedCustomerReferencedByActiveCard(t *testing.T) { + ctx := context.Background() + client := newErasureTestClient(t) + + cust, err := client.CreateCustomer(ctx, "Shared User", "shared-erasure@example.com") + if err != nil { + t.Fatalf("failed to seed mock customer: %v", err) + } + customerID := cust.ID + + outboxRowID := seedErasureOutboxRow(t, nil, &customerID) + + var activeUserID string + if err := db.Conn.QueryRow(ctx, ` + INSERT INTO users (n_first_name, n_last_name, phone, date_of_birth) + VALUES ('Active', 'User', '+447700900127', '1990-01-01') + RETURNING id`).Scan(&activeUserID); err != nil { + t.Fatalf("failed to seed active user: %v", err) + } + var activeRowID string + if err := db.Conn.QueryRow(ctx, ` + INSERT INTO user_saved_cards (user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year) + VALUES ($1, 'ccof:mock_active', $2, 'VISA', '4242', 12, 2030) + RETURNING id`, activeUserID, customerID).Scan(&activeRowID); err != nil { + t.Fatalf("failed to seed active card row: %v", err) + } + t.Cleanup(func() { + _, _ = db.Conn.Exec(ctx, "DELETE FROM user_saved_cards WHERE id = $1", activeRowID) + _, _ = db.Conn.Exec(ctx, "DELETE FROM users WHERE id = $1", activeUserID) + }) + + orig := payments.SquareClient + payments.SquareClient = client + t.Cleanup(func() { payments.SquareClient = orig }) + + n, err := RetryPendingSquareErasures(ctx) + if err != nil { + t.Fatalf("RetryPendingSquareErasures failed: %v", err) + } + if n != 1 { + t.Errorf("expected 1 drained outbox row, got %d", n) + } + if got := client.customerDeletes(); len(got) != 0 { + t.Errorf("expected NO DeleteCustomer call for a still-referenced customer, got %v", got) + } + if id := querySquareCustomerID(ctx, t, outboxRowID); id != nil { + t.Errorf("expected outbox row square_customer_id to be drained, got %q", *id) + } + if id := querySquareCustomerID(ctx, t, activeRowID); id == nil || *id != customerID { + t.Errorf("expected active row to keep its customer reference, got %v", id) + } +} diff --git a/backend/internal/square/square.go b/backend/internal/square/square.go index 3ddc280..c85b493 100644 --- a/backend/internal/square/square.go +++ b/backend/internal/square/square.go @@ -55,10 +55,17 @@ func (p *ProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (* return refundPaymentHTTP(ctx, req) } +func (p *ProdClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) { + return paymentWasRefundedWithClient(ctx, paymentID, newHTTPClient()) +} + func (p *ProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) { return createCardOnFileHTTP(ctx, userID, cardToken, customerID) } +// GetCardsOnFile has ZERO production callers (grep across the repo confirms +// the only users are this package's tests) and is kept on the SquareClient +// interface solely so the dev mock's List Cards parity tests can exercise it. func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) { return getCardsOnFileHTTP(ctx, userID) } diff --git a/backend/internal/square/square_dev.go b/backend/internal/square/square_dev.go index e2cf140..a2e5c00 100644 --- a/backend/internal/square/square_dev.go +++ b/backend/internal/square/square_dev.go @@ -22,18 +22,21 @@ package square // // FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail, // FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout, -// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus) that let dev/tests -// drive Square failure modes that are otherwise only reachable against the real -// API. FailAfterCommit simulates the exact "charged but response lost → same-key -// retry" prod scenario: CreatePayment COMMITS the charge (retaining the key -// and source in the ledgers exactly like a successful charge) and THEN returns -// a 5xx-style error to the caller. A subsequent CreatePayment with the SAME -// key + SAME source dedups to the committed payment, proving no double charge. +// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus, +// SimulateVerificationRequired) that let dev/tests drive Square failure modes +// that are otherwise only reachable against the real API. FailAfterCommit +// simulates the exact "charged but response lost → same-key retry" prod +// scenario: CreatePayment COMMITS the charge (retaining the key and source in +// the ledgers exactly like a successful charge) and THEN returns a 5xx-style +// error to the caller. A subsequent CreatePayment with the SAME key + SAME +// source dedups to the committed payment, proving no double charge. // SimulateSourceUsed simulates Square's SOURCE_USED rejection of a card source // (cnon: nonce) reused after a previous save. ForcePaymentStatus forces // CreatePayment's payment status while returning nil error — the "Square // returned 200 with a non-terminal payment" prod scenario, so a status-blind // handler (records 'completed' on nil error alone) is caught in dev. +// SimulateVerificationRequired mirrors Square's SCA enforcement on +// customer-initiated new-card charges (see the field doc). // // REAL-API SAFETY GUARD. A `//go:build dev` build must never silently route to // the real PRODUCTION Square API on an env-string match alone — a typo'd or @@ -114,18 +117,24 @@ type MockClient struct { // committed payment — never a second charge — exercising the retry path // devs hit in prod when Square processes a charge but the response is lost. FailAfterCommit bool - // SimulateSourceUsed makes CreateCardOnFile enforce Square's SOURCE_USED - // rejection: a card source (cnon: nonce) already used to create a card on - // this mock instance is rejected with the same structured 400 SOURCE_USED - // error real Square's CreateCard API returns (SOURCE_USED — NOT the - // CreatePayment code CARD_TOKEN_USED). Off by default — dev/test flows - // reuse plain "cnon:test-card"-style tokens across requests, so enforcement - // is enabled only in tests that exercise the reused-source rejection. - // UsedSources() reports the sources consumed so far. + // SimulateSourceUsed enforces Square's single-use source simulation on both + // endpoints: CreateCardOnFile rejects a card source (cnon: nonce) already + // used to create a card with the structured 400 SOURCE_USED error real + // Square's CreateCard API returns, and CreatePayment rejects a cnon nonce + // already used to create a payment or card with 400 CARD_TOKEN_USED. Off by + // default — the handler integration suite shares ONE mock instance across + // parallel tests (testmain_test.go) and reuses "cnon:test-card"-style + // tokens across requests, so enforcement is enabled only in tests that + // exercise the reused-source rejection. UsedSources() reports the sources + // consumed so far. SimulateSourceUsed bool // usedSources records card sources consumed by CreateCardOnFile while // SimulateSourceUsed is enabled (Square consumes a cnon: nonce on card - // creation, so reusing it is rejected with SOURCE_USED). + // creation, so reusing it is rejected with SOURCE_USED). CreatePayment's + // single-use nonce simulation shares the same map: with the toggle on, a + // cnon consumed by either endpoint is rejected on reuse (CARD_TOKEN_USED + // from CreatePayment, SOURCE_USED from CreateCardOnFile) — exactly like + // real Square, which consumes a nonce regardless of which endpoint used it. usedSources map[string]bool // ForcePaymentStatus forces CreatePayment's payment status instead of the // default "COMPLETED" (or "APPROVED" for autocomplete=false). When set, @@ -137,6 +146,16 @@ type MockClient struct { // see square_http_client.go), so only the handler's own status check can // catch a FAILED/CANCELED/PENDING/APPROVED payment. ForcePaymentStatus string + // SimulateVerificationRequired mirrors Square's SCA enforcement on + // customer-initiated new-card charges: when true, CreatePayment with a + // cnon: (new-card nonce) source that carries no VerificationToken is + // rejected with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — + // the buyer must complete 3DS/SCA verification and re-tokenize, NOT retry + // the same request (the code is in definitivePaymentCodes). A present + // verification token (e.g. a verify_mock_... token) satisfies the gate and + // the charge succeeds. Off by default — existing dev/test flows charge + // plain "cnon:test-card"-style tokens without verification tokens. + SimulateVerificationRequired bool } type devProdClient struct{} @@ -169,6 +188,9 @@ func (d *devProdClient) CancelCheckout(ctx context.Context, checkoutID string) e func (d *devProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) { return refundPaymentHTTP(ctx, req) } +func (d *devProdClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) { + return PaymentWasRefunded(ctx, paymentID) +} func (d *devProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) { return createCardOnFileHTTP(ctx, userID, cardToken, customerID) } @@ -338,6 +360,47 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (* } } + // Mirror Square's SCA enforcement (opt-in toggle, off by default): a + // new-card (cnon:) charge without a 3DS/SCA verification token is rejected + // with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the buyer + // must re-verify and re-tokenize, NOT retry the same request (the code is + // in definitivePaymentCodes). A present verification token (e.g. + // verify_mock_...) satisfies the gate exactly as production accepts a + // Square-issued verification_token on the CreatePayment body. + if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") && req.VerificationToken == "" { + return nil, &squareAPIError{ + Code: "CARD_DECLINED_VERIFICATION_REQUIRED", + Category: "PAYMENT_METHOD_ERROR", + Detail: "card requires buyer verification (3DS/SCA); supply a verification token", + StatusCode: http.StatusBadRequest, + err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"), + } + } + + // Mirror Square's single-use card nonces: when SimulateSourceUsed is set, + // a cnon: nonce can only be charged once on this mock instance. Square + // consumes a nonce when it is used to create a payment, so reusing it + // under a DIFFERENT idempotency key is rejected with CARD_TOKEN_USED (the + // CreatePayment code for a used source) — a same-key retry already deduped + // above and never reaches here. The consumption is OFF by default: the + // handler integration suite shares ONE mock instance across parallel tests + // (testmain_test.go assigns a single square.NewDevClient() to the package + // global) and reuses "cnon:test-card"-style tokens across tests, so + // default-on consumption would break those tests. Tests that need the + // single-use simulation flip the toggle on. + if strings.HasPrefix(req.SourceID, "cnon:") && m.SimulateSourceUsed { + if m.usedSources[req.SourceID] { + return nil, &squareAPIError{ + Code: "CARD_TOKEN_USED", + Category: "PAYMENT_METHOD_ERROR", + Detail: "The card nonce can no longer be used because it has been used to create a payment", + StatusCode: http.StatusBadRequest, + err: fmt.Errorf("square: card nonce %s has already been used to create a payment", tokenPrefix(req.SourceID)), + } + } + m.usedSources[req.SourceID] = true + } + now := clock.Now().UTC() status := "COMPLETED" @@ -712,15 +775,62 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (* return nil, fmt.Errorf("square: refund amount must be positive (amount_money is required)") } - if _, ok := m.payments[req.PaymentID]; !ok { - // Payment not in mock map — this happens when integration tests - // create payments via DB fixture with a square_payment_id, bypassing - // the mock. Process the refund without full payment data. - log.Printf("[SQUARE-MOCK] RefundPayment: payment %s not in mock map — proceeding without full payment data", req.PaymentID) + // Reject refunds for a mock-artifact payment ID that was never created. + // The mock mints payment IDs as "pay_mock_"; a refund targeting such an + // ID that is NOT in the ledger is a provable bug (that charge never went + // through this mock) and real Square answers 404 NOT_FOUND. Non-"pay_mock_" + // IDs (e.g. the DB-fixture square_payment_id values handler tests seed + // refunds against) are payments that exist outside the mock's ledger — + // exactly as they would at real Square — so they take the lenient path. + if strings.HasPrefix(req.PaymentID, "pay_mock_") { + if _, known := m.payments[req.PaymentID]; !known { + log.Printf("[SQUARE-MOCK] RefundPayment REJECTED: payment %s not found (NOT_FOUND)", req.PaymentID) + return nil, &squareAPIError{ + Code: "NOT_FOUND", + Category: "INVALID_REQUEST_ERROR", + Detail: "The payment_id in the refund request does not exist", + StatusCode: http.StatusNotFound, + err: fmt.Errorf("square: no payment %s exists to refund", tokenPrefix(req.PaymentID)), + } + } } amount := req.Amount + // Known payments get the real Square over-refund rejection: refunding more + // than the remaining balance answers 400 REFUND_AMOUNT_INVALID. Square + // returns that SAME code for an already-refunded payment, so — exactly like + // the real client — the mock reconciles: an existing refund (money already + // moved) → ErrRefundAlreadyProcessed; no refund recorded → the amount is + // genuinely invalid → ErrRefundDeclined. + if payment, ok := m.payments[req.PaymentID]; ok { + remaining := payment.Amount + for _, r := range m.refunds { + if r.PaymentID == req.PaymentID && (r.Status == "COMPLETED" || r.Status == "APPROVED" || r.Status == "PENDING") { + remaining -= r.Amount + } + } + if req.Amount > remaining { + apiErr := &squareAPIError{ + Code: "REFUND_AMOUNT_INVALID", + Category: "INVALID_REQUEST_ERROR", + Detail: "The refunded amount is more than the remaining balance", + StatusCode: http.StatusBadRequest, + err: fmt.Errorf("square: refund amount %d exceeds remaining balance %d for payment %s", req.Amount, remaining, req.PaymentID), + } + if remaining < payment.Amount { + return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, apiErr) + } + return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, apiErr) + } + } else { + // Payment not in mock map — this happens when integration tests create + // payments via DB fixture with a square_payment_id, bypassing the mock. + // Process the refund without full payment data (the balance is unknown, + // so no over-refund check applies). + log.Printf("[SQUARE-MOCK] RefundPayment: payment %s not in mock map — proceeding without full payment data", req.PaymentID) + } + locationID := req.LocationID if locationID == "" { locationID = "L_MOCK" @@ -758,6 +868,26 @@ func (m *MockClient) RefundKeyCount() int { return len(m.refundByKey) } +// PaymentWasRefunded mirrors the real client's reconciliation source: true when +// any refund with status COMPLETED, APPROVED, or PENDING exists for the payment +// (FAILED/REJECTED refunds never moved money and are ignored). Shares the exact +// status set the real client's paymentWasRefundedWithClient uses so handler +// reconciliation behaves identically in dev/mock and production. +func (m *MockClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + for _, r := range m.refunds { + if r.PaymentID != paymentID { + continue + } + switch r.Status { + case "COMPLETED", "APPROVED", "PENDING": + return true, nil + } + } + return false, nil +} + // UsedSources returns the card sources consumed by CreateCardOnFile while // SimulateSourceUsed is enabled. Test accessor for asserting that a reused // source is rejected with SOURCE_USED after a previous save. @@ -883,14 +1013,26 @@ func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error m.mu.Lock() defer m.mu.Unlock() + // Production callers pass the DB-stored ccof: card reference + // (CardOnFile.CardID, e.g. "ccof:mock_..."), which the mock must resolve + // through cardByToken (keyed by the full CardID) so the deletion actually + // finds and disables the card — previously the mock keyed only by its + // mock-local ID (mock_card_...) and silently missed every ccof: call. + if card, ok := m.cardByToken[cardID]; ok { + card.Enabled = false + log.Printf("[SQUARE-MOCK] Card disabled: id=%s (user=%s)", tokenPrefix(cardID), tokenPrefix(card.ReferenceID)) + return nil + } + // Fallback for the mock-local ID form (mock_card_...) still exercised by + // this package's own tests — resolve the card through the per-user maps. for userID, cards := range m.cards { if card, ok := cards[cardID]; ok { card.Enabled = false - log.Printf("[SQUARE-MOCK] Card disabled: id=%s (user=%s)", cardID, userID) + log.Printf("[SQUARE-MOCK] Card disabled: id=%s (user=%s)", tokenPrefix(cardID), userID) return nil } } - return fmt.Errorf("card not found: %s", cardID) + return fmt.Errorf("square: card not found: %s", cardID) } func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) { diff --git a/backend/internal/square/square_dev_test.go b/backend/internal/square/square_dev_test.go index e3d4ded..3e88bba 100644 --- a/backend/internal/square/square_dev_test.go +++ b/backend/internal/square/square_dev_test.go @@ -438,6 +438,47 @@ func TestDevClient_RefundPayment_RefundAlreadyPending(t *testing.T) { assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored when a refund is already pending") } +func TestDevClient_PaymentWasRefunded_StatusSet(t *testing.T) { + // Locks the mock's PaymentWasRefunded status set against the real client's + // reconciliation source (COMPLETED/APPROVED/PENDING → true; FAILED/REJECTED + // → false): handler-side REFUND_AMOUNT_INVALID reconciliation must behave + // identically in dev/mock and production. + client := NewDevClient().(*MockClient) + ctx := context.Background() + + now := time.Now().UTC() + for _, tc := range []struct { + status string + want bool + }{ + {"COMPLETED", true}, + {"APPROVED", true}, + {"PENDING", true}, + {"FAILED", false}, + {"REJECTED", false}, + } { + client.mu.Lock() + id := fmt.Sprintf("ref_mock_%d", now.UnixNano()) + client.refunds[id] = &RefundResult{ + ID: id, + Status: tc.status, + Amount: 5000, + PaymentID: "pay_mock_was_refunded", + CreatedAt: now.Format(time.RFC3339), + } + client.mu.Unlock() + + got, err := client.PaymentWasRefunded(ctx, "pay_mock_was_refunded") + require.NoError(t, err) + assert.Equal(t, tc.want, got, "status %s", tc.status) + } + + // A different payment with no refunds reports false. + got, err := client.PaymentWasRefunded(ctx, "pay_mock_no_refunds") + require.NoError(t, err) + assert.False(t, got) +} + func TestDevClient_RefundPayment_FailRefundCode_OtherCode(t *testing.T) { // Any other code configured via FailRefundCode preserves the prior // ErrRefundDeclined classification (e.g. REFUND_DECLINED in prod). @@ -1890,3 +1931,294 @@ func TestDevClient_CreateCheckout_CompletedPaymentResolvableByID(t *testing.T) { assert.Equal(t, completed.ID, got.ID) assert.Equal(t, "COMPLETED", got.Status) } + +// TestDevClient_DeleteCardOnFile_CcofResolution locks the DeleteCardOnFile +// ccof: resolution fix: production callers pass the DB-stored ccof: card +// reference (CardOnFile.CardID, e.g. "ccof:mock_..."), which the mock must +// resolve through cardByToken so the deletion actually finds and disables the +// card — previously the mock keyed only by its mock-local ID (mock_card_...) +// and silently missed every ccof: call. +func TestDevClient_DeleteCardOnFile_CcofResolution(t *testing.T) { + client := NewDevClient().(*MockClient) + ctx := context.Background() + userID := "user-ccof-delete" + + t.Run("ccof_card_id_disables_and_hides", func(t *testing.T) { + card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-ccof", "cus_test123") + require.NoError(t, err) + require.True(t, strings.HasPrefix(card.CardID, "ccof:"), "mock CardID must be ccof:-prefixed to exercise the cardByToken path") + + err = client.DeleteCardOnFile(ctx, card.CardID) + require.NoError(t, err, "deleting by the DB-stored ccof: CardID must resolve the card through cardByToken") + + // The card object itself must be disabled, not just hidden. + client.mu.RLock() + deleted := client.cardByToken[card.CardID] + client.mu.RUnlock() + require.NotNil(t, deleted, "the ccof: token must remain resolvable after deletion") + assert.False(t, deleted.Enabled, "the ccof: resolved card must be disabled") + + // Square's List Cards API excludes disabled cards by default — the + // deleted card disappears from GetCardsOnFile. + cards, err := client.GetCardsOnFile(ctx, userID) + require.NoError(t, err) + assert.Empty(t, cards, "a card deleted by its ccof: CardID must disappear from GetCardsOnFile") + }) + + t.Run("mock_local_id_still_works", func(t *testing.T) { + card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-local", "cus_test123") + require.NoError(t, err) + + err = client.DeleteCardOnFile(ctx, card.ID) + require.NoError(t, err, "deleting by the mock-local ID (mock_card_...) must keep working via the per-user fallback") + + cards, err := client.GetCardsOnFile(ctx, userID) + require.NoError(t, err) + assert.Empty(t, cards, "a card deleted by its mock-local ID must also disappear from GetCardsOnFile") + }) + + t.Run("unknown_id_errors_not_silent", func(t *testing.T) { + err := client.DeleteCardOnFile(ctx, "ccof:never-created") + require.Error(t, err, "an unknown card ID must return an error, never silent success") + assert.Contains(t, err.Error(), "card not found") + }) +} + +// TestDevClient_RefundPayment_UnknownPayMockID_NotFound locks the mock's +// NOT_FOUND strictness: a refund targeting a "pay_mock_*" ID that was never +// created is a provable bug (that charge never went through this mock) and real +// Square answers 404 NOT_FOUND — the mock must surface the structured error, +// never silently proceed. +func TestDevClient_RefundPayment_UnknownPayMockID_NotFound(t *testing.T) { + client := NewDevClient().(*MockClient) + ctx := context.Background() + + result, err := client.RefundPayment(ctx, RefundPaymentReq{ + PaymentID: "pay_mock_never_created", + Amount: 5000, + IdempotencyKey: "refund-unknown-pay-mock", + }) + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, "NOT_FOUND", ErrorCode(err)) + assert.Equal(t, http.StatusNotFound, ErrorStatusCode(err)) + + client.mu.RLock() + defer client.mu.RUnlock() + assert.Len(t, client.refunds, 0, "no refund must be stored for an unknown pay_mock_* payment") + assert.Len(t, client.refundByKey, 0, "no refund-by-key entry must be stored for an unknown pay_mock_* payment") +} + +// TestDevClient_RefundPayment_OverRefund locks the mock's real Square +// over-refund rejection: refunding more than the remaining balance answers 400 +// REFUND_AMOUNT_INVALID. Square returns that SAME code for an already-refunded +// payment, so — exactly like the real client — the mock reconciles: no refund +// recorded yet → the amount is genuinely invalid → ErrRefundDeclined; an +// existing refund (money already moved) → ErrRefundAlreadyProcessed. The +// exact-remaining boundary refund succeeds. +func TestDevClient_RefundPayment_OverRefund(t *testing.T) { + client := NewDevClient().(*MockClient) + ctx := context.Background() + + t.Run("over_refund_no_prior_refunds_is_declined", func(t *testing.T) { + payment, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-overrefund-fresh", + }) + require.NoError(t, err) + + result, err := client.RefundPayment(ctx, RefundPaymentReq{ + PaymentID: payment.ID, Amount: 12000, IdempotencyKey: "refund-overrefund-fresh", + }) + require.Error(t, err) + assert.Nil(t, result) + // The mock builds a squareAPIError with Code REFUND_AMOUNT_INVALID but + // wraps it with %v (exactly like the real client's sentinel wrap), so + // the code is not reachable via ErrorCode(err) — the observable + // contract is the ErrRefundDeclined sentinel. + assert.True(t, errors.Is(err, ErrRefundDeclined), "a genuine over-refund with no prior refunds must be ErrRefundDeclined, got %v", err) + assert.False(t, errors.Is(err, ErrRefundAlreadyProcessed)) + }) + + t.Run("exact_remaining_refund_succeeds", func(t *testing.T) { + payment, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-exact-remaining", + }) + require.NoError(t, err) + + // A partial refund leaves 7000 remaining. + first, err := client.RefundPayment(ctx, RefundPaymentReq{ + PaymentID: payment.ID, Amount: 3000, IdempotencyKey: "refund-partial", + }) + require.NoError(t, err) + assert.Equal(t, "COMPLETED", first.Status) + + // Refunding exactly the remaining balance is NOT an over-refund. + second, err := client.RefundPayment(ctx, RefundPaymentReq{ + PaymentID: payment.ID, Amount: 7000, IdempotencyKey: "refund-exact-remaining", + }) + require.NoError(t, err) + assert.Equal(t, int64(7000), second.Amount) + assert.Equal(t, "COMPLETED", second.Status) + }) + + t.Run("over_refund_with_existing_refund_is_already_processed", func(t *testing.T) { + payment, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 10000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "pay-overrefund-existing", + }) + require.NoError(t, err) + + // A COMPLETED refund moves money; the remaining balance drops to 7000. + first, err := client.RefundPayment(ctx, RefundPaymentReq{ + PaymentID: payment.ID, Amount: 3000, IdempotencyKey: "refund-first-move", + }) + require.NoError(t, err) + require.Equal(t, "COMPLETED", first.Status) + + result, err := client.RefundPayment(ctx, RefundPaymentReq{ + PaymentID: payment.ID, Amount: 8000, IdempotencyKey: "refund-overrefund-existing", + }) + require.Error(t, err) + assert.Nil(t, result) + // The REFUND_AMOUNT_INVALID squareAPIError is hidden behind the %v + // sentinel wrap (ErrorCode returns ""), so the observable contract is + // the ErrRefundAlreadyProcessed sentinel. + assert.True(t, errors.Is(err, ErrRefundAlreadyProcessed), "an over-refund on a payment that already has refunds must reconcile to ErrRefundAlreadyProcessed, got %v", err) + assert.False(t, errors.Is(err, ErrRefundDeclined)) + }) +} + +// TestDevClient_RefundPayment_LenientPathForNonMockIDs locks the lenient refund +// path: payment IDs that are NOT "pay_mock_*" (e.g. the DB-fixture +// square_payment_id values like "sqp_..." that sweep tests seed refunds +// against) exist outside the mock's ledger — exactly as they would at real +// Square — so RefundPayment processes them without the NOT_FOUND rejection. +func TestDevClient_RefundPayment_LenientPathForNonMockIDs(t *testing.T) { + client := NewDevClient().(*MockClient) + ctx := context.Background() + + result, err := client.RefundPayment(ctx, RefundPaymentReq{ + PaymentID: "sqp_fixture_123", + Amount: 5000, + IdempotencyKey: "refund-lenient-sqp", + }) + require.NoError(t, err, "a non-mock fixture payment ID must take the lenient path, not NOT_FOUND") + assert.Equal(t, "COMPLETED", result.Status) + assert.Equal(t, int64(5000), result.Amount) + assert.Equal(t, "sqp_fixture_123", result.PaymentID) +} + +// TestDevClient_CreatePayment_SimulateVerificationRequired locks the mock's SCA +// enforcement: with SimulateVerificationRequired=true, a new-card (cnon:) charge +// without a 3DS/SCA verification token is rejected with a structured 400 +// CARD_DECLINED_VERIFICATION_REQUIRED (a definitive payment error the buyer must +// resolve by re-verifying — never retried as-is); a present verification token +// (verify_mock_...) satisfies the gate; and with the toggle off (default) no +// verification is required. +func TestDevClient_CreatePayment_SimulateVerificationRequired(t *testing.T) { + client := NewDevClient().(*MockClient) + client.SimulateVerificationRequired = true + ctx := context.Background() + + t.Run("cnon_without_verification_token_is_rejected", func(t *testing.T) { + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-no-token", + }) + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err)) + assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err)) + assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) + assert.True(t, IsDefinitivePaymentError(err), "CARD_DECLINED_VERIFICATION_REQUIRED must classify as a definitive payment error") + }) + + t.Run("verification_token_satisfies_gate", func(t *testing.T) { + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-with-token", + VerificationToken: "verify_mock_ok", + }) + require.NoError(t, err) + assert.Equal(t, "COMPLETED", result.Status) + }) + + t.Run("toggle_off_requires_no_verification", func(t *testing.T) { + client.SimulateVerificationRequired = false + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: "cnon:new-card", IdempotencyKey: "verify-req-default-off", + }) + require.NoError(t, err) + assert.Equal(t, "COMPLETED", result.Status, "with SimulateVerificationRequired off (default), no verification token is required") + }) +} + +// TestDevClient_CreatePayment_SimulateSourceUsed_CnonConsumption locks the +// mock's single-use cnon: nonce simulation on CreatePayment: with +// SimulateSourceUsed enabled, a cnon used in one CreatePayment is rejected with +// CARD_TOKEN_USED on a second CreatePayment under a DIFFERENT idempotency key; +// ccof: (card-on-file) sources are NEVER consumed (they are stored references, +// not single-use nonces); and with the toggle off (default) the same cnon can +// be reused freely. +func TestDevClient_CreatePayment_SimulateSourceUsed_CnonConsumption(t *testing.T) { + client := NewDevClient().(*MockClient) + client.SimulateSourceUsed = true + ctx := context.Background() + + t.Run("cnon_reuse_rejected_with_card_token_used", func(t *testing.T) { + source := "cnon:single-use-pay" + first, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: source, IdempotencyKey: "consumed-key-1", + }) + require.NoError(t, err) + assert.Equal(t, "COMPLETED", first.Status) + + // Second CreatePayment with the SAME cnon under a DIFFERENT key → + // Square's CARD_TOKEN_USED rejection (the CreatePayment code for a used + // source). + result, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: source, IdempotencyKey: "consumed-key-2", + }) + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, "CARD_TOKEN_USED", ErrorCode(err)) + assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err)) + assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) + assert.Contains(t, client.UsedSources(), source, "the consumed cnon must be reported by UsedSources") + }) + + t.Run("ccof_sources_are_never_consumed", func(t *testing.T) { + // Create the card with the toggle off so the underlying cnon is not + // consumed; CreatePayment charges the ccof: CardID, which is a stored + // reference rather than a single-use nonce. + client.SimulateSourceUsed = false + card, err := client.CreateCardOnFile(ctx, "user-ccof-never-consumed", "cnon:card-src", "cus_test123") + require.NoError(t, err) + client.SimulateSourceUsed = true + + first, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: card.CardID, CustomerID: "cus_test123", IdempotencyKey: "ccof-charge-1", + }) + require.NoError(t, err) + assert.Equal(t, "COMPLETED", first.Status) + + second, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: card.CardID, CustomerID: "cus_test123", IdempotencyKey: "ccof-charge-2", + }) + require.NoError(t, err) + assert.Equal(t, "COMPLETED", second.Status, "a ccof: card must be chargeable again under a different key") + assert.NotContains(t, client.UsedSources(), card.CardID, "ccof: sources must never be consumed") + }) + + t.Run("toggle_off_allows_reuse", func(t *testing.T) { + client.SimulateSourceUsed = false + first, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: "cnon:reused-pay", IdempotencyKey: "reuse-key-1", + }) + require.NoError(t, err) + assert.Equal(t, "COMPLETED", first.Status) + + second, err := client.CreatePayment(ctx, CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: "cnon:reused-pay", IdempotencyKey: "reuse-key-2", + }) + require.NoError(t, err) + assert.Equal(t, "COMPLETED", second.Status, "with SimulateSourceUsed off (default), the same cnon must be reusable") + }) +} diff --git a/backend/internal/square/square_http_client.go b/backend/internal/square/square_http_client.go index 86a9f5c..59d76a0 100644 --- a/backend/internal/square/square_http_client.go +++ b/backend/internal/square/square_http_client.go @@ -135,7 +135,9 @@ func (c *httpClient) doJSON(ctx context.Context, method, path string, body, targ respBody = respBody[:maxResponseBody] } if resp.StatusCode >= 300 { - var errResp struct{ Errors []SquareError `json:"errors"` } + var errResp struct { + Errors []SquareError `json:"errors"` + } if json.Unmarshal(respBody, &errResp) == nil && len(errResp.Errors) > 0 { se := errResp.Errors[0] msg := fmt.Sprintf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, capBody(se.Detail), se.Field) @@ -212,6 +214,11 @@ type sqCreatePaymentRequest struct { TipMoney *sqMoney `json:"tip_money,omitempty"` VerificationToken string `json:"verification_token,omitempty"` BuyerEmailAddress string `json:"buyer_email_address,omitempty"` + // CustomerDetails carries customer_initiated so Square classifies the + // charge as cardholder-initiated (SCA applies) rather than defaulting to a + // merchant-initiated classification. Online card entry is always + // cardholder-initiated in this app, so the flag is sent as true when set. + CustomerDetails *CreateCustomerDetails `json:"customer_details,omitempty"` } type sqCreatePaymentResponse struct { @@ -498,6 +505,7 @@ func buildCreatePaymentBody(req CreatePaymentReq, hc *httpClient) sqCreatePaymen Note: req.Note, VerificationToken: req.VerificationToken, BuyerEmailAddress: req.BuyerEmail, + CustomerDetails: req.CustomerDetails, } if req.TipMoney != nil { body.TipMoney = &sqMoney{Amount: *req.TipMoney, Currency: req.Currency} @@ -614,6 +622,12 @@ func replayPaymentByKeyHTTP(ctx context.Context, snapshotJSON []byte) (*PaymentR // A replay body missing fields the original charge carried would return // IDEMPOTENCY_KEY_REUSED for a RETAINED key and strand the row pending forever, // so the snapshot is never reconstructed from partial row data. +// +// TODO (UNVERIFIED ASSUMPTION): this codebase assumes Square retains +// idempotency keys for ~24 hours (the stale-pending sweeps use a 23h/25h age +// guard on that window). Square's public docs no longer state the exact +// retention window — confirm the current value with Square support and update +// the sweep age guards and this comment when confirmed. func replayPaymentByKeyHTTPWithClient(ctx context.Context, snapshotJSON []byte, hc *httpClient) (*PaymentResult, error) { var req CreatePaymentReq if err := json.Unmarshal(snapshotJSON, &req); err != nil { @@ -687,7 +701,9 @@ func (e *squareAPIError) Unwrap() error { return e.err } // error it wraps) is a *squareAPIError — i.e. a structured error parsed from // Square's error response body. It returns "" for non-Square errors so callers // can classify charge failures structurally instead of substring-matching the -// message. +// message. This is the exported Code accessor for the error type (a direct +// `(*SquareError).Code()` method is impossible: SquareError already declares a +// field named Code, and Go forbids a method colliding with a struct field). func ErrorCode(err error) string { var sqErr *squareAPIError if errors.As(err, &sqErr) { @@ -719,7 +735,11 @@ func ErrorStatusCode(err error) int { } // ErrorCategory returns the Square error Category carried by err when err (or -// any error it wraps) is a *squareAPIError, and "" otherwise. +// any error it wraps) is a *squareAPIError, and "" otherwise. This is the +// exported Category accessor for the error type (a direct +// `(*SquareError).Category()` method is impossible: SquareError already +// declares a field named Category, and Go forbids a method colliding with a +// struct field). func ErrorCategory(err error) string { var sqErr *squareAPIError if errors.As(err, &sqErr) { @@ -754,17 +774,65 @@ func IsNotFound(err error) bool { return strings.Contains(err.Error(), "HTTP 404") } +// definitivePaymentCodes are Square CreatePayment error codes that mean the +// charge can NEVER succeed as-is. This includes the card decline/expiry codes +// and — critically for SCA — the buyer-verification codes +// (CARD_DECLINED_VERIFICATION_REQUIRED, VERIFICATION_TOKEN_EXPIRED, +// VERIFICATION_TOKEN_INVALID, CVV_VERIFICATION_REQUIRED, +// ADDRESS_VERIFICATION_REQUIRED, MISSING_PIN, MISSING_VERIFICATION_TOKEN): +// those mean the user must re-verify (3DS/SCA) or re-tokenize the card, NOT +// that the same request should be retried. A same-request retry with the same +// source/token can never succeed, so the failure is DEFINITIVE. This map is +// the package-level source of truth; handlers mirror it via +// IsDefinitivePaymentError / square.ErrorCode (the dev mock emits the same +// codes so dev parity holds). +var definitivePaymentCodes = map[string]bool{ + "CARD_DECLINED": true, + "CARD_EXPIRED": true, + "INVALID_EXPIRATION": true, + "INVALID_EXPIRATION_DATE": true, + "CARD_NOT_SUPPORTED": true, + "VERIFY_CVV_FAILURE": true, + "AVS_FAILURE": true, + "PAYMENT_CARD_DECLINED": true, + "GENERIC_DECLINE": true, + "INSUFFICIENT_FUNDS": true, + "ADDRESS_VERIFICATION_FAILURE": true, + "TRANSACTION_LIMIT": true, + // SCA / buyer-verification codes — the buyer must re-verify or the card be + // re-tokenized before the charge can succeed; retrying is pointless. + "CARD_DECLINED_VERIFICATION_REQUIRED": true, + "VERIFICATION_TOKEN_EXPIRED": true, + "VERIFICATION_TOKEN_INVALID": true, + "CVV_VERIFICATION_REQUIRED": true, + "ADDRESS_VERIFICATION_REQUIRED": true, + "MISSING_PIN": true, + "MISSING_VERIFICATION_TOKEN": true, +} + +// IsDefinitivePaymentError reports whether err is a definitive CreatePayment +// rejection (declined card, expired source, or an SCA/verification failure the +// buyer must resolve) rather than an ambiguous transport/server error. Handlers +// use this to avoid retrying a request that can never succeed as-is. +func IsDefinitivePaymentError(err error) bool { + return definitivePaymentCodes[ErrorCode(err)] +} + // Definitive Square refund rejection codes — the refund was declined and can // never succeed, so retrying is pointless and the refund record should be // marked 'failed'. Anything else (transport errors, 5xx) is left ambiguous so // callers leave the refund 'pending' for a scheduler retry. Codes match // Square's documented Refunds error list (REFUND_DECLINED, REFUND_AMOUNT_INVALID, -// PAYMENT_NOT_REFUNDABLE); note PAYMENT_ALREADY_REFUNDED and -// REFUND_ALREADY_PENDING are intentionally absent — money is in flight or has -// moved, so they map to ErrRefundAlreadyProcessed instead of ErrRefundDeclined. +// PAYMENT_NOT_REFUNDABLE). REFUND_AMOUNT_INVALID is special: Square returns it +// BOTH for a genuinely invalid refund amount AND for an already-refunded +// payment, so refundPaymentHTTP reconciles it via PaymentWasRefunded before +// classifying (an existing refund → ErrRefundAlreadyProcessed, otherwise +// ErrRefundDeclined). REFUND_ALREADY_PENDING maps to ErrRefundAlreadyProcessed +// (money in flight); PAYMENT_ALREADY_REFUNDED is no longer emitted by Square +// but is kept as a defensive fallback for the same outcome. var definitiveRefundCodes = map[string]bool{ - "REFUND_DECLINED": true, - "REFUND_AMOUNT_INVALID": true, + "REFUND_DECLINED": true, + "REFUND_AMOUNT_INVALID": true, "PAYMENT_NOT_REFUNDABLE": true, } @@ -782,17 +850,87 @@ func refundPaymentHTTPWithClient(ctx context.Context, req RefundPaymentReq, hc * var resp sqRefundPaymentResponse if err := hc.doJSON(ctx, http.MethodPost, "/v2/refunds", body, &resp); err != nil { var sqErr *squareAPIError - if errors.As(err, &sqErr) && definitiveRefundCodes[sqErr.Code] { - return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err) - } - if errors.As(err, &sqErr) && (sqErr.Code == "PAYMENT_ALREADY_REFUNDED" || sqErr.Code == "REFUND_ALREADY_PENDING") { - return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err) + if errors.As(err, &sqErr) { + switch sqErr.Code { + case "PAYMENT_ALREADY_REFUNDED", "REFUND_ALREADY_PENDING": + // Money is in flight or has already moved at Square — never + // mark 'failed' (that would let the guard over-refund). + return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err) + case "REFUND_AMOUNT_INVALID": + // Square returns REFUND_AMOUNT_INVALID both for a genuinely + // invalid refund amount AND for an already-refunded payment + // (Square no longer emits PAYMENT_ALREADY_REFUNDED). Reconcile + // against the refund list to tell the two apart: money already + // moved → ErrRefundAlreadyProcessed (resolve 'completed'); + // nothing moved → ErrRefundDeclined (mark 'failed', never + // retry). The reconciliation is amount-aware: only an EXACT- + // amount COMPLETED refund proves THIS requested amount already + // moved. A smaller partial refund does NOT cover the requested + // amount — resolving the row 'completed' against a partial + // refund would claim the full amount was returned when only + // part of it was, permanently blocking the remaining refund + // (the over-refund guard excludes completed rows). If the + // reconciliation itself fails, return the error unwrapped so + // the caller keeps the refund pending rather than making a + // money decision on partial data. + exactRefund, rErr := paymentRefundedExactlyWithClient(ctx, req.PaymentID, req.Amount, hc) + if rErr != nil { + return nil, rErr + } + if exactRefund { + return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err) + } + return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err) + } + if definitiveRefundCodes[sqErr.Code] { + return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err) + } } return nil, err } return refundFromSquare(&resp.Refund), nil } +// PaymentWasRefunded reports whether Square holds any refund for the payment +// (status COMPLETED, APPROVED, or PENDING). It is the reconciliation source for +// deciding whether a REFUND_AMOUNT_INVALID rejection means "already refunded" +// (money has already moved) vs "amount invalid" (nothing happened). +func PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) { + return paymentWasRefundedWithClient(ctx, paymentID, newHTTPClient()) +} + +func paymentWasRefundedWithClient(ctx context.Context, paymentID string, hc *httpClient) (bool, error) { + refunds, err := listRefundsHTTPWithClient(ctx, paymentID, time.Time{}, hc) + if err != nil { + return false, err + } + for _, r := range refunds { + switch r.Status { + case "COMPLETED", "APPROVED", "PENDING": + return true, nil + } + } + return false, nil +} + +// paymentRefundedExactlyWithClient reports whether Square holds a COMPLETED +// refund for the EXACT amount requested. Unlike paymentWasRefundedWithClient +// (any refund counts), an exact-match is required so a REFUND_AMOUNT_INVALID +// rejection can only resolve to "already refunded" when THIS requested amount +// provably moved — a partial refund does not cover it. +func paymentRefundedExactlyWithClient(ctx context.Context, paymentID string, amount int64, hc *httpClient) (bool, error) { + refunds, err := listRefundsHTTPWithClient(ctx, paymentID, time.Time{}, hc) + if err != nil { + return false, err + } + for _, r := range refunds { + if r.Status == "COMPLETED" && r.Amount == amount { + return true, nil + } + } + return false, nil +} + func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) { return listRefundsHTTPWithClient(ctx, paymentID, beginTime, newHTTPClient()) } diff --git a/backend/internal/square/square_http_client_test.go b/backend/internal/square/square_http_client_test.go index b0ec54c..909d011 100644 --- a/backend/internal/square/square_http_client_test.go +++ b/backend/internal/square/square_http_client_test.go @@ -518,15 +518,21 @@ func TestCreatePaymentHTTP_TipMoneyAbsentWhenNil(t *testing.T) { // PAYMENT_NOT_REFUNDABLE) map to ErrRefundDeclined, the money-in-flight codes // (PAYMENT_ALREADY_REFUNDED, REFUND_ALREADY_PENDING) map to // ErrRefundAlreadyProcessed, and ambiguous errors pass through unwrapped. +// REFUND_AMOUNT_INVALID is special: Square returns it BOTH for a genuinely +// invalid amount and for an already-refunded payment, so the client reconciles +// via PaymentWasRefunded (GET /v2/refunds) — no existing refund → declined, +// an existing COMPLETED/APPROVED/PENDING refund → already processed. func TestRefundPaymentHTTP_CodeClassification(t *testing.T) { cases := []struct { name string code string - wantErrIs error // nil = no sentinel expected + refundList string // GET /v2/refunds body served to the PaymentWasRefunded reconciliation + wantErrIs error // nil = no sentinel expected wantErrNil bool }{ {name: "refund_declined", code: "REFUND_DECLINED", wantErrIs: ErrRefundDeclined}, - {name: "amount_invalid", code: "REFUND_AMOUNT_INVALID", wantErrIs: ErrRefundDeclined}, + {name: "amount_invalid_no_refund_reconciles_to_declined", code: "REFUND_AMOUNT_INVALID", refundList: `{"refunds":[]}`, wantErrIs: ErrRefundDeclined}, + {name: "amount_invalid_existing_refund_reconciles_to_already_processed", code: "REFUND_AMOUNT_INVALID", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","location_id":"loc","reason":"cancellation","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundAlreadyProcessed}, {name: "payment_not_refundable", code: "PAYMENT_NOT_REFUNDABLE", wantErrIs: ErrRefundDeclined}, {name: "already_refunded", code: "PAYMENT_ALREADY_REFUNDED", wantErrIs: ErrRefundAlreadyProcessed}, {name: "already_pending", code: "REFUND_ALREADY_PENDING", wantErrIs: ErrRefundAlreadyProcessed}, @@ -537,6 +543,17 @@ func TestRefundPaymentHTTP_CodeClassification(t *testing.T) { t.Run(tc.name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + // The PaymentWasRefunded reconciliation call (GET /v2/refunds) + // must be answered with the configured refund list so the + // REFUND_AMOUNT_INVALID branch runs end to end. + body := tc.refundList + if body == "" { + body = `{"refunds":[]}` + } + _, _ = w.Write([]byte(body)) + return + } w.WriteHeader(http.StatusBadRequest) if tc.code == "" { _, _ = w.Write([]byte("plain text failure")) @@ -1783,3 +1800,232 @@ func TestDoJSON_CardProcessingNotEnabled403(t *testing.T) { t.Errorf("expected ErrorCategory PAYMENT_METHOD_ERROR, got %q", got) } } + +// TestCreatePaymentHTTP_CustomerDetailsWireShape verifies the customer_details +// wiring on POST /v2/payments: when CustomerDetails is set +// (CustomerInitiated=true — online card entry is always cardholder-initiated), +// the request body carries customer_details.customer_initiated=true; when nil, +// the field is omitted entirely (Square's default classification applies). +func TestCreatePaymentHTTP_CustomerDetailsWireShape(t *testing.T) { + t.Run("customer_initiated_true_is_sent", func(t *testing.T) { + var captured map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"payment":{"id":"pay_cd","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242"},"entry_method":"KEYED"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) + })) + defer srv.Close() + + hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} + _, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{ + Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-cd", + CustomerDetails: &CreateCustomerDetails{CustomerInitiated: true}, + }, hc) + if err != nil { + t.Fatalf("createPaymentHTTP failed: %v", err) + } + cd, ok := captured["customer_details"].(map[string]any) + if !ok { + t.Fatalf("expected customer_details object, got %v", captured["customer_details"]) + } + if cd["customer_initiated"] != true { + t.Errorf("expected customer_details.customer_initiated=true, got %v", cd["customer_initiated"]) + } + }) + + t.Run("nil_customer_details_is_omitted", func(t *testing.T) { + var captured map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"payment":{"id":"pay_cd2","status":"COMPLETED","total_money":{"amount":1000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"MASTERCARD","last_4":"4444"},"entry_method":"KEYED"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`)) + })) + defer srv.Close() + + hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} + _, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{ + Amount: 1000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-cd-nil", + }, hc) + if err != nil { + t.Fatalf("createPaymentHTTP failed: %v", err) + } + if _, present := captured["customer_details"]; present { + t.Errorf("expected customer_details to be ABSENT when CustomerDetails is nil, got %v", captured["customer_details"]) + } + }) +} + +// TestPaymentWasRefunded verifies the exported PaymentWasRefunded reconciliation +// helper: an existing COMPLETED/APPROVED/PENDING refund for the payment means +// money has moved (true), a FAILED/rejected refund or an empty list means +// nothing moved (false), and a server error propagates as an error. +func TestPaymentWasRefunded(t *testing.T) { + cases := []struct { + name string + refundList string + statusCode int + want bool + wantErr bool + }{ + {name: "completed_refund_means_money_moved", refundList: `{"refunds":[{"id":"ref_c","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true}, + {name: "approved_refund_means_money_moved", refundList: `{"refunds":[{"id":"ref_a","status":"APPROVED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true}, + {name: "pending_refund_means_money_in_flight", refundList: `{"refunds":[{"id":"ref_p","status":"PENDING","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: true}, + {name: "failed_refund_means_nothing_moved", refundList: `{"refunds":[{"id":"ref_f","status":"FAILED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: false}, + {name: "rejected_refund_means_nothing_moved", refundList: `{"refunds":[{"id":"ref_r","status":"REJECTED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_1","created_at":"2026-07-31T00:00:00Z"}]}`, want: false}, + {name: "empty_list_means_nothing_moved", refundList: `{"refunds":[]}`, want: false}, + {name: "other_payment_refund_is_filtered_out", refundList: `{"refunds":[{"id":"ref_o","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_other","created_at":"2026-07-31T00:00:00Z"}]}`, want: false}, + {name: "server_error_propagates", refundList: `{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"INTERNAL_SERVER_ERROR","detail":"boom"}]}`, statusCode: http.StatusInternalServerError, wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/v2/refunds" { + t.Errorf("expected /v2/refunds, got %s", r.URL.Path) + } + if !strings.Contains(r.URL.RawQuery, "begin_time=") { + t.Errorf("expected begin_time in query, got %q", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + if tc.statusCode != 0 { + w.WriteHeader(tc.statusCode) + } + _, _ = w.Write([]byte(tc.refundList)) + })) + defer srv.Close() + + hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} + got, err := paymentWasRefundedWithClient(context.Background(), "pay_1", hc) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got wasRefunded=%v", got) + } + return + } + if err != nil { + t.Fatalf("paymentWasRefunded failed: %v", err) + } + if got != tc.want { + t.Errorf("paymentWasRefunded = %v, want %v", got, tc.want) + } + }) + } +} + +// TestRefundPaymentHTTP_RefundAmountInvalidReconciliation locks the +// REFUND_AMOUNT_INVALID reconciliation end-to-end: Square returns that code BOTH +// for a genuinely invalid refund amount AND for an already-refunded payment, so +// refundPaymentHTTP re-checks the refund list (GET /v2/refunds) before +// classifying — an existing COMPLETED refund → ErrRefundAlreadyProcessed (money +// already moved), an empty list → ErrRefundDeclined (mark failed, never retry). +func TestRefundPaymentHTTP_RefundAmountInvalidReconciliation(t *testing.T) { + cases := []struct { + name string + refundList string + wantErrIs error + }{ + {name: "existing_exact_amount_completed_refund_reconciles_to_already_processed", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":5000,"currency":"GBP"},"payment_id":"pay_rec","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundAlreadyProcessed}, + {name: "partial_refund_does_not_cover_requested_amount_reconciles_to_declined", refundList: `{"refunds":[{"id":"ref_1","status":"COMPLETED","amount_money":{"amount":1000,"currency":"GBP"},"payment_id":"pay_rec","created_at":"2026-07-31T00:00:00Z"}]}`, wantErrIs: ErrRefundDeclined}, + {name: "no_refunds_reconciles_to_declined", refundList: `{"refunds":[]}`, wantErrIs: ErrRefundDeclined}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var reconcileGETs int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + // The PaymentWasRefunded reconciliation call must hit + // GET /v2/refunds for the payment. + reconcileGETs++ + if r.URL.Path != "/v2/refunds" { + t.Errorf("expected reconciliation GET /v2/refunds, got %s", r.URL.Path) + } + if !strings.Contains(r.URL.RawQuery, "begin_time=") { + t.Errorf("expected begin_time in reconciliation query, got %q", r.URL.RawQuery) + } + _, _ = w.Write([]byte(tc.refundList)) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"REFUND_AMOUNT_INVALID","detail":"The refunded amount is more than the remaining balance"}]}`)) + })) + defer srv.Close() + + hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} + _, err := refundPaymentHTTPWithClient(context.Background(), RefundPaymentReq{ + PaymentID: "pay_rec", Amount: 5000, IdempotencyKey: "ik-rec", + }, hc) + if err == nil { + t.Fatal("expected REFUND_AMOUNT_INVALID rejection error") + } + if reconcileGETs == 0 { + t.Error("expected the client to reconcile against GET /v2/refunds before classifying") + } + if !errors.Is(err, tc.wantErrIs) { + t.Errorf("expected errors.Is(%v), got %v", tc.wantErrIs, err) + } + }) + } +} + +// TestSCACodes_ClassifyAsDefinitivePaymentErrors locks the SCA / buyer-verification +// classification: the seven Square verification codes all mean the buyer must +// re-verify (3DS/SCA) or re-tokenize the card, NOT that the same request should +// be retried — so each must classify as a definitive payment error via +// IsDefinitivePaymentError and surface its code through ErrorCode. +func TestSCACodes_ClassifyAsDefinitivePaymentErrors(t *testing.T) { + scaCodes := []string{ + "CARD_DECLINED_VERIFICATION_REQUIRED", + "VERIFICATION_TOKEN_EXPIRED", + "VERIFICATION_TOKEN_INVALID", + "CVV_VERIFICATION_REQUIRED", + "ADDRESS_VERIFICATION_REQUIRED", + "MISSING_PIN", + "MISSING_VERIFICATION_TOKEN", + } + for _, code := range scaCodes { + t.Run(code, func(t *testing.T) { + err := &squareAPIError{ + Code: code, Category: "PAYMENT_METHOD_ERROR", StatusCode: http.StatusBadRequest, + err: errors.New("square: " + code), + } + if !IsDefinitivePaymentError(err) { + t.Errorf("IsDefinitivePaymentError(%s) must be true — SCA codes are definitive", code) + } + if got := ErrorCode(err); got != code { + t.Errorf("expected ErrorCode %s, got %q", code, got) + } + // Handlers wrap the client error before classifying — the accessor + // must see through the wrap. + if !IsDefinitivePaymentError(fmt.Errorf("wrap: %w", err)) { + t.Errorf("IsDefinitivePaymentError must work through a wrapped error for %s", code) + } + }) + } +} + +// TestInvalidRequestError_IsCategoryNotCode locks the category/code distinction: +// INVALID_REQUEST_ERROR is a Square error CATEGORY, never an error CODE — so +// ErrorCategory surfaces it while ErrorCode must NOT (ErrorCode returns "" for +// a code-less squareAPIError), and it must never classify as definitive. +func TestInvalidRequestError_IsCategoryNotCode(t *testing.T) { + err := &squareAPIError{ + Category: "INVALID_REQUEST_ERROR", StatusCode: http.StatusBadRequest, + err: errors.New("square: invalid request"), + } + if got := ErrorCategory(err); got != "INVALID_REQUEST_ERROR" { + t.Errorf("expected ErrorCategory INVALID_REQUEST_ERROR, got %q", got) + } + if got := ErrorCode(err); got != "" { + t.Errorf("expected ErrorCode \"\" for a category-only error, got %q", got) + } + if IsDefinitivePaymentError(err) { + t.Error("INVALID_REQUEST_ERROR is a category, never a definitive payment code") + } +} diff --git a/backend/internal/square/types.go b/backend/internal/square/types.go index e19661f..178a3c9 100644 --- a/backend/internal/square/types.go +++ b/backend/internal/square/types.go @@ -15,10 +15,16 @@ import ( // money has already moved, so it maps to ErrRefundAlreadyProcessed instead. var ErrRefundDeclined = errors.New("square: refund declined") -// ErrRefundAlreadyProcessed is returned by RefundPayment when Square reports -// PAYMENT_ALREADY_REFUNDED — the payment is already fully refunded at Square, -// so the money has already moved. Callers resolve the refund record to -// 'completed' rather than 'failed' (which would let the guard over-refund). +// ErrRefundAlreadyProcessed is returned by RefundPayment when the money has +// already moved at Square — either Square reports REFUND_ALREADY_PENDING (a +// refund for this payment is in flight) or the reconciliation performed on a +// REFUND_AMOUNT_INVALID rejection finds an existing COMPLETED/APPROVED/PENDING +// refund for the payment (PaymentWasRefunded). Note: Square no longer returns +// PAYMENT_ALREADY_REFUNDED for an already-refunded payment — it returns +// REFUND_AMOUNT_INVALID, which the client now reconciles via PaymentWasRefunded +// (the PAYMENT_ALREADY_REFUNDED mapping is kept only as a defensive fallback). +// Callers resolve the refund record to 'completed' rather than 'failed' (which +// would let the guard over-refund). var ErrRefundAlreadyProcessed = errors.New("square: refund already processed") // ErrReplayKeyNotRetained is returned by ReplayPaymentByKey when Square proves @@ -53,6 +59,27 @@ type CreatePaymentReq struct { LocationID string // Square location ID (optional; defaults to main location) VerificationToken string // 3DS / SCA verification token from buyer verification BuyerEmail string // buyer email for receipt + // CustomerDetails classifies the charge as cardholder-initiated (true) or + // merchant-initiated (false) for Square's SCA / liability-shift logic, + // wired through as customer_details.customer_initiated on POST /v2/payments. + // Online card entry in this app is always cardholder-initiated (the buyer + // is present, typing their card details), so the flag is true when set; + // nil omits the field from the wire body (Square's default classification). + CustomerDetails *CreateCustomerDetails +} + +// CreateCustomerDetails maps to Square's customer_details object on +// CreatePayment. customer_initiated tells Square whether the cardholder +// initiated the transaction (true — buyer present, e.g. online card entry) or +// the merchant initiated it on the cardholder's behalf (false — e.g. a +// subscription/recurring charge). Square uses it to classify the charge for +// SCA (Strong Customer Authentication) and card-scheme liability-shift rules: +// omitting it can silently change how the transaction is classified (a missing +// customer_initiated can be read as merchant-initiated, skipping the SCA that +// a cardholder-present charge must undergo). +// Reference: https://developer.squareup.com/reference/square/objects/Payment +type CreateCustomerDetails struct { + CustomerInitiated bool `json:"customer_initiated"` } // CreateCheckoutReq maps to Square's CreateTerminalCheckout endpoint @@ -189,7 +216,21 @@ type SquareClient interface { CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) + // PaymentWasRefunded reports whether Square holds any refund for the + // payment (status COMPLETED, APPROVED, or PENDING). It is the + // reconciliation source for deciding whether a REFUND_AMOUNT_INVALID + // rejection means "already refunded" (money has already moved) vs "amount + // invalid" (nothing happened). On the interface (not just the package + // function) so handlers can reconcile through the injected client — a + // package-level call constructs a real HTTP client even in dev/mock + // builds, making the dev path dead code and untestable. + PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) + // GetCardsOnFile returns the enabled cards on file for a user. TEST-ONLY: + // it has ZERO production callers (grep across the repo confirms the only + // users are this package's tests) and is kept on the interface solely so + // the dev mock's List Cards parity tests can exercise it. Do not add + // production callers without also re-examining the interface surface. GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) DeleteCardOnFile(ctx context.Context, cardID string) error diff --git a/backend/main.go b/backend/main.go index 61cb7bd..f21885c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -8,6 +8,7 @@ import ( "crussell/internal/logutil" "crussell/internal/s3" "crussell/internal/square" + "encoding/base64" "encoding/json" "fmt" "log" @@ -180,10 +181,23 @@ func initSquare() { // case-insensitive) or SQUARE_ENVIRONMENT explicitly selects the dev/mock // stack. Warn loudly when a non-dev env (empty/unknown — a likely // misconfiguration) leaves the gate disabled, so saved-card charges can - // never silently ship without the PSD2 SCA stand-in. + // never silently ship without this merchant-level authorization gate (an + // additional fraud control; NOT PSD2 SCA — Square buyer verification is the + // SCA mechanism, wired for new-card charges). enforced := payments.NewPaymentService().TwoFactorEnforced() if enforced { - log.Printf("WARNING: 2FA codes are delivered in PLAINTEXT via the server log ([2FA] prefix) — anyone with log read access can defeat the 2FA gate. Restrict backend log access and relay codes out-of-band; this loose-fake delivery must be replaced by email/SMS (P6) before launch.") + // Delivery is build-dependent (handlers/user/twofa_dev.go / + // twofa_prod.go): dev/test builds ALWAYS write the plaintext code to + // the [2FA] log line; production builds write it ONLY when the operator + // explicitly opts in with TWO_FACTOR_ALLOW_LOG_DELIVERY=true and refuse + // issuance otherwise. Warn accurately per case so the operator is never + // misled into thinking codes are reaching users when issuance is + // actually failing closed. + if os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" { + log.Printf("WARNING: 2FA codes are delivered in PLAINTEXT via the server log ([2FA] prefix) — anyone with backend log access can defeat the 2FA gate. Restrict log access and relay codes out-of-band; replace this loose-fake delivery with email/SMS (P6) before launch.") + } else { + log.Printf("WARNING: 2FA enforcement is ON but TWO_FACTOR_ALLOW_LOG_DELIVERY is unset: in a production build there is NO code-delivery channel (email/SMS is not wired — P6), so 2FA code issuance FAILS CLOSED and no user can complete setup or disable. Every enforced saved-card online payment for a user without 2FA will 403 with no way to enable it. Set TWO_FACTOR_ALLOW_LOG_DELIVERY=true to opt into the insecure [2FA] log-delivery channel (plaintext codes in the server log — restrict log access), or wire email/SMS (P6).") + } } if !enforced && !payments.IsExplicitDevOrMockEnv() { log.Printf("WARNING: 2FA enforcement is OFF (REQUIRE_2FA=%q) with SQUARE_ENVIRONMENT=%q (not an explicit mock/dev value). Online saved-card payments will NOT require 2FA.", os.Getenv("REQUIRE_2FA"), env) @@ -196,6 +210,59 @@ func initSquare() { if enforced && env != "sandbox" && env != "production" { log.Printf("WARNING: 2FA enforcement is ON but SQUARE_ENVIRONMENT=%q is empty/unknown — the Square client is the dev mock while the 2FA gate stays enforced (fail-closed). Online saved-card payments will 403 until users enable 2FA; set SQUARE_ENVIRONMENT to a dev value (mock/dev/development/test) to lift the gate, or to sandbox/production for the real API.", env) } + + checkSnapshotEncKey() + checkProxyRateLimitConfig() +} + +// checkSnapshotEncKey validates SNAPSHOT_ENC_KEY at startup in non-mock +// deployments. charge_helpers.snapshotEncKey() (handlers/payments) parses the +// key on every call and silently falls back to storing square_request_snapshot +// rows PLAINTEXT (buyer PII: email + ccof card tokens) with a one-time CRITICAL +// log. This startup check makes the misconfiguration unmissable at boot: the +// key must be present and decode to exactly 32 bytes (AES-256). Money-safety +// first — it warns CRITICAL but does NOT fail the process (a failing startup +// would strand pending replayable snapshots), matching the runtime fallback. +func checkSnapshotEncKey() { + if payments.IsExplicitDevOrMockEnv() { + return + } + raw := strings.TrimSpace(os.Getenv("SNAPSHOT_ENC_KEY")) + switch { + case raw == "": + log.Printf("CRITICAL: SNAPSHOT_ENC_KEY is not set with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows (buyer PII: email + ccof card tokens) will be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", os.Getenv("SQUARE_ENVIRONMENT")) + return + default: + decoded, err := base64.StdEncoding.DecodeString(raw) + switch { + case err != nil: + log.Printf("CRITICAL: SNAPSHOT_ENC_KEY is not valid base64 (%v) with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows will be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", err, os.Getenv("SQUARE_ENVIRONMENT")) + case len(decoded) != 32: + log.Printf("CRITICAL: SNAPSHOT_ENC_KEY must decode to exactly 32 bytes for AES-256 (got %d) with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows will be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", len(decoded), os.Getenv("SQUARE_ENVIRONMENT")) + } + } +} + +// checkProxyRateLimitConfig warns when per-IP rate limiting collapses to a +// single GLOBAL budget: TRUST_PROXY_HEADERS is unset/false (the shipped +// default — .env.example ships false, compose.yml never sets it) while +// SQUARE_ENVIRONMENT selects a real deployment (sandbox/production/empty). +// Behind a trusted proxy (the nginx in compose.yml), every request's +// RemoteAddr is the proxy's IP, so clientIP() returns the SAME key for all +// users and any one client can exhaust the shared per-IP budget — permanently +// 429ing the whole surface for everyone. The user+IP 2FA limiter is immune +// (each authenticated account gets its own bucket), but every other per-IP +// limiter still collapses. Set TRUST_PROXY_HEADERS=true when a trusted proxy +// (nginx and/or the Cloudflare edge) sits in front and overwrites X-Real-IP / +// CF-Connecting-IP with the real client IP. +func checkProxyRateLimitConfig() { + if payments.IsExplicitDevOrMockEnv() { + return + } + if mw.TrustProxyHeaders() { + return + } + log.Printf("WARNING: TRUST_PROXY_HEADERS is unset/false with SQUARE_ENVIRONMENT=%q (not a dev/mock value) — behind a trusted proxy (e.g. the nginx in compose.yml) every per-IP rate-limit key uses the proxy's RemoteAddr, collapsing all rate limiters to ONE global budget that any single client can exhaust for everyone. Set TRUST_PROXY_HEADERS=true when a trusted proxy sits in front and overwrites X-Real-IP/CF-Connecting-IP; keep it false only when the backend is origin-exposed.", os.Getenv("SQUARE_ENVIRONMENT")) } func healthCheckHandler(w http.ResponseWriter, r *http.Request) { @@ -496,14 +563,31 @@ func main() { r.Put("/user/change-password", user.ChangePasswordHandler) r.Get("/user/notification-preferences", user.GetNotificationPreferencesHandler) r.Put("/user/notification-preferences", user.UpdateNotificationPreferencesHandler) - // 2FA settings (loose-fake PSD2 SCA gate for online card payments). + // 2FA settings — merchant-level authorization gate on saved-card + // payments; NOT PSD2 SCA (Square buyer verification is the SCA + // mechanism, wired for new-card charges); kept as an additional + // fraud control until Square verification is wired for saved-card + // charges. // RequireNonGuest: any logged-in user who could save cards must be // able to reach these, not just verified accounts. r.With(mw.RequireNonGuest).Get("/user/2fa/status", user.GetTwoFAStatusHandler) - r.With(mw.RequireNonGuest).Post("/user/2fa/setup", user.SetupTwoFAHandler) - r.With(mw.RequireNonGuest).Post("/user/2fa/verify", user.VerifyTwoFAHandler) - r.With(mw.RequireNonGuest).Post("/user/2fa/disable", user.DisableTwoFAHandler) - r.With(mw.RequireNonGuest).Post("/user/2fa/disable/code", user.SendDisableCodeHandler) + // The code-issuing/verifying endpoints get a dedicated per-user+IP + // limiter (10/min) on top of the group's generic 120/min limiter: + // the 6-digit codes live in a 1M space, so a single user must not be + // able to hammer setup/verify/disable faster than the per-user + // 5-attempt lockout can trip. The key combines the authenticated + // userID with the client IP: even behind a proxy that does not set + // TRUST_PROXY_HEADERS=true (so every request's RemoteAddr is the + // proxy's IP), the budget stays per-account — one account holder can + // never exhaust a shared GLOBAL bucket that 429s the entire 2FA + // surface (setup/verify/disable, and thus saved-card payments) for + // everyone. One shared limiter for all four so the whole 2FA surface + // counts against a single per-user budget. + twoFALimiter := mw.RateLimitByUserAndIP(10, time.Minute) + r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/setup", user.SetupTwoFAHandler) + r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/verify", user.VerifyTwoFAHandler) + r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/disable", user.DisableTwoFAHandler) + r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/disable/code", user.SendDisableCodeHandler) r.Delete("/user/account", user.DeleteAccountHandler) r.Get("/user/gdpr-export", user.GetGDPRExportHandler) r.Get("/user/loyalty", user.GetLoyaltyHandler) diff --git a/backend/main_test.go b/backend/main_test.go index 9b45600..df19022 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -183,3 +183,82 @@ func TestCORSPreflight_RejectsUnlistedOrigin(t *testing.T) { t.Errorf("expected no Access-Control-Allow-Origin on preflight for unlisted origin, got %q", got) } } + +// ============================================================ +// isWeakJWTSecret — fail-closed JWT signing-key guard (batch-1 fix) +// ============================================================ + +// TestIsWeakJWTSecret_WeakSecrets verifies known placeholder/example values +// (publicly documented in .env.example, READMEs, or attack tooling) are always +// rejected even when they clear the 32-character minimum. +func TestIsWeakJWTSecret_WeakSecrets(t *testing.T) { + weak := []string{ + "password", + "secret", + "changeme", + "change-me", + "changethis", + "CHANGE_ME", + "your-secret-key", + "your-secret", + "jwt-secret", + "jwt-secret-key", + "default-secret", + "my-secret", + "test-secret", + "test-secret-key", + "super-secret", + "a-very-secret-key-that-should-be-in-env", // 39 chars — length passes, list blocks + "test-secret-key-for-testing-only", // 32 chars — length passes, list blocks + } + for _, s := range weak { + if !isWeakJWTSecret(s) { + t.Errorf("expected known weak secret %q to be rejected", s) + } + } +} + +// TestIsWeakJWTSecret_ShortSecrets verifies anything under 32 bytes is weak: +// HS256 keys must be at least 256 bits to be meaningful. +func TestIsWeakJWTSecret_ShortSecrets(t *testing.T) { + short := []string{ + "", + "a", + "short", + "0123456789", + "0123456789012345678901234567890", // 31 chars < 32 + } + for _, s := range short { + if !isWeakJWTSecret(s) { + t.Errorf("expected %q (%d chars) to be weak", s, len(s)) + } + } +} + +// TestIsWeakJWTSecret_StrongRandomSecret verifies a strong random 32+ byte +// secret (not a known placeholder) is accepted. +func TestIsWeakJWTSecret_StrongRandomSecret(t *testing.T) { + strong := "b8f0c2a1d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2" + if isWeakJWTSecret(strong) { + t.Errorf("expected a strong 50-char random secret to be accepted") + } + exactly32 := "9f2kL7mP4qR8sT1uV5wX3yZ6aB0cD2eG" // exactly 32 chars + if isWeakJWTSecret(exactly32) { + t.Errorf("expected an exactly-32-char random secret to be accepted") + } +} + +// TestIsWeakJWTSecret_CaseAndWhitespaceInsensitive verifies the check lowercases +// and trims before comparing, so padded/case-varied placeholders cannot slip +// through. +func TestIsWeakJWTSecret_CaseAndWhitespaceInsensitive(t *testing.T) { + for _, s := range []string{"PASSWORD", " SeCrEt ", "\tchangeme\n", " super-secret "} { + if !isWeakJWTSecret(s) { + t.Errorf("expected %q to be weak after case/whitespace normalisation", s) + } + } + paddedStrong := " xY9Q2mR7vB4nK8wP1tL5sD3fG6hJ0cVnW4 " + if isWeakJWTSecret(paddedStrong) { + t.Errorf("expected a whitespace-padded strong secret to be accepted") + } +} diff --git a/backend/mw/ratelimit.go b/backend/mw/ratelimit.go index bd01ceb..ca0ec40 100644 --- a/backend/mw/ratelimit.go +++ b/backend/mw/ratelimit.go @@ -136,6 +136,35 @@ func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler } } +// RateLimitByUserAndIP limits requests per authenticated user + client IP. The +// key combines the authenticated userID (mw.UserIDKey, injected by RequireAuth) +// with the derived client IP, so a per-IP budget can never collapse into a +// single GLOBAL bucket when the backend sits behind a proxy that does not set +// TRUST_PROXY_HEADERS=true: without that flag clientIP() keys every request on +// RemoteAddr = the proxy's IP, so one account holder could otherwise exhaust +// the shared budget and permanently 429 the whole surface for everyone. With +// the userID in the key each account gets its own independent budget per IP. +// When no userID is present (unauthenticated path) the key falls back to +// clientIP alone, matching RateLimit's behaviour. +func RateLimitByUserAndIP(limit int, window time.Duration) func(http.Handler) http.Handler { + limiter := NewRateLimiter(limit, window) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := clientIP(r) + if userID, ok := GetUserID(r.Context()); ok && userID != "" { + key = userID + "|" + key + } + + if !limiter.Allow(key) { + RespondJSON(w, http.StatusTooManyRequests, map[string]string{"error": "Rate limit exceeded"}) + return + } + + next.ServeHTTP(w, r) + }) + } +} + // clientIP derives the per-client rate-limit key. Priority: // 1. CF-Connecting-IP header — honored ONLY when TRUST_PROXY_HEADERS=true // (see trustProxyHeaders). A trusted edge (Cloudflare, or nginx whose diff --git a/backend/mw/ratelimit_dev.go b/backend/mw/ratelimit_dev.go index d5fa080..147f832 100644 --- a/backend/mw/ratelimit_dev.go +++ b/backend/mw/ratelimit_dev.go @@ -34,3 +34,15 @@ func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler }) } } + +// RateLimitByUserAndIP is the dev-build no-op twin of the production +// user+IP-keyed limiter in ratelimit.go: pure dev builds pass everything +// through so the dev seed's bursty traffic is never throttled (tests build +// with the `test` tag and get the real implementation). +func RateLimitByUserAndIP(limit int, window time.Duration) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + }) + } +} diff --git a/backend/mw/ratelimit_dev_test.go b/backend/mw/ratelimit_dev_test.go new file mode 100644 index 0000000..9669047 --- /dev/null +++ b/backend/mw/ratelimit_dev_test.go @@ -0,0 +1,48 @@ +//go:build dev && !test + +package mw + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// The dev build (ratelimit_dev.go) swaps the real rate limiters for no-op +// passthroughs so the dev seed (11+ logins, bursty seeding traffic) never +// trips a limiter. These tests pin that contract for a pure dev build +// (-tags dev, no test tag): every request passes through untouched. + +func TestDevBuild_RateLimit_PassesEverythingThrough(t *testing.T) { + handler := RateLimit(2, time.Minute)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + for i := 0; i < 25; i++ { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.1:1234" + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("dev no-op RateLimit blocked request %d with %d", i+1, w.Code) + } + } +} + +func TestDevBuild_ProgressiveRateLimit_PassesEverythingThrough(t *testing.T) { + handler := ProgressiveRateLimit(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + for i := 0; i < 100; i++ { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.1:1234" + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("dev no-op ProgressiveRateLimit blocked request %d with %d", i+1, w.Code) + } + if got := w.Header().Get("X-RateLimit-Delay"); got != "" { + t.Fatalf("dev no-op ProgressiveRateLimit set X-RateLimit-Delay on request %d: %q", i+1, got) + } + } +} diff --git a/backend/mw/ratelimit_shared_test.go b/backend/mw/ratelimit_shared_test.go new file mode 100644 index 0000000..e4fabc6 --- /dev/null +++ b/backend/mw/ratelimit_shared_test.go @@ -0,0 +1,126 @@ +//go:build test + +package mw + +import ( + "testing" + "time" + + "crussell/clock" +) + +// ============================================================ +// TrustProxyHeaders — exported accessor for the init-time +// TRUST_PROXY_HEADERS capture (batch-1 fix) +// ============================================================ + +// TestTrustProxyHeaders_ReflectsPackageVar pins the accessor contract: it +// reports the current value of the package-level trustProxyHeaders flag. The +// flag is captured once at init from TRUST_PROXY_HEADERS (ratelimit_shared.go) +// and cannot be re-read after process start, so the true/false paths are +// exercised by toggling the var directly (the test lives in package mw). +func TestTrustProxyHeaders_ReflectsPackageVar(t *testing.T) { + saved := trustProxyHeaders + t.Cleanup(func() { trustProxyHeaders = saved }) + + trustProxyHeaders = false + if TrustProxyHeaders() { + t.Error("expected TrustProxyHeaders() == false when trustProxyHeaders is false") + } + + trustProxyHeaders = true + if !TrustProxyHeaders() { + t.Error("expected TrustProxyHeaders() == true when trustProxyHeaders is true") + } +} + +// TestTrustProxyHeaders_DefaultIsFalse verifies the documented default: when +// the flag has never been flipped to true (the init-time no-TRUST_PROXY_HEADERS +// path resolves to false), the accessor reports false — an origin-exposed +// backend must not trust proxy headers by default. +func TestTrustProxyHeaders_DefaultIsFalse(t *testing.T) { + saved := trustProxyHeaders + t.Cleanup(func() { trustProxyHeaders = saved }) + + trustProxyHeaders = false + if TrustProxyHeaders() { + t.Error("expected TrustProxyHeaders() to default to false") + } +} + +// ============================================================ +// RateLimiter.Allow — fixed-window per-key semantics (batch-1 +// fix regression: per-key bucketing) +// ============================================================ + +// TestRateLimiter_Allow_RespectsLimit verifies exactly `limit` allowances per +// key within the window, then refusals. +func TestRateLimiter_Allow_RespectsLimit(t *testing.T) { + rl := NewRateLimiter(2, time.Minute) + + if !rl.Allow("key-a") { + t.Fatal("expected first request to be allowed") + } + if !rl.Allow("key-a") { + t.Fatal("expected second request to be allowed") + } + if rl.Allow("key-a") { + t.Error("expected third request to be refused once the limit is hit") + } +} + +// TestRateLimiter_Allow_PerKeyBuckets verifies keys are bucketed independently: +// exhausting one key must not consume another key's allowance. +func TestRateLimiter_Allow_PerKeyBuckets(t *testing.T) { + rl := NewRateLimiter(2, time.Minute) + + for i := 0; i < 2; i++ { + if !rl.Allow("busy-key") { + t.Fatalf("request %d: expected busy-key to be allowed", i+1) + } + } + if rl.Allow("busy-key") { + t.Error("expected busy-key to be exhausted after its limit") + } + + if !rl.Allow("other-key") { + t.Error("expected other-key to keep its own allowance") + } +} + +// TestRateLimiter_Allow_WindowExpiryPrunesStale verifies timestamps older than +// the window no longer count: an Allow after the window is free again. +func TestRateLimiter_Allow_WindowExpiryPrunesStale(t *testing.T) { + rl := NewRateLimiter(1, time.Minute) + + // Fill the bucket with a timestamp from before the window started. + rl.mu.Lock() + rl.requests["key-stale"] = []time.Time{clock.Now().Add(-2 * time.Minute)} + rl.mu.Unlock() + + if !rl.Allow("key-stale") { + t.Error("expected an expired entry to be pruned and the request allowed") + } + rl.mu.RLock() + got := len(rl.requests["key-stale"]) + rl.mu.RUnlock() + if got != 1 { + t.Errorf("expected the stale entry to be replaced by the fresh one, got %d entries", got) + } +} + +// TestRateLimiter_Allow_BoundaryExactLimit verifies the limit is an exclusive +// boundary: the request that makes the count EQUAL the limit is the last one +// allowed. +func TestRateLimiter_Allow_BoundaryExactLimit(t *testing.T) { + rl := NewRateLimiter(3, time.Minute) + + for i := 0; i < 3; i++ { + if !rl.Allow("boundary-key") { + t.Fatalf("request %d: expected allowed (count == limit is allowed)", i+1) + } + } + if rl.Allow("boundary-key") { + t.Error("expected the count-over-limit request to be refused") + } +} diff --git a/backend/mw/ratelimit_test.go b/backend/mw/ratelimit_test.go index 77cae5b..27b072c 100644 --- a/backend/mw/ratelimit_test.go +++ b/backend/mw/ratelimit_test.go @@ -2,10 +2,15 @@ package mw import ( "context" + "net/http" + "net/http/httptest" + "strings" "testing" "time" "crussell/clock" + + "github.com/go-chi/chi/v5/middleware" ) // TestProgressiveRateLimiter_CleanupRemovesStaleEntries verifies that @@ -162,3 +167,419 @@ func TestCleanupProgressiveRateLimiter(t *testing.T) { t.Error("expected global-stale to be removed") } } + +// ============================================================ +// clientIP — per-IP rate-limit key derivation (batch-1 fix) +// ============================================================ + +// setTrustProxyHeaders temporarily overrides the package-level +// trustProxyHeaders flag so clientIP()'s priority order can be exercised in +// both states. The flag is an init-time capture of TRUST_PROXY_HEADERS (see +// ratelimit_shared.go) that cannot be re-read after process start; the tests +// live in package mw, so the unexported var is reachable directly. +func setTrustProxyHeaders(t *testing.T, v bool) { + t.Helper() + saved := trustProxyHeaders + trustProxyHeaders = v + t.Cleanup(func() { trustProxyHeaders = saved }) +} + +// TestClientIP_CFConnectingIP_HonoredWhenTrusted verifies that with +// TRUST_PROXY_HEADERS=true a CF-Connecting-IP header becomes the rate-limit +// key (a trusted edge has already overwritten it with the real client IP). +func TestClientIP_CFConnectingIP_HonoredWhenTrusted(t *testing.T) { + setTrustProxyHeaders(t, true) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.1:1234" + req.Header.Set("CF-Connecting-IP", "203.0.113.7") + + if got := clientIP(req); got != "203.0.113.7" { + t.Errorf("expected trusted CF-Connecting-IP to win, got %q", got) + } +} + +// TestClientIP_CFConnectingIP_IgnoredWhenUntrusted verifies the default +// TRUST_PROXY_HEADERS=false behaviour: a client-supplied CF-Connecting-IP is +// ignored so an origin-exposed backend can never let a client forge its own +// rate-limit key. +func TestClientIP_CFConnectingIP_IgnoredWhenUntrusted(t *testing.T) { + setTrustProxyHeaders(t, false) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.1:1234" + req.Header.Set("CF-Connecting-IP", "203.0.113.7") + + if got := clientIP(req); got != "192.0.2.1" { + t.Errorf("expected untrusted CF-Connecting-IP to be ignored, got %q", got) + } +} + +// TestClientIP_XRealIPContext_WhenMiddlewareRegistered verifies the chi +// ClientIPFromHeader("X-Real-IP") context path: the X-Real-IP value nginx sets +// from $remote_addr is used as the key once the middleware has captured it. +func TestClientIP_XRealIPContext_WhenMiddlewareRegistered(t *testing.T) { + setTrustProxyHeaders(t, true) + var got string + h := middleware.ClientIPFromHeader("X-Real-IP")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = clientIP(r) + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.1:1234" + req.Header.Set("X-Real-IP", "198.51.100.42") + h.ServeHTTP(httptest.NewRecorder(), req) + + if got != "198.51.100.42" { + t.Errorf("expected X-Real-IP from context, got %q", got) + } +} + +// TestClientIP_CFWinsOverContext verifies the priority order when both a +// trusted CF-Connecting-IP header and a context client IP are present: the +// CF header is priority 1, the context (X-Real-IP) value priority 2. +func TestClientIP_CFWinsOverContext(t *testing.T) { + setTrustProxyHeaders(t, true) + var got string + h := middleware.ClientIPFromHeader("X-Real-IP")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = clientIP(r) + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.1:1234" + req.Header.Set("CF-Connecting-IP", "203.0.113.9") + req.Header.Set("X-Real-IP", "198.51.100.42") + h.ServeHTTP(httptest.NewRecorder(), req) + + if got != "203.0.113.9" { + t.Errorf("expected CF-Connecting-IP to beat the context value, got %q", got) + } +} + +// TestClientIP_ContextBeatsRemoteAddrEvenWhenCFUntrusted verifies the context +// value (set by the middleware) is read unconditionally — clientIP does not +// re-check trustProxyHeaders for it — so a spoofed CF header with no trusted +// edge cannot override a middleware-captured value. +func TestClientIP_ContextBeatsRemoteAddrEvenWhenCFUntrusted(t *testing.T) { + setTrustProxyHeaders(t, false) + var got string + h := middleware.ClientIPFromHeader("X-Real-IP")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = clientIP(r) + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.1:1234" + req.Header.Set("CF-Connecting-IP", "203.0.113.7") + req.Header.Set("X-Real-IP", "198.51.100.42") + h.ServeHTTP(httptest.NewRecorder(), req) + + if got != "198.51.100.42" { + t.Errorf("expected context client IP to beat RemoteAddr, got %q", got) + } +} + +// TestClientIP_FallbackToRemoteAddr verifies the last-resort key source: the +// TCP peer from r.RemoteAddr once the port is split off. +func TestClientIP_FallbackToRemoteAddr(t *testing.T) { + setTrustProxyHeaders(t, false) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.1:1234" + + if got := clientIP(req); got != "192.0.2.1" { + t.Errorf("expected RemoteAddr host as fallback, got %q", got) + } +} + +// TestClientIP_RemoteAddrWithoutPort_ReturnedAsIs verifies that a RemoteAddr +// lacking a port (no SplitHostPort success) is returned verbatim rather than +// being dropped. +func TestClientIP_RemoteAddrWithoutPort_ReturnedAsIs(t *testing.T) { + setTrustProxyHeaders(t, false) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.5" + + if got := clientIP(req); got != "192.0.2.5" { + t.Errorf("expected portless RemoteAddr to pass through, got %q", got) + } +} + +// ============================================================ +// RateLimit middleware — 429 on burst, pass-through under limit, +// per-key buckets (batch-1 fix regression) +// ============================================================ + +// newRateLimitTestHandler builds a RateLimit-wrapped handler that records how +// many times the inner handler was reached. +func newRateLimitTestHandler(limit int, window time.Duration) (http.Handler, *int) { + calls := 0 + handler := RateLimit(limit, window)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusOK) + })) + return handler, &calls +} + +func serveRateLimitRequest(t *testing.T, h http.Handler, remoteAddr string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = remoteAddr + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w +} + +// TestRateLimitMiddleware_RequestsUnderLimitPassThrough verifies requests +// within the per-IP limit reach the handler untouched. +func TestRateLimitMiddleware_RequestsUnderLimitPassThrough(t *testing.T) { + handler, calls := newRateLimitTestHandler(2, time.Minute) + + for i := 0; i < 2; i++ { + w := serveRateLimitRequest(t, handler, "192.0.2.10:1234") + if w.Code != http.StatusOK { + t.Fatalf("request %d: expected 200, got %d (body: %s)", i+1, w.Code, w.Body.String()) + } + } + if *calls != 2 { + t.Errorf("expected 2 handler calls, got %d", *calls) + } +} + +// TestRateLimitMiddleware_BurstAboveLimitReturns429 verifies the request that +// crosses the limit is answered 429 and the inner handler is never reached. +func TestRateLimitMiddleware_BurstAboveLimitReturns429(t *testing.T) { + handler, calls := newRateLimitTestHandler(2, time.Minute) + + for i := 0; i < 2; i++ { + if w := serveRateLimitRequest(t, handler, "192.0.2.11:1234"); w.Code != http.StatusOK { + t.Fatalf("request %d: expected 200, got %d", i+1, w.Code) + } + } + + w := serveRateLimitRequest(t, handler, "192.0.2.11:1234") + if w.Code != http.StatusTooManyRequests { + t.Errorf("expected 429 on the request above the limit, got %d", w.Code) + } + if body := w.Body.String(); !strings.Contains(body, "Rate limit exceeded") { + t.Errorf("expected a rate-limit error body, got %q", body) + } + if *calls != 2 { + t.Errorf("expected inner handler to be called exactly twice, got %d", *calls) + } +} + +// TestRateLimitMiddleware_PerKeyBuckets verifies different IPs get independent +// buckets: exhausting one IP must not exhaust another. +func TestRateLimitMiddleware_PerKeyBuckets(t *testing.T) { + handler, _ := newRateLimitTestHandler(2, time.Minute) + + // Exhaust IP A. + for i := 0; i < 2; i++ { + if w := serveRateLimitRequest(t, handler, "192.0.2.20:1234"); w.Code != http.StatusOK { + t.Fatalf("A request %d: expected 200, got %d", i+1, w.Code) + } + } + if w := serveRateLimitRequest(t, handler, "192.0.2.20:1234"); w.Code != http.StatusTooManyRequests { + t.Errorf("expected IP A to be rate-limited after its burst, got %d", w.Code) + } + + // IP B is a separate bucket and still has its full allowance. + for i := 0; i < 2; i++ { + if w := serveRateLimitRequest(t, handler, "192.0.2.21:1234"); w.Code != http.StatusOK { + t.Fatalf("B request %d: expected 200 (independent bucket), got %d", i+1, w.Code) + } + } + if w := serveRateLimitRequest(t, handler, "192.0.2.21:1234"); w.Code != http.StatusTooManyRequests { + t.Errorf("expected IP B to be rate-limited only after ITS OWN burst, got %d", w.Code) + } +} + +// ============================================================ +// RateLimitByUserAndIP — user+IP-keyed middleware (A7 fix: +// per-IP limiter collapsing to a global budget behind a proxy) +// ============================================================ + +// newRateLimitByUserAndIPTestHandler builds a RateLimitByUserAndIP-wrapped +// handler that records how many times the inner handler was reached. +func newRateLimitByUserAndIPTestHandler(limit int, window time.Duration) (http.Handler, *int) { + calls := 0 + handler := RateLimitByUserAndIP(limit, window)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusOK) + })) + return handler, &calls +} + +// serveRateLimitUserRequest serves a request with an optional authenticated +// userID in context (the equivalent of RequireAuth having run) through h. +func serveRateLimitUserRequest(t *testing.T, h http.Handler, userID, remoteAddr string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = remoteAddr + if userID != "" { + req = req.WithContext(context.WithValue(req.Context(), UserIDKey, userID)) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w +} + +// TestRateLimitByUserAndIP_PerUserBucketsBehindSameIP verifies the keyed +// limiter's core guarantee: two different users behind the SAME proxy IP (the +// TRUST_PROXY_HEADERS=false nginx scenario, where clientIP returns the proxy's +// address for everyone) get INDEPENDENT budgets — one user exhausting their +// 10/min allowance can never 429 the other user's 2FA setup/verify/disable. +func TestRateLimitByUserAndIP_PerUserBucketsBehindSameIP(t *testing.T) { + handler, calls := newRateLimitByUserAndIPTestHandler(2, time.Minute) + + // User A exhausts its own budget from the shared proxy IP. + for i := 0; i < 2; i++ { + if w := serveRateLimitUserRequest(t, handler, "user-a", "10.0.0.5:1234"); w.Code != http.StatusOK { + t.Fatalf("A request %d: expected 200, got %d", i+1, w.Code) + } + } + if w := serveRateLimitUserRequest(t, handler, "user-a", "10.0.0.5:1234"); w.Code != http.StatusTooManyRequests { + t.Errorf("expected user A to be rate-limited after its own burst, got %d", w.Code) + } + + // User B shares the proxy IP but must keep its own full allowance. + for i := 0; i < 2; i++ { + if w := serveRateLimitUserRequest(t, handler, "user-b", "10.0.0.5:1234"); w.Code != http.StatusOK { + t.Fatalf("B request %d: expected 200 (independent user bucket), got %d", i+1, w.Code) + } + } + if w := serveRateLimitUserRequest(t, handler, "user-b", "10.0.0.5:1234"); w.Code != http.StatusTooManyRequests { + t.Errorf("expected user B to be limited only after ITS OWN burst, got %d", w.Code) + } + if *calls != 4 { + t.Errorf("expected exactly 4 handler calls (2 per user), got %d", *calls) + } +} + +// TestRateLimitByUserAndIP_SameUserSharesOneBudget verifies the same user's +// requests across all four 2FA routes count against one shared budget (the +// "one shared limiter for all four routes" contract). +func TestRateLimitByUserAndIP_SameUserSharesOneBudget(t *testing.T) { + handler, _ := newRateLimitByUserAndIPTestHandler(3, time.Minute) + + for i := 0; i < 3; i++ { + if w := serveRateLimitUserRequest(t, handler, "user-c", "198.51.100.15:1234"); w.Code != http.StatusOK { + t.Fatalf("request %d: expected 200, got %d", i+1, w.Code) + } + } + if w := serveRateLimitUserRequest(t, handler, "user-c", "198.51.100.15:1234"); w.Code != http.StatusTooManyRequests { + t.Errorf("expected the 4th request from the same user to be limited, got %d", w.Code) + } +} + +// TestRateLimitByUserAndIP_UnauthenticatedFallsBackToIP verifies the fallback: +// without a userID in context the key is the client IP alone (same behaviour +// as RateLimit), so the middleware stays safe on unauthenticated paths. +func TestRateLimitByUserAndIP_UnauthenticatedFallsBackToIP(t *testing.T) { + handler, _ := newRateLimitByUserAndIPTestHandler(1, time.Minute) + + if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.20:1234"); w.Code != http.StatusOK { + t.Fatalf("expected 200 for the first request, got %d", w.Code) + } + if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.20:1234"); w.Code != http.StatusTooManyRequests { + t.Errorf("expected a second unauthenticated request from the same IP to be limited, got %d", w.Code) + } + if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.21:1234"); w.Code != http.StatusOK { + t.Errorf("expected a different IP to keep its own bucket, got %d", w.Code) + } +} + +// ============================================================ +// ProgressiveRateLimiter.Check — dual-window progressive delay +// algorithm (batch-1 fix regression) +// ============================================================ + +// seedProgressiveTimestamps arms prl.requests[ip] so the NEXT Check() call +// observes exactly `burst` timestamps inside the 5s window and `sustained` +// inside the 60s window. Check() appends its own timestamp first, so one fewer +// is seeded: `burst-1` recent timestamps (now-1s, inside the burst window) and +// `sustained-burst` older-but-in-window timestamps (now-10s, outside the 5s +// burst window but inside the 60s sustained window). +func seedProgressiveTimestamps(t *testing.T, prl *ProgressiveRateLimiter, ip string, burst, sustained int) { + t.Helper() + if sustained < burst { + t.Fatalf("sustained (%d) must be >= burst (%d)", sustained, burst) + } + now := clock.Now() + ts := make([]time.Time, 0, sustained-1) + for i := 0; i < burst-1; i++ { + ts = append(ts, now.Add(-time.Second)) + } + for i := 0; i < sustained-burst; i++ { + ts = append(ts, now.Add(-10*time.Second)) + } + prl.mu.Lock() + prl.requests[ip] = &ipProgressiveState{timestamps: ts} + prl.mu.Unlock() +} + +// TestProgressiveRateLimiter_CheckDelayTiers pins the exact algorithm: no +// delay while burst <= 30 AND sustained <= 120; then delay escalates with the +// sustained rate (500ms / 2s / 5s / 10s tiers). +func TestProgressiveRateLimiter_CheckDelayTiers(t *testing.T) { + cases := []struct { + name string + burst int + sustained int + wantMs int + }{ + {"under both thresholds is free", 30, 120, 0}, + {"burst alone trips the lowest tier", 31, 120, 500}, + {"sustained alone trips the lowest tier", 30, 121, 500}, + {"500ms tier ceiling", 31, 140, 500}, + {"2s tier floor", 31, 141, 2000}, + {"2s tier ceiling", 31, 200, 2000}, + {"5s tier floor", 31, 201, 5000}, + {"5s tier ceiling", 31, 300, 5000}, + {"10s abuse tier", 31, 301, 10000}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + prl := NewProgressiveRateLimiter() + ip := "198.51.100.7" + seedProgressiveTimestamps(t, prl, ip, tc.burst, tc.sustained) + if got := prl.Check(ip); got != tc.wantMs { + t.Errorf("burst=%d sustained=%d: expected %dms delay, got %dms", tc.burst, tc.sustained, tc.wantMs, got) + } + }) + } +} + +// TestProgressiveRateLimiter_DelayEscalatesWithSustainedRate verifies the +// delay increases as a sustained high rate climbs through the tiers on +// repeated hits. +func TestProgressiveRateLimiter_DelayEscalatesWithSustainedRate(t *testing.T) { + prl := NewProgressiveRateLimiter() + ip := "203.0.113.42" + + // Start already over the burst limit so every check is throttled. + seedProgressiveTimestamps(t, prl, ip, 31, 31) + + steps := []struct { + extraSustained int + wantMs int + }{ + {0, 500}, // observed sustained=31 → 500ms tier + {109, 2000}, // observed sustained=141 → 2s tier + {60, 5000}, // observed sustained=202 → 5s tier + {100, 10000}, // observed sustained=303 → 10s abuse tier + } + for i, s := range steps { + if s.extraSustained > 0 { + prl.mu.Lock() + state := prl.requests[ip] + for j := 0; j < s.extraSustained; j++ { + state.timestamps = append(state.timestamps, clock.Now().Add(-6*time.Second)) + } + prl.mu.Unlock() + } + if got := prl.Check(ip); got != s.wantMs { + t.Errorf("step %d: expected %dms delay, got %dms", i, s.wantMs, got) + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 64ec623..9296118 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,6 +29,7 @@ "@tailwindcss/vite": "^4.2.0", "@types/geojson": "^7946.0.16", "@types/node": "^26.1.1", + "@vitest/coverage-v8": "^4.1.10", "bits-ui": "^2.16.1", "clsx": "^2.1.1", "eslint": "^9.39.5", @@ -50,7 +51,68 @@ "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", "typescript-eslint": "^8.20.0", - "vite": "^8.1.4" + "vite": "^8.1.4", + "vitest": "^4.1.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" } }, "node_modules/@discourse/jxl": { @@ -1280,6 +1342,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -1287,6 +1360,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1606,6 +1686,150 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@zxcvbn-ts/core": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@zxcvbn-ts/core/-/core-4.1.2.tgz", @@ -1712,6 +1936,28 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1800,6 +2046,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1880,6 +2136,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -2000,6 +2263,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2245,6 +2515,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2255,6 +2535,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2424,6 +2714,13 @@ "integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==", "license": "MIT" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2508,6 +2805,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2518,6 +2854,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", @@ -2936,6 +3279,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/maplibre-gl": { "version": "5.24.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", @@ -3222,6 +3593,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pbf": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", @@ -3680,6 +4058,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -3705,6 +4090,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3970,6 +4369,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3993,6 +4409,16 @@ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -4206,6 +4632,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/wasm-feature-detect": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", @@ -4228,6 +4744,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index cee962b..2188956 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "vitest run", "format": "prettier --write .", "lint": "prettier --check . && eslint .", "lint:a11y": "eslint 'src/**/*.svelte' --config eslint.a11y.config.js" @@ -28,6 +29,7 @@ "@tailwindcss/vite": "^4.2.0", "@types/geojson": "^7946.0.16", "@types/node": "^26.1.1", + "@vitest/coverage-v8": "^4.1.10", "bits-ui": "^2.16.1", "clsx": "^2.1.1", "eslint": "^9.39.5", @@ -49,7 +51,8 @@ "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", "typescript-eslint": "^8.20.0", - "vite": "^8.1.4" + "vite": "^8.1.4", + "vitest": "^4.1.10" }, "dependencies": { "@discourse/jxl": "^1.3.0", diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 51b1698..89c15d1 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -8,15 +8,13 @@ import { extractErrorMessage } from '$lib/utils/toast-safe'; import * as Modal from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; - import { Input } from '$lib/components/ui/input'; import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte'; + import TipPayment from '$lib/components/payments/TipPayment.svelte'; import EditRequestModal from '$lib/components/account/EditRequestModal.svelte'; import { computeBalanceDue } from '$lib/utils/booking'; import { parseWallClockDate } from '$lib/utils/timeSlots'; import type { Booking, BookingDiscount, Payment } from '$lib/types/booking'; - import CardSelection from '$lib/components/payments/CardSelection.svelte'; - import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte'; - import { canSaveCardsForRole, isNonceStale, submitPaymentWithRetry } from '$lib/square/square'; + import { canSaveCardsForRole } from '$lib/square/square'; interface Props { open: boolean; bookingId: string; @@ -132,210 +130,17 @@ let showPaymentModal = $state(false); let showTipModal = $state(false); - let tipAmount = $state(0); - let selectedTipPreset = $state(null); - let customTipInput = $state(''); - let tipProcessing = $state(false); - - // Cached idempotency key: generated once per tip attempt, reused on retry - // (so a network-timeout retry dedupes instead of double-charging), cleared on - // success. Reset when the tip amount changes so an amount change after a - // failed attempt gets a fresh key instead of a false dedup (under-charge). - let tipIdempotencyKey = $state(''); - let tipKeyedAmount = $state(0); - let tipKeyedCard = $state(''); - - // Card selection for tips — delegated to CardSelection.svelte. - let tipSavedCards = $state([]); - let tipLoadingCards = $state(false); - let tipCardSelection = $state(null); - let tipSelectedCardId = $state(''); - let tipCardSelectionValid = $state(false); - let tipSaveCard = $state(false); - // Cached nonce: tokenization is one-shot — a retry reuses this token instead - // of re-tokenizing (the backend idempotency key dedups). - let tipNonce = $state(''); - // Cached SCA verification token paired with tipNonce (both one-shot, reused - // together on retry). The verification token is amount-bound, so changing - // the tip invalidates the cached pair. - let tipVerificationToken = $state(''); - let tipTokenAmount = $state(0); - // Epoch ms when the cached pair was tokenized — Square nonces and SCA - // verification tokens expire after ~5 minutes, so a stale pair is discarded - // on late retries and re-tokenized instead of rejected by Square. - let tipTokenizedAt = $state(0); - // Save intent at tokenization time: the SCA verification token is bound to - // CHARGE vs CHARGE_AND_STORE, so toggling the save-card checkbox after a - // tokenize must force a fresh tokenization rather than reuse a token minted - // with the wrong intent. - let tipTokenizedForSaveCard = $state(false); + // Tip payment is delegated to the shared TipPayment component (see the tip + // modal below) so the preset/custom-amount selection, 2FA gating, SCA + // verification-token handling, idempotency-key derivation and retry logic + // all live in ONE place instead of diverging between this modal and the + // /tip and /pay-tip/[id] pages. const canSaveCards = $derived(canSaveCardsForRole(authStore.currentUser?.role)); - const isTipCardValid = $derived(tipCardSelectionValid); - - const tipPresets = $derived( - selectedBooking - ? [ - { pct: 10, amount: Math.round(selectedBooking.total_amount * 0.1 * 100) / 100 }, - { pct: 15, amount: Math.round(selectedBooking.total_amount * 0.15 * 100) / 100 }, - { pct: 20, amount: Math.round(selectedBooking.total_amount * 0.2 * 100) / 100 } - ] - : [] - ); - - function selectTipPreset(amount: number) { - selectedTipPreset = amount; - customTipInput = ''; - tipAmount = amount; - } - - function handleCustomTip(e: Event) { - const input = e.target as HTMLInputElement; - const cleaned = input.value.replace(/[^0-9.]/g, ''); - const firstDot = cleaned.indexOf('.'); - let sanitized: string; - if (firstDot !== -1) { - const integerPart = cleaned.substring(0, firstDot); - const decimalPart = cleaned.substring(firstDot + 1).replace(/\./g, ''); - sanitized = integerPart + '.' + decimalPart; - } else { - sanitized = cleaned; - } - if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') { - customTipInput = sanitized; - selectedTipPreset = null; - tipAmount = parseFloat(sanitized) || 0; - } - } - - async function loadTipSavedCards() { - if (savedCardsStore.loaded) { - tipSavedCards = savedCardsStore.cards; - if (tipSavedCards.length > 0 && !tipSelectedCardId) { - tipSelectedCardId = tipSavedCards.find((c) => c.is_default)?.id || tipSavedCards[0].id; - } - return; - } - tipLoadingCards = true; - try { - await savedCardsStore.fetch(); - tipSavedCards = savedCardsStore.cards; - if (tipSavedCards.length > 0 && !tipSelectedCardId) { - tipSelectedCardId = tipSavedCards.find((c) => c.is_default)?.id || tipSavedCards[0].id; - } - } catch { - // ignore - } finally { - tipLoadingCards = false; - } - } - - async function submitTip() { - if (!selectedBooking) return; - if (tipAmount <= 0) { - toast.error('Please select a tip amount'); - return; - } - - let newCardToken: string | undefined; - let verificationToken: string | undefined; - if (tipSelectedCardId) { - // saved card — nothing to tokenize - } else if (tipCardSelection) { - // New-card mode: tokenize once per attempt, reuse the nonce + SCA - // verification token on retry (tokenization is one-shot; the backend - // idempotency key dedups). The verification token is amount-bound, so - // a changed tip amount forces a fresh tokenization. - if ( - !tipNonce || - tipTokenizedForSaveCard !== tipSaveCard || - isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount) - ) { - try { - const tokenized = await tipCardSelection.tokenizeWithVerification( - Math.round(tipAmount * 100), - { - givenName: authStore.currentUser?.firstName, - familyName: authStore.currentUser?.lastName, - email: authStore.currentUser?.email - }, - tipSaveCard - ); - tipNonce = tokenized.nonce; - tipVerificationToken = tokenized.verificationToken ?? ''; - tipTokenAmount = tipAmount; - tipTokenizedAt = Date.now(); - tipTokenizedForSaveCard = tipSaveCard; - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Card entry failed'); - return; - } - } - newCardToken = tipNonce; - verificationToken = tipVerificationToken || undefined; - } else { - toast.error('Please select a payment method'); - return; - } - - tipProcessing = true; - - try { - const bookingId = selectedBooking.id; - // New-card identity is a STABLE sentinel, NOT the cnon: nonce (same - // rationale as the booking/account flows). Include the card so a - // same-amount tip on a DIFFERENT card gets a fresh key instead of - // deduping against the previous card's charge. - const cardKey = tipSelectedCardId || 'new-card'; - if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) { - tipIdempotencyKey = crypto.randomUUID(); - tipKeyedAmount = tipAmount; - tipKeyedCard = cardKey; - } - const body: Record = { - amount: Math.round(tipAmount * 100), - idempotency_key: tipIdempotencyKey, - ...(tipSelectedCardId ? { card_id: tipSelectedCardId } : {}), - ...(newCardToken ? { new_card_token: newCardToken, save_card: tipSaveCard } : {}), - ...(verificationToken ? { verification_token: verificationToken } : {}) - }; - - const response = await submitPaymentWithRetry(() => - apiFetch(`/api/bookings/${bookingId}/tip`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }) - ); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(errorText || 'Tip payment failed'); - } - toast.success('Thank you for your tip!'); - tipIdempotencyKey = ''; - tipKeyedAmount = 0; - tipKeyedCard = ''; - tipNonce = ''; - tipVerificationToken = ''; - tipTokenAmount = 0; - tipTokenizedAt = 0; - tipTokenizedForSaveCard = false; - showTipModal = false; - fetchBookingDetails(); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Tip payment failed'); - // A definitive charge failure consumes the nonce + SCA verification - // token — clear the cached pair so retries re-tokenize fresh. The - // idempotency key stays for network-timeout dedup. - tipNonce = ''; - tipVerificationToken = ''; - tipTokenAmount = 0; - tipTokenizedAt = 0; - tipTokenizedForSaveCard = false; - } finally { - tipProcessing = false; - } + function handleTipSuccess() { + showTipModal = false; + fetchBookingDetails(); } function handlePaymentComplete() { @@ -392,12 +197,6 @@ } }); - $effect(() => { - if (showTipModal) { - loadTipSavedCards(); - } - }); - function printReceipt() { if (!selectedBooking) { toast.error('No booking data to print'); @@ -1155,103 +954,23 @@ ${hasVAT ? `

VAT is included at ${biz?.default_vat_rate ?? 20} - { - if (!v) { - showTipModal = false; - tipAmount = 0; - selectedTipPreset = null; - customTipInput = ''; - tipIdempotencyKey = ''; - tipKeyedAmount = 0; - tipKeyedCard = ''; - tipSelectedCardId = ''; - tipSaveCard = false; - tipNonce = ''; - tipVerificationToken = ''; - tipTokenAmount = 0; - tipTokenizedAt = 0; - tipTokenizedForSaveCard = false; - } - }} -> - - - Leave a Tip - Show your appreciation for great service - +{#if showTipModal && selectedBooking} + { + if (!v) showTipModal = false; + }} + > + + + Leave a Tip + Show your appreciation for great service + -

-
- {#each tipPresets as preset (preset.pct)} - - {/each} +
+ +
- -
- -
- £ - -
-
- - -
- Payment Method - - {#if tipLoadingCards} -
Loading payment methods...
- {:else} - (tipCardSelectionValid = v)} - /> - {/if} -
-
- - - - - - -

Secure payment powered by Square

- - + + +{/if} diff --git a/frontend/src/lib/components/admin/GiftCardsManagement.svelte b/frontend/src/lib/components/admin/GiftCardsManagement.svelte index cc46fe4..99f97c3 100644 --- a/frontend/src/lib/components/admin/GiftCardsManagement.svelte +++ b/frontend/src/lib/components/admin/GiftCardsManagement.svelte @@ -168,29 +168,14 @@ let onlineSquareCardInput = $state(null); let onlineSquareProcessing = $state(false); - // Idempotency Key - let idempotencyKey = $state(''); - - function getIdempotencyKey(): string { - if (!idempotencyKey) { - const array = new Uint8Array(16); - if (typeof window !== 'undefined' && window.crypto) { - window.crypto.getRandomValues(array); - } else { - for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256); - } - array[6] = (array[6] & 0x0f) | 0x40; - array[8] = (array[8] & 0x3f) | 0x80; - idempotencyKey = [...array] - .map((b, i) => { - const hex = b.toString(16).padStart(2, '0'); - if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex; - return hex; - }) - .join(''); - } - return idempotencyKey; - } + // Idempotency: the client deliberately sends NO idempotency_key. The backend + // (CreateTillSale) derives a DETERMINISTIC key server-side from the canonical + // request fields (action + admin + amount + gift-card/user/card targets), + // so a lost-response retry with the SAME amount reuses the same key and the + // pending till_sale row (dedup — no double charge), while ANY amount change + // derives a fresh key. A client-generated key cached for the modal's lifetime + // (as before) is keyed by nothing and would be reused across an amount change + // after a failed attempt, making Square return the PRIOR request's result. let topUpStep = $state< 'choice' | 'amount' | 'payment' | 'cash_entry' | 'processing' | 'success' | 'error' @@ -530,7 +515,6 @@ paymentError = ''; paymentResult = null; cardMachineItemID = null; - idempotencyKey = ''; onlineSquareAction = null; onlineSquareProcessing = false; } @@ -557,8 +541,7 @@ item_type: 'gift_card', action: actionType, amount: amt, - payment_method: 'cash', - idempotency_key: getIdempotencyKey() + payment_method: 'cash' }; if (gcId) body.gift_card_id = gcId; if (selectedCustomer) body.user_id = selectedCustomer.id; @@ -596,8 +579,7 @@ item_type: 'gift_card', action: actionType, amount: amt, - payment_method: 'card_machine', - idempotency_key: getIdempotencyKey() + payment_method: 'card_machine' }; if (gcId) body.gift_card_id = gcId; if (selectedCustomer) body.user_id = selectedCustomer.id; @@ -695,8 +677,7 @@ action: actionType, amount: amt, payment_method: 'online_square', - card_token: token, - idempotency_key: getIdempotencyKey() + card_token: token }; if (verificationToken) body.verification_token = verificationToken; if (gcId) body.gift_card_id = gcId; @@ -740,8 +721,7 @@ action: 'topup', amount: Number(topUpAmount), payment_method: 'on_the_house', - gift_card_id: gcId, - idempotency_key: getIdempotencyKey() + gift_card_id: gcId }; const res = await apiFetch('/api/admin/till/sale', { diff --git a/frontend/src/lib/components/admin/RescheduleModal.svelte b/frontend/src/lib/components/admin/RescheduleModal.svelte index 949d97a..85b7961 100644 --- a/frontend/src/lib/components/admin/RescheduleModal.svelte +++ b/frontend/src/lib/components/admin/RescheduleModal.svelte @@ -453,17 +453,14 @@
- What happens with forgiveness + When to use this

- "We've waived deposit protection on this reschedule as a goodwill gesture. The full - amount moves to the new appointment instead of having up to 50% retained as - deposit." + Use for genuinely excusable cancellations, or when the salon cancels and chooses + not to keep the money. When unchecked, standard notice-period fees apply (e.g. a + customer who calls up to cancel).

{/if} diff --git a/frontend/src/lib/components/payments/TipPayment.svelte b/frontend/src/lib/components/payments/TipPayment.svelte index 94907bb..4eda98e 100644 --- a/frontend/src/lib/components/payments/TipPayment.svelte +++ b/frontend/src/lib/components/payments/TipPayment.svelte @@ -11,19 +11,28 @@ import { Input } from '$lib/components/ui/input'; import * as Card from '$lib/components/ui/card'; import { onMount } from 'svelte'; - import { canSaveCardsForRole, isNonceStale, submitPaymentWithRetry } from '$lib/square/square'; + import { + canSaveCardsForRole, + isNonceStale, + isSavedCardVerificationRequired, + SAVED_CARD_VERIFICATION_MESSAGE, + submitPaymentWithRetry + } from '$lib/square/square'; - // Shared tip-payment UI used by /tip and /pay-tip/[id]. The routes resolve - // the booking (most-recent past booking vs. booking by URL id) and hand it - // here; everything else — tip selection, CardSelection wiring, nonce + SCA - // verification caching, idempotency-key derivation, submitTip, success and - // error handling — lives in ONE place so the two pages can't diverge. + // Shared tip-payment UI used by /tip, /pay-tip/[id] and the account + // booking-modal tip dialog. The routes resolve the booking (most-recent past + // booking vs. booking by URL id) and hand it here; everything else — tip + // selection, CardSelection wiring, nonce + SCA verification caching, + // idempotency-key derivation, submitTip, success and error handling — lives + // in ONE place so the three surfaces can't diverge. When embedded in a modal + // (UserBookingModal), `onSuccess` lets the host close itself and refresh + // instead of navigating home; standalone pages omit it. type BookingService = { service_id: string; booking_id: string; - service_name: string; - price: number; - duration_minutes: number; + service_name?: string; + price?: number; + duration_minutes?: number; override_price?: number; override_duration_minutes?: number; }; @@ -40,13 +49,13 @@ type Booking = { id: string; start_time: string; - services: BookingService[]; + services?: BookingService[]; total_amount: number; duration_minutes: number; payments?: Payment[]; }; - const { booking }: { booking: Booking } = $props(); + const { booking, onSuccess }: { booking: Booking; onSuccess?: () => void } = $props(); let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle'); // Synchronous double-click guard for submitTip. paymentState is only set to @@ -272,6 +281,9 @@ paymentState = 'processing'; + const usedSavedCard = !!(selectedCardId && !twoFactorBlocksSavedCards); + let responseStatus = 0; + try { // New-card identity is a STABLE sentinel, NOT the cnon: nonce (same // rationale as the booking/account flows). Include the card so a @@ -301,6 +313,7 @@ ); if (!response.ok) { + responseStatus = response.status; const errorText = await response.text(); throw new Error(extractErrorMessage(errorText) || 'Payment failed'); } @@ -315,9 +328,17 @@ tipTokenizedAt = 0; tipTokenizedForSaveCard = false; toast.success('Thank you for your tip!'); + onSuccess?.(); } catch (err) { paymentState = 'error'; - const errorMessage = err instanceof Error ? err.message : 'Payment failed'; + let errorMessage = err instanceof Error ? err.message : 'Payment failed'; + // Saved-card (ccof) charges skip the client-side SCA step, so a + // definitive 402 on the saved-card path means the issuer still + // requires verification — retrying the same saved card can never + // succeed. Surface the fix instead of the generic backend text. + if (isSavedCardVerificationRequired(responseStatus, usedSavedCard)) { + errorMessage = SAVED_CARD_VERIFICATION_MESSAGE; + } toast.error(errorMessage); // A definitive charge failure (e.g. declined card) consumes the nonce // and SCA verification token — they can never succeed again. Clear the @@ -357,8 +378,12 @@

Thank you!

Your generosity is greatly appreciated.

- - + {#if onSuccess} + + {:else} + + + {/if} {:else} @@ -376,7 +401,7 @@ {formatTimeRange( booking.start_time, - booking.services, + booking.services ?? [], booking.duration_minutes ?? 0 )} @@ -397,9 +422,9 @@
{#each booking.services as service (service.service_id || service.booking_id)}
- {service.service_name} + {service.service_name || '—'} {formatPrice(service.override_price ?? service.price)}{formatPrice(service.override_price ?? service.price ?? 0)}
{/each} diff --git a/frontend/src/lib/components/payments/UserPaymentModal.svelte b/frontend/src/lib/components/payments/UserPaymentModal.svelte index eb8a41e..e18e7f9 100644 --- a/frontend/src/lib/components/payments/UserPaymentModal.svelte +++ b/frontend/src/lib/components/payments/UserPaymentModal.svelte @@ -12,7 +12,13 @@ import PolicyPopover from '$lib/components/ui/policyPopover.svelte'; import { authStore } from '$lib/stores/auth.svelte'; import { apiFetch } from '$lib/utils/api'; - import { isNonceStale, submitPaymentWithRetry } from '$lib/square/square'; + import { + isNonceStale, + isOverflowTipConfirmationRequired, + isSavedCardVerificationRequired, + SAVED_CARD_VERIFICATION_MESSAGE, + submitPaymentWithRetry + } from '$lib/square/square'; const LOYALTY_DISCOUNT_RATE = 0.1; @@ -136,6 +142,42 @@ const amountRemaining = $derived(booking.total_amount - totalPaid); + // Mirror of the backend's GetBookingRemainingBalanceCents (see + // backend/handlers/payments/service.go): total − completed non-tip payments + // + completed refunds, clamped to the booking total and floored at 0. The + // backend rejects an unconfirmed pre-start overpayment when req.Amount > + // this value, and records the excess (req.Amount − remainingCents) as a tip + // once confirmed — so the overflow-confirmation prompt shows exactly that. + const remainingBalanceCents = $derived.by(() => { + const total = booking.total_amount ?? 0; + const paid = (booking.payments ?? []) + .filter((p) => p.status === 'completed' && p.payment_type !== 'tip') + .reduce((sum, p) => sum + p.amount, 0); + const refunded = (booking.refunds ?? []) + .filter((r) => r.status === 'completed') + .reduce((sum, r) => sum + r.amount, 0); + return Math.round(Math.max(0, Math.min(total - paid + refunded, total)) * 100); + }); + + // Pre-start overpayment confirmation. The backend rejects a payment that + // exceeds the booking's remaining balance before the appointment has + // started unless the request carries `confirm_overflow_tip: true` — a tip + // is gratuity for service already rendered. The frontend caps amounts at + // amountRemaining in normal flows, so this fires on STALE booking data + // (multi-tab, admin-changed totals, refunds that reopened capacity) where + // the user would otherwise be stuck with an unresolvable 400. On the guard + // firing, the rejected request (amount, type, cached card tokens) is parked + // here and a Confirm/Cancel prompt is shown; Confirm resends the SAME + // request with the flag, Cancel returns to the amount-editing form. + let overflowConfirm = $state<{ + amountCents: number; + paymentType: string; + overflowCents: number; + cardId?: string; + newCardToken?: string; + verificationToken?: string; + } | null>(null); + const loyaltyEligible = $derived( stamps >= 10 && !(booking.discounts ?? []).some((d) => d.discount_source === 'loyalty') && @@ -445,6 +487,25 @@ payKeyedCard = cardKey; } + await submitBookingPayment(paymentType, amountCents, cardId, newCardToken, verificationToken, false); + } + + // Submits a booking-payment request and processes the outcome. Shared by + // the initial attempt and the overflow-tip confirm resend so both use the + // exact same success/error handling. `confirmOverflowTip` adds the backend's + // opt-in flag for a pre-start overpayment; the resend reuses the SAME + // cached nonce/verification token/idempotency key as the rejected attempt + // (the guard fired before any Square call, so the tokens are unconsumed and + // the key is still the correct dedup identity for this amount+type+card). + async function submitBookingPayment( + paymentType: string, + amountCents: number, + cardId: string | undefined, + newCardToken: string | undefined, + verificationToken: string | undefined, + confirmOverflowTip: boolean + ): Promise { + let responseStatus = 0; try { const response = await submitPaymentWithRetry(() => apiFetch(`/api/bookings/${booking.id}/payment`, { @@ -453,6 +514,7 @@ body: JSON.stringify({ amount: amountCents, payment_type: paymentType, + ...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}), ...(cardId ? { card_id: cardId } : {}), ...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}), ...(verificationToken ? { verification_token: verificationToken } : {}), @@ -462,13 +524,32 @@ ); if (!response.ok) { + responseStatus = response.status; const errData = await response.text(); + // Pre-start overpayment on stale booking data: park the rejected + // request (amount, type, cached tokens) and surface the + // Confirm/Cancel prompt instead of a dead-end 400. The cached + // nonce + SCA verification token + idempotency key are NOT + // cleared here — the confirm resend is the same logical charge. + if (!confirmOverflowTip && isOverflowTipConfirmationRequired(errData)) { + overflowConfirm = { + amountCents, + paymentType, + overflowCents: Math.max(0, amountCents - remainingBalanceCents), + cardId, + newCardToken, + verificationToken + }; + status = 'idle'; + return; + } throw new Error(extractErrorMessage(errData) || 'Failed to initiate payment'); } const data = await response.json(); // Payment is synchronous (completed immediately) status = 'success'; + overflowConfirm = null; payIdempotencyKey = ''; payKeyedAmount = 0; payKeyedType = ''; @@ -491,9 +572,16 @@ releaseLock(); } catch (_err) { status = 'error'; - const msg = _err instanceof Error ? _err.message : 'Payment declined'; + overflowConfirm = null; + let msg = _err instanceof Error ? _err.message : 'Payment declined'; + // Saved-card (ccof) charges skip the client-side SCA step, so a + // definitive 402 on the saved-card path means the issuer still + // requires verification — retrying the same saved card can never + // succeed. Surface the fix instead of the generic backend text. + const verificationFailure = isSavedCardVerificationRequired(responseStatus, !!cardId); + if (verificationFailure) msg = SAVED_CARD_VERIFICATION_MESSAGE; error = msg; - toast.error(`${msg}. Please try again or use another card.`); + toast.error(verificationFailure ? msg : `${msg}. Please try again or use another card.`); // A definitive charge failure consumes the nonce + SCA verification // token — clear the cached pair so retries re-tokenize fresh. The // idempotency key stays for network-timeout dedup. CardSelection stays @@ -508,6 +596,32 @@ } } + // Confirm the pre-start overpayment: resend the SAME rejected request with + // confirm_overflow_tip: true so the excess is recorded as a tip. + async function confirmOverflowPayment() { + const pending = overflowConfirm; + if (!pending || status === 'processing') return; + status = 'processing'; + error = null; + await submitBookingPayment( + pending.paymentType, + pending.amountCents, + pending.cardId, + pending.newCardToken, + pending.verificationToken, + true + ); + } + + // Revert to the amount-editing form. The cached nonce/tokens/idempotency key + // stay: a resubmit with the SAME amount+type+card reuses them (no charge was + // made — the guard fired before Square), and a changed amount forces a fresh + // tokenization + key. + function cancelOverflowConfirmation() { + overflowConfirm = null; + status = 'idle'; + } + function handlePayDeposit() { const depositCents = booking.deposit_amount ? Math.round(booking.deposit_amount * 100) @@ -585,7 +699,63 @@ {/if} - {#if status === 'idle' || status === 'processing' || status === 'error'} + {#if overflowConfirm} +
+ +
+
+ + + + +
+

Confirm extra as tip

+

+ The balance for this booking has changed since it was last loaded. The extra + {formatCurrency(overflowConfirm.overflowCents)} will be recorded as a tip. Confirm + to continue? +

+
+
+
+ + +
+
+

Secure payment powered by Square

+ +
+ {:else if status === 'idle' || status === 'processing' || status === 'error'}
{#if booking.status === 'pending_release' && lockAcquired && lockTimer > 0} diff --git a/frontend/src/lib/square/square.test.ts b/frontend/src/lib/square/square.test.ts new file mode 100644 index 0000000..77a034e --- /dev/null +++ b/frontend/src/lib/square/square.test.ts @@ -0,0 +1,267 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + NONCE_STALENESS_MS, + OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE, + PAYMENT_AMBIGUOUS_STATUS, + PAYMENT_DEFINITIVE_STATUS, + SAVED_CARD_VERIFICATION_MESSAGE, + canSaveCardsForRole, + isAmbiguousPaymentFailure, + isNonceStale, + isOverflowTipConfirmationRequired, + isSavedCardVerificationRequired, + submitPaymentWithRetry +} from './square'; +import type * as SquareModule from './square'; + +describe('isNonceStale', () => { + const tokenizedFor = 2500; + + it('is false for a fresh nonce (under the staleness limit)', () => { + const tokenizedAt = 1_000_000; + const now = tokenizedAt + NONCE_STALENESS_MS - 1; + expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(false); + }); + + it('is false exactly at the staleness boundary (limit is exclusive, > not >=)', () => { + const tokenizedAt = 1_000_000; + const now = tokenizedAt + NONCE_STALENESS_MS; + expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(false); + }); + + it('is true just past the staleness boundary', () => { + const tokenizedAt = 1_000_000; + const now = tokenizedAt + NONCE_STALENESS_MS + 1; + expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(true); + }); + + it('is true when the nonce was tokenized for a different amount than now', () => { + expect(isNonceStale(1_000_000, tokenizedFor, tokenizedFor + 1, Date.now())).toBe(true); + }); + + it('is true for a very old nonce', () => { + expect(isNonceStale(1, tokenizedFor, tokenizedFor, Date.now())).toBe(true); + }); + + it('defaults `now` to Date.now() when omitted', () => { + expect(isNonceStale(Date.now(), tokenizedFor, tokenizedFor)).toBe(false); + }); +}); + +describe('isSquareMock / isSquareConfigured / getSquareConfig', () => { + type SquareEnv = { + VITE_SQUARE_ENVIRONMENT?: string; + VITE_SQUARE_APPLICATION_ID?: string; + VITE_SQUARE_LOCATION_ID?: string; + DEV?: boolean; + }; + + // square.ts captures APP_ID/LOCATION_ID/SQUARE_ENV at module load, so each + // env combination must reload the module with vi.resetModules + vi.stubEnv. + // All three VITE_* keys are stubbed explicitly ('' when omitted) so local + // .env files can never leak into the suite. + async function loadSquareWithEnv(env: SquareEnv): Promise { + vi.resetModules(); + vi.stubEnv('VITE_SQUARE_ENVIRONMENT', env.VITE_SQUARE_ENVIRONMENT ?? ''); + vi.stubEnv('VITE_SQUARE_APPLICATION_ID', env.VITE_SQUARE_APPLICATION_ID ?? ''); + vi.stubEnv('VITE_SQUARE_LOCATION_ID', env.VITE_SQUARE_LOCATION_ID ?? ''); + if (env.DEV !== undefined) { + vi.stubEnv('DEV', env.DEV); + } + return await import('./square'); + } + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('mock env in a dev build → isSquareMock true, isSquareConfigured true', async () => { + const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock', DEV: true }); + expect(mod.isSquareMock()).toBe(true); + expect(mod.isSquareConfigured()).toBe(true); + }); + + it('mock env in a production build → isSquareMock false (DEV gate)', async () => { + const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock', DEV: false }); + expect(mod.isSquareMock()).toBe(false); + }); + + it('mock env without credentials → isSquareConfigured true (mock form needs no keys)', async () => { + const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock' }); + expect(mod.isSquareConfigured()).toBe(true); + }); + + it('sandbox env with credentials → not mock, configured, config returned', async () => { + const mod = await loadSquareWithEnv({ + VITE_SQUARE_ENVIRONMENT: 'sandbox', + VITE_SQUARE_APPLICATION_ID: 'sandbox-sq0idb-abc123', + VITE_SQUARE_LOCATION_ID: 'L0MOCK123' + }); + expect(mod.isSquareMock()).toBe(false); + expect(mod.isSquareConfigured()).toBe(true); + expect(mod.getSquareConfig()).toEqual({ + appId: 'sandbox-sq0idb-abc123', + locationId: 'L0MOCK123' + }); + }); + + it('sandbox env without credentials → not configured, config null', async () => { + const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'sandbox' }); + expect(mod.isSquareMock()).toBe(false); + expect(mod.isSquareConfigured()).toBe(false); + expect(mod.getSquareConfig()).toBeNull(); + }); + + it('empty env → neither mock nor configured', async () => { + const mod = await loadSquareWithEnv({}); + expect(mod.isSquareMock()).toBe(false); + expect(mod.isSquareConfigured()).toBe(false); + expect(mod.getSquareConfig()).toBeNull(); + }); +}); + +describe('canSaveCardsForRole', () => { + it.each([ + ['admin', true], + ['verified_email', true], + ['unverified_email', false], + ['guest', false], + ['affiliate', false], + ['user', false], + ['', false], + [undefined, false] + ])('role %s → %s', (role, expected) => { + expect(canSaveCardsForRole(role)).toBe(expected); + }); +}); + +describe('payment failure classification', () => { + it('PAYMENT_DEFINITIVE_STATUS is 402', () => { + expect(PAYMENT_DEFINITIVE_STATUS).toBe(402); + }); + + it('PAYMENT_AMBIGUOUS_STATUS is 503', () => { + expect(PAYMENT_AMBIGUOUS_STATUS).toBe(503); + }); + + it('isAmbiguousPaymentFailure matches only 503', () => { + expect(isAmbiguousPaymentFailure(503)).toBe(true); + expect(isAmbiguousPaymentFailure(402)).toBe(false); + expect(isAmbiguousPaymentFailure(200)).toBe(false); + expect(isAmbiguousPaymentFailure(500)).toBe(false); + }); + + it.each([ + [true, 402, true], + [false, 402, false], + [true, 503, false], + [true, 200, false], + [false, 200, false] + ])('isSavedCardVerificationRequired(%s, %d) → %s', (usedSavedCard, status, expected) => { + expect(isSavedCardVerificationRequired(status, usedSavedCard)).toBe(expected); + }); + + it('SAVED_CARD_VERIFICATION_MESSAGE is non-empty and mentions verification', () => { + expect(SAVED_CARD_VERIFICATION_MESSAGE.length).toBeGreaterThan(0); + expect(SAVED_CARD_VERIFICATION_MESSAGE.toLowerCase()).toContain('verification'); + }); +}); + +describe('isOverflowTipConfirmationRequired', () => { + it('matches the backend overflow-guard error body by its code', () => { + const body = JSON.stringify({ + error: 'The extra amount will be recorded as a tip. Confirm to continue.', + code: OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE + }); + expect(isOverflowTipConfirmationRequired(body)).toBe(true); + }); + + it('is false for a 400 body with a different code', () => { + expect( + isOverflowTipConfirmationRequired(JSON.stringify({ error: 'Bad amount', code: 'invalid_amount' })) + ).toBe(false); + }); + + it('is false for a body with only the error text and no code', () => { + expect(isOverflowTipConfirmationRequired('The extra amount will be recorded as a tip.')).toBe( + false + ); + }); + + it('is false for a non-JSON body', () => { + expect(isOverflowTipConfirmationRequired('Payment declined')).toBe(false); + }); + + it('is false for an empty body', () => { + expect(isOverflowTipConfirmationRequired('')).toBe(false); + }); + + it('is false when the code is not an exact match (guards against prefix drift)', () => { + expect( + isOverflowTipConfirmationRequired( + JSON.stringify({ error: 'x', code: 'overflow_tip_confirmation_required_extra' }) + ) + ).toBe(false); + }); +}); + +describe('submitPaymentWithRetry', () => { + function jsonResponse(status: number): Response { + return new Response(null, { status }); + } + + it('retries on a 503 and resolves the follow-up 200', async () => { + const submit = vi + .fn() + .mockResolvedValueOnce(jsonResponse(503)) + .mockResolvedValueOnce(jsonResponse(200)); + const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 }); + expect(response.status).toBe(200); + expect(response.ok).toBe(true); + expect(submit).toHaveBeenCalledTimes(2); + }); + + it('returns a 402 immediately without retrying', async () => { + const submit = vi.fn().mockResolvedValue(jsonResponse(402)); + const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 }); + expect(response.status).toBe(402); + expect(submit).toHaveBeenCalledTimes(1); + }); + + it('succeeds after two 503s followed by a 200', async () => { + const submit = vi + .fn() + .mockResolvedValueOnce(jsonResponse(503)) + .mockResolvedValueOnce(jsonResponse(503)) + .mockResolvedValueOnce(jsonResponse(200)); + const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 }); + expect(response.status).toBe(200); + expect(submit).toHaveBeenCalledTimes(3); + }); + + it('gives up after maxRetries and returns the last 503', async () => { + const submit = vi.fn().mockResolvedValue(jsonResponse(503)); + const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 }); + expect(response.status).toBe(503); + expect(submit).toHaveBeenCalledTimes(4); + }); + + it('honours a custom maxRetries', async () => { + const submit = vi.fn().mockResolvedValue(jsonResponse(503)); + const response = await submitPaymentWithRetry(submit, { maxRetries: 1, retryDelayMs: 1 }); + expect(response.status).toBe(503); + expect(submit).toHaveBeenCalledTimes(2); + }); + + it('backs off with retryDelayMs between retries', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const submit = vi + .fn() + .mockResolvedValueOnce(jsonResponse(503)) + .mockResolvedValueOnce(jsonResponse(200)); + const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1234 }); + expect(response.status).toBe(200); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1234); + }); +}); diff --git a/frontend/src/lib/square/square.ts b/frontend/src/lib/square/square.ts index b28416e..9a7f589 100644 --- a/frontend/src/lib/square/square.ts +++ b/frontend/src/lib/square/square.ts @@ -55,6 +55,64 @@ export function isNonceStale( export const PAYMENT_AMBIGUOUS_STATUS = 503; export const PAYMENT_DEFINITIVE_STATUS = 402; +/** + * True when a definitive (402) charge failure on a SAVED CARD should be + * surfaced as a card-issuer verification problem rather than a plain decline. + * The backend sets customer_details.customer_initiated=true on saved-card + * (ccof) charges and classifies issuer-verification rejections — Square's + * CARD_DECLINED_VERIFICATION_REQUIRED and friends — as definitive 402s, but the + * response body is the generic "Payment failed" text with no distinguishing + * code. Saved cards skip the client-side tokenizeWithVerification SCA step, so + * a 402 on the saved-card path means the issuer still requires verification: + * retrying the same saved card can never succeed, and the buyer must pay with + * a freshly tokenized card or re-add theirs. New-card (cnon) charges carry + * their own SCA verification token, so they are never classified this way. + */ +export function isSavedCardVerificationRequired(status: number, usedSavedCard: boolean): boolean { + return usedSavedCard && status === PAYMENT_DEFINITIVE_STATUS; +} + +/** User-facing guidance for a saved-card charge the issuer requires + * verification to complete. Retrying the same saved card is pointless — the + * buyer must pay with a new card or re-add their card. */ +export const SAVED_CARD_VERIFICATION_MESSAGE = + 'Your card issuer requires verification. Please pay with a new card or re-add your card.'; + +/** + * Error code the booking-payment endpoint (POST /api/bookings/{id}/payment) + * returns with a 400 when a payment would exceed the booking's remaining + * balance BEFORE the appointment has started. buildSplitRecords records any + * overflow beyond the booking total as a tip — but a tip is gratuity for + * service already rendered, so the backend refuses to silently convert an + * unconfirmed pre-start overpayment into a tip (see CreateBookingPayment). + * The frontend must surface a Confirm/Cancel prompt and resend the SAME + * request with `confirm_overflow_tip: true` on confirm. This fires mainly on + * stale booking data (multi-tab, admin-changed totals, refunds that reopened + * capacity), so the response body carries no amount — the caller computes the + * overflow as `req.Amount - remainingCents` from its booking data. + */ +export const OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE = 'overflow_tip_confirmation_required'; + +/** + * True when an API error body is the backend's overflow-tip confirmation guard + * (a 400 JSON body of the form `{"error": "...", "code": + * "overflow_tip_confirmation_required"}`). The shared error-parsing helper + * (`extractErrorMessage`) surfaces only the human-readable message text, not + * the machine-readable `code` field, so this checks the raw response body + * directly. Returns false for any non-JSON body or any other error. + */ +export function isOverflowTipConfirmationRequired(errorText: string): boolean { + const trimmed = errorText.trim(); + if (!trimmed) return false; + try { + const parsed = JSON.parse(trimmed) as Record; + return parsed?.code === OVERFLOW_TIP_CONFIRMATION_REQUIRED_CODE; + } catch { + // Not JSON — cannot be the overflow guard + return false; + } +} + /** True when a payment submission response is an ambiguous failure (503) that * must be retried with the SAME idempotency key so the backend resumes the * pending record. */ diff --git a/frontend/src/routes/privacy-policy/+page.svelte b/frontend/src/routes/privacy-policy/+page.svelte index 28d2dd3..e2a4ea4 100644 --- a/frontend/src/routes/privacy-policy/+page.svelte +++ b/frontend/src/routes/privacy-policy/+page.svelte @@ -75,7 +75,8 @@

Data Controller

Crussell Salon

Edinburgh, Scotland

-

Email: help@crussell.invalid

+ +

Email: {"{{SUPPORT_EMAIL}}"}

@@ -319,7 +320,7 @@
  • Withdraw Consent (Article 7(3))
  • - To exercise these rights, contact help@crussell.invalid. You also have the right to + To exercise these rights, contact {"{{SUPPORT_EMAIL}}"}. You also have the right to complain to the Information Commissioner’s Office (ICO) at any time.

    diff --git a/frontend/src/routes/terms/+page.svelte b/frontend/src/routes/terms/+page.svelte index 78ff8ac..8d23992 100644 --- a/frontend/src/routes/terms/+page.svelte +++ b/frontend/src/routes/terms/+page.svelte @@ -73,7 +73,8 @@

    Business Details

    Trading name: Crussell Salon

    Registered address: Edinburgh, Scotland

    -

    Contact email: help@crussell.invalid

    + +

    Contact email: {"{{SUPPORT_EMAIL}}"}

    VAT: Not currently registered (threshold £90,000; will register when reached)

    diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..34a2ddc --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +// Separate from vite.config.ts: SvelteKit warns about a `test` block there, and +// `vite build`/`vite dev` must never pick up test-only config. Pure-logic tests +// need no svelte/tailwind plugins — no DOM/jsdom this round. +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/*.svelte.test.ts'], + restoreMocks: true, + unstubEnvs: true + } +}); diff --git a/obsidian/Crussell/Gift Card Terms & Conditions.md b/obsidian/Crussell/Gift Card Terms & Conditions.md index da2821a..d0b56ff 100644 --- a/obsidian/Crussell/Gift Card Terms & Conditions.md +++ b/obsidian/Crussell/Gift Card Terms & Conditions.md @@ -138,9 +138,9 @@ To recover a dormant balance: If you buy a gift card **online** (or by any distance method rather than face-to-face in the salon), the **Consumer Contracts (Information, Cancellation and Additional Charges) Regulations 2013** give you a **14-day right to cancel**, running from the day after purchase. -- **How to cancel:** email us at help@crussell.invalid within 14 days of purchase with your order details and the gift card code, if you have it. +- **How to cancel:** cancel in-app from your account — **Account → Gift Cards** — within 14 days of purchase; you'll need the gift card code. No email is required. If the card was partly used on salon services, an admin can also process the cancellation on your behalf from the salon's Gift Card Management screen. - **Refund:** we will refund to the original payment method within 14 days of receiving your cancellation: the full purchase amount if the card is unused, or the unspent balance if the card has been partly used on salon services (the amount already spent is not refundable). The card is then cancelled, so any remaining balance can no longer be spent. -- **When the right is lost:** the right to cancel ends once the gift card's value has been fully spent or redeemed to an account balance within the 14-day period. Where a card is only partly used, the partial-use rule above applies (regulation 34(9) of the Regulations). This is why we ask you to return the unused card code where possible. +- **When the right is lost:** the right to cancel ends once the gift card's value has been fully spent or redeemed to an account balance within the 14-day period. Where a card is only partly used, the partial-use rule above applies (regulation 34(9) of the Regulations) — the unspent balance is refunded to the original payment method. - **In-store purchases:** this right applies to distance purchases only. Gift cards bought in person in the salon are not distance sales. See our [[Terms & Conditions - Overall App#5. Distance Contracts & Right to Cancel|General Terms]] for the wider distance-contract position. diff --git a/obsidian/Crussell/Overview.md b/obsidian/Crussell/Overview.md index 685eb55..f4231e7 100644 --- a/obsidian/Crussell/Overview.md +++ b/obsidian/Crussell/Overview.md @@ -36,7 +36,7 @@ Square integration has two build-tagged implementations: Saved cards stored in `user_saved_cards` with soft delete (`retained_until` for 7-year UK compliance). Refunds tracked in `refunds` table — partial or full. Square webhooks at `/webhooks/square` (registered on the router root, proxied exact-match by nginx — not under `/api`) are HMAC-verified **fail-closed** (503 without the signing key, 403 on bad signature) and deduplicated by `event_id`: a fast-path in-memory cache plus a `square_webhook_events` DB row committed **after** dispatch, so delivery is at-least-once and Square retries on any failure. Events dispatch to state-mutating handlers that reconcile `payments`, `till_sales`, `refunds`, and `disputes` — a lost dispute marks the payment failed and a `critical_payment_log` admin notification is always raised, even when the disputed payment is not tracked locally (no sweep fallback exists for disputes). The background sweeps remain as the eventual backstop. -**2FA on online card payments:** a loosely-faked two-factor-authentication feature stands in for PSD2 Strong Customer Authentication. Charging a **saved card** requires the user to have 2FA enabled when it is enforced (enforcement is **fail-closed**: ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced — and disabled only by `REQUIRE_2FA=false` (case-insensitive, also `0`/`off`/`no`); new-card/nonce charges are not gated). The 6-digit code is delivered via the server log (`[2FA]` prefix) in ALL modes — the operator reads it and relays it to the customer — standing in for real email/SMS delivery until that infrastructure lands (P6). In unenforced/dev mode the setup response also returns the code, so the flow is testable without grepping backend logs; there is no email/SMS transport yet. UI: Account → Two-Factor Authentication. Details in the [[Technical Manual]]. +**2FA on online card payments:** a two-factor authorization feature that acts as a **merchant-level authorization gate on saved-card payments — NOT PSD2 SCA**. Square buyer verification via `tokenizeWithVerification` is the SCA mechanism, wired for new-card charges; the gate is retained as an additional fraud control until Square buyer verification is wired for saved-card charges. Charging a **saved card** requires the user to have 2FA enabled when it is enforced (enforcement is **fail-closed**: ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced — and disabled only by `REQUIRE_2FA=false` (case-insensitive, also `0`/`off`/`no`); new-card/nonce charges are not gated). The 6-digit code is delivered via the server log (`[2FA]` prefix) in ALL modes — the operator reads it and relays it to the customer — standing in for real email/SMS delivery until that infrastructure lands (P6). In unenforced/dev mode the setup response also returns the code, so the flow is testable without grepping backend logs; there is no email/SMS transport yet. UI: Account → Two-Factor Authentication. Details in the [[Technical Manual]]. Fees column on `payments` stores actual Square deductions. **`square_deposits` (and the `generate_square_deposit_id()` function) were dead schema with zero Go references, a placeholder for Square bank reconciliation against Mettle; they were dropped from `init-scripts/init-script.sql` in the fresh-DB recreate (backlog T1 closed). Mettle/FreeAgent integration is a planned upcoming body of work.** @@ -220,7 +220,7 @@ npm run dev # Dev server with HMR ```bash cd backend -go test -tags "test,dev" ./... # 2,169 tests passed (4 skipped) +go test -tags "test,dev" ./... # 2,269 tests passed (4 skipped), as of 13 Aug 2026 go test -tags "test,dev" -v -run TestName ./... # Single test ``` diff --git a/obsidian/Crussell/Privacy Policy.md b/obsidian/Crussell/Privacy Policy.md index cc464e2..e759f3b 100644 --- a/obsidian/Crussell/Privacy Policy.md +++ b/obsidian/Crussell/Privacy Policy.md @@ -14,7 +14,7 @@ We are committed to protecting your privacy and complying with the **UK General **Data Controller:** Crussell Salon Edinburgh, Scotland -Email: help@crussell.invalid +Email: `{{SUPPORT_EMAIL}}` *(placeholder — the real support address must be substituted before launch)* --- @@ -129,4 +129,4 @@ Under UK GDPR, you have the right to: - **Object** to processing (Article 21) - **Withdraw Consent** (Article 7(3)) -To exercise these rights, contact help@crussell.invalid. You also have the right to complain to the Information Commissioner's Office (ICO) at any time. +To exercise these rights, contact `{{SUPPORT_EMAIL}}` *(placeholder — set before launch)*. You also have the right to complain to the Information Commissioner's Office (ICO) at any time. diff --git a/obsidian/Crussell/Technical Manual.md b/obsidian/Crussell/Technical Manual.md index e69fa33..12b1968 100644 --- a/obsidian/Crussell/Technical Manual.md +++ b/obsidian/Crussell/Technical Manual.md @@ -823,9 +823,9 @@ validTransitions := map[string]map[string]bool{ --- -### Two-Factor Authentication (2FA) — PSD2 SCA stand-in +### Two-Factor Authentication (2FA) — merchant-level authorization gate (not PSD2 SCA) -**What it is:** a loosely-faked two-factor-authentication feature that stands in for PSD2 Strong Customer Authentication on saved-card online charges until real SCA/email-SMS infrastructure lands. Enabling it is optional per-user; when enforcement is active, a user who has **not** enabled 2FA is blocked (403 JSON, parseable via `extractErrorMessage`) from saved-card online payment paths. +**What it is:** a two-factor authorization feature that acts as a **merchant-level authorization gate on saved-card payments**. It is **NOT PSD2 SCA**: Square buyer verification via `tokenizeWithVerification` is the SCA mechanism, wired for new-card charges. The gate is retained as an additional fraud control until Square buyer verification is wired for saved-card charges. Enabling it is optional per-user; when enforcement is active, a user who has **not** enabled 2FA is blocked (403 JSON, parseable via `extractErrorMessage`) from saved-card online payment paths. **Enforcement** (`twoFactorEnforced`, `handlers/payments/twofa.go`): - Enforcement is **fail-closed**: ON by default for any `SQUARE_ENVIRONMENT`, including empty and unknown values, which are treated as production-enforced. It is disabled only when `REQUIRE_2FA` is an explicit disable value (`false`/`0`/`off`/`no`, case-insensitive) **or** `SQUARE_ENVIRONMENT` is an explicit dev/mock value (`mock`, `dev`, `development`, `test`). @@ -1323,7 +1323,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user ### Test Coverage -**2,169 tests compiled** across all packages (4 skipped, 0 failures). Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000. +**2,269 tests compiled** across all packages (4 skipped, 0 failures) — as of 13 Aug 2026. Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000. | Package | Coverage Area | |---------|--------------| @@ -1342,6 +1342,17 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user --- +## Pre-Launch Checklist + +Items that must be closed before a production go-live. This is a living list; add to it as gaps surface. + +- **Set `SUPPORT_EMAIL`.** Every consumer-facing legal doc ([[Terms & Conditions - Overall App]], [[Privacy Policy]], [[Gift Card Terms & Conditions]], and the `/terms`, `/privacy-policy`, `/cancellation-policy` routes) currently uses the `{{SUPPORT_EMAIL}}` placeholder for the support address. The real address must be substituted in **all** of those places before launch — a placeholder in a live policy is a consumer-law exposure. +- **Legal review of the DRAFT-bannered legal docs.** The T&Cs, Privacy Policy, Gift Card Terms, and the policy routes are still drafts for go-live review; have the wording checked by a solicitor before launch. +- **Wire real email/SMS or keep the `[2FA]` log relay.** 2FA codes are delivered via the server log until email/SMS lands (see the Two-Factor Authentication section in this manual); confirm the delivery channel before launch. +- **Production storage (S3/R2)** and **SMTP** are unimplemented stubs (see README "Limitations") — required for prod. + +--- + ## Build Tags Reference | Tag | File | Meaning | diff --git a/obsidian/Crussell/Terms & Conditions - Overall App.md b/obsidian/Crussell/Terms & Conditions - Overall App.md index 1da63f6..7410000 100644 --- a/obsidian/Crussell/Terms & Conditions - Overall App.md +++ b/obsidian/Crussell/Terms & Conditions - Overall App.md @@ -14,7 +14,7 @@ By creating an account or making a booking through our Platform, you agree to be **Business Details:** - **Trading Name:** Crussell Salon - **Registered Address:** Edinburgh, Scotland -- **Contact Email:** help@crussell.invalid +- **Contact Email:** `{{SUPPORT_EMAIL}}` *(placeholder — the real support address must be substituted before launch; see the [[Technical Manual#Pre-Launch Checklist|Pre-Launch Checklist]])* - **VAT Status:** Not currently VAT registered (threshold is £90,000; will register when reached) --- @@ -152,7 +152,7 @@ Purchases made on our Platform (rather than face-to-face in the salon) are **dis - **Gift cards bought online** carry this 14-day right, refunded to the original payment method — in full if unused, or the unspent balance if partly used on salon services (the card is then cancelled) — see the [[Gift Card Terms & Conditions#7. Right to Cancel (Online Purchases)|Gift Card Terms]]. - **Appointment bookings** made online for a specific date are services with a specified date of performance (regulation 28(1)(h) — services related to leisure activities), so the 14-day right does not apply to the service itself; our cancellation and refund policy in section 2 applies instead. -- **How to exercise it:** email help@crussell.invalid within 14 days. Refunds are made within 14 days, to the original payment method. +- **How to exercise it:** for gift cards, cancel in-app from your account within 14 days (no email needed); for anything else, email `{{SUPPORT_EMAIL}}` *(placeholder — set before launch)* within 14 days. Refunds are made within 14 days, to the original payment method. *This is a summary of consumer protection law of a general nature, not legal advice; please verify the position with a solicitor before going live.* diff --git a/obsidian/Crussell/Testing Architecture & DB Management.md b/obsidian/Crussell/Testing Architecture & DB Management.md index 03d7ab3..4fb0ffd 100644 --- a/obsidian/Crussell/Testing Architecture & DB Management.md +++ b/obsidian/Crussell/Testing Architecture & DB Management.md @@ -1,6 +1,6 @@ # Testing Architecture & DB Management -**Last Updated:** August 2026 (v6 — coverage 50.4%→65.0%, 2,137 tests compiled, 4 skipped) +**Last Updated:** August 2026 (v6 — coverage 50.4%→65.0%, 2,269 tests compiled, 4 skipped) --- @@ -502,7 +502,7 @@ This appears in `TestAccount_DeleteGuest` and `TestLoyalty_Get`. The `dav.Servic |--------|-------| | Quick check (`-count=1`) | **~2min** | | Packages | 25 tested, 0 failures | -| Tests | 2,137 compiled under test,dev tags | +| Tests | 2,269 compiled under test,dev tags (as of 13 Aug 2026) | New test additions in this batch: | Test | Coverage | @@ -521,7 +521,7 @@ New test additions in this batch: | `TestCancelReservation_DoesNotTouchAnonReservations` | Inverse-isolation test — user cancel ignores `RESERVATION:anon:%` (defensive — the WHERE clause only matches `RESERVATION:user:%`) | | `TestCancelReservation_DoesNotTouchAdminReservations` | Inverse-isolation test — user cancel ignores `RESERVATION:admin:%`. Pairs with the admin-side test that verifies admin cancel ignores `RESERVATION:user:%`. Proves the two endpoints are properly partitioned. | -**Total tests:** 2,137 compiled across all packages (4 skipped). 0 failures. Growth driven by: coverage improvement pass (new test files for bookings, user, payments, giftcards, till, refunds, DAV, auth, middleware, validators, zxcvbn — 56 new files, coverage 50.4%→65.0%), VAT lifecycle and parallel-deadlock regression tests, savepoint/transaction-context pattern for time-sensitive tests, split-lunch detection tests, removal of 10 dead test functions flagged by staticcheck U1000, and the Square payments test-gap round (terminal CreateCheckout-failure, GetCheckoutStatus reference_id mismatch, deadline wire shape, loyalty lock contention 409, GDPR saved-card scrubbing, ValidateAmount/isTokenLike/lock helpers direct units, buildSplitRecords tip overflow). +**Total tests:** 2,269 compiled across all packages (4 skipped) — as of 13 Aug 2026. 0 failures. Growth driven by: coverage improvement pass (new test files for bookings, user, payments, giftcards, till, refunds, DAV, auth, middleware, validators, zxcvbn — 56 new files, coverage 50.4%→65.0%), VAT lifecycle and parallel-deadlock regression tests, savepoint/transaction-context pattern for time-sensitive tests, split-lunch detection tests, removal of 10 dead test functions flagged by staticcheck U1000, and the Square payments test-gap round (terminal CreateCheckout-failure, GetCheckoutStatus reference_id mismatch, deadline wire shape, loyalty lock contention 409, GDPR saved-card scrubbing, ValidateAmount/isTokenLike/lock helpers direct units, buildSplitRecords tip overflow). ### What Drives Test Time @@ -638,7 +638,7 @@ This shouldn't appear anymore — the auth package's TestMain was updated to use ### Q: What's the total test count? -2,137 tests compiled across all packages (4 skipped). 0 failures. +2,269 tests compiled across all packages (4 skipped), as of 13 Aug 2026. 0 failures. **Notable new tests:** Centralised job scheduler tests (3 — RegisterAll count, schedules, handler signatures), scheduled-cleanup handler tests (21 — NotifyUnpaidOneWeek/Month, TransitionDiscountCampaigns, CleanupExpiredVerificationCodes/RefreshTokens), GDPR export cache cleanup (4), stale login entry cleanup (4), rate limiter cleanup tests (6), rate limiter production behavior tests (6). Duplicate completion guard (idempotent second `"completed"` call), daily stamp cap (two completions same day → 1 stamp), invalid status transitions (no-show→completed rejected with 400), sequential edit (two edits in sequence), timezone independence (UTC in, UTC out — no shift), past-booking no-show guard (past confirmed booking cancelled → `client_cancelled`, not `no_show`). New closing_time tests (3), content-type middleware tests (2), clock package tests, expanded admin reserve overlap tests, expanded gift card buy flow tests with VAT, and full admin reservation cancel coverage (12 tests covering walkin + callin + isolation + no-op + idempotency + response format parity). diff --git a/scripts/check-env-docs.py b/scripts/check-env-docs.py index e942703..e5017c6 100644 --- a/scripts/check-env-docs.py +++ b/scripts/check-env-docs.py @@ -7,21 +7,77 @@ import sys REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -def find_env_vars_in_code(): - """Find all os.Getenv() and import.meta.env.VITE_* calls in the codebase.""" - env_vars = set() +# Matches `const foo = "ENV_NAME"` (with optional explicit string type) on a +# single line, plus the same declarations inside a `const ( ... )` block. +CONST_SINGLE_RE = re.compile(r'const\s+(\w+)(?:\s+string)?\s*=\s*"([A-Z_][A-Z0-9_]*)"') +CONST_BLOCK_RE = re.compile(r'const\s*\(([^)]*)\)') +CONST_BLOCK_ITEM_RE = re.compile(r'(\w+)(?:\s+string)?\s*=\s*"([A-Z_][A-Z0-9_]*)"') - # Search backend Go files for os.Getenv("VAR_NAME") +# Matches os.Getenv / os.LookupEnv with either a string literal +# (os.Getenv("VAR")) or an identifier (os.Getenv(varName)) that may be a +# const holding the env var name (const-indirect reads). +GETENV_RE = re.compile( + r'os\.(?:Getenv|LookupEnv)\(\s*(?:"([A-Z_][A-Z0-9_]*)"|([A-Za-z_]\w*))\s*\)' +) + + +def go_const_map(): + """Return {package_name: {const_name: "ENV_NAME"}} from all backend Go files. + + Consts are package-scoped in Go: a const may be defined in one file and + referenced from os.Getenv in another file of the same package (e.g. + twoFAPepperEnv defined in twofa.go, read in twofa_prod.go), so collection + must be per package rather than per file. + """ + consts = {} # package -> {name -> value} go_dir = os.path.join(REPO_ROOT, 'backend') for root, dirs, files in os.walk(go_dir): dirs[:] = [d for d in dirs if d not in ('vendor', '.git', 'node_modules')] for f in files: - if f.endswith('.go'): - path = os.path.join(root, f) - with open(path) as fh: - for line in fh: - m = re.findall(r'os\.Getenv\(["\']([A-Z_][A-Z0-9_]*)["\']', line) - env_vars.update(m) + if not f.endswith('.go'): + continue + path = os.path.join(root, f) + with open(path) as fh: + content = fh.read() + pkg_match = re.search(r'^package\s+(\w+)', content, re.MULTILINE) + if not pkg_match: + continue + pkg = pkg_match.group(1) + pkg_consts = consts.setdefault(pkg, {}) + for name, value in CONST_SINGLE_RE.findall(content): + pkg_consts[name] = value + for block in CONST_BLOCK_RE.findall(content): + for name, value in CONST_BLOCK_ITEM_RE.findall(block): + pkg_consts[name] = value + return consts + + +def find_env_vars_in_code(): + """Find all env vars read in the codebase (os.Getenv/os.LookupEnv in backend + Go files, import.meta.env.VITE_* in frontend files).""" + env_vars = set() + consts = go_const_map() + + # Search backend Go files for os.Getenv / os.LookupEnv reads, including + # const-indirect reads (os.Getenv(constName) resolved via the const map). + go_dir = os.path.join(REPO_ROOT, 'backend') + for root, dirs, files in os.walk(go_dir): + dirs[:] = [d for d in dirs if d not in ('vendor', '.git', 'node_modules')] + for f in files: + if not f.endswith('.go'): + continue + path = os.path.join(root, f) + with open(path) as fh: + content = fh.read() + pkg_match = re.search(r'^package\s+(\w+)', content, re.MULTILINE) + pkg_consts = consts.get(pkg_match.group(1), {}) if pkg_match else {} + for literal, identifier in GETENV_RE.findall(content): + if literal: + env_vars.add(literal) + elif identifier in pkg_consts: + env_vars.add(pkg_consts[identifier]) + # Non-const identifiers (e.g. function params like getEnv(key)) + # cannot be resolved to a specific env var — skip them. # Search frontend files for import.meta.env.VITE_* / import.meta.env.* frontend_dir = os.path.join(REPO_ROOT, 'frontend')