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

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:
Ilia Denisov
2026-07-08 01:07:56 +02:00
parent d9bc7596c6
commit ce8b5026c1
34 changed files with 2243 additions and 78 deletions
+80 -59
View File
@@ -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.
---
+11
View File
@@ -31,6 +31,7 @@ import (
"scrabble/backend/internal/link"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/pushgrpc"
"scrabble/backend/internal/ratewatch"
@@ -260,6 +261,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// block and the banner admin console section.
adsSvc := ads.NewService(ads.NewStore(db))
// In-game currency domain (data foundation): the payments schema behind a
// narrow interface. A boot-time reachability check fails fast if the schema
// did not migrate; the wallet routes are registered when that surface lands.
paymentsSvc := payments.NewService(payments.NewStore(db))
if err := paymentsSvc.Ping(ctx); err != nil {
return fmt.Errorf("payments schema unreachable: %w", err)
}
logger.Info("payments domain ready")
// The image-render sidecar client for the PNG export artifact; nil (PNG
// download answers 404) when BACKEND_RENDERER_URL is unset.
var renderer *render.Client
@@ -287,6 +297,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
RateWatch: rateWatch,
BanView: banView,
Ads: adsSvc,
Payments: paymentsSvc,
Notifier: hub,
ExportSignKey: cfg.ExportSignKey,
Renderer: renderer,
+12 -5
View File
@@ -9,7 +9,8 @@
// 1. start a postgres:17-alpine container via testcontainers-go
// 2. open it with search_path=backend and apply the embedded goose migrations
// 3. drop goose's bookkeeping table so jet does not generate a model for it
// 4. run jet's PostgreSQL generator for schema=backend into internal/postgres/jet
// 4. run jet's PostgreSQL generator for the backend and payments schemas into
// internal/postgres/jet (one subdirectory per schema)
package main
import (
@@ -36,6 +37,7 @@ const (
superuserPassword = "scrabble"
superuserDatabase = "scrabble_backend"
backendSchema = "backend"
paymentsSchema = "payments"
containerStartup = 90 * time.Second
jetOutputDirSuffix = "internal/postgres/jet"
)
@@ -105,11 +107,16 @@ func run(ctx context.Context) error {
return fmt.Errorf("drop goose_db_version: %w", err)
}
if err := jetpostgres.GenerateDB(db, backendSchema, outputDir); err != nil {
return fmt.Errorf("jet generate: %w", err)
// Each schema generates into its own jet/<schema>/ subdirectory (the
// generator wipes only that subtree), so the two calls do not clobber each
// other. GenerateDB takes the schema explicitly, so the connection's
// search_path=backend does not affect the payments introspection.
for _, schema := range []string{backendSchema, paymentsSchema} {
if err := jetpostgres.GenerateDB(db, schema, outputDir); err != nil {
return fmt.Errorf("jet generate schema=%s: %w", schema, err)
}
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, schema)
}
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, backendSchema)
return nil
}
+263
View File
@@ -0,0 +1,263 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/pressly/goose/v3"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/postgres/migrations"
)
// isPgCode reports whether err is a PostgreSQL server error with the given
// SQLSTATE code.
func isPgCode(err error, code string) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == code
}
// TestPaymentsSchemaAndSeeds checks the payments schema, its role and the fixed
// seed rows are present after migration.
func TestPaymentsSchemaAndSeeds(t *testing.T) {
ctx := context.Background()
var atoms int
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.catalog_atom").Scan(&atoms); err != nil {
t.Fatalf("count catalog_atom: %v", err)
}
if atoms != 4 {
t.Errorf("catalog_atom rows = %d, want 4 (chips/hints/noads_days/tournament)", atoms)
}
var cfg int
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&cfg); err != nil {
t.Fatalf("count config: %v", err)
}
if cfg != 1 {
t.Errorf("config rows = %d, want the single seeded row", cfg)
}
var roleExists bool
if err := testDB.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil {
t.Fatalf("check payments role: %v", err)
}
if !roleExists {
t.Error("payments role missing")
}
}
// TestPaymentsRoleConfinement proves the payments role is confined to its own
// schema: it can read payments.* but is denied on backend.*. The application
// itself connects as a superuser (which bypasses this), so this asserts the
// grants are correct as the stepping-stone to a real separate login; the runtime
// wall for the superuser pool is the import-boundary test in the payments package.
func TestPaymentsRoleConfinement(t *testing.T) {
ctx := context.Background()
tx, err := testDB.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin tx: %v", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, "SET LOCAL ROLE payments"); err != nil {
t.Fatalf("SET LOCAL ROLE payments: %v", err)
}
// The payments role can read its own schema.
var n int
if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&n); err != nil {
t.Errorf("payments role denied on payments.config (grants wrong): %v", err)
}
// The payments role must NOT reach the backend schema.
err = tx.QueryRowContext(ctx, "SELECT count(*) FROM backend.accounts").Scan(&n)
if err == nil {
t.Fatal("payments role could read backend.accounts — cross-schema isolation broken")
}
if !isPgCode(err, "42501") {
t.Errorf("reading backend.accounts as payments: got %v, want permission-denied (42501)", err)
}
}
// TestPaymentsLedgerAppendOnly verifies the append-only trigger rejects any
// UPDATE or DELETE on the ledger (a superuser is bound by the trigger, unlike a
// mere privilege REVOKE).
func TestPaymentsLedgerAppendOnly(t *testing.T) {
ctx := context.Background()
id := uuid.New()
if _, err := testDB.ExecContext(ctx,
`INSERT INTO payments.ledger (ledger_id, account_id, kind, chips_delta) VALUES ($1, $2, 'admin_grant', 0)`,
id, uuid.New()); err != nil {
t.Fatalf("insert ledger row: %v", err)
}
if _, err := testDB.ExecContext(ctx, `UPDATE payments.ledger SET chips_delta = 1 WHERE ledger_id = $1`, id); err == nil {
t.Error("UPDATE on payments.ledger succeeded — append-only violated")
} else if !isPgCode(err, "P0001") {
t.Errorf("UPDATE ledger: got %v, want the append-only exception (P0001)", err)
}
if _, err := testDB.ExecContext(ctx, `DELETE FROM payments.ledger WHERE ledger_id = $1`, id); err == nil {
t.Error("DELETE on payments.ledger succeeded — append-only violated")
} else if !isPgCode(err, "P0001") {
t.Errorf("DELETE ledger: got %v, want the append-only exception (P0001)", err)
}
}
// TestPaymentsCheckConstraints verifies the domain CHECKs bite: non-negative
// balances/hints/amount and the enum-like columns.
func TestPaymentsCheckConstraints(t *testing.T) {
ctx := context.Background()
prod := uuid.New()
if _, err := testDB.ExecContext(ctx, `INSERT INTO payments.product (product_id, title) VALUES ($1, 'test')`, prod); err != nil {
t.Fatalf("insert product: %v", err)
}
cases := []struct {
name string
sql string
args []any
}{
{"balances chips negative", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'vk', -1)`, []any{uuid.New()}},
{"balances bad source", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'nope', 0)`, []any{uuid.New()}},
{"benefits hints negative", `INSERT INTO payments.benefits (account_id, origin, hints) VALUES ($1, 'vk', -1)`, []any{uuid.New()}},
{"ledger bad kind", `INSERT INTO payments.ledger (ledger_id, account_id, kind) VALUES ($1, $2, 'bogus')`, []any{uuid.New(), uuid.New()}},
{"product_price amount negative", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'RUB', -1)`, []any{prod}},
{"product_price bad currency", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'EUR', 100)`, []any{prod}},
}
for _, c := range cases {
if _, err := testDB.ExecContext(ctx, c.sql, c.args...); err == nil {
t.Errorf("%s: insert succeeded, want a CHECK violation", c.name)
} else if !isPgCode(err, "23514") {
t.Errorf("%s: got %v, want check_violation (23514)", c.name, err)
}
}
}
// TestPaymentsLedgerIdempotencyIndex verifies the partial unique index makes a
// (provider, provider_payment_id) pair collide once but leaves NULL-keyed rows
// unconstrained.
func TestPaymentsLedgerIdempotencyIndex(t *testing.T) {
ctx := context.Background()
insert := func(ppid *string) error {
_, err := testDB.ExecContext(ctx,
`INSERT INTO payments.ledger (ledger_id, account_id, kind, provider, provider_payment_id) VALUES ($1, $2, 'fund', 'robokassa', $3)`,
uuid.New(), uuid.New(), ppid)
return err
}
ppid := "inv-" + uuid.NewString()
if err := insert(&ppid); err != nil {
t.Fatalf("first insert: %v", err)
}
if err := insert(&ppid); err == nil {
t.Error("duplicate (provider, provider_payment_id) inserted — idempotency index missing")
} else if !isPgCode(err, "23505") {
t.Errorf("duplicate insert: got %v, want unique_violation (23505)", err)
}
// A NULL provider_payment_id is outside the partial index — repeats allowed.
if err := insert(nil); err != nil {
t.Fatalf("first null-ppid insert: %v", err)
}
if err := insert(nil); err != nil {
t.Errorf("second null-ppid insert should be allowed by the partial index: %v", err)
}
}
// TestPaymentsMigrationReversible proves the migration is expand-contract: it
// applies, rolls fully back (schema, role, trigger and all), and re-applies
// cleanly — so a backend image rollback stays DB-safe. It uses its own container
// so the Down does not disturb the shared suite database.
func TestPaymentsMigrationReversible(t *testing.T) {
ctx := context.Background()
container, err := tcpostgres.Run(ctx, pgImage,
tcpostgres.WithDatabase(pgDatabase),
tcpostgres.WithUsername(pgUser),
tcpostgres.WithPassword(pgPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(containerStartup),
),
)
if err != nil {
t.Fatalf("start container: %v", err)
}
defer func() { _ = container.Terminate(context.Background()) }()
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
dsn, err := withSearchPath(baseDSN, pgSchema)
if err != nil {
t.Fatalf("search path: %v", err)
}
cfg := postgres.DefaultConfig()
cfg.DSN = dsn
db, err := postgres.Open(ctx, cfg)
if err != nil {
t.Fatalf("open pool: %v", err)
}
defer func() { _ = db.Close() }()
// Up: apply every migration, including the payments foundation.
if err := postgres.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply up: %v", err)
}
if !schemaExists(ctx, t, db, "payments") {
t.Fatal("payments schema absent after up")
}
// Down the payments migration and assert a clean teardown.
goose.SetBaseFS(migrations.Migrations())
defer goose.SetBaseFS(nil)
if err := goose.SetDialect("postgres"); err != nil {
t.Fatalf("set dialect: %v", err)
}
if err := goose.DownToContext(ctx, db, ".", 9); err != nil {
t.Fatalf("down to 9: %v", err)
}
if schemaExists(ctx, t, db, "payments") {
t.Error("payments schema survived the down migration")
}
var roleExists bool
if err := db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil {
t.Fatalf("check role after down: %v", err)
}
if roleExists {
t.Error("payments role survived the down migration")
}
// Re-apply and assert it comes back cleanly.
if err := goose.UpContext(ctx, db, "."); err != nil {
t.Fatalf("re-apply up: %v", err)
}
if !schemaExists(ctx, t, db, "payments") {
t.Fatal("payments schema absent after re-apply")
}
}
// schemaExists reports whether a schema of the given name is present.
func schemaExists(ctx context.Context, t *testing.T, db *sql.DB, name string) bool {
t.Helper()
var exists bool
if err := db.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = $1)", name).Scan(&exists); err != nil {
t.Fatalf("check schema %s: %v", name, err)
}
return exists
}
@@ -0,0 +1,57 @@
package payments
import (
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestPaymentsSchemaImportBoundary enforces that only this package reaches the
// payments jet code. The payments schema is isolated behind this domain; the
// application connects to Postgres as a superuser that bypasses the schema
// grants, so the runtime wall that keeps every other package out of payments.*
// is this import boundary, not the DB privileges. If another package imported
// the generated payments tables it could issue SQL against the schema directly,
// breaking the single-writer guarantee and the domain's extractability.
func TestPaymentsSchemaImportBoundary(t *testing.T) {
const jetPkg = "scrabble/backend/internal/postgres/jet/payments"
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot resolve the test file path")
}
// thisFile = .../backend/internal/payments/boundary_test.go
backendRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
allowedPrefix := filepath.Join(backendRoot, "internal", "payments") + string(filepath.Separator)
fset := token.NewFileSet()
walkErr := filepath.WalkDir(backendRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".go") {
return nil
}
file, perr := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if perr != nil {
return perr
}
for _, imp := range file.Imports {
p := strings.Trim(imp.Path.Value, `"`)
if p != jetPkg && !strings.HasPrefix(p, jetPkg+"/") {
continue
}
if !strings.HasPrefix(path, allowedPrefix) {
t.Errorf("%s imports %s — only internal/payments may reach the payments jet code", path, p)
}
}
return nil
})
if walkErr != nil {
t.Fatalf("walk backend tree: %v", walkErr)
}
}
+136
View File
@@ -0,0 +1,136 @@
package payments
import "testing"
func TestMoneyFromMinor(t *testing.T) {
m, err := MoneyFromMinor(14950, CurrencyRUB)
if err != nil {
t.Fatalf("MoneyFromMinor: %v", err)
}
if m.Minor() != 14950 || m.Currency() != CurrencyRUB {
t.Fatalf("got minor=%d currency=%s", m.Minor(), m.Currency())
}
if _, err := MoneyFromMinor(1, Currency("EUR")); err == nil {
t.Fatal("expected an error for an unknown currency")
}
}
func TestMoneyFromMajor(t *testing.T) {
cases := []struct {
major int64
cur Currency
minor int64
}{
{149, CurrencyRUB, 14900}, // roubles scale to kopecks
{250, CurrencyStar, 250}, // Stars are whole units
{7, CurrencyVote, 7},
{50, CurrencyChip, 50},
}
for _, c := range cases {
m, err := MoneyFromMajor(c.major, c.cur)
if err != nil {
t.Fatalf("MoneyFromMajor(%d,%s): %v", c.major, c.cur, err)
}
if m.Minor() != c.minor {
t.Errorf("MoneyFromMajor(%d,%s): minor=%d, want %d", c.major, c.cur, m.Minor(), c.minor)
}
}
if _, err := MoneyFromMajor(1<<62, CurrencyRUB); err == nil {
t.Fatal("expected an overflow error")
}
}
func TestParseMoneyExact(t *testing.T) {
cases := []struct {
text string
cur Currency
minor int64
}{
{"149.50", CurrencyRUB, 14950},
{"149", CurrencyRUB, 14900},
{"0.01", CurrencyRUB, 1},
{"0.10", CurrencyRUB, 10}, // 0.1 is inexact as a float; big.Rat keeps it exact
{"-5.00", CurrencyRUB, -500},
{"250", CurrencyStar, 250},
{"7", CurrencyVote, 7},
{"50", CurrencyChip, 50},
}
for _, c := range cases {
m, err := ParseMoney(c.text, c.cur)
if err != nil {
t.Fatalf("ParseMoney(%q,%s): %v", c.text, c.cur, err)
}
if m.Minor() != c.minor {
t.Errorf("ParseMoney(%q,%s): minor=%d, want %d", c.text, c.cur, m.Minor(), c.minor)
}
}
}
func TestParseMoneyRejectsFraction(t *testing.T) {
// A whole-unit currency (Vote/Star/chip) must never accept a fraction, and
// the rouble must never accept finer than a kopeck — the no-float gate.
bad := []struct {
text string
cur Currency
}{
{"250.5", CurrencyStar},
{"1.5", CurrencyChip},
{"7.01", CurrencyVote},
{"1.999", CurrencyRUB},
{"0.001", CurrencyRUB},
{"abc", CurrencyRUB},
{"12.34", Currency("EUR")}, // unknown currency
}
for _, c := range bad {
if m, err := ParseMoney(c.text, c.cur); err == nil {
t.Errorf("ParseMoney(%q,%s) = %d, want an error", c.text, c.cur, m.Minor())
}
}
}
func TestMoneyAddCmp(t *testing.T) {
a, _ := MoneyFromMinor(14900, CurrencyRUB)
b, _ := MoneyFromMinor(50, CurrencyRUB)
sum, err := a.Add(b)
if err != nil || sum.Minor() != 14950 {
t.Fatalf("Add: minor=%d err=%v", sum.Minor(), err)
}
if c, err := a.Cmp(b); err != nil || c != 1 {
t.Fatalf("Cmp a>b: got %d err=%v", c, err)
}
if c, err := b.Cmp(a); err != nil || c != -1 {
t.Fatalf("Cmp b<a: got %d err=%v", c, err)
}
if c, err := a.Cmp(a); err != nil || c != 0 {
t.Fatalf("Cmp a==a: got %d err=%v", c, err)
}
// Cross-currency arithmetic is refused, not silently coerced.
star, _ := MoneyFromMinor(1, CurrencyStar)
if _, err := a.Add(star); err == nil {
t.Error("Add across currencies: expected an error")
}
if _, err := a.Cmp(star); err == nil {
t.Error("Cmp across currencies: expected an error")
}
}
func TestMoneyString(t *testing.T) {
cases := []struct {
minor int64
cur Currency
want string
}{
{14900, CurrencyRUB, "149.00 RUB"},
{14950, CurrencyRUB, "149.50 RUB"},
{1, CurrencyRUB, "0.01 RUB"},
{-500, CurrencyRUB, "-5.00 RUB"},
{250, CurrencyStar, "250 XTR"},
{5, CurrencyChip, "5 CHIP"},
}
for _, c := range cases {
m, _ := MoneyFromMinor(c.minor, c.cur)
if got := m.String(); got != c.want {
t.Errorf("Money{%d,%s}.String() = %q, want %q", c.minor, c.cur, got, c.want)
}
}
}
+171
View File
@@ -0,0 +1,171 @@
// Package payments is the in-game currency, wallet, benefit and catalog domain.
//
// It owns its own Postgres schema (payments) and is the only backend package
// that issues SQL against it — an import-boundary test forbids any other package
// from importing the payments jet code, which keeps the domain extractable into
// its own database or process later. There is no cross-schema foreign key to the
// account schema: an account id is a plain uuid here, kept referentially honest
// in code.
//
// Money is carried exclusively by [Money] — an exact integer amount in a
// currency's minor units — so no floating-point value ever reaches a monetary
// amount, and a whole-unit currency (Vote, Star, chip) can never hold a
// fraction. This file is the data-foundation layer: the currency value type; the
// wallet mechanics build on the schema and this package later.
package payments
import (
"fmt"
"math/big"
)
// Currency identifies the unit a monetary amount is denominated in.
type Currency string
const (
// CurrencyRUB is the Russian rouble; its minor unit is the kopeck (1/100).
CurrencyRUB Currency = "RUB"
// CurrencyVote is the VK Vote — a whole unit with no sub-unit.
CurrencyVote Currency = "VOTE"
// CurrencyStar is the Telegram Star (XTR) — a whole unit with no sub-unit.
CurrencyStar Currency = "XTR"
// CurrencyChip is the in-game chip, the unit a value's price is quoted in —
// a whole unit with no sub-unit.
CurrencyChip Currency = "CHIP"
)
// minorPerUnit reports how many minor units make one major unit of the currency.
// Every currency except the rouble is a whole-unit currency (scale 1), so an
// amount in it structurally cannot carry a fraction.
func (c Currency) minorPerUnit() int64 {
if c == CurrencyRUB {
return 100
}
return 1
}
// Valid reports whether the currency is one of the known units.
func (c Currency) Valid() bool {
switch c {
case CurrencyRUB, CurrencyVote, CurrencyStar, CurrencyChip:
return true
default:
return false
}
}
// Money is an exact monetary amount: a signed integer count of a currency's
// minor units (rouble kopecks; Vote/Star/chip whole units). It is the sole
// carrier of money in the payments domain — construction, arithmetic and
// formatting all go through it, so no float ever reaches an amount and a
// whole-unit currency can never hold a fraction. The zero value has an empty,
// invalid currency; build a value with [MoneyFromMinor], [MoneyFromMajor] or
// [ParseMoney].
type Money struct {
minor int64
currency Currency
}
// MoneyFromMinor builds a Money from a raw count of the currency's minor units
// (kopecks for the rouble, whole units otherwise). It errors on an unknown
// currency.
func MoneyFromMinor(minor int64, c Currency) (Money, error) {
if !c.Valid() {
return Money{}, fmt.Errorf("payments: unknown currency %q", c)
}
return Money{minor: minor, currency: c}, nil
}
// MoneyFromMajor builds a Money from a whole number of major units (roubles,
// Votes, Stars, chips). It errors on an unknown currency or on overflow.
func MoneyFromMajor(major int64, c Currency) (Money, error) {
if !c.Valid() {
return Money{}, fmt.Errorf("payments: unknown currency %q", c)
}
scaled := new(big.Int).Mul(big.NewInt(major), big.NewInt(c.minorPerUnit()))
if !scaled.IsInt64() {
return Money{}, fmt.Errorf("payments: %d %s overflows", major, c)
}
return Money{minor: scaled.Int64(), currency: c}, nil
}
// ParseMoney parses a decimal amount (e.g. "149.50", "250") in the currency,
// exactly and without floating point (via math/big). It rejects a value with
// finer precision than the currency allows — in particular, any fractional part
// for a whole-unit currency — which is the gate that keeps a fraction out of an
// integer currency.
func ParseMoney(text string, c Currency) (Money, error) {
if !c.Valid() {
return Money{}, fmt.Errorf("payments: unknown currency %q", c)
}
r, ok := new(big.Rat).SetString(text)
if !ok {
return Money{}, fmt.Errorf("payments: %q is not a valid amount", text)
}
scaled := new(big.Rat).Mul(r, new(big.Rat).SetInt64(c.minorPerUnit()))
if !scaled.IsInt() {
return Money{}, fmt.Errorf("payments: %q has finer precision than %s allows", text, c)
}
num := scaled.Num() // the denominator is 1 once scaled to an integer
if !num.IsInt64() {
return Money{}, fmt.Errorf("payments: %q overflows %s", text, c)
}
return Money{minor: num.Int64(), currency: c}, nil
}
// Minor returns the amount as a count of the currency's minor units — the value
// persisted to an amount column.
func (m Money) Minor() int64 { return m.minor }
// Currency returns the amount's currency.
func (m Money) Currency() Currency { return m.currency }
// IsZero reports whether the amount is zero.
func (m Money) IsZero() bool { return m.minor == 0 }
// Add returns the sum of the two amounts. It errors when the currencies differ.
func (m Money) Add(o Money) (Money, error) {
if m.currency != o.currency {
return Money{}, fmt.Errorf("payments: cannot add %s to %s", o.currency, m.currency)
}
return Money{minor: m.minor + o.minor, currency: m.currency}, nil
}
// Cmp compares the two amounts, returning -1, 0 or +1. It errors when the
// currencies differ.
func (m Money) Cmp(o Money) (int, error) {
if m.currency != o.currency {
return 0, fmt.Errorf("payments: cannot compare %s to %s", o.currency, m.currency)
}
switch {
case m.minor < o.minor:
return -1, nil
case m.minor > o.minor:
return 1, nil
default:
return 0, nil
}
}
// String renders the amount as "<value> <currency>", with the currency's
// fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR").
func (m Money) String() string {
scale := m.currency.minorPerUnit()
if scale == 1 {
return fmt.Sprintf("%d %s", m.minor, m.currency)
}
neg := m.minor < 0
abs := m.minor
if neg {
abs = -abs
}
width := 0
for s := scale; s > 1; s /= 10 {
width++
}
sign := ""
if neg {
sign = "-"
}
return fmt.Sprintf("%s%d.%0*d %s", sign, abs/scale, width, abs%scale, m.currency)
}
+17
View File
@@ -0,0 +1,17 @@
package payments
import "context"
// Service is the payments domain's application layer — the narrow surface other
// domains depend on, keeping the schema reachable through one seam. The
// data-foundation layer exposes only a health check; the wallet, benefit and
// store-compliance operations arrive with the currency mechanics.
type Service struct {
store *Store
}
// NewService constructs a Service over store.
func NewService(store *Store) *Service { return &Service{store: store} }
// Ping reports whether the payments schema is reachable.
func (s *Service) Ping(ctx context.Context) error { return s.store.Ping(ctx) }
+37
View File
@@ -0,0 +1,37 @@
package payments
import (
"context"
"database/sql"
"fmt"
"github.com/go-jet/jet/v2/postgres"
"scrabble/backend/internal/postgres/jet/payments/model"
"scrabble/backend/internal/postgres/jet/payments/table"
)
// Store is the Postgres-backed query surface for the payments schema. It is the
// only place in the backend that issues SQL against payments.* (an
// import-boundary test enforces it), so the domain stays extractable into its
// own database.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// Ping verifies the payments schema is reachable by reading the singleton config
// row. It is the data-foundation health check; the wallet query surface arrives
// with the currency mechanics.
func (s *Store) Ping(ctx context.Context) error {
var row model.Config
if err := postgres.SELECT(table.Config.AllColumns).
FROM(table.Config).
LIMIT(1).
QueryContext(ctx, s.db, &row); err != nil {
return fmt.Errorf("payments: ping: %w", err)
}
return nil
}
@@ -0,0 +1,20 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type Balances struct {
AccountID uuid.UUID `sql:"primary_key"`
Source string `sql:"primary_key"`
Chips int32
UpdatedAt time.Time
}
@@ -0,0 +1,22 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type Benefits struct {
AccountID uuid.UUID `sql:"primary_key"`
Origin string `sql:"primary_key"`
AdsPaidUntil *time.Time
AdsForever bool
Hints int32
UpdatedAt time.Time
}
@@ -0,0 +1,12 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
type CatalogAtom struct {
AtomType string `sql:"primary_key"`
}
@@ -0,0 +1,17 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
type Config struct {
OnlyRow bool `sql:"primary_key"`
RewardedPayoutChips int32
CooldownGlobalSeconds int32
CooldownVsAiSeconds int32
CooldownHintSeconds int32
OrderTTLSeconds int32
}
@@ -0,0 +1,28 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type Ledger struct {
LedgerID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
Kind string
Source *string
Origin *string
ChipsDelta int32
ProductID *uuid.UUID
OrderID *uuid.UUID
Provider *string
ProviderPaymentID *string
Snapshot *string
CreatedAt time.Time
}
@@ -0,0 +1,28 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type Orders struct {
OrderID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
Platform string
ProductID uuid.UUID
ExpectedAmount int64
Currency string
Origin string
Status string
Provider *string
ProviderPaymentID *string
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -0,0 +1,23 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type PaymentEvents struct {
EventID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
OrderID *uuid.UUID
Type string
Payload *string
CreatedAt time.Time
DispatchedAt *time.Time
}
@@ -0,0 +1,21 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type Product struct {
ProductID uuid.UUID `sql:"primary_key"`
Title string
Active bool
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -0,0 +1,18 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
)
type ProductItem struct {
ProductID uuid.UUID `sql:"primary_key"`
AtomType string `sql:"primary_key"`
Quantity int32
}
@@ -0,0 +1,19 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
)
type ProductPrice struct {
ProductID uuid.UUID
Method *string
Currency string
Amount int64
}
@@ -0,0 +1,87 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Balances = newBalancesTable("payments", "balances", "")
type balancesTable struct {
postgres.Table
// Columns
AccountID postgres.ColumnString
Source postgres.ColumnString
Chips postgres.ColumnInteger
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type BalancesTable struct {
balancesTable
EXCLUDED balancesTable
}
// AS creates new BalancesTable with assigned alias
func (a BalancesTable) AS(alias string) *BalancesTable {
return newBalancesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new BalancesTable with assigned schema name
func (a BalancesTable) FromSchema(schemaName string) *BalancesTable {
return newBalancesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new BalancesTable with assigned table prefix
func (a BalancesTable) WithPrefix(prefix string) *BalancesTable {
return newBalancesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new BalancesTable with assigned table suffix
func (a BalancesTable) WithSuffix(suffix string) *BalancesTable {
return newBalancesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newBalancesTable(schemaName, tableName, alias string) *BalancesTable {
return &BalancesTable{
balancesTable: newBalancesTableImpl(schemaName, tableName, alias),
EXCLUDED: newBalancesTableImpl("", "excluded", ""),
}
}
func newBalancesTableImpl(schemaName, tableName, alias string) balancesTable {
var (
AccountIDColumn = postgres.StringColumn("account_id")
SourceColumn = postgres.StringColumn("source")
ChipsColumn = postgres.IntegerColumn("chips")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, SourceColumn, ChipsColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{ChipsColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{ChipsColumn, UpdatedAtColumn}
)
return balancesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AccountID: AccountIDColumn,
Source: SourceColumn,
Chips: ChipsColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,93 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Benefits = newBenefitsTable("payments", "benefits", "")
type benefitsTable struct {
postgres.Table
// Columns
AccountID postgres.ColumnString
Origin postgres.ColumnString
AdsPaidUntil postgres.ColumnTimestampz
AdsForever postgres.ColumnBool
Hints postgres.ColumnInteger
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type BenefitsTable struct {
benefitsTable
EXCLUDED benefitsTable
}
// AS creates new BenefitsTable with assigned alias
func (a BenefitsTable) AS(alias string) *BenefitsTable {
return newBenefitsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new BenefitsTable with assigned schema name
func (a BenefitsTable) FromSchema(schemaName string) *BenefitsTable {
return newBenefitsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new BenefitsTable with assigned table prefix
func (a BenefitsTable) WithPrefix(prefix string) *BenefitsTable {
return newBenefitsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new BenefitsTable with assigned table suffix
func (a BenefitsTable) WithSuffix(suffix string) *BenefitsTable {
return newBenefitsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newBenefitsTable(schemaName, tableName, alias string) *BenefitsTable {
return &BenefitsTable{
benefitsTable: newBenefitsTableImpl(schemaName, tableName, alias),
EXCLUDED: newBenefitsTableImpl("", "excluded", ""),
}
}
func newBenefitsTableImpl(schemaName, tableName, alias string) benefitsTable {
var (
AccountIDColumn = postgres.StringColumn("account_id")
OriginColumn = postgres.StringColumn("origin")
AdsPaidUntilColumn = postgres.TimestampzColumn("ads_paid_until")
AdsForeverColumn = postgres.BoolColumn("ads_forever")
HintsColumn = postgres.IntegerColumn("hints")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, OriginColumn, AdsPaidUntilColumn, AdsForeverColumn, HintsColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{AdsPaidUntilColumn, AdsForeverColumn, HintsColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{AdsForeverColumn, HintsColumn, UpdatedAtColumn}
)
return benefitsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AccountID: AccountIDColumn,
Origin: OriginColumn,
AdsPaidUntil: AdsPaidUntilColumn,
AdsForever: AdsForeverColumn,
Hints: HintsColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,78 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var CatalogAtom = newCatalogAtomTable("payments", "catalog_atom", "")
type catalogAtomTable struct {
postgres.Table
// Columns
AtomType postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type CatalogAtomTable struct {
catalogAtomTable
EXCLUDED catalogAtomTable
}
// AS creates new CatalogAtomTable with assigned alias
func (a CatalogAtomTable) AS(alias string) *CatalogAtomTable {
return newCatalogAtomTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new CatalogAtomTable with assigned schema name
func (a CatalogAtomTable) FromSchema(schemaName string) *CatalogAtomTable {
return newCatalogAtomTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new CatalogAtomTable with assigned table prefix
func (a CatalogAtomTable) WithPrefix(prefix string) *CatalogAtomTable {
return newCatalogAtomTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new CatalogAtomTable with assigned table suffix
func (a CatalogAtomTable) WithSuffix(suffix string) *CatalogAtomTable {
return newCatalogAtomTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newCatalogAtomTable(schemaName, tableName, alias string) *CatalogAtomTable {
return &CatalogAtomTable{
catalogAtomTable: newCatalogAtomTableImpl(schemaName, tableName, alias),
EXCLUDED: newCatalogAtomTableImpl("", "excluded", ""),
}
}
func newCatalogAtomTableImpl(schemaName, tableName, alias string) catalogAtomTable {
var (
AtomTypeColumn = postgres.StringColumn("atom_type")
allColumns = postgres.ColumnList{AtomTypeColumn}
mutableColumns = postgres.ColumnList{}
defaultColumns = postgres.ColumnList{}
)
return catalogAtomTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AtomType: AtomTypeColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,93 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Config = newConfigTable("payments", "config", "")
type configTable struct {
postgres.Table
// Columns
OnlyRow postgres.ColumnBool
RewardedPayoutChips postgres.ColumnInteger
CooldownGlobalSeconds postgres.ColumnInteger
CooldownVsAiSeconds postgres.ColumnInteger
CooldownHintSeconds postgres.ColumnInteger
OrderTTLSeconds postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ConfigTable struct {
configTable
EXCLUDED configTable
}
// AS creates new ConfigTable with assigned alias
func (a ConfigTable) AS(alias string) *ConfigTable {
return newConfigTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ConfigTable with assigned schema name
func (a ConfigTable) FromSchema(schemaName string) *ConfigTable {
return newConfigTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ConfigTable with assigned table prefix
func (a ConfigTable) WithPrefix(prefix string) *ConfigTable {
return newConfigTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ConfigTable with assigned table suffix
func (a ConfigTable) WithSuffix(suffix string) *ConfigTable {
return newConfigTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newConfigTable(schemaName, tableName, alias string) *ConfigTable {
return &ConfigTable{
configTable: newConfigTableImpl(schemaName, tableName, alias),
EXCLUDED: newConfigTableImpl("", "excluded", ""),
}
}
func newConfigTableImpl(schemaName, tableName, alias string) configTable {
var (
OnlyRowColumn = postgres.BoolColumn("only_row")
RewardedPayoutChipsColumn = postgres.IntegerColumn("rewarded_payout_chips")
CooldownGlobalSecondsColumn = postgres.IntegerColumn("cooldown_global_seconds")
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
)
return configTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
OnlyRow: OnlyRowColumn,
RewardedPayoutChips: RewardedPayoutChipsColumn,
CooldownGlobalSeconds: CooldownGlobalSecondsColumn,
CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
CooldownHintSeconds: CooldownHintSecondsColumn,
OrderTTLSeconds: OrderTTLSecondsColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,111 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Ledger = newLedgerTable("payments", "ledger", "")
type ledgerTable struct {
postgres.Table
// Columns
LedgerID postgres.ColumnString
AccountID postgres.ColumnString
Kind postgres.ColumnString
Source postgres.ColumnString
Origin postgres.ColumnString
ChipsDelta postgres.ColumnInteger
ProductID postgres.ColumnString
OrderID postgres.ColumnString
Provider postgres.ColumnString
ProviderPaymentID postgres.ColumnString
Snapshot postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type LedgerTable struct {
ledgerTable
EXCLUDED ledgerTable
}
// AS creates new LedgerTable with assigned alias
func (a LedgerTable) AS(alias string) *LedgerTable {
return newLedgerTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new LedgerTable with assigned schema name
func (a LedgerTable) FromSchema(schemaName string) *LedgerTable {
return newLedgerTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new LedgerTable with assigned table prefix
func (a LedgerTable) WithPrefix(prefix string) *LedgerTable {
return newLedgerTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new LedgerTable with assigned table suffix
func (a LedgerTable) WithSuffix(suffix string) *LedgerTable {
return newLedgerTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newLedgerTable(schemaName, tableName, alias string) *LedgerTable {
return &LedgerTable{
ledgerTable: newLedgerTableImpl(schemaName, tableName, alias),
EXCLUDED: newLedgerTableImpl("", "excluded", ""),
}
}
func newLedgerTableImpl(schemaName, tableName, alias string) ledgerTable {
var (
LedgerIDColumn = postgres.StringColumn("ledger_id")
AccountIDColumn = postgres.StringColumn("account_id")
KindColumn = postgres.StringColumn("kind")
SourceColumn = postgres.StringColumn("source")
OriginColumn = postgres.StringColumn("origin")
ChipsDeltaColumn = postgres.IntegerColumn("chips_delta")
ProductIDColumn = postgres.StringColumn("product_id")
OrderIDColumn = postgres.StringColumn("order_id")
ProviderColumn = postgres.StringColumn("provider")
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
SnapshotColumn = postgres.StringColumn("snapshot")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{LedgerIDColumn, AccountIDColumn, KindColumn, SourceColumn, OriginColumn, ChipsDeltaColumn, ProductIDColumn, OrderIDColumn, ProviderColumn, ProviderPaymentIDColumn, SnapshotColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, KindColumn, SourceColumn, OriginColumn, ChipsDeltaColumn, ProductIDColumn, OrderIDColumn, ProviderColumn, ProviderPaymentIDColumn, SnapshotColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{ChipsDeltaColumn, CreatedAtColumn}
)
return ledgerTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
LedgerID: LedgerIDColumn,
AccountID: AccountIDColumn,
Kind: KindColumn,
Source: SourceColumn,
Origin: OriginColumn,
ChipsDelta: ChipsDeltaColumn,
ProductID: ProductIDColumn,
OrderID: OrderIDColumn,
Provider: ProviderColumn,
ProviderPaymentID: ProviderPaymentIDColumn,
Snapshot: SnapshotColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,111 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Orders = newOrdersTable("payments", "orders", "")
type ordersTable struct {
postgres.Table
// Columns
OrderID postgres.ColumnString
AccountID postgres.ColumnString
Platform postgres.ColumnString
ProductID postgres.ColumnString
ExpectedAmount postgres.ColumnInteger
Currency postgres.ColumnString
Origin postgres.ColumnString
Status postgres.ColumnString
Provider postgres.ColumnString
ProviderPaymentID postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type OrdersTable struct {
ordersTable
EXCLUDED ordersTable
}
// AS creates new OrdersTable with assigned alias
func (a OrdersTable) AS(alias string) *OrdersTable {
return newOrdersTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new OrdersTable with assigned schema name
func (a OrdersTable) FromSchema(schemaName string) *OrdersTable {
return newOrdersTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new OrdersTable with assigned table prefix
func (a OrdersTable) WithPrefix(prefix string) *OrdersTable {
return newOrdersTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new OrdersTable with assigned table suffix
func (a OrdersTable) WithSuffix(suffix string) *OrdersTable {
return newOrdersTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newOrdersTable(schemaName, tableName, alias string) *OrdersTable {
return &OrdersTable{
ordersTable: newOrdersTableImpl(schemaName, tableName, alias),
EXCLUDED: newOrdersTableImpl("", "excluded", ""),
}
}
func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
var (
OrderIDColumn = postgres.StringColumn("order_id")
AccountIDColumn = postgres.StringColumn("account_id")
PlatformColumn = postgres.StringColumn("platform")
ProductIDColumn = postgres.StringColumn("product_id")
ExpectedAmountColumn = postgres.IntegerColumn("expected_amount")
CurrencyColumn = postgres.StringColumn("currency")
OriginColumn = postgres.StringColumn("origin")
StatusColumn = postgres.StringColumn("status")
ProviderColumn = postgres.StringColumn("provider")
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn}
)
return ordersTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
OrderID: OrderIDColumn,
AccountID: AccountIDColumn,
Platform: PlatformColumn,
ProductID: ProductIDColumn,
ExpectedAmount: ExpectedAmountColumn,
Currency: CurrencyColumn,
Origin: OriginColumn,
Status: StatusColumn,
Provider: ProviderColumn,
ProviderPaymentID: ProviderPaymentIDColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,96 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var PaymentEvents = newPaymentEventsTable("payments", "payment_events", "")
type paymentEventsTable struct {
postgres.Table
// Columns
EventID postgres.ColumnString
AccountID postgres.ColumnString
OrderID postgres.ColumnString
Type postgres.ColumnString
Payload postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
DispatchedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type PaymentEventsTable struct {
paymentEventsTable
EXCLUDED paymentEventsTable
}
// AS creates new PaymentEventsTable with assigned alias
func (a PaymentEventsTable) AS(alias string) *PaymentEventsTable {
return newPaymentEventsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new PaymentEventsTable with assigned schema name
func (a PaymentEventsTable) FromSchema(schemaName string) *PaymentEventsTable {
return newPaymentEventsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new PaymentEventsTable with assigned table prefix
func (a PaymentEventsTable) WithPrefix(prefix string) *PaymentEventsTable {
return newPaymentEventsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new PaymentEventsTable with assigned table suffix
func (a PaymentEventsTable) WithSuffix(suffix string) *PaymentEventsTable {
return newPaymentEventsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newPaymentEventsTable(schemaName, tableName, alias string) *PaymentEventsTable {
return &PaymentEventsTable{
paymentEventsTable: newPaymentEventsTableImpl(schemaName, tableName, alias),
EXCLUDED: newPaymentEventsTableImpl("", "excluded", ""),
}
}
func newPaymentEventsTableImpl(schemaName, tableName, alias string) paymentEventsTable {
var (
EventIDColumn = postgres.StringColumn("event_id")
AccountIDColumn = postgres.StringColumn("account_id")
OrderIDColumn = postgres.StringColumn("order_id")
TypeColumn = postgres.StringColumn("type")
PayloadColumn = postgres.StringColumn("payload")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
DispatchedAtColumn = postgres.TimestampzColumn("dispatched_at")
allColumns = postgres.ColumnList{EventIDColumn, AccountIDColumn, OrderIDColumn, TypeColumn, PayloadColumn, CreatedAtColumn, DispatchedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, OrderIDColumn, TypeColumn, PayloadColumn, CreatedAtColumn, DispatchedAtColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn}
)
return paymentEventsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
EventID: EventIDColumn,
AccountID: AccountIDColumn,
OrderID: OrderIDColumn,
Type: TypeColumn,
Payload: PayloadColumn,
CreatedAt: CreatedAtColumn,
DispatchedAt: DispatchedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,90 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Product = newProductTable("payments", "product", "")
type productTable struct {
postgres.Table
// Columns
ProductID postgres.ColumnString
Title postgres.ColumnString
Active postgres.ColumnBool
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ProductTable struct {
productTable
EXCLUDED productTable
}
// AS creates new ProductTable with assigned alias
func (a ProductTable) AS(alias string) *ProductTable {
return newProductTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ProductTable with assigned schema name
func (a ProductTable) FromSchema(schemaName string) *ProductTable {
return newProductTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ProductTable with assigned table prefix
func (a ProductTable) WithPrefix(prefix string) *ProductTable {
return newProductTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ProductTable with assigned table suffix
func (a ProductTable) WithSuffix(suffix string) *ProductTable {
return newProductTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newProductTable(schemaName, tableName, alias string) *ProductTable {
return &ProductTable{
productTable: newProductTableImpl(schemaName, tableName, alias),
EXCLUDED: newProductTableImpl("", "excluded", ""),
}
}
func newProductTableImpl(schemaName, tableName, alias string) productTable {
var (
ProductIDColumn = postgres.StringColumn("product_id")
TitleColumn = postgres.StringColumn("title")
ActiveColumn = postgres.BoolColumn("active")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{ProductIDColumn, TitleColumn, ActiveColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{TitleColumn, ActiveColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{TitleColumn, ActiveColumn, CreatedAtColumn, UpdatedAtColumn}
)
return productTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ProductID: ProductIDColumn,
Title: TitleColumn,
Active: ActiveColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,84 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var ProductItem = newProductItemTable("payments", "product_item", "")
type productItemTable struct {
postgres.Table
// Columns
ProductID postgres.ColumnString
AtomType postgres.ColumnString
Quantity postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ProductItemTable struct {
productItemTable
EXCLUDED productItemTable
}
// AS creates new ProductItemTable with assigned alias
func (a ProductItemTable) AS(alias string) *ProductItemTable {
return newProductItemTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ProductItemTable with assigned schema name
func (a ProductItemTable) FromSchema(schemaName string) *ProductItemTable {
return newProductItemTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ProductItemTable with assigned table prefix
func (a ProductItemTable) WithPrefix(prefix string) *ProductItemTable {
return newProductItemTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ProductItemTable with assigned table suffix
func (a ProductItemTable) WithSuffix(suffix string) *ProductItemTable {
return newProductItemTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newProductItemTable(schemaName, tableName, alias string) *ProductItemTable {
return &ProductItemTable{
productItemTable: newProductItemTableImpl(schemaName, tableName, alias),
EXCLUDED: newProductItemTableImpl("", "excluded", ""),
}
}
func newProductItemTableImpl(schemaName, tableName, alias string) productItemTable {
var (
ProductIDColumn = postgres.StringColumn("product_id")
AtomTypeColumn = postgres.StringColumn("atom_type")
QuantityColumn = postgres.IntegerColumn("quantity")
allColumns = postgres.ColumnList{ProductIDColumn, AtomTypeColumn, QuantityColumn}
mutableColumns = postgres.ColumnList{QuantityColumn}
defaultColumns = postgres.ColumnList{}
)
return productItemTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ProductID: ProductIDColumn,
AtomType: AtomTypeColumn,
Quantity: QuantityColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,87 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var ProductPrice = newProductPriceTable("payments", "product_price", "")
type productPriceTable struct {
postgres.Table
// Columns
ProductID postgres.ColumnString
Method postgres.ColumnString
Currency postgres.ColumnString
Amount postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ProductPriceTable struct {
productPriceTable
EXCLUDED productPriceTable
}
// AS creates new ProductPriceTable with assigned alias
func (a ProductPriceTable) AS(alias string) *ProductPriceTable {
return newProductPriceTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ProductPriceTable with assigned schema name
func (a ProductPriceTable) FromSchema(schemaName string) *ProductPriceTable {
return newProductPriceTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ProductPriceTable with assigned table prefix
func (a ProductPriceTable) WithPrefix(prefix string) *ProductPriceTable {
return newProductPriceTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ProductPriceTable with assigned table suffix
func (a ProductPriceTable) WithSuffix(suffix string) *ProductPriceTable {
return newProductPriceTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newProductPriceTable(schemaName, tableName, alias string) *ProductPriceTable {
return &ProductPriceTable{
productPriceTable: newProductPriceTableImpl(schemaName, tableName, alias),
EXCLUDED: newProductPriceTableImpl("", "excluded", ""),
}
}
func newProductPriceTableImpl(schemaName, tableName, alias string) productPriceTable {
var (
ProductIDColumn = postgres.StringColumn("product_id")
MethodColumn = postgres.StringColumn("method")
CurrencyColumn = postgres.StringColumn("currency")
AmountColumn = postgres.IntegerColumn("amount")
allColumns = postgres.ColumnList{ProductIDColumn, MethodColumn, CurrencyColumn, AmountColumn}
mutableColumns = postgres.ColumnList{ProductIDColumn, MethodColumn, CurrencyColumn, AmountColumn}
defaultColumns = postgres.ColumnList{}
)
return productPriceTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ProductID: ProductIDColumn,
Method: MethodColumn,
Currency: CurrencyColumn,
Amount: AmountColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,23 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
// this method only once at the beginning of the program.
func UseSchema(schema string) {
Balances = Balances.FromSchema(schema)
Benefits = Benefits.FromSchema(schema)
CatalogAtom = CatalogAtom.FromSchema(schema)
Config = Config.FromSchema(schema)
Ledger = Ledger.FromSchema(schema)
Orders = Orders.FromSchema(schema)
PaymentEvents = PaymentEvents.FromSchema(schema)
Product = Product.FromSchema(schema)
ProductItem = ProductItem.FromSchema(schema)
ProductPrice = ProductPrice.FromSchema(schema)
}
@@ -0,0 +1,266 @@
-- 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
+8
View File
@@ -28,6 +28,7 @@ import (
"scrabble/backend/internal/link"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/render"
"scrabble/backend/internal/session"
@@ -93,6 +94,11 @@ type Deps struct {
// profile.get banner block, plus the banner admin console section. A nil Ads
// omits the banner block and disables the banner console.
Ads *ads.Service
// Payments is the in-game currency domain service (wallet, benefits, catalog).
// The data-foundation layer exposes only a reachability check; its user and
// console routes are registered when the wallet surface lands. A nil Payments
// omits them.
Payments *payments.Service
// Notifier publishes live-event intents — here the banner-eligibility re-poll
// signal the banner/hint/role console actions emit. A nil Notifier discards
// them (notify.Nop).
@@ -129,6 +135,7 @@ type Server struct {
ratewatch *ratewatch.Watch
banview *banview.View
ads *ads.Service
payments *payments.Service
notifier notify.Publisher
console *adminconsole.Renderer
exportKey []byte
@@ -181,6 +188,7 @@ func New(addr string, deps Deps) *Server {
ratewatch: deps.RateWatch,
banview: deps.BanView,
ads: deps.Ads,
payments: deps.Payments,
notifier: notifier,
renderer: deps.Renderer,
http: &http.Server{Addr: addr, Handler: engine},
+11 -6
View File
@@ -49,7 +49,8 @@ The chip balance is **segmented by `source`** — the platform where the chips w
| `direct` | Robokassa purchase (web / native)|
A single account holds all three segments simultaneously: `balance = (account_id, source)`,
exactly three rows. Segmentation is **not** per-identity — see §6.
up to three rows — a row is materialised lazily on the segment's first funding, and an absent
segment reads as zero. Segmentation is **not** per-identity — see §6.
Why segmented, not one pooled balance: store rules forbid activating value that was paid
for outside the store's own cash desk. Chips funded inside VK (Votes) may only be spent in
@@ -302,8 +303,11 @@ confirms the exact НПД scheme with a tax advisor.)
The payments domain lives in its **own schema `payments`** in the shared Postgres instance,
with its own DB role (rights limited to `payments`) and a domain package behind a hard
interface. Cross-schema FKs to `backend.accounts` keep the chip↔benefit spend atomic in one
transaction. Durability is **PITR** (continuous WAL archive), independent of DB topology.
interface. There is **no cross-schema foreign key** to `backend.accounts` — an account id is a
plain value here, kept consistent in code and joined to the tombstoned account /
retained-identities dossier by the stable id. That keeps the chip↔benefit spend atomic
**within `payments`** and the domain extractable into its own database. Durability is **PITR**
(continuous WAL archive), independent of DB topology.
Core tables (final names/columns fixed in `PLAN.md`):
@@ -318,9 +322,10 @@ Core tables (final names/columns fixed in `PLAN.md`):
(pending/paid/expired).
- **payment_events** — succeeded/failed/refunded for the dispatcher.
Legacy `accounts.hint_balance` and `accounts.paid_account` are **deprecated** and dropped
(expand-contract) in favour of the segmented model; neither was ever set in prod (no purchase
flow existed), so legacy values are zeroed.
Legacy `accounts.hint_balance` and `accounts.paid_account` are **deprecated** in favour of the
segmented model and dropped in a later **contract-phase** migration (expand-contract, after the
currency core flips reads and Release 2 — image rollback stays DB-safe); neither was ever set in
prod (no purchase flow existed), so legacy values are zeroed.
## 15. Glossary
+13 -8
View File
@@ -48,8 +48,9 @@
| `telegram` | Покупка за Stars TG |
| `direct` | Покупка через Robokassa (web / native) |
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, ровно
три записи. Сегментация **не** по привязке (identity) — см. §6.
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, до трёх
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
читается как ноль. Сегментация **не** по привязке (identity) — см. §6.
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
@@ -304,9 +305,11 @@ INSERT** (никогда UPDATE/DELETE — полный аудит). Балан
Платёжный домен живёт в **своей схеме `payments`** в общем инстансе Postgres, со своим
DB-ролём (права только на `payments`) и доменным пакетом за жёстким интерфейсом.
Cross-schema внешние ключи к `backend.accounts` держат трату «Фишки↔бенефит» атомарной в
одной транзакции. Сохранность — **PITR** (непрерывный WAL-архив), независимо от топологии
базы.
**Cross-schema внешнего ключа** к `backend.accounts` **нет** — идентификатор аккаунта здесь
обычное значение, согласуемое в коде и связываемое с tombstone-аккаунтом / досье
retained-identities по стабильному id. Это держит трату «Фишки↔бенефит» атомарной **внутри
`payments`** и делает домен извлекаемым в свою базу. Сохранность — **PITR** (непрерывный
WAL-архив), независимо от топологии базы.
Основные таблицы (финальные имена/колонки — в `PLAN.md`):
@@ -322,9 +325,11 @@ Cross-schema внешние ключи к `backend.accounts` держат тра
(pending/paid/expired).
- **payment_events** — succeeded/failed/refunded для диспетчера.
Legacy `accounts.hint_balance` и `accounts.paid_account` **устаревают** и удаляются
(expand-contract) в пользу сегментированной модели; ни то, ни другое в проде никогда не
выставлялось (потока покупки не было), поэтому legacy-значения обнуляются.
Legacy `accounts.hint_balance` и `accounts.paid_account` **устаревают** в пользу
сегментированной модели и удаляются в отдельной **contract-миграции** (expand-contract, после
того как валютное ядро переключит чтения, и после Release 2 — откат образа остаётся безопасным
для БД); ни то, ни другое в проде никогда не выставлялось (потока покупки не было), поэтому
legacy-значения обнуляются.
## 15. Словарь