fix: comprehensive payment system hardening (4 review passes)

CRITICAL fixes:
- C1: JWT exp claim now validated via jwtauth.VerifyToken (was Decode)
- C2: OverrideAmount validated post-substitution (prevents negative money minting)
- C3: Terminal gift-card payments store gift_card_id; refund credits user balance
- C4: Refund dedup returns stored amount, not req.Amount (prevents admin mislead)
- C5: Booking recheck uses FOR UPDATE (prevents TOCTOU with cancellation)
- C6: processChargeGroup idempotency key stable (charge-only, prevents double-refund)

MAJOR fixes:
- M2: Gift-card refund UPDATE checks RowsAffected; 0 rows -> failed
- M3: ProcessCancellationRefund returns commit error (was swallowed)
- M5: Dispute webhook handling (created + state.updated + disputes table)

MEDIUM fixes:
- ME1: CORS restricted to FRONTEND_ORIGIN env var (was reflect-any)
- ME2: anonymize_user() scrubs users.notes, bookings.notes, name_history, refresh_tokens
- ME3: Webhook handlers now mutate state (payment.updated, refund.updated)

Frontend fixes:
- Same-key retry on 503 (ambiguous failure) wired to all 8 payment flows
- CHARGE_AND_STORE intent for save-card flows (SCA compliance)
- Nonce staleness check verified across all flows

Additional fixes from adversarial re-review:
- F1: Till-sale completed dedup echoes stored amount (C4-class)
- F2: Cash/giftcard terminal path uses FOR UPDATE (C5-class)
- F3: Square-success UPDATE checks RowsAffected (till sales)
- F4: Dispute reason truncated to 192 chars (prevents INSERT failure)
- F5: Booking-user lookup failure marks refund failed (prevents silent money loss)
- F6: Saved-card/tip rechecks wrapped in transaction (C5 residual)

Tests:
- 15 adversarial attack tests (negative override, zero override, terminal gift card,
  refund dedup, TOCTOU, deleted gift card, advisory lock, overcharge, zero/negative/huge
  amount, raw PAN, missing auth, gift card balance, concurrent refunds)
- 14 webhook state tests (dispute created/state, payment/refund updated)
- 3 CORS tests, 3 GDPR tests, 1 HTTP timeout test
- Full suite passes with -race (25 packages, 0 failures)

25 files changed, +1532/-275 lines
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 7df983052b
commit 5e3dc9b428
28 changed files with 2905 additions and 275 deletions
+46
View File
@@ -160,6 +160,10 @@ CREATE OR REPLACE FUNCTION generate_admin_audit_log_id() RETURNS CHAR(12) AS $$
SELECT generate_short_id('admin_audit_log');
$$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION generate_dispute_id() RETURNS CHAR(12) AS $$
SELECT generate_short_id('disputes');
$$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION generate_verification_code() RETURNS CHAR(12) AS $$ SELECT substr(encode(gen_random_bytes(6), 'hex'), 1, 12); $$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION generate_referral_code()
@@ -926,6 +930,8 @@ BEGIN
phone = '+000000000000', -- NOT NULL column: use placeholder
date_of_birth = '1900-01-01', -- NOT NULL column: use placeholder
profile_pic_url = NULL,
notes = NULL, -- free-text staff notes may contain PII
last_login_at = NULL, -- removes activity metadata (right to be forgotten)
account_role = 'guest',
loyalty_stamps = 0,
referral_code = NULL,
@@ -971,6 +977,18 @@ BEGIN
SET notes = NULL
WHERE requested_by = target_id;
-- Scrub notes on this user's bookings (free-text PII written by the user)
UPDATE bookings
SET notes = NULL
WHERE user_id = target_id;
-- Scrub previous-name history (names are PII; leaving them would let
-- "formerly [name]" receipts reveal the erased identity)
UPDATE name_history
SET previous_first_name = 'Deleted',
previous_last_name = 'User'
WHERE user_id = target_id;
-- Clear notification preferences (no contractual basis after account closure)
DELETE FROM user_notification_preferences WHERE user_id = target_id;
@@ -986,6 +1004,9 @@ BEGIN
-- Clear login audit trail (no ongoing legal basis after account closure)
DELETE FROM login_audit WHERE user_id = target_id;
-- Revoke stored refresh tokens (auth credentials of an erased identity)
DELETE FROM refresh_tokens WHERE user_id = target_id;
END;
$$ LANGUAGE plpgsql;
@@ -1014,6 +1035,9 @@ BEGIN
-- Clear login audit trail before deleting the user row
DELETE FROM login_audit WHERE user_id = target_id;
-- Revoke stored refresh tokens (auth credentials of an erased identity)
DELETE FROM refresh_tokens WHERE user_id = target_id;
DELETE FROM users WHERE id = target_id AND account_role = 'guest';
END;
$$ LANGUAGE plpgsql;
@@ -2305,6 +2329,28 @@ CREATE TABLE IF NOT EXISTS square_webhook_events (
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- =======================================
-- DISPUTES TABLE
-- Chargeback/dispute tracking surfaced from Square webhooks (dispute.created,
-- dispute.state.updated). Each row is one Square dispute against a local
-- payment, with the terminal resolution (open → won/lost) recorded so the
-- admin can see chargebacks in the DB-backed 'critical_payment_log'
-- notification centre instead of un-watched CRITICAL log lines.
-- =======================================
CREATE TABLE IF NOT EXISTS disputes (
id CHAR(12) PRIMARY KEY DEFAULT generate_dispute_id(),
square_dispute_id VARCHAR(64) UNIQUE NOT NULL,
payment_id CHAR(12) NOT NULL REFERENCES payments(id),
status VARCHAR(32) NOT NULL DEFAULT 'open', -- open, won, lost
amount NUMERIC(10,2) NOT NULL,
reason VARCHAR(192),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_disputes_payment ON disputes(payment_id);
CREATE INDEX idx_disputes_status ON disputes(status);
-- =======================================
-- CARDDAV / CALDAV TABLES (used by SabreDAV sync in Go backend)
-- =======================================