feat(payments): add the payments schema, currency domain and money type
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Stand up the payments data foundation: a self-contained `payments` Postgres schema for the in-game currency, wallets, benefits, catalog, orders and the append-only operations ledger, behind a domain package — nothing wired to real money yet. - Migration 00010: the `payments` schema and a NOLOGIN confinement role (ALL on payments.*, nothing on backend); the ledger with a BEFORE UPDATE/DELETE append-only trigger and a partial idempotency index; the materialised balances/benefits; the catalog (atoms seeded) + products + per-method prices; the typed single-row config; orders and payment_events. There is no cross-schema foreign key — account_id is a plain uuid kept consistent in code, which keeps the domain extractable. Expand-contract and reversible. - Money is a bigint in the currency's minor units carried by a `Money` value type (exact, math/big): no float ever touches an amount, and a whole-unit currency cannot hold a fraction. - Extend jetgen to generate the payments schema; construct the service in the composition root behind a narrow interface with a boot reachability check. - Tests: integration (role confinement via SET ROLE, the append-only trigger, CHECK constraints, the idempotency index, and a forward+backward migration), Money unit tests, and an import-boundary test keeping the payments jet code private to the domain. - Docs: PLAN.md, docs/PAYMENTS.md (+ _ru mirror) updated to the built model.
This commit is contained in:
@@ -26,7 +26,7 @@ tests, done-criteria, and current status — without re-deriving decisions.
|
||||
|
||||
| Stage | Title | Release | Status |
|
||||
|-------|-------|---------|--------|
|
||||
| E0 | Payments data foundation | 1 | TODO |
|
||||
| E0 | Payments data foundation | 1 | DONE |
|
||||
| E1 | Trusted platform signal | 1 | TODO |
|
||||
| E2 | Currency + benefit core | 1 | TODO |
|
||||
| E3 | Wallet UI | 1 | TODO |
|
||||
@@ -52,15 +52,25 @@ Read once; individual stages assume it.
|
||||
- The payments domain owns its **own Postgres schema `payments`** in the shared instance
|
||||
(`scrabble` DB). All payments tables are `payments.*`. `backend` schema is untouched
|
||||
except for the deprecation of two legacy columns (E2).
|
||||
- **DB role.** A dedicated role owns `payments` (ALL on `payments.*`, plus `REFERENCES` on
|
||||
`backend.accounts(account_id)` for the cross-schema FK — nothing else on `backend`). The
|
||||
game/backend role keeps no rights on `payments`. Isolation is one-directional: payments
|
||||
may reference accounts; the rest of the backend never touches `payments` SQL.
|
||||
- **No cross-schema writes.** Benefits move **into** `payments` (they no longer live on
|
||||
`accounts` — see E2), so the spend transaction (ledger INSERT + balance UPDATE + benefit
|
||||
UPDATE) is atomic **within `payments`**. The only cross-schema link is the FK
|
||||
`payments.*.account_id → backend.accounts.account_id` (referential integrity, not a
|
||||
write). Game code reads benefits through the payments **Go interface**, never via SQL.
|
||||
- **DB role.** A dedicated **NOLOGIN** role holds ALL privileges on `payments.*` and
|
||||
**nothing** on `backend` — grant-based confinement, asserted by a `SET ROLE` isolation
|
||||
test. The application still connects on its single (superuser) pool, so these grants are a
|
||||
stepping-stone to a real separate login/process, not the runtime wall. The **runtime wall
|
||||
is code-level**: only the `internal/payments` package imports the payments jet code and
|
||||
issues `payments.*` SQL (an **import-boundary test** enforces it); every other domain
|
||||
reaches payments through the narrow Go interface.
|
||||
- **No cross-schema link at all.** There is **no** foreign key from `payments.*` to
|
||||
`backend.accounts`: `account_id` is a plain `uuid`, kept referentially honest in code and
|
||||
joined to the tombstoned account / `retained_identities` dossier by the stable id. This
|
||||
keeps the spend transaction (ledger INSERT + balance UPDATE + benefit UPDATE) fully
|
||||
**within `payments`** and the domain extractable into its own database later. Benefits move
|
||||
**into** `payments` (they no longer live on `accounts` — see E2); game code reads them
|
||||
through the payments **Go interface**, never via SQL.
|
||||
- **Money.** Monetary amounts are a single **`bigint` in the currency's minor units** (RUB
|
||||
kopecks; Votes/Stars/chips are whole units, scale 1) carried in Go by the `payments.Money`
|
||||
value type — the sole constructor/formatter/arithmetic, so **no `float64` ever touches an
|
||||
amount** and a whole-unit currency structurally cannot hold a fraction. `chip_rate` is not a
|
||||
table: a chip pack is a product, so its per-method rate **is** `product_price`.
|
||||
|
||||
### Domain boundary
|
||||
|
||||
@@ -122,67 +132,78 @@ Read once; individual stages assume it.
|
||||
|
||||
## E0 — Payments data foundation
|
||||
|
||||
**Status:** TODO · **Release 1** · depends on: none · mechanics: PAYMENTS §14, §7, §11.
|
||||
**Status:** DONE · **Release 1** · depends on: none · mechanics: PAYMENTS §14, §7, §11.
|
||||
|
||||
**Goal.** Stand up the `payments` schema, DB role, jetgen target, domain package skeleton,
|
||||
and all core tables — with nothing wired to real money yet. This is the substrate every
|
||||
later stage builds on.
|
||||
**Goal.** Stand up the `payments` schema, its confinement role, the jetgen target, the domain
|
||||
package skeleton and all core tables — nothing wired to real money yet. This is the substrate
|
||||
every later stage builds on.
|
||||
|
||||
**Migrations (`payments` schema).**
|
||||
**Migration `00010_payments_foundation.sql` (`payments` schema).**
|
||||
|
||||
- `CREATE SCHEMA payments`; create the payments DB role + GRANTs (ALL on `payments`,
|
||||
`REFERENCES` on `backend.accounts(account_id)`).
|
||||
- `payments.ledger` — append-only. Columns: `ledger_id` (UUIDv7 PK), `account_id` (FK →
|
||||
`backend.accounts`), `kind` (`fund|spend|admin_grant|refund`), `source` / `origin`
|
||||
(nullable per kind), `chips_delta` (signed; 0 for admin_grant), `product_id` (nullable,
|
||||
FK → catalog), `order_id` (nullable, FK → orders), `provider` + `provider_payment_id`
|
||||
(nullable), `snapshot` (JSONB of sold atoms+price for spend/grant), `created_at`. **No
|
||||
UPDATE/DELETE ever.** Unique partial index on `(provider, provider_payment_id)` where not
|
||||
null — the idempotency key.
|
||||
- `payments.balances` — materialized `(account_id, source)` PK, `chips int CHECK (chips >=
|
||||
0)`, `updated_at`.
|
||||
- `payments.benefits` — materialized `(account_id, origin)` PK, `ads_paid_until timestamptz`
|
||||
null, `ads_forever bool`, `hints int CHECK (hints >= 0)`, `updated_at`.
|
||||
- `payments.catalog_atom` — atom types (`chips|hints|noads_days|tournament`).
|
||||
- `payments.product` — `product_id`, `title`, `active bool` (soft-delete), created/updated;
|
||||
`payments.product_item` (product → atom + quantity); `payments.product_price` (product →
|
||||
method `vk|telegram|direct` + amount + currency — multi-currency chip packs; value
|
||||
products carry a single chips price).
|
||||
- `payments.chip_rate` / rewarded payout config — either dedicated rows or a small
|
||||
`payments.config` KV (frequency cooldowns, rewarded payout, pending-expiry). Pick one and
|
||||
document it here at implementation.
|
||||
- `payments.order` — `order_id` (UUIDv7 PK), `account_id`, `platform`, `product_id`,
|
||||
`expected_amount` + currency, `origin`, `status` (`pending|paid|expired`), `provider`,
|
||||
`provider_payment_id` (nullable), timestamps. Index for the pending-expiry sweep.
|
||||
- `payments.payment_event` — `event_id`, `account_id`, `order_id` (nullable), `type`
|
||||
(`succeeded|failed|refunded`), `payload`, `created_at`, `dispatched_at` (nullable).
|
||||
- `CREATE SCHEMA payments`; an idempotent `DO $$` block creates a **NOLOGIN** `payments` role;
|
||||
`GRANT USAGE` + `GRANT ALL ON ALL TABLES` (+ `ALTER DEFAULT PRIVILEGES … GRANT ALL`) confine it
|
||||
to `payments.*` with nothing on `backend`. No `REFERENCES` grant (there is no cross-schema FK).
|
||||
- `payments.ledger` — append-only. `ledger_id` (uuid PK, app-generated v7), `account_id` (plain
|
||||
`uuid`, **no FK**), `kind` (`fund|spend|admin_grant|refund`), `source`/`origin` (nullable,
|
||||
`vk|telegram|direct`), `chips_delta` (signed int, 0 for a grant), `product_id` (nullable
|
||||
FK→product), `order_id` (nullable FK→orders), `provider` + `provider_payment_id` (nullable),
|
||||
`snapshot` (jsonb, written from E2), `created_at`. Immutability is a **`BEFORE UPDATE OR DELETE`
|
||||
trigger** (a superuser bypasses a privilege REVOKE). Unique **partial** index on `(provider,
|
||||
provider_payment_id) WHERE provider_payment_id IS NOT NULL` — the idempotency key.
|
||||
- `payments.balances` — `(account_id, source)` PK, `chips int CHECK (>=0)`, `updated_at`. Created
|
||||
**lazily** on first fund (up to three rows/account, absent = zero).
|
||||
- `payments.benefits` — `(account_id, origin)` PK, `ads_paid_until timestamptz` null,
|
||||
`ads_forever bool`, `hints int CHECK (>=0)`, `updated_at`. Created lazily.
|
||||
- `payments.catalog_atom` — `atom_type` PK (`chips|hints|noads_days|tournament`); the four atoms
|
||||
are **seeded** here (the `tournament` atom is provisioned for E9).
|
||||
- `payments.product` (`product_id` PK, `title`, `active bool` soft-delete, timestamps);
|
||||
`payments.product_item` (`(product_id, atom_type)` PK → `quantity`); `payments.product_price`
|
||||
(`(product_id, COALESCE(method,''), currency)` unique → `amount bigint` minor units): a chip
|
||||
pack carries a money price per `method` (`vk|telegram|direct`), a value carries one
|
||||
`currency='CHIP', method=NULL` row. `currency ∈ {RUB, VOTE, XTR, CHIP}`; `amount CHECK (>=0)`.
|
||||
- `payments.config` — a single typed row (`only_row bool PK CHECK`): `rewarded_payout_chips`,
|
||||
`cooldown_global_seconds` (300), `cooldown_vs_ai_seconds` (1800), `cooldown_hint_seconds` (60),
|
||||
`order_ttl_seconds` (1800). No `chip_rate` table — the pack rate is `product_price`.
|
||||
- `payments.orders` — `order_id` (uuid PK), `account_id`, `platform`, `product_id` (FK),
|
||||
`expected_amount bigint` + `currency`, `origin`, `status` (`pending|paid|expired`), `provider`,
|
||||
`provider_payment_id` (nullable), timestamps. Index `(status, created_at)` for the pending sweep.
|
||||
- `payments.payment_events` — `event_id` (uuid PK), `account_id`, `order_id` (nullable FK), `type`
|
||||
(`succeeded|failed|refunded`), `payload jsonb`, `created_at`, `dispatched_at` (nullable, partial
|
||||
index for the dispatch queue).
|
||||
- Full `-- +goose Down` (DROP SCHEMA CASCADE + DROP OWNED BY + DROP ROLE); expand-contract, proven
|
||||
reversible by an integration test.
|
||||
|
||||
**Backend.**
|
||||
|
||||
- `backend/internal/payments/` package skeleton: `Store` + `Service` + a `withTx` helper.
|
||||
No handlers wired yet beyond a health-style no-op — this stage is schema + package.
|
||||
- Extend `cmd/jetgen` to generate `payments` schema → `internal/postgres/jet/payments/`;
|
||||
commit generated code.
|
||||
- `backend/internal/payments/{payments,service,store}.go` — the `Money` value type + `Currency`
|
||||
(bigint minor units, exact, no float); `Store{db *sql.DB}` with a `Ping` health read via jet;
|
||||
`Service` over the store. (`withTx` arrives with E2's first transaction.)
|
||||
- `cmd/jetgen` generates the `payments` schema into `internal/postgres/jet/payments/` (a second
|
||||
`GenerateDB` call); committed. Constructed in `cmd/backend/main.go` and passed to `server.Deps`
|
||||
(`Payments`) with a boot-time reachability check; its routes are registered from E2.
|
||||
|
||||
**Legacy deprecation (expand phase only).** Add a migration comment/marker that
|
||||
`accounts.hint_balance` and `accounts.paid_account` are deprecated; do NOT drop yet (drop is
|
||||
the contract phase, after E2 reads/writes the new tables and after Release 2). Reads still
|
||||
use the old columns until E2 flips them.
|
||||
**Legacy deprecation (expand phase only).** A `--` header note marks `accounts.hint_balance` and
|
||||
`accounts.paid_account` deprecated; they are **not** dropped (the DROP is the contract phase after
|
||||
E2 flips reads and after Release 2). Reads still use the old columns until E2.
|
||||
|
||||
**Tests.**
|
||||
|
||||
- integration: schema/role created; role cannot read `backend`-only tables and vice-versa;
|
||||
ledger rejects UPDATE/DELETE (trigger or role privilege); idempotency unique index holds;
|
||||
FK to `backend.accounts` enforced; balance/benefit CHECKs hold.
|
||||
- unit: none material yet (logic arrives in E2).
|
||||
- integration (`inttest`): schema + role + seeds present; `SET ROLE payments` cannot read
|
||||
`backend.accounts` (grant confinement); ledger rejects UPDATE/DELETE (trigger); idempotency
|
||||
partial index holds (NULL-keyed rows repeat); balance/benefit/amount/enum CHECKs bite; migration
|
||||
applies forward **and backward** on a throwaway PG.
|
||||
- unit: `Money` — exact round-trip, per-currency scale, no-float parse rejecting a fraction for a
|
||||
whole-unit currency, arithmetic, formatting. Import-boundary test: only `internal/payments`
|
||||
imports the payments jet code.
|
||||
|
||||
**Done-criteria.** Migrations apply cleanly forward+backward on a throwaway PG (jetgen path
|
||||
proves this); `go build ./backend/...` + `go vet` + `gofmt -l .` clean; committed jet code
|
||||
for `payments`; integration tests green; no behaviour change for users.
|
||||
**Done-criteria (met).** Migrations apply forward+backward on a throwaway PG 17; `go build
|
||||
./backend/...` + `go vet` + `gofmt -l .` clean; committed `jet/payments/`; all tests green; no
|
||||
behaviour change for users.
|
||||
|
||||
**Notes/risks.** jetgen may reorder untouched `backend` tables — revert that churn, commit
|
||||
only the `payments` additions. Keep the `payments` role creation idempotent (`IF NOT
|
||||
EXISTS` / `DO $$`), since migrations re-run on fresh volumes.
|
||||
**Notes.** jetgen regenerates the whole `backend` schema; its committed jet was already drifted
|
||||
from the migrations (`robot_blocks`, `robot_friend_requests`, a `feedback_messages` column), so
|
||||
that churn was reverted and only `jet/payments/` committed — a pre-existing drift worth a separate
|
||||
cleanup. The `payments` role creation is idempotent (`DO $$` / `IF NOT EXISTS`) for fresh volumes.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"scrabble/backend/internal/link"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/pushgrpc"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
@@ -260,6 +261,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
// block and the banner admin console section.
|
||||
adsSvc := ads.NewService(ads.NewStore(db))
|
||||
|
||||
// In-game currency domain (data foundation): the payments schema behind a
|
||||
// narrow interface. A boot-time reachability check fails fast if the schema
|
||||
// did not migrate; the wallet routes are registered when that surface lands.
|
||||
paymentsSvc := payments.NewService(payments.NewStore(db))
|
||||
if err := paymentsSvc.Ping(ctx); err != nil {
|
||||
return fmt.Errorf("payments schema unreachable: %w", err)
|
||||
}
|
||||
logger.Info("payments domain ready")
|
||||
|
||||
// The image-render sidecar client for the PNG export artifact; nil (PNG
|
||||
// download answers 404) when BACKEND_RENDERER_URL is unset.
|
||||
var renderer *render.Client
|
||||
@@ -287,6 +297,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
RateWatch: rateWatch,
|
||||
BanView: banView,
|
||||
Ads: adsSvc,
|
||||
Payments: paymentsSvc,
|
||||
Notifier: hub,
|
||||
ExportSignKey: cfg.ExportSignKey,
|
||||
Renderer: renderer,
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
// 1. start a postgres:17-alpine container via testcontainers-go
|
||||
// 2. open it with search_path=backend and apply the embedded goose migrations
|
||||
// 3. drop goose's bookkeeping table so jet does not generate a model for it
|
||||
// 4. run jet's PostgreSQL generator for schema=backend into internal/postgres/jet
|
||||
// 4. run jet's PostgreSQL generator for the backend and payments schemas into
|
||||
// internal/postgres/jet (one subdirectory per schema)
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -36,6 +37,7 @@ const (
|
||||
superuserPassword = "scrabble"
|
||||
superuserDatabase = "scrabble_backend"
|
||||
backendSchema = "backend"
|
||||
paymentsSchema = "payments"
|
||||
containerStartup = 90 * time.Second
|
||||
jetOutputDirSuffix = "internal/postgres/jet"
|
||||
)
|
||||
@@ -105,11 +107,16 @@ func run(ctx context.Context) error {
|
||||
return fmt.Errorf("drop goose_db_version: %w", err)
|
||||
}
|
||||
|
||||
if err := jetpostgres.GenerateDB(db, backendSchema, outputDir); err != nil {
|
||||
return fmt.Errorf("jet generate: %w", err)
|
||||
// Each schema generates into its own jet/<schema>/ subdirectory (the
|
||||
// generator wipes only that subtree), so the two calls do not clobber each
|
||||
// other. GenerateDB takes the schema explicitly, so the connection's
|
||||
// search_path=backend does not affect the payments introspection.
|
||||
for _, schema := range []string{backendSchema, paymentsSchema} {
|
||||
if err := jetpostgres.GenerateDB(db, schema, outputDir); err != nil {
|
||||
return fmt.Errorf("jet generate schema=%s: %w", schema, err)
|
||||
}
|
||||
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, schema)
|
||||
}
|
||||
|
||||
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, backendSchema)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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/lobby"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/render"
|
||||
"scrabble/backend/internal/session"
|
||||
@@ -93,6 +94,11 @@ type Deps struct {
|
||||
// profile.get banner block, plus the banner admin console section. A nil Ads
|
||||
// omits the banner block and disables the banner console.
|
||||
Ads *ads.Service
|
||||
// Payments is the in-game currency domain service (wallet, benefits, catalog).
|
||||
// The data-foundation layer exposes only a reachability check; its user and
|
||||
// console routes are registered when the wallet surface lands. A nil Payments
|
||||
// omits them.
|
||||
Payments *payments.Service
|
||||
// Notifier publishes live-event intents — here the banner-eligibility re-poll
|
||||
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
||||
// them (notify.Nop).
|
||||
@@ -129,6 +135,7 @@ type Server struct {
|
||||
ratewatch *ratewatch.Watch
|
||||
banview *banview.View
|
||||
ads *ads.Service
|
||||
payments *payments.Service
|
||||
notifier notify.Publisher
|
||||
console *adminconsole.Renderer
|
||||
exportKey []byte
|
||||
@@ -181,6 +188,7 @@ func New(addr string, deps Deps) *Server {
|
||||
ratewatch: deps.RateWatch,
|
||||
banview: deps.BanView,
|
||||
ads: deps.Ads,
|
||||
payments: deps.Payments,
|
||||
notifier: notifier,
|
||||
renderer: deps.Renderer,
|
||||
http: &http.Server{Addr: addr, Handler: engine},
|
||||
|
||||
+11
-6
@@ -49,7 +49,8 @@ The chip balance is **segmented by `source`** — the platform where the chips w
|
||||
| `direct` | Robokassa purchase (web / native)|
|
||||
|
||||
A single account holds all three segments simultaneously: `balance = (account_id, source)`,
|
||||
exactly three rows. Segmentation is **not** per-identity — see §6.
|
||||
up to three rows — a row is materialised lazily on the segment's first funding, and an absent
|
||||
segment reads as zero. Segmentation is **not** per-identity — see §6.
|
||||
|
||||
Why segmented, not one pooled balance: store rules forbid activating value that was paid
|
||||
for outside the store's own cash desk. Chips funded inside VK (Votes) may only be spent in
|
||||
@@ -302,8 +303,11 @@ confirms the exact НПД scheme with a tax advisor.)
|
||||
|
||||
The payments domain lives in its **own schema `payments`** in the shared Postgres instance,
|
||||
with its own DB role (rights limited to `payments`) and a domain package behind a hard
|
||||
interface. Cross-schema FKs to `backend.accounts` keep the chip↔benefit spend atomic in one
|
||||
transaction. Durability is **PITR** (continuous WAL archive), independent of DB topology.
|
||||
interface. There is **no cross-schema foreign key** to `backend.accounts` — an account id is a
|
||||
plain value here, kept consistent in code and joined to the tombstoned account /
|
||||
retained-identities dossier by the stable id. That keeps the chip↔benefit spend atomic
|
||||
**within `payments`** and the domain extractable into its own database. Durability is **PITR**
|
||||
(continuous WAL archive), independent of DB topology.
|
||||
|
||||
Core tables (final names/columns fixed in `PLAN.md`):
|
||||
|
||||
@@ -318,9 +322,10 @@ Core tables (final names/columns fixed in `PLAN.md`):
|
||||
(pending/paid/expired).
|
||||
- **payment_events** — succeeded/failed/refunded for the dispatcher.
|
||||
|
||||
Legacy `accounts.hint_balance` and `accounts.paid_account` are **deprecated** and dropped
|
||||
(expand-contract) in favour of the segmented model; neither was ever set in prod (no purchase
|
||||
flow existed), so legacy values are zeroed.
|
||||
Legacy `accounts.hint_balance` and `accounts.paid_account` are **deprecated** in favour of the
|
||||
segmented model and dropped in a later **contract-phase** migration (expand-contract, after the
|
||||
currency core flips reads and Release 2 — image rollback stays DB-safe); neither was ever set in
|
||||
prod (no purchase flow existed), so legacy values are zeroed.
|
||||
|
||||
## 15. Glossary
|
||||
|
||||
|
||||
+13
-8
@@ -48,8 +48,9 @@
|
||||
| `telegram` | Покупка за Stars TG |
|
||||
| `direct` | Покупка через Robokassa (web / native) |
|
||||
|
||||
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, ровно
|
||||
три записи. Сегментация **не** по привязке (identity) — см. §6.
|
||||
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, до трёх
|
||||
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
|
||||
читается как ноль. Сегментация **не** по привязке (identity) — см. §6.
|
||||
|
||||
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
|
||||
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
|
||||
@@ -304,9 +305,11 @@ INSERT** (никогда UPDATE/DELETE — полный аудит). Балан
|
||||
|
||||
Платёжный домен живёт в **своей схеме `payments`** в общем инстансе Postgres, со своим
|
||||
DB-ролём (права только на `payments`) и доменным пакетом за жёстким интерфейсом.
|
||||
Cross-schema внешние ключи к `backend.accounts` держат трату «Фишки↔бенефит» атомарной в
|
||||
одной транзакции. Сохранность — **PITR** (непрерывный WAL-архив), независимо от топологии
|
||||
базы.
|
||||
**Cross-schema внешнего ключа** к `backend.accounts` **нет** — идентификатор аккаунта здесь
|
||||
обычное значение, согласуемое в коде и связываемое с tombstone-аккаунтом / досье
|
||||
retained-identities по стабильному id. Это держит трату «Фишки↔бенефит» атомарной **внутри
|
||||
`payments`** и делает домен извлекаемым в свою базу. Сохранность — **PITR** (непрерывный
|
||||
WAL-архив), независимо от топологии базы.
|
||||
|
||||
Основные таблицы (финальные имена/колонки — в `PLAN.md`):
|
||||
|
||||
@@ -322,9 +325,11 @@ Cross-schema внешние ключи к `backend.accounts` держат тра
|
||||
(pending/paid/expired).
|
||||
- **payment_events** — succeeded/failed/refunded для диспетчера.
|
||||
|
||||
Legacy `accounts.hint_balance` и `accounts.paid_account` **устаревают** и удаляются
|
||||
(expand-contract) в пользу сегментированной модели; ни то, ни другое в проде никогда не
|
||||
выставлялось (потока покупки не было), поэтому legacy-значения обнуляются.
|
||||
Legacy `accounts.hint_balance` и `accounts.paid_account` **устаревают** в пользу
|
||||
сегментированной модели и удаляются в отдельной **contract-миграции** (expand-contract, после
|
||||
того как валютное ядро переключит чтения, и после Release 2 — откат образа остаётся безопасным
|
||||
для БД); ни то, ни другое в проде никогда не выставлялось (потока покупки не было), поэтому
|
||||
legacy-значения обнуляются.
|
||||
|
||||
## 15. Словарь
|
||||
|
||||
|
||||
Reference in New Issue
Block a user