feat(payments): add the payments schema, currency domain and money type
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
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.
This commit is contained in:
@@ -26,7 +26,7 @@ tests, done-criteria, and current status — without re-deriving decisions.
|
||||
|
||||
| Stage | Title | Release | Status |
|
||||
|-------|-------|---------|--------|
|
||||
| E0 | Payments data foundation | 1 | TODO |
|
||||
| E0 | Payments data foundation | 1 | DONE |
|
||||
| E1 | Trusted platform signal | 1 | TODO |
|
||||
| E2 | Currency + benefit core | 1 | TODO |
|
||||
| E3 | Wallet UI | 1 | TODO |
|
||||
@@ -52,15 +52,25 @@ Read once; individual stages assume it.
|
||||
- The payments domain owns its **own Postgres schema `payments`** in the shared instance
|
||||
(`scrabble` DB). All payments tables are `payments.*`. `backend` schema is untouched
|
||||
except for the deprecation of two legacy columns (E2).
|
||||
- **DB role.** A dedicated role owns `payments` (ALL on `payments.*`, plus `REFERENCES` on
|
||||
`backend.accounts(account_id)` for the cross-schema FK — nothing else on `backend`). The
|
||||
game/backend role keeps no rights on `payments`. Isolation is one-directional: payments
|
||||
may reference accounts; the rest of the backend never touches `payments` SQL.
|
||||
- **No cross-schema writes.** Benefits move **into** `payments` (they no longer live on
|
||||
`accounts` — see E2), so the spend transaction (ledger INSERT + balance UPDATE + benefit
|
||||
UPDATE) is atomic **within `payments`**. The only cross-schema link is the FK
|
||||
`payments.*.account_id → backend.accounts.account_id` (referential integrity, not a
|
||||
write). Game code reads benefits through the payments **Go interface**, never via SQL.
|
||||
- **DB role.** A dedicated **NOLOGIN** role holds ALL privileges on `payments.*` and
|
||||
**nothing** on `backend` — grant-based confinement, asserted by a `SET ROLE` isolation
|
||||
test. The application still connects on its single (superuser) pool, so these grants are a
|
||||
stepping-stone to a real separate login/process, not the runtime wall. The **runtime wall
|
||||
is code-level**: only the `internal/payments` package imports the payments jet code and
|
||||
issues `payments.*` SQL (an **import-boundary test** enforces it); every other domain
|
||||
reaches payments through the narrow Go interface.
|
||||
- **No cross-schema link at all.** There is **no** foreign key from `payments.*` 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 id. This
|
||||
keeps the spend transaction (ledger INSERT + balance UPDATE + benefit UPDATE) fully
|
||||
**within `payments`** and the domain extractable into its own database later. Benefits move
|
||||
**into** `payments` (they no longer live on `accounts` — see E2); game code reads them
|
||||
through the payments **Go interface**, never via SQL.
|
||||
- **Money.** Monetary amounts are a single **`bigint` in the currency's minor units** (RUB
|
||||
kopecks; Votes/Stars/chips are whole units, scale 1) carried in Go by the `payments.Money`
|
||||
value type — the sole constructor/formatter/arithmetic, so **no `float64` ever touches an
|
||||
amount** and a whole-unit currency structurally cannot hold a fraction. `chip_rate` is not a
|
||||
table: a chip pack is a product, so its per-method rate **is** `product_price`.
|
||||
|
||||
### Domain boundary
|
||||
|
||||
@@ -122,67 +132,78 @@ Read once; individual stages assume it.
|
||||
|
||||
## E0 — Payments data foundation
|
||||
|
||||
**Status:** TODO · **Release 1** · depends on: none · mechanics: PAYMENTS §14, §7, §11.
|
||||
**Status:** DONE · **Release 1** · depends on: none · mechanics: PAYMENTS §14, §7, §11.
|
||||
|
||||
**Goal.** Stand up the `payments` schema, DB role, jetgen target, domain package skeleton,
|
||||
and all core tables — with nothing wired to real money yet. This is the substrate every
|
||||
later stage builds on.
|
||||
**Goal.** Stand up the `payments` schema, its confinement role, the jetgen target, the domain
|
||||
package skeleton and all core tables — nothing wired to real money yet. This is the substrate
|
||||
every later stage builds on.
|
||||
|
||||
**Migrations (`payments` schema).**
|
||||
**Migration `00010_payments_foundation.sql` (`payments` schema).**
|
||||
|
||||
- `CREATE SCHEMA payments`; create the payments DB role + GRANTs (ALL on `payments`,
|
||||
`REFERENCES` on `backend.accounts(account_id)`).
|
||||
- `payments.ledger` — append-only. Columns: `ledger_id` (UUIDv7 PK), `account_id` (FK →
|
||||
`backend.accounts`), `kind` (`fund|spend|admin_grant|refund`), `source` / `origin`
|
||||
(nullable per kind), `chips_delta` (signed; 0 for admin_grant), `product_id` (nullable,
|
||||
FK → catalog), `order_id` (nullable, FK → orders), `provider` + `provider_payment_id`
|
||||
(nullable), `snapshot` (JSONB of sold atoms+price for spend/grant), `created_at`. **No
|
||||
UPDATE/DELETE ever.** Unique partial index on `(provider, provider_payment_id)` where not
|
||||
null — the idempotency key.
|
||||
- `payments.balances` — materialized `(account_id, source)` PK, `chips int CHECK (chips >=
|
||||
0)`, `updated_at`.
|
||||
- `payments.benefits` — materialized `(account_id, origin)` PK, `ads_paid_until timestamptz`
|
||||
null, `ads_forever bool`, `hints int CHECK (hints >= 0)`, `updated_at`.
|
||||
- `payments.catalog_atom` — atom types (`chips|hints|noads_days|tournament`).
|
||||
- `payments.product` — `product_id`, `title`, `active bool` (soft-delete), created/updated;
|
||||
`payments.product_item` (product → atom + quantity); `payments.product_price` (product →
|
||||
method `vk|telegram|direct` + amount + currency — multi-currency chip packs; value
|
||||
products carry a single chips price).
|
||||
- `payments.chip_rate` / rewarded payout config — either dedicated rows or a small
|
||||
`payments.config` KV (frequency cooldowns, rewarded payout, pending-expiry). Pick one and
|
||||
document it here at implementation.
|
||||
- `payments.order` — `order_id` (UUIDv7 PK), `account_id`, `platform`, `product_id`,
|
||||
`expected_amount` + currency, `origin`, `status` (`pending|paid|expired`), `provider`,
|
||||
`provider_payment_id` (nullable), timestamps. Index for the pending-expiry sweep.
|
||||
- `payments.payment_event` — `event_id`, `account_id`, `order_id` (nullable), `type`
|
||||
(`succeeded|failed|refunded`), `payload`, `created_at`, `dispatched_at` (nullable).
|
||||
- `CREATE SCHEMA payments`; an idempotent `DO $$` block creates a **NOLOGIN** `payments` role;
|
||||
`GRANT USAGE` + `GRANT ALL ON ALL TABLES` (+ `ALTER DEFAULT PRIVILEGES … GRANT ALL`) confine it
|
||||
to `payments.*` with nothing on `backend`. No `REFERENCES` grant (there is no cross-schema FK).
|
||||
- `payments.ledger` — append-only. `ledger_id` (uuid PK, app-generated v7), `account_id` (plain
|
||||
`uuid`, **no FK**), `kind` (`fund|spend|admin_grant|refund`), `source`/`origin` (nullable,
|
||||
`vk|telegram|direct`), `chips_delta` (signed int, 0 for a grant), `product_id` (nullable
|
||||
FK→product), `order_id` (nullable FK→orders), `provider` + `provider_payment_id` (nullable),
|
||||
`snapshot` (jsonb, written from E2), `created_at`. Immutability is a **`BEFORE UPDATE OR DELETE`
|
||||
trigger** (a superuser bypasses a privilege REVOKE). Unique **partial** index on `(provider,
|
||||
provider_payment_id) WHERE provider_payment_id IS NOT NULL` — the idempotency key.
|
||||
- `payments.balances` — `(account_id, source)` PK, `chips int CHECK (>=0)`, `updated_at`. Created
|
||||
**lazily** on first fund (up to three rows/account, absent = zero).
|
||||
- `payments.benefits` — `(account_id, origin)` PK, `ads_paid_until timestamptz` null,
|
||||
`ads_forever bool`, `hints int CHECK (>=0)`, `updated_at`. Created lazily.
|
||||
- `payments.catalog_atom` — `atom_type` PK (`chips|hints|noads_days|tournament`); the four atoms
|
||||
are **seeded** here (the `tournament` atom is provisioned for E9).
|
||||
- `payments.product` (`product_id` PK, `title`, `active bool` soft-delete, timestamps);
|
||||
`payments.product_item` (`(product_id, atom_type)` PK → `quantity`); `payments.product_price`
|
||||
(`(product_id, COALESCE(method,''), currency)` unique → `amount bigint` minor units): a chip
|
||||
pack carries a money price per `method` (`vk|telegram|direct`), a value carries one
|
||||
`currency='CHIP', method=NULL` row. `currency ∈ {RUB, VOTE, XTR, CHIP}`; `amount CHECK (>=0)`.
|
||||
- `payments.config` — a single typed row (`only_row bool PK CHECK`): `rewarded_payout_chips`,
|
||||
`cooldown_global_seconds` (300), `cooldown_vs_ai_seconds` (1800), `cooldown_hint_seconds` (60),
|
||||
`order_ttl_seconds` (1800). No `chip_rate` table — the pack rate is `product_price`.
|
||||
- `payments.orders` — `order_id` (uuid PK), `account_id`, `platform`, `product_id` (FK),
|
||||
`expected_amount bigint` + `currency`, `origin`, `status` (`pending|paid|expired`), `provider`,
|
||||
`provider_payment_id` (nullable), timestamps. Index `(status, created_at)` for the pending sweep.
|
||||
- `payments.payment_events` — `event_id` (uuid PK), `account_id`, `order_id` (nullable FK), `type`
|
||||
(`succeeded|failed|refunded`), `payload jsonb`, `created_at`, `dispatched_at` (nullable, partial
|
||||
index for the dispatch queue).
|
||||
- Full `-- +goose Down` (DROP SCHEMA CASCADE + DROP OWNED BY + DROP ROLE); expand-contract, proven
|
||||
reversible by an integration test.
|
||||
|
||||
**Backend.**
|
||||
|
||||
- `backend/internal/payments/` package skeleton: `Store` + `Service` + a `withTx` helper.
|
||||
No handlers wired yet beyond a health-style no-op — this stage is schema + package.
|
||||
- Extend `cmd/jetgen` to generate `payments` schema → `internal/postgres/jet/payments/`;
|
||||
commit generated code.
|
||||
- `backend/internal/payments/{payments,service,store}.go` — the `Money` value type + `Currency`
|
||||
(bigint minor units, exact, no float); `Store{db *sql.DB}` with a `Ping` health read via jet;
|
||||
`Service` over the store. (`withTx` arrives with E2's first transaction.)
|
||||
- `cmd/jetgen` generates the `payments` schema into `internal/postgres/jet/payments/` (a second
|
||||
`GenerateDB` call); committed. Constructed in `cmd/backend/main.go` and passed to `server.Deps`
|
||||
(`Payments`) with a boot-time reachability check; its routes are registered from E2.
|
||||
|
||||
**Legacy deprecation (expand phase only).** Add a migration comment/marker that
|
||||
`accounts.hint_balance` and `accounts.paid_account` are deprecated; do NOT drop yet (drop is
|
||||
the contract phase, after E2 reads/writes the new tables and after Release 2). Reads still
|
||||
use the old columns until E2 flips them.
|
||||
**Legacy deprecation (expand phase only).** A `--` header note marks `accounts.hint_balance` and
|
||||
`accounts.paid_account` deprecated; they are **not** dropped (the DROP is the contract phase after
|
||||
E2 flips reads and after Release 2). Reads still use the old columns until E2.
|
||||
|
||||
**Tests.**
|
||||
|
||||
- integration: schema/role created; role cannot read `backend`-only tables and vice-versa;
|
||||
ledger rejects UPDATE/DELETE (trigger or role privilege); idempotency unique index holds;
|
||||
FK to `backend.accounts` enforced; balance/benefit CHECKs hold.
|
||||
- unit: none material yet (logic arrives in E2).
|
||||
- integration (`inttest`): schema + role + seeds present; `SET ROLE payments` cannot read
|
||||
`backend.accounts` (grant confinement); ledger rejects UPDATE/DELETE (trigger); idempotency
|
||||
partial index holds (NULL-keyed rows repeat); balance/benefit/amount/enum CHECKs bite; migration
|
||||
applies forward **and backward** on a throwaway PG.
|
||||
- unit: `Money` — exact round-trip, per-currency scale, no-float parse rejecting a fraction for a
|
||||
whole-unit currency, arithmetic, formatting. Import-boundary test: only `internal/payments`
|
||||
imports the payments jet code.
|
||||
|
||||
**Done-criteria.** Migrations apply cleanly forward+backward on a throwaway PG (jetgen path
|
||||
proves this); `go build ./backend/...` + `go vet` + `gofmt -l .` clean; committed jet code
|
||||
for `payments`; integration tests green; no behaviour change for users.
|
||||
**Done-criteria (met).** Migrations apply forward+backward on a throwaway PG 17; `go build
|
||||
./backend/...` + `go vet` + `gofmt -l .` clean; committed `jet/payments/`; all tests green; no
|
||||
behaviour change for users.
|
||||
|
||||
**Notes/risks.** jetgen may reorder untouched `backend` tables — revert that churn, commit
|
||||
only the `payments` additions. Keep the `payments` role creation idempotent (`IF NOT
|
||||
EXISTS` / `DO $$`), since migrations re-run on fresh volumes.
|
||||
**Notes.** jetgen regenerates the whole `backend` schema; its committed jet was already drifted
|
||||
from the migrations (`robot_blocks`, `robot_friend_requests`, a `feedback_messages` column), so
|
||||
that churn was reverted and only `jet/payments/` committed — a pre-existing drift worth a separate
|
||||
cleanup. The `payments` role creation is idempotent (`DO $$` / `IF NOT EXISTS`) for fresh volumes.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user