ce8b5026c1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Stand up the payments data foundation: a self-contained `payments` Postgres schema for the in-game currency, wallets, benefits, catalog, orders and the append-only operations ledger, behind a domain package — nothing wired to real money yet. - Migration 00010: the `payments` schema and a NOLOGIN confinement role (ALL on payments.*, nothing on backend); the ledger with a BEFORE UPDATE/DELETE append-only trigger and a partial idempotency index; the materialised balances/benefits; the catalog (atoms seeded) + products + per-method prices; the typed single-row config; orders and payment_events. There is no cross-schema foreign key — account_id is a plain uuid kept consistent in code, which keeps the domain extractable. Expand-contract and reversible. - Money is a bigint in the currency's minor units carried by a `Money` value type (exact, math/big): no float ever touches an amount, and a whole-unit currency cannot hold a fraction. - Extend jetgen to generate the payments schema; construct the service in the composition root behind a narrow interface with a boot reachability check. - Tests: integration (role confinement via SET ROLE, the append-only trigger, CHECK constraints, the idempotency index, and a forward+backward migration), Money unit tests, and an import-boundary test keeping the payments jet code private to the domain. - Docs: PLAN.md, docs/PAYMENTS.md (+ _ru mirror) updated to the built model.
267 lines
11 KiB
PL/PgSQL
267 lines
11 KiB
PL/PgSQL
-- 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
|