feat(payments): add the payments schema, currency domain and money type
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Stand up the payments data foundation: a self-contained `payments` Postgres schema for the in-game currency, wallets, benefits, catalog, orders and the append-only operations ledger, behind a domain package — nothing wired to real money yet. - Migration 00010: the `payments` schema and a NOLOGIN confinement role (ALL on payments.*, nothing on backend); the ledger with a BEFORE UPDATE/DELETE append-only trigger and a partial idempotency index; the materialised balances/benefits; the catalog (atoms seeded) + products + per-method prices; the typed single-row config; orders and payment_events. There is no cross-schema foreign key — account_id is a plain uuid kept consistent in code, which keeps the domain extractable. Expand-contract and reversible. - Money is a bigint in the currency's minor units carried by a `Money` value type (exact, math/big): no float ever touches an amount, and a whole-unit currency cannot hold a fraction. - Extend jetgen to generate the payments schema; construct the service in the composition root behind a narrow interface with a boot reachability check. - Tests: integration (role confinement via SET ROLE, the append-only trigger, CHECK constraints, the idempotency index, and a forward+backward migration), Money unit tests, and an import-boundary test keeping the payments jet code private to the domain. - Docs: PLAN.md, docs/PAYMENTS.md (+ _ru mirror) updated to the built model.
This commit is contained in:
@@ -26,7 +26,7 @@ tests, done-criteria, and current status — without re-deriving decisions.
|
|||||||
|
|
||||||
| Stage | Title | Release | Status |
|
| Stage | Title | Release | Status |
|
||||||
|-------|-------|---------|--------|
|
|-------|-------|---------|--------|
|
||||||
| E0 | Payments data foundation | 1 | TODO |
|
| E0 | Payments data foundation | 1 | DONE |
|
||||||
| E1 | Trusted platform signal | 1 | TODO |
|
| E1 | Trusted platform signal | 1 | TODO |
|
||||||
| E2 | Currency + benefit core | 1 | TODO |
|
| E2 | Currency + benefit core | 1 | TODO |
|
||||||
| E3 | Wallet UI | 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
|
- The payments domain owns its **own Postgres schema `payments`** in the shared instance
|
||||||
(`scrabble` DB). All payments tables are `payments.*`. `backend` schema is untouched
|
(`scrabble` DB). All payments tables are `payments.*`. `backend` schema is untouched
|
||||||
except for the deprecation of two legacy columns (E2).
|
except for the deprecation of two legacy columns (E2).
|
||||||
- **DB role.** A dedicated role owns `payments` (ALL on `payments.*`, plus `REFERENCES` on
|
- **DB role.** A dedicated **NOLOGIN** role holds ALL privileges on `payments.*` and
|
||||||
`backend.accounts(account_id)` for the cross-schema FK — nothing else on `backend`). The
|
**nothing** on `backend` — grant-based confinement, asserted by a `SET ROLE` isolation
|
||||||
game/backend role keeps no rights on `payments`. Isolation is one-directional: payments
|
test. The application still connects on its single (superuser) pool, so these grants are a
|
||||||
may reference accounts; the rest of the backend never touches `payments` SQL.
|
stepping-stone to a real separate login/process, not the runtime wall. The **runtime wall
|
||||||
- **No cross-schema writes.** Benefits move **into** `payments` (they no longer live on
|
is code-level**: only the `internal/payments` package imports the payments jet code and
|
||||||
`accounts` — see E2), so the spend transaction (ledger INSERT + balance UPDATE + benefit
|
issues `payments.*` SQL (an **import-boundary test** enforces it); every other domain
|
||||||
UPDATE) is atomic **within `payments`**. The only cross-schema link is the FK
|
reaches payments through the narrow Go interface.
|
||||||
`payments.*.account_id → backend.accounts.account_id` (referential integrity, not a
|
- **No cross-schema link at all.** There is **no** foreign key from `payments.*` to
|
||||||
write). Game code reads benefits through the payments **Go interface**, never via SQL.
|
`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
|
### Domain boundary
|
||||||
|
|
||||||
@@ -122,67 +132,78 @@ Read once; individual stages assume it.
|
|||||||
|
|
||||||
## E0 — Payments data foundation
|
## 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,
|
**Goal.** Stand up the `payments` schema, its confinement role, the jetgen target, the domain
|
||||||
and all core tables — with nothing wired to real money yet. This is the substrate every
|
package skeleton and all core tables — nothing wired to real money yet. This is the substrate
|
||||||
later stage builds on.
|
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`,
|
- `CREATE SCHEMA payments`; an idempotent `DO $$` block creates a **NOLOGIN** `payments` role;
|
||||||
`REFERENCES` on `backend.accounts(account_id)`).
|
`GRANT USAGE` + `GRANT ALL ON ALL TABLES` (+ `ALTER DEFAULT PRIVILEGES … GRANT ALL`) confine it
|
||||||
- `payments.ledger` — append-only. Columns: `ledger_id` (UUIDv7 PK), `account_id` (FK →
|
to `payments.*` with nothing on `backend`. No `REFERENCES` grant (there is no cross-schema FK).
|
||||||
`backend.accounts`), `kind` (`fund|spend|admin_grant|refund`), `source` / `origin`
|
- `payments.ledger` — append-only. `ledger_id` (uuid PK, app-generated v7), `account_id` (plain
|
||||||
(nullable per kind), `chips_delta` (signed; 0 for admin_grant), `product_id` (nullable,
|
`uuid`, **no FK**), `kind` (`fund|spend|admin_grant|refund`), `source`/`origin` (nullable,
|
||||||
FK → catalog), `order_id` (nullable, FK → orders), `provider` + `provider_payment_id`
|
`vk|telegram|direct`), `chips_delta` (signed int, 0 for a grant), `product_id` (nullable
|
||||||
(nullable), `snapshot` (JSONB of sold atoms+price for spend/grant), `created_at`. **No
|
FK→product), `order_id` (nullable FK→orders), `provider` + `provider_payment_id` (nullable),
|
||||||
UPDATE/DELETE ever.** Unique partial index on `(provider, provider_payment_id)` where not
|
`snapshot` (jsonb, written from E2), `created_at`. Immutability is a **`BEFORE UPDATE OR DELETE`
|
||||||
null — the idempotency key.
|
trigger** (a superuser bypasses a privilege REVOKE). Unique **partial** index on `(provider,
|
||||||
- `payments.balances` — materialized `(account_id, source)` PK, `chips int CHECK (chips >=
|
provider_payment_id) WHERE provider_payment_id IS NOT NULL` — the idempotency key.
|
||||||
0)`, `updated_at`.
|
- `payments.balances` — `(account_id, source)` PK, `chips int CHECK (>=0)`, `updated_at`. Created
|
||||||
- `payments.benefits` — materialized `(account_id, origin)` PK, `ads_paid_until timestamptz`
|
**lazily** on first fund (up to three rows/account, absent = zero).
|
||||||
null, `ads_forever bool`, `hints int CHECK (hints >= 0)`, `updated_at`.
|
- `payments.benefits` — `(account_id, origin)` PK, `ads_paid_until timestamptz` null,
|
||||||
- `payments.catalog_atom` — atom types (`chips|hints|noads_days|tournament`).
|
`ads_forever bool`, `hints int CHECK (>=0)`, `updated_at`. Created lazily.
|
||||||
- `payments.product` — `product_id`, `title`, `active bool` (soft-delete), created/updated;
|
- `payments.catalog_atom` — `atom_type` PK (`chips|hints|noads_days|tournament`); the four atoms
|
||||||
`payments.product_item` (product → atom + quantity); `payments.product_price` (product →
|
are **seeded** here (the `tournament` atom is provisioned for E9).
|
||||||
method `vk|telegram|direct` + amount + currency — multi-currency chip packs; value
|
- `payments.product` (`product_id` PK, `title`, `active bool` soft-delete, timestamps);
|
||||||
products carry a single chips price).
|
`payments.product_item` (`(product_id, atom_type)` PK → `quantity`); `payments.product_price`
|
||||||
- `payments.chip_rate` / rewarded payout config — either dedicated rows or a small
|
(`(product_id, COALESCE(method,''), currency)` unique → `amount bigint` minor units): a chip
|
||||||
`payments.config` KV (frequency cooldowns, rewarded payout, pending-expiry). Pick one and
|
pack carries a money price per `method` (`vk|telegram|direct`), a value carries one
|
||||||
document it here at implementation.
|
`currency='CHIP', method=NULL` row. `currency ∈ {RUB, VOTE, XTR, CHIP}`; `amount CHECK (>=0)`.
|
||||||
- `payments.order` — `order_id` (UUIDv7 PK), `account_id`, `platform`, `product_id`,
|
- `payments.config` — a single typed row (`only_row bool PK CHECK`): `rewarded_payout_chips`,
|
||||||
`expected_amount` + currency, `origin`, `status` (`pending|paid|expired`), `provider`,
|
`cooldown_global_seconds` (300), `cooldown_vs_ai_seconds` (1800), `cooldown_hint_seconds` (60),
|
||||||
`provider_payment_id` (nullable), timestamps. Index for the pending-expiry sweep.
|
`order_ttl_seconds` (1800). No `chip_rate` table — the pack rate is `product_price`.
|
||||||
- `payments.payment_event` — `event_id`, `account_id`, `order_id` (nullable), `type`
|
- `payments.orders` — `order_id` (uuid PK), `account_id`, `platform`, `product_id` (FK),
|
||||||
(`succeeded|failed|refunded`), `payload`, `created_at`, `dispatched_at` (nullable).
|
`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.**
|
||||||
|
|
||||||
- `backend/internal/payments/` package skeleton: `Store` + `Service` + a `withTx` helper.
|
- `backend/internal/payments/{payments,service,store}.go` — the `Money` value type + `Currency`
|
||||||
No handlers wired yet beyond a health-style no-op — this stage is schema + package.
|
(bigint minor units, exact, no float); `Store{db *sql.DB}` with a `Ping` health read via jet;
|
||||||
- Extend `cmd/jetgen` to generate `payments` schema → `internal/postgres/jet/payments/`;
|
`Service` over the store. (`withTx` arrives with E2's first transaction.)
|
||||||
commit generated code.
|
- `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
|
**Legacy deprecation (expand phase only).** A `--` header note marks `accounts.hint_balance` and
|
||||||
`accounts.hint_balance` and `accounts.paid_account` are deprecated; do NOT drop yet (drop is
|
`accounts.paid_account` deprecated; they are **not** dropped (the DROP is the contract phase after
|
||||||
the contract phase, after E2 reads/writes the new tables and after Release 2). Reads still
|
E2 flips reads and after Release 2). Reads still use the old columns until E2.
|
||||||
use the old columns until E2 flips them.
|
|
||||||
|
|
||||||
**Tests.**
|
**Tests.**
|
||||||
|
|
||||||
- integration: schema/role created; role cannot read `backend`-only tables and vice-versa;
|
- integration (`inttest`): schema + role + seeds present; `SET ROLE payments` cannot read
|
||||||
ledger rejects UPDATE/DELETE (trigger or role privilege); idempotency unique index holds;
|
`backend.accounts` (grant confinement); ledger rejects UPDATE/DELETE (trigger); idempotency
|
||||||
FK to `backend.accounts` enforced; balance/benefit CHECKs hold.
|
partial index holds (NULL-keyed rows repeat); balance/benefit/amount/enum CHECKs bite; migration
|
||||||
- unit: none material yet (logic arrives in E2).
|
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
|
**Done-criteria (met).** Migrations apply forward+backward on a throwaway PG 17; `go build
|
||||||
proves this); `go build ./backend/...` + `go vet` + `gofmt -l .` clean; committed jet code
|
./backend/...` + `go vet` + `gofmt -l .` clean; committed `jet/payments/`; all tests green; no
|
||||||
for `payments`; integration tests green; no behaviour change for users.
|
behaviour change for users.
|
||||||
|
|
||||||
**Notes/risks.** jetgen may reorder untouched `backend` tables — revert that churn, commit
|
**Notes.** jetgen regenerates the whole `backend` schema; its committed jet was already drifted
|
||||||
only the `payments` additions. Keep the `payments` role creation idempotent (`IF NOT
|
from the migrations (`robot_blocks`, `robot_friend_requests`, a `feedback_messages` column), so
|
||||||
EXISTS` / `DO $$`), since migrations re-run on fresh volumes.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
"scrabble/backend/internal/pushgrpc"
|
"scrabble/backend/internal/pushgrpc"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"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.
|
// block and the banner admin console section.
|
||||||
adsSvc := ads.NewService(ads.NewStore(db))
|
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
|
// The image-render sidecar client for the PNG export artifact; nil (PNG
|
||||||
// download answers 404) when BACKEND_RENDERER_URL is unset.
|
// download answers 404) when BACKEND_RENDERER_URL is unset.
|
||||||
var renderer *render.Client
|
var renderer *render.Client
|
||||||
@@ -287,6 +297,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
RateWatch: rateWatch,
|
RateWatch: rateWatch,
|
||||||
BanView: banView,
|
BanView: banView,
|
||||||
Ads: adsSvc,
|
Ads: adsSvc,
|
||||||
|
Payments: paymentsSvc,
|
||||||
Notifier: hub,
|
Notifier: hub,
|
||||||
ExportSignKey: cfg.ExportSignKey,
|
ExportSignKey: cfg.ExportSignKey,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
// 1. start a postgres:17-alpine container via testcontainers-go
|
// 1. start a postgres:17-alpine container via testcontainers-go
|
||||||
// 2. open it with search_path=backend and apply the embedded goose migrations
|
// 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
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -36,6 +37,7 @@ const (
|
|||||||
superuserPassword = "scrabble"
|
superuserPassword = "scrabble"
|
||||||
superuserDatabase = "scrabble_backend"
|
superuserDatabase = "scrabble_backend"
|
||||||
backendSchema = "backend"
|
backendSchema = "backend"
|
||||||
|
paymentsSchema = "payments"
|
||||||
containerStartup = 90 * time.Second
|
containerStartup = 90 * time.Second
|
||||||
jetOutputDirSuffix = "internal/postgres/jet"
|
jetOutputDirSuffix = "internal/postgres/jet"
|
||||||
)
|
)
|
||||||
@@ -105,11 +107,16 @@ func run(ctx context.Context) error {
|
|||||||
return fmt.Errorf("drop goose_db_version: %w", err)
|
return fmt.Errorf("drop goose_db_version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := jetpostgres.GenerateDB(db, backendSchema, outputDir); err != nil {
|
// Each schema generates into its own jet/<schema>/ subdirectory (the
|
||||||
return fmt.Errorf("jet generate: %w", err)
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||||
|
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||||
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/postgres"
|
||||||
|
"scrabble/backend/internal/postgres/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isPgCode reports whether err is a PostgreSQL server error with the given
|
||||||
|
// SQLSTATE code.
|
||||||
|
func isPgCode(err error, code string) bool {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
return errors.As(err, &pgErr) && pgErr.Code == code
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsSchemaAndSeeds checks the payments schema, its role and the fixed
|
||||||
|
// seed rows are present after migration.
|
||||||
|
func TestPaymentsSchemaAndSeeds(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var atoms int
|
||||||
|
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.catalog_atom").Scan(&atoms); err != nil {
|
||||||
|
t.Fatalf("count catalog_atom: %v", err)
|
||||||
|
}
|
||||||
|
if atoms != 4 {
|
||||||
|
t.Errorf("catalog_atom rows = %d, want 4 (chips/hints/noads_days/tournament)", atoms)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg int
|
||||||
|
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&cfg); err != nil {
|
||||||
|
t.Fatalf("count config: %v", err)
|
||||||
|
}
|
||||||
|
if cfg != 1 {
|
||||||
|
t.Errorf("config rows = %d, want the single seeded row", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var roleExists bool
|
||||||
|
if err := testDB.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil {
|
||||||
|
t.Fatalf("check payments role: %v", err)
|
||||||
|
}
|
||||||
|
if !roleExists {
|
||||||
|
t.Error("payments role missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsRoleConfinement proves the payments role is confined to its own
|
||||||
|
// schema: it can read payments.* but is denied on backend.*. The application
|
||||||
|
// itself connects as a superuser (which bypasses this), so this asserts the
|
||||||
|
// grants are correct as the stepping-stone to a real separate login; the runtime
|
||||||
|
// wall for the superuser pool is the import-boundary test in the payments package.
|
||||||
|
func TestPaymentsRoleConfinement(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tx, err := testDB.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin tx: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, "SET LOCAL ROLE payments"); err != nil {
|
||||||
|
t.Fatalf("SET LOCAL ROLE payments: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The payments role can read its own schema.
|
||||||
|
var n int
|
||||||
|
if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&n); err != nil {
|
||||||
|
t.Errorf("payments role denied on payments.config (grants wrong): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The payments role must NOT reach the backend schema.
|
||||||
|
err = tx.QueryRowContext(ctx, "SELECT count(*) FROM backend.accounts").Scan(&n)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("payments role could read backend.accounts — cross-schema isolation broken")
|
||||||
|
}
|
||||||
|
if !isPgCode(err, "42501") {
|
||||||
|
t.Errorf("reading backend.accounts as payments: got %v, want permission-denied (42501)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsLedgerAppendOnly verifies the append-only trigger rejects any
|
||||||
|
// UPDATE or DELETE on the ledger (a superuser is bound by the trigger, unlike a
|
||||||
|
// mere privilege REVOKE).
|
||||||
|
func TestPaymentsLedgerAppendOnly(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
id := uuid.New()
|
||||||
|
if _, err := testDB.ExecContext(ctx,
|
||||||
|
`INSERT INTO payments.ledger (ledger_id, account_id, kind, chips_delta) VALUES ($1, $2, 'admin_grant', 0)`,
|
||||||
|
id, uuid.New()); err != nil {
|
||||||
|
t.Fatalf("insert ledger row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := testDB.ExecContext(ctx, `UPDATE payments.ledger SET chips_delta = 1 WHERE ledger_id = $1`, id); err == nil {
|
||||||
|
t.Error("UPDATE on payments.ledger succeeded — append-only violated")
|
||||||
|
} else if !isPgCode(err, "P0001") {
|
||||||
|
t.Errorf("UPDATE ledger: got %v, want the append-only exception (P0001)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := testDB.ExecContext(ctx, `DELETE FROM payments.ledger WHERE ledger_id = $1`, id); err == nil {
|
||||||
|
t.Error("DELETE on payments.ledger succeeded — append-only violated")
|
||||||
|
} else if !isPgCode(err, "P0001") {
|
||||||
|
t.Errorf("DELETE ledger: got %v, want the append-only exception (P0001)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsCheckConstraints verifies the domain CHECKs bite: non-negative
|
||||||
|
// balances/hints/amount and the enum-like columns.
|
||||||
|
func TestPaymentsCheckConstraints(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
prod := uuid.New()
|
||||||
|
if _, err := testDB.ExecContext(ctx, `INSERT INTO payments.product (product_id, title) VALUES ($1, 'test')`, prod); err != nil {
|
||||||
|
t.Fatalf("insert product: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
sql string
|
||||||
|
args []any
|
||||||
|
}{
|
||||||
|
{"balances chips negative", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'vk', -1)`, []any{uuid.New()}},
|
||||||
|
{"balances bad source", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'nope', 0)`, []any{uuid.New()}},
|
||||||
|
{"benefits hints negative", `INSERT INTO payments.benefits (account_id, origin, hints) VALUES ($1, 'vk', -1)`, []any{uuid.New()}},
|
||||||
|
{"ledger bad kind", `INSERT INTO payments.ledger (ledger_id, account_id, kind) VALUES ($1, $2, 'bogus')`, []any{uuid.New(), uuid.New()}},
|
||||||
|
{"product_price amount negative", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'RUB', -1)`, []any{prod}},
|
||||||
|
{"product_price bad currency", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'EUR', 100)`, []any{prod}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if _, err := testDB.ExecContext(ctx, c.sql, c.args...); err == nil {
|
||||||
|
t.Errorf("%s: insert succeeded, want a CHECK violation", c.name)
|
||||||
|
} else if !isPgCode(err, "23514") {
|
||||||
|
t.Errorf("%s: got %v, want check_violation (23514)", c.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsLedgerIdempotencyIndex verifies the partial unique index makes a
|
||||||
|
// (provider, provider_payment_id) pair collide once but leaves NULL-keyed rows
|
||||||
|
// unconstrained.
|
||||||
|
func TestPaymentsLedgerIdempotencyIndex(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
insert := func(ppid *string) error {
|
||||||
|
_, err := testDB.ExecContext(ctx,
|
||||||
|
`INSERT INTO payments.ledger (ledger_id, account_id, kind, provider, provider_payment_id) VALUES ($1, $2, 'fund', 'robokassa', $3)`,
|
||||||
|
uuid.New(), uuid.New(), ppid)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ppid := "inv-" + uuid.NewString()
|
||||||
|
if err := insert(&ppid); err != nil {
|
||||||
|
t.Fatalf("first insert: %v", err)
|
||||||
|
}
|
||||||
|
if err := insert(&ppid); err == nil {
|
||||||
|
t.Error("duplicate (provider, provider_payment_id) inserted — idempotency index missing")
|
||||||
|
} else if !isPgCode(err, "23505") {
|
||||||
|
t.Errorf("duplicate insert: got %v, want unique_violation (23505)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A NULL provider_payment_id is outside the partial index — repeats allowed.
|
||||||
|
if err := insert(nil); err != nil {
|
||||||
|
t.Fatalf("first null-ppid insert: %v", err)
|
||||||
|
}
|
||||||
|
if err := insert(nil); err != nil {
|
||||||
|
t.Errorf("second null-ppid insert should be allowed by the partial index: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsMigrationReversible proves the migration is expand-contract: it
|
||||||
|
// applies, rolls fully back (schema, role, trigger and all), and re-applies
|
||||||
|
// cleanly — so a backend image rollback stays DB-safe. It uses its own container
|
||||||
|
// so the Down does not disturb the shared suite database.
|
||||||
|
func TestPaymentsMigrationReversible(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
container, err := tcpostgres.Run(ctx, pgImage,
|
||||||
|
tcpostgres.WithDatabase(pgDatabase),
|
||||||
|
tcpostgres.WithUsername(pgUser),
|
||||||
|
tcpostgres.WithPassword(pgPassword),
|
||||||
|
testcontainers.WithWaitStrategy(
|
||||||
|
wait.ForLog("database system is ready to accept connections").
|
||||||
|
WithOccurrence(2).
|
||||||
|
WithStartupTimeout(containerStartup),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start container: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = container.Terminate(context.Background()) }()
|
||||||
|
|
||||||
|
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connection string: %v", err)
|
||||||
|
}
|
||||||
|
dsn, err := withSearchPath(baseDSN, pgSchema)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search path: %v", err)
|
||||||
|
}
|
||||||
|
cfg := postgres.DefaultConfig()
|
||||||
|
cfg.DSN = dsn
|
||||||
|
db, err := postgres.Open(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open pool: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
// Up: apply every migration, including the payments foundation.
|
||||||
|
if err := postgres.ApplyMigrations(ctx, db); err != nil {
|
||||||
|
t.Fatalf("apply up: %v", err)
|
||||||
|
}
|
||||||
|
if !schemaExists(ctx, t, db, "payments") {
|
||||||
|
t.Fatal("payments schema absent after up")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down the payments migration and assert a clean teardown.
|
||||||
|
goose.SetBaseFS(migrations.Migrations())
|
||||||
|
defer goose.SetBaseFS(nil)
|
||||||
|
if err := goose.SetDialect("postgres"); err != nil {
|
||||||
|
t.Fatalf("set dialect: %v", err)
|
||||||
|
}
|
||||||
|
if err := goose.DownToContext(ctx, db, ".", 9); err != nil {
|
||||||
|
t.Fatalf("down to 9: %v", err)
|
||||||
|
}
|
||||||
|
if schemaExists(ctx, t, db, "payments") {
|
||||||
|
t.Error("payments schema survived the down migration")
|
||||||
|
}
|
||||||
|
var roleExists bool
|
||||||
|
if err := db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil {
|
||||||
|
t.Fatalf("check role after down: %v", err)
|
||||||
|
}
|
||||||
|
if roleExists {
|
||||||
|
t.Error("payments role survived the down migration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply and assert it comes back cleanly.
|
||||||
|
if err := goose.UpContext(ctx, db, "."); err != nil {
|
||||||
|
t.Fatalf("re-apply up: %v", err)
|
||||||
|
}
|
||||||
|
if !schemaExists(ctx, t, db, "payments") {
|
||||||
|
t.Fatal("payments schema absent after re-apply")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaExists reports whether a schema of the given name is present.
|
||||||
|
func schemaExists(ctx context.Context, t *testing.T, db *sql.DB, name string) bool {
|
||||||
|
t.Helper()
|
||||||
|
var exists bool
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = $1)", name).Scan(&exists); err != nil {
|
||||||
|
t.Fatalf("check schema %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return exists
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPaymentsSchemaImportBoundary enforces that only this package reaches the
|
||||||
|
// payments jet code. The payments schema is isolated behind this domain; the
|
||||||
|
// application connects to Postgres as a superuser that bypasses the schema
|
||||||
|
// grants, so the runtime wall that keeps every other package out of payments.*
|
||||||
|
// is this import boundary, not the DB privileges. If another package imported
|
||||||
|
// the generated payments tables it could issue SQL against the schema directly,
|
||||||
|
// breaking the single-writer guarantee and the domain's extractability.
|
||||||
|
func TestPaymentsSchemaImportBoundary(t *testing.T) {
|
||||||
|
const jetPkg = "scrabble/backend/internal/postgres/jet/payments"
|
||||||
|
|
||||||
|
_, thisFile, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("cannot resolve the test file path")
|
||||||
|
}
|
||||||
|
// thisFile = .../backend/internal/payments/boundary_test.go
|
||||||
|
backendRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
|
||||||
|
allowedPrefix := filepath.Join(backendRoot, "internal", "payments") + string(filepath.Separator)
|
||||||
|
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
walkErr := filepath.WalkDir(backendRoot, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(path, ".go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
file, perr := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
|
||||||
|
if perr != nil {
|
||||||
|
return perr
|
||||||
|
}
|
||||||
|
for _, imp := range file.Imports {
|
||||||
|
p := strings.Trim(imp.Path.Value, `"`)
|
||||||
|
if p != jetPkg && !strings.HasPrefix(p, jetPkg+"/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, allowedPrefix) {
|
||||||
|
t.Errorf("%s imports %s — only internal/payments may reach the payments jet code", path, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if walkErr != nil {
|
||||||
|
t.Fatalf("walk backend tree: %v", walkErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMoneyFromMinor(t *testing.T) {
|
||||||
|
m, err := MoneyFromMinor(14950, CurrencyRUB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MoneyFromMinor: %v", err)
|
||||||
|
}
|
||||||
|
if m.Minor() != 14950 || m.Currency() != CurrencyRUB {
|
||||||
|
t.Fatalf("got minor=%d currency=%s", m.Minor(), m.Currency())
|
||||||
|
}
|
||||||
|
if _, err := MoneyFromMinor(1, Currency("EUR")); err == nil {
|
||||||
|
t.Fatal("expected an error for an unknown currency")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoneyFromMajor(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
major int64
|
||||||
|
cur Currency
|
||||||
|
minor int64
|
||||||
|
}{
|
||||||
|
{149, CurrencyRUB, 14900}, // roubles scale to kopecks
|
||||||
|
{250, CurrencyStar, 250}, // Stars are whole units
|
||||||
|
{7, CurrencyVote, 7},
|
||||||
|
{50, CurrencyChip, 50},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
m, err := MoneyFromMajor(c.major, c.cur)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MoneyFromMajor(%d,%s): %v", c.major, c.cur, err)
|
||||||
|
}
|
||||||
|
if m.Minor() != c.minor {
|
||||||
|
t.Errorf("MoneyFromMajor(%d,%s): minor=%d, want %d", c.major, c.cur, m.Minor(), c.minor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := MoneyFromMajor(1<<62, CurrencyRUB); err == nil {
|
||||||
|
t.Fatal("expected an overflow error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMoneyExact(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
text string
|
||||||
|
cur Currency
|
||||||
|
minor int64
|
||||||
|
}{
|
||||||
|
{"149.50", CurrencyRUB, 14950},
|
||||||
|
{"149", CurrencyRUB, 14900},
|
||||||
|
{"0.01", CurrencyRUB, 1},
|
||||||
|
{"0.10", CurrencyRUB, 10}, // 0.1 is inexact as a float; big.Rat keeps it exact
|
||||||
|
{"-5.00", CurrencyRUB, -500},
|
||||||
|
{"250", CurrencyStar, 250},
|
||||||
|
{"7", CurrencyVote, 7},
|
||||||
|
{"50", CurrencyChip, 50},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
m, err := ParseMoney(c.text, c.cur)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMoney(%q,%s): %v", c.text, c.cur, err)
|
||||||
|
}
|
||||||
|
if m.Minor() != c.minor {
|
||||||
|
t.Errorf("ParseMoney(%q,%s): minor=%d, want %d", c.text, c.cur, m.Minor(), c.minor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMoneyRejectsFraction(t *testing.T) {
|
||||||
|
// A whole-unit currency (Vote/Star/chip) must never accept a fraction, and
|
||||||
|
// the rouble must never accept finer than a kopeck — the no-float gate.
|
||||||
|
bad := []struct {
|
||||||
|
text string
|
||||||
|
cur Currency
|
||||||
|
}{
|
||||||
|
{"250.5", CurrencyStar},
|
||||||
|
{"1.5", CurrencyChip},
|
||||||
|
{"7.01", CurrencyVote},
|
||||||
|
{"1.999", CurrencyRUB},
|
||||||
|
{"0.001", CurrencyRUB},
|
||||||
|
{"abc", CurrencyRUB},
|
||||||
|
{"12.34", Currency("EUR")}, // unknown currency
|
||||||
|
}
|
||||||
|
for _, c := range bad {
|
||||||
|
if m, err := ParseMoney(c.text, c.cur); err == nil {
|
||||||
|
t.Errorf("ParseMoney(%q,%s) = %d, want an error", c.text, c.cur, m.Minor())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoneyAddCmp(t *testing.T) {
|
||||||
|
a, _ := MoneyFromMinor(14900, CurrencyRUB)
|
||||||
|
b, _ := MoneyFromMinor(50, CurrencyRUB)
|
||||||
|
sum, err := a.Add(b)
|
||||||
|
if err != nil || sum.Minor() != 14950 {
|
||||||
|
t.Fatalf("Add: minor=%d err=%v", sum.Minor(), err)
|
||||||
|
}
|
||||||
|
if c, err := a.Cmp(b); err != nil || c != 1 {
|
||||||
|
t.Fatalf("Cmp a>b: got %d err=%v", c, err)
|
||||||
|
}
|
||||||
|
if c, err := b.Cmp(a); err != nil || c != -1 {
|
||||||
|
t.Fatalf("Cmp b<a: got %d err=%v", c, err)
|
||||||
|
}
|
||||||
|
if c, err := a.Cmp(a); err != nil || c != 0 {
|
||||||
|
t.Fatalf("Cmp a==a: got %d err=%v", c, err)
|
||||||
|
}
|
||||||
|
// Cross-currency arithmetic is refused, not silently coerced.
|
||||||
|
star, _ := MoneyFromMinor(1, CurrencyStar)
|
||||||
|
if _, err := a.Add(star); err == nil {
|
||||||
|
t.Error("Add across currencies: expected an error")
|
||||||
|
}
|
||||||
|
if _, err := a.Cmp(star); err == nil {
|
||||||
|
t.Error("Cmp across currencies: expected an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoneyString(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
minor int64
|
||||||
|
cur Currency
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{14900, CurrencyRUB, "149.00 RUB"},
|
||||||
|
{14950, CurrencyRUB, "149.50 RUB"},
|
||||||
|
{1, CurrencyRUB, "0.01 RUB"},
|
||||||
|
{-500, CurrencyRUB, "-5.00 RUB"},
|
||||||
|
{250, CurrencyStar, "250 XTR"},
|
||||||
|
{5, CurrencyChip, "5 CHIP"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
m, _ := MoneyFromMinor(c.minor, c.cur)
|
||||||
|
if got := m.String(); got != c.want {
|
||||||
|
t.Errorf("Money{%d,%s}.String() = %q, want %q", c.minor, c.cur, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Package payments is the in-game currency, wallet, benefit and catalog domain.
|
||||||
|
//
|
||||||
|
// It owns its own Postgres schema (payments) and is the only backend package
|
||||||
|
// that issues SQL against it — an import-boundary test forbids any other package
|
||||||
|
// from importing the payments jet code, which keeps the domain extractable into
|
||||||
|
// its own database or process later. There is no cross-schema foreign key to the
|
||||||
|
// account schema: an account id is a plain uuid here, kept referentially honest
|
||||||
|
// in code.
|
||||||
|
//
|
||||||
|
// Money is carried exclusively by [Money] — an exact integer amount in a
|
||||||
|
// currency's minor units — so no floating-point value ever reaches a monetary
|
||||||
|
// amount, and a whole-unit currency (Vote, Star, chip) can never hold a
|
||||||
|
// fraction. This file is the data-foundation layer: the currency value type; the
|
||||||
|
// wallet mechanics build on the schema and this package later.
|
||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Currency identifies the unit a monetary amount is denominated in.
|
||||||
|
type Currency string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CurrencyRUB is the Russian rouble; its minor unit is the kopeck (1/100).
|
||||||
|
CurrencyRUB Currency = "RUB"
|
||||||
|
// CurrencyVote is the VK Vote — a whole unit with no sub-unit.
|
||||||
|
CurrencyVote Currency = "VOTE"
|
||||||
|
// CurrencyStar is the Telegram Star (XTR) — a whole unit with no sub-unit.
|
||||||
|
CurrencyStar Currency = "XTR"
|
||||||
|
// CurrencyChip is the in-game chip, the unit a value's price is quoted in —
|
||||||
|
// a whole unit with no sub-unit.
|
||||||
|
CurrencyChip Currency = "CHIP"
|
||||||
|
)
|
||||||
|
|
||||||
|
// minorPerUnit reports how many minor units make one major unit of the currency.
|
||||||
|
// Every currency except the rouble is a whole-unit currency (scale 1), so an
|
||||||
|
// amount in it structurally cannot carry a fraction.
|
||||||
|
func (c Currency) minorPerUnit() int64 {
|
||||||
|
if c == CurrencyRUB {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid reports whether the currency is one of the known units.
|
||||||
|
func (c Currency) Valid() bool {
|
||||||
|
switch c {
|
||||||
|
case CurrencyRUB, CurrencyVote, CurrencyStar, CurrencyChip:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Money is an exact monetary amount: a signed integer count of a currency's
|
||||||
|
// minor units (rouble kopecks; Vote/Star/chip whole units). It is the sole
|
||||||
|
// carrier of money in the payments domain — construction, arithmetic and
|
||||||
|
// formatting all go through it, so no float ever reaches an amount and a
|
||||||
|
// whole-unit currency can never hold a fraction. The zero value has an empty,
|
||||||
|
// invalid currency; build a value with [MoneyFromMinor], [MoneyFromMajor] or
|
||||||
|
// [ParseMoney].
|
||||||
|
type Money struct {
|
||||||
|
minor int64
|
||||||
|
currency Currency
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoneyFromMinor builds a Money from a raw count of the currency's minor units
|
||||||
|
// (kopecks for the rouble, whole units otherwise). It errors on an unknown
|
||||||
|
// currency.
|
||||||
|
func MoneyFromMinor(minor int64, c Currency) (Money, error) {
|
||||||
|
if !c.Valid() {
|
||||||
|
return Money{}, fmt.Errorf("payments: unknown currency %q", c)
|
||||||
|
}
|
||||||
|
return Money{minor: minor, currency: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoneyFromMajor builds a Money from a whole number of major units (roubles,
|
||||||
|
// Votes, Stars, chips). It errors on an unknown currency or on overflow.
|
||||||
|
func MoneyFromMajor(major int64, c Currency) (Money, error) {
|
||||||
|
if !c.Valid() {
|
||||||
|
return Money{}, fmt.Errorf("payments: unknown currency %q", c)
|
||||||
|
}
|
||||||
|
scaled := new(big.Int).Mul(big.NewInt(major), big.NewInt(c.minorPerUnit()))
|
||||||
|
if !scaled.IsInt64() {
|
||||||
|
return Money{}, fmt.Errorf("payments: %d %s overflows", major, c)
|
||||||
|
}
|
||||||
|
return Money{minor: scaled.Int64(), currency: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMoney parses a decimal amount (e.g. "149.50", "250") in the currency,
|
||||||
|
// exactly and without floating point (via math/big). It rejects a value with
|
||||||
|
// finer precision than the currency allows — in particular, any fractional part
|
||||||
|
// for a whole-unit currency — which is the gate that keeps a fraction out of an
|
||||||
|
// integer currency.
|
||||||
|
func ParseMoney(text string, c Currency) (Money, error) {
|
||||||
|
if !c.Valid() {
|
||||||
|
return Money{}, fmt.Errorf("payments: unknown currency %q", c)
|
||||||
|
}
|
||||||
|
r, ok := new(big.Rat).SetString(text)
|
||||||
|
if !ok {
|
||||||
|
return Money{}, fmt.Errorf("payments: %q is not a valid amount", text)
|
||||||
|
}
|
||||||
|
scaled := new(big.Rat).Mul(r, new(big.Rat).SetInt64(c.minorPerUnit()))
|
||||||
|
if !scaled.IsInt() {
|
||||||
|
return Money{}, fmt.Errorf("payments: %q has finer precision than %s allows", text, c)
|
||||||
|
}
|
||||||
|
num := scaled.Num() // the denominator is 1 once scaled to an integer
|
||||||
|
if !num.IsInt64() {
|
||||||
|
return Money{}, fmt.Errorf("payments: %q overflows %s", text, c)
|
||||||
|
}
|
||||||
|
return Money{minor: num.Int64(), currency: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minor returns the amount as a count of the currency's minor units — the value
|
||||||
|
// persisted to an amount column.
|
||||||
|
func (m Money) Minor() int64 { return m.minor }
|
||||||
|
|
||||||
|
// Currency returns the amount's currency.
|
||||||
|
func (m Money) Currency() Currency { return m.currency }
|
||||||
|
|
||||||
|
// IsZero reports whether the amount is zero.
|
||||||
|
func (m Money) IsZero() bool { return m.minor == 0 }
|
||||||
|
|
||||||
|
// Add returns the sum of the two amounts. It errors when the currencies differ.
|
||||||
|
func (m Money) Add(o Money) (Money, error) {
|
||||||
|
if m.currency != o.currency {
|
||||||
|
return Money{}, fmt.Errorf("payments: cannot add %s to %s", o.currency, m.currency)
|
||||||
|
}
|
||||||
|
return Money{minor: m.minor + o.minor, currency: m.currency}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmp compares the two amounts, returning -1, 0 or +1. It errors when the
|
||||||
|
// currencies differ.
|
||||||
|
func (m Money) Cmp(o Money) (int, error) {
|
||||||
|
if m.currency != o.currency {
|
||||||
|
return 0, fmt.Errorf("payments: cannot compare %s to %s", o.currency, m.currency)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case m.minor < o.minor:
|
||||||
|
return -1, nil
|
||||||
|
case m.minor > o.minor:
|
||||||
|
return 1, nil
|
||||||
|
default:
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the amount as "<value> <currency>", with the currency's
|
||||||
|
// fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR").
|
||||||
|
func (m Money) String() string {
|
||||||
|
scale := m.currency.minorPerUnit()
|
||||||
|
if scale == 1 {
|
||||||
|
return fmt.Sprintf("%d %s", m.minor, m.currency)
|
||||||
|
}
|
||||||
|
neg := m.minor < 0
|
||||||
|
abs := m.minor
|
||||||
|
if neg {
|
||||||
|
abs = -abs
|
||||||
|
}
|
||||||
|
width := 0
|
||||||
|
for s := scale; s > 1; s /= 10 {
|
||||||
|
width++
|
||||||
|
}
|
||||||
|
sign := ""
|
||||||
|
if neg {
|
||||||
|
sign = "-"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%d.%0*d %s", sign, abs/scale, width, abs%scale, m.currency)
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/postgres/jet/payments/model"
|
||||||
|
"scrabble/backend/internal/postgres/jet/payments/table"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the Postgres-backed query surface for the payments schema. It is the
|
||||||
|
// only place in the backend that issues SQL against payments.* (an
|
||||||
|
// import-boundary test enforces it), so the domain stays extractable into its
|
||||||
|
// own database.
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore constructs a Store wrapping db.
|
||||||
|
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||||
|
|
||||||
|
// Ping verifies the payments schema is reachable by reading the singleton config
|
||||||
|
// row. It is the data-foundation health check; the wallet query surface arrives
|
||||||
|
// with the currency mechanics.
|
||||||
|
func (s *Store) Ping(ctx context.Context) error {
|
||||||
|
var row model.Config
|
||||||
|
if err := postgres.SELECT(table.Config.AllColumns).
|
||||||
|
FROM(table.Config).
|
||||||
|
LIMIT(1).
|
||||||
|
QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
return fmt.Errorf("payments: ping: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Balances struct {
|
||||||
|
AccountID uuid.UUID `sql:"primary_key"`
|
||||||
|
Source string `sql:"primary_key"`
|
||||||
|
Chips int32
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Benefits struct {
|
||||||
|
AccountID uuid.UUID `sql:"primary_key"`
|
||||||
|
Origin string `sql:"primary_key"`
|
||||||
|
AdsPaidUntil *time.Time
|
||||||
|
AdsForever bool
|
||||||
|
Hints int32
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
type CatalogAtom struct {
|
||||||
|
AtomType string `sql:"primary_key"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
OnlyRow bool `sql:"primary_key"`
|
||||||
|
RewardedPayoutChips int32
|
||||||
|
CooldownGlobalSeconds int32
|
||||||
|
CooldownVsAiSeconds int32
|
||||||
|
CooldownHintSeconds int32
|
||||||
|
OrderTTLSeconds int32
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Ledger struct {
|
||||||
|
LedgerID uuid.UUID `sql:"primary_key"`
|
||||||
|
AccountID uuid.UUID
|
||||||
|
Kind string
|
||||||
|
Source *string
|
||||||
|
Origin *string
|
||||||
|
ChipsDelta int32
|
||||||
|
ProductID *uuid.UUID
|
||||||
|
OrderID *uuid.UUID
|
||||||
|
Provider *string
|
||||||
|
ProviderPaymentID *string
|
||||||
|
Snapshot *string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Orders struct {
|
||||||
|
OrderID uuid.UUID `sql:"primary_key"`
|
||||||
|
AccountID uuid.UUID
|
||||||
|
Platform string
|
||||||
|
ProductID uuid.UUID
|
||||||
|
ExpectedAmount int64
|
||||||
|
Currency string
|
||||||
|
Origin string
|
||||||
|
Status string
|
||||||
|
Provider *string
|
||||||
|
ProviderPaymentID *string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PaymentEvents struct {
|
||||||
|
EventID uuid.UUID `sql:"primary_key"`
|
||||||
|
AccountID uuid.UUID
|
||||||
|
OrderID *uuid.UUID
|
||||||
|
Type string
|
||||||
|
Payload *string
|
||||||
|
CreatedAt time.Time
|
||||||
|
DispatchedAt *time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Product struct {
|
||||||
|
ProductID uuid.UUID `sql:"primary_key"`
|
||||||
|
Title string
|
||||||
|
Active bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProductItem struct {
|
||||||
|
ProductID uuid.UUID `sql:"primary_key"`
|
||||||
|
AtomType string `sql:"primary_key"`
|
||||||
|
Quantity int32
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProductPrice struct {
|
||||||
|
ProductID uuid.UUID
|
||||||
|
Method *string
|
||||||
|
Currency string
|
||||||
|
Amount int64
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Balances = newBalancesTable("payments", "balances", "")
|
||||||
|
|
||||||
|
type balancesTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
AccountID postgres.ColumnString
|
||||||
|
Source postgres.ColumnString
|
||||||
|
Chips postgres.ColumnInteger
|
||||||
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type BalancesTable struct {
|
||||||
|
balancesTable
|
||||||
|
|
||||||
|
EXCLUDED balancesTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new BalancesTable with assigned alias
|
||||||
|
func (a BalancesTable) AS(alias string) *BalancesTable {
|
||||||
|
return newBalancesTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new BalancesTable with assigned schema name
|
||||||
|
func (a BalancesTable) FromSchema(schemaName string) *BalancesTable {
|
||||||
|
return newBalancesTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new BalancesTable with assigned table prefix
|
||||||
|
func (a BalancesTable) WithPrefix(prefix string) *BalancesTable {
|
||||||
|
return newBalancesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new BalancesTable with assigned table suffix
|
||||||
|
func (a BalancesTable) WithSuffix(suffix string) *BalancesTable {
|
||||||
|
return newBalancesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBalancesTable(schemaName, tableName, alias string) *BalancesTable {
|
||||||
|
return &BalancesTable{
|
||||||
|
balancesTable: newBalancesTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newBalancesTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBalancesTableImpl(schemaName, tableName, alias string) balancesTable {
|
||||||
|
var (
|
||||||
|
AccountIDColumn = postgres.StringColumn("account_id")
|
||||||
|
SourceColumn = postgres.StringColumn("source")
|
||||||
|
ChipsColumn = postgres.IntegerColumn("chips")
|
||||||
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
|
allColumns = postgres.ColumnList{AccountIDColumn, SourceColumn, ChipsColumn, UpdatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{ChipsColumn, UpdatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{ChipsColumn, UpdatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return balancesTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
AccountID: AccountIDColumn,
|
||||||
|
Source: SourceColumn,
|
||||||
|
Chips: ChipsColumn,
|
||||||
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Benefits = newBenefitsTable("payments", "benefits", "")
|
||||||
|
|
||||||
|
type benefitsTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
AccountID postgres.ColumnString
|
||||||
|
Origin postgres.ColumnString
|
||||||
|
AdsPaidUntil postgres.ColumnTimestampz
|
||||||
|
AdsForever postgres.ColumnBool
|
||||||
|
Hints postgres.ColumnInteger
|
||||||
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type BenefitsTable struct {
|
||||||
|
benefitsTable
|
||||||
|
|
||||||
|
EXCLUDED benefitsTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new BenefitsTable with assigned alias
|
||||||
|
func (a BenefitsTable) AS(alias string) *BenefitsTable {
|
||||||
|
return newBenefitsTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new BenefitsTable with assigned schema name
|
||||||
|
func (a BenefitsTable) FromSchema(schemaName string) *BenefitsTable {
|
||||||
|
return newBenefitsTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new BenefitsTable with assigned table prefix
|
||||||
|
func (a BenefitsTable) WithPrefix(prefix string) *BenefitsTable {
|
||||||
|
return newBenefitsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new BenefitsTable with assigned table suffix
|
||||||
|
func (a BenefitsTable) WithSuffix(suffix string) *BenefitsTable {
|
||||||
|
return newBenefitsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBenefitsTable(schemaName, tableName, alias string) *BenefitsTable {
|
||||||
|
return &BenefitsTable{
|
||||||
|
benefitsTable: newBenefitsTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newBenefitsTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBenefitsTableImpl(schemaName, tableName, alias string) benefitsTable {
|
||||||
|
var (
|
||||||
|
AccountIDColumn = postgres.StringColumn("account_id")
|
||||||
|
OriginColumn = postgres.StringColumn("origin")
|
||||||
|
AdsPaidUntilColumn = postgres.TimestampzColumn("ads_paid_until")
|
||||||
|
AdsForeverColumn = postgres.BoolColumn("ads_forever")
|
||||||
|
HintsColumn = postgres.IntegerColumn("hints")
|
||||||
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
|
allColumns = postgres.ColumnList{AccountIDColumn, OriginColumn, AdsPaidUntilColumn, AdsForeverColumn, HintsColumn, UpdatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{AdsPaidUntilColumn, AdsForeverColumn, HintsColumn, UpdatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{AdsForeverColumn, HintsColumn, UpdatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return benefitsTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
AccountID: AccountIDColumn,
|
||||||
|
Origin: OriginColumn,
|
||||||
|
AdsPaidUntil: AdsPaidUntilColumn,
|
||||||
|
AdsForever: AdsForeverColumn,
|
||||||
|
Hints: HintsColumn,
|
||||||
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var CatalogAtom = newCatalogAtomTable("payments", "catalog_atom", "")
|
||||||
|
|
||||||
|
type catalogAtomTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
AtomType postgres.ColumnString
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type CatalogAtomTable struct {
|
||||||
|
catalogAtomTable
|
||||||
|
|
||||||
|
EXCLUDED catalogAtomTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new CatalogAtomTable with assigned alias
|
||||||
|
func (a CatalogAtomTable) AS(alias string) *CatalogAtomTable {
|
||||||
|
return newCatalogAtomTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new CatalogAtomTable with assigned schema name
|
||||||
|
func (a CatalogAtomTable) FromSchema(schemaName string) *CatalogAtomTable {
|
||||||
|
return newCatalogAtomTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new CatalogAtomTable with assigned table prefix
|
||||||
|
func (a CatalogAtomTable) WithPrefix(prefix string) *CatalogAtomTable {
|
||||||
|
return newCatalogAtomTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new CatalogAtomTable with assigned table suffix
|
||||||
|
func (a CatalogAtomTable) WithSuffix(suffix string) *CatalogAtomTable {
|
||||||
|
return newCatalogAtomTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCatalogAtomTable(schemaName, tableName, alias string) *CatalogAtomTable {
|
||||||
|
return &CatalogAtomTable{
|
||||||
|
catalogAtomTable: newCatalogAtomTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newCatalogAtomTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCatalogAtomTableImpl(schemaName, tableName, alias string) catalogAtomTable {
|
||||||
|
var (
|
||||||
|
AtomTypeColumn = postgres.StringColumn("atom_type")
|
||||||
|
allColumns = postgres.ColumnList{AtomTypeColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{}
|
||||||
|
defaultColumns = postgres.ColumnList{}
|
||||||
|
)
|
||||||
|
|
||||||
|
return catalogAtomTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
AtomType: AtomTypeColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Config = newConfigTable("payments", "config", "")
|
||||||
|
|
||||||
|
type configTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
OnlyRow postgres.ColumnBool
|
||||||
|
RewardedPayoutChips postgres.ColumnInteger
|
||||||
|
CooldownGlobalSeconds postgres.ColumnInteger
|
||||||
|
CooldownVsAiSeconds postgres.ColumnInteger
|
||||||
|
CooldownHintSeconds postgres.ColumnInteger
|
||||||
|
OrderTTLSeconds postgres.ColumnInteger
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigTable struct {
|
||||||
|
configTable
|
||||||
|
|
||||||
|
EXCLUDED configTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new ConfigTable with assigned alias
|
||||||
|
func (a ConfigTable) AS(alias string) *ConfigTable {
|
||||||
|
return newConfigTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new ConfigTable with assigned schema name
|
||||||
|
func (a ConfigTable) FromSchema(schemaName string) *ConfigTable {
|
||||||
|
return newConfigTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new ConfigTable with assigned table prefix
|
||||||
|
func (a ConfigTable) WithPrefix(prefix string) *ConfigTable {
|
||||||
|
return newConfigTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new ConfigTable with assigned table suffix
|
||||||
|
func (a ConfigTable) WithSuffix(suffix string) *ConfigTable {
|
||||||
|
return newConfigTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConfigTable(schemaName, tableName, alias string) *ConfigTable {
|
||||||
|
return &ConfigTable{
|
||||||
|
configTable: newConfigTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newConfigTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConfigTableImpl(schemaName, tableName, alias string) configTable {
|
||||||
|
var (
|
||||||
|
OnlyRowColumn = postgres.BoolColumn("only_row")
|
||||||
|
RewardedPayoutChipsColumn = postgres.IntegerColumn("rewarded_payout_chips")
|
||||||
|
CooldownGlobalSecondsColumn = postgres.IntegerColumn("cooldown_global_seconds")
|
||||||
|
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
|
||||||
|
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
|
||||||
|
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
|
||||||
|
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return configTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
OnlyRow: OnlyRowColumn,
|
||||||
|
RewardedPayoutChips: RewardedPayoutChipsColumn,
|
||||||
|
CooldownGlobalSeconds: CooldownGlobalSecondsColumn,
|
||||||
|
CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
|
||||||
|
CooldownHintSeconds: CooldownHintSecondsColumn,
|
||||||
|
OrderTTLSeconds: OrderTTLSecondsColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Ledger = newLedgerTable("payments", "ledger", "")
|
||||||
|
|
||||||
|
type ledgerTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
LedgerID postgres.ColumnString
|
||||||
|
AccountID postgres.ColumnString
|
||||||
|
Kind postgres.ColumnString
|
||||||
|
Source postgres.ColumnString
|
||||||
|
Origin postgres.ColumnString
|
||||||
|
ChipsDelta postgres.ColumnInteger
|
||||||
|
ProductID postgres.ColumnString
|
||||||
|
OrderID postgres.ColumnString
|
||||||
|
Provider postgres.ColumnString
|
||||||
|
ProviderPaymentID postgres.ColumnString
|
||||||
|
Snapshot postgres.ColumnString
|
||||||
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type LedgerTable struct {
|
||||||
|
ledgerTable
|
||||||
|
|
||||||
|
EXCLUDED ledgerTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new LedgerTable with assigned alias
|
||||||
|
func (a LedgerTable) AS(alias string) *LedgerTable {
|
||||||
|
return newLedgerTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new LedgerTable with assigned schema name
|
||||||
|
func (a LedgerTable) FromSchema(schemaName string) *LedgerTable {
|
||||||
|
return newLedgerTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new LedgerTable with assigned table prefix
|
||||||
|
func (a LedgerTable) WithPrefix(prefix string) *LedgerTable {
|
||||||
|
return newLedgerTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new LedgerTable with assigned table suffix
|
||||||
|
func (a LedgerTable) WithSuffix(suffix string) *LedgerTable {
|
||||||
|
return newLedgerTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLedgerTable(schemaName, tableName, alias string) *LedgerTable {
|
||||||
|
return &LedgerTable{
|
||||||
|
ledgerTable: newLedgerTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newLedgerTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLedgerTableImpl(schemaName, tableName, alias string) ledgerTable {
|
||||||
|
var (
|
||||||
|
LedgerIDColumn = postgres.StringColumn("ledger_id")
|
||||||
|
AccountIDColumn = postgres.StringColumn("account_id")
|
||||||
|
KindColumn = postgres.StringColumn("kind")
|
||||||
|
SourceColumn = postgres.StringColumn("source")
|
||||||
|
OriginColumn = postgres.StringColumn("origin")
|
||||||
|
ChipsDeltaColumn = postgres.IntegerColumn("chips_delta")
|
||||||
|
ProductIDColumn = postgres.StringColumn("product_id")
|
||||||
|
OrderIDColumn = postgres.StringColumn("order_id")
|
||||||
|
ProviderColumn = postgres.StringColumn("provider")
|
||||||
|
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
|
||||||
|
SnapshotColumn = postgres.StringColumn("snapshot")
|
||||||
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
|
allColumns = postgres.ColumnList{LedgerIDColumn, AccountIDColumn, KindColumn, SourceColumn, OriginColumn, ChipsDeltaColumn, ProductIDColumn, OrderIDColumn, ProviderColumn, ProviderPaymentIDColumn, SnapshotColumn, CreatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{AccountIDColumn, KindColumn, SourceColumn, OriginColumn, ChipsDeltaColumn, ProductIDColumn, OrderIDColumn, ProviderColumn, ProviderPaymentIDColumn, SnapshotColumn, CreatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{ChipsDeltaColumn, CreatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return ledgerTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
LedgerID: LedgerIDColumn,
|
||||||
|
AccountID: AccountIDColumn,
|
||||||
|
Kind: KindColumn,
|
||||||
|
Source: SourceColumn,
|
||||||
|
Origin: OriginColumn,
|
||||||
|
ChipsDelta: ChipsDeltaColumn,
|
||||||
|
ProductID: ProductIDColumn,
|
||||||
|
OrderID: OrderIDColumn,
|
||||||
|
Provider: ProviderColumn,
|
||||||
|
ProviderPaymentID: ProviderPaymentIDColumn,
|
||||||
|
Snapshot: SnapshotColumn,
|
||||||
|
CreatedAt: CreatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Orders = newOrdersTable("payments", "orders", "")
|
||||||
|
|
||||||
|
type ordersTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
OrderID postgres.ColumnString
|
||||||
|
AccountID postgres.ColumnString
|
||||||
|
Platform postgres.ColumnString
|
||||||
|
ProductID postgres.ColumnString
|
||||||
|
ExpectedAmount postgres.ColumnInteger
|
||||||
|
Currency postgres.ColumnString
|
||||||
|
Origin postgres.ColumnString
|
||||||
|
Status postgres.ColumnString
|
||||||
|
Provider postgres.ColumnString
|
||||||
|
ProviderPaymentID postgres.ColumnString
|
||||||
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrdersTable struct {
|
||||||
|
ordersTable
|
||||||
|
|
||||||
|
EXCLUDED ordersTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new OrdersTable with assigned alias
|
||||||
|
func (a OrdersTable) AS(alias string) *OrdersTable {
|
||||||
|
return newOrdersTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new OrdersTable with assigned schema name
|
||||||
|
func (a OrdersTable) FromSchema(schemaName string) *OrdersTable {
|
||||||
|
return newOrdersTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new OrdersTable with assigned table prefix
|
||||||
|
func (a OrdersTable) WithPrefix(prefix string) *OrdersTable {
|
||||||
|
return newOrdersTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new OrdersTable with assigned table suffix
|
||||||
|
func (a OrdersTable) WithSuffix(suffix string) *OrdersTable {
|
||||||
|
return newOrdersTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOrdersTable(schemaName, tableName, alias string) *OrdersTable {
|
||||||
|
return &OrdersTable{
|
||||||
|
ordersTable: newOrdersTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newOrdersTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
|
||||||
|
var (
|
||||||
|
OrderIDColumn = postgres.StringColumn("order_id")
|
||||||
|
AccountIDColumn = postgres.StringColumn("account_id")
|
||||||
|
PlatformColumn = postgres.StringColumn("platform")
|
||||||
|
ProductIDColumn = postgres.StringColumn("product_id")
|
||||||
|
ExpectedAmountColumn = postgres.IntegerColumn("expected_amount")
|
||||||
|
CurrencyColumn = postgres.StringColumn("currency")
|
||||||
|
OriginColumn = postgres.StringColumn("origin")
|
||||||
|
StatusColumn = postgres.StringColumn("status")
|
||||||
|
ProviderColumn = postgres.StringColumn("provider")
|
||||||
|
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
|
||||||
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
|
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return ordersTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
OrderID: OrderIDColumn,
|
||||||
|
AccountID: AccountIDColumn,
|
||||||
|
Platform: PlatformColumn,
|
||||||
|
ProductID: ProductIDColumn,
|
||||||
|
ExpectedAmount: ExpectedAmountColumn,
|
||||||
|
Currency: CurrencyColumn,
|
||||||
|
Origin: OriginColumn,
|
||||||
|
Status: StatusColumn,
|
||||||
|
Provider: ProviderColumn,
|
||||||
|
ProviderPaymentID: ProviderPaymentIDColumn,
|
||||||
|
CreatedAt: CreatedAtColumn,
|
||||||
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var PaymentEvents = newPaymentEventsTable("payments", "payment_events", "")
|
||||||
|
|
||||||
|
type paymentEventsTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
EventID postgres.ColumnString
|
||||||
|
AccountID postgres.ColumnString
|
||||||
|
OrderID postgres.ColumnString
|
||||||
|
Type postgres.ColumnString
|
||||||
|
Payload postgres.ColumnString
|
||||||
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
DispatchedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaymentEventsTable struct {
|
||||||
|
paymentEventsTable
|
||||||
|
|
||||||
|
EXCLUDED paymentEventsTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new PaymentEventsTable with assigned alias
|
||||||
|
func (a PaymentEventsTable) AS(alias string) *PaymentEventsTable {
|
||||||
|
return newPaymentEventsTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new PaymentEventsTable with assigned schema name
|
||||||
|
func (a PaymentEventsTable) FromSchema(schemaName string) *PaymentEventsTable {
|
||||||
|
return newPaymentEventsTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new PaymentEventsTable with assigned table prefix
|
||||||
|
func (a PaymentEventsTable) WithPrefix(prefix string) *PaymentEventsTable {
|
||||||
|
return newPaymentEventsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new PaymentEventsTable with assigned table suffix
|
||||||
|
func (a PaymentEventsTable) WithSuffix(suffix string) *PaymentEventsTable {
|
||||||
|
return newPaymentEventsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPaymentEventsTable(schemaName, tableName, alias string) *PaymentEventsTable {
|
||||||
|
return &PaymentEventsTable{
|
||||||
|
paymentEventsTable: newPaymentEventsTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newPaymentEventsTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPaymentEventsTableImpl(schemaName, tableName, alias string) paymentEventsTable {
|
||||||
|
var (
|
||||||
|
EventIDColumn = postgres.StringColumn("event_id")
|
||||||
|
AccountIDColumn = postgres.StringColumn("account_id")
|
||||||
|
OrderIDColumn = postgres.StringColumn("order_id")
|
||||||
|
TypeColumn = postgres.StringColumn("type")
|
||||||
|
PayloadColumn = postgres.StringColumn("payload")
|
||||||
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
|
DispatchedAtColumn = postgres.TimestampzColumn("dispatched_at")
|
||||||
|
allColumns = postgres.ColumnList{EventIDColumn, AccountIDColumn, OrderIDColumn, TypeColumn, PayloadColumn, CreatedAtColumn, DispatchedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{AccountIDColumn, OrderIDColumn, TypeColumn, PayloadColumn, CreatedAtColumn, DispatchedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return paymentEventsTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
EventID: EventIDColumn,
|
||||||
|
AccountID: AccountIDColumn,
|
||||||
|
OrderID: OrderIDColumn,
|
||||||
|
Type: TypeColumn,
|
||||||
|
Payload: PayloadColumn,
|
||||||
|
CreatedAt: CreatedAtColumn,
|
||||||
|
DispatchedAt: DispatchedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Product = newProductTable("payments", "product", "")
|
||||||
|
|
||||||
|
type productTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
ProductID postgres.ColumnString
|
||||||
|
Title postgres.ColumnString
|
||||||
|
Active postgres.ColumnBool
|
||||||
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductTable struct {
|
||||||
|
productTable
|
||||||
|
|
||||||
|
EXCLUDED productTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new ProductTable with assigned alias
|
||||||
|
func (a ProductTable) AS(alias string) *ProductTable {
|
||||||
|
return newProductTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new ProductTable with assigned schema name
|
||||||
|
func (a ProductTable) FromSchema(schemaName string) *ProductTable {
|
||||||
|
return newProductTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new ProductTable with assigned table prefix
|
||||||
|
func (a ProductTable) WithPrefix(prefix string) *ProductTable {
|
||||||
|
return newProductTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new ProductTable with assigned table suffix
|
||||||
|
func (a ProductTable) WithSuffix(suffix string) *ProductTable {
|
||||||
|
return newProductTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProductTable(schemaName, tableName, alias string) *ProductTable {
|
||||||
|
return &ProductTable{
|
||||||
|
productTable: newProductTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newProductTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProductTableImpl(schemaName, tableName, alias string) productTable {
|
||||||
|
var (
|
||||||
|
ProductIDColumn = postgres.StringColumn("product_id")
|
||||||
|
TitleColumn = postgres.StringColumn("title")
|
||||||
|
ActiveColumn = postgres.BoolColumn("active")
|
||||||
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
|
allColumns = postgres.ColumnList{ProductIDColumn, TitleColumn, ActiveColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{TitleColumn, ActiveColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{TitleColumn, ActiveColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return productTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
ProductID: ProductIDColumn,
|
||||||
|
Title: TitleColumn,
|
||||||
|
Active: ActiveColumn,
|
||||||
|
CreatedAt: CreatedAtColumn,
|
||||||
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ProductItem = newProductItemTable("payments", "product_item", "")
|
||||||
|
|
||||||
|
type productItemTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
ProductID postgres.ColumnString
|
||||||
|
AtomType postgres.ColumnString
|
||||||
|
Quantity postgres.ColumnInteger
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductItemTable struct {
|
||||||
|
productItemTable
|
||||||
|
|
||||||
|
EXCLUDED productItemTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new ProductItemTable with assigned alias
|
||||||
|
func (a ProductItemTable) AS(alias string) *ProductItemTable {
|
||||||
|
return newProductItemTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new ProductItemTable with assigned schema name
|
||||||
|
func (a ProductItemTable) FromSchema(schemaName string) *ProductItemTable {
|
||||||
|
return newProductItemTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new ProductItemTable with assigned table prefix
|
||||||
|
func (a ProductItemTable) WithPrefix(prefix string) *ProductItemTable {
|
||||||
|
return newProductItemTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new ProductItemTable with assigned table suffix
|
||||||
|
func (a ProductItemTable) WithSuffix(suffix string) *ProductItemTable {
|
||||||
|
return newProductItemTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProductItemTable(schemaName, tableName, alias string) *ProductItemTable {
|
||||||
|
return &ProductItemTable{
|
||||||
|
productItemTable: newProductItemTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newProductItemTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProductItemTableImpl(schemaName, tableName, alias string) productItemTable {
|
||||||
|
var (
|
||||||
|
ProductIDColumn = postgres.StringColumn("product_id")
|
||||||
|
AtomTypeColumn = postgres.StringColumn("atom_type")
|
||||||
|
QuantityColumn = postgres.IntegerColumn("quantity")
|
||||||
|
allColumns = postgres.ColumnList{ProductIDColumn, AtomTypeColumn, QuantityColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{QuantityColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{}
|
||||||
|
)
|
||||||
|
|
||||||
|
return productItemTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
ProductID: ProductIDColumn,
|
||||||
|
AtomType: AtomTypeColumn,
|
||||||
|
Quantity: QuantityColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ProductPrice = newProductPriceTable("payments", "product_price", "")
|
||||||
|
|
||||||
|
type productPriceTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
ProductID postgres.ColumnString
|
||||||
|
Method postgres.ColumnString
|
||||||
|
Currency postgres.ColumnString
|
||||||
|
Amount postgres.ColumnInteger
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductPriceTable struct {
|
||||||
|
productPriceTable
|
||||||
|
|
||||||
|
EXCLUDED productPriceTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new ProductPriceTable with assigned alias
|
||||||
|
func (a ProductPriceTable) AS(alias string) *ProductPriceTable {
|
||||||
|
return newProductPriceTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new ProductPriceTable with assigned schema name
|
||||||
|
func (a ProductPriceTable) FromSchema(schemaName string) *ProductPriceTable {
|
||||||
|
return newProductPriceTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new ProductPriceTable with assigned table prefix
|
||||||
|
func (a ProductPriceTable) WithPrefix(prefix string) *ProductPriceTable {
|
||||||
|
return newProductPriceTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new ProductPriceTable with assigned table suffix
|
||||||
|
func (a ProductPriceTable) WithSuffix(suffix string) *ProductPriceTable {
|
||||||
|
return newProductPriceTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProductPriceTable(schemaName, tableName, alias string) *ProductPriceTable {
|
||||||
|
return &ProductPriceTable{
|
||||||
|
productPriceTable: newProductPriceTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newProductPriceTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProductPriceTableImpl(schemaName, tableName, alias string) productPriceTable {
|
||||||
|
var (
|
||||||
|
ProductIDColumn = postgres.StringColumn("product_id")
|
||||||
|
MethodColumn = postgres.StringColumn("method")
|
||||||
|
CurrencyColumn = postgres.StringColumn("currency")
|
||||||
|
AmountColumn = postgres.IntegerColumn("amount")
|
||||||
|
allColumns = postgres.ColumnList{ProductIDColumn, MethodColumn, CurrencyColumn, AmountColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{ProductIDColumn, MethodColumn, CurrencyColumn, AmountColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{}
|
||||||
|
)
|
||||||
|
|
||||||
|
return productPriceTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
ProductID: ProductIDColumn,
|
||||||
|
Method: MethodColumn,
|
||||||
|
Currency: CurrencyColumn,
|
||||||
|
Amount: AmountColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
||||||
|
// this method only once at the beginning of the program.
|
||||||
|
func UseSchema(schema string) {
|
||||||
|
Balances = Balances.FromSchema(schema)
|
||||||
|
Benefits = Benefits.FromSchema(schema)
|
||||||
|
CatalogAtom = CatalogAtom.FromSchema(schema)
|
||||||
|
Config = Config.FromSchema(schema)
|
||||||
|
Ledger = Ledger.FromSchema(schema)
|
||||||
|
Orders = Orders.FromSchema(schema)
|
||||||
|
PaymentEvents = PaymentEvents.FromSchema(schema)
|
||||||
|
Product = Product.FromSchema(schema)
|
||||||
|
ProductItem = ProductItem.FromSchema(schema)
|
||||||
|
ProductPrice = ProductPrice.FromSchema(schema)
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
-- Payments data foundation: a self-contained `payments` schema for the in-game
|
||||||
|
-- currency, wallets, benefits, catalog, orders and the append-only operations
|
||||||
|
-- ledger. Nothing is wired to real money yet — this is the substrate the rest of
|
||||||
|
-- the payments domain builds on. Mechanics live in docs/PAYMENTS.md.
|
||||||
|
--
|
||||||
|
-- Isolation. The schema is confined to a dedicated NOLOGIN role that holds ALL
|
||||||
|
-- privileges on `payments.*` and none on `backend.*`; the backend application
|
||||||
|
-- keeps its single (superuser) pool, so the DB grants are a stepping-stone to a
|
||||||
|
-- future separate login/process rather than a runtime wall. The runtime wall is
|
||||||
|
-- code-level: only the payments package reaches these tables (an import-boundary
|
||||||
|
-- test guards it). There is deliberately NO cross-schema foreign key to
|
||||||
|
-- `backend.accounts`: `account_id` is a plain uuid, kept referentially honest in
|
||||||
|
-- code and joined to the tombstoned account / retained-identities dossier by the
|
||||||
|
-- stable account id — which keeps the domain extractable into its own database.
|
||||||
|
--
|
||||||
|
-- The ledger is append-only, enforced by a trigger (a superuser bypasses table
|
||||||
|
-- privileges, so a REVOKE would not bind the application). Money is stored as an
|
||||||
|
-- integer amount in the currency's minor units (RUB kopecks; Votes/Stars/chips
|
||||||
|
-- are whole units, scale 1), never a float — integer-currency integrality is
|
||||||
|
-- therefore structural.
|
||||||
|
--
|
||||||
|
-- Legacy note: backend.accounts.hint_balance and backend.accounts.paid_account
|
||||||
|
-- are superseded by the segmented model here and are DEPRECATED. They are NOT
|
||||||
|
-- dropped in this expand phase — reads still use them until the currency core
|
||||||
|
-- flips them, and the DROP is a later contract-phase migration (image rollback
|
||||||
|
-- must stay DB-safe). Neither was ever set in production.
|
||||||
|
--
|
||||||
|
-- Expand-contract: everything here is additive in its own schema, so a backend
|
||||||
|
-- image rollback stays DB-safe (older code simply ignores the new schema).
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS payments;
|
||||||
|
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'payments') THEN
|
||||||
|
CREATE ROLE payments NOLOGIN;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA payments TO payments;
|
||||||
|
|
||||||
|
-- Base value types (atoms) a product is composed of. A fixed, code-known set.
|
||||||
|
CREATE TABLE payments.catalog_atom (
|
||||||
|
atom_type text NOT NULL,
|
||||||
|
CONSTRAINT catalog_atom_pkey PRIMARY KEY (atom_type),
|
||||||
|
CONSTRAINT catalog_atom_type_chk
|
||||||
|
CHECK ((atom_type = ANY (ARRAY['chips'::text, 'hints'::text, 'noads_days'::text, 'tournament'::text])))
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO payments.catalog_atom (atom_type) VALUES
|
||||||
|
('chips'), ('hints'), ('noads_days'), ('tournament');
|
||||||
|
|
||||||
|
-- A sellable product: a set of atoms at a price. Soft-deleted (deactivated),
|
||||||
|
-- never removed, so historical orders/ledger keep resolving it.
|
||||||
|
CREATE TABLE payments.product (
|
||||||
|
product_id uuid NOT NULL,
|
||||||
|
title text DEFAULT ''::text NOT NULL,
|
||||||
|
active boolean DEFAULT true NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT product_pkey PRIMARY KEY (product_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The atom composition of a product (e.g. 250 hints + 30 no-ads days).
|
||||||
|
CREATE TABLE payments.product_item (
|
||||||
|
product_id uuid NOT NULL,
|
||||||
|
atom_type text NOT NULL,
|
||||||
|
quantity integer NOT NULL,
|
||||||
|
CONSTRAINT product_item_pkey PRIMARY KEY (product_id, atom_type),
|
||||||
|
CONSTRAINT product_item_product_fkey FOREIGN KEY (product_id)
|
||||||
|
REFERENCES payments.product (product_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT product_item_atom_fkey FOREIGN KEY (atom_type)
|
||||||
|
REFERENCES payments.catalog_atom (atom_type),
|
||||||
|
CONSTRAINT product_item_quantity_chk CHECK ((quantity > 0))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Price of a product. A chip pack carries a money price per method
|
||||||
|
-- (multi-currency Votes/Stars/RUB); a value carries a single chips price
|
||||||
|
-- (currency='CHIP', method NULL). `amount` is an integer in the currency's
|
||||||
|
-- minor units (RUB kopecks; Votes/Stars/chips whole, scale 1).
|
||||||
|
CREATE TABLE payments.product_price (
|
||||||
|
product_id uuid NOT NULL,
|
||||||
|
method text,
|
||||||
|
currency text NOT NULL,
|
||||||
|
amount bigint NOT NULL,
|
||||||
|
CONSTRAINT product_price_product_fkey FOREIGN KEY (product_id)
|
||||||
|
REFERENCES payments.product (product_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT product_price_method_chk
|
||||||
|
CHECK ((method IS NULL) OR (method = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
|
||||||
|
CONSTRAINT product_price_currency_chk
|
||||||
|
CHECK ((currency = ANY (ARRAY['RUB'::text, 'VOTE'::text, 'XTR'::text, 'CHIP'::text]))),
|
||||||
|
CONSTRAINT product_price_amount_chk CHECK ((amount >= 0))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One price row per (product, method, currency); NULL method (a value's chip
|
||||||
|
-- price) collapses to one row via the COALESCE key.
|
||||||
|
CREATE UNIQUE INDEX product_price_unique_idx
|
||||||
|
ON payments.product_price (product_id, COALESCE(method, ''::text), currency);
|
||||||
|
|
||||||
|
-- A pre-created purchase intent. `expected_amount` is in `currency` minor units.
|
||||||
|
CREATE TABLE payments.orders (
|
||||||
|
order_id uuid NOT NULL,
|
||||||
|
account_id uuid NOT NULL,
|
||||||
|
platform text NOT NULL,
|
||||||
|
product_id uuid NOT NULL,
|
||||||
|
expected_amount bigint NOT NULL,
|
||||||
|
currency text NOT NULL,
|
||||||
|
origin text NOT NULL,
|
||||||
|
status text DEFAULT 'pending'::text NOT NULL,
|
||||||
|
provider text,
|
||||||
|
provider_payment_id text,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT orders_pkey PRIMARY KEY (order_id),
|
||||||
|
CONSTRAINT orders_product_fkey FOREIGN KEY (product_id)
|
||||||
|
REFERENCES payments.product (product_id),
|
||||||
|
CONSTRAINT orders_status_chk
|
||||||
|
CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'expired'::text]))),
|
||||||
|
CONSTRAINT orders_origin_chk
|
||||||
|
CHECK ((origin = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
|
||||||
|
CONSTRAINT orders_currency_chk
|
||||||
|
CHECK ((currency = ANY (ARRAY['RUB'::text, 'VOTE'::text, 'XTR'::text, 'CHIP'::text]))),
|
||||||
|
CONSTRAINT orders_expected_amount_chk CHECK ((expected_amount >= 0))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The pending-expiry sweep scans (status, created_at).
|
||||||
|
CREATE INDEX orders_status_created_at_idx ON payments.orders (status, created_at);
|
||||||
|
|
||||||
|
-- The append-only operations ledger: every fund / spend / admin_grant / refund.
|
||||||
|
-- `chips_delta` is signed (0 for a grant, which credits values not chips);
|
||||||
|
-- `snapshot` records the sold atoms + price for a spend/grant. Never updated or
|
||||||
|
-- deleted (a trigger enforces it); a reversal is a new `refund` row.
|
||||||
|
CREATE TABLE payments.ledger (
|
||||||
|
ledger_id uuid NOT NULL,
|
||||||
|
account_id uuid NOT NULL,
|
||||||
|
kind text NOT NULL,
|
||||||
|
source text,
|
||||||
|
origin text,
|
||||||
|
chips_delta integer DEFAULT 0 NOT NULL,
|
||||||
|
product_id uuid,
|
||||||
|
order_id uuid,
|
||||||
|
provider text,
|
||||||
|
provider_payment_id text,
|
||||||
|
snapshot jsonb,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT ledger_pkey PRIMARY KEY (ledger_id),
|
||||||
|
CONSTRAINT ledger_product_fkey FOREIGN KEY (product_id)
|
||||||
|
REFERENCES payments.product (product_id),
|
||||||
|
CONSTRAINT ledger_order_fkey FOREIGN KEY (order_id)
|
||||||
|
REFERENCES payments.orders (order_id),
|
||||||
|
CONSTRAINT ledger_kind_chk
|
||||||
|
CHECK ((kind = ANY (ARRAY['fund'::text, 'spend'::text, 'admin_grant'::text, 'refund'::text]))),
|
||||||
|
CONSTRAINT ledger_source_chk
|
||||||
|
CHECK ((source IS NULL) OR (source = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
|
||||||
|
CONSTRAINT ledger_origin_chk
|
||||||
|
CHECK ((origin IS NULL) OR (origin = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text])))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Idempotency key: a provider callback credits at most once. Partial so rows
|
||||||
|
-- without a provider payment id (spend/admin_grant) are unconstrained.
|
||||||
|
CREATE UNIQUE INDEX ledger_provider_payment_idx
|
||||||
|
ON payments.ledger (provider, provider_payment_id)
|
||||||
|
WHERE provider_payment_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE FUNCTION payments.ledger_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'payments.ledger is append-only: % denied', TG_OP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
CREATE TRIGGER ledger_no_mutation
|
||||||
|
BEFORE UPDATE OR DELETE ON payments.ledger
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION payments.ledger_append_only();
|
||||||
|
|
||||||
|
-- Materialised chip balance, segmented by funding source. A fast cache of the
|
||||||
|
-- ledger, recomputable from it; created lazily on first fund (up to three rows
|
||||||
|
-- per account, absent = zero).
|
||||||
|
CREATE TABLE payments.balances (
|
||||||
|
account_id uuid NOT NULL,
|
||||||
|
source text NOT NULL,
|
||||||
|
chips integer DEFAULT 0 NOT NULL,
|
||||||
|
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT balances_pkey PRIMARY KEY (account_id, source),
|
||||||
|
CONSTRAINT balances_source_chk
|
||||||
|
CHECK ((source = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
|
||||||
|
CONSTRAINT balances_chips_chk CHECK ((chips >= 0))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Materialised benefits, segmented by the purchase origin. no-ads is a duration
|
||||||
|
-- (ads_paid_until) plus a perpetual override (ads_forever); hints is a count.
|
||||||
|
-- Created lazily on first grant/spend.
|
||||||
|
CREATE TABLE payments.benefits (
|
||||||
|
account_id uuid NOT NULL,
|
||||||
|
origin text NOT NULL,
|
||||||
|
ads_paid_until timestamp with time zone,
|
||||||
|
ads_forever boolean DEFAULT false NOT NULL,
|
||||||
|
hints integer DEFAULT 0 NOT NULL,
|
||||||
|
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT benefits_pkey PRIMARY KEY (account_id, origin),
|
||||||
|
CONSTRAINT benefits_origin_chk
|
||||||
|
CHECK ((origin = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
|
||||||
|
CONSTRAINT benefits_hints_chk CHECK ((hints >= 0))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The single-row tunable config (durations in whole seconds — go-jet stringifies
|
||||||
|
-- interval; payouts as counts). Edited in the admin console, no release needed.
|
||||||
|
CREATE TABLE payments.config (
|
||||||
|
only_row boolean DEFAULT true NOT NULL,
|
||||||
|
rewarded_payout_chips integer DEFAULT 0 NOT NULL,
|
||||||
|
cooldown_global_seconds integer DEFAULT 300 NOT NULL,
|
||||||
|
cooldown_vs_ai_seconds integer DEFAULT 1800 NOT NULL,
|
||||||
|
cooldown_hint_seconds integer DEFAULT 60 NOT NULL,
|
||||||
|
order_ttl_seconds integer DEFAULT 1800 NOT NULL,
|
||||||
|
CONSTRAINT config_pkey PRIMARY KEY (only_row),
|
||||||
|
CONSTRAINT config_single_row_chk CHECK (only_row)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO payments.config (only_row) VALUES (true);
|
||||||
|
|
||||||
|
-- Payment lifecycle events for the dispatcher (live stream / botlink / email).
|
||||||
|
CREATE TABLE payments.payment_events (
|
||||||
|
event_id uuid NOT NULL,
|
||||||
|
account_id uuid NOT NULL,
|
||||||
|
order_id uuid,
|
||||||
|
type text NOT NULL,
|
||||||
|
payload jsonb,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
dispatched_at timestamp with time zone,
|
||||||
|
CONSTRAINT payment_events_pkey PRIMARY KEY (event_id),
|
||||||
|
CONSTRAINT payment_events_order_fkey FOREIGN KEY (order_id)
|
||||||
|
REFERENCES payments.orders (order_id),
|
||||||
|
CONSTRAINT payment_events_type_chk
|
||||||
|
CHECK ((type = ANY (ARRAY['succeeded'::text, 'failed'::text, 'refunded'::text])))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX payment_events_dispatch_idx
|
||||||
|
ON payments.payment_events (dispatched_at)
|
||||||
|
WHERE dispatched_at IS NULL;
|
||||||
|
|
||||||
|
-- Confine the payments role to this schema: ALL on its tables, nothing on
|
||||||
|
-- backend. New tables added later inherit the grant via default privileges.
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA payments TO payments;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA payments GRANT ALL ON TABLES TO payments;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA payments REVOKE ALL ON TABLES FROM payments;
|
||||||
|
DROP SCHEMA IF EXISTS payments CASCADE;
|
||||||
|
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'payments') THEN
|
||||||
|
EXECUTE 'DROP OWNED BY payments';
|
||||||
|
DROP ROLE payments;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
"scrabble/backend/internal/render"
|
"scrabble/backend/internal/render"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
@@ -93,6 +94,11 @@ type Deps struct {
|
|||||||
// profile.get banner block, plus the banner admin console section. A nil Ads
|
// profile.get banner block, plus the banner admin console section. A nil Ads
|
||||||
// omits the banner block and disables the banner console.
|
// omits the banner block and disables the banner console.
|
||||||
Ads *ads.Service
|
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
|
// Notifier publishes live-event intents — here the banner-eligibility re-poll
|
||||||
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
||||||
// them (notify.Nop).
|
// them (notify.Nop).
|
||||||
@@ -129,6 +135,7 @@ type Server struct {
|
|||||||
ratewatch *ratewatch.Watch
|
ratewatch *ratewatch.Watch
|
||||||
banview *banview.View
|
banview *banview.View
|
||||||
ads *ads.Service
|
ads *ads.Service
|
||||||
|
payments *payments.Service
|
||||||
notifier notify.Publisher
|
notifier notify.Publisher
|
||||||
console *adminconsole.Renderer
|
console *adminconsole.Renderer
|
||||||
exportKey []byte
|
exportKey []byte
|
||||||
@@ -181,6 +188,7 @@ func New(addr string, deps Deps) *Server {
|
|||||||
ratewatch: deps.RateWatch,
|
ratewatch: deps.RateWatch,
|
||||||
banview: deps.BanView,
|
banview: deps.BanView,
|
||||||
ads: deps.Ads,
|
ads: deps.Ads,
|
||||||
|
payments: deps.Payments,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
renderer: deps.Renderer,
|
renderer: deps.Renderer,
|
||||||
http: &http.Server{Addr: addr, Handler: engine},
|
http: &http.Server{Addr: addr, Handler: engine},
|
||||||
|
|||||||
+11
-6
@@ -49,7 +49,8 @@ The chip balance is **segmented by `source`** — the platform where the chips w
|
|||||||
| `direct` | Robokassa purchase (web / native)|
|
| `direct` | Robokassa purchase (web / native)|
|
||||||
|
|
||||||
A single account holds all three segments simultaneously: `balance = (account_id, source)`,
|
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
|
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
|
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,
|
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
|
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
|
interface. There is **no cross-schema foreign key** to `backend.accounts` — an account id is a
|
||||||
transaction. Durability is **PITR** (continuous WAL archive), independent of DB topology.
|
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`):
|
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).
|
(pending/paid/expired).
|
||||||
- **payment_events** — succeeded/failed/refunded for the dispatcher.
|
- **payment_events** — succeeded/failed/refunded for the dispatcher.
|
||||||
|
|
||||||
Legacy `accounts.hint_balance` and `accounts.paid_account` are **deprecated** and dropped
|
Legacy `accounts.hint_balance` and `accounts.paid_account` are **deprecated** in favour of the
|
||||||
(expand-contract) in favour of the segmented model; neither was ever set in prod (no purchase
|
segmented model and dropped in a later **contract-phase** migration (expand-contract, after the
|
||||||
flow existed), so legacy values are zeroed.
|
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
|
## 15. Glossary
|
||||||
|
|
||||||
|
|||||||
+13
-8
@@ -48,8 +48,9 @@
|
|||||||
| `telegram` | Покупка за Stars TG |
|
| `telegram` | Покупка за Stars TG |
|
||||||
| `direct` | Покупка через Robokassa (web / native) |
|
| `direct` | Покупка через Robokassa (web / native) |
|
||||||
|
|
||||||
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, ровно
|
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, до трёх
|
||||||
три записи. Сегментация **не** по привязке (identity) — см. §6.
|
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
|
||||||
|
читается как ноль. Сегментация **не** по привязке (identity) — см. §6.
|
||||||
|
|
||||||
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
|
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
|
||||||
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
|
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
|
||||||
@@ -304,9 +305,11 @@ INSERT** (никогда UPDATE/DELETE — полный аудит). Балан
|
|||||||
|
|
||||||
Платёжный домен живёт в **своей схеме `payments`** в общем инстансе Postgres, со своим
|
Платёжный домен живёт в **своей схеме `payments`** в общем инстансе Postgres, со своим
|
||||||
DB-ролём (права только на `payments`) и доменным пакетом за жёстким интерфейсом.
|
DB-ролём (права только на `payments`) и доменным пакетом за жёстким интерфейсом.
|
||||||
Cross-schema внешние ключи к `backend.accounts` держат трату «Фишки↔бенефит» атомарной в
|
**Cross-schema внешнего ключа** к `backend.accounts` **нет** — идентификатор аккаунта здесь
|
||||||
одной транзакции. Сохранность — **PITR** (непрерывный WAL-архив), независимо от топологии
|
обычное значение, согласуемое в коде и связываемое с tombstone-аккаунтом / досье
|
||||||
базы.
|
retained-identities по стабильному id. Это держит трату «Фишки↔бенефит» атомарной **внутри
|
||||||
|
`payments`** и делает домен извлекаемым в свою базу. Сохранность — **PITR** (непрерывный
|
||||||
|
WAL-архив), независимо от топологии базы.
|
||||||
|
|
||||||
Основные таблицы (финальные имена/колонки — в `PLAN.md`):
|
Основные таблицы (финальные имена/колонки — в `PLAN.md`):
|
||||||
|
|
||||||
@@ -322,9 +325,11 @@ Cross-schema внешние ключи к `backend.accounts` держат тра
|
|||||||
(pending/paid/expired).
|
(pending/paid/expired).
|
||||||
- **payment_events** — succeeded/failed/refunded для диспетчера.
|
- **payment_events** — succeeded/failed/refunded для диспетчера.
|
||||||
|
|
||||||
Legacy `accounts.hint_balance` и `accounts.paid_account` **устаревают** и удаляются
|
Legacy `accounts.hint_balance` и `accounts.paid_account` **устаревают** в пользу
|
||||||
(expand-contract) в пользу сегментированной модели; ни то, ни другое в проде никогда не
|
сегментированной модели и удаляются в отдельной **contract-миграции** (expand-contract, после
|
||||||
выставлялось (потока покупки не было), поэтому legacy-значения обнуляются.
|
того как валютное ядро переключит чтения, и после Release 2 — откат образа остаётся безопасным
|
||||||
|
для БД); ни то, ни другое в проде никогда не выставлялось (потока покупки не было), поэтому
|
||||||
|
legacy-значения обнуляются.
|
||||||
|
|
||||||
## 15. Словарь
|
## 15. Словарь
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user