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