diff --git a/PLAN.md b/PLAN.md index 113a619..01d4b80 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. --- diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index cf8a82d..34fa135 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -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, diff --git a/backend/cmd/jetgen/main.go b/backend/cmd/jetgen/main.go index 8103f4c..5ec77d5 100644 --- a/backend/cmd/jetgen/main.go +++ b/backend/cmd/jetgen/main.go @@ -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// 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 } diff --git a/backend/internal/inttest/payments_test.go b/backend/internal/inttest/payments_test.go new file mode 100644 index 0000000..1f9ea1b --- /dev/null +++ b/backend/internal/inttest/payments_test.go @@ -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 +} diff --git a/backend/internal/payments/boundary_test.go b/backend/internal/payments/boundary_test.go new file mode 100644 index 0000000..031c54d --- /dev/null +++ b/backend/internal/payments/boundary_test.go @@ -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) + } +} diff --git a/backend/internal/payments/money_test.go b/backend/internal/payments/money_test.go new file mode 100644 index 0000000..9da7b9f --- /dev/null +++ b/backend/internal/payments/money_test.go @@ -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 o.minor: + return 1, nil + default: + return 0, nil + } +} + +// String renders the amount as " ", 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) +} diff --git a/backend/internal/payments/service.go b/backend/internal/payments/service.go new file mode 100644 index 0000000..faac8d4 --- /dev/null +++ b/backend/internal/payments/service.go @@ -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) } diff --git a/backend/internal/payments/store.go b/backend/internal/payments/store.go new file mode 100644 index 0000000..4fd19ac --- /dev/null +++ b/backend/internal/payments/store.go @@ -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 +} diff --git a/backend/internal/postgres/jet/payments/model/balances.go b/backend/internal/postgres/jet/payments/model/balances.go new file mode 100644 index 0000000..04a0c7a --- /dev/null +++ b/backend/internal/postgres/jet/payments/model/balances.go @@ -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 +} diff --git a/backend/internal/postgres/jet/payments/model/benefits.go b/backend/internal/postgres/jet/payments/model/benefits.go new file mode 100644 index 0000000..2640e13 --- /dev/null +++ b/backend/internal/postgres/jet/payments/model/benefits.go @@ -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 +} diff --git a/backend/internal/postgres/jet/payments/model/catalog_atom.go b/backend/internal/postgres/jet/payments/model/catalog_atom.go new file mode 100644 index 0000000..08b8161 --- /dev/null +++ b/backend/internal/postgres/jet/payments/model/catalog_atom.go @@ -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"` +} diff --git a/backend/internal/postgres/jet/payments/model/config.go b/backend/internal/postgres/jet/payments/model/config.go new file mode 100644 index 0000000..55957f4 --- /dev/null +++ b/backend/internal/postgres/jet/payments/model/config.go @@ -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 +} diff --git a/backend/internal/postgres/jet/payments/model/ledger.go b/backend/internal/postgres/jet/payments/model/ledger.go new file mode 100644 index 0000000..379e2a4 --- /dev/null +++ b/backend/internal/postgres/jet/payments/model/ledger.go @@ -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 +} diff --git a/backend/internal/postgres/jet/payments/model/orders.go b/backend/internal/postgres/jet/payments/model/orders.go new file mode 100644 index 0000000..55010ff --- /dev/null +++ b/backend/internal/postgres/jet/payments/model/orders.go @@ -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 +} diff --git a/backend/internal/postgres/jet/payments/model/payment_events.go b/backend/internal/postgres/jet/payments/model/payment_events.go new file mode 100644 index 0000000..9f1dc1a --- /dev/null +++ b/backend/internal/postgres/jet/payments/model/payment_events.go @@ -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 +} diff --git a/backend/internal/postgres/jet/payments/model/product.go b/backend/internal/postgres/jet/payments/model/product.go new file mode 100644 index 0000000..74bce9f --- /dev/null +++ b/backend/internal/postgres/jet/payments/model/product.go @@ -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 +} diff --git a/backend/internal/postgres/jet/payments/model/product_item.go b/backend/internal/postgres/jet/payments/model/product_item.go new file mode 100644 index 0000000..a99f298 --- /dev/null +++ b/backend/internal/postgres/jet/payments/model/product_item.go @@ -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 +} diff --git a/backend/internal/postgres/jet/payments/model/product_price.go b/backend/internal/postgres/jet/payments/model/product_price.go new file mode 100644 index 0000000..55c0c00 --- /dev/null +++ b/backend/internal/postgres/jet/payments/model/product_price.go @@ -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 +} diff --git a/backend/internal/postgres/jet/payments/table/balances.go b/backend/internal/postgres/jet/payments/table/balances.go new file mode 100644 index 0000000..51055c8 --- /dev/null +++ b/backend/internal/postgres/jet/payments/table/balances.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/payments/table/benefits.go b/backend/internal/postgres/jet/payments/table/benefits.go new file mode 100644 index 0000000..bd9eeee --- /dev/null +++ b/backend/internal/postgres/jet/payments/table/benefits.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/payments/table/catalog_atom.go b/backend/internal/postgres/jet/payments/table/catalog_atom.go new file mode 100644 index 0000000..3d9b52e --- /dev/null +++ b/backend/internal/postgres/jet/payments/table/catalog_atom.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/payments/table/config.go b/backend/internal/postgres/jet/payments/table/config.go new file mode 100644 index 0000000..35682d4 --- /dev/null +++ b/backend/internal/postgres/jet/payments/table/config.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/payments/table/ledger.go b/backend/internal/postgres/jet/payments/table/ledger.go new file mode 100644 index 0000000..e7d0504 --- /dev/null +++ b/backend/internal/postgres/jet/payments/table/ledger.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/payments/table/orders.go b/backend/internal/postgres/jet/payments/table/orders.go new file mode 100644 index 0000000..26de702 --- /dev/null +++ b/backend/internal/postgres/jet/payments/table/orders.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/payments/table/payment_events.go b/backend/internal/postgres/jet/payments/table/payment_events.go new file mode 100644 index 0000000..982b0a8 --- /dev/null +++ b/backend/internal/postgres/jet/payments/table/payment_events.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/payments/table/product.go b/backend/internal/postgres/jet/payments/table/product.go new file mode 100644 index 0000000..4ed7ac0 --- /dev/null +++ b/backend/internal/postgres/jet/payments/table/product.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/payments/table/product_item.go b/backend/internal/postgres/jet/payments/table/product_item.go new file mode 100644 index 0000000..07df739 --- /dev/null +++ b/backend/internal/postgres/jet/payments/table/product_item.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/payments/table/product_price.go b/backend/internal/postgres/jet/payments/table/product_price.go new file mode 100644 index 0000000..e55a7ad --- /dev/null +++ b/backend/internal/postgres/jet/payments/table/product_price.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/payments/table/table_use_schema.go b/backend/internal/postgres/jet/payments/table/table_use_schema.go new file mode 100644 index 0000000..7c9d881 --- /dev/null +++ b/backend/internal/postgres/jet/payments/table/table_use_schema.go @@ -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) +} diff --git a/backend/internal/postgres/migrations/00010_payments_foundation.sql b/backend/internal/postgres/migrations/00010_payments_foundation.sql new file mode 100644 index 0000000..502e509 --- /dev/null +++ b/backend/internal/postgres/migrations/00010_payments_foundation.sql @@ -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 diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 271c473..29b2385 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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}, diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index 94b9239..12aa3f2 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -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 diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index 4a6a90f..134629c 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -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. Словарь