fix: review-loop hardening — identical-body replay, 2FA gates, webhook at-least-once, GDPR scrub

Follow-up to the comprehensive payment-system review. Fixes the issues the
review found in the initial integration, plus the rough edges it introduced.

Money-safety:
- Replay-by-key now replays the FULL original request verbatim from a stored
  square_request_snapshot, so a retained idempotency key returns the original
  payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending
  forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge).
- Dev mock mirrors real Square for unknown-key replays: ccof: saved-card
  sources are charged and rescued; spent cnon: nonces surface
  ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.)
- Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales
  claw back gift-card funding; event-type strings match Square's real catalog.
- Expired-gift-card cancellation refunds set creditFailed (never a phantom
  'completed' refund); cancellation refunds lock all payment rows ascending.
- Sweep never rescue-completes a gift-card purchase without delivering the card.
- Tip no-client-key fallback is a deterministic count-based key under the
  booking advisory lock (retry-safe, distinct tips don't collapse).
- M-cap subtracts completed refunds, clamped to [0, total].

2FA (PSD2 SCA stand-in) for online saved-card payments:
- Full feature: status/setup/verify/disable endpoints, gating helper wired into
  all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account
  admin-tab settings UI, frontend gating across all payment surfaces.
- Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit
  mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env.
- Verify is brute-force hardened (5-attempt lockout, timing-safe compare);
  plaintext codes only logged when enforcement is off (dev).
- GDPR: anonymize_user also scrubs 2FA columns and staff notes.

Infra/docs:
- nginx: /api/ response cache removed (cross-user disclosure); port 80
  redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS;
  separate webhook rate-limit zone.
- Schema: users 2FA columns; payments/till_sales square_source_id +
  square_request_snapshot.
- Legal docs: gift-card cooling-off, international-transfers section, tips
  policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected.
- Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26
  packages green, 2,142 tests, svelte-check clean.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 4b28e93710
commit e9b0f0f2a7
50 changed files with 4223 additions and 413 deletions
+29
View File
@@ -228,6 +228,16 @@ CREATE TABLE users (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Deposit tracking: remaining deposits needed (0-3). Reduces by 1 when booking with payment completes.
deposits_required INT NOT NULL DEFAULT 0,
-- Two-factor authentication (2FA). Loosely faked pending email/SMS delivery
-- infrastructure: two_factor_enabled is the source of truth for the
-- online-card-payment gate; two_factor_method records the chosen delivery
-- channel ('email' | 'sms'); pending_* hold the in-flight verification code
-- (hashed) and its expiry. Set REQUIRE_2FA=false (or the dev build tag) to
-- disable all 2FA requirements for local testing.
two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
two_factor_method TEXT CHECK (two_factor_method IN ('email', 'sms') OR two_factor_method IS NULL),
two_factor_pending_code_hash TEXT,
two_factor_pending_code_expires TIMESTAMPTZ,
-- staff fields
notes TEXT
);
@@ -669,6 +679,19 @@ CREATE TABLE payments (
user_saved_card_id CHAR(12),
square_payment_id TEXT,
square_deposit_id CHAR(12),
-- The exact source_id (card nonce or ccof: card-on-file id) sent to Square
-- in the CreatePayment call, so the sweep can replay the charge with an
-- IDENTICAL request body under the same idempotency key (Square returns the
-- original payment on identical-body key reuse; a different body would
-- trigger IDEMPOTENCY_KEY_REUSED and defeat reconciliation).
square_source_id TEXT,
-- Full JSON of the original CreatePayment request (source_id, idempotency
-- key, amount, plus customer_id/reference_id/note/buyer_email/etc.). The
-- sweep replays this verbatim so Square's idempotency dedup returns the
-- original payment for a retained key. Without it, the replay body differs
-- from the original (Square compares the whole request) and returns
-- IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
square_request_snapshot TEXT,
idempotency_key VARCHAR(64) UNIQUE,
fees NUMERIC(10,2) DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -2108,6 +2131,12 @@ CREATE TABLE till_sales (
user_saved_card_id CHAR(12) REFERENCES user_saved_cards(id) ON DELETE SET NULL,
square_payment_id TEXT,
square_checkout_id TEXT,
-- Mirrors payments.square_source_id: the exact source_id sent in the
-- CreatePayment call, enabling identical-body replay by the sweep.
square_source_id TEXT,
-- Full JSON of the original CreatePayment request for identical-body replay
-- by the sweep (see payments.square_request_snapshot).
square_request_snapshot TEXT,
idempotency_key VARCHAR(64) UNIQUE,
notes TEXT,
created_by CHAR(12) NOT NULL REFERENCES users(id) ON DELETE SET NULL,