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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user