fix: fresh-review round — 2FA deliverability, disable re-verification, GDPR batch scrub, dispute alerting, docs accuracy

Second fresh-eyes review pass (7 agents: goal, security, code-quality,
context-mining, webhooks+2FA, client+mock+sweep, refunds/giftcards/handlers).
Money-safety core verified sound (identical-body replay byte-lossless, clawback
gated on definitive proof, no double-charge window). This round fixes the
issues the fresh pass surfaced:

2FA:
- Setup now DELIVERS the code via the [2FA] server log in ALL modes (was:
  nothing in enforced mode -> production 2FA was an unbreakable dead-end and
  saved-card charges were permanently 403). Enforced mode still withholds the
  code from the API response; the log line is the fake delivery channel until
  email/SMS lands (P6).
- Disabling 2FA now requires a fresh verification code when enforcement is ON
  (previously ignored the code -> a password-only attacker could lift the gate).
  Shares the 5-attempt lockout and timing-safe compare. Dev bypass retained.
- REQUIRE_2FA parsing normalized (false/0/off/no, case-insensitive);
  startup warning extended to the empty-env/mock-client/enforced-2FA confusion.

GDPR:
- anonymize_user() SQL now scrubs two_factor_* columns + staff notes, so the
  idle-account batch cleanup (CleanupIdleAccounts) is erasure-clean, not just
  the user-initiated delete path.

Webhooks:
- dispute.created for an untracked Square payment now raises a
  critical_payment_log admin notification (chargeback the app can't reconcile
  is never silent). Reason strings truncated on rune boundaries (valid UTF-8).
  Stale at-most-once comment corrected; revertTillSaleGiftCardFunding
  duplication noted.

Sweep/mock parity:
- Mock CreatePayment dedup is now source-aware (IDEMPOTENCY_KEY_REUSED on
  source mismatch) matching ReplayPaymentByKey and real Square.
- COMPLETED-but-never-polled terminal till-sale checkouts are now recorded by
  the sweep (previously only booking checkouts were; till charges were
  invisible until the 24h blind-fail WARN).
- Legacy snapshot-less minimal-body replay, SQUARE_LOCATION_ID drift, and
  in-memory-mock-restart limitations documented.

Docs:
- Webhook path corrected everywhere (/webhooks/square, not /api/webhooks/square
  - a deployer following the old path would 404 and silently lose all webhook
  reconciliation).
- 2FA enforcement semantics + code-delivery mechanism documented accurately
  (fail-closed default; log-delivery channel; disable re-verification).
- README/User Manual note the 2FA requirement on online saved-card payments.

Tests: 2,151 (up from 2,142). Backend 26/27 packages green (crussell/db fails
only in this environment: local postgres doesn't offer scram-sha-256 for the
test role; package is byte-identical to HEAD and untouched here). Frontend
builds; svelte-check 0 errors.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent e9b0f0f2a7
commit 9bb812669e
22 changed files with 972 additions and 209 deletions
+10 -5
View File
@@ -42,11 +42,16 @@ SQUARE_LOCATION_ID=
SQUARE_TERMINAL_DEVICE_ID= SQUARE_TERMINAL_DEVICE_ID=
SQUARE_ENVIRONMENT=mock SQUARE_ENVIRONMENT=mock
# 2FA (PSD2 SCA stand-in) for online card payments. Enforcement is FAIL-CLOSED: # 2FA (PSD2 SCA stand-in) for online card payments. Enforcement is FAIL-CLOSED:
# ON unless REQUIRE_2FA=false OR SQUARE_ENVIRONMENT explicitly equals one of # ON unless REQUIRE_2FA explicitly disables it (false/0/off/no, case-insensitive)
# mock/dev/development/test. Empty or unknown SQUARE_ENVIRONMENT values are # OR SQUARE_ENVIRONMENT explicitly equals one of mock/dev/development/test.
# treated as production-enforced (a mistyped env var can never silently disarm # Empty or unknown SQUARE_ENVIRONMENT values are treated as production-enforced
# the gate; the backend logs a startup warning in that case). Set # (a mistyped env var can never silently disarm the gate; the backend logs a
# REQUIRE_2FA=false only in controlled environments. # startup warning in that case). Set REQUIRE_2FA=false only in controlled
# environments.
# Code delivery: there is NO email/SMS transport yet. The verification code is
# delivered via the server log (a [2FA]-prefixed line). In enforced/production
# 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 REQUIRE_2FA=true
# Webhook config MUST exactly match the Square Dashboard webhook subscription # Webhook config MUST exactly match the Square Dashboard webhook subscription
# (URL + signature key). If SQUARE_WEBHOOK_NOTIFICATION_URL is left unset it # (URL + signature key). If SQUARE_WEBHOOK_NOTIFICATION_URL is left unset it
+5 -1
View File
@@ -68,6 +68,10 @@ docker compose up --build -d
`SQUARE_WEBHOOK_NOTIFICATION_URL` and `SQUARE_WEBHOOK_SIGNATURE_KEY` in `.env` must exactly match the webhook subscription configured in the Square Dashboard. An unset URL defaults to `http://localhost:8080/webhooks/square`, which is fail-closed (503 without the signing key, 403 on missing/bad signature). If you don't need webhooks, leave both empty — the handler still rejects cleanly. `SQUARE_WEBHOOK_NOTIFICATION_URL` and `SQUARE_WEBHOOK_SIGNATURE_KEY` in `.env` must exactly match the webhook subscription configured in the Square Dashboard. An unset URL defaults to `http://localhost:8080/webhooks/square`, which is fail-closed (503 without the signing key, 403 on missing/bad signature). If you don't need webhooks, leave both empty — the handler still rejects cleanly.
### Two-factor authentication (2FA)
`REQUIRE_2FA` gates saved-card online payments (PSD2 SCA stand-in) and is **fail-closed**: enforcement is ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced. Disable it with `REQUIRE_2FA=false` or an explicit mock env. The 6-digit code is delivered via the server log (`[2FA]` prefix; the operator relays it) until email/SMS lands.
### Local dev (tmux) ### Local dev (tmux)
```bash ```bash
@@ -85,7 +89,7 @@ Default logins (password: `password`):
```bash ```bash
cd backend && go build -o bin/backend ./main.go cd backend && go build -o bin/backend ./main.go
cd frontend && npm ci && npm run build cd frontend && npm ci && npm run build
cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,133 tests passed (4 skipped, ~2min) cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,142 tests passed (4 skipped, ~2min)
cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min) cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min)
cd backend && go test -tags "test,dev" -count=10 -parallel 8 ./... # thorough verification (~2-3min) cd backend && go test -tags "test,dev" -count=10 -parallel 8 ./... # thorough verification (~2-3min)
``` ```
+6 -1
View File
@@ -4187,7 +4187,12 @@ func TestSavedCardPayment_ClientKey_SameKeyRetry_Dedups(t *testing.T) {
PaymentType: "full", PaymentType: "full",
PaymentMethod: strPtr("saved_card"), PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID, UserSavedCardID: &cardID,
IdempotencyKey: "saved-card-uuid-0001", // Distinct from TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup's
// keys: both tests share the package-level singleton mock (SquareClient
// is set once in TestMain), and the mock now mirrors Square's body-aware
// dedup — reusing a retained key with a different source_id returns
// IDEMPOTENCY_KEY_REUSED (same as real Square).
IdempotencyKey: "saved-card-uuid-retry-0001",
} }
// First charge. // First charge.
+88 -8
View File
@@ -511,9 +511,30 @@ func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool {
// 5xx / ambiguous) leaves the row pending — the charge may still have completed // 5xx / ambiguous) leaves the row pending — the charge may still have completed
// at Square. The second return value is the Square payment id of the completed // at Square. The second return value is the Square payment id of the completed
// payment ("" otherwise), written back on a rescue. // payment ("" otherwise), written back on a rescue.
//
// ENV CONTRACT (finding 4): the sweep and the charge process MUST run with the
// SAME SQUARE_ENVIRONMENT and SQUARE_LOCATION_ID. The stored snapshot embeds
// the location_id used at charge time; if the env/location changes between a
// charge and its replay, the replayed wire body differs and a RETAINED key
// returns IDEMPOTENCY_KEY_REUSED — stranding every retained-key row pending
// (safe) until the 24h blind-fail. This is a single-location deployment; the
// contract is enforced by ops (same env for the sweeper and the API), NOT by a
// runtime equality check — deliberately comment-only.
func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (staleReconcileResult, string) { func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (staleReconcileResult, string) {
snapshot := r.SquareRequestSnapshot snapshot := r.SquareRequestSnapshot
// fallbackBody is true when the row has NO stored square_request_snapshot
// and the minimal body (key + source + amount) is rebuilt. The rebuilt body
// omits reference_id / customer_id / note / buyer_email_address that the
// original charge carried, so for a RETAINED key real Square compares the
// WHOLE body, sees the difference and returns IDEMPOTENCY_KEY_REUSED
// (stranding the row pending) while the dev mock — which compares only the
// source — returns the original payment (rescuing it). Both are money-safe;
// the divergence is dev-vs-prod OBSERVABILITY only, and pre-launch there
// are no prod legacy rows (every charge now stores its snapshot), so the
// fallback semantics are deliberately left unchanged.
fallbackBody := false
if len(bytes.TrimSpace(snapshot)) == 0 { if len(bytes.TrimSpace(snapshot)) == 0 {
fallbackBody = true
// Legacy row without a stored request snapshot — rebuild the minimal // Legacy row without a stored request snapshot — rebuild the minimal
// identical body (key + source + amount) exactly as the pre-snapshot // identical body (key + source + amount) exactly as the pre-snapshot
// replay did. Such rows can still be reconciled as long as the stored // replay did. Such rows can still be reconciled as long as the stored
@@ -530,6 +551,10 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
} }
snapshot = fallback snapshot = fallback
} }
// The replay repeats the stored request snapshot verbatim so Square's
// idempotency dedup returns the original payment for a retained key — which
// requires the sweep to share SQUARE_ENVIRONMENT/SQUARE_LOCATION_ID with the
// charge process (see the ENV CONTRACT above).
pr, err := SquareClient.ReplayPaymentByKey(ctx, snapshot) pr, err := SquareClient.ReplayPaymentByKey(ctx, snapshot)
if err != nil { if err != nil {
if errors.Is(err, square.ErrReplayKeyNotRetained) { if errors.Is(err, square.ErrReplayKeyNotRetained) {
@@ -537,7 +562,16 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
return staleReconcileDefinitivelyFailed, "" return staleReconcileDefinitivelyFailed, ""
} }
if square.ErrorCode(err) == "IDEMPOTENCY_KEY_REUSED" { if square.ErrorCode(err) == "IDEMPOTENCY_KEY_REUSED" {
log.Printf("CRITICAL: stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED — the stored square_source_id differs from the original charge's source (data bug); this is NOT proof the charge never happened — leaving pending — MANUAL RECONCILIATION REQUIRED", table) if fallbackBody {
// Snapshot-less fallback row: the minimal rebuilt body cannot be
// identical to the original charge, so real Square rejects the
// retained-key replay while the dev mock (source-aware only)
// rescues it. The stranded row would otherwise be invisible
// until the 24h blind-fail — surface it now (finding 2).
log.Printf("CRITICAL: stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED via the minimal snapshot-less fallback body (key=%s) — real Square compares the WHOLE request (reference_id/customer_id/note/buyer_email_address absent from the rebuilt body) and rejects the identical-key replay, stranding the row pending; the dev mock would rescue it — dev-vs-prod observability divergence, NOT proof the charge never happened — MANUAL RECONCILIATION REQUIRED", table, r.IdempotencyKey)
} else {
log.Printf("CRITICAL: stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED — the stored square_source_id differs from the original charge's source (data bug); this is NOT proof the charge never happened — leaving pending — MANUAL RECONCILIATION REQUIRED", table)
}
return staleReconcileLeavePending, "" return staleReconcileLeavePending, ""
} }
log.Printf("Stale pending %s reconcile by idempotency key hit an ambiguous error (%v) — leaving pending for a later sweep run", table, err) log.Printf("Stale pending %s reconcile by idempotency key hit an ambiguous error (%v) — leaving pending for a later sweep run", table, err)
@@ -731,7 +765,11 @@ const staleTerminalCheckoutAge = 1 * time.Hour
// payment_status enum has no 'cancelled' value, and 'failed' is the same // payment_status enum has no 'cancelled' value, and 'failed' is the same
// terminal state the stale-pending sweep uses, blocking the till // terminal state the stale-pending sweep uses, blocking the till
// pending-retry path); a checkout that completed during the cancel window // pending-retry path); a checkout that completed during the cancel window
// leaves the sale pending for the poll handler to record. // (or was already COMPLETED when first checked) is RECORDED by the sweep —
// the sale is marked 'completed' with the returned square_payment_id, the
// till mirror of the booking path's recordUntrackedTerminalPayment (a
// never-polled sale would otherwise stay invisible in till reporting until
// the 24h blind-fail).
// //
// Each row is checked at Square FIRST and only cancelled when the checkout is // Each row is checked at Square FIRST and only cancelled when the checkout is
// provably still waiting (ErrCheckoutPending): a COMPLETED checkout is never // provably still waiting (ErrCheckoutPending): a COMPLETED checkout is never
@@ -822,13 +860,15 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
case rErr == nil && recheck.Status == "COMPLETED": case rErr == nil && recheck.Status == "COMPLETED":
// The customer completed the payment during the cancel window. // The customer completed the payment during the cancel window.
// Record it if it was never polled/recorded — a COMPLETED // Record it if it was never polled/recorded — a COMPLETED
// checkout must not stay an untracked charge (H4). // checkout must not stay an untracked charge (H4). Booking
// checkouts record full payments rows; till-sale checkouts
// record the sale row (finding 3).
if r.Kind == "terminal_checkout" { if r.Kind == "terminal_checkout" {
if recordUntrackedTerminalPayment(ctx, r.CheckoutID, r.BookingID, recheck) { if recordUntrackedTerminalPayment(ctx, r.CheckoutID, r.BookingID, recheck) {
resolved++ resolved++
} }
} else { } else if recordUntrackedTillSalePayment(ctx, r.RowID, recheck) {
log.Printf("Terminal checkout %s completed during sweep cancel — leaving sale %s pending (poll handler records it)", r.CheckoutID, r.RowID) resolved++
} }
log.Printf("Cancelled stale terminal checkout %s (%s %s, pending >%s) but re-check shows COMPLETED — payment recorded by the sweep", r.CheckoutID, r.Kind, r.RowID, staleTerminalCheckoutAge) log.Printf("Cancelled stale terminal checkout %s (%s %s, pending >%s) but re-check shows COMPLETED — payment recorded by the sweep", r.CheckoutID, r.Kind, r.RowID, staleTerminalCheckoutAge)
case isTerminalCheckoutError(rErr) || errors.Is(rErr, square.ErrCheckoutPending): case isTerminalCheckoutError(rErr) || errors.Is(rErr, square.ErrCheckoutPending):
@@ -860,9 +900,11 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
if recordUntrackedTerminalPayment(ctx, r.CheckoutID, r.BookingID, pr) { if recordUntrackedTerminalPayment(ctx, r.CheckoutID, r.BookingID, pr) {
resolved++ resolved++
} }
} else { } else if recordUntrackedTillSalePayment(ctx, r.RowID, pr) {
// The till poll handler records it — leave the sale pending. // A never-polled COMPLETED till-sale checkout must not stay
log.Printf("Terminal checkout %s already COMPLETED at Square — leaving sale %s pending (poll handler records it)", r.CheckoutID, r.RowID) // pending until the 24h blind-fail (finding 3) — the charge is
// real, so the sale is recorded completed with the payment id.
resolved++
} }
case isTerminalCheckoutError(gErr): case isTerminalCheckoutError(gErr):
// The checkout is cancelled / cancel-requested / expired at Square. // The checkout is cancelled / cancel-requested / expired at Square.
@@ -1088,6 +1130,44 @@ func recordUntrackedTerminalPayment(ctx context.Context, checkoutID, bookingID s
return true return true
} }
// recordUntrackedTillSalePayment records a stale card-machine till sale whose
// checkout COMPLETED at Square but was never polled/recorded: the sale is
// marked 'completed' with the returned square_payment_id written back. A
// never-polled COMPLETED checkout would otherwise leave the sale pending until
// the 24h blind-fail — the charge is real but invisible in till reporting
// (money-safe: the gift card was funded pre-charge, a blind-fail never claws
// back, and failed rows reject retries — but the till reporting would be wrong,
// finding 3). There is no split/deposit concept for till sales — a single
// completed row, exactly the update the till poll handler (GetTillCheckoutStatus)
// performs. The WHERE status = 'pending' guard makes it safe against a
// concurrent poll: only one of them wins the update, the loser sees 0 rows and
// backs off. Returns true when the sale was updated; false when the checkout
// carries no Square payment id (the sale is left PENDING — never blind-failed
// — with a CRITICAL log for manual reconciliation) or the sale was already
// resolved by someone else.
func recordUntrackedTillSalePayment(ctx context.Context, saleID string, pr *square.PaymentResult) bool {
if pr == nil || pr.SquarePayID == "" {
log.Printf("CRITICAL: terminal checkout is COMPLETED at Square but carries no Square payment ID — cannot record till sale %s — leaving it PENDING — MANUAL RECONCILIATION REQUIRED", saleID)
return false
}
tag, err := db.Conn.Exec(ctx, `
UPDATE till_sales SET status = 'completed', square_payment_id = $1, updated_at = NOW()
WHERE id = $2 AND status = 'pending'
`, pr.SquarePayID, saleID)
if err != nil {
log.Printf("Failed to record untracked terminal till sale %s as completed: %v", saleID, err)
return false
}
if int(tag.RowsAffected()) == 0 {
// A concurrent poll (or a prior sweep run) already recorded the sale —
// nothing left for this run to do.
log.Printf("Terminal till sale %s was already resolved — skipping untracked terminal completion record", saleID)
return false
}
log.Printf("CRITICAL: recorded untracked terminal till-sale charge %s (sale %s) from a stale checkout — sale marked completed (never polled by the frontend)", pr.SquarePayID, saleID)
return true
}
// isTerminalCheckoutError reports whether a GetCheckout error proves the // isTerminalCheckoutError reports whether a GetCheckout error proves the
// checkout can never complete. Square's HTTP client returns ErrCheckoutPending // checkout can never complete. Square's HTTP client returns ErrCheckoutPending
// for a still-live checkout and surfaces a definitively CANCELED status as a // for a still-live checkout and surfaces a definitively CANCELED status as a
+108 -9
View File
@@ -237,6 +237,22 @@ func (c *completedTerminalClient) GetCheckout(ctx context.Context, checkoutID st
return c.SquareClient.GetCheckout(ctx, checkoutID) return c.SquareClient.GetCheckout(ctx, checkoutID)
} }
// noPaymentIDCompletedClient makes one checkout look COMPLETED at Square with
// NO Square payment id — the record-impossible condition the till-sale sweep
// must leave pending (never blind-fail) — while delegating everything else to
// the real mock.
type noPaymentIDCompletedClient struct {
square.SquareClient
checkoutID string
}
func (c *noPaymentIDCompletedClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
if checkoutID == c.checkoutID {
return &square.PaymentResult{Status: "COMPLETED", Amount: 5000}, nil
}
return c.SquareClient.GetCheckout(ctx, checkoutID)
}
// ============================================================================= // =============================================================================
// SweepStalePendingPayments — lost-response reconcile by idempotency key // SweepStalePendingPayments — lost-response reconcile by idempotency key
// ============================================================================= // =============================================================================
@@ -833,11 +849,14 @@ func TestSweepStaleTerminalCheckouts_CancelsStalePending(t *testing.T) {
} }
} }
// TestSweepStaleTerminalCheckouts_LeavesCompletedAlone locks the conservative // TestSweepStaleTerminalCheckouts_CompletedTillSale_Recorded locks the finding-3
// F4 rule: a checkout that has COMPLETED at Square is never cancelled — the // fix: a stale card-machine till sale whose checkout has COMPLETED at Square
// poll handler records it; cancelling a completed checkout would orphan the // (never polled by the frontend) is RECORDED by the sweep — the sale is marked
// charge. // 'completed' with the returned square_payment_id — instead of being left
func TestSweepStaleTerminalCheckouts_LeavesCompletedAlone(t *testing.T) { // pending for a poll handler that never runs (the 24h blind-fail would then
// hide the real charge from till reporting). A COMPLETED checkout is never
// cancelled.
func TestSweepStaleTerminalCheckouts_CompletedTillSale_Recorded(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
@@ -873,20 +892,100 @@ func TestSweepStaleTerminalCheckouts_LeavesCompletedAlone(t *testing.T) {
}) })
freshCtx := context.Background() freshCtx := context.Background()
// Drop any other stale terminal rows left by parallel tests so the count is
// deterministic.
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL AND id <> $1`, saleID); err != nil {
t.Fatalf("failed to clean leftover stale terminal sales: %v", err)
}
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS')`); err != nil {
t.Fatalf("failed to clean leftover stale booking terminal checkouts: %v", err)
}
n, err := SweepStaleTerminalCheckouts(freshCtx)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
if n != 1 {
t.Errorf("expected the COMPLETED till-sale checkout recorded by the sweep, got %d resolutions", n)
}
var status, sqPayID string
if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM till_sales WHERE id = $1", saleID).Scan(&status, &sqPayID); err != nil {
t.Fatalf("failed to query sale: %v", err)
}
if status != "completed" {
t.Errorf("expected a COMPLETED till-sale checkout's sale marked 'completed', got %q", status)
}
if sqPayID != "sqp_terminal_completed" {
t.Errorf("expected square_payment_id written back on the recorded till sale, got %q", sqPayID)
}
}
// TestSweepStaleTerminalCheckouts_CompletedTillSale_NoPaymentID_LeavesPending
// locks the finding-3 safety rule: a COMPLETED till-sale checkout whose payment
// result carries NO Square payment id cannot be recorded, so the sale is left
// PENDING with a CRITICAL log — never blind-failed (the charge may be real and
// the blind-fail would hide it from till reporting).
func TestSweepStaleTerminalCheckouts_CompletedTillSale_NoPaymentID_LeavesPending(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)
}
var saleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, square_checkout_id, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending', 'chk_completed_no_payment_id', $1, NOW() - INTERVAL '2 hours', NOW())
RETURNING id
`, adminID).Scan(&saleID)
if err != nil {
t.Fatalf("failed to seed stale terminal sale: %v", err)
}
origClient := SquareClient
SquareClient = &noPaymentIDCompletedClient{SquareClient: square.NewDevClient(), checkoutID: "chk_completed_no_payment_id"}
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 till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID)
})
freshCtx := context.Background()
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL AND id <> $1`, saleID); err != nil {
t.Fatalf("failed to clean leftover stale terminal sales: %v", err)
}
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS')`); err != nil {
t.Fatalf("failed to clean leftover stale booking terminal checkouts: %v", err)
}
n, err := SweepStaleTerminalCheckouts(freshCtx) n, err := SweepStaleTerminalCheckouts(freshCtx)
if err != nil { if err != nil {
t.Fatalf("sweep failed: %v", err) t.Fatalf("sweep failed: %v", err)
} }
if n != 0 { if n != 0 {
t.Errorf("expected a completed terminal checkout to be left alone, got %d cancellations", n) t.Errorf("expected a COMPLETED till-sale checkout with no Square payment id left unresolved, got %d resolutions", n)
} }
var status string var status, sqPayID string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM till_sales WHERE id = $1", saleID).Scan(&status); err != nil { if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM till_sales WHERE id = $1", saleID).Scan(&status, &sqPayID); err != nil {
t.Fatalf("failed to query sale: %v", err) t.Fatalf("failed to query sale: %v", err)
} }
if status != "pending" { if status != "pending" {
t.Errorf("expected completed terminal checkout's sale left 'pending' (poll handler records it), got %q", status) t.Errorf("expected the unrecordable COMPLETED till-sale checkout left 'pending' (never blind-failed), got %q", status)
}
if sqPayID != "" {
t.Errorf("expected no square_payment_id written on the unrecordable till sale, got %q", sqPayID)
} }
} }
+22 -9
View File
@@ -6,6 +6,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"crussell/db" "crussell/db"
"crussell/mw" "crussell/mw"
@@ -13,17 +14,29 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
// twoFactorEnforced reports whether 2FA is required for online card payments. // require2FADisabled reports whether REQUIRE_2FA explicitly disables 2FA
// It is fail-closed: enforcement is ON unless 2FA has been explicitly disabled // enforcement. The parse is case-insensitive and alias-tolerant (false/0/off/no),
// (REQUIRE_2FA=false) or SQUARE_ENVIRONMENT explicitly selects the dev/mock // so a value like "False", "OFF" or "off" never silently leaves the gate ON.
// stack (mock/dev/development/test). Empty or unknown SQUARE_ENVIRONMENT values // Any other value — including empty or unknown — keeps enforcement ON
// are treated as production-enforced, so a mistyped env var can never silently // (fail-closed).
// disarm the gate — main.go logs a startup warning for that misconfiguration. func require2FADisabled() bool {
func twoFactorEnforced() bool { switch strings.ToLower(strings.TrimSpace(os.Getenv("REQUIRE_2FA"))) {
if os.Getenv("REQUIRE_2FA") == "false" { case "false", "0", "off", "no":
return true
default:
return false return false
} }
return !IsExplicitDevOrMockEnv() }
// twoFactorEnforced reports whether 2FA is required for online card payments.
// It is fail-closed: enforcement is ON unless 2FA has been explicitly disabled
// (REQUIRE_2FA=false/0/off/no, case-insensitive — see require2FADisabled) or
// SQUARE_ENVIRONMENT explicitly selects the dev/mock stack
// (mock/dev/development/test). Empty or unknown SQUARE_ENVIRONMENT values are
// treated as production-enforced, so a mistyped env var can never silently
// disarm the gate — main.go logs a startup warning for that misconfiguration.
func twoFactorEnforced() bool {
return !require2FADisabled() && !IsExplicitDevOrMockEnv()
} }
// IsExplicitDevOrMockEnv reports whether SQUARE_ENVIRONMENT explicitly selects // IsExplicitDevOrMockEnv reports whether SQUARE_ENVIRONMENT explicitly selects
+14
View File
@@ -49,6 +49,20 @@ func TestTwoFactorEnforced(t *testing.T) {
{"require2fa_false_disables_prod", "false", "production", false}, {"require2fa_false_disables_prod", "false", "production", false},
{"require2fa_false_disables_sandbox", "false", "sandbox", false}, {"require2fa_false_disables_sandbox", "false", "sandbox", false},
{"require2fa_false_disables_unknown_env", "false", "staging", false}, {"require2fa_false_disables_unknown_env", "false", "staging", false},
// REQUIRE_2FA parsing is case-insensitive and alias-tolerant: any of
// false/0/off/no (any casing) disables, nothing else does.
{"require2fa_capitalized_false_disables", "False", "production", false},
{"require2fa_uppercase_false_disables", "FALSE", "production", false},
{"require2fa_zero_disables", "0", "production", false},
{"require2fa_off_disables", "off", "production", false},
{"require2fa_uppercase_off_disables", "OFF", "production", false},
{"require2fa_no_disables", "no", "production", false},
{"require2fa_true_stays_enforced", "true", "production", true},
{"require2fa_one_stays_enforced", "1", "production", true},
{"require2fa_yes_stays_enforced", "yes", "production", true},
{"require2fa_on_stays_enforced", "on", "production", true},
{"require2fa_unknown_stays_enforced", "enable", "production", true},
{"require2fa_off_but_dev_never_enforced", "off", "mock", false},
{"production_enforced", "", "production", true}, {"production_enforced", "", "production", true},
{"sandbox_enforced", "", "sandbox", true}, {"sandbox_enforced", "", "sandbox", true},
{"require2fa_true_prod_enforced", "true", "production", true}, {"require2fa_true_prod_enforced", "true", "production", true},
@@ -2948,6 +2948,79 @@ func TestCleanupIdleAccounts_NoBalance(t *testing.T) {
} }
} }
// TestCleanupIdleAccounts_Scrubs2FAAndNotes verifies the GDPR erasure gap
// closure for the BATCH path: accounts anonymized via anonymize_user(unnest(...))
// must also have their 2FA columns and staff notes scrubbed, exactly like the
// user-initiated DeleteAccountHandler path.
func TestCleanupIdleAccounts_Scrubs2FAAndNotes(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create a user idle past the 2yr threshold (no balance) with 2FA + a note
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, `
UPDATE users SET
last_login_at = NOW() - INTERVAL '3 years',
notes = 'Staff note with PII for idle-account cleanup',
two_factor_enabled = TRUE,
two_factor_method = 'email',
two_factor_pending_code_hash = 'batch-abc123',
two_factor_pending_code_expires = NOW() + INTERVAL '10 minutes'
WHERE id = $1
`, userID)
if err != nil {
t.Fatalf("failed to set last_login_at + 2FA + notes: %v", err)
}
// Run cleanup (batch path: SELECT anonymize_user(unnest($1::text[])))
_, err = CleanupIdleAccounts(ctx)
if err != nil {
t.Fatalf("CleanupIdleAccounts failed: %v", err)
}
// Verify the user was anonymized
var email string
err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email)
if err != nil {
t.Fatalf("failed to query user email: %v", err)
}
if !strings.Contains(email, "deleted+") || !strings.HasSuffix(email, "@deleted.invalid") {
t.Errorf("expected anonymized email 'deleted+%s@deleted.invalid', got '%s'", userID, email)
}
// Verify 2FA columns + notes were scrubbed by the batch anonymization
var notes interface{}
var enabled bool
var method, pendingHash, pendingExpires interface{}
err = tx.QueryRow(ctx, `
SELECT notes, two_factor_enabled, two_factor_method,
two_factor_pending_code_hash, two_factor_pending_code_expires
FROM users WHERE id = $1
`, userID).Scan(&notes, &enabled, &method, &pendingHash, &pendingExpires)
if err != nil {
t.Fatalf("failed to query user after cleanup: %v", err)
}
if notes != nil {
t.Errorf("expected users.notes to be NULL after idle-account cleanup, got %v", notes)
}
if enabled {
t.Error("expected two_factor_enabled to be FALSE after idle-account cleanup")
}
if method != nil {
t.Errorf("expected two_factor_method to be NULL after idle-account cleanup, got %v", method)
}
if pendingHash != nil {
t.Errorf("expected two_factor_pending_code_hash to be NULL after idle-account cleanup, got %v", pendingHash)
}
if pendingExpires != nil {
t.Errorf("expected two_factor_pending_code_expires to be NULL after idle-account cleanup, got %v", pendingExpires)
}
}
// TestCleanupIdleAccounts_SkipActive verifies that recently active accounts // TestCleanupIdleAccounts_SkipActive verifies that recently active accounts
// are NOT anonymized. // are NOT anonymized.
func TestCleanupIdleAccounts_SkipActive(t *testing.T) { func TestCleanupIdleAccounts_SkipActive(t *testing.T) {
+5 -27
View File
@@ -21,24 +21,6 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
// scrubAnonymizedUser2FA nulls the 2FA columns and staff notes that the SQL
// anonymize_user() function does not scrub: it predates the 2FA columns and
// intentionally preserves notes. A deleted user's live 2FA credential and any
// PII in staff notes must not survive erasure, so run this inside the same
// transaction as anonymize_user() to keep erasure atomic.
func scrubAnonymizedUser2FA(ctx context.Context, q db.Querier, userID string) error {
_, err := q.Exec(ctx, `
UPDATE users
SET two_factor_enabled = FALSE,
two_factor_method = NULL,
two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL,
notes = NULL
WHERE id = $1
`, userID)
return err
}
// DELETE /api/user/account // DELETE /api/user/account
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) { func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context()) userID, ok := mw.GetUserID(r.Context())
@@ -169,6 +151,11 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
} }
}() }()
// anonymize_user() is the single source of truth for erasure: it NULLs
// the 2FA columns and staff notes in the same statement that anonymizes
// the rest of the row, so every call site (this handler AND the
// idle-account batch cleanup in scheduling.CleanupIdleAccounts) is
// GDPR-clean without a separate Go-side scrub.
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID) _, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil { if err != nil {
log.Printf("Failed to anonymize user %s: %v", userID, err) log.Printf("Failed to anonymize user %s: %v", userID, err)
@@ -176,15 +163,6 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// GDPR erasure gap: anonymize_user() leaves the 2FA columns and staff
// notes on the row. Scrub them here, in the same transaction, so erasure
// is atomic with the anonymization.
if err := scrubAnonymizedUser2FA(ctx, tx, userID); err != nil {
log.Printf("Failed to scrub 2FA fields for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
log.Printf("Failed to commit transaction for user anonymization: %v", err) log.Printf("Failed to commit transaction for user anonymization: %v", err)
http.Error(w, "server error", http.StatusInternalServerError) http.Error(w, "server error", http.StatusInternalServerError)
+6 -10
View File
@@ -518,9 +518,9 @@ func TestAnonymizeUser_ClearsNotificationPrefs(t *testing.T) {
} }
// TestAnonymizeUser_Scrubs2FAAndNotes verifies the GDPR erasure gap closure: // TestAnonymizeUser_Scrubs2FAAndNotes verifies the GDPR erasure gap closure:
// anonymize_user() alone leaves the 2FA columns and staff notes on the row, so // anonymize_user() itself NULLs the 2FA columns and staff notes on the row, so
// the Go-side scrub (run in the same transaction by DeleteAccountHandler) must // every call site (user-initiated delete AND idle-account batch cleanup) is
// null them. // covered without a separate Go-side scrub.
func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) { func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
@@ -548,11 +548,6 @@ func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) {
t.Fatalf("anonymize_user failed: %v", err) t.Fatalf("anonymize_user failed: %v", err)
} }
// Mirror DeleteAccountHandler: scrub after anonymize_user in the same tx.
if err := scrubAnonymizedUser2FA(ctx, tx, userID); err != nil {
t.Fatalf("scrubAnonymizedUser2FA failed: %v", err)
}
var notes interface{} var notes interface{}
var enabled bool var enabled bool
var method, pendingHash, pendingExpires interface{} var method, pendingHash, pendingExpires interface{}
@@ -582,8 +577,9 @@ func TestAnonymizeUser_Scrubs2FAAndNotes(t *testing.T) {
} }
// TestDeleteAccount_Scrubs2FAAndNotes runs the full DeleteAccountHandler for a // TestDeleteAccount_Scrubs2FAAndNotes runs the full DeleteAccountHandler for a
// user with 2FA enabled and staff notes, asserting the handler's transaction // user with 2FA enabled and staff notes, asserting the end-to-end delete path
// scrubs both. Kept sequential (no t.Parallel) because the handler reads the // scrubs both (via anonymize_user(), which is now the single source of truth).
// Kept sequential (no t.Parallel) because the handler reads the
// process-global payments.SquareClient. // process-global payments.SquareClient.
func TestDeleteAccount_Scrubs2FAAndNotes(t *testing.T) { func TestDeleteAccount_Scrubs2FAAndNotes(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
+238 -93
View File
@@ -41,8 +41,8 @@ func generateTwoFACode() (string, error) {
} }
// hashTwoFACode returns the SHA-256 hex digest of a verification code. The DB // hashTwoFACode returns the SHA-256 hex digest of a verification code. The DB
// stores only the digest; the plaintext code is only ever logged in unenforced // stores only the digest; the plaintext code is delivered by logging it with a
// (dev) environments (see SetupTwoFAHandler). The digest is unsalted SHA-256 — // [2FA] prefix (see deliverTwoFACode). The digest is unsalted SHA-256 —
// peppering it via HMAC-SHA256 with a server-side 2FA_PEPPER secret is a future // peppering it via HMAC-SHA256 with a server-side 2FA_PEPPER secret is a future
// hardening step once such a secret is provisioned. // hardening step once such a secret is provisioned.
func hashTwoFACode(code string) string { func hashTwoFACode(code string) string {
@@ -119,6 +119,56 @@ func twoFAResetAttempts(userID string) {
twoFAAttemptMapMu.Unlock() twoFAAttemptMapMu.Unlock()
} }
// deliverTwoFACode generates a fresh verification code, persists only its
// SHA-256 hash plus the pending expiry (updating two_factor_method when method
// is non-empty), resets any prior lockout, and logs the plaintext code.
//
// The [2FA] log line is the delivery channel — the loose-fake stand-in for the
// not-yet-wired email/SMS transport (P6). The plaintext code is ALWAYS logged,
// enforced and unenforced alike: in enforced (production) environments the
// server log is the only way a code can reach the user, so an operator must
// relay it out-of-band. Do not gate this log line on the environment — without
// it, enforced-mode 2FA has no delivery path at all and every online saved-card
// charge stays 403. The API response still only returns the code when 2FA is
// unenforced (dev convenience). purpose labels the log line (e.g. "setup",
// "disable 2FA").
func deliverTwoFACode(r *http.Request, userID, method, purpose string) (string, error) {
code, err := generateTwoFACode()
if err != nil {
return "", err
}
expires := clock.Now().Add(twoFAPendingExpiry)
if method != "" {
_, err = db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_method = $2,
two_factor_pending_code_hash = $3,
two_factor_pending_code_expires = $4
WHERE id = $1
`, userID, method, hashTwoFACode(code), expires)
} else {
_, err = db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_pending_code_hash = $2,
two_factor_pending_code_expires = $3
WHERE id = $1
`, userID, hashTwoFACode(code), expires)
}
if err != nil {
return "", err
}
// A fresh code invalidates any prior lockout state.
twoFAResetAttempts(userID)
label := method
if label == "" {
label = purpose
}
log.Printf("[2FA] verification code for user %s (%s): %s", userID, label, code)
return code, nil
}
type TwoFAStatusResponse struct { type TwoFAStatusResponse struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Method *string `json:"method"` Method *string `json:"method"`
@@ -161,10 +211,13 @@ type TwoFASetupRequest struct {
// POST /api/user/2fa/setup // POST /api/user/2fa/setup
// Generates a verification code and stores only its SHA-256 hash plus a // Generates a verification code and stores only its SHA-256 hash plus a
// 10-minute expiry in the pending columns. The code itself is delivered by // 10-minute expiry in the pending columns. The code is delivered by logging it
// logging it with a [2FA] prefix — a loose fake for the not-yet-wired email/SMS // with a [2FA] prefix — the loose-fake stand-in for the not-yet-wired email/SMS
// transport. When 2FA is not enforced (dev), the code is also returned in the // transport (P6). The plaintext code is ALWAYS logged, enforced and unenforced
// response so the flow is testable without reading backend logs. // alike: in enforced (production) environments the server log is the only
// delivery channel, so an operator must relay the code to the user out-of-band.
// When 2FA is not enforced (dev), the code is also returned in the response so
// the flow is testable without reading backend logs.
func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) { func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context()) userID, ok := mw.GetUserID(r.Context())
if !ok { if !ok {
@@ -194,39 +247,16 @@ func SetupTwoFAHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
code, err := generateTwoFACode() // Deliver a fresh code via the shared setup mechanism: generate, persist
if err != nil { // only the hash + expiry, reset any prior lockout, and log the plaintext
log.Printf("failed to generate 2FA code for user %s: %v", userID, err) // code (the [2FA] log channel — see deliverTwoFACode).
http.Error(w, "server error", http.StatusInternalServerError) code, err := deliverTwoFACode(r, userID, req.Method, "setup")
return
}
expires := clock.Now().Add(twoFAPendingExpiry)
_, err = db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_method = $2,
two_factor_pending_code_hash = $3,
two_factor_pending_code_expires = $4
WHERE id = $1
`, userID, req.Method, hashTwoFACode(code), expires)
if err != nil { if err != nil {
log.Printf("failed to store 2FA pending code for user %s: %v", userID, err) log.Printf("failed to store 2FA pending code for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) http.Error(w, "server error", http.StatusInternalServerError)
return return
} }
// A fresh code invalidates any prior lockout state.
twoFAResetAttempts(userID)
if !twoFARequired() {
// Dev-only convenience: unenforced environments log the plaintext code
// (loose fake delivery). NEVER log it when enforced — a production
// misconfiguration must not leak verification codes to stdout.
log.Printf("[2FA] verification code for user %s (%s): %s", userID, req.Method, code)
} else {
log.Printf("[2FA] 2FA code generated for user %s (delivery channel: %s — NOT SENT, fake delivery)", userID, req.Method)
}
resp := map[string]any{"message": "Code sent"} resp := map[string]any{"message": "Code sent"}
if !twoFARequired() { if !twoFARequired() {
// Dev convenience: unenforced environments return the code so the // Dev convenience: unenforced environments return the code so the
@@ -242,6 +272,77 @@ type TwoFAVerifyRequest struct {
Code string `json:"code"` Code string `json:"code"`
} }
// twoFACodeCheckResult classifies checkTwoFACode's outcome so callers can map
// it to the correct HTTP status.
type twoFACodeCheckResult int
const (
twoFACodeOK twoFACodeCheckResult = iota
twoFACodeIncorrect
twoFACodeLockedOut
twoFACodeMissingOrExpired
)
// checkTwoFACode verifies the submitted code against the user's stored pending
// code under the per-user brute-force lockout, shared by VerifyTwoFAHandler and
// DisableTwoFAHandler. The caller must hold st.mu (from twoFAAttemptStateFor)
// so concurrent attempts from the same user cannot race the limit check. A
// correct code resets the attempt counter and returns twoFACodeOK. An incorrect
// code increments the counter and, on the 5th consecutive failure, invalidates
// the pending code (lockout). A missing or expired pending code returns
// twoFACodeMissingOrExpired. The returned error is non-nil only for DB failures
// (callers return 500); a lockout's pending-code invalidation failure is logged
// here and still reported as a lockout.
func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) {
if now := clock.Now(); now.Sub(st.lastAt) > twoFAAttemptWindow {
st.count = 0
st.lastAt = now
}
if st.count >= twoFAMaxAttempts {
return twoFACodeLockedOut, nil
}
var pendingHash sql.NullString
var pendingExpires sql.NullTime
err := db.Conn.QueryRow(r.Context(), `
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
FROM users
WHERE id = $1
`, userID).Scan(&pendingHash, &pendingExpires)
if err != nil {
return twoFACodeLockedOut, err
}
if !pendingHash.Valid || !pendingExpires.Valid || !pendingExpires.Time.After(clock.Now()) {
return twoFACodeMissingOrExpired, nil
}
// Constant-time compare (subtle) so a wrong code's match position cannot be
// inferred from response timing. Both digests are fixed-length hex.
if subtle.ConstantTimeCompare([]byte(hashTwoFACode(reqCode)), []byte(pendingHash.String)) != 1 {
st.count++
st.lastAt = clock.Now()
if st.count >= twoFAMaxAttempts {
// Lockout reached: destroy the pending code so a stolen digest
// cannot be replayed against a fresh guessing loop.
if _, err := db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
WHERE id = $1
`, userID); err != nil {
log.Printf("failed to invalidate 2FA pending code for user %s: %v", userID, err)
}
return twoFACodeLockedOut, nil
}
return twoFACodeIncorrect, nil
}
// Success: clear the attempt counter before the caller performs its action.
st.count = 0
st.lastAt = clock.Now()
twoFAResetAttempts(userID)
return twoFACodeOK, nil
}
// POST /api/user/2fa/verify // POST /api/user/2fa/verify
// Confirms the pending code (SHA-256, timing-safe, not expired) and flips // Confirms the pending code (SHA-256, timing-safe, not expired) and flips
// two_factor_enabled on. When 2FA is not enforced (dev) any code — including an // two_factor_enabled on. When 2FA is not enforced (dev) any code — including an
@@ -271,67 +372,31 @@ func VerifyTwoFAHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Enforced path — brute-force resistant. The attempt counter is per-user and // Enforced path — brute-force resistant (see checkTwoFACode): the per-user
// in-memory (no schema change); after 5 consecutive failures the pending // mutex serializes the critical section so concurrent attempts cannot race
// code is invalidated and further attempts get 429 until a new code is // the limit; after 5 consecutive failures the pending code is invalidated
// requested via setup. The per-user mutex serializes the critical section so // and further attempts get 429 until a new code is requested via setup.
// concurrent attempts cannot race the limit.
st := twoFAAttemptStateFor(userID) st := twoFAAttemptStateFor(userID)
st.mu.Lock() st.mu.Lock()
defer st.mu.Unlock() defer st.mu.Unlock()
if now := clock.Now(); now.Sub(st.lastAt) > twoFAAttemptWindow { result, err := checkTwoFACode(r, userID, st, req.Code)
st.count = 0
st.lastAt = now
}
if st.count >= twoFAMaxAttempts {
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
return
}
var pendingHash sql.NullString
var pendingExpires sql.NullTime
err := db.Conn.QueryRow(r.Context(), `
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
FROM users
WHERE id = $1
`, userID).Scan(&pendingHash, &pendingExpires)
if err != nil { if err != nil {
log.Printf("failed to fetch 2FA pending code for user %s: %v", userID, err) log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) http.Error(w, "server error", http.StatusInternalServerError)
return return
} }
if !pendingHash.Valid || !pendingExpires.Valid || !pendingExpires.Time.After(clock.Now()) { switch result {
case twoFACodeIncorrect:
http.Error(w, "incorrect verification code", http.StatusBadRequest)
return
case twoFACodeLockedOut:
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
return
case twoFACodeMissingOrExpired:
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest) http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
return return
} }
// Constant-time compare (subtle) so a wrong code's match position cannot be
// inferred from response timing. Both digests are fixed-length hex.
if subtle.ConstantTimeCompare([]byte(hashTwoFACode(req.Code)), []byte(pendingHash.String)) != 1 {
st.count++
st.lastAt = clock.Now()
if st.count >= twoFAMaxAttempts {
// Lockout reached: destroy the pending code so a stolen digest
// cannot be replayed against a fresh guessing loop.
if _, err := db.Conn.Exec(r.Context(), `
UPDATE users
SET two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
WHERE id = $1
`, userID); err != nil {
log.Printf("failed to invalidate 2FA pending code for user %s: %v", userID, err)
}
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
return
}
http.Error(w, "incorrect verification code", http.StatusBadRequest)
return
}
// Success: clear the attempt counter before enabling 2FA.
st.count = 0
st.lastAt = clock.Now()
twoFAResetAttempts(userID)
if err := enableTwoFA(r, userID); err != nil { if err := enableTwoFA(r, userID); err != nil {
log.Printf("failed to enable 2FA for user %s: %v", userID, err) log.Printf("failed to enable 2FA for user %s: %v", userID, err)
@@ -366,8 +431,15 @@ type TwoFADisableRequest struct {
// POST /api/user/2fa/disable // POST /api/user/2fa/disable
// Turns 2FA off and clears method + pending fields for the authenticated user. // Turns 2FA off and clears method + pending fields for the authenticated user.
// The code field is accepted but ignored — a documented loose-fake simplification //
// until the real SCA flow requires re-authentication to disable. // Disabling 2FA lifts the SCA stand-in gate on saved-card charges, so in
// enforced environments a verification code is required — a password-only
// attacker must not be able to disable the protection. A fresh code is generated
// and delivered via the [2FA] log channel when no valid pending code exists, and
// the submitted code is checked under the shared 5-attempt lockout (wrong code →
// 400, lockout → 429); only a correct code clears the flag. In unenforced (dev)
// environments the loose behavior is kept: no code required, so local dev is not
// blocked.
func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) { func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context()) userID, ok := mw.GetUserID(r.Context())
if !ok { if !ok {
@@ -375,10 +447,89 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Body is optional; decode leniently so an empty body disables cleanly. // Body is optional; decode leniently so an empty body still works in
// unenforced (dev) environments.
var req TwoFADisableRequest var req TwoFADisableRequest
_ = json.NewDecoder(r.Body).Decode(&req) _ = json.NewDecoder(r.Body).Decode(&req)
if !twoFARequired() {
// Dev bypass: no re-verification in unenforced environments.
if err := disableTwoFA(r, userID); err != nil {
log.Printf("failed to disable 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
return
}
// Enforced path. The per-user mutex serializes the whole critical section
// (fresh-code generation + code check) so concurrent requests cannot race
// the lockout counter.
st := twoFAAttemptStateFor(userID)
st.mu.Lock()
defer st.mu.Unlock()
// Reuse a valid pending code when one exists; otherwise generate + deliver
// a fresh one via the same [2FA] log channel as setup.
if err := ensurePendingTwoFACode(r, userID); err != nil {
log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
result, err := checkTwoFACode(r, userID, st, req.Code)
if err != nil {
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
switch result {
case twoFACodeIncorrect:
http.Error(w, "incorrect verification code", http.StatusBadRequest)
return
case twoFACodeLockedOut:
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
return
case twoFACodeMissingOrExpired:
// ensurePendingTwoFACode just guaranteed a valid pending code; defensive.
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
return
}
if err := disableTwoFA(r, userID); err != nil {
log.Printf("failed to disable 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// ensurePendingTwoFACode guarantees the user has a valid (unexpired) pending
// code to verify against, generating + delivering a fresh one via the same [2FA]
// log channel as setup when the stored code is missing or expired. A fresh code
// also resets any prior lockout, matching setup's recovery behavior. The caller
// must hold the user's attempt-state mutex.
func ensurePendingTwoFACode(r *http.Request, userID string) error {
var pendingHash sql.NullString
var pendingExpires sql.NullTime
err := db.Conn.QueryRow(r.Context(), `
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
FROM users
WHERE id = $1
`, userID).Scan(&pendingHash, &pendingExpires)
if err != nil {
return err
}
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) {
return nil
}
_, err = deliverTwoFACode(r, userID, "", "disable 2FA")
return err
}
// disableTwoFA clears two_factor_enabled and the method + pending code fields.
func disableTwoFA(r *http.Request, userID string) error {
_, err := db.Conn.Exec(r.Context(), ` _, err := db.Conn.Exec(r.Context(), `
UPDATE users UPDATE users
SET two_factor_enabled = false, SET two_factor_enabled = false,
@@ -387,11 +538,5 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
two_factor_pending_code_expires = NULL two_factor_pending_code_expires = NULL
WHERE id = $1 WHERE id = $1
`, userID) `, userID)
if err != nil { return err
log.Printf("failed to disable 2FA for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
} }
+114 -11
View File
@@ -241,22 +241,125 @@ func TestTwoFAVerify_Unenforced_AnyCodeSucceeds(t *testing.T) {
require.True(t, enabled) require.True(t, enabled)
} }
func TestTwoFADisable(t *testing.T) { // TestTwoFADisable_CorrectCode verifies that disabling in an enforced env
// requires the pending code: the correct code clears the flag, method and
// pending fields.
func TestTwoFADisable_CorrectCode(t *testing.T) {
twofaEnvEnforced(t) twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err) require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID) _, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err) require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", nil, userID) w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "123456"}, userID)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var enabled bool var enabled bool
var method sql.NullString var method sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled, two_factor_method FROM users WHERE id = $1", userID).Scan(&enabled, &method)) var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled, two_factor_method, two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&enabled, &method, &pendingHash))
require.False(t, enabled, "disable must clear two_factor_enabled") require.False(t, enabled, "disable must clear two_factor_enabled")
require.False(t, method.Valid, "disable must clear the method") require.False(t, method.Valid, "disable must clear the method")
require.False(t, pendingHash.Valid, "disable must clear the pending code")
}
// TestTwoFADisable_WrongCode verifies that a wrong code leaves 2FA enabled:
// the gate cannot be lifted with the password alone.
func TestTwoFADisable_WrongCode(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.True(t, enabled, "wrong code must not disable 2FA")
}
// TestTwoFADisable_NoPendingCode_GeneratesFreshCode verifies that disabling
// with no valid pending code delivers a fresh one via the [2FA] log channel and
// requires it before clearing the flag.
func TestTwoFADisable_NoPendingCode_GeneratesFreshCode(t *testing.T) {
twofaEnvEnforced(t)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
// No pending code exists: the handler must generate + log a fresh code and
// reject the (empty) submission.
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: ""}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "disable must log the fresh code as the delivery channel")
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.True(t, pendingHash.Valid, "disable must persist a fresh pending code when none existed")
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.True(t, enabled, "fresh code must be verified before 2FA can be disabled")
}
// TestTwoFADisable_LockoutAfterFiveFailedAttempts verifies that disable shares
// the 5-attempt lockout: 4 wrong codes 400, the 5th 429s and invalidates the
// pending code.
func TestTwoFADisable_LockoutAfterFiveFailedAttempts(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
for i := 0; i < 4; i++ {
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, "attempt %d", i+1)
}
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), "Too many attempts. Request a new code.")
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.False(t, pendingHash.Valid, "lockout must invalidate the pending code")
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.True(t, enabled, "locked-out user must still have 2FA enabled")
}
// TestTwoFADisable_Unenforced_NoCodeRequired verifies the dev bypass: in an
// unenforced env disabling works with no code at all.
func TestTwoFADisable_Unenforced_NoCodeRequired(t *testing.T) {
twofaEnvUnenforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
w := performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: ""}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.False(t, enabled, "dev bypass must disable without a code")
} }
func TestTwoFA_Unauthenticated(t *testing.T) { func TestTwoFA_Unauthenticated(t *testing.T) {
@@ -335,25 +438,25 @@ func TestTwoFASetup_Enforced_NoCodeInResponse(t *testing.T) {
require.False(t, hasCode, "enforced setup must NOT return the code in the response") require.False(t, hasCode, "enforced setup must NOT return the code in the response")
} }
func TestTwoFASetup_CodeLoggedOnlyWhenUnenforced(t *testing.T) { func TestTwoFASetup_CodeAlwaysLoggedAsDeliveryChannel(t *testing.T) {
// Capture the standard logger so we can assert on what setup logs. // Capture the standard logger so we can assert on what setup logs.
var buf bytes.Buffer var buf bytes.Buffer
log.SetOutput(&buf) log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) }) t.Cleanup(func() { log.SetOutput(os.Stderr) })
// Enforced: the [2FA] log line must carry the delivery note but never the // Enforced (production): the plaintext code MUST be logged — the [2FA] log
// plaintext code — a production misconfig must not leak codes to stdout. // line is the only delivery channel until email/SMS lands, and an operator
// relays it to the user out-of-band. Without it, enforced-mode 2FA is a
// dead-end (every online saved-card charge stays 403).
twofaEnvEnforced(t) twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err) require.NoError(t, err)
w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID) w := performUser2FARequest(t, SetupTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/setup", TwoFASetupRequest{Method: "email"}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String()) require.Equal(t, http.StatusOK, w.Code, w.Body.String())
out := buf.String() require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "enforced setup must log the plaintext code as the delivery channel")
require.Contains(t, out, "NOT SENT, fake delivery")
require.NotContains(t, out, "verification code for user", "enforced setup must NOT log the plaintext code")
// Unenforced (dev): the plaintext code IS logged for the loose-fake flow. // Unenforced (dev): the plaintext code is also logged for the loose-fake flow.
buf.Reset() buf.Reset()
twofaEnvUnenforced(t) twofaEnvUnenforced(t)
ctx2, tx2 := testutils.SetupTestTx(t) ctx2, tx2 := testutils.SetupTestTx(t)
+35 -9
View File
@@ -32,7 +32,9 @@ type SquareWebhookEvent struct {
// event IDs. It is a FAST-PATH cache only: the persistent source of truth is // event IDs. It is a FAST-PATH cache only: the persistent source of truth is
// the square_webhook_events table (see HandleSquareWebhook). It lets a replayed // the square_webhook_events table (see HandleSquareWebhook). It lets a replayed
// delivery be dropped without a DB round-trip, but a restart clears it — the DB // delivery be dropped without a DB round-trip, but a restart clears it — the DB
// row is what guarantees at-most-once processing across restarts. // row is what keeps delivery at-least-once across restarts (a replay after a
// crash is re-dispatched and absorbed by the idempotent handlers, per the
// dispatch-first ordering in HandleSquareWebhook).
type squareWebhookDedup struct { type squareWebhookDedup struct {
mu sync.Mutex mu sync.Mutex
seen map[string]struct{} seen map[string]struct{}
@@ -598,8 +600,13 @@ func clawbackOneTillSale(saleID, itemType string, itemID sql.NullString, totalAm
// whose charge definitively failed, in the SAME transaction as the failed mark // whose charge definitively failed, in the SAME transaction as the failed mark
// (claim-first): a created card is deleted (with its purchase transaction) and // (claim-first): a created card is deleted (with its purchase transaction) and
// any immediate redeem-to-account credit reversed; a topped-up card has the // any immediate redeem-to-account credit reversed; a topped-up card has the
// amount subtracted back out and its top-up transaction removed. SQL mirrors // amount subtracted back out and its top-up transaction removed.
// handlers/payments/till.go revertGiftCardFunding. //
// This is a byte-for-byte copy of revertGiftCardFunding in
// handlers/payments/till.go (the webhook path cannot reuse the till handler's
// signature), and the two MUST be kept in sync: a fix or schema change applied
// to only one silently diverges the sweep's clawback from the webhook's. Keep
// the SQL and the CRITICAL log lines identical in both.
func revertTillSaleGiftCardFunding(action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error { func revertTillSaleGiftCardFunding(action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error {
tx, err := db.Conn.Begin(context.Background()) tx, err := db.Conn.Begin(context.Background())
if err != nil { if err != nil {
@@ -732,18 +739,29 @@ func handleRefundUpdated(data json.RawMessage) error {
// VARCHAR(192) column width. An over-long reason would fail the disputes // VARCHAR(192) column width. An over-long reason would fail the disputes
// INSERT; the handler treats that as a dispatch error (no dedup row, 5xx), so // INSERT; the handler treats that as a dispatch error (no dedup row, 5xx), so
// Square would retry forever — truncating lets the event succeed instead. // Square would retry forever — truncating lets the event succeed instead.
// VARCHAR(192) counts CHARACTERS, not bytes, so truncation must slice on a rune
// boundary: byte-slicing (reason[:192]) can split a multi-byte UTF-8 rune and
// store invalid UTF-8 (which Postgres rejects), failing the INSERT the same way
// an over-long reason would. Slicing the []rune form keeps the stored reason a
// valid, at-most-192-character UTF-8 string.
func truncateDisputeReason(reason string) string { func truncateDisputeReason(reason string) string {
if len(reason) > 192 { runes := []rune(reason)
return reason[:192] if len(runes) > 192 {
return string(runes[:192])
} }
return reason return reason
} }
// handleDisputeCreated records a newly opened dispute: inserts the disputes row // handleDisputeCreated records a newly opened dispute: inserts the disputes row
// and surfaces a critical_payment_log admin notification so the owner sees the // and surfaces a critical_payment_log admin notification so the owner sees the
// chargeback in-app. Idempotent via ON CONFLICT (square_dispute_id) DO NOTHING // chargeback in-app. A disputed Square payment with NO local payments row (a
// plus the event_id dedup. A non-nil error means dispatch failed (no dedup row // Dashboard-initiated charge, a mismatched Square id, or a deleted/erased row)
// committed — Square retries). // cannot be reconciled to a booking — no disputes row is written — but the
// admin notification is STILL raised with booking_id NULL, because there is no
// sweep fallback for disputes and a chargeback the app cannot see is a silent
// money-loss path the owner must always be told about. Idempotent via
// ON CONFLICT (square_dispute_id) DO NOTHING plus the event_id dedup. A non-nil
// error means dispatch failed (no dedup row committed — Square retries).
func handleDisputeCreated(data json.RawMessage) error { func handleDisputeCreated(data json.RawMessage) error {
var env squareWebhookData var env squareWebhookData
if err := json.Unmarshal(data, &env); err != nil { if err := json.Unmarshal(data, &env); err != nil {
@@ -761,7 +779,15 @@ func handleDisputeCreated(data json.RawMessage) error {
} }
paymentID, bookingID, paymentFound := findPaymentBySquareID(squarePaymentID) paymentID, bookingID, paymentFound := findPaymentBySquareID(squarePaymentID)
if !paymentFound { if !paymentFound {
log.Printf("[SQUARE-WEBHOOK] dispute.created: no local payment for square payment %q — dispute %s not recorded", squarePaymentID, dispute.ID) // Untracked chargeback: no local payments row for this Square charge
// (Dashboard-initiated, mismatched Square payment id, or a deleted/erased
// row). There is NO sweep fallback for disputes — this notification is
// the only in-app trace the owner gets that Square is clawing back funds,
// so it must never be skipped. booking_id stays NULL; the helper's dedup
// guard keeps ONE unacknowledged row until the owner acts on it. Still
// return nil so the dedup row commits and Square's retry is acknowledged.
log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s created for square payment %q with NO local payment row — chargeback cannot be reconciled in-app — admin notified (booking_id NULL)", dispute.ID, squarePaymentID)
insertCriticalPaymentNotification("")
return nil return nil
} }
amount := squareMoneyToAmount(dispute.AmountMoney) amount := squareMoneyToAmount(dispute.AmountMoney)
@@ -9,6 +9,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"unicode/utf8"
"crussell/db" "crussell/db"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
@@ -315,6 +316,15 @@ func TestWebhook_DisputeCreated_InsertsDisputeRow(t *testing.T) {
} }
func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) { func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) {
// insertCriticalPaymentNotification dedups on unacknowledged rows per
// (reason, booking_id), so a NULL-booking notification left unacknowledged
// by an earlier test would mask this test's assertion. Acknowledge any
// stragglers first.
if _, err := db.Conn.Exec(context.Background(),
"UPDATE admin_notifications SET acknowledged_at = NOW() WHERE reason = 'critical_payment_log' AND acknowledged_at IS NULL"); err != nil {
t.Fatalf("failed to acknowledge prior critical notifications: %v", err)
}
event := SquareWebhookEvent{ event := SquareWebhookEvent{
Type: "dispute.created", Type: "dispute.created",
EventID: "evt_dispute_orphan_1", EventID: "evt_dispute_orphan_1",
@@ -336,6 +346,7 @@ func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) {
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
} }
// No local payment to reconcile against — no disputes row can be written.
var n int var n int
if err := db.Conn.QueryRow(context.Background(), if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM disputes WHERE square_dispute_id = 'dts_orphan_1'").Scan(&n); err != nil { "SELECT COUNT(*) FROM disputes WHERE square_dispute_id = 'dts_orphan_1'").Scan(&n); err != nil {
@@ -344,6 +355,23 @@ func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) {
if n != 0 { if n != 0 {
t.Errorf("expected no disputes row for an unknown square payment, got %d", n) t.Errorf("expected no disputes row for an unknown square payment, got %d", n)
} }
// ...but the chargeback MUST still surface in-app: a dispute on a payment
// with no local row is exactly the silent money-loss path this guards (no
// sweep fallback, no reconciliable booking). One unacknowledged
// NULL-booking critical notification must exist.
var unack int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id IS NULL AND acknowledged_at IS NULL").Scan(&unack); err != nil {
t.Fatalf("failed to count unacknowledged critical notifications: %v", err)
}
if unack != 1 {
t.Errorf("expected exactly 1 unacknowledged NULL-booking critical_payment_log notification, got %d", unack)
}
// The dedup row still commits: the handler returned nil, so Square's retry
// is acknowledged 200 rather than re-dispatched forever.
if got := countWebhookEvents(t, event.EventID); got != 1 {
t.Errorf("expected 1 dedup row for the untracked dispute, got %d", got)
}
} }
func TestWebhook_DisputeCreated_LongReason_Truncated(t *testing.T) { func TestWebhook_DisputeCreated_LongReason_Truncated(t *testing.T) {
@@ -390,6 +418,52 @@ func TestWebhook_DisputeCreated_LongReason_Truncated(t *testing.T) {
} }
} }
// TestWebhook_DisputeCreated_Utf8Reason_StoredValid delivers a dispute whose
// reason is long enough that the old byte-truncation (reason[:192]) would have
// split a 3-byte rune and stored invalid UTF-8 — which Postgres rejects,
// failing the INSERT and making Square retry forever. Rune-safe truncation must
// store a valid, at-most-192-character string.
func TestWebhook_DisputeCreated_Utf8Reason_StoredValid(t *testing.T) {
const squarePaymentID = "sqp_dispute_utf8"
_ = createWebhookTestPayment(t, squarePaymentID, "completed")
// 300 three-byte runes = 900 bytes, far past VARCHAR(192).
reason := strings.Repeat("界", 300)
event := SquareWebhookEvent{
Type: "dispute.created",
EventID: "evt_dispute_utf8_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_utf8_1",
"object": {
"dispute": {
"id": "dts_utf8_1",
"state": "UNDER_REVIEW",
"amount_money": {"amount": 1234, "currency": "GBP"},
"reason": "` + reason + `",
"disputed_payment": {"payment_id": "` + squarePaymentID + `"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var stored string
if err := db.Conn.QueryRow(context.Background(),
"SELECT reason FROM disputes WHERE square_dispute_id = 'dts_utf8_1'").Scan(&stored); err != nil {
t.Fatalf("expected a disputes row to be inserted, got: %v", err)
}
if !utf8.ValidString(stored) {
t.Errorf("expected stored reason to be valid UTF-8, got %q", stored)
}
if r := []rune(stored); len(r) != 192 {
t.Errorf("expected stored reason to be exactly 192 characters, got %d", len(r))
}
}
// ============================================================================= // =============================================================================
// Dispute handling — dispute.state.updated // Dispute handling — dispute.state.updated
// ============================================================================= // =============================================================================
@@ -16,6 +16,7 @@ import (
"os" "os"
"strings" "strings"
"testing" "testing"
"unicode/utf8"
"crussell/db" "crussell/db"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -101,6 +102,37 @@ func TestVerifySquareSignature_TamperedBody(t *testing.T) {
} }
} }
func TestTruncateDisputeReason_RuneSafe(t *testing.T) {
// 191 ASCII bytes + a 4-byte emoji: byte-slicing at 192 would split the
// emoji into an incomplete rune (invalid UTF-8). Rune-safe truncation must
// keep the whole emoji and stay valid UTF-8.
edge := strings.Repeat("a", 191) + "\U0001F600"
got := truncateDisputeReason(edge)
if !utf8.ValidString(got) {
t.Errorf("expected valid UTF-8 after truncation, got %q", got)
}
if !strings.HasSuffix(got, "\U0001F600") {
t.Errorf("expected the 4-byte rune to be preserved intact, got %q", got)
}
if len(got) != 195 { // 191 ASCII + 4-byte emoji
t.Errorf("expected 195 bytes (191 ASCII + 4-byte emoji), got %d", len(got))
}
// An over-long multi-byte reason truncates to exactly 192 runes.
got = truncateDisputeReason(strings.Repeat("界", 300))
if r := []rune(got); len(r) != 192 {
t.Errorf("expected exactly 192 runes after truncation, got %d", len(r))
}
if !utf8.ValidString(got) {
t.Errorf("expected valid UTF-8 after truncation, got %q", got)
}
// Short and ASCII reasons pass through untouched.
if got := truncateDisputeReason("NO_KNOWLEDGE"); got != "NO_KNOWLEDGE" {
t.Errorf("expected short reason unchanged, got %q", got)
}
}
// ============================================================================= // =============================================================================
// Integration tests — HandleSquareWebhook // Integration tests — HandleSquareWebhook
// ============================================================================= // =============================================================================
+47 -7
View File
@@ -2,6 +2,24 @@
package square package square
// KNOWN LIMITATION — THIS MOCK IS IN-MEMORY ONLY. Every ledger map below
// (payments, paymentByKey, paymentSource, cards, cardByToken, checkouts,
// completed, refunds, refundByKey, customers) lives for the lifetime of the
// process and is reset on ANY dev-server restart. There is intentionally NO
// persistence — this is a dev mock, not a store.
//
// Money-state consequence: a keyed pending row that is replayed AFTER a
// restart looks like an UNKNOWN idempotency key to the fresh mock, so the
// replay takes the unknown-key path — a spent/expired cnon: nonce is rejected
// (ErrReplayKeyNotRetained → the sweep DEFINITIVELY fails the row, and a till
// sale's funded gift card is clawed back) where prod would still hold the
// ORIGINAL payment under the retained key and return it. A test that
// "simulates a restart" with a fresh MockClient is therefore exercising the
// prod UNKNOWN-KEY case, NOT the prod retained-key case — do not read such a
// test as evidence of how prod treats a retained key after a restart. If a
// test needs retained-key behaviour, it must re-seed the payment under the key
// into the same mock instance (see TestSweepStalePendingPayments_KeyedLostResponse_CompletedRescued).
import ( import (
"context" "context"
"crussell/clock" "crussell/clock"
@@ -148,6 +166,23 @@ func detectCardInfo(sourceID string) (brand, last4 string) {
} }
} }
// keyReuseError is Square's documented IDEMPOTENCY_KEY_REUSED rejection: an
// idempotency key reused with a DIFFERENT request body (real Square compares
// the WHOLE body; the mock checks the source_id, the only body field that
// legitimately varies between same-intent retries). The structured code lets
// ErrorCode(err) read it, and the sweep treats it as ambiguous — a data bug,
// NOT proof the charge never happened. Shared by CreatePayment's dedup and
// ReplayPaymentByKey so both paths return the byte-identical error the real
// API would.
func keyReuseError(key string) error {
return &squareAPIError{
Code: "IDEMPOTENCY_KEY_REUSED",
Detail: "idempotency key was reused with a different request body",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: idempotency key %s reused with a different source_id", key),
}
}
func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) { func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
if m.ShouldFail { if m.ShouldFail {
return nil, fmt.Errorf("mock: payment declined (simulated failure)") return nil, fmt.Errorf("mock: payment declined (simulated failure)")
@@ -188,9 +223,19 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
// Real Square dedups on idempotency key: a retry with the same key returns // Real Square dedups on idempotency key: a retry with the same key returns
// the original payment rather than creating a second charge. The mock // the original payment rather than creating a second charge. The mock
// mirrors this so dev/testing behaves like production (also why the tip // mirrors this so dev/testing behaves like production (also why the tip
// retry regression test can rely on the mock). // retry regression test can rely on the mock). Like real Square, the dedup
// is BODY-AWARE: a retained key reused with a DIFFERENT source_id is
// rejected with IDEMPOTENCY_KEY_REUSED (the same error ReplayPaymentByKey
// returns for a source mismatch), never silently satisfied — so the
// gift-card same-key retry (which refreshes square_source_id with a fresh
// cnon on pending-reuse) surfaces the real prod rejection in dev instead of
// succeeding where prod would strand the row pending for the sweep.
if req.IdempotencyKey != "" { if req.IdempotencyKey != "" {
if existing, ok := m.paymentByKey[req.IdempotencyKey]; ok { if existing, ok := m.paymentByKey[req.IdempotencyKey]; ok {
if storedSource, hasSource := m.paymentSource[req.IdempotencyKey]; hasSource && storedSource != "" && storedSource != req.SourceID {
log.Printf("[SQUARE-MOCK] CreatePayment IDEMPOTENCY_KEY_REUSED: key=%s reused with a different source (%s vs %s)", req.IdempotencyKey, tokenPrefix(req.SourceID), tokenPrefix(storedSource))
return nil, keyReuseError(req.IdempotencyKey)
}
log.Printf("[SQUARE-MOCK] CreatePayment dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID) log.Printf("[SQUARE-MOCK] CreatePayment dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID)
return existing, nil return existing, nil
} }
@@ -438,12 +483,7 @@ func (m *MockClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte
// Same key, different body — Square's documented IDEMPOTENCY_KEY_REUSED // Same key, different body — Square's documented IDEMPOTENCY_KEY_REUSED
// rejection. A data bug (the stored source differs from the original // rejection. A data bug (the stored source differs from the original
// charge), NOT proof the charge never happened. // charge), NOT proof the charge never happened.
return nil, &squareAPIError{ return nil, keyReuseError(req.IdempotencyKey)
Code: "IDEMPOTENCY_KEY_REUSED",
Detail: "idempotency key was reused with a different request body",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: idempotency key %s reused with a different source_id", req.IdempotencyKey),
}
} }
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID) log.Printf("[SQUARE-MOCK] ReplayPaymentByKey dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID)
return existing, nil return existing, nil
@@ -558,6 +558,62 @@ func TestDevClient_CreatePayment_DedupsOnIdempotencyKey(t *testing.T) {
assert.Equal(t, first.ID, byKey.ID) assert.Equal(t, first.ID, byKey.ID)
} }
// TestDevClient_CreatePayment_DedupSourceAware locks the body-aware dedup
// parity fix: real Square compares the WHOLE request body on an
// idempotency-key hit, so a same-key retry with a DIFFERENT source must return
// IDEMPOTENCY_KEY_REUSED (never the original payment) — exactly like
// ReplayPaymentByKey and the real API. Only an IDENTICAL body (matching
// source) returns the original payment. Before this fix the mock was
// body-blind and the gift-card same-key retry (which refreshes square_source_id
// with a fresh cnon on pending-reuse) succeeded in dev where prod returns
// IDEMPOTENCY_KEY_REUSED and leaves the row pending for the sweep.
func TestDevClient_CreatePayment_DedupSourceAware(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
const key = "dedup-source-aware-key"
first, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:original-source",
IdempotencyKey: key,
})
require.NoError(t, err)
// Same key + SAME source → the original payment (Square's idempotency
// guarantee), never a second charge.
same, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:original-source",
IdempotencyKey: key,
})
require.NoError(t, err)
assert.Equal(t, first.ID, same.ID, "same-key + same-source retry must return the original payment")
// Same key + DIFFERENT source (the gift-card pending-reuse refresh) →
// Square's structured IDEMPOTENCY_KEY_REUSED rejection.
_, err = client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:fresh-refreshed-nonce",
IdempotencyKey: key,
})
require.Error(t, err)
assert.Equal(t, "IDEMPOTENCY_KEY_REUSED", ErrorCode(err), "same-key different-source retry must carry IDEMPOTENCY_KEY_REUSED")
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
assert.False(t, errors.Is(err, ErrReplayKeyNotRetained), "IDEMPOTENCY_KEY_REUSED is NOT proof the charge never happened")
// The original payment must still be returned for the IDENTICAL body.
got, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:original-source",
IdempotencyKey: key,
})
require.NoError(t, err)
assert.Equal(t, first.ID, got.ID, "the identical-body retry must keep returning the original payment after a rejected mismatch")
}
func TestDevClient_RefundPayment_DedupsOnIdempotencyKey(t *testing.T) { func TestDevClient_RefundPayment_DedupsOnIdempotencyKey(t *testing.T) {
// Real Square dedups on idempotency key: a same-key retry returns the // Real Square dedups on idempotency key: a same-key retry returns the
// original refund. The mock must mirror this or the pending-refund resume // original refund. The mock must mirror this or the pending-refund resume
+18 -7
View File
@@ -119,18 +119,29 @@ func initS3() {
func initSquare() { func initSquare() {
payments.SquareClient = square.NewClient() payments.SquareClient = square.NewClient()
if env := os.Getenv("SQUARE_ENVIRONMENT"); env == "sandbox" || env == "production" { env := os.Getenv("SQUARE_ENVIRONMENT")
if env == "sandbox" || env == "production" {
fmt.Printf("Square client initialized (%s, real API)\n", env) fmt.Printf("Square client initialized (%s, real API)\n", env)
} else { } else {
fmt.Println("Square client initialized (dev mock)") fmt.Println("Square client initialized (dev mock)")
} }
// 2FA enforcement is fail-closed (see payments.twoFactorEnforced): it is // 2FA enforcement is fail-closed (see payments.twoFactorEnforced): it is
// OFF only when REQUIRE_2FA=false or SQUARE_ENVIRONMENT explicitly selects // OFF only when REQUIRE_2FA explicitly disables it (false/0/off/no,
// the dev/mock stack. Warn loudly when a non-dev env (empty/unknown — a // case-insensitive) or SQUARE_ENVIRONMENT explicitly selects the dev/mock
// likely misconfiguration) leaves the gate disabled, so saved-card charges // stack. Warn loudly when a non-dev env (empty/unknown — a likely
// can never silently ship without the PSD2 SCA stand-in. // misconfiguration) leaves the gate disabled, so saved-card charges can
if os.Getenv("REQUIRE_2FA") == "false" && !payments.IsExplicitDevOrMockEnv() { // never silently ship without the PSD2 SCA stand-in.
log.Printf("WARNING: 2FA enforcement is OFF (REQUIRE_2FA=false) with SQUARE_ENVIRONMENT=%q (not an explicit mock/dev value). Online saved-card payments will NOT require 2FA.", os.Getenv("SQUARE_ENVIRONMENT")) enforced := payments.NewPaymentService().TwoFactorEnforced()
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)
}
// The mirror-image confusion: enforcement is ON but the Square client fell
// back to the in-memory mock (internal/square.NewDevClient only picks the
// real API for sandbox/production) because SQUARE_ENVIRONMENT is empty or
// unknown. The operator may believe they are in dev — warn so enforced-2FA
// 403s on online saved-card payments do not arrive as a surprise.
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)
} }
} }
+9 -2
View File
@@ -939,9 +939,11 @@ GDPR COMPLIANCE NOTES:
-- Anonymize registered user (Right to be Forgotten) -- Anonymize registered user (Right to be Forgotten)
-- WHY: GDPR Article 17 - users can request data deletion -- WHY: GDPR Article 17 - users can request data deletion
-- WHEN: User requests account deletion -- WHEN: User requests account deletion OR idle-account batch cleanup
-- OUTPUT: Converts personal data to anonymous placeholder -- OUTPUT: Converts personal data to anonymous placeholder
-- NOTE: phone/date_of_birth are NOT NULL so we use placeholder values, not NULL -- NOTE: phone/date_of_birth are NOT NULL so we use placeholder values, not NULL
-- NOTE: Also scrubs 2FA state (two_factor_*) and staff notes (PII) — a row
-- presented as erased keeps no PII at any call site.
CREATE OR REPLACE FUNCTION anonymize_user(target_id CHAR(12)) CREATE OR REPLACE FUNCTION anonymize_user(target_id CHAR(12))
RETURNS VOID AS $$ RETURNS VOID AS $$
BEGIN BEGIN
@@ -960,7 +962,12 @@ BEGIN
data_retention_consent = FALSE, data_retention_consent = FALSE,
data_consent_updated_at = NOW(), data_consent_updated_at = NOW(),
updated_at = NOW(), updated_at = NOW(),
password_hash = NULL password_hash = NULL,
two_factor_enabled = FALSE, -- live 2FA credential must not survive erasure
two_factor_method = NULL,
two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL,
notes = NULL -- staff notes are PII; scrub at erasure
WHERE id = target_id WHERE id = target_id
AND account_role != 'guest'; AND account_role != 'guest';
+2 -2
View File
@@ -34,9 +34,9 @@ Square integration has two build-tagged implementations:
- **Dev** (`//go:build dev`): Mock client simulates async checkout with polling. No real payments. - **Dev** (`//go:build dev`): Mock client simulates async checkout with polling. No real payments.
- **Prod** (`//go:build !dev`): Connects to live Square API. Requires Square credentials in `.env`. - **Prod** (`//go:build !dev`): Connects to live Square API. Requires Square credentials in `.env`.
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 `/api/webhooks/square` 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 raises a `critical_payment_log` admin notification). The background sweeps remain as the eventual backstop. 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 only when `REQUIRE_2FA` is not `false` **and** `SQUARE_ENVIRONMENT` is `sandbox`/`production`; new-card/nonce charges are not gated). The 6-digit code is currently delivered by logging it server-side (`[2FA]` prefix) — fake delivery until real email/SMS infrastructure lands. UI: Account → Two-Factor Authentication. Details in the [[Technical Manual]]. **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]].
Fees column on `payments` stores actual Square deductions. **`square_deposits` (and the `generate_square_deposit_id()` function) are DEAD SCHEMA — zero Go references; they were a placeholder for Square bank reconciliation against Mettle. Keep them unused; backlog item T1 tracks dropping them, and Mettle/FreeAgent integration is a planned upcoming body of work.** Fees column on `payments` stores actual Square deductions. **`square_deposits` (and the `generate_square_deposit_id()` function) are DEAD SCHEMA — zero Go references; they were a placeholder for Square bank reconciliation against Mettle. Keep them unused; backlog item T1 tracks dropping them, and Mettle/FreeAgent integration is a planned upcoming body of work.**
+8 -8
View File
@@ -50,7 +50,7 @@ Backend (:8080)
|---------|--------|---------| |---------|--------|---------|
| SabreDAV (CardDAV/CalDAV) | Active | Contact sync (profile photos), calendar events | | SabreDAV (CardDAV/CalDAV) | Active | Contact sync (profile photos), calendar events |
| S3/R2 | Active (dev); prod side **planned** | Portfolio images (AVIF), profile pictures (WebP) | | S3/R2 | Active (dev); prod side **planned** | Portfolio images (AVIF), profile pictures (WebP) |
| Square | **Active** | Payment processing — in-person Terminal (`CreateTerminalCheckout`) + online card payments (saved cards + new cards tokenized via the Square Web Payments SDK `cnon:` nonces; new-card entry is gated only when the frontend Square env vars are unset — see `plans/p11-square-web-payments-sdk.md`). Backend accepts only `cnon:`/`ccof:` tokens (raw PANs rejected). Dev mock (`//go:build dev`) mirrors production PCI-DSS behaviour; prod client (`!dev`) connects to live API. Webhook events arrive at `/api/webhooks/square` — HMAC-verified fail-closed and dispatched to state-mutating handlers (see `handlers/webhooks`). | | Square | **Active** | Payment processing — in-person Terminal (`CreateTerminalCheckout`) + online card payments (saved cards + new cards tokenized via the Square Web Payments SDK `cnon:` nonces; new-card entry is gated only when the frontend Square env vars are unset — see `plans/p11-square-web-payments-sdk.md`). Backend accepts only `cnon:`/`ccof:` tokens (raw PANs rejected). Dev mock (`//go:build dev`) mirrors production PCI-DSS behaviour; prod client (`!dev`) connects to live API. Webhook events arrive at `/webhooks/square` registered on the router root (not under `/api`; nginx proxies it exact-match) — HMAC-verified fail-closed and dispatched to state-mutating handlers (see `handlers/webhooks`). |
| SMTP | Planned | Email/SMS notification delivery — upcoming body of work (backend not wired yet) | | SMTP | Planned | Email/SMS notification delivery — upcoming body of work (backend not wired yet) |
| Mettle / FreeAgent | Planned | Accounting integration (bank feed + bookkeeping export) — upcoming body of work | | Mettle / FreeAgent | Planned | Accounting integration (bank feed + bookkeeping export) — upcoming body of work |
@@ -65,7 +65,7 @@ Backend (:8080)
| `handlers/auth` | local.go, social.go | Registration (with referral code validation), login, refresh, email verification | | `handlers/auth` | local.go, social.go | Registration (with referral code validation), login, refresh, email verification |
| `handlers/bookings` | bookings.go, reserve.go, manage.go, admin_reserve.go, cancel_reservation.go, admin_cancel_reservation.go, closing_time.go | Booking CRUD, reservations with **self-blocking prevention** (`excludeUserID` parameter on `CheckTimeBlockerOverlap` + pre-overlap DELETE with IP hash anon cleanup), admin management, edit requests, discounts, closing hours validation (`checkClosingHours` + `getClosingTimeForDate` resolves staged default hours for bookings), active booking limits, GetBookingsByCreatedRange, created_by_name resolution, **explicit reservation cancellation** (`DELETE /api/bookings/reserve` for users, `DELETE /api/admin/bookings/reserve` for admin walk-in/call-in) | | `handlers/bookings` | bookings.go, reserve.go, manage.go, admin_reserve.go, cancel_reservation.go, admin_cancel_reservation.go, closing_time.go | Booking CRUD, reservations with **self-blocking prevention** (`excludeUserID` parameter on `CheckTimeBlockerOverlap` + pre-overlap DELETE with IP hash anon cleanup), admin management, edit requests, discounts, closing hours validation (`checkClosingHours` + `getClosingTimeForDate` resolves staged default hours for bookings), active booking limits, GetBookingsByCreatedRange, created_by_name resolution, **explicit reservation cancellation** (`DELETE /api/bookings/reserve` for users, `DELETE /api/admin/bookings/reserve` for admin walk-in/call-in) |
| `handlers/payments` | handlers.go, service.go, validators.go, giftcards.go, till.go, refunds.go, refund_policy.go | Square payments: terminal, online, refunds, tips, saved cards, gift cards (CRUD, topup, transfer, redeem, buy, expired balances, till sales). Refund calculation with notice-period tiers and deposit protection | | `handlers/payments` | handlers.go, service.go, validators.go, giftcards.go, till.go, refunds.go, refund_policy.go | Square payments: terminal, online, refunds, tips, saved cards, gift cards (CRUD, topup, transfer, redeem, buy, expired balances, till sales). Refund calculation with notice-period tiers and deposit protection |
| `handlers/webhooks` | square.go | Square webhook endpoint. **Fail-closed signature check** — rejects with 503 when `SQUARE_WEBHOOK_SIGNATURE_KEY` is unset, 403 on a missing/invalid `x-square-hmacsha256-signature`, 400 on an empty `event_id`. HMAC-SHA256 verified per Square spec (base64 output, notificationURL + body). Events are deduplicated by `event_id` — a fast-path in-memory cache plus a persistent `square_webhook_events` row committed **after** successful dispatch (at-least-once: on any dispatch error no dedup row is written and a 5xx is returned so Square retries) — and dispatched to state-mutating handlers: `payment.updated`/`payment.created` reconcile `payments` and `till_sales` (pending-only, with gift-card funding clawback on definitively failed charges), `refund.updated`/`refund.created` update `refunds`, and `dispute.created`/`dispute.state.updated` upsert `disputes` — a lost dispute marks the payment failed and raises a `critical_payment_log` admin notification. `terminal.checkout.*` and dispute-evidence events are logged only. The background sweeps remain the eventual backstop. | | `handlers/webhooks` | square.go | Square webhook endpoint. **Fail-closed signature check** — rejects with 503 when `SQUARE_WEBHOOK_SIGNATURE_KEY` is unset, 403 on a missing/invalid `x-square-hmacsha256-signature`, 400 on an empty `event_id`. HMAC-SHA256 verified per Square spec (base64 output, notificationURL + body). Events are deduplicated by `event_id` — a fast-path in-memory cache plus a persistent `square_webhook_events` row committed **after** successful dispatch (at-least-once: on any dispatch error no dedup row is written and a 5xx is returned so Square retries) — and dispatched to state-mutating handlers: `payment.updated`/`payment.created` reconcile `payments` and `till_sales` (pending-only, with gift-card funding clawback on definitively failed charges), `refund.updated`/`refund.created` update `refunds`, and `dispute.created`/`dispute.state.updated` upsert `disputes` — a lost dispute marks the payment failed, and a `critical_payment_log` admin notification is always raised, even when the disputed Square payment has no local `payments` row (untracked chargeback: no sweep fallback exists, so the owner must always be told). `terminal.checkout.*` and dispute-evidence events are logged only. The background sweeps remain the eventual backstop. |
| `handlers/admin` | users.go, analytics.go, custom_services.go, discount_campaigns.go, settings.go | Admin user management, custom services CRUD (list/create/get/update/promote/delete), discount campaigns, analytics (stub), business settings (GET/PUT with VAT, gift card config) | | `handlers/admin` | users.go, analytics.go, custom_services.go, discount_campaigns.go, settings.go | Admin user management, custom services CRUD (list/create/get/update/promote/delete), discount campaigns, analytics (stub), business settings (GET/PUT with VAT, gift card config) |
| `handlers/today` | today.go | Current/next appointment, today's grid, pending approvals, `DoneForDay` state with daily/weekly summary (`DailySummary` with `total_bookings`, `customers_served`, `summary_scope`), auto-status transitions, closed-day aggregation via `findWeekSummaryRange` + `computeAggregateSummary`. Exceptional hours lookup uses `exceptional_group_applications.week_start` (0=Monday). | | `handlers/today` | today.go | Current/next appointment, today's grid, pending approvals, `DoneForDay` state with daily/weekly summary (`DailySummary` with `total_bookings`, `customers_served`, `summary_scope`), auto-status transitions, closed-day aggregation via `findWeekSummaryRange` + `computeAggregateSummary`. Exceptional hours lookup uses `exceptional_group_applications.week_start` (0=Monday). |
| `handlers/user` | profile.go, account.go, guest.go, loyalty.go, customer_relationship.go, gdpr_export.go | User profile, guest creation (with CheckEmailHandler for registered-email detection), loyalty, contact info, GDPR export (async with 12h cache) | | `handlers/user` | profile.go, account.go, guest.go, loyalty.go, customer_relationship.go, gdpr_export.go | User profile, guest creation (with CheckEmailHandler for registered-email detection), loyalty, contact info, GDPR export (async with 12h cache) |
@@ -394,7 +394,7 @@ CORS uses `*` in local dev. In production behind Cloudflare, nginx handles CORS.
| POST | `/api/admin/bookings/{id}/payment` | Create Terminal payment | | POST | `/api/admin/bookings/{id}/payment` | Create Terminal payment |
| GET | `/api/admin/payments/{checkout_id}/status` | Poll checkout status | | GET | `/api/admin/payments/{checkout_id}/status` | Poll checkout status |
| POST | `/api/admin/payments/{payment_id}/refund` | Refund payment | | POST | `/api/admin/payments/{payment_id}/refund` | Refund payment |
| POST | `/api/webhooks/square` | Square webhook endpoint | | POST | `/webhooks/square` | Square webhook endpoint (root router — not under `/api`) |
| POST | `/api/admin/gift-cards/expired-balances` | List expired/dormant balances | | POST | `/api/admin/gift-cards/expired-balances` | List expired/dormant balances |
| POST | `/api/admin/gift-cards/expired-balances/claim` | Claim an expired balance (409 if already claimed) | | POST | `/api/admin/gift-cards/expired-balances/claim` | Claim an expired balance (409 if already claimed) |
| GET | `/api/admin/settings` | Get business settings (name, address, VAT, gift card config) | | GET | `/api/admin/settings` | Get business settings (name, address, VAT, gift card config) |
@@ -812,12 +812,12 @@ validTransitions := map[string]map[string]bool{
**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 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.
**Enforcement** (`twoFactorEnforced`, `handlers/payments/twofa.go`): **Enforcement** (`twoFactorEnforced`, `handlers/payments/twofa.go`):
- Enforced only when `REQUIRE_2FA` is **not** `"false"` **AND** `SQUARE_ENVIRONMENT` is `sandbox` or `production`. - 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`).
- Local dev (`SQUARE_ENVIRONMENT` empty or `"mock"`) never enforces. `REQUIRE_2FA=false` disables enforcement even in a deployed environment, for local testing. - A mistyped or unset `SQUARE_ENVIRONMENT` can never silently disarm the gate. `REQUIRE_2FA=false` disables enforcement even in a deployed environment, for local testing.
**State:** stored on `users``two_factor_enabled BOOLEAN DEFAULT FALSE`, `two_factor_method` (`'email'` / `'sms'`), `two_factor_pending_code_hash` (SHA-256), `two_factor_pending_code_expires` (10-minute TTL). Only the digest is stored in the DB; the plaintext code is delivered by logging it with a `[2FA]` prefix **fake delivery** until real email/SMS infrastructure replaces that log line. When enforcement is off (dev), the setup endpoint also returns the code in its response and verify accepts any code, so the flow is testable without grepping backend logs. **State:** stored on `users``two_factor_enabled BOOLEAN DEFAULT FALSE`, `two_factor_method` (`'email'` / `'sms'`), `two_factor_pending_code_hash` (SHA-256), `two_factor_pending_code_expires` (10-minute TTL). Only the digest is stored in the DB; the plaintext code is delivered via the server log with a `[2FA]` prefix in **all** modes — enforced and unenforced alike — the operator reads it and relays it to the customer. This is the fake delivery channel until real email/SMS infrastructure replaces that log line (P6); there is no email/SMS transport yet. When enforcement is off (dev), the setup endpoint also returns the code in its response and verify accepts any code, so the flow is testable without grepping backend logs.
**Gate:** `requireTwoFactorForCardAccess` (`handlers/payments/twofa.go`) is called on the saved-card online charge paths — booking payments, tips, and saved-card till sales. New-card (nonce) charges are **not** gated; a verification token from Square's own SDK covers the SCA step on new-card entry. Disabling 2FA accepts a code field but ignores it — a documented loose-fake simplification until the real SCA flow requires re-authentication to disable. **Gate:** `requireTwoFactorForCardAccess` (`handlers/payments/twofa.go`) is called on the saved-card online charge paths — booking payments, tips, and saved-card till sales. New-card (nonce) charges are **not** gated; a verification token from Square's own SDK covers the SCA step on new-card entry. Disabling 2FA requires a verification code when enforcement is ON (a password-only attacker must not be able to lift the protection) — a fresh code is generated and delivered via the same `[2FA]` log channel when none is pending, and it is checked under the shared 5-attempt lockout. In dev (unenforced) environments no code is required to disable.
**Endpoints:** `GET /api/user/2fa/status`, `POST /api/user/2fa/setup`, `POST /api/user/2fa/verify`, `POST /api/user/2fa/disable`. UI: Account → Two-Factor Authentication. **Endpoints:** `GET /api/user/2fa/status`, `POST /api/user/2fa/setup`, `POST /api/user/2fa/verify`, `POST /api/user/2fa/disable`. UI: Account → Two-Factor Authentication.
@@ -1306,7 +1306,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user
### Test Coverage ### Test Coverage
**2,133 tests run** 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,142 tests run** 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.
| Package | Coverage Area | | Package | Coverage Area |
|---------|--------------| |---------|--------------|
+2
View File
@@ -197,6 +197,8 @@ Customers can see their gift card details in the **Gift Cards** tab:
Customers can pay online in several ways: Customers can pay online in several ways:
**A note on saved cards:** Online payments made with a **saved card** may ask for a one-time two-factor verification code if the salon has 2FA switched on. If a customer wants to use a saved card, they can set up 2FA in advance under **Account → Admin** (Two-Factor Authentication). New-card payments don't need it.
### Paying a Deposit ### Paying a Deposit
Customers may need to pay a deposit if they have deposit obligations, or they may choose to pay early voluntarily. Customers may need to pay a deposit if they have deposit obligations, or they may choose to pay early voluntarily.