fix: payments hardening — SCA wire contract (saved-card ref + tokenize-result), terminal/till token routing, tip-cap overflow carve, completion campaign atomicity, orphan B1-evidence gate, gift-card gates/locks, admin backstops

- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped
- maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass
- completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions)
- webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed
- gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification
- admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned
- 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs
- tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 1d9c87d6d6
commit 1429eddd34
43 changed files with 2211 additions and 1275 deletions
+21 -2
View File
@@ -277,7 +277,15 @@ CREATE TYPE verification_purpose AS ENUM ('email_verify', 'password_reset');
CREATE TABLE verification_codes (
id CHAR(12) PRIMARY KEY DEFAULT generate_verification_codes_id(),
code CHAR(12) NOT NULL UNIQUE DEFAULT generate_verification_code(),
-- code stores the hex digest (HMAC-SHA256 keyed by TWO_FACTOR_PEPPER, or
-- the legacy plain SHA-256 in dev/test when the pepper is unset — see
-- crussell/internal/twofa Hash) of the verification code, NOT the
-- plaintext: the old 12-hex-char plaintext column exposed the credential
-- at rest. The plaintext is generated server-side (handlers/auth/local.go),
-- delivered out-of-band (dev [VERIFY] log relay / future SMTP), and the
-- submitted code is hashed the same way for the lookup. 64 hex chars holds
-- the SHA-256 digest.
code CHAR(64) NOT NULL UNIQUE,
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
purpose verification_purpose NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
@@ -799,7 +807,8 @@ CREATE TABLE discount_campaigns (
created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_dates CHECK (campaign_type = 'milestone' OR (start_date IS NOT NULL AND end_date IS NOT NULL AND end_date > start_date)),
CONSTRAINT chk_discount CHECK (discount_percent > 0 AND discount_percent <= 100),
CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND (milestone_type != 'anniversary' OR milestone_unit IS NOT NULL)))
CONSTRAINT chk_milestone CHECK (campaign_type = 'time_based' OR (milestone_type IS NOT NULL AND milestone_value IS NOT NULL AND (milestone_type != 'anniversary' OR milestone_unit IS NOT NULL))),
CONSTRAINT chk_times_redeemed CHECK (max_redemptions IS NULL OR times_redeemed <= max_redemptions)
);
CREATE INDEX idx_discount_campaigns_dates ON discount_campaigns(start_date, end_date) WHERE start_date IS NOT NULL;
@@ -829,6 +838,16 @@ CREATE INDEX idx_booking_discounts_user ON booking_discounts(user_id);
CREATE INDEX idx_booking_discounts_milestone ON booking_discounts(user_id, milestone_type, source_id);
CREATE INDEX idx_booking_discounts_booking_source ON booking_discounts(booking_id, discount_source);
-- Once-per-user backstop: the completion path guards the per-user booking-count
-- and anniversary milestones with a NOT EXISTS read; two concurrent completions
-- of DIFFERENT bookings of the same user can both pass that read. This partial
-- unique index makes the write-side race impossible — at most one milestone
-- redemption per user per campaign. Time_based and global_booking_count
-- campaigns are per-booking and legitimately repeat, so they are excluded.
CREATE UNIQUE INDEX uq_booking_discounts_user_milestone_campaign
ON booking_discounts(user_id, source_id)
WHERE discount_source = 'campaign' AND milestone_type IN ('per_user_booking_count', 'anniversary');
-- =======================================
-- BUSINESS SETTINGS TABLE (FOR COMPLIANCE)
-- Stores legal business info for receipts, VAT status, currency, etc.