-- 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
