-- Payments data foundation: a self-contained `payments` schema for the in-game -- currency, wallets, benefits, catalog, orders and the append-only operations -- ledger. Nothing is wired to real money yet — this is the substrate the rest of -- the payments domain builds on. Mechanics live in docs/PAYMENTS.md. -- -- Isolation. The schema is confined to a dedicated NOLOGIN role that holds ALL -- privileges on `payments.*` and none on `backend.*`; the backend application -- keeps its single (superuser) pool, so the DB grants are a stepping-stone to a -- future separate login/process rather than a runtime wall. The runtime wall is -- code-level: only the payments package reaches these tables (an import-boundary -- test guards it). There is deliberately NO cross-schema foreign key to -- `backend.accounts`: `account_id` is a plain uuid, kept referentially honest in -- code and joined to the tombstoned account / retained-identities dossier by the -- stable account id — which keeps the domain extractable into its own database. -- -- The ledger is append-only, enforced by a trigger (a superuser bypasses table -- privileges, so a REVOKE would not bind the application). Money is stored as an -- integer amount in the currency's minor units (RUB kopecks; Votes/Stars/chips -- are whole units, scale 1), never a float — integer-currency integrality is -- therefore structural. -- -- Legacy note: backend.accounts.hint_balance and backend.accounts.paid_account -- are superseded by the segmented model here and are DEPRECATED. They are NOT -- dropped in this expand phase — reads still use them until the currency core -- flips them, and the DROP is a later contract-phase migration (image rollback -- must stay DB-safe). Neither was ever set in production. -- -- Expand-contract: everything here is additive in its own schema, so a backend -- image rollback stays DB-safe (older code simply ignores the new schema). -- +goose Up CREATE SCHEMA IF NOT EXISTS payments; -- +goose StatementBegin DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'payments') THEN CREATE ROLE payments NOLOGIN; END IF; END $$; -- +goose StatementEnd GRANT USAGE ON SCHEMA payments TO payments; -- Base value types (atoms) a product is composed of. A fixed, code-known set. CREATE TABLE payments.catalog_atom ( atom_type text NOT NULL, CONSTRAINT catalog_atom_pkey PRIMARY KEY (atom_type), CONSTRAINT catalog_atom_type_chk CHECK ((atom_type = ANY (ARRAY['chips'::text, 'hints'::text, 'noads_days'::text, 'tournament'::text]))) ); INSERT INTO payments.catalog_atom (atom_type) VALUES ('chips'), ('hints'), ('noads_days'), ('tournament'); -- A sellable product: a set of atoms at a price. Soft-deleted (deactivated), -- never removed, so historical orders/ledger keep resolving it. CREATE TABLE payments.product ( product_id uuid NOT NULL, title text DEFAULT ''::text NOT NULL, active boolean DEFAULT true NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT product_pkey PRIMARY KEY (product_id) ); -- The atom composition of a product (e.g. 250 hints + 30 no-ads days). CREATE TABLE payments.product_item ( product_id uuid NOT NULL, atom_type text NOT NULL, quantity integer NOT NULL, CONSTRAINT product_item_pkey PRIMARY KEY (product_id, atom_type), CONSTRAINT product_item_product_fkey FOREIGN KEY (product_id) REFERENCES payments.product (product_id) ON DELETE CASCADE, CONSTRAINT product_item_atom_fkey FOREIGN KEY (atom_type) REFERENCES payments.catalog_atom (atom_type), CONSTRAINT product_item_quantity_chk CHECK ((quantity > 0)) ); -- Price of a product. A chip pack carries a money price per method -- (multi-currency Votes/Stars/RUB); a value carries a single chips price -- (currency='CHIP', method NULL). `amount` is an integer in the currency's -- minor units (RUB kopecks; Votes/Stars/chips whole, scale 1). CREATE TABLE payments.product_price ( product_id uuid NOT NULL, method text, currency text NOT NULL, amount bigint NOT NULL, CONSTRAINT product_price_product_fkey FOREIGN KEY (product_id) REFERENCES payments.product (product_id) ON DELETE CASCADE, CONSTRAINT product_price_method_chk CHECK ((method IS NULL) OR (method = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))), CONSTRAINT product_price_currency_chk CHECK ((currency = ANY (ARRAY['RUB'::text, 'VOTE'::text, 'XTR'::text, 'CHIP'::text]))), CONSTRAINT product_price_amount_chk CHECK ((amount >= 0)) ); -- One price row per (product, method, currency); NULL method (a value's chip -- price) collapses to one row via the COALESCE key. CREATE UNIQUE INDEX product_price_unique_idx ON payments.product_price (product_id, COALESCE(method, ''::text), currency); -- A pre-created purchase intent. `expected_amount` is in `currency` minor units. CREATE TABLE payments.orders ( order_id uuid NOT NULL, account_id uuid NOT NULL, platform text NOT NULL, product_id uuid NOT NULL, expected_amount bigint NOT NULL, currency text NOT NULL, origin text NOT NULL, status text DEFAULT 'pending'::text NOT NULL, provider text, provider_payment_id text, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT orders_pkey PRIMARY KEY (order_id), CONSTRAINT orders_product_fkey FOREIGN KEY (product_id) REFERENCES payments.product (product_id), CONSTRAINT orders_status_chk CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'expired'::text]))), CONSTRAINT orders_origin_chk CHECK ((origin = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))), CONSTRAINT orders_currency_chk CHECK ((currency = ANY (ARRAY['RUB'::text, 'VOTE'::text, 'XTR'::text, 'CHIP'::text]))), CONSTRAINT orders_expected_amount_chk CHECK ((expected_amount >= 0)) ); -- The pending-expiry sweep scans (status, created_at). CREATE INDEX orders_status_created_at_idx ON payments.orders (status, created_at); -- The append-only operations ledger: every fund / spend / admin_grant / refund. -- `chips_delta` is signed (0 for a grant, which credits values not chips); -- `snapshot` records the sold atoms + price for a spend/grant. Never updated or -- deleted (a trigger enforces it); a reversal is a new `refund` row. CREATE TABLE payments.ledger ( ledger_id uuid NOT NULL, account_id uuid NOT NULL, kind text NOT NULL, source text, origin text, chips_delta integer DEFAULT 0 NOT NULL, product_id uuid, order_id uuid, provider text, provider_payment_id text, snapshot jsonb, created_at timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT ledger_pkey PRIMARY KEY (ledger_id), CONSTRAINT ledger_product_fkey FOREIGN KEY (product_id) REFERENCES payments.product (product_id), CONSTRAINT ledger_order_fkey FOREIGN KEY (order_id) REFERENCES payments.orders (order_id), CONSTRAINT ledger_kind_chk CHECK ((kind = ANY (ARRAY['fund'::text, 'spend'::text, 'admin_grant'::text, 'refund'::text]))), CONSTRAINT ledger_source_chk CHECK ((source IS NULL) OR (source = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))), CONSTRAINT ledger_origin_chk CHECK ((origin IS NULL) OR (origin = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))) ); -- Idempotency key: a provider callback credits at most once. Partial so rows -- without a provider payment id (spend/admin_grant) are unconstrained. CREATE UNIQUE INDEX ledger_provider_payment_idx ON payments.ledger (provider, provider_payment_id) WHERE provider_payment_id IS NOT NULL; -- +goose StatementBegin CREATE FUNCTION payments.ledger_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'payments.ledger is append-only: % denied', TG_OP; END; $$; -- +goose StatementEnd CREATE TRIGGER ledger_no_mutation BEFORE UPDATE OR DELETE ON payments.ledger FOR EACH ROW EXECUTE FUNCTION payments.ledger_append_only(); -- Materialised chip balance, segmented by funding source. A fast cache of the -- ledger, recomputable from it; created lazily on first fund (up to three rows -- per account, absent = zero). CREATE TABLE payments.balances ( account_id uuid NOT NULL, source text NOT NULL, chips integer DEFAULT 0 NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT balances_pkey PRIMARY KEY (account_id, source), CONSTRAINT balances_source_chk CHECK ((source = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))), CONSTRAINT balances_chips_chk CHECK ((chips >= 0)) ); -- Materialised benefits, segmented by the purchase origin. no-ads is a duration -- (ads_paid_until) plus a perpetual override (ads_forever); hints is a count. -- Created lazily on first grant/spend. CREATE TABLE payments.benefits ( account_id uuid NOT NULL, origin text NOT NULL, ads_paid_until timestamp with time zone, ads_forever boolean DEFAULT false NOT NULL, hints integer DEFAULT 0 NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT benefits_pkey PRIMARY KEY (account_id, origin), CONSTRAINT benefits_origin_chk CHECK ((origin = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))), CONSTRAINT benefits_hints_chk CHECK ((hints >= 0)) ); -- The single-row tunable config (durations in whole seconds — go-jet stringifies -- interval; payouts as counts). Edited in the admin console, no release needed. CREATE TABLE payments.config ( only_row boolean DEFAULT true NOT NULL, rewarded_payout_chips integer DEFAULT 0 NOT NULL, cooldown_global_seconds integer DEFAULT 300 NOT NULL, cooldown_vs_ai_seconds integer DEFAULT 1800 NOT NULL, cooldown_hint_seconds integer DEFAULT 60 NOT NULL, order_ttl_seconds integer DEFAULT 1800 NOT NULL, CONSTRAINT config_pkey PRIMARY KEY (only_row), CONSTRAINT config_single_row_chk CHECK (only_row) ); INSERT INTO payments.config (only_row) VALUES (true); -- Payment lifecycle events for the dispatcher (live stream / botlink / email). CREATE TABLE payments.payment_events ( event_id uuid NOT NULL, account_id uuid NOT NULL, order_id uuid, type text NOT NULL, payload jsonb, created_at timestamp with time zone DEFAULT now() NOT NULL, dispatched_at timestamp with time zone, CONSTRAINT payment_events_pkey PRIMARY KEY (event_id), CONSTRAINT payment_events_order_fkey FOREIGN KEY (order_id) REFERENCES payments.orders (order_id), CONSTRAINT payment_events_type_chk CHECK ((type = ANY (ARRAY['succeeded'::text, 'failed'::text, 'refunded'::text]))) ); CREATE INDEX payment_events_dispatch_idx ON payments.payment_events (dispatched_at) WHERE dispatched_at IS NULL; -- Confine the payments role to this schema: ALL on its tables, nothing on -- backend. New tables added later inherit the grant via default privileges. GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA payments TO payments; ALTER DEFAULT PRIVILEGES IN SCHEMA payments GRANT ALL ON TABLES TO payments; -- +goose Down ALTER DEFAULT PRIVILEGES IN SCHEMA payments REVOKE ALL ON TABLES FROM payments; DROP SCHEMA IF EXISTS payments CASCADE; -- +goose StatementBegin DO $$ BEGIN IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'payments') THEN EXECUTE 'DROP OWNED BY payments'; DROP ROLE payments; END IF; END $$; -- +goose StatementEnd