Compare commits
9 Commits
v1.12.0
...
711fabe9cc
| Author | SHA1 | Date | |
|---|---|---|---|
| 711fabe9cc | |||
| 92633f935e | |||
| 07815c5a30 | |||
| bab57343e1 | |||
| ce8b5026c1 | |||
| d9bc7596c6 | |||
| 3ad3b1dd7f | |||
| 4d1f389cf1 | |||
| 4c7bc7baa9 |
@@ -316,9 +316,13 @@ jobs:
|
|||||||
# Auto test-deploy on a PR into development and on the push that merges it.
|
# Auto test-deploy on a PR into development and on the push that merges it.
|
||||||
# A PR into master is test-only (this job is skipped); prod deploy is manual.
|
# A PR into master is test-only (this job is skipped); prod deploy is manual.
|
||||||
# Gates on `gate` (so a real test failure blocks the deploy) but runs even when
|
# Gates on `gate` (so a real test failure blocks the deploy) but runs even when
|
||||||
# some test jobs were path-skipped.
|
# some test jobs were path-skipped. Skipped entirely when neither the Go nor the
|
||||||
needs: [gate]
|
# UI side changed (e.g. a docs-only change): the contour image is unchanged, so
|
||||||
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }}
|
# there is nothing to redeploy. `changes` still defaults both to true when the
|
||||||
|
# diff is uncomputable, and a workflow/deploy edit forces both true, so an
|
||||||
|
# ambiguous or infra change still deploys as a safety net.
|
||||||
|
needs: [changes, gate]
|
||||||
|
if: ${{ (needs.changes.outputs.go == 'true' || needs.changes.outputs.ui == 'true') && ((github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development')) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
|
|||||||
@@ -0,0 +1,638 @@
|
|||||||
|
# PLAN — monetization implementation
|
||||||
|
|
||||||
|
Technical, step-by-step implementation of the monetization domain. Business mechanics and
|
||||||
|
the rationale for every rule live in [`docs/PAYMENTS.md`](docs/PAYMENTS.md) (RU mirror
|
||||||
|
[`docs/PAYMENTS_ru.md`](docs/PAYMENTS_ru.md)); this file is the *how*. Each stage is written
|
||||||
|
to be self-sufficient: returning to it gives full context — goal, exact touch-points,
|
||||||
|
tests, done-criteria, and current status — without re-deriving decisions.
|
||||||
|
|
||||||
|
## How to use this file
|
||||||
|
|
||||||
|
- **Stage granularity (E0–E9) is fixed.** Do not split or merge stages. Plan each stage
|
||||||
|
densely enough to execute whole.
|
||||||
|
- **Never leak stage ids into the product.** Code, comments, commit messages, PR
|
||||||
|
titles/descriptions must read as finalized feature copy — never "E5", "stage 3", etc.
|
||||||
|
Stage ids exist only in this file.
|
||||||
|
- **Deviations are allowed** when new unknowns surface, but the plan is then edited **whole
|
||||||
|
and in agreement** (not piecemeal), keeping it coherent, and `docs/PAYMENTS.md` updated in
|
||||||
|
step.
|
||||||
|
- **Status marks:** each stage header carries `Status: TODO | WIP | DONE`. When a stage
|
||||||
|
lands, flip it to DONE and note the PR. The **Progress** table is the at-a-glance index.
|
||||||
|
- **Per-change discipline** (repo `CLAUDE.md`): update tests at the layers `docs/TESTING.md`
|
||||||
|
calls out, bake doc updates into the same PR, run local full verification before pushing,
|
||||||
|
feature branch → PR into `development`.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
| Stage | Title | Release | Status |
|
||||||
|
|-------|-------|---------|--------|
|
||||||
|
| E0 | Payments data foundation | 1 | DONE |
|
||||||
|
| E1 | Trusted platform signal | 1 | DONE |
|
||||||
|
| E2 | Currency + benefit core | 1 | TODO |
|
||||||
|
| E3 | Wallet UI | 1 | TODO |
|
||||||
|
| E4 | Durability (PITR) | 2 | TODO |
|
||||||
|
| E5 | Payment intake | 2 | TODO |
|
||||||
|
| E6 | Ads | 2 | TODO |
|
||||||
|
| E7 | Admin & reports | 2 | TODO |
|
||||||
|
| E8 | Guest limits | — | TODO |
|
||||||
|
| E9 | Tournament fee | future | TODO |
|
||||||
|
|
||||||
|
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
||||||
|
**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
|
||||||
|
parallel). E9 is future.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture baseline (applies to all stages)
|
||||||
|
|
||||||
|
Read once; individual stages assume it.
|
||||||
|
|
||||||
|
### Schema & isolation
|
||||||
|
|
||||||
|
- 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 **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
|
||||||
|
|
||||||
|
- New package `backend/internal/payments/` — `payments.go` (types + `Service`),
|
||||||
|
`store.go` (`type Store struct{ db *sql.DB }`, go-jet against `internal/postgres/jet/
|
||||||
|
payments`), following the `ads` domain shape (`backend/internal/ads/{ads,service,store}.go`).
|
||||||
|
- Wired in `backend/cmd/backend/main.go` `run()` (construct after `account`, before
|
||||||
|
`server.New`), handed into `server.Deps` (`server.go`), routes registered in
|
||||||
|
`registerRoutes` gated `if s.payments != nil`, admin section in `registerConsole` gated
|
||||||
|
`if s.payments != nil`.
|
||||||
|
- Game/other domains depend on payments only through a **narrow interface** (e.g.
|
||||||
|
`AdFree(ctx, account, platform) bool`, `SpendHint(ctx, account, platform) error`,
|
||||||
|
`Balances(ctx, account, platform) …`). Keep the surface small; this is the seam that lets
|
||||||
|
payments become a separate process later without touching callers.
|
||||||
|
|
||||||
|
### Migrations & codegen
|
||||||
|
|
||||||
|
- Migrations: same goose dir `backend/internal/postgres/migrations/`, sequential
|
||||||
|
`0000N_*.sql`, `-- +goose Up/Down`, schema-qualified, **expand-contract mandatory**
|
||||||
|
(image rollback must stay DB-safe). First payments migration does `CREATE SCHEMA IF NOT
|
||||||
|
EXISTS payments`, creates the role, grants.
|
||||||
|
- **jetgen** (`backend/cmd/jetgen/main.go`) is currently hardwired to `schema=backend`.
|
||||||
|
Extend it to also generate `schema=payments` into `internal/postgres/jet/payments/
|
||||||
|
{model,table}` (committed). Regenerate only after a payments migration; revert unrelated
|
||||||
|
`backend`-schema churn (jetgen may reorder untouched tables — commit only intended diffs).
|
||||||
|
- Transactions: reuse the local `withTx(ctx, db, fn)` idiom. Balance/benefit mutations are
|
||||||
|
guarded single-row atomic UPDATEs (mirror `account.SpendHint`); the ledger INSERT +
|
||||||
|
materialized-cache UPDATE go in one `withTx`. Default Read-Committed is sufficient given
|
||||||
|
the guarded updates + unique idempotency keys; do not reach for Serializable without a
|
||||||
|
demonstrated need.
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
- client ↔ gateway: Connect-RPC + FlatBuffers (h2c). gateway ↔ backend: REST/JSON +
|
||||||
|
`X-User-ID` via `gateway/internal/backendclient`. Any user-facing payments call needs the
|
||||||
|
full chain: backend REST handler (`u := s.user` group) → `backendclient` method →
|
||||||
|
Connect-RPC method + transcode (`gateway/internal/transcode/`) → FlatBuffers schema
|
||||||
|
(`pkg/fbs/…`, regen with `make -C pkg fbs` + `pnpm -C ui codegen`).
|
||||||
|
- Provider webhooks (Robokassa/VK) do NOT use the Connect path — model on the existing
|
||||||
|
public HMAC-signed route `s.public.GET("/dl/:id/:kind", …)` (`handlers.go`): a public
|
||||||
|
route with signature verification, proxied by the gateway/Caddy `@gateway` matcher
|
||||||
|
(`deploy/caddy/Caddyfile` — a new edge route MUST be added there or it falls to the
|
||||||
|
landing catch-all).
|
||||||
|
|
||||||
|
### Testing layers (`docs/TESTING.md`)
|
||||||
|
|
||||||
|
- **unit** (Go `_test.go`, TS vitest): gate-by-context, no-ads stacking, priority draw,
|
||||||
|
rate math, idempotency keys, catalog snapshotting. UI logic must be extracted out of
|
||||||
|
`.svelte` into `ui/src/lib/*` to be unit-testable.
|
||||||
|
- **integration** (`backend/internal/inttest/`, `//go:build integration`, Postgres-backed):
|
||||||
|
atomic chip↔benefit spend, callback idempotency, order-flow, TG outbox delivery, merge/
|
||||||
|
unlink of segments.
|
||||||
|
- **UI** (Playwright mock e2e, Chromium+WebKit): Wallet screen, warnings, guest-hidden, GP
|
||||||
|
stub. Mock e2e bypasses codec — wire bugs need codec unit tests.
|
||||||
|
- Local full verification before push: integration (`-tags=integration` + DAWG sibling +
|
||||||
|
Ryuk-off), UI check/test/build, codegen (`make -C pkg proto fbs`, `pnpm -C ui codegen`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E0 — Payments data foundation
|
||||||
|
|
||||||
|
**Status:** DONE · **Release 1** · depends on: none · mechanics: PAYMENTS §14, §7, §11.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**Migration `00010_payments_foundation.sql` (`payments` schema).**
|
||||||
|
|
||||||
|
- `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/{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).** 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 (`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 (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.** jetgen regenerates the whole `backend` schema; its committed jet had drifted from the
|
||||||
|
migrations (`robot_blocks`, `robot_friend_requests`, the `feedback_messages` `app_version` /
|
||||||
|
`browser_tz` columns, and `UseSchema` gaps), so this change also **regenerates and commits
|
||||||
|
`jet/backend`** to bring it back in sync (additive only — all suites stay green). The `payments`
|
||||||
|
role creation is idempotent (`DO $$` / `IF NOT EXISTS`) for fresh volumes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E1 — Trusted platform signal
|
||||||
|
|
||||||
|
**Status:** DONE · **Release 1** · depends on: none (parallel to E0) · mechanics: PAYMENTS §8.
|
||||||
|
|
||||||
|
**Goal.** Make the server know the execution platform from a trusted, unforgeable source,
|
||||||
|
carried on the session. This is the foundation the gate (E2) stands on; without it the gate is
|
||||||
|
meaningless. Signal plumbing only — no user-visible change.
|
||||||
|
|
||||||
|
**Model.** `platform = {kind: vk|telegram|direct, subtype: ios|android|web}` is a property of
|
||||||
|
the **session**, captured at creation. `kind` is always trusted — the gateway derives it from
|
||||||
|
the validated establish path (VK launch / TG initData / a web-native session), never a client
|
||||||
|
field. `subtype` is **cryptographically trusted only for VK** (it rides inside the signed
|
||||||
|
`vk_platform` launch param); for telegram and direct it is client-reported best-effort, and the
|
||||||
|
gate never relies on it (only the VK-iOS-frozen case is compliance-critical, and VK subtype is
|
||||||
|
trusted). VK/TG re-mint a session on **every cold start**, so their platform is re-captured each
|
||||||
|
launch; web/direct/email reuse the stored token, so their platform is captured once at creation
|
||||||
|
(`direct` needs no signature).
|
||||||
|
|
||||||
|
**Validation stays at the gateway.** Both wrapper verifiers already live there and the backend
|
||||||
|
cannot import either (separate modules under `internal/`, and the VK app secret is gateway-only):
|
||||||
|
VK launch params via the in-process `gateway/internal/vkauth.Verify` (HMAC-SHA256 over the signed
|
||||||
|
`vk_*` params under `GATEWAY_VK_APP_SECRET`, extended here to expose the signed `vk_platform` →
|
||||||
|
a trusted `Subtype()`), TG `initData` via the validator RPC. The gateway hands the derived
|
||||||
|
platform to the backend at establish; the backend persists it and returns it on resolve.
|
||||||
|
|
||||||
|
**Backend.**
|
||||||
|
|
||||||
|
- `backend.sessions` gains nullable `platform_kind` + `platform_subtype` columns (migration
|
||||||
|
`00011`, CHECK-constrained, jet regenerated). A `session.Platform` value type + `session.Create`
|
||||||
|
captures it; `Session` carries it through the store and the warm cache.
|
||||||
|
- `handleResolveSession` → `resolveResponse` (`dto.go`) returns the platform; the establish
|
||||||
|
handlers set `kind` from their own endpoint (`/sessions/telegram|vk|guest|email/*`) and
|
||||||
|
`subtype` from the request (VK: gateway-extracted from the signed params; TG/direct: the client
|
||||||
|
best-effort field, defaulting web). The account-merge session mint (`link.merge`) inherits the
|
||||||
|
caller's platform from the request context.
|
||||||
|
- Middleware (`middleware.go`) parses the gateway-injected `X-Platform` into the request context
|
||||||
|
on the `s.user` group; `platform(c)` exposes it. Absent ⇒ untrusted (fail-closed).
|
||||||
|
|
||||||
|
**Gateway.**
|
||||||
|
|
||||||
|
- `backendclient.WithPlatform(ctx, "<kind>/<subtype>")` + `do`/`getRaw` inject `X-Platform` on
|
||||||
|
every authenticated backend call; `connectsrv.Execute` enriches the request context from the
|
||||||
|
resolved session's platform (carried through the gateway session cache + `ResolveSession`).
|
||||||
|
Never from a client request body.
|
||||||
|
|
||||||
|
**Client (`ui/`).** `platformSubtype()` (`ui/src/lib/platform.ts`) supplies a best-effort device
|
||||||
|
subtype on the `auth.telegram` / `auth.guest` / `auth.email.login` requests (new FBS `subtype`
|
||||||
|
field): Telegram's `WebApp.platform` inside a Mini App, else the Capacitor/web channel. VK sends
|
||||||
|
nothing (the gateway derives the trusted subtype from the signed launch params).
|
||||||
|
|
||||||
|
**Tests.**
|
||||||
|
|
||||||
|
- unit (Go): `vkauth` exposes the signed `vk_platform` and maps iPhone/iPad → ios (the frozen
|
||||||
|
case); the `X-Platform` middleware round-trips + reports untrusted when absent; the backend
|
||||||
|
client injects `X-Platform` from the context and omits it when untrusted.
|
||||||
|
- integration: a session carries its platform through create + a cold-cache (DB) resolve for each
|
||||||
|
kind; an unattributed session is untrusted; the CHECK constraints bite; migration `00011`
|
||||||
|
applies forward **and backward** on a throwaway PG.
|
||||||
|
- UI: subtype normalization (vitest) + the new `subtype` field on the wire (codec unit test).
|
||||||
|
|
||||||
|
**Done-criteria (met).** A VK/TG session resolves to a trusted `{kind, subtype}`; a forged body
|
||||||
|
cannot change `kind` (nor the VK subtype); `direct` sessions resolve to `direct`; the untrusted
|
||||||
|
path is reachable and observable via `platform(c)`. No user-visible change; all layers green.
|
||||||
|
|
||||||
|
**Notes/risks.** High blast-radius (auth/session table + broad gateway header threading): additive
|
||||||
|
migration, no mixed-in refactors, `X-Platform` inert until E2 consumes it. **Sessions minted before
|
||||||
|
E1 carry no platform → untrusted (view-only) until re-login**; VK/TG self-heal on the next
|
||||||
|
cold-start re-mint, direct/email do not (accepted: Release 1 has no money, sessions cycle by
|
||||||
|
Release 2). TG/direct subtype is not cryptographically trusted — E2 must keep the gate on `kind`
|
||||||
|
+ the trusted VK subtype only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E2 — Currency + benefit core
|
||||||
|
|
||||||
|
**Status:** TODO · **Release 1** · depends on: E0, E1 · mechanics: PAYMENTS §2–§6, §11.
|
||||||
|
|
||||||
|
**Goal.** The full internal money mechanic, exercisable end-to-end **without real money**
|
||||||
|
via `admin_grant`: chip balances, spend→benefit atomically, the compliance gate by context,
|
||||||
|
benefit application (no-ads stacking, hints per-origin), the legacy migration, and the
|
||||||
|
`SpendHint` rewrite. This is the heart.
|
||||||
|
|
||||||
|
**Payments service (Go).**
|
||||||
|
|
||||||
|
- `Balances(ctx, account, platform)` → the segments spendable in the platform's context
|
||||||
|
(VK→vk; TG→tg; direct→direct+vk+tg; VK-iOS→frozen/view; untrusted→none).
|
||||||
|
- `Spend(ctx, account, platform, productID)` → gate-checked purchase of a value with chips:
|
||||||
|
resolve spendable segments by context, draw by priority (direct→vk→tg on web), write a
|
||||||
|
`spend` ledger row + decrement balance + apply benefit (extend `ads_paid_until[origin]`
|
||||||
|
from `max(now,end)` / set `ads_forever` / add hints) **in one tx**, stamping `origin` =
|
||||||
|
platform context. Refuse if untrusted (fail-closed) or funds insufficient.
|
||||||
|
- `Grant(ctx, account, origin, atoms)` → `admin_grant` ledger row (price 0, concrete values
|
||||||
|
only, never chips), same benefit application; origin chosen by the admin (E7 UI; here the
|
||||||
|
service method + an internal/admin entrypoint).
|
||||||
|
- `AdFree(ctx, account, platform)` / `HintsAvailable(ctx, account, platform)` /
|
||||||
|
`SpendHint(ctx, account, platform)` → apply the one-directional origin rule: in context P,
|
||||||
|
usable origins = {P} plus, when P is web/native, {vk,tg} (relaxation outward); in VK only
|
||||||
|
vk; in TG only tg.
|
||||||
|
- `Merge` / `Unlink` hooks: extend `accountmerge` to merge segments+benefits by origin
|
||||||
|
(same-origin add, different coexist), and make segment availability follow identity
|
||||||
|
presence (unlink → sleep, re-link → wake). Warn-before-unlink is a UI concern (surface the
|
||||||
|
balance via the interface).
|
||||||
|
|
||||||
|
**Backend wiring & migration.**
|
||||||
|
|
||||||
|
- Deprecate-and-flip `accounts.hint_balance` / `accounts.paid_account`: E0 added the tables;
|
||||||
|
here, move reads/writes to `payments.benefits`. `SpendHint` (`game/service.go` ~:1147) and
|
||||||
|
`ads.Eligible` (`ads/ads.go` :107) call the payments interface instead of the account
|
||||||
|
columns. Legacy values were never set in prod → zero them; the DROP is the contract phase
|
||||||
|
(guarded, after Release 2 — keep columns until then for rollback safety).
|
||||||
|
- `vs_ai` hints stay free/unlimited (unchanged 30-min gate); only online-game hint spend
|
||||||
|
routes through the segmented, context-aware path.
|
||||||
|
|
||||||
|
**API (user-facing, Connect chain).** Read-only wallet view + spend:
|
||||||
|
`GET /api/v1/user/wallet` (balances+benefits for the context), `POST /api/v1/user/wallet/
|
||||||
|
buy` (spend chips on a product). Add the backend handlers (`u := s.user`), `backendclient`
|
||||||
|
methods, Connect methods + transcode + FBS. Admin grant is internal/admin (E7 UI).
|
||||||
|
|
||||||
|
**Tests.**
|
||||||
|
|
||||||
|
- unit (Go): gate-by-context matrix (every row of PAYMENTS §4); priority draw; no-ads
|
||||||
|
stacking (`+=` from max(now,end)) + forever override; per-origin hint applicability;
|
||||||
|
merge/unlink segment math.
|
||||||
|
- integration: atomic spend (ledger+balance+benefit in one tx; failure rolls all back);
|
||||||
|
admin_grant credits values not chips; legacy migration zeroes and flips reads; merge/
|
||||||
|
unlink over Postgres.
|
||||||
|
- UI: none new (E3 builds the screen); wallet DTO covered by codec unit tests.
|
||||||
|
|
||||||
|
**Done-criteria.** With chips seeded via `admin_grant`, a user can buy no-ads/hints; the gate
|
||||||
|
blocks cross-context spend and cross-origin application; ads gate reads the new benefit;
|
||||||
|
`SpendHint` uses segments; all layers green; **compliance regression** (a `direct` benefit
|
||||||
|
never activates inside VK/TG) is a named test.
|
||||||
|
|
||||||
|
**Notes/risks.** This is the high-blast-radius core (money semantics + a live path
|
||||||
|
`SpendHint`/`ads.Eligible`). Minimize surface, keep the interface narrow, no mixed-in
|
||||||
|
refactors. The legacy flip is expand-contract: reads move first, DROP much later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E3 — Wallet UI
|
||||||
|
|
||||||
|
**Status:** TODO · **Release 1** · depends on: E2 · mechanics: PAYMENTS §1, §7, §6, §13, §4.
|
||||||
|
|
||||||
|
**Goal.** The user-facing "Кошелёк" (Wallet) section: balances + active benefits + the
|
||||||
|
catalog storefront, honouring guest-hidden, GP-stub, and the web-spend warning.
|
||||||
|
|
||||||
|
**UI (`ui/`).**
|
||||||
|
|
||||||
|
- New `'wallet'` tab in `ui/src/screens/SettingsHub.svelte` — `SettingsTab` union, tab
|
||||||
|
button **between Friends (:65) and About (:66)**, body branch in the `:42-51` switch,
|
||||||
|
`'wallet'` route in `ui/src/lib/routeparse.ts` + `ui/src/App.svelte`. Reuse the hub's
|
||||||
|
guest/offline gating.
|
||||||
|
- `Wallet.svelte` screen: **minimal** — context-available chip balances + active benefits
|
||||||
|
(no-ads until date, hints count). **No history feed** (PAYMENTS §11 — noise). Storefront:
|
||||||
|
products from the configurable catalog, prices in chips (values) / per-method (chip packs).
|
||||||
|
- **Guest:** section hidden entirely (durable-only).
|
||||||
|
- **GP build:** purchase CTA replaced by a stub ("install the RuStore build to make
|
||||||
|
purchases"); rewarded + spending earned chips still work. Detect the GP native build.
|
||||||
|
- **Web-spend warning:** before spending vk/tg chips in a web/native (direct) context, show
|
||||||
|
an own `Modal` (not `showPopup` — that eats the user-activation gesture) warning the value
|
||||||
|
will be web/native-only.
|
||||||
|
- Extract all logic into `ui/src/lib/*` (unit-testable); keep `.svelte` thin.
|
||||||
|
|
||||||
|
**Tests.**
|
||||||
|
|
||||||
|
- unit (vitest): storefront/price formatting, context-available-segment selection, warning
|
||||||
|
trigger condition.
|
||||||
|
- UI (Playwright mock, Chromium+WebKit): Wallet renders between Friends/About; guest hides
|
||||||
|
it; GP stub; warning modal on web vk/tg spend. Mock overlay must stay instant under
|
||||||
|
`MODE==='mock'` (or it intercepts taps).
|
||||||
|
|
||||||
|
**Done-criteria.** Owner can review the Wallet on the deployed test contour (visual sign-off
|
||||||
|
is on the contour, not local); balances/benefits/storefront correct per context; guest/GP/
|
||||||
|
warning paths verified. Release 1 is now demonstrable end-to-end via `admin_grant`.
|
||||||
|
|
||||||
|
**Notes/risks.** No global `.btn`/`.ghost` in `ui/` — style per-component with scoped CSS +
|
||||||
|
tokens (mirror NewGame `.invite` for a CTA). Svelte whitespace/`$state` naming gotchas
|
||||||
|
apply. iOS WebView download/gesture caveats are irrelevant here (no file delivery).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E4 — Durability (PITR)
|
||||||
|
|
||||||
|
**Status:** TODO · **Release 2** · depends on: E0 (schema exists) · mechanics: PAYMENTS §14.
|
||||||
|
|
||||||
|
**Goal.** Continuous WAL archiving with point-in-time recovery, armed **before the first
|
||||||
|
real money** is accepted (E5 prod). Protects both money and game data.
|
||||||
|
|
||||||
|
**Work.**
|
||||||
|
|
||||||
|
- Add WAL archiving to the prod Postgres (pgBackRest or WAL-G): base backups + continuous
|
||||||
|
WAL to a second host or object storage. Wire into `deploy/` (compose/prod overlay +
|
||||||
|
ansible `deploy/ansible/`), config + secrets under the `PROD_` prefix.
|
||||||
|
- Restore runbook in `deploy/README.md`: base + WAL replay to a timestamp; test the restore
|
||||||
|
on a scratch target.
|
||||||
|
- Keep the existing migration-time `pg_dump` (`deploy/prod-deploy.sh`) as belt-and-braces.
|
||||||
|
|
||||||
|
**Mandatory pre-prod assessment (owner-required, gates the Release 2 prod rollout).**
|
||||||
|
Before the first money goes live, produce and record here:
|
||||||
|
|
||||||
|
1. **Disk-storage cost estimate** for the WAL archive + base backups (retention window ×
|
||||||
|
WAL volume; account for both hosts / object-storage pricing). This can change hosting
|
||||||
|
sizing.
|
||||||
|
2. **PG performance-degradation assessment** from continuous archiving (write amplification,
|
||||||
|
archive_command latency, backup I/O contention) on the prod-sized instance.
|
||||||
|
|
||||||
|
Neither blocks building the plan, but both **must** be completed and reviewed before the
|
||||||
|
prod release — surface the numbers to the owner (they may change host settings).
|
||||||
|
|
||||||
|
**Tests.** Restore drill on a scratch instance (base + WAL → target timestamp) documented
|
||||||
|
and passing. No app-level tests.
|
||||||
|
|
||||||
|
**Done-criteria.** WAL archiving live on prod PG; a timed restore verified; cost + perf
|
||||||
|
assessment recorded and owner-reviewed; runbook in `deploy/README.md`.
|
||||||
|
|
||||||
|
**Notes/risks.** The tg host is weak (1 vCPU) — if the archive lands there, watch
|
||||||
|
contention. Migrations stay expand-contract so image rollback remains DB-safe alongside PITR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E5 — Payment intake
|
||||||
|
|
||||||
|
**Status:** TODO · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
|
||||||
|
|
||||||
|
**Goal.** Accept real money on all three rails into the payments domain: order-flow,
|
||||||
|
verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher,
|
||||||
|
receipts, and refunds.
|
||||||
|
|
||||||
|
**Order-flow & intake (single writer).**
|
||||||
|
|
||||||
|
- `POST /api/v1/user/wallet/order` → create `order(pending)` (account/platform/product/
|
||||||
|
expected amount/origin), return the provider-specific launch payload with `order_id`
|
||||||
|
threaded in (Robokassa `InvId` / TG `invoice_payload` / VK `item`).
|
||||||
|
- Robokassa + VK **public webhooks**: new edge routes (add to `deploy/caddy/Caddyfile`
|
||||||
|
`@gateway` matcher — or they fall to the landing catch-all), signature/HMAC verified,
|
||||||
|
proxied into a payments intake handler. Match by `order_id`, verify amount, credit
|
||||||
|
(ledger `fund` + balance UPDATE in one tx), mark order `paid`, emit `payment_event`.
|
||||||
|
Idempotent by `(provider, provider_payment_id)`.
|
||||||
|
- **Pending sweep:** background reaper expires pending orders after the configured timeout
|
||||||
|
(~30 min). Expiry is cosmetic — a later valid callback still credits (revive/honour).
|
||||||
|
|
||||||
|
**TG bot outbox (`platform/telegram/`).**
|
||||||
|
|
||||||
|
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. Add a
|
||||||
|
**SQLite** store on the bot's disk: on receipt, persist → ack the Telegram update → forward
|
||||||
|
to a backend payments-intake endpoint (internal, authenticated over the existing reverse
|
||||||
|
mTLS bot-link) → on backend ack, mark `forwarded`. Retries with backoff; re-drive
|
||||||
|
undelivered on restart. Backend intake dedups by `telegram_payment_charge_id`.
|
||||||
|
- Provide the invoice creation path (Stars) from the Mini App via the bot as needed.
|
||||||
|
|
||||||
|
**Events & notifications.**
|
||||||
|
|
||||||
|
- `payment_events` dispatcher: `succeeded`/`failed`/`refunded` → live gRPC stream if the
|
||||||
|
user is connected, else `botlink` push / email relay (existing). "failed" = an active
|
||||||
|
provider decline (not an abandoned pending) surfaced to the user; "succeeded" hook
|
||||||
|
(email/bot message).
|
||||||
|
|
||||||
|
**Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK
|
||||||
|
handles Votes tax itself; TG Stars — no receipt.
|
||||||
|
|
||||||
|
**Refunds (§9).** ToS non-refundable; admin manual refund (ties to `accountdelete`); external
|
||||||
|
`refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse
|
||||||
|
flag if spent), ledger `refund` row. Ledger export-ready (reconciliation not built).
|
||||||
|
|
||||||
|
**Tests.**
|
||||||
|
|
||||||
|
- unit: signature/HMAC verifiers per rail; order-id matching; idempotency-key dedup.
|
||||||
|
- integration: full order→callback→credit per rail; duplicate callback credits once; expired
|
||||||
|
order still honoured on late callback; TG outbox store→forward→ack→cleanup + restart
|
||||||
|
re-drive; refund revoke best-effort/never-negative.
|
||||||
|
- UI: purchase flow reaches the provider launch (mock); success/failure surfaced.
|
||||||
|
|
||||||
|
**Done-criteria.** A real (sandbox) payment on each rail credits chips exactly once; a
|
||||||
|
replayed callback does not double-credit; the TG outbox survives a bot restart mid-flight;
|
||||||
|
failed/succeeded events reach the user; refund path exercised. **PITR (E4) armed and the
|
||||||
|
cost/perf assessment reviewed before this goes to prod.**
|
||||||
|
|
||||||
|
**Notes/risks.** Manual `docker run -p` boot tests fail from the shell — verify the runnable
|
||||||
|
artifact via the testcontainers integration suite. Distroless services run UID 65532;
|
||||||
|
bind-mounted secrets must be 0644. Edge routes silently fall through if not added to the
|
||||||
|
Caddyfile — add a CI probe. The prod rolling deploy skips caddy on config-only changes —
|
||||||
|
force-recreate when the Caddyfile changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E6 — Ads
|
||||||
|
|
||||||
|
**Status:** TODO · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
|
||||||
|
mechanics: PAYMENTS §10.
|
||||||
|
|
||||||
|
**Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video
|
||||||
|
(credits chips via server verify), plus extending the existing banner suppression to
|
||||||
|
per-origin.
|
||||||
|
|
||||||
|
**Work.**
|
||||||
|
|
||||||
|
- **Provider abstraction:** a small ads-network interface (VK impl now) so a future network
|
||||||
|
for other platforms slots in without rework. VK integration via `ui/src/lib/vk.ts`.
|
||||||
|
- **Rewarded:** voluntary view → the network's **server verify callback** → payments intake
|
||||||
|
credits chips to the `vk` segment (client not believed, like a payment). On-launch
|
||||||
|
anti-fraud = provider verify only (no own daily cap; abstraction allows later).
|
||||||
|
- **Interstitial** (post-move fullscreen), configurable server values (from `payments`
|
||||||
|
config): global per-user cooldown across all games (default 5 min); `vs_ai` 30 min; a hint
|
||||||
|
application triggers a post-move interstitial independently with its own 1-min cooldown;
|
||||||
|
offline banner-only; respect VK's own frequency caps. Cooldown state tracked server-side
|
||||||
|
(per user) or client-mirrored from a server value — pick and document at implementation.
|
||||||
|
- **Banner suppression:** extend `ads.Eligible` (`backend/internal/ads/ads.go` :107) to gate
|
||||||
|
on the **origin benefit applicable in the current context** (E2 interface) instead of the
|
||||||
|
single legacy flag. No-ads suppresses banner + interstitial; rewarded never suppressed.
|
||||||
|
|
||||||
|
**Tests.**
|
||||||
|
|
||||||
|
- unit: cooldown logic (global 5m / vs_ai 30m / hint-trigger 1m independence); rewarded
|
||||||
|
credit path; banner eligibility per context.
|
||||||
|
- integration: rewarded verify → credit exactly once (idempotent like a payment).
|
||||||
|
- UI: interstitial shows/respects cooldown (mock); rewarded button; no-ads hides banner +
|
||||||
|
interstitial; rewarded still available under no-ads.
|
||||||
|
|
||||||
|
**Done-criteria.** VK rewarded credits chips once per verified view; interstitial respects
|
||||||
|
all cooldowns incl. the hint trigger; no-ads suppresses banner+interstitial but not
|
||||||
|
rewarded; offline shows banner only.
|
||||||
|
|
||||||
|
**Notes/risks.** Crypto-payout networks stay rejected. Rewarded-without-payout is pointless —
|
||||||
|
only enable where the network pays (VK). Keep the interstitial frequency as server config so
|
||||||
|
it tunes without a store release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E7 — Admin & reports
|
||||||
|
|
||||||
|
**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant), E5 (payments/refunds) ·
|
||||||
|
mechanics: PAYMENTS §11.
|
||||||
|
|
||||||
|
**Goal.** The admin console financial surface: per-user report, admin grant UI, manual
|
||||||
|
refund UI, ledger export.
|
||||||
|
|
||||||
|
**Work (`/_gm`, `backend/internal/server/handlers_admin_console.go` + `adminconsole/`).**
|
||||||
|
|
||||||
|
- **Per-user financial panel** on the existing user card (`consoleUserDetail` :343,
|
||||||
|
`UserDetailView`): segment balances, payments, spends, grants, refunds, full history —
|
||||||
|
read from the append-only ledger + materialized cache.
|
||||||
|
- **Admin grant** action (mirror the existing `POST /_gm/users/:id/grant-hints`): grant
|
||||||
|
concrete values (no-ads days / hints), **origin picker**, **never chips**; writes an
|
||||||
|
`admin_grant` ledger row (E2 `Grant`).
|
||||||
|
- **Manual refund** action: admin-initiated refund of a specific order (ties to the refund
|
||||||
|
path); records a `refund` ledger row; best-effort benefit revoke.
|
||||||
|
- **Ledger export**: CSV/JSON export of the ledger for tax reporting + future Robokassa
|
||||||
|
reconciliation (export-ready schema; reconciliation itself not built).
|
||||||
|
- Auth unchanged: gateway Basic-Auth in front of `/_gm` + backend same-origin CSRF on POSTs.
|
||||||
|
|
||||||
|
**Tests.**
|
||||||
|
|
||||||
|
- unit: report view assembly; grant refuses chips; export shape.
|
||||||
|
- integration: grant/refund write correct ledger rows; report reflects balances+history.
|
||||||
|
|
||||||
|
**Done-criteria.** An operator can see a user's full financial picture, grant concrete
|
||||||
|
values (origin-picked, never chips), issue a manual refund, and export the ledger.
|
||||||
|
|
||||||
|
**Notes/risks.** `/_gm` per-user detail already surfaces `PaidAccount`/`HintBalance` — replace
|
||||||
|
those with the segmented view as the legacy columns retire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E8 — Guest limits
|
||||||
|
|
||||||
|
**Status:** TODO · **standalone** (game-behaviour change; can run in parallel) · depends on:
|
||||||
|
none · mechanics: PAYMENTS §6.
|
||||||
|
|
||||||
|
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today the
|
||||||
|
gating is UI-only).
|
||||||
|
|
||||||
|
**Finding (verified).** The friend/invitation paths do **not** check `is_guest` on the
|
||||||
|
server — only the UI hides them: `social/friends.go:50` `SendFriendRequest`,
|
||||||
|
`robotfriends.go:41` `RequestInGame`, `friendcodes.go:67` `RedeemFriendCode`,
|
||||||
|
`lobby/invitations.go:208` `CreateInvitation`. A guest with a valid `X-User-ID` can call them
|
||||||
|
in the UI's stead.
|
||||||
|
|
||||||
|
**Work.**
|
||||||
|
|
||||||
|
- **Server guest gate** on friend-request / redeem-code / invitation-create: refuse when the
|
||||||
|
caller `is_guest` (add an `ErrGuestForbidden`-style guard, as `feedback` already has).
|
||||||
|
- **Active-game limits** for guests: at most **1 random-opponent game + 1 vs_ai** concurrently
|
||||||
|
(enforce on game/invitation creation in `lobby`/`game`; today no such limit exists).
|
||||||
|
- Keep the existing UI gating; this adds the server as the source of truth.
|
||||||
|
|
||||||
|
**Tests.**
|
||||||
|
|
||||||
|
- integration: guest is refused friend/redeem/invitation server-side; guest blocked from a
|
||||||
|
2nd random / 2nd vs_ai; durable account unaffected.
|
||||||
|
- UI: guest sees the funnel (existing hides + any new messaging).
|
||||||
|
|
||||||
|
**Done-criteria.** Guests are server-limited to 1 random + 1 vs_ai and cannot friend/invite;
|
||||||
|
durable accounts unchanged; regression that this does not break existing durable flows.
|
||||||
|
|
||||||
|
**Notes/risks.** This changes existing game behaviour — its own tests, its own PR, no
|
||||||
|
mixed-in payments changes. Decide whether a guest may finish an already-started game (limit
|
||||||
|
on creation, not mid-game) at implementation and record it here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E9 — Tournament fee (future)
|
||||||
|
|
||||||
|
**Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature ·
|
||||||
|
mechanics: PAYMENTS §5, §7.
|
||||||
|
|
||||||
|
**Goal.** Charge a chip entry fee for tournaments. The `tournament` atom + a catalog product
|
||||||
|
are provisioned in E0/E7; the spend mechanic reuses E2 (`Spend`). The actual tournament
|
||||||
|
feature and its coupling to entry are out of scope until the tournament feature exists.
|
||||||
|
|
||||||
|
**Done-criteria.** Deferred — revisit when tournaments are built.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification & CI (all stages)
|
||||||
|
|
||||||
|
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
|
||||||
|
`direct` benefit must never activate inside VK/TG) and runs from E2 onward.
|
||||||
|
- Local full verification before every push: `go build/vet ./backend/... && gofmt -l .`,
|
||||||
|
integration (`-tags=integration` + DAWG sibling + Ryuk-off), `pnpm -C ui check/test:unit/
|
||||||
|
build`, Playwright mock e2e, codegen (`make -C pkg proto fbs`, `pnpm -C ui codegen`).
|
||||||
|
- Contour: each PR into `development` auto-deploys the test contour; owner does visual sign-
|
||||||
|
off there (not local). Keep a multi-PR batch a linear stack (one shared contour,
|
||||||
|
last-deploy-wins).
|
||||||
|
- Prod (Release 2): expand-contract migrations only (image rollback DB-safe); PITR armed +
|
||||||
|
the E4 cost/perf assessment reviewed **before** the first money; new edge routes added to
|
||||||
|
the Caddyfile with a CI probe; caddy force-recreated on config-only changes.
|
||||||
|
- Release framing: **shipped docs/code/commits/PRs carry no stage ids** — finalize copy for
|
||||||
|
the release, not the plan.
|
||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
"scrabble/backend/internal/pushgrpc"
|
"scrabble/backend/internal/pushgrpc"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
@@ -260,6 +261,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
// block and the banner admin console section.
|
// block and the banner admin console section.
|
||||||
adsSvc := ads.NewService(ads.NewStore(db))
|
adsSvc := ads.NewService(ads.NewStore(db))
|
||||||
|
|
||||||
|
// In-game currency domain (data foundation): the payments schema behind a
|
||||||
|
// narrow interface. A boot-time reachability check fails fast if the schema
|
||||||
|
// did not migrate; the wallet routes are registered when that surface lands.
|
||||||
|
paymentsSvc := payments.NewService(payments.NewStore(db))
|
||||||
|
if err := paymentsSvc.Ping(ctx); err != nil {
|
||||||
|
return fmt.Errorf("payments schema unreachable: %w", err)
|
||||||
|
}
|
||||||
|
logger.Info("payments domain ready")
|
||||||
|
|
||||||
// The image-render sidecar client for the PNG export artifact; nil (PNG
|
// The image-render sidecar client for the PNG export artifact; nil (PNG
|
||||||
// download answers 404) when BACKEND_RENDERER_URL is unset.
|
// download answers 404) when BACKEND_RENDERER_URL is unset.
|
||||||
var renderer *render.Client
|
var renderer *render.Client
|
||||||
@@ -287,6 +297,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
RateWatch: rateWatch,
|
RateWatch: rateWatch,
|
||||||
BanView: banView,
|
BanView: banView,
|
||||||
Ads: adsSvc,
|
Ads: adsSvc,
|
||||||
|
Payments: paymentsSvc,
|
||||||
Notifier: hub,
|
Notifier: hub,
|
||||||
ExportSignKey: cfg.ExportSignKey,
|
ExportSignKey: cfg.ExportSignKey,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
// 1. start a postgres:17-alpine container via testcontainers-go
|
// 1. start a postgres:17-alpine container via testcontainers-go
|
||||||
// 2. open it with search_path=backend and apply the embedded goose migrations
|
// 2. open it with search_path=backend and apply the embedded goose migrations
|
||||||
// 3. drop goose's bookkeeping table so jet does not generate a model for it
|
// 3. drop goose's bookkeeping table so jet does not generate a model for it
|
||||||
// 4. run jet's PostgreSQL generator for schema=backend into internal/postgres/jet
|
// 4. run jet's PostgreSQL generator for the backend and payments schemas into
|
||||||
|
// internal/postgres/jet (one subdirectory per schema)
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -36,6 +37,7 @@ const (
|
|||||||
superuserPassword = "scrabble"
|
superuserPassword = "scrabble"
|
||||||
superuserDatabase = "scrabble_backend"
|
superuserDatabase = "scrabble_backend"
|
||||||
backendSchema = "backend"
|
backendSchema = "backend"
|
||||||
|
paymentsSchema = "payments"
|
||||||
containerStartup = 90 * time.Second
|
containerStartup = 90 * time.Second
|
||||||
jetOutputDirSuffix = "internal/postgres/jet"
|
jetOutputDirSuffix = "internal/postgres/jet"
|
||||||
)
|
)
|
||||||
@@ -105,11 +107,16 @@ func run(ctx context.Context) error {
|
|||||||
return fmt.Errorf("drop goose_db_version: %w", err)
|
return fmt.Errorf("drop goose_db_version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := jetpostgres.GenerateDB(db, backendSchema, outputDir); err != nil {
|
// Each schema generates into its own jet/<schema>/ subdirectory (the
|
||||||
return fmt.Errorf("jet generate: %w", err)
|
// generator wipes only that subtree), so the two calls do not clobber each
|
||||||
|
// other. GenerateDB takes the schema explicitly, so the connection's
|
||||||
|
// search_path=backend does not affect the payments introspection.
|
||||||
|
for _, schema := range []string{backendSchema, paymentsSchema} {
|
||||||
|
if err := jetpostgres.GenerateDB(db, schema, outputDir); err != nil {
|
||||||
|
return fmt.Errorf("jet generate schema=%s: %w", schema, err)
|
||||||
|
}
|
||||||
|
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, schema)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, backendSchema)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||||
|
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||||
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/postgres"
|
||||||
|
"scrabble/backend/internal/postgres/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isPgCode reports whether err is a PostgreSQL server error with the given
|
||||||
|
// SQLSTATE code.
|
||||||
|
func isPgCode(err error, code string) bool {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
return errors.As(err, &pgErr) && pgErr.Code == code
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsSchemaAndSeeds checks the payments schema, its role and the fixed
|
||||||
|
// seed rows are present after migration.
|
||||||
|
func TestPaymentsSchemaAndSeeds(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var atoms int
|
||||||
|
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.catalog_atom").Scan(&atoms); err != nil {
|
||||||
|
t.Fatalf("count catalog_atom: %v", err)
|
||||||
|
}
|
||||||
|
if atoms != 4 {
|
||||||
|
t.Errorf("catalog_atom rows = %d, want 4 (chips/hints/noads_days/tournament)", atoms)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg int
|
||||||
|
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&cfg); err != nil {
|
||||||
|
t.Fatalf("count config: %v", err)
|
||||||
|
}
|
||||||
|
if cfg != 1 {
|
||||||
|
t.Errorf("config rows = %d, want the single seeded row", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var roleExists bool
|
||||||
|
if err := testDB.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil {
|
||||||
|
t.Fatalf("check payments role: %v", err)
|
||||||
|
}
|
||||||
|
if !roleExists {
|
||||||
|
t.Error("payments role missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsRoleConfinement proves the payments role is confined to its own
|
||||||
|
// schema: it can read payments.* but is denied on backend.*. The application
|
||||||
|
// itself connects as a superuser (which bypasses this), so this asserts the
|
||||||
|
// grants are correct as the stepping-stone to a real separate login; the runtime
|
||||||
|
// wall for the superuser pool is the import-boundary test in the payments package.
|
||||||
|
func TestPaymentsRoleConfinement(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tx, err := testDB.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin tx: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, "SET LOCAL ROLE payments"); err != nil {
|
||||||
|
t.Fatalf("SET LOCAL ROLE payments: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The payments role can read its own schema.
|
||||||
|
var n int
|
||||||
|
if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&n); err != nil {
|
||||||
|
t.Errorf("payments role denied on payments.config (grants wrong): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The payments role must NOT reach the backend schema.
|
||||||
|
err = tx.QueryRowContext(ctx, "SELECT count(*) FROM backend.accounts").Scan(&n)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("payments role could read backend.accounts — cross-schema isolation broken")
|
||||||
|
}
|
||||||
|
if !isPgCode(err, "42501") {
|
||||||
|
t.Errorf("reading backend.accounts as payments: got %v, want permission-denied (42501)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsLedgerAppendOnly verifies the append-only trigger rejects any
|
||||||
|
// UPDATE or DELETE on the ledger (a superuser is bound by the trigger, unlike a
|
||||||
|
// mere privilege REVOKE).
|
||||||
|
func TestPaymentsLedgerAppendOnly(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
id := uuid.New()
|
||||||
|
if _, err := testDB.ExecContext(ctx,
|
||||||
|
`INSERT INTO payments.ledger (ledger_id, account_id, kind, chips_delta) VALUES ($1, $2, 'admin_grant', 0)`,
|
||||||
|
id, uuid.New()); err != nil {
|
||||||
|
t.Fatalf("insert ledger row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := testDB.ExecContext(ctx, `UPDATE payments.ledger SET chips_delta = 1 WHERE ledger_id = $1`, id); err == nil {
|
||||||
|
t.Error("UPDATE on payments.ledger succeeded — append-only violated")
|
||||||
|
} else if !isPgCode(err, "P0001") {
|
||||||
|
t.Errorf("UPDATE ledger: got %v, want the append-only exception (P0001)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := testDB.ExecContext(ctx, `DELETE FROM payments.ledger WHERE ledger_id = $1`, id); err == nil {
|
||||||
|
t.Error("DELETE on payments.ledger succeeded — append-only violated")
|
||||||
|
} else if !isPgCode(err, "P0001") {
|
||||||
|
t.Errorf("DELETE ledger: got %v, want the append-only exception (P0001)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsCheckConstraints verifies the domain CHECKs bite: non-negative
|
||||||
|
// balances/hints/amount and the enum-like columns.
|
||||||
|
func TestPaymentsCheckConstraints(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
prod := uuid.New()
|
||||||
|
if _, err := testDB.ExecContext(ctx, `INSERT INTO payments.product (product_id, title) VALUES ($1, 'test')`, prod); err != nil {
|
||||||
|
t.Fatalf("insert product: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
sql string
|
||||||
|
args []any
|
||||||
|
}{
|
||||||
|
{"balances chips negative", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'vk', -1)`, []any{uuid.New()}},
|
||||||
|
{"balances bad source", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'nope', 0)`, []any{uuid.New()}},
|
||||||
|
{"benefits hints negative", `INSERT INTO payments.benefits (account_id, origin, hints) VALUES ($1, 'vk', -1)`, []any{uuid.New()}},
|
||||||
|
{"ledger bad kind", `INSERT INTO payments.ledger (ledger_id, account_id, kind) VALUES ($1, $2, 'bogus')`, []any{uuid.New(), uuid.New()}},
|
||||||
|
{"product_price amount negative", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'RUB', -1)`, []any{prod}},
|
||||||
|
{"product_price bad currency", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'EUR', 100)`, []any{prod}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if _, err := testDB.ExecContext(ctx, c.sql, c.args...); err == nil {
|
||||||
|
t.Errorf("%s: insert succeeded, want a CHECK violation", c.name)
|
||||||
|
} else if !isPgCode(err, "23514") {
|
||||||
|
t.Errorf("%s: got %v, want check_violation (23514)", c.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsLedgerIdempotencyIndex verifies the partial unique index makes a
|
||||||
|
// (provider, provider_payment_id) pair collide once but leaves NULL-keyed rows
|
||||||
|
// unconstrained.
|
||||||
|
func TestPaymentsLedgerIdempotencyIndex(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
insert := func(ppid *string) error {
|
||||||
|
_, err := testDB.ExecContext(ctx,
|
||||||
|
`INSERT INTO payments.ledger (ledger_id, account_id, kind, provider, provider_payment_id) VALUES ($1, $2, 'fund', 'robokassa', $3)`,
|
||||||
|
uuid.New(), uuid.New(), ppid)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ppid := "inv-" + uuid.NewString()
|
||||||
|
if err := insert(&ppid); err != nil {
|
||||||
|
t.Fatalf("first insert: %v", err)
|
||||||
|
}
|
||||||
|
if err := insert(&ppid); err == nil {
|
||||||
|
t.Error("duplicate (provider, provider_payment_id) inserted — idempotency index missing")
|
||||||
|
} else if !isPgCode(err, "23505") {
|
||||||
|
t.Errorf("duplicate insert: got %v, want unique_violation (23505)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A NULL provider_payment_id is outside the partial index — repeats allowed.
|
||||||
|
if err := insert(nil); err != nil {
|
||||||
|
t.Fatalf("first null-ppid insert: %v", err)
|
||||||
|
}
|
||||||
|
if err := insert(nil); err != nil {
|
||||||
|
t.Errorf("second null-ppid insert should be allowed by the partial index: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentsMigrationReversible proves the migration is expand-contract: it
|
||||||
|
// applies, rolls fully back (schema, role, trigger and all), and re-applies
|
||||||
|
// cleanly — so a backend image rollback stays DB-safe. It uses its own container
|
||||||
|
// so the Down does not disturb the shared suite database.
|
||||||
|
func TestPaymentsMigrationReversible(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
container, err := tcpostgres.Run(ctx, pgImage,
|
||||||
|
tcpostgres.WithDatabase(pgDatabase),
|
||||||
|
tcpostgres.WithUsername(pgUser),
|
||||||
|
tcpostgres.WithPassword(pgPassword),
|
||||||
|
testcontainers.WithWaitStrategy(
|
||||||
|
wait.ForLog("database system is ready to accept connections").
|
||||||
|
WithOccurrence(2).
|
||||||
|
WithStartupTimeout(containerStartup),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start container: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = container.Terminate(context.Background()) }()
|
||||||
|
|
||||||
|
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connection string: %v", err)
|
||||||
|
}
|
||||||
|
dsn, err := withSearchPath(baseDSN, pgSchema)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search path: %v", err)
|
||||||
|
}
|
||||||
|
cfg := postgres.DefaultConfig()
|
||||||
|
cfg.DSN = dsn
|
||||||
|
db, err := postgres.Open(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open pool: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
// Up: apply every migration, including the payments foundation.
|
||||||
|
if err := postgres.ApplyMigrations(ctx, db); err != nil {
|
||||||
|
t.Fatalf("apply up: %v", err)
|
||||||
|
}
|
||||||
|
if !schemaExists(ctx, t, db, "payments") {
|
||||||
|
t.Fatal("payments schema absent after up")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down the payments migration and assert a clean teardown.
|
||||||
|
goose.SetBaseFS(migrations.Migrations())
|
||||||
|
defer goose.SetBaseFS(nil)
|
||||||
|
if err := goose.SetDialect("postgres"); err != nil {
|
||||||
|
t.Fatalf("set dialect: %v", err)
|
||||||
|
}
|
||||||
|
if err := goose.DownToContext(ctx, db, ".", 9); err != nil {
|
||||||
|
t.Fatalf("down to 9: %v", err)
|
||||||
|
}
|
||||||
|
if schemaExists(ctx, t, db, "payments") {
|
||||||
|
t.Error("payments schema survived the down migration")
|
||||||
|
}
|
||||||
|
var roleExists bool
|
||||||
|
if err := db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil {
|
||||||
|
t.Fatalf("check role after down: %v", err)
|
||||||
|
}
|
||||||
|
if roleExists {
|
||||||
|
t.Error("payments role survived the down migration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply and assert it comes back cleanly.
|
||||||
|
if err := goose.UpContext(ctx, db, "."); err != nil {
|
||||||
|
t.Fatalf("re-apply up: %v", err)
|
||||||
|
}
|
||||||
|
if !schemaExists(ctx, t, db, "payments") {
|
||||||
|
t.Fatal("payments schema absent after re-apply")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaExists reports whether a schema of the given name is present.
|
||||||
|
func schemaExists(ctx context.Context, t *testing.T, db *sql.DB, name string) bool {
|
||||||
|
t.Helper()
|
||||||
|
var exists bool
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = $1)", name).Scan(&exists); err != nil {
|
||||||
|
t.Fatalf("check schema %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return exists
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"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/account"
|
||||||
|
"scrabble/backend/internal/postgres"
|
||||||
|
"scrabble/backend/internal/postgres/migrations"
|
||||||
|
"scrabble/backend/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSessionPlatformCaptureAndUntrusted proves a session round-trips its captured
|
||||||
|
// platform through create and a cold-cache (DB-backed) resolve for each kind, and
|
||||||
|
// that an unattributed session records no platform — the untrusted, view-only case.
|
||||||
|
func TestSessionPlatformCaptureAndUntrusted(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
accounts := account.NewStore(testDB)
|
||||||
|
store := session.NewStore(testDB)
|
||||||
|
svc := session.NewService(store, session.NewCache())
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
platform session.Platform
|
||||||
|
}{
|
||||||
|
{"vk-ios", session.Platform{Kind: session.PlatformKindVK, Subtype: session.SubtypeIOS}},
|
||||||
|
{"telegram-android", session.Platform{Kind: session.PlatformKindTelegram, Subtype: session.SubtypeAndroid}},
|
||||||
|
{"direct-web", session.Platform{Kind: session.PlatformKindDirect, Subtype: session.SubtypeWeb}},
|
||||||
|
{"untrusted", session.Platform{}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
acc, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
token, sess, err := svc.Create(ctx, acc.ID, tc.platform)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if sess.Platform != tc.platform {
|
||||||
|
t.Errorf("created platform = %+v, want %+v", sess.Platform, tc.platform)
|
||||||
|
}
|
||||||
|
// Resolve through a cold cache so the value comes back via the DB columns.
|
||||||
|
cold := session.NewService(store, session.NewCache())
|
||||||
|
if err := cold.Warm(ctx); err != nil {
|
||||||
|
t.Fatalf("warm: %v", err)
|
||||||
|
}
|
||||||
|
got, err := cold.Resolve(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if got.Platform != tc.platform {
|
||||||
|
t.Errorf("resolved platform = %+v, want %+v", got.Platform, tc.platform)
|
||||||
|
}
|
||||||
|
if got.Platform.Trusted() != tc.platform.Trusted() {
|
||||||
|
t.Errorf("resolved Trusted() = %v, want %v", got.Platform.Trusted(), tc.platform.Trusted())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionPlatformCheckConstraints proves the CHECK constraints reject an
|
||||||
|
// out-of-range kind or subtype, so a corrupt value cannot be persisted.
|
||||||
|
func TestSessionPlatformCheckConstraints(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := account.NewStore(testDB).ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
const pgCheckViolation = "23514"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
kind string
|
||||||
|
subtype string
|
||||||
|
}{
|
||||||
|
{"bad-kind", "facebook", "web"},
|
||||||
|
{"bad-subtype", "vk", "windows"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := testDB.ExecContext(ctx,
|
||||||
|
`INSERT INTO backend.sessions (session_id, account_id, token_hash, platform_kind, platform_subtype)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
uuid.New(), acc.ID, uuid.NewString(), tc.kind, tc.subtype)
|
||||||
|
if !isPgCode(err, pgCheckViolation) {
|
||||||
|
t.Errorf("insert %s: err = %v, want check_violation", tc.name, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionPlatformMigrationReversible proves migration 00011 is expand-contract:
|
||||||
|
// it applies, rolls back (dropping the platform columns), 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 TestSessionPlatformMigrationReversible(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() }()
|
||||||
|
|
||||||
|
if err := postgres.ApplyMigrations(ctx, db); err != nil {
|
||||||
|
t.Fatalf("apply up: %v", err)
|
||||||
|
}
|
||||||
|
if !sessionsPlatformColumnsExist(ctx, t, db) {
|
||||||
|
t.Fatal("platform columns absent after up")
|
||||||
|
}
|
||||||
|
|
||||||
|
goose.SetBaseFS(migrations.Migrations())
|
||||||
|
defer goose.SetBaseFS(nil)
|
||||||
|
if err := goose.SetDialect("postgres"); err != nil {
|
||||||
|
t.Fatalf("set dialect: %v", err)
|
||||||
|
}
|
||||||
|
// Down past 00011 (to 00010) and assert the columns are gone.
|
||||||
|
if err := goose.DownToContext(ctx, db, ".", 10); err != nil {
|
||||||
|
t.Fatalf("down to 10: %v", err)
|
||||||
|
}
|
||||||
|
if sessionsPlatformColumnsExist(ctx, t, db) {
|
||||||
|
t.Error("platform columns survived the down migration")
|
||||||
|
}
|
||||||
|
// Re-apply and assert they return.
|
||||||
|
if err := goose.UpContext(ctx, db, "."); err != nil {
|
||||||
|
t.Fatalf("re-apply up: %v", err)
|
||||||
|
}
|
||||||
|
if !sessionsPlatformColumnsExist(ctx, t, db) {
|
||||||
|
t.Fatal("platform columns absent after re-apply")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionsPlatformColumnsExist reports whether both platform columns are present on
|
||||||
|
// backend.sessions.
|
||||||
|
func sessionsPlatformColumnsExist(ctx context.Context, t *testing.T, db *sql.DB) bool {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'backend' AND table_name = 'sessions'
|
||||||
|
AND column_name IN ('platform_kind', 'platform_subtype')`).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count platform columns: %v", err)
|
||||||
|
}
|
||||||
|
return n == 2
|
||||||
|
}
|
||||||
@@ -26,7 +26,8 @@ func TestSessionLifecycle(t *testing.T) {
|
|||||||
store := session.NewStore(testDB)
|
store := session.NewStore(testDB)
|
||||||
svc := session.NewService(store, session.NewCache())
|
svc := session.NewService(store, session.NewCache())
|
||||||
|
|
||||||
token, sess, err := svc.Create(ctx, acc.ID)
|
platform := session.Platform{Kind: session.PlatformKindVK, Subtype: session.SubtypeIOS}
|
||||||
|
token, sess, err := svc.Create(ctx, acc.ID, platform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create session: %v", err)
|
t.Fatalf("create session: %v", err)
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,9 @@ func TestSessionLifecycle(t *testing.T) {
|
|||||||
if token == sess.TokenHash {
|
if token == sess.TokenHash {
|
||||||
t.Error("plaintext token must not equal the stored hash")
|
t.Error("plaintext token must not equal the stored hash")
|
||||||
}
|
}
|
||||||
|
if sess.Platform != platform {
|
||||||
|
t.Errorf("session platform = %+v, want %+v", sess.Platform, platform)
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve via the warm write-through cache.
|
// Resolve via the warm write-through cache.
|
||||||
got, err := svc.Resolve(ctx, token)
|
got, err := svc.Resolve(ctx, token)
|
||||||
@@ -63,8 +67,8 @@ func TestSessionLifecycle(t *testing.T) {
|
|||||||
if _, ok := cold.Get(session.HashToken(token)); !ok {
|
if _, ok := cold.Get(session.HashToken(token)); !ok {
|
||||||
t.Error("Warm must load the active session into the cache")
|
t.Error("Warm must load the active session into the cache")
|
||||||
}
|
}
|
||||||
if got2, err := svc2.Resolve(ctx, token); err != nil || got2.ID != sess.ID {
|
if got2, err := svc2.Resolve(ctx, token); err != nil || got2.ID != sess.ID || got2.Platform != platform {
|
||||||
t.Errorf("resolve after warm = (%s, %v), want %s", got2.ID, err, sess.ID)
|
t.Errorf("resolve after warm = (%s, %+v, %v), want %s / %+v", got2.ID, got2.Platform, err, sess.ID, platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revoke, then the token no longer resolves; revoke again is a no-op.
|
// Revoke, then the token no longer resolves; revoke again is a no-op.
|
||||||
|
|||||||
@@ -200,7 +200,11 @@ func (s *Service) merge(ctx context.Context, callerID, otherID uuid.UUID) (Merge
|
|||||||
}
|
}
|
||||||
res := MergeResult{PrimaryID: primary}
|
res := MergeResult{PrimaryID: primary}
|
||||||
if primary != callerID {
|
if primary != callerID {
|
||||||
token, _, err := s.sessions.Create(ctx, primary)
|
// The switched-to session inherits the caller's current trusted platform
|
||||||
|
// (the context the merge was initiated from); absent when the caller's
|
||||||
|
// session itself is untrusted.
|
||||||
|
platform, _ := session.PlatformFromContext(ctx)
|
||||||
|
token, _, err := s.sessions.Create(ctx, primary, platform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return MergeResult{}, err
|
return MergeResult{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -27,4 +27,6 @@ type FeedbackMessages struct {
|
|||||||
ReplyReadAt *time.Time
|
ReplyReadAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Lang *string
|
Lang *string
|
||||||
|
AppVersion *string
|
||||||
|
BrowserTz *string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 RobotBlocks struct {
|
||||||
|
ID uuid.UUID `sql:"primary_key"`
|
||||||
|
BlockerID uuid.UUID
|
||||||
|
GameID uuid.UUID
|
||||||
|
Seat int16
|
||||||
|
RobotID uuid.UUID
|
||||||
|
DisplayName string
|
||||||
|
CreatedAt 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 RobotFriendRequests struct {
|
||||||
|
ID uuid.UUID `sql:"primary_key"`
|
||||||
|
RequesterID uuid.UUID
|
||||||
|
GameID uuid.UUID
|
||||||
|
Seat int16
|
||||||
|
RobotID uuid.UUID
|
||||||
|
DisplayName string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
@@ -13,11 +13,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Sessions struct {
|
type Sessions struct {
|
||||||
SessionID uuid.UUID `sql:"primary_key"`
|
SessionID uuid.UUID `sql:"primary_key"`
|
||||||
AccountID uuid.UUID
|
AccountID uuid.UUID
|
||||||
TokenHash string
|
TokenHash string
|
||||||
Status string
|
Status string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
LastSeenAt *time.Time
|
LastSeenAt *time.Time
|
||||||
RevokedAt *time.Time
|
RevokedAt *time.Time
|
||||||
|
PlatformKind *string
|
||||||
|
PlatformSubtype *string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type feedbackMessagesTable struct {
|
|||||||
ReplyReadAt postgres.ColumnTimestampz
|
ReplyReadAt postgres.ColumnTimestampz
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
Lang postgres.ColumnString
|
Lang postgres.ColumnString
|
||||||
|
AppVersion postgres.ColumnString
|
||||||
|
BrowserTz postgres.ColumnString
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -86,8 +88,10 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
|
|||||||
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
|
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
LangColumn = postgres.StringColumn("lang")
|
LangColumn = postgres.StringColumn("lang")
|
||||||
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn}
|
AppVersionColumn = postgres.StringColumn("app_version")
|
||||||
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn}
|
BrowserTzColumn = postgres.StringColumn("browser_tz")
|
||||||
|
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn, AppVersionColumn, BrowserTzColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn, AppVersionColumn, BrowserTzColumn}
|
||||||
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,6 +113,8 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
|
|||||||
ReplyReadAt: ReplyReadAtColumn,
|
ReplyReadAt: ReplyReadAtColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
Lang: LangColumn,
|
Lang: LangColumn,
|
||||||
|
AppVersion: AppVersionColumn,
|
||||||
|
BrowserTz: BrowserTzColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -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 RobotBlocks = newRobotBlocksTable("backend", "robot_blocks", "")
|
||||||
|
|
||||||
|
type robotBlocksTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
ID postgres.ColumnString
|
||||||
|
BlockerID postgres.ColumnString
|
||||||
|
GameID postgres.ColumnString
|
||||||
|
Seat postgres.ColumnInteger
|
||||||
|
RobotID postgres.ColumnString
|
||||||
|
DisplayName postgres.ColumnString
|
||||||
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type RobotBlocksTable struct {
|
||||||
|
robotBlocksTable
|
||||||
|
|
||||||
|
EXCLUDED robotBlocksTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new RobotBlocksTable with assigned alias
|
||||||
|
func (a RobotBlocksTable) AS(alias string) *RobotBlocksTable {
|
||||||
|
return newRobotBlocksTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new RobotBlocksTable with assigned schema name
|
||||||
|
func (a RobotBlocksTable) FromSchema(schemaName string) *RobotBlocksTable {
|
||||||
|
return newRobotBlocksTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new RobotBlocksTable with assigned table prefix
|
||||||
|
func (a RobotBlocksTable) WithPrefix(prefix string) *RobotBlocksTable {
|
||||||
|
return newRobotBlocksTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new RobotBlocksTable with assigned table suffix
|
||||||
|
func (a RobotBlocksTable) WithSuffix(suffix string) *RobotBlocksTable {
|
||||||
|
return newRobotBlocksTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRobotBlocksTable(schemaName, tableName, alias string) *RobotBlocksTable {
|
||||||
|
return &RobotBlocksTable{
|
||||||
|
robotBlocksTable: newRobotBlocksTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newRobotBlocksTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRobotBlocksTableImpl(schemaName, tableName, alias string) robotBlocksTable {
|
||||||
|
var (
|
||||||
|
IDColumn = postgres.StringColumn("id")
|
||||||
|
BlockerIDColumn = postgres.StringColumn("blocker_id")
|
||||||
|
GameIDColumn = postgres.StringColumn("game_id")
|
||||||
|
SeatColumn = postgres.IntegerColumn("seat")
|
||||||
|
RobotIDColumn = postgres.StringColumn("robot_id")
|
||||||
|
DisplayNameColumn = postgres.StringColumn("display_name")
|
||||||
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
|
allColumns = postgres.ColumnList{IDColumn, BlockerIDColumn, GameIDColumn, SeatColumn, RobotIDColumn, DisplayNameColumn, CreatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{BlockerIDColumn, GameIDColumn, SeatColumn, RobotIDColumn, DisplayNameColumn, CreatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return robotBlocksTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
ID: IDColumn,
|
||||||
|
BlockerID: BlockerIDColumn,
|
||||||
|
GameID: GameIDColumn,
|
||||||
|
Seat: SeatColumn,
|
||||||
|
RobotID: RobotIDColumn,
|
||||||
|
DisplayName: DisplayNameColumn,
|
||||||
|
CreatedAt: CreatedAtColumn,
|
||||||
|
|
||||||
|
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 RobotFriendRequests = newRobotFriendRequestsTable("backend", "robot_friend_requests", "")
|
||||||
|
|
||||||
|
type robotFriendRequestsTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
ID postgres.ColumnString
|
||||||
|
RequesterID postgres.ColumnString
|
||||||
|
GameID postgres.ColumnString
|
||||||
|
Seat postgres.ColumnInteger
|
||||||
|
RobotID postgres.ColumnString
|
||||||
|
DisplayName postgres.ColumnString
|
||||||
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type RobotFriendRequestsTable struct {
|
||||||
|
robotFriendRequestsTable
|
||||||
|
|
||||||
|
EXCLUDED robotFriendRequestsTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new RobotFriendRequestsTable with assigned alias
|
||||||
|
func (a RobotFriendRequestsTable) AS(alias string) *RobotFriendRequestsTable {
|
||||||
|
return newRobotFriendRequestsTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new RobotFriendRequestsTable with assigned schema name
|
||||||
|
func (a RobotFriendRequestsTable) FromSchema(schemaName string) *RobotFriendRequestsTable {
|
||||||
|
return newRobotFriendRequestsTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new RobotFriendRequestsTable with assigned table prefix
|
||||||
|
func (a RobotFriendRequestsTable) WithPrefix(prefix string) *RobotFriendRequestsTable {
|
||||||
|
return newRobotFriendRequestsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new RobotFriendRequestsTable with assigned table suffix
|
||||||
|
func (a RobotFriendRequestsTable) WithSuffix(suffix string) *RobotFriendRequestsTable {
|
||||||
|
return newRobotFriendRequestsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRobotFriendRequestsTable(schemaName, tableName, alias string) *RobotFriendRequestsTable {
|
||||||
|
return &RobotFriendRequestsTable{
|
||||||
|
robotFriendRequestsTable: newRobotFriendRequestsTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newRobotFriendRequestsTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRobotFriendRequestsTableImpl(schemaName, tableName, alias string) robotFriendRequestsTable {
|
||||||
|
var (
|
||||||
|
IDColumn = postgres.StringColumn("id")
|
||||||
|
RequesterIDColumn = postgres.StringColumn("requester_id")
|
||||||
|
GameIDColumn = postgres.StringColumn("game_id")
|
||||||
|
SeatColumn = postgres.IntegerColumn("seat")
|
||||||
|
RobotIDColumn = postgres.StringColumn("robot_id")
|
||||||
|
DisplayNameColumn = postgres.StringColumn("display_name")
|
||||||
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
|
allColumns = postgres.ColumnList{IDColumn, RequesterIDColumn, GameIDColumn, SeatColumn, RobotIDColumn, DisplayNameColumn, CreatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{RequesterIDColumn, GameIDColumn, SeatColumn, RobotIDColumn, DisplayNameColumn, CreatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return robotFriendRequestsTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
ID: IDColumn,
|
||||||
|
RequesterID: RequesterIDColumn,
|
||||||
|
GameID: GameIDColumn,
|
||||||
|
Seat: SeatColumn,
|
||||||
|
RobotID: RobotIDColumn,
|
||||||
|
DisplayName: DisplayNameColumn,
|
||||||
|
CreatedAt: CreatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,13 +17,15 @@ type sessionsTable struct {
|
|||||||
postgres.Table
|
postgres.Table
|
||||||
|
|
||||||
// Columns
|
// Columns
|
||||||
SessionID postgres.ColumnString
|
SessionID postgres.ColumnString
|
||||||
AccountID postgres.ColumnString
|
AccountID postgres.ColumnString
|
||||||
TokenHash postgres.ColumnString
|
TokenHash postgres.ColumnString
|
||||||
Status postgres.ColumnString
|
Status postgres.ColumnString
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
LastSeenAt postgres.ColumnTimestampz
|
LastSeenAt postgres.ColumnTimestampz
|
||||||
RevokedAt postgres.ColumnTimestampz
|
RevokedAt postgres.ColumnTimestampz
|
||||||
|
PlatformKind postgres.ColumnString
|
||||||
|
PlatformSubtype postgres.ColumnString
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -65,29 +67,33 @@ func newSessionsTable(schemaName, tableName, alias string) *SessionsTable {
|
|||||||
|
|
||||||
func newSessionsTableImpl(schemaName, tableName, alias string) sessionsTable {
|
func newSessionsTableImpl(schemaName, tableName, alias string) sessionsTable {
|
||||||
var (
|
var (
|
||||||
SessionIDColumn = postgres.StringColumn("session_id")
|
SessionIDColumn = postgres.StringColumn("session_id")
|
||||||
AccountIDColumn = postgres.StringColumn("account_id")
|
AccountIDColumn = postgres.StringColumn("account_id")
|
||||||
TokenHashColumn = postgres.StringColumn("token_hash")
|
TokenHashColumn = postgres.StringColumn("token_hash")
|
||||||
StatusColumn = postgres.StringColumn("status")
|
StatusColumn = postgres.StringColumn("status")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at")
|
LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at")
|
||||||
RevokedAtColumn = postgres.TimestampzColumn("revoked_at")
|
RevokedAtColumn = postgres.TimestampzColumn("revoked_at")
|
||||||
allColumns = postgres.ColumnList{SessionIDColumn, AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn}
|
PlatformKindColumn = postgres.StringColumn("platform_kind")
|
||||||
mutableColumns = postgres.ColumnList{AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn}
|
PlatformSubtypeColumn = postgres.StringColumn("platform_subtype")
|
||||||
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
|
allColumns = postgres.ColumnList{SessionIDColumn, AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn, PlatformKindColumn, PlatformSubtypeColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn, PlatformKindColumn, PlatformSubtypeColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return sessionsTable{
|
return sessionsTable{
|
||||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
//Columns
|
//Columns
|
||||||
SessionID: SessionIDColumn,
|
SessionID: SessionIDColumn,
|
||||||
AccountID: AccountIDColumn,
|
AccountID: AccountIDColumn,
|
||||||
TokenHash: TokenHashColumn,
|
TokenHash: TokenHashColumn,
|
||||||
Status: StatusColumn,
|
Status: StatusColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
LastSeenAt: LastSeenAtColumn,
|
LastSeenAt: LastSeenAtColumn,
|
||||||
RevokedAt: RevokedAtColumn,
|
RevokedAt: RevokedAtColumn,
|
||||||
|
PlatformKind: PlatformKindColumn,
|
||||||
|
PlatformSubtype: PlatformSubtypeColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ package table
|
|||||||
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
// 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.
|
// this method only once at the beginning of the program.
|
||||||
func UseSchema(schema string) {
|
func UseSchema(schema string) {
|
||||||
|
AccountBestMove = AccountBestMove.FromSchema(schema)
|
||||||
AccountRoles = AccountRoles.FromSchema(schema)
|
AccountRoles = AccountRoles.FromSchema(schema)
|
||||||
AccountStats = AccountStats.FromSchema(schema)
|
AccountStats = AccountStats.FromSchema(schema)
|
||||||
AccountSuspensions = AccountSuspensions.FromSchema(schema)
|
AccountSuspensions = AccountSuspensions.FromSchema(schema)
|
||||||
@@ -35,6 +36,8 @@ func UseSchema(schema string) {
|
|||||||
Games = Games.FromSchema(schema)
|
Games = Games.FromSchema(schema)
|
||||||
Identities = Identities.FromSchema(schema)
|
Identities = Identities.FromSchema(schema)
|
||||||
RetainedIdentities = RetainedIdentities.FromSchema(schema)
|
RetainedIdentities = RetainedIdentities.FromSchema(schema)
|
||||||
|
RobotBlocks = RobotBlocks.FromSchema(schema)
|
||||||
|
RobotFriendRequests = RobotFriendRequests.FromSchema(schema)
|
||||||
Sessions = Sessions.FromSchema(schema)
|
Sessions = Sessions.FromSchema(schema)
|
||||||
SuspensionReasons = SuspensionReasons.FromSchema(schema)
|
SuspensionReasons = SuspensionReasons.FromSchema(schema)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Record the trusted execution platform on a session: platform_kind (the wrapper the
|
||||||
|
-- session was established through — vk/telegram/direct) plus platform_subtype (the device
|
||||||
|
-- family — ios/android/web). Both are nullable: a session minted before this migration, or
|
||||||
|
-- one the gateway could not attribute, carries NULL and is treated as untrusted (view-only)
|
||||||
|
-- by the payments compliance gate. kind is derived server-side from the validated establish
|
||||||
|
-- path (never a client field); the subtype is trustworthy only for VK (vk_platform rides
|
||||||
|
-- inside the signed launch params) and best-effort for telegram/direct.
|
||||||
|
--
|
||||||
|
-- Expand-contract: the columns are additive and nullable, so a backend image rollback stays
|
||||||
|
-- DB-safe (older code neither writes nor reads them). The table shape changes, so the
|
||||||
|
-- generated go-jet model IS regenerated.
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE backend.sessions ADD COLUMN platform_kind text;
|
||||||
|
ALTER TABLE backend.sessions ADD COLUMN platform_subtype text;
|
||||||
|
ALTER TABLE backend.sessions ADD CONSTRAINT sessions_platform_kind_chk CHECK ((platform_kind IS NULL) OR (platform_kind = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text])));
|
||||||
|
ALTER TABLE backend.sessions ADD CONSTRAINT sessions_platform_subtype_chk CHECK ((platform_subtype IS NULL) OR (platform_subtype = ANY (ARRAY['ios'::text, 'android'::text, 'web'::text])));
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE backend.sessions DROP CONSTRAINT sessions_platform_subtype_chk;
|
||||||
|
ALTER TABLE backend.sessions DROP CONSTRAINT sessions_platform_kind_chk;
|
||||||
|
ALTER TABLE backend.sessions DROP COLUMN platform_subtype;
|
||||||
|
ALTER TABLE backend.sessions DROP COLUMN platform_kind;
|
||||||
@@ -28,10 +28,14 @@ type okResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveResponse maps a session token to its account. IsGuest lets the gateway
|
// resolveResponse maps a session token to its account. IsGuest lets the gateway
|
||||||
// gate guest-forbidden operations without an extra round-trip.
|
// gate guest-forbidden operations without an extra round-trip. PlatformKind and
|
||||||
|
// PlatformSubtype carry the session's trusted execution platform so the gateway can
|
||||||
|
// inject X-Platform; both are empty for an untrusted (pre-capture) session.
|
||||||
type resolveResponse struct {
|
type resolveResponse struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
IsGuest bool `json:"is_guest"`
|
IsGuest bool `json:"is_guest"`
|
||||||
|
PlatformKind string `json:"platform_kind"`
|
||||||
|
PlatformSubtype string `json:"platform_subtype"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
|
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
|
"scrabble/backend/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
|
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
|
||||||
@@ -23,7 +24,9 @@ import (
|
|||||||
// brand-new account's display name and language; BrowserTZ (the client's detected
|
// brand-new account's display name and language; BrowserTZ (the client's detected
|
||||||
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
|
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
|
||||||
// deep-link payload, which may seed the new account's variant preferences (first
|
// deep-link payload, which may seed the new account's variant preferences (first
|
||||||
// contact only).
|
// contact only). Subtype is the client-reported device family (ios/android/web);
|
||||||
|
// Telegram's initData does not sign it, so it is recorded best-effort and the
|
||||||
|
// payments gate never relies on it.
|
||||||
type telegramAuthRequest struct {
|
type telegramAuthRequest struct {
|
||||||
ExternalID string `json:"external_id"`
|
ExternalID string `json:"external_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -31,6 +34,7 @@ type telegramAuthRequest struct {
|
|||||||
LanguageCode string `json:"language_code"`
|
LanguageCode string `json:"language_code"`
|
||||||
BrowserTZ string `json:"browser_tz"`
|
BrowserTZ string `json:"browser_tz"`
|
||||||
StartParam string `json:"start_param"`
|
StartParam string `json:"start_param"`
|
||||||
|
Subtype string `json:"subtype"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
||||||
@@ -63,19 +67,22 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.mintSession(c, acc)
|
s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindTelegram, Subtype: session.NormalizeSubtype(req.Subtype)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// vkAuthRequest carries the identity the gateway extracted from verified VK launch
|
// vkAuthRequest carries the identity the gateway extracted from verified VK launch
|
||||||
// params. LanguageCode (vk_language) and DisplayName (read client-side via
|
// params. LanguageCode (vk_language) and DisplayName (read client-side via
|
||||||
// VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new
|
// VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new
|
||||||
// account's language and display name; BrowserTZ (the client's detected "±HH:MM" UTC
|
// account's language and display name; BrowserTZ (the client's detected "±HH:MM" UTC
|
||||||
// offset) seeds its time zone. All seeds apply on first contact only.
|
// offset) seeds its time zone. All seeds apply on first contact only. Subtype is the
|
||||||
|
// device family the gateway derived from the signed vk_platform param — trusted,
|
||||||
|
// since it rides inside the verified launch signature.
|
||||||
type vkAuthRequest struct {
|
type vkAuthRequest struct {
|
||||||
ExternalID string `json:"external_id"`
|
ExternalID string `json:"external_id"`
|
||||||
LanguageCode string `json:"language_code"`
|
LanguageCode string `json:"language_code"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
BrowserTZ string `json:"browser_tz"`
|
BrowserTZ string `json:"browser_tz"`
|
||||||
|
Subtype string `json:"subtype"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleVKAuth provisions (or finds) the account bound to a VK identity and mints a
|
// handleVKAuth provisions (or finds) the account bound to a VK identity and mints a
|
||||||
@@ -94,7 +101,7 @@ func (s *Server) handleVKAuth(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.mintSession(c, acc)
|
s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindVK, Subtype: session.NormalizeSubtype(req.Subtype)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
|
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
|
||||||
@@ -150,6 +157,9 @@ func (s *Server) handlePushTarget(c *gin.Context) {
|
|||||||
// time zone, so robot timing is anchored to the player's zone from the first game.
|
// time zone, so robot timing is anchored to the player's zone from the first game.
|
||||||
type guestAuthRequest struct {
|
type guestAuthRequest struct {
|
||||||
BrowserTZ string `json:"browser_tz"`
|
BrowserTZ string `json:"browser_tz"`
|
||||||
|
// Subtype is the client-reported device family (ios/android/web) of this direct
|
||||||
|
// (web/native) session; there is no external signer, so it is recorded best-effort.
|
||||||
|
Subtype string `json:"subtype"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session,
|
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session,
|
||||||
@@ -164,7 +174,7 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.mintSession(c, acc)
|
s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.NormalizeSubtype(req.Subtype)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// emailRequest is an email-login code request. BrowserTZ (the client's detected
|
// emailRequest is an email-login code request. BrowserTZ (the client's detected
|
||||||
@@ -196,10 +206,12 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// emailLoginRequest verifies an email login code.
|
// emailLoginRequest verifies an email login code. Subtype is the client-reported
|
||||||
|
// device family (ios/android/web) of this direct session; recorded best-effort.
|
||||||
type emailLoginRequest struct {
|
type emailLoginRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
|
Subtype string `json:"subtype"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleEmailLogin verifies the code and mints a session for the owning account.
|
// handleEmailLogin verifies the code and mints a session for the owning account.
|
||||||
@@ -214,7 +226,7 @@ func (s *Server) handleEmailLogin(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.mintSession(c, acc)
|
s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.NormalizeSubtype(req.Subtype)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login,
|
// confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login,
|
||||||
@@ -248,7 +260,8 @@ func (s *Server) handleEmailConfirmLink(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
|
// A magic-link login always opens in a browser, so its session is direct/web.
|
||||||
|
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.SubtypeWeb})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
@@ -288,7 +301,11 @@ func (s *Server) handleResolveSession(c *gin.Context) {
|
|||||||
// is_guest is best-effort: a transient account read must not fail an otherwise
|
// is_guest is best-effort: a transient account read must not fail an otherwise
|
||||||
// valid resolve (the auth hot path), so a read error falls back to false; the
|
// valid resolve (the auth hot path), so a read error falls back to false; the
|
||||||
// per-operation backend gate remains the authoritative guest check.
|
// per-operation backend gate remains the authoritative guest check.
|
||||||
resp := resolveResponse{UserID: sess.AccountID.String()}
|
resp := resolveResponse{
|
||||||
|
UserID: sess.AccountID.String(),
|
||||||
|
PlatformKind: sess.Platform.Kind,
|
||||||
|
PlatformSubtype: sess.Platform.Subtype,
|
||||||
|
}
|
||||||
if acc, err := s.accounts.GetByID(c.Request.Context(), sess.AccountID); err == nil {
|
if acc, err := s.accounts.GetByID(c.Request.Context(), sess.AccountID); err == nil {
|
||||||
resp.IsGuest = acc.IsGuest
|
resp.IsGuest = acc.IsGuest
|
||||||
}
|
}
|
||||||
@@ -309,9 +326,10 @@ func (s *Server) handleRevokeSession(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// mintSession creates a session for acc and writes the credential response.
|
// mintSession creates a session for acc carrying the captured platform and writes
|
||||||
func (s *Server) mintSession(c *gin.Context, acc account.Account) {
|
// the credential response.
|
||||||
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
|
func (s *Server) mintSession(c *gin.Context, acc account.Account, platform session.Platform) {
|
||||||
|
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID, platform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// headerUserID is the identity header the gateway injects after resolving a
|
// headerUserID is the identity header the gateway injects after resolving a
|
||||||
@@ -41,6 +44,46 @@ func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
|
|||||||
return id, ok
|
return id, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// headerPlatform is the trusted execution-platform header the gateway injects after
|
||||||
|
// resolving a session's platform. Its value is "<kind>/<subtype>" (e.g. "vk/ios");
|
||||||
|
// it is omitted for an untrusted session, in which case platform(c) reports the
|
||||||
|
// zero Platform. Like X-User-ID, the value is the gateway's — never a client body.
|
||||||
|
const headerPlatform = "X-Platform"
|
||||||
|
|
||||||
|
// platformContext returns middleware that parses the gateway-injected X-Platform
|
||||||
|
// header into the request context, so handlers (and the link/merge session mint)
|
||||||
|
// read the caller's trusted platform. An absent or malformed header leaves the
|
||||||
|
// context without a platform (untrusted) and never rejects the request — X-Platform
|
||||||
|
// is a capability signal, not an identity gate.
|
||||||
|
func platformContext() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if p, ok := parsePlatformHeader(c.GetHeader(headerPlatform)); ok {
|
||||||
|
c.Request = c.Request.WithContext(session.WithPlatform(c.Request.Context(), p))
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePlatformHeader splits a "<kind>/<subtype>" X-Platform value into a Platform.
|
||||||
|
// A blank value or blank kind yields no platform (an untrusted session).
|
||||||
|
func parsePlatformHeader(h string) (session.Platform, bool) {
|
||||||
|
if h == "" {
|
||||||
|
return session.Platform{}, false
|
||||||
|
}
|
||||||
|
kind, subtype, _ := strings.Cut(h, "/")
|
||||||
|
if kind == "" {
|
||||||
|
return session.Platform{}, false
|
||||||
|
}
|
||||||
|
return session.Platform{Kind: kind, Subtype: subtype}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// platform returns the caller's trusted execution platform, or the zero Platform
|
||||||
|
// and false when the session was not attributed to one — an untrusted, view-only
|
||||||
|
// context for the payments gate.
|
||||||
|
func platform(c *gin.Context) (session.Platform, bool) {
|
||||||
|
return session.PlatformFromContext(c.Request.Context())
|
||||||
|
}
|
||||||
|
|
||||||
// requireSameOrigin guards the admin console's state-changing requests: it rejects
|
// requireSameOrigin guards the admin console's state-changing requests: it rejects
|
||||||
// a non-safe request whose Origin (or, failing that, Referer) host does not match
|
// a non-safe request whose Origin (or, failing that, Referer) host does not match
|
||||||
// the request Host. The gateway authenticates the operator with Basic-Auth in front
|
// the request Host. The gateway authenticates the operator with Basic-Auth in front
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestRequireUserID checks that the middleware accepts a valid X-User-ID,
|
// TestRequireUserID checks that the middleware accepts a valid X-User-ID,
|
||||||
@@ -58,3 +60,51 @@ func TestRequireUserID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPlatformContext checks that the middleware parses the gateway-injected
|
||||||
|
// X-Platform header into a trusted platform reachable via platform(c), treats an
|
||||||
|
// absent or blank-kind header as untrusted, and never rejects the request.
|
||||||
|
func TestPlatformContext(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
var seen session.Platform
|
||||||
|
var ok bool
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(platformContext())
|
||||||
|
r.GET("/x", func(c *gin.Context) {
|
||||||
|
seen, ok = platform(c)
|
||||||
|
c.String(http.StatusOK, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
setHeader bool
|
||||||
|
wantOK bool
|
||||||
|
want session.Platform
|
||||||
|
}{
|
||||||
|
{"vk-ios", "vk/ios", true, true, session.Platform{Kind: session.PlatformKindVK, Subtype: session.SubtypeIOS}},
|
||||||
|
{"telegram-no-subtype", "telegram", true, true, session.Platform{Kind: session.PlatformKindTelegram}},
|
||||||
|
{"direct-web", "direct/web", true, true, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.SubtypeWeb}},
|
||||||
|
{"absent", "", false, false, session.Platform{}},
|
||||||
|
{"empty", "", true, false, session.Platform{}},
|
||||||
|
{"blank-kind", "/ios", true, false, session.Platform{}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
seen, ok = session.Platform{}, false
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
if tc.setHeader {
|
||||||
|
req.Header.Set("X-Platform", tc.header)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (X-Platform never rejects)", rec.Code)
|
||||||
|
}
|
||||||
|
if ok != tc.wantOK || seen != tc.want {
|
||||||
|
t.Fatalf("platform = %+v (ok=%v), want %+v (ok=%v)", seen, ok, tc.want, tc.wantOK)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
"scrabble/backend/internal/render"
|
"scrabble/backend/internal/render"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
@@ -93,6 +94,11 @@ type Deps struct {
|
|||||||
// profile.get banner block, plus the banner admin console section. A nil Ads
|
// profile.get banner block, plus the banner admin console section. A nil Ads
|
||||||
// omits the banner block and disables the banner console.
|
// omits the banner block and disables the banner console.
|
||||||
Ads *ads.Service
|
Ads *ads.Service
|
||||||
|
// Payments is the in-game currency domain service (wallet, benefits, catalog).
|
||||||
|
// The data-foundation layer exposes only a reachability check; its user and
|
||||||
|
// console routes are registered when the wallet surface lands. A nil Payments
|
||||||
|
// omits them.
|
||||||
|
Payments *payments.Service
|
||||||
// Notifier publishes live-event intents — here the banner-eligibility re-poll
|
// Notifier publishes live-event intents — here the banner-eligibility re-poll
|
||||||
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
||||||
// them (notify.Nop).
|
// them (notify.Nop).
|
||||||
@@ -129,6 +135,7 @@ type Server struct {
|
|||||||
ratewatch *ratewatch.Watch
|
ratewatch *ratewatch.Watch
|
||||||
banview *banview.View
|
banview *banview.View
|
||||||
ads *ads.Service
|
ads *ads.Service
|
||||||
|
payments *payments.Service
|
||||||
notifier notify.Publisher
|
notifier notify.Publisher
|
||||||
console *adminconsole.Renderer
|
console *adminconsole.Renderer
|
||||||
exportKey []byte
|
exportKey []byte
|
||||||
@@ -181,6 +188,7 @@ func New(addr string, deps Deps) *Server {
|
|||||||
ratewatch: deps.RateWatch,
|
ratewatch: deps.RateWatch,
|
||||||
banview: deps.BanView,
|
banview: deps.BanView,
|
||||||
ads: deps.Ads,
|
ads: deps.Ads,
|
||||||
|
payments: deps.Payments,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
renderer: deps.Renderer,
|
renderer: deps.Renderer,
|
||||||
http: &http.Server{Addr: addr, Handler: engine},
|
http: &http.Server{Addr: addr, Handler: engine},
|
||||||
@@ -230,6 +238,10 @@ func (s *Server) registerAPIGroups(engine *gin.Engine) {
|
|||||||
s.public = v1.Group("/public")
|
s.public = v1.Group("/public")
|
||||||
s.user = v1.Group("/user")
|
s.user = v1.Group("/user")
|
||||||
s.user.Use(RequireUserID())
|
s.user.Use(RequireUserID())
|
||||||
|
// Capture the gateway-injected trusted platform (X-Platform) into the request context,
|
||||||
|
// so the payments gate can read it via platform(c). Optional: an untrusted session simply
|
||||||
|
// carries no platform and is treated as view-only. Never rejects.
|
||||||
|
s.user.Use(platformContext())
|
||||||
// The suspension gate runs after identity is established: a blocked account is refused on
|
// The suspension gate runs after identity is established: a blocked account is refused on
|
||||||
// every user route (except the block-status probe) so the UI can show the blocked screen.
|
// every user route (except the block-status probe) so the UI can show the blocked screen.
|
||||||
s.user.Use(s.requireNotSuspended())
|
s.user.Use(s.requireNotSuspended())
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Platform is the trusted execution context recorded on a session: the wrapper
|
||||||
|
// Kind the session was established through and the device Subtype. An empty Kind
|
||||||
|
// marks an untrusted session — one minted before platform capture, or one the
|
||||||
|
// gateway could not attribute — which the payments compliance gate treats as
|
||||||
|
// view-only.
|
||||||
|
//
|
||||||
|
// Kind is always trustworthy: it is derived server-side from the validated
|
||||||
|
// establish path, never from a client-supplied field. Subtype is trustworthy only
|
||||||
|
// for VK (vk_platform rides inside the signed launch params); for telegram and
|
||||||
|
// direct it is client-reported and best-effort, so the gate must never rely on it.
|
||||||
|
type Platform struct {
|
||||||
|
Kind string
|
||||||
|
Subtype string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform kinds recorded in platform_kind.
|
||||||
|
const (
|
||||||
|
PlatformKindVK = "vk"
|
||||||
|
PlatformKindTelegram = "telegram"
|
||||||
|
PlatformKindDirect = "direct"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Platform subtypes recorded in platform_subtype.
|
||||||
|
const (
|
||||||
|
SubtypeIOS = "ios"
|
||||||
|
SubtypeAndroid = "android"
|
||||||
|
SubtypeWeb = "web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trusted reports whether the session was attributed to a platform (a non-empty
|
||||||
|
// Kind). The zero Platform is untrusted.
|
||||||
|
func (p Platform) Trusted() bool { return p.Kind != "" }
|
||||||
|
|
||||||
|
// NormalizeSubtype coerces subtype to one of the known device families, defaulting
|
||||||
|
// an empty or unrecognised value to web. A newly established session always carries
|
||||||
|
// at least a web subtype, so a trusted session is never left without one.
|
||||||
|
func NormalizeSubtype(subtype string) string {
|
||||||
|
switch subtype {
|
||||||
|
case SubtypeIOS, SubtypeAndroid, SubtypeWeb:
|
||||||
|
return subtype
|
||||||
|
default:
|
||||||
|
return SubtypeWeb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformCtxKey types the request-context slot the trusted platform rides in.
|
||||||
|
type platformCtxKey struct{}
|
||||||
|
|
||||||
|
// WithPlatform returns a copy of ctx carrying platform. The backend middleware
|
||||||
|
// calls it after parsing the gateway-injected X-Platform header, so downstream
|
||||||
|
// handlers and the link/merge session mint read the caller's platform from ctx.
|
||||||
|
func WithPlatform(ctx context.Context, platform Platform) context.Context {
|
||||||
|
return context.WithValue(ctx, platformCtxKey{}, platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlatformFromContext returns the platform stored by WithPlatform and whether one
|
||||||
|
// was present. A missing value yields the zero (untrusted) Platform.
|
||||||
|
func PlatformFromContext(ctx context.Context) (Platform, bool) {
|
||||||
|
p, ok := ctx.Value(platformCtxKey{}).(Platform)
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
@@ -29,14 +29,16 @@ func (svc *Service) Ready() bool {
|
|||||||
return svc.cache.Ready()
|
return svc.cache.Ready()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create mints a new active session for accountID and returns the plaintext
|
// Create mints a new active session for accountID carrying the captured platform
|
||||||
// token (shown to the caller once) together with the persisted session.
|
// and returns the plaintext token (shown to the caller once) together with the
|
||||||
func (svc *Service) Create(ctx context.Context, accountID uuid.UUID) (string, Session, error) {
|
// persisted session. Pass the zero Platform for a session that could not be
|
||||||
|
// attributed to a trusted platform.
|
||||||
|
func (svc *Service) Create(ctx context.Context, accountID uuid.UUID, platform Platform) (string, Session, error) {
|
||||||
token, tokenHash, err := GenerateToken()
|
token, tokenHash, err := GenerateToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", Session{}, err
|
return "", Session{}, err
|
||||||
}
|
}
|
||||||
sess, err := svc.store.Insert(ctx, accountID, tokenHash)
|
sess, err := svc.store.Insert(ctx, accountID, tokenHash, platform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", Session{}, err
|
return "", Session{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ const (
|
|||||||
var ErrNotFound = errors.New("session: not found")
|
var ErrNotFound = errors.New("session: not found")
|
||||||
|
|
||||||
// Session mirrors a row in backend.sessions. TokenHash is the hex-encoded
|
// Session mirrors a row in backend.sessions. TokenHash is the hex-encoded
|
||||||
// SHA-256 of the bearer token.
|
// SHA-256 of the bearer token. Platform is the trusted execution context captured
|
||||||
|
// at creation; its zero value marks an untrusted session (see Platform).
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
AccountID uuid.UUID
|
AccountID uuid.UUID
|
||||||
@@ -34,6 +35,7 @@ type Session struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
LastSeenAt *time.Time
|
LastSeenAt *time.Time
|
||||||
RevokedAt *time.Time
|
RevokedAt *time.Time
|
||||||
|
Platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store is the Postgres-backed query surface for backend.sessions.
|
// Store is the Postgres-backed query surface for backend.sessions.
|
||||||
@@ -46,9 +48,10 @@ func NewStore(db *sql.DB) *Store {
|
|||||||
return &Store{db: db}
|
return &Store{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert persists a new active session for accountID carrying tokenHash and
|
// Insert persists a new active session for accountID carrying tokenHash and the
|
||||||
// returns the persisted row.
|
// captured platform, and returns the persisted row. An empty platform Kind/Subtype
|
||||||
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash string) (Session, error) {
|
// is written as SQL NULL, so an untrusted session records no platform.
|
||||||
|
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash string, platform Platform) (Session, error) {
|
||||||
id, err := uuid.NewV7()
|
id, err := uuid.NewV7()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Session{}, fmt.Errorf("session: new id: %w", err)
|
return Session{}, fmt.Errorf("session: new id: %w", err)
|
||||||
@@ -57,7 +60,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash strin
|
|||||||
table.Sessions.SessionID,
|
table.Sessions.SessionID,
|
||||||
table.Sessions.AccountID,
|
table.Sessions.AccountID,
|
||||||
table.Sessions.TokenHash,
|
table.Sessions.TokenHash,
|
||||||
).VALUES(id, accountID, tokenHash).RETURNING(table.Sessions.AllColumns)
|
table.Sessions.PlatformKind,
|
||||||
|
table.Sessions.PlatformSubtype,
|
||||||
|
).VALUES(id, accountID, tokenHash, stringOrNull(platform.Kind), stringOrNull(platform.Subtype)).RETURNING(table.Sessions.AllColumns)
|
||||||
|
|
||||||
var row model.Sessions
|
var row model.Sessions
|
||||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
@@ -173,5 +178,20 @@ func modelToSession(row model.Sessions) Session {
|
|||||||
t := *row.RevokedAt
|
t := *row.RevokedAt
|
||||||
s.RevokedAt = &t
|
s.RevokedAt = &t
|
||||||
}
|
}
|
||||||
|
if row.PlatformKind != nil {
|
||||||
|
s.Platform.Kind = *row.PlatformKind
|
||||||
|
}
|
||||||
|
if row.PlatformSubtype != nil {
|
||||||
|
s.Platform.Subtype = *row.PlatformSubtype
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stringOrNull maps an empty string to a SQL NULL literal and any other value to a
|
||||||
|
// string literal, so an untrusted session's absent platform is stored as NULL.
|
||||||
|
func stringOrNull(s string) postgres.Expression {
|
||||||
|
if s == "" {
|
||||||
|
return postgres.NULL
|
||||||
|
}
|
||||||
|
return postgres.String(s)
|
||||||
|
}
|
||||||
|
|||||||
+18
-3
@@ -130,8 +130,8 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
solver version, so it cannot drift from the running backend. The **move journal, history
|
solver version, so it cannot drift from the running backend. The **move journal, history
|
||||||
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
||||||
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
||||||
`X-User-ID` for authenticated requests; `backend` never re-derives identity
|
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
||||||
from the body. Because every sync call targets the one backend host, the
|
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||||
gateway's REST client widens its keep-alive pool well past the stdlib default
|
gateway's REST client widens its keep-alive pool well past the stdlib default
|
||||||
of 2 idle connections per host; otherwise the per-request connection churn
|
of 2 idle connections per host; otherwise the per-request connection churn
|
||||||
exhausts ephemeral ports and burns gateway CPU under load (see
|
exhausts ephemeral ports and burns gateway CPU under load (see
|
||||||
@@ -199,6 +199,21 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
until explicitly revoked (`status` → `revoked`). A revoke can target one token or,
|
until explicitly revoked (`status` → `revoked`). A revoke can target one token or,
|
||||||
on an account merge (§4), **every** session of the retired account
|
on an account merge (§4), **every** session of the retired account
|
||||||
(`RevokeAllForAccount`, which also evicts them from the warm cache).
|
(`RevokeAllForAccount`, which also evicts them from the warm cache).
|
||||||
|
- **Trusted execution platform.** Each session also records a `platform` — a `kind`
|
||||||
|
(`vk`/`telegram`/`direct`) plus a device `subtype` (`ios`/`android`/`web`) — captured
|
||||||
|
at creation, so a store-compliance gate has an unforgeable execution context
|
||||||
|
(`docs/PAYMENTS.md` §8). `kind` is derived server-side from the validated establish
|
||||||
|
path (the VK launch, the Telegram initData, or a web-native session), **never a client
|
||||||
|
field**; the `subtype` is trusted only for VK (it rides inside the signed `vk_platform`
|
||||||
|
parameter) and is client-reported best-effort for telegram/direct. VK and Telegram
|
||||||
|
re-mint (and so re-validate and re-capture) on every cold start; a direct session
|
||||||
|
captures it once. The gateway injects the resolved platform as **`X-Platform`**
|
||||||
|
(`<kind>/<subtype>`) alongside `X-User-ID`, sourced from the session and never the
|
||||||
|
request body; an absent or unrecorded platform — a session predating the feature, or
|
||||||
|
one the gateway could not attribute — is **untrusted**, treated as view-only (no
|
||||||
|
spends). Consistent with the revoke-only model, platform freshness carries no separate
|
||||||
|
TTL; a stale untrusted session recovers on its next VK/TG cold-start re-mint, or (for a
|
||||||
|
reused direct/email session) on re-login.
|
||||||
- **Guest** = ephemeral web session (no platform, no email). A guest is backed by
|
- **Guest** = ephemeral web session (no platform, no email). A guest is backed by
|
||||||
a durable `accounts` row flagged `is_guest` and carrying **no identity** — the
|
a durable `accounts` row flagged `is_guest` and carrying **no identity** — the
|
||||||
row is a technical necessity (the `sessions` and `game_players` foreign keys
|
row is a technical necessity (the `sessions` and `game_players` foreign keys
|
||||||
@@ -1150,7 +1165,7 @@ link — misses the event; while an add-email confirmation is pending the client
|
|||||||
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
|
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
|
||||||
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
|
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
|
||||||
| Session minting; email-code / guest validation | gateway (with backend) |
|
| Session minting; email-code / guest validation | gateway (with backend) |
|
||||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
| Session → `user_id` + trusted platform resolution, `X-User-ID` / `X-Platform` injection | gateway (platform sourced from the session, never the request body — §3) |
|
||||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||||
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) |
|
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) |
|
||||||
| User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) |
|
| User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) |
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
# PAYMENTS — monetization mechanics
|
||||||
|
|
||||||
|
Authoritative specification of the monetization domain: the in-game currency, wallets,
|
||||||
|
benefits, store-compliance rules, payment intake, ads, catalog, ledger, and reporting.
|
||||||
|
English is authoritative; [`PAYMENTS_ru.md`](PAYMENTS_ru.md) mirrors it in plain Russian
|
||||||
|
(mirror every point edit in the same PR, like `FUNCTIONAL.md`/`FUNCTIONAL_ru.md`).
|
||||||
|
|
||||||
|
Read this before changing any payments behaviour. The technical implementation plan lives
|
||||||
|
in [`../PLAN.md`](../PLAN.md).
|
||||||
|
|
||||||
|
> Status: specification approved; implementation staged (see `PLAN.md`). Nothing here is
|
||||||
|
> live yet.
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The game earns through two orthogonal channels:
|
||||||
|
|
||||||
|
- **Purchases** of the in-game currency **Фишка** (chips) with real money, then spending
|
||||||
|
chips on **values** (benefits).
|
||||||
|
- **Ads** — a rewarded video tops chips up; an interstitial and a house banner monetize by
|
||||||
|
impression.
|
||||||
|
|
||||||
|
The currency is **two-tier**:
|
||||||
|
|
||||||
|
```
|
||||||
|
money (VK Votes / TG Stars / RUB via Robokassa) ─┐
|
||||||
|
├─► Фишки (chips) ──► values
|
||||||
|
rewarded ad view ─┘ (no-ads, hints,
|
||||||
|
tournament fee)
|
||||||
|
```
|
||||||
|
|
||||||
|
Chips are the single storefront unit. Money and rewarded views **fund** chips; chips
|
||||||
|
**buy** values. There is no path that spends money directly on a value — always through
|
||||||
|
chips.
|
||||||
|
|
||||||
|
## 2. Currency model
|
||||||
|
|
||||||
|
**One chip is one chip everywhere** — the unit is uniform. The difference between payment
|
||||||
|
methods lives entirely in the **purchase rate**: a chip pack costs *X* Votes / *Y* Stars /
|
||||||
|
*Z* RUB, and the rate absorbs each store's commission. Value prices are fixed **in chips**
|
||||||
|
and identical across methods.
|
||||||
|
|
||||||
|
The chip balance is **segmented by `source`** — the platform where the chips were funded:
|
||||||
|
|
||||||
|
| `source` | Funded by |
|
||||||
|
|------------|----------------------------------|
|
||||||
|
| `vk` | VK Votes purchase, VK rewarded ad |
|
||||||
|
| `telegram` | TG Stars purchase |
|
||||||
|
| `direct` | Robokassa purchase (web / native)|
|
||||||
|
|
||||||
|
A single account holds all three segments simultaneously: `balance = (account_id, source)`,
|
||||||
|
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
|
||||||
|
the VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside
|
||||||
|
the stores. See §4.
|
||||||
|
|
||||||
|
## 3. Three operations, kept distinct
|
||||||
|
|
||||||
|
Do not conflate these — they use different keys:
|
||||||
|
|
||||||
|
1. **Fund chips** — money/ad → chip `source` segment, keyed by the **execution context**.
|
||||||
|
2. **Spend chips = buy a value** — segment → benefit, keyed by context with the gate (§4).
|
||||||
|
This is where a benefit is **born** and stamped with its `origin`.
|
||||||
|
3. **Apply a benefit** over time (e.g. "no-ads until *T*") — governed by the origin rule
|
||||||
|
(§5).
|
||||||
|
|
||||||
|
`source` (where chips were funded) and `origin` (where a value was bought) share the same
|
||||||
|
value set `{vk, telegram, direct}` but mean different things. On the web they can diverge:
|
||||||
|
web spending may draw `vk` chips (`source=vk`) into a `direct` purchase (`origin=direct`).
|
||||||
|
|
||||||
|
## 4. Store-compliance gate
|
||||||
|
|
||||||
|
The compliance wall is **one-directional**. The dangerous direction — activating externally
|
||||||
|
paid value *inside* a store wrapper — is blocked; the safe direction (a store benefit
|
||||||
|
leaking out to the open web) is allowed.
|
||||||
|
|
||||||
|
**Spend context → which segments/benefits are usable:**
|
||||||
|
|
||||||
|
| Execution context | Spendable chip segments | Spend priority |
|
||||||
|
|---------------------|-------------------------|--------------------|
|
||||||
|
| Inside VK (Android) | `vk` | — |
|
||||||
|
| Inside VK (iOS) | none — frozen (view only)| — |
|
||||||
|
| Inside Telegram | `telegram` | — |
|
||||||
|
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
|
||||||
|
|
||||||
|
- Inside VK/TG only the same-named segment is usable; everything else (chiefly `direct`) is
|
||||||
|
invisible-as-spendable there.
|
||||||
|
- On the web the store has no jurisdiction, so all attached segments are spendable, drained
|
||||||
|
by priority direct → vk → tg.
|
||||||
|
- **VK iOS** is frozen for spending (Apple forbids spending virtual currency on digital
|
||||||
|
goods outside IAP inside VK on iOS): the balance is shown as a number, but no purchase or
|
||||||
|
spend is possible. A previously bought benefit still *applies* there.
|
||||||
|
|
||||||
|
The account is **single** (identities merge, one profile/friends/stats). The gate is
|
||||||
|
**logical**: in a VK/TG context the server activates only the same-named segment. It rests
|
||||||
|
on a **trusted platform signal** (§8) — the client is never believed. When the platform
|
||||||
|
cannot be trusted, the gate is **fail-closed**: spends/purchases are denied, view only.
|
||||||
|
|
||||||
|
### One-directional benefit application
|
||||||
|
|
||||||
|
A benefit carries `origin` = the **purchase context** (not "what it was paid with"). Buying
|
||||||
|
on the web with `vk` chips still yields `origin=direct`.
|
||||||
|
|
||||||
|
| `origin` | Where the benefit applies |
|
||||||
|
|------------------|----------------------------------------------------|
|
||||||
|
| `vk` / `telegram`| **Everywhere** — inside its store *and* out on web/native |
|
||||||
|
| `direct` | **Only** web/native — never inside VK/TG (= store ban) |
|
||||||
|
|
||||||
|
Before a web spend draws `vk`/`telegram` chips, the UI **warns** the user that the value
|
||||||
|
will be available here (web/native) only, because of VK/TG restrictions.
|
||||||
|
|
||||||
|
## 5. Benefits
|
||||||
|
|
||||||
|
Three distinct entities, do not merge:
|
||||||
|
|
||||||
|
- **Chips** — currency. Segment by `source`.
|
||||||
|
- **Hints** — consumable, bought with chips. Segment by `origin`. Spent one-per-hint in
|
||||||
|
online games; `vs_ai` hints stay free/unlimited (30-min idle gate, not counted). A hint
|
||||||
|
spent in a game is drawn from an `origin` applicable to the current context (same
|
||||||
|
one-directional relaxation as above).
|
||||||
|
- **No-ads** — a duration benefit, bought with chips. Segment by `origin`.
|
||||||
|
|
||||||
|
**No-ads stacking.** Buying a term extends `paid_until[origin] += term` from
|
||||||
|
`max(now, current end)` — the remainder is never lost ("terms add up"). **Forever** is a
|
||||||
|
separate perpetual flag that overrides terms. "Ads off in context *P*" ⇔ some `origin`
|
||||||
|
applicable in *P* has `paid_until > now` (on the web take the max over direct/vk/tg; in VK
|
||||||
|
only vk; in TG only tg).
|
||||||
|
|
||||||
|
**What no-ads suppresses:** the top banner **and** the post-move fullscreen interstitial.
|
||||||
|
The voluntary **rewarded** view (for chips) is never suppressed — it is the user's choice.
|
||||||
|
|
||||||
|
**Tournament fee** — a future value type; the atom is provisioned but the mechanic ships
|
||||||
|
later.
|
||||||
|
|
||||||
|
## 6. Wallet lifecycle
|
||||||
|
|
||||||
|
**Segment availability rule.** A segment is spendable ⇔ the account has an identity of that
|
||||||
|
`source` (for `direct`: a durable identity/email). This makes unlink/merge fall out
|
||||||
|
naturally.
|
||||||
|
|
||||||
|
**Unlink (vk/tg).** Allowed even with a non-zero balance/active benefit. The segment is not
|
||||||
|
burned — it **sleeps** (no identity ⇒ unavailable in VK *and*, lacking the attachment,
|
||||||
|
unavailable as an attached web segment); re-linking wakes it. The user is warned before
|
||||||
|
unlinking ("N chips will be unavailable until you re-link"). The last identity cannot be
|
||||||
|
unlinked (existing `ErrLastIdentity`).
|
||||||
|
|
||||||
|
**Merge.** Segments and benefits merge **by origin**: same-origin segments add (chips sum,
|
||||||
|
benefit terms extend per origin), different origins coexist. This extends the current
|
||||||
|
account-merge (`hint_balance +=`, `paid_account OR=`) so origin is preserved and nothing
|
||||||
|
leaks across platforms.
|
||||||
|
|
||||||
|
**Guest.** A guest account has **no wallet at all** — the Wallet section is hidden, no
|
||||||
|
purchases, no balance, by design. Balances belong only to durable accounts. The guest
|
||||||
|
reaper therefore deletes guests freely (no money can exist there). In `direct`, email is
|
||||||
|
required **before the first purchase** as a recovery anchor (in VK/TG the vk/tg identity is
|
||||||
|
already the anchor); the existing email flow (request code → confirm → clear guest →
|
||||||
|
durable) is reused.
|
||||||
|
|
||||||
|
## 7. Catalog and pricing
|
||||||
|
|
||||||
|
The catalog is **configurable** — products, prices, purchase rates, and rewarded payout
|
||||||
|
live in the database and are edited in the admin console, no release required.
|
||||||
|
|
||||||
|
- **Base values (atoms):** chips, hints, no-ads days, tournament entry.
|
||||||
|
- **Product = a set of atoms + a price.** Sold singly or as a combo (e.g. "250 hints + 30
|
||||||
|
no-ads days").
|
||||||
|
- **Chip pack** (funds chips) — priced **per method** (multi-currency Votes/Stars/RUB) as a
|
||||||
|
single product.
|
||||||
|
- **Value** (bought with chips) — priced **in chips** (uniform across methods).
|
||||||
|
|
||||||
|
**Deactivation, not deletion.** Products are soft-deleted (deactivated). A completed
|
||||||
|
purchase stores a **snapshot** of what was sold (atom composition + price at the time) into
|
||||||
|
an archive, so history/receipts/tax are independent of later catalog edits.
|
||||||
|
|
||||||
|
## 8. Trusted platform signal
|
||||||
|
|
||||||
|
The gate (§4) needs a **trusted, unforgeable** platform context on the server. The client is
|
||||||
|
never the source of truth.
|
||||||
|
|
||||||
|
- The platform is a **property of the session**, captured when the session is created. VK and
|
||||||
|
Telegram wrappers re-mint (and so re-validate the launch signature — VK launch-params `sign`
|
||||||
|
/ Telegram `initData`) on **every cold start**, so their platform is re-confirmed each launch;
|
||||||
|
a `direct` session captures it once, established by the fact of a web/native session's creation
|
||||||
|
(no external signer, and none needed — reaching vk/tg segments in a direct context still
|
||||||
|
requires a real attachment, §6).
|
||||||
|
- The platform carries **kind** (`vk`/`telegram`/`direct`) **plus subtype**
|
||||||
|
(`ios`/`android`/`web`). `kind` is always trusted — the server derives it from the validated
|
||||||
|
launch, never a client field. The **subtype is trusted only for VK**: it rides inside the
|
||||||
|
signed launch parameters, which is what makes the **VK iOS freeze** enforceable; for Telegram
|
||||||
|
and direct the subtype is client-reported and best-effort, so the gate never keys off it.
|
||||||
|
- The gateway resolves the session and passes `platform` to the backend (alongside the existing
|
||||||
|
`X-User-ID`), sourced from the session — never the client request body.
|
||||||
|
- **Fail-closed:** an untrusted platform — a session with no recorded platform, one predating
|
||||||
|
this feature or one the gateway could not attribute — denies spends/purchases and applying any
|
||||||
|
foreign origin (view only). A VK/TG session recovers on its next cold-start re-mint; a reused
|
||||||
|
direct/email session on re-login.
|
||||||
|
|
||||||
|
## 9. Payment intake
|
||||||
|
|
||||||
|
**Server callback only.** Chips are credited solely on a **verified** (signature/HMAC)
|
||||||
|
provider callback — Robokassa webhook / TG `successful_payment` / VK callback. A client "I
|
||||||
|
paid" is ignored.
|
||||||
|
|
||||||
|
**Single writer.** One payments domain is the only writer of the ledger. Public webhooks
|
||||||
|
(Robokassa/VK) terminate at the edge (Caddy/gateway) and proxy into payments; TG
|
||||||
|
`successful_payment` reaches the bot, which forwards into payments. One place credits and
|
||||||
|
dedupes.
|
||||||
|
|
||||||
|
**Order-flow.** The server pre-creates an `order(pending)` with account / platform / pack /
|
||||||
|
expected amount / origin. The `order_id` is threaded to the provider (Robokassa `InvId` / TG
|
||||||
|
`invoice_payload` / VK `item` — confirm the exact VK field at integration). The callback
|
||||||
|
matches by `order_id` (never by amount, so equal-amount collisions cannot happen), verifies
|
||||||
|
the amount, credits, marks `paid`. **Idempotency:** dedup by `(provider, provider_payment_id)`.
|
||||||
|
|
||||||
|
**Pending is invisible** to the user; it auto-expires on a timeout (~30 min, DB hygiene). A
|
||||||
|
valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation —
|
||||||
|
the money is real, the chips are owed). The user sees only successful purchases.
|
||||||
|
|
||||||
|
**TG bot outbox.** `successful_payment` reaches the bot only (Bot API, not the Mini App), and
|
||||||
|
the bot host is weak and can lose connectivity, so the bot is a durable link. Store-and-
|
||||||
|
forward on **SQLite** on the bot's disk: receive → store → ack the Telegram update → forward
|
||||||
|
to payments (idempotent, dedup by `telegram_payment_charge_id`) → ack → mark `forwarded`.
|
||||||
|
Retries with backoff; re-drives undelivered on restart. At-least-once delivery + idempotent
|
||||||
|
intake = credited exactly once.
|
||||||
|
|
||||||
|
**Events.** The payments domain writes `payment_events` (succeeded / failed / refunded); a
|
||||||
|
dispatcher fans out over channels — the live gRPC stream if the user is in-app, else the
|
||||||
|
existing `botlink` push / email relay. "Payment failed" (an **active** provider decline, not
|
||||||
|
an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (email / bot
|
||||||
|
message).
|
||||||
|
|
||||||
|
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. An admin may
|
||||||
|
issue a **manual** refund (edge case: a user demands one shortly after paying / closes their
|
||||||
|
account — tie into `accountdelete`, which already preserves messages). **External** refunds
|
||||||
|
(chargeback / store decision / TG / VK) are honoured: the system takes the `refunded` event,
|
||||||
|
**best-effort** revokes the benefit (never going negative; if chips were already spent, it
|
||||||
|
records the loss + an abuse flag), and writes to the ledger. The ledger is **export-ready**
|
||||||
|
for future tax reporting and Robokassa reconciliation (reconciliation itself is not built
|
||||||
|
yet; the schema stays compatible).
|
||||||
|
|
||||||
|
## 10. Ads
|
||||||
|
|
||||||
|
**Launch scope: VK only** for video (rewarded payout in RUB, ОРД automatic, API ready).
|
||||||
|
web/native/TG keep the existing house **text banner** only; video is deferred until a
|
||||||
|
ruble-paying in-app network exists. The ad provider is behind an **abstraction** so a future
|
||||||
|
network for other platforms slots in without rework. Crypto-payout networks (AdsGram/AdMob)
|
||||||
|
are rejected — no legal ruble income for a self-employed (НПД) developer.
|
||||||
|
|
||||||
|
**Rewarded** (voluntary video for chips) credits chips through payments on the network's
|
||||||
|
**server verify callback** (client not believed, like a payment). On-launch anti-fraud is
|
||||||
|
the provider's verify only (no own daily cap yet; the abstraction allows adding caps later).
|
||||||
|
|
||||||
|
**Interstitial** (post-move fullscreen), configurable server-side values:
|
||||||
|
|
||||||
|
- Global cooldown **per user, across all games**, default **5 min**.
|
||||||
|
- **`vs_ai` — 30 min** (aligned with the hint cooldown, so it does not scare casual players).
|
||||||
|
- Applying a **hint** triggers a post-move interstitial **independently** of the main
|
||||||
|
cooldown, with its own **1-min** cooldown.
|
||||||
|
- Offline — banner only.
|
||||||
|
- Respect VK's own frequency caps.
|
||||||
|
|
||||||
|
**No ads offline** beyond the house banner. The `no-ads` benefit suppresses the banner via
|
||||||
|
the existing `ads.Eligible` (`backend/internal/ads/ads.go`), which is extended to gate on
|
||||||
|
the **origin benefit applicable in the current context** rather than one global flag.
|
||||||
|
|
||||||
|
## 11. Admin, audit, reporting
|
||||||
|
|
||||||
|
**Append-only ledger + materialized balance.** The operations ledger is **INSERT-only**
|
||||||
|
(never UPDATE/DELETE — full audit). Segment balances `(account, source)` and benefits
|
||||||
|
`(account, origin)` are a fast **materialized** cache, updated **in the same transaction** as
|
||||||
|
the ledger write, and recomputable from the ledger for reconciliation.
|
||||||
|
|
||||||
|
**Admin rewards.** An admin grants **concrete values only** (no-ads / hints) — **never
|
||||||
|
chips** (a gifted currency balance = a store cash-desk bypass). The admin **picks the
|
||||||
|
origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume = low risk,
|
||||||
|
`origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0
|
||||||
|
chips — full audit of rewards.
|
||||||
|
|
||||||
|
**Per-user financial report** in the admin console `/_gm` — segment balances, payments,
|
||||||
|
spends, grants, refunds, full history — as an extension of the existing user card
|
||||||
|
(`UserDetailView`, `handlers_admin_console.go`). Plus a ledger export.
|
||||||
|
|
||||||
|
## 12. Taxes and compliance
|
||||||
|
|
||||||
|
Receipts are automatic **through the provider**, and differ by rail:
|
||||||
|
|
||||||
|
- **Robokassa** (direct) — self-employed НПД receipt on payment.
|
||||||
|
- **VK** — VK processes Votes through the tax authority itself; nothing to do.
|
||||||
|
- **TG Stars** — no tax side (for a RU self-employed, Stars are not legally withdrawable = not
|
||||||
|
НПД income; accepted, no receipt issued).
|
||||||
|
|
||||||
|
**ОРД** (ad marking) for VK ads is handled on VK's side. (Not legal advice — the owner
|
||||||
|
confirms the exact НПД scheme with a tax advisor.)
|
||||||
|
|
||||||
|
## 13. Distribution (native Android)
|
||||||
|
|
||||||
|
- **RuStore** — Robokassa/external gate allowed (0%); native = clean `direct` context.
|
||||||
|
- **Google Play** — direct purchases are **hidden**; the Wallet shows a stub ("install the
|
||||||
|
RuStore build to make purchases"). Rewarded ads and spending already-earned chips still
|
||||||
|
work. Confirm Google's current in-app-currency rules before the GP release.
|
||||||
|
|
||||||
|
## 14. Data model (schema `payments`)
|
||||||
|
|
||||||
|
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. 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`):
|
||||||
|
|
||||||
|
- **ledger** — append-only operations: fund / spend / `admin_grant` / refund; `(provider,
|
||||||
|
provider_payment_id)` unique for idempotency; export-ready.
|
||||||
|
- **balances** — materialized `(account_id, source) → chips`.
|
||||||
|
- **benefits** — materialized `(account_id, origin)` → no-ads `paid_until`/`forever` +
|
||||||
|
hints count.
|
||||||
|
- **catalog** — atoms + products (soft-deletable), per-method prices, chip-purchase rates,
|
||||||
|
rewarded payout.
|
||||||
|
- **orders** — pending purchases, `order_id`, expected amount, origin, status
|
||||||
|
(pending/paid/expired).
|
||||||
|
- **payment_events** — succeeded/failed/refunded for the dispatcher.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- **Фишка / chip** — the in-game currency; uniform unit, segmented by `source`.
|
||||||
|
- **source** — where chips were funded (`vk`/`telegram`/`direct`).
|
||||||
|
- **origin** — where a value was bought; governs where the benefit applies.
|
||||||
|
- **value / benefit** — what chips buy (no-ads, hints, tournament fee).
|
||||||
|
- **gate** — the one-directional store-compliance rule (§4).
|
||||||
|
- **ledger** — the append-only record of all money/value operations.
|
||||||
|
- **rail** — a payment provider (Robokassa / VK Votes / TG Stars).
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
# PAYMENTS — механики монетизации
|
||||||
|
|
||||||
|
Русское зеркало [`PAYMENTS.md`](PAYMENTS.md) (английская версия — основная). Описывает
|
||||||
|
домен монетизации: игровую валюту, кошельки, ценности, правила комплаенса сторов, приём
|
||||||
|
платежей, рекламу, каталог, журнал операций и отчёты. Каждую правку `PAYMENTS.md` зеркалим
|
||||||
|
сюда в том же PR (как `FUNCTIONAL.md`/`FUNCTIONAL_ru.md`).
|
||||||
|
|
||||||
|
Читать перед любым изменением платёжного поведения. Технический план внедрения —
|
||||||
|
[`../PLAN.md`](../PLAN.md).
|
||||||
|
|
||||||
|
> Статус: спецификация утверждена; внедрение поэтапно (см. `PLAN.md`). В проде пока ничего
|
||||||
|
> из этого нет.
|
||||||
|
|
||||||
|
## 1. Обзор
|
||||||
|
|
||||||
|
Игра зарабатывает двумя независимыми каналами:
|
||||||
|
|
||||||
|
- **Покупки** игровой валюты **«Фишка»** за реальные деньги, затем трата Фишек на
|
||||||
|
**ценности** (бенефиты).
|
||||||
|
- **Реклама** — ролик за награду пополняет Фишки; полноэкранный ролик и наш баннер
|
||||||
|
зарабатывают показами.
|
||||||
|
|
||||||
|
Валюта **двухуровневая**:
|
||||||
|
|
||||||
|
```
|
||||||
|
деньги (Голоса VK / Stars TG / рубли через Robokassa) ─┐
|
||||||
|
├─► Фишки ──► ценности
|
||||||
|
просмотр ролика за награду ─┘ (без рекламы,
|
||||||
|
подсказки,
|
||||||
|
турнирный взнос)
|
||||||
|
```
|
||||||
|
|
||||||
|
Фишки — единая единица витрины. Деньги и просмотры **пополняют** Фишки; Фишки **покупают**
|
||||||
|
ценности. Прямой оплаты ценности деньгами нет — всегда через Фишки.
|
||||||
|
|
||||||
|
## 2. Модель валюты
|
||||||
|
|
||||||
|
**Одна Фишка равна одной Фишке везде** — единица единая. Разница между методами оплаты
|
||||||
|
целиком в **курсе покупки**: пакет Фишек стоит *X* Голосов / *Y* Stars / *Z* рублей, и курс
|
||||||
|
учитывает комиссию каждого стора. Цены ценностей фиксированы **в Фишках** и одинаковы для
|
||||||
|
всех методов.
|
||||||
|
|
||||||
|
Баланс Фишек **сегментирован по «источнику» (`source`)** — платформе, где Фишки пополнены:
|
||||||
|
|
||||||
|
| `source` | Чем пополняется |
|
||||||
|
|------------|------------------------------------|
|
||||||
|
| `vk` | Покупка за Голоса VK, ролик за награду в VK |
|
||||||
|
| `telegram` | Покупка за Stars TG |
|
||||||
|
| `direct` | Покупка через Robokassa (web / native) |
|
||||||
|
|
||||||
|
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, до трёх
|
||||||
|
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
|
||||||
|
читается как ноль. Сегментация **не** по привязке (identity) — см. §6.
|
||||||
|
|
||||||
|
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
|
||||||
|
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
|
||||||
|
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
|
||||||
|
сторов. См. §4.
|
||||||
|
|
||||||
|
## 3. Три операции, которые нельзя путать
|
||||||
|
|
||||||
|
Не смешивать — у них разные ключи:
|
||||||
|
|
||||||
|
1. **Пополнить Фишки** — деньги/реклама → сегмент `source` Фишек, по **контексту
|
||||||
|
исполнения**.
|
||||||
|
2. **Потратить Фишки = купить ценность** — сегмент → бенефит, по контексту с гейтом (§4).
|
||||||
|
Здесь бенефит **рождается** и помечается своим `origin`.
|
||||||
|
3. **Применить бенефит** во времени (напр. «без рекламы до *T*») — по правилу `origin`
|
||||||
|
(§5).
|
||||||
|
|
||||||
|
`source` (где пополнены Фишки) и `origin` (где куплена ценность) используют одно множество
|
||||||
|
значений `{vk, telegram, direct}`, но значат разное. В вебе они расходятся: покупка в вебе
|
||||||
|
может списать `vk`-Фишки (`source=vk`) в `direct`-покупку (`origin=direct`).
|
||||||
|
|
||||||
|
## 4. Гейт комплаенса сторов
|
||||||
|
|
||||||
|
Стена комплаенса **односторонняя**. Опасное направление — активация оплаченной снаружи
|
||||||
|
ценности *внутри* обёртки стора — заблокировано; безопасное (бенефит стора действует
|
||||||
|
наружу, в открытом вебе) — разрешено.
|
||||||
|
|
||||||
|
**Контекст траты → какие сегменты/бенефиты доступны:**
|
||||||
|
|
||||||
|
| Контекст исполнения | Тратимые сегменты Фишек | Приоритет траты |
|
||||||
|
|----------------------|---------------------------|--------------------|
|
||||||
|
| Внутри VK (Android) | `vk` | — |
|
||||||
|
| Внутри VK (iOS) | нет — заморожено (только просмотр) | — |
|
||||||
|
| Внутри Telegram | `telegram` | — |
|
||||||
|
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
|
||||||
|
|
||||||
|
- Внутри VK/TG доступен только одноимённый сегмент; всё остальное (в первую очередь
|
||||||
|
`direct`) там невидимо как тратимое.
|
||||||
|
- В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по
|
||||||
|
приоритету direct → vk → tg.
|
||||||
|
- **VK iOS** заморожен для траты (Apple запрещает тратить виртуальную валюту на цифровые
|
||||||
|
товары мимо IAP внутри VK на iOS): баланс показывается числом, но покупка/трата
|
||||||
|
невозможны. Ранее купленный бенефит там всё равно *действует*.
|
||||||
|
|
||||||
|
Аккаунт **единый** (привязки сливаются, один профиль/друзья/статистика). Гейт
|
||||||
|
**логический**: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на
|
||||||
|
**доверенном сигнале платформы** (§8) — клиенту не верим. Когда платформу нельзя доверенно
|
||||||
|
установить, гейт **fail-closed**: траты/покупки запрещены, только просмотр.
|
||||||
|
|
||||||
|
### Одностороннее применение бенефита
|
||||||
|
|
||||||
|
Бенефит несёт `origin` = **контекст покупки** (не «чем оплачено»). Покупка в вебе за
|
||||||
|
`vk`-Фишки всё равно даёт `origin=direct`.
|
||||||
|
|
||||||
|
| `origin` | Где действует бенефит |
|
||||||
|
|------------------|----------------------------------------------------|
|
||||||
|
| `vk` / `telegram`| **Везде** — внутри своего стора *и* наружу в web/native |
|
||||||
|
| `direct` | **Только** web/native — никогда внутри VK/TG (= бан) |
|
||||||
|
|
||||||
|
Перед тратой в вебе `vk`/`telegram`-Фишек интерфейс **предупреждает**, что ценность будет
|
||||||
|
доступна только здесь (web/native) из-за ограничений VK/TG.
|
||||||
|
|
||||||
|
## 5. Ценности
|
||||||
|
|
||||||
|
Три разные сущности, не смешивать:
|
||||||
|
|
||||||
|
- **Фишки** — валюта. Сегмент по `source`.
|
||||||
|
- **Подсказки** — расходник, покупается за Фишки. Сегмент по `origin`. Тратятся по одной в
|
||||||
|
онлайн-играх; в `vs_ai` подсказки бесплатны/безлимитны (30-мин кулдаун, не в счёт).
|
||||||
|
Подсказка в игре списывается из `origin`, применимого в текущем контексте (то же
|
||||||
|
одностороннее послабление).
|
||||||
|
- **Без рекламы** — срок-бенефит, покупается за Фишки. Сегмент по `origin`.
|
||||||
|
|
||||||
|
**Складывание «без рекламы».** Покупка срока продлевает `paid_until[origin] += срок` от
|
||||||
|
`max(сейчас, текущий конец)` — остаток не теряется («сроки плюсуются»). **Навсегда** —
|
||||||
|
отдельный вечный флаг, перекрывает сроки. «Реклама выключена в контексте *P*» ⇔ есть
|
||||||
|
применимый в *P* `origin` с `paid_until > сейчас` (в вебе берём максимум по direct/vk/tg; в
|
||||||
|
VK только vk; в TG только tg).
|
||||||
|
|
||||||
|
**Что гасит «без рекламы»:** верхний баннер **и** полноэкранный ролик после хода.
|
||||||
|
Добровольный **ролик за награду** (за Фишки) не гасится — это выбор пользователя.
|
||||||
|
|
||||||
|
**Турнирный взнос** — будущий тип ценности; атом заложен, механика позже.
|
||||||
|
|
||||||
|
## 6. Жизненный цикл кошелька
|
||||||
|
|
||||||
|
**Правило доступности сегмента.** Сегмент тратим ⇔ на аккаунте есть привязка этого
|
||||||
|
`source` (для `direct` — устойчивая привязка/email). Отсюда unlink/мерж выводятся
|
||||||
|
естественно.
|
||||||
|
|
||||||
|
**Отвязка (vk/tg).** Разрешена даже при ненулевом балансе/активном бенефите. Сегмент не
|
||||||
|
сжигается — он **засыпает** (нет привязки ⇒ недоступен в VK *и*, без привязки, недоступен
|
||||||
|
как подтянутый веб-сегмент); повторная привязка будит. Перед отвязкой предупреждение
|
||||||
|
(«N Фишек станут недоступны до повторной привязки»). Последнюю привязку отвязать нельзя
|
||||||
|
(существующий `ErrLastIdentity`).
|
||||||
|
|
||||||
|
**Мерж.** Сегменты и бенефиты сливаются **по origin**: одноимённые складываются (Фишки
|
||||||
|
суммируются, сроки бенефита продлеваются по origin), разные сосуществуют. Это расширяет
|
||||||
|
текущий мерж аккаунтов (`hint_balance +=`, `paid_account OR=`), так что origin сохраняется и
|
||||||
|
ничего не протекает между платформами.
|
||||||
|
|
||||||
|
**Гость.** У гостевого аккаунта **вообще нет кошелька** — раздел «Кошелёк» скрыт, покупок
|
||||||
|
нет, баланса нет, by design. Балансы — только у durable-аккаунтов. Поэтому чистильщик
|
||||||
|
гостей удаляет их свободно (денег там быть не может). В `direct` email обязателен **перед
|
||||||
|
первой покупкой** как якорь восстановления (в VK/TG якорь — сама vk/tg-привязка);
|
||||||
|
переиспользуем существующий флоу email (запрос кода → подтверждение → снятие флага гостя →
|
||||||
|
durable).
|
||||||
|
|
||||||
|
## 7. Каталог и цены
|
||||||
|
|
||||||
|
Каталог **конфигурируемый** — продукты, цены, курсы покупки и награда за ролик живут в базе
|
||||||
|
и правятся в админке, без релиза.
|
||||||
|
|
||||||
|
- **Базовые ценности (атомы):** Фишки, подсказки, дни без рекламы, участие в турнире.
|
||||||
|
- **Продукт = набор атомов + цена.** Продаётся по одной или комбо (напр. «250 подсказок +
|
||||||
|
30 дней без рекламы»).
|
||||||
|
- **Пакет Фишек** (пополняет Фишки) — цена **per-метод** (мультивалютная Голоса/Stars/руб)
|
||||||
|
одним продуктом.
|
||||||
|
- **Ценность** (за Фишки) — цена **в Фишках** (единая для всех методов).
|
||||||
|
|
||||||
|
**Деактивация, не удаление.** Продукты деактивируются (soft-delete). Состоявшаяся покупка
|
||||||
|
хранит **снимок** проданного (состав атомов + цена на момент) в архиве, чтобы
|
||||||
|
история/чеки/налоги не зависели от последующих правок каталога.
|
||||||
|
|
||||||
|
## 8. Доверенный сигнал платформы
|
||||||
|
|
||||||
|
Гейту (§4) нужен **доверенный, неподделываемый** контекст платформы на сервере. Клиент —
|
||||||
|
никогда не источник правды.
|
||||||
|
|
||||||
|
- Платформа — **свойство сессии**, фиксируется при создании сессии. Обёртки VK и Telegram
|
||||||
|
пересоздают сессию (и потому заново проверяют подпись запуска — VK launch-params `sign` /
|
||||||
|
Telegram `initData`) на **каждом холодном старте**, поэтому их платформа переподтверждается
|
||||||
|
при каждом запуске; `direct`-сессия фиксирует платформу один раз, самим фактом создания
|
||||||
|
веб/native-сессии (внешней подписи нет и не нужно — доступ к vk/tg-сегментам в direct-контексте
|
||||||
|
всё равно требует реальной привязки, §6).
|
||||||
|
- Платформа несёт **kind** (`vk`/`telegram`/`direct`) **плюс подтип** (`ios`/`android`/`web`).
|
||||||
|
`kind` доверенный всегда — сервер выводит его из проверенного запуска, не из клиентского поля.
|
||||||
|
**Подтип доверенный только у VK**: он лежит внутри подписанных параметров запуска, что и делает
|
||||||
|
**заморозку VK iOS** выполнимой; у Telegram и direct подтип сообщает клиент, он best-effort, и
|
||||||
|
гейт на него не опирается.
|
||||||
|
- Гейтвей резолвит сессию и передаёт `platform` в бэкенд (рядом с существующим `X-User-ID`),
|
||||||
|
беря его из сессии — не из тела клиентского запроса.
|
||||||
|
- **Fail-closed:** недоверенная платформа — сессия без записанной платформы, созданная до этой
|
||||||
|
функции или которую гейтвей не смог атрибутировать — запрещает траты/покупки и применение
|
||||||
|
любого чужого origin (только просмотр). VK/TG-сессия восстанавливается на следующем холодном
|
||||||
|
старте (пересоздание), переиспользуемая direct/email-сессия — при повторном входе.
|
||||||
|
|
||||||
|
## 9. Приём платежей
|
||||||
|
|
||||||
|
**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному**
|
||||||
|
(подпись/HMAC) серверному колбэку — Robokassa webhook / TG `successful_payment` / VK
|
||||||
|
callback. Клиентское «я оплатил» игнорируется.
|
||||||
|
|
||||||
|
**Единственный писатель.** Один платёжный домен — единственный, кто пишет в журнал операций.
|
||||||
|
Публичные вебхуки (Robokassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
|
||||||
|
платёжный домен; TG `successful_payment` приходит боту, тот форвардит в платёжный домен.
|
||||||
|
Одно место начисляет и защищает от повторов.
|
||||||
|
|
||||||
|
**Флоу заказа.** Сервер заранее создаёт `order(pending)` с account / платформой / пакетом /
|
||||||
|
ожидаемой суммой / origin. `order_id` прокидывается провайдеру (Robokassa `InvId` / TG
|
||||||
|
`invoice_payload` / VK `item` — точную форму VK уточнить при интеграции). Колбэк матчится по
|
||||||
|
`order_id` (никогда по сумме, поэтому коллизии одинаковых сумм невозможны), сверяет сумму,
|
||||||
|
начисляет, помечает `paid`. **Защита от повторов:** дедуп по `(провайдер,
|
||||||
|
provider_payment_id)`.
|
||||||
|
|
||||||
|
**Pending невидим** пользователю; авто-истекает по таймауту (~30 мин, гигиена базы).
|
||||||
|
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
|
||||||
|
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.
|
||||||
|
|
||||||
|
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а
|
||||||
|
хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на
|
||||||
|
**SQLite** на диске бота: получил → сохранил → подтвердил апдейт Telegram → форвардит в
|
||||||
|
платёжный домен (идемпотентно, дедуп по `telegram_payment_charge_id`) → ack → пометил
|
||||||
|
`forwarded`. Ретраи с backoff; дореталивает недоставленное при рестарте. Доставка
|
||||||
|
at-least-once + идемпотентный приём = начисление ровно один раз.
|
||||||
|
|
||||||
|
**События.** Платёжный домен пишет `payment_events` (succeeded / failed / refunded);
|
||||||
|
диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе
|
||||||
|
существующий пуш `botlink` / email. «Оплата не прошла» (**активный** отказ провайдера, не
|
||||||
|
брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в
|
||||||
|
бота).
|
||||||
|
|
||||||
|
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Админ может сделать
|
||||||
|
**ручной** возврат (крайний случай: пользователь требует вскоре после оплаты / закрывает
|
||||||
|
аккаунт — связано с `accountdelete`, где уже сохраняются сообщения). **Внешние** возвраты
|
||||||
|
(чарджбек / решение стора / TG / VK) обрабатываются: система принимает событие `refunded`,
|
||||||
|
**по возможности** отзывает бенефит (в минус не уходим; если Фишки уже потрачены —
|
||||||
|
фиксирует убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций
|
||||||
|
**спроектирован экспортопригодным** для будущей налоговой отчётности и сверки с Robokassa
|
||||||
|
(саму сверку пока не строим; схема остаётся совместимой).
|
||||||
|
|
||||||
|
## 10. Реклама
|
||||||
|
|
||||||
|
**Охват на старте: только VK** для видео (награда в рублях, ОРД автоматом, API готов).
|
||||||
|
web/native/TG держат только существующий наш **текст-баннер**; видео отложено до появления
|
||||||
|
рублёвой in-app сети. Рекламный провайдер за **абстракцией**, чтобы будущая сеть для других
|
||||||
|
платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального
|
||||||
|
рублёвого дохода самозанятому (НПД).
|
||||||
|
|
||||||
|
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен по
|
||||||
|
**серверному verify-колбэку** сети (клиенту не верим, как платежу). Антифрод на старте — только
|
||||||
|
verify провайдера (своего дневного потолка пока нет; абстракция позволит добавить лимиты
|
||||||
|
позже).
|
||||||
|
|
||||||
|
**Полноэкранный ролик** (после хода), конфигурируемые серверные значения:
|
||||||
|
|
||||||
|
- Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**.
|
||||||
|
- **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов).
|
||||||
|
- Применение **подсказки** триггерит ролик после хода **независимо** от основного кулдауна,
|
||||||
|
со своим кулдауном **1 мин**.
|
||||||
|
- Оффлайн — только баннер.
|
||||||
|
- Уважать собственные лимиты частоты VK.
|
||||||
|
|
||||||
|
**Оффлайн без рекламы**, кроме нашего баннера. Бенефит `без рекламы` гасит баннер через
|
||||||
|
существующий `ads.Eligible` (`backend/internal/ads/ads.go`), который расширяется до гейта по
|
||||||
|
**origin-бенефиту, применимому в текущем контексте**, а не по одному глобальному флагу.
|
||||||
|
|
||||||
|
## 11. Админ, аудит, отчётность
|
||||||
|
|
||||||
|
**Неизменяемый журнал операций + материализованный баланс.** Журнал операций **только на
|
||||||
|
INSERT** (никогда UPDATE/DELETE — полный аудит). Балансы сегментов `(account, source)` и
|
||||||
|
бенефиты `(account, origin)` — быстрый **материализованный** кэш, обновляется **в той же
|
||||||
|
транзакции**, что и запись журнала, и пересчитывается из журнала для сверки.
|
||||||
|
|
||||||
|
**Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы /
|
||||||
|
подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Админ
|
||||||
|
**выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk`
|
||||||
|
точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала
|
||||||
|
типа `admin_grant`, цена 0 Фишек — полный аудит наград.
|
||||||
|
|
||||||
|
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
|
||||||
|
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
|
||||||
|
`handlers_admin_console.go`). Плюс экспорт журнала.
|
||||||
|
|
||||||
|
## 12. Налоги и комплаенс
|
||||||
|
|
||||||
|
Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу:
|
||||||
|
|
||||||
|
- **Robokassa** (direct) — чек НПД самозанятого при оплате.
|
||||||
|
- **VK** — VK сам процессит Голоса через налоговую; делать нечего.
|
||||||
|
- **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не
|
||||||
|
доход НПД; принимаем, чек не формируем).
|
||||||
|
|
||||||
|
**ОРД** (маркировка рекламы) по VK-рекламе — на стороне VK. (Не юридическая консультация —
|
||||||
|
владелец сверяет точную схему НПД с налоговым консультантом.)
|
||||||
|
|
||||||
|
## 13. Дистрибуция (native Android)
|
||||||
|
|
||||||
|
- **RuStore** — Robokassa/внешний гейт разрешён (0%); native = чистый контекст `direct`.
|
||||||
|
- **Google Play** — direct-покупки **скрыты**; «Кошелёк» показывает заглушку («установите
|
||||||
|
версию из RuStore для покупок»). Ролик за награду и трата уже накопленных Фишек работают.
|
||||||
|
Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте.
|
||||||
|
|
||||||
|
## 14. Модель данных (схема `payments`)
|
||||||
|
|
||||||
|
Платёжный домен живёт в **своей схеме `payments`** в общем инстансе Postgres, со своим
|
||||||
|
DB-ролём (права только на `payments`) и доменным пакетом за жёстким интерфейсом.
|
||||||
|
**Cross-schema внешнего ключа** к `backend.accounts` **нет** — идентификатор аккаунта здесь
|
||||||
|
обычное значение, согласуемое в коде и связываемое с tombstone-аккаунтом / досье
|
||||||
|
retained-identities по стабильному id. Это держит трату «Фишки↔бенефит» атомарной **внутри
|
||||||
|
`payments`** и делает домен извлекаемым в свою базу. Сохранность — **PITR** (непрерывный
|
||||||
|
WAL-архив), независимо от топологии базы.
|
||||||
|
|
||||||
|
Основные таблицы (финальные имена/колонки — в `PLAN.md`):
|
||||||
|
|
||||||
|
- **журнал операций (ledger)** — append-only операции: пополнение / трата / `admin_grant` /
|
||||||
|
возврат; `(провайдер, provider_payment_id)` уникален для защиты от повторов;
|
||||||
|
экспортопригоден.
|
||||||
|
- **балансы** — материализованные `(account_id, source) → Фишки`.
|
||||||
|
- **бенефиты** — материализованные `(account_id, origin)` → «без рекламы»
|
||||||
|
`paid_until`/`forever` + счётчик подсказок.
|
||||||
|
- **каталог** — атомы + продукты (деактивируемые), цены per-метод, курсы покупки Фишек,
|
||||||
|
награда за ролик.
|
||||||
|
- **заказы (orders)** — pending-покупки, `order_id`, ожидаемая сумма, origin, статус
|
||||||
|
(pending/paid/expired).
|
||||||
|
- **payment_events** — succeeded/failed/refunded для диспетчера.
|
||||||
|
|
||||||
|
Legacy `accounts.hint_balance` и `accounts.paid_account` **устаревают** в пользу
|
||||||
|
сегментированной модели и удаляются в отдельной **contract-миграции** (expand-contract, после
|
||||||
|
того как валютное ядро переключит чтения, и после Release 2 — откат образа остаётся безопасным
|
||||||
|
для БД); ни то, ни другое в проде никогда не выставлялось (потока покупки не было), поэтому
|
||||||
|
legacy-значения обнуляются.
|
||||||
|
|
||||||
|
## 15. Словарь
|
||||||
|
|
||||||
|
- **Фишка** — игровая валюта; единая единица, сегментирована по `source`.
|
||||||
|
- **source (источник)** — где пополнены Фишки (`vk`/`telegram`/`direct`).
|
||||||
|
- **origin (происхождение)** — где куплена ценность; определяет, где действует бенефит.
|
||||||
|
- **ценность / бенефит** — то, что покупается за Фишки (без рекламы, подсказки, турнирный
|
||||||
|
взнос).
|
||||||
|
- **гейт** — одностороннее правило комплаенса сторов (§4).
|
||||||
|
- **журнал операций (ledger)** — неизменяемая запись всех операций с деньгами/ценностями.
|
||||||
|
- **платёжный канал / рельса** — платёжный провайдер (Robokassa / Голоса VK / Stars TG).
|
||||||
@@ -211,7 +211,7 @@ type ChatResp struct {
|
|||||||
// brand-new account's display name and language from the validated launch fields, its
|
// brand-new account's display name and language from the validated launch fields, its
|
||||||
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
|
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
|
||||||
// validated launch deep-link startParam, its variant preferences (first contact only).
|
// validated launch deep-link startParam, its variant preferences (first contact only).
|
||||||
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam string) (SessionResp, error) {
|
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam, subtype string) (SessionResp, error) {
|
||||||
var out SessionResp
|
var out SessionResp
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
@@ -221,6 +221,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
|||||||
"first_name": firstName,
|
"first_name": firstName,
|
||||||
"browser_tz": browserTz,
|
"browser_tz": browserTz,
|
||||||
"start_param": startParam,
|
"start_param": startParam,
|
||||||
|
"subtype": subtype,
|
||||||
}, &out)
|
}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -230,7 +231,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
|||||||
// name from displayName (read client-side via VKWebAppGetUserInfo, since VK omits the
|
// name from displayName (read client-side via VKWebAppGetUserInfo, since VK omits the
|
||||||
// name from the signed launch params) and its time zone from browserTz (the client's
|
// name from the signed launch params) and its time zone from browserTz (the client's
|
||||||
// detected "±HH:MM" UTC offset). All seeds apply on first contact only.
|
// detected "±HH:MM" UTC offset). All seeds apply on first contact only.
|
||||||
func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz string) (SessionResp, error) {
|
func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz, subtype string) (SessionResp, error) {
|
||||||
var out SessionResp
|
var out SessionResp
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
@@ -238,6 +239,7 @@ func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayNa
|
|||||||
"language_code": languageCode,
|
"language_code": languageCode,
|
||||||
"display_name": displayName,
|
"display_name": displayName,
|
||||||
"browser_tz": browserTz,
|
"browser_tz": browserTz,
|
||||||
|
"subtype": subtype,
|
||||||
}, &out)
|
}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -290,10 +292,10 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces
|
|||||||
|
|
||||||
// GuestAuth provisions a guest account and mints a session, seeding its time zone
|
// GuestAuth provisions a guest account and mints a session, seeding its time zone
|
||||||
// from browserTz (the client's detected "±HH:MM" UTC offset).
|
// from browserTz (the client's detected "±HH:MM" UTC offset).
|
||||||
func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp, error) {
|
func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype string) (SessionResp, error) {
|
||||||
var out SessionResp
|
var out SessionResp
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "",
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "",
|
||||||
map[string]string{"browser_tz": browserTz}, &out)
|
map[string]string{"browser_tz": browserTz, "subtype": subtype}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,10 +309,10 @@ func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EmailLogin verifies a login code and mints a session.
|
// EmailLogin verifies a login code and mints a session.
|
||||||
func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionResp, error) {
|
func (c *Client) EmailLogin(ctx context.Context, email, code, subtype string) (SessionResp, error) {
|
||||||
var out SessionResp
|
var out SessionResp
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/login", "", "",
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/login", "", "",
|
||||||
map[string]string{"email": email, "code": code}, &out)
|
map[string]string{"email": email, "code": code, "subtype": subtype}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,16 +333,24 @@ func (c *Client) EmailConfirmLink(ctx context.Context, token string) (ConfirmLin
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveSession maps a token to its account id and guest flag (gateway
|
// ResolveSession maps a token to its account id, guest flag, and trusted platform
|
||||||
// session-cache miss). The guest flag lets the edge gate guest-forbidden ops.
|
// (gateway session-cache miss). The guest flag lets the edge gate guest-forbidden
|
||||||
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) {
|
// ops; the platform ("<kind>/<subtype>", empty for an untrusted session) is injected
|
||||||
|
// as X-Platform on the caller's backend requests.
|
||||||
|
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, string, error) {
|
||||||
var out struct {
|
var out struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
IsGuest bool `json:"is_guest"`
|
IsGuest bool `json:"is_guest"`
|
||||||
|
PlatformKind string `json:"platform_kind"`
|
||||||
|
PlatformSubtype string `json:"platform_subtype"`
|
||||||
}
|
}
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
|
||||||
map[string]string{"token": token}, &out)
|
map[string]string{"token": token}, &out)
|
||||||
return out.UserID, out.IsGuest, err
|
platform := ""
|
||||||
|
if out.PlatformKind != "" {
|
||||||
|
platform = out.PlatformKind + "/" + out.PlatformSubtype
|
||||||
|
}
|
||||||
|
return out.UserID, out.IsGuest, platform, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile returns the authenticated account's profile.
|
// Profile returns the authenticated account's profile.
|
||||||
|
|||||||
@@ -118,8 +118,30 @@ func (e *APIError) Error() string {
|
|||||||
return fmt.Sprintf("backend %d (%s): %s", e.Status, e.Code, e.Message)
|
return fmt.Sprintf("backend %d (%s): %s", e.Status, e.Code, e.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// platformCtxKey types the request-context slot the trusted X-Platform value rides in.
|
||||||
|
type platformCtxKey struct{}
|
||||||
|
|
||||||
|
// WithPlatform returns a copy of ctx carrying the trusted platform header value
|
||||||
|
// ("<kind>/<subtype>", e.g. "vk/ios") that do and getRaw inject as X-Platform on the
|
||||||
|
// outbound backend request. An empty platform (an untrusted session) leaves ctx
|
||||||
|
// unchanged, so no header is sent and the backend treats the request as untrusted.
|
||||||
|
func WithPlatform(ctx context.Context, platform string) context.Context {
|
||||||
|
if platform == "" {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, platformCtxKey{}, platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformFromContext returns the platform header value stored by WithPlatform, or
|
||||||
|
// an empty string when none is present.
|
||||||
|
func platformFromContext(ctx context.Context) string {
|
||||||
|
p, _ := ctx.Value(platformCtxKey{}).(string)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
||||||
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation). A non-2xx
|
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
|
||||||
|
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
|
||||||
// response is returned as an *APIError carrying the backend error code.
|
// response is returned as an *APIError carrying the backend error code.
|
||||||
func (c *Client) do(ctx context.Context, method, path, userID, clientIP string, body, out any) error {
|
func (c *Client) do(ctx context.Context, method, path, userID, clientIP string, body, out any) error {
|
||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
@@ -141,6 +163,9 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
|
|||||||
if clientIP != "" {
|
if clientIP != "" {
|
||||||
req.Header.Set("X-Forwarded-For", clientIP)
|
req.Header.Set("X-Forwarded-For", clientIP)
|
||||||
}
|
}
|
||||||
|
if p := platformFromContext(ctx); p != "" {
|
||||||
|
req.Header.Set("X-Platform", p)
|
||||||
|
}
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("backendclient: %s %s: %w", method, path, err)
|
return fmt.Errorf("backendclient: %s %s: %w", method, path, err)
|
||||||
@@ -174,6 +199,9 @@ func (c *Client) getRaw(ctx context.Context, path, userID, respHeader string) ([
|
|||||||
if userID != "" {
|
if userID != "" {
|
||||||
req.Header.Set("X-User-ID", userID)
|
req.Header.Set("X-User-ID", userID)
|
||||||
}
|
}
|
||||||
|
if p := platformFromContext(ctx); p != "" {
|
||||||
|
req.Header.Set("X-Platform", p)
|
||||||
|
}
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("backendclient: GET %s: %w", path, err)
|
return nil, "", fmt.Errorf("backendclient: GET %s: %w", path, err)
|
||||||
|
|||||||
@@ -82,3 +82,43 @@ func TestSyncBans(t *testing.T) {
|
|||||||
t.Fatalf("unban = %v, want [203.0.113.9]", unban)
|
t.Fatalf("unban = %v, want [203.0.113.9]", unban)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestXPlatformInjection verifies the trusted platform carried on the context
|
||||||
|
// (WithPlatform) rides an authenticated backend request as X-Platform, and that an
|
||||||
|
// untrusted context (no platform) sends no header at all — the fail-closed default.
|
||||||
|
func TestXPlatformInjection(t *testing.T) {
|
||||||
|
var gotPlatform string
|
||||||
|
var hadHeader bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPlatform = r.Header.Get("X-Platform")
|
||||||
|
_, hadHeader = r.Header["X-Platform"]
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backendclient: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = c.Close() }()
|
||||||
|
|
||||||
|
t.Run("trusted forwards header", func(t *testing.T) {
|
||||||
|
ctx := backendclient.WithPlatform(context.Background(), "vk/ios")
|
||||||
|
if _, err := c.Profile(ctx, "user-1"); err != nil {
|
||||||
|
t.Fatalf("Profile: %v", err)
|
||||||
|
}
|
||||||
|
if gotPlatform != "vk/ios" {
|
||||||
|
t.Fatalf("X-Platform = %q, want vk/ios", gotPlatform)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("untrusted omits header", func(t *testing.T) {
|
||||||
|
hadHeader = true // ensure the handler actually clears it
|
||||||
|
if _, err := c.Profile(context.Background(), "user-1"); err != nil {
|
||||||
|
t.Fatalf("Profile: %v", err)
|
||||||
|
}
|
||||||
|
if hadHeader {
|
||||||
|
t.Fatal("X-Platform must be absent for an untrusted context")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
|
|
||||||
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
||||||
if op.Auth {
|
if op.Auth {
|
||||||
uid, isGuest, err := s.resolve(ctx, req.Header(), clientIP)
|
uid, isGuest, platform, err := s.resolve(ctx, req.Header(), clientIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result = "unauthenticated"
|
result = "unauthenticated"
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -297,6 +297,10 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
return nil, s.rejectRateLimited(ctx, classUser, uid, msgType)
|
return nil, s.rejectRateLimited(ctx, classUser, uid, msgType)
|
||||||
}
|
}
|
||||||
tr.UserID = uid
|
tr.UserID = uid
|
||||||
|
// Carry the resolved trusted platform on the context so the backend client injects
|
||||||
|
// X-Platform on every downstream REST call for this request. Empty for an untrusted
|
||||||
|
// session ⇒ no header ⇒ the backend treats the request as untrusted (view-only).
|
||||||
|
ctx = backendclient.WithPlatform(ctx, platform)
|
||||||
} else {
|
} else {
|
||||||
if !s.limiter.Allow("ip:"+clientIP, s.publicPolicy) {
|
if !s.limiter.Allow("ip:"+clientIP, s.publicPolicy) {
|
||||||
result = "rate_limited"
|
result = "rate_limited"
|
||||||
@@ -331,7 +335,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
// Subscribe streams the authenticated user's live events with a keep-alive
|
// Subscribe streams the authenticated user's live events with a keep-alive
|
||||||
// heartbeat until the client disconnects.
|
// heartbeat until the client disconnects.
|
||||||
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
|
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
|
||||||
uid, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
|
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -494,7 +498,7 @@ func (s *Server) dictBytesHandler() http.Handler {
|
|||||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid, _, err := s.resolve(r.Context(), r.Header, ip)
|
uid, _, _, err := s.resolve(r.Context(), r.Header, ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@@ -552,7 +556,7 @@ func (s *Server) localEvalMetricsHandler() http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ip := peerIP(r.RemoteAddr, r.Header)
|
ip := peerIP(r.RemoteAddr, r.Header)
|
||||||
if _, _, err := s.resolve(r.Context(), r.Header, ip); err != nil {
|
if _, _, _, err := s.resolve(r.Context(), r.Header, ip); err != nil {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -659,10 +663,10 @@ func truncate(s string, n int) string {
|
|||||||
// resolve extracts and resolves the Authorization bearer token to an account id
|
// resolve extracts and resolves the Authorization bearer token to an account id
|
||||||
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
||||||
// or unknown.
|
// or unknown.
|
||||||
func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (string, bool, error) {
|
func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (string, bool, string, error) {
|
||||||
token := bearerToken(h.Get("Authorization"))
|
token := bearerToken(h.Get("Authorization"))
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return "", false, connect.NewError(connect.CodeUnauthenticated, errMissingToken)
|
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errMissingToken)
|
||||||
}
|
}
|
||||||
// The honeytoken is a planted value no real client holds: presenting it is a
|
// The honeytoken is a planted value no real client holds: presenting it is a
|
||||||
// high-confidence intrusion signal, so ban the caller and raise the alarm, then
|
// high-confidence intrusion signal, so ban the caller and raise the alarm, then
|
||||||
@@ -672,9 +676,9 @@ func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (s
|
|||||||
if s.banlist.BanNow(clientIP, ratelimit.ReasonHoneytoken) {
|
if s.banlist.BanNow(clientIP, ratelimit.ReasonHoneytoken) {
|
||||||
s.metrics.recordBan(ctx, string(ratelimit.ReasonHoneytoken))
|
s.metrics.recordBan(ctx, string(ratelimit.ReasonHoneytoken))
|
||||||
}
|
}
|
||||||
return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
|
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
|
||||||
}
|
}
|
||||||
uid, isGuest, err := s.sessions.Resolve(ctx, token)
|
uid, isGuest, platform, err := s.sessions.Resolve(ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// An unknown or expired token (a backend 4xx) is the client's problem and
|
// An unknown or expired token (a backend 4xx) is the client's problem and
|
||||||
// stays silent; anything else — a resolve timeout, a refused connection, a
|
// stays silent; anything else — a resolve timeout, a refused connection, a
|
||||||
@@ -685,9 +689,9 @@ func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (s
|
|||||||
if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError {
|
if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError {
|
||||||
s.log.Warn("session resolve failed", zap.Error(err))
|
s.log.Warn("session resolve failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
|
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
|
||||||
}
|
}
|
||||||
return uid, isGuest, nil
|
return uid, isGuest, platform, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// bearerToken extracts the token from an "Authorization: Bearer <token>" header,
|
// bearerToken extracts the token from an "Authorization: Bearer <token>" header,
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resolver resolves a token to an account id and its guest flag at the backend
|
// Resolver resolves a token to an account id, its guest flag and its trusted
|
||||||
// (the cache miss path). backendclient.Client satisfies it.
|
// platform ("<kind>/<subtype>", empty when untrusted) at the backend (the cache
|
||||||
|
// miss path). backendclient.Client satisfies it.
|
||||||
type Resolver interface {
|
type Resolver interface {
|
||||||
ResolveSession(ctx context.Context, token string) (string, bool, error)
|
ResolveSession(ctx context.Context, token string) (string, bool, string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache resolves session tokens to account ids, caching hits for ttl.
|
// Cache resolves session tokens to account ids, caching hits for ttl.
|
||||||
@@ -29,9 +30,10 @@ type Cache struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type entry struct {
|
type entry struct {
|
||||||
userID string
|
userID string
|
||||||
isGuest bool
|
isGuest bool
|
||||||
expires time.Time
|
platform string
|
||||||
|
expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCache constructs a Cache over backend with the given TTL and maximum size.
|
// NewCache constructs a Cache over backend with the given TTL and maximum size.
|
||||||
@@ -48,19 +50,19 @@ func NewCache(backend Resolver, ttl time.Duration, max int) *Cache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve returns the account id and guest flag for token, consulting the cache
|
// Resolve returns the account id, guest flag and trusted platform for token,
|
||||||
// first and the backend on a miss (caching the result). An empty token is rejected
|
// consulting the cache first and the backend on a miss (caching the result). An
|
||||||
// by the backend like any unknown token.
|
// empty token is rejected by the backend like any unknown token.
|
||||||
func (c *Cache) Resolve(ctx context.Context, token string) (string, bool, error) {
|
func (c *Cache) Resolve(ctx context.Context, token string) (string, bool, string, error) {
|
||||||
if uid, guest, ok := c.lookup(token); ok {
|
if uid, guest, platform, ok := c.lookup(token); ok {
|
||||||
return uid, guest, nil
|
return uid, guest, platform, nil
|
||||||
}
|
}
|
||||||
uid, guest, err := c.backend.ResolveSession(ctx, token)
|
uid, guest, platform, err := c.backend.ResolveSession(ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return "", false, "", err
|
||||||
}
|
}
|
||||||
c.store(token, uid, guest)
|
c.store(token, uid, guest, platform)
|
||||||
return uid, guest, nil
|
return uid, guest, platform, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate drops a token from the cache (e.g. after a revoke).
|
// Invalidate drops a token from the cache (e.g. after a revoke).
|
||||||
@@ -70,26 +72,26 @@ func (c *Cache) Invalidate(token string) {
|
|||||||
delete(c.entries, token)
|
delete(c.entries, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookup returns a live cached account id and guest flag for token.
|
// lookup returns a live cached account id, guest flag and platform for token.
|
||||||
func (c *Cache) lookup(token string) (string, bool, bool) {
|
func (c *Cache) lookup(token string) (string, bool, string, bool) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
e, ok := c.entries[token]
|
e, ok := c.entries[token]
|
||||||
if !ok || !c.now().Before(e.expires) {
|
if !ok || !c.now().Before(e.expires) {
|
||||||
return "", false, false
|
return "", false, "", false
|
||||||
}
|
}
|
||||||
return e.userID, e.isGuest, true
|
return e.userID, e.isGuest, e.platform, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// store caches token -> (userID, isGuest), sweeping expired entries and bounding
|
// store caches token -> (userID, isGuest, platform), sweeping expired entries and
|
||||||
// the size.
|
// bounding the size.
|
||||||
func (c *Cache) store(token, userID string, isGuest bool) {
|
func (c *Cache) store(token, userID string, isGuest bool, platform string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if len(c.entries) >= c.max {
|
if len(c.entries) >= c.max {
|
||||||
c.evictLocked()
|
c.evictLocked()
|
||||||
}
|
}
|
||||||
c.entries[token] = entry{userID: userID, isGuest: isGuest, expires: c.now().Add(c.ttl)}
|
c.entries[token] = entry{userID: userID, isGuest: isGuest, platform: platform, expires: c.now().Add(c.ttl)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// evictLocked removes expired entries and, if still at capacity, drops arbitrary
|
// evictLocked removes expired entries and, if still at capacity, drops arbitrary
|
||||||
|
|||||||
@@ -8,18 +8,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fakeResolver struct {
|
type fakeResolver struct {
|
||||||
uid string
|
uid string
|
||||||
guest bool
|
guest bool
|
||||||
err error
|
platform string
|
||||||
calls int
|
err error
|
||||||
|
calls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, bool, error) {
|
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, bool, string, error) {
|
||||||
f.calls++
|
f.calls++
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return "", false, f.err
|
return "", false, "", f.err
|
||||||
}
|
}
|
||||||
return f.uid, f.guest, nil
|
return f.uid, f.guest, f.platform, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCachesBackendHit(t *testing.T) {
|
func TestResolveCachesBackendHit(t *testing.T) {
|
||||||
@@ -27,7 +28,7 @@ func TestResolveCachesBackendHit(t *testing.T) {
|
|||||||
c := NewCache(r, time.Minute, 10)
|
c := NewCache(r, time.Minute, 10)
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
uid, _, err := c.Resolve(context.Background(), "tok")
|
uid, _, _, err := c.Resolve(context.Background(), "tok")
|
||||||
if err != nil || uid != "user-1" {
|
if err != nil || uid != "user-1" {
|
||||||
t.Fatalf("resolve #%d = (%q, %v)", i, uid, err)
|
t.Fatalf("resolve #%d = (%q, %v)", i, uid, err)
|
||||||
}
|
}
|
||||||
@@ -37,24 +38,24 @@ func TestResolveCachesBackendHit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCarriesAndCachesGuestFlag(t *testing.T) {
|
func TestResolveCarriesAndCachesGuestFlagAndPlatform(t *testing.T) {
|
||||||
r := &fakeResolver{uid: "guest-1", guest: true}
|
r := &fakeResolver{uid: "guest-1", guest: true, platform: "vk/ios"}
|
||||||
c := NewCache(r, time.Minute, 10)
|
c := NewCache(r, time.Minute, 10)
|
||||||
for i := 0; i < 2; i++ {
|
for i := 0; i < 2; i++ {
|
||||||
uid, guest, err := c.Resolve(context.Background(), "tok")
|
uid, guest, platform, err := c.Resolve(context.Background(), "tok")
|
||||||
if err != nil || uid != "guest-1" || !guest {
|
if err != nil || uid != "guest-1" || !guest || platform != "vk/ios" {
|
||||||
t.Fatalf("resolve #%d = (%q, guest=%v, %v)", i, uid, guest, err)
|
t.Fatalf("resolve #%d = (%q, guest=%v, platform=%q, %v)", i, uid, guest, platform, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.calls != 1 {
|
if r.calls != 1 {
|
||||||
t.Fatalf("backend calls = %d, want 1 (guest flag cached)", r.calls)
|
t.Fatalf("backend calls = %d, want 1 (guest flag + platform cached)", r.calls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolvePropagatesBackendError(t *testing.T) {
|
func TestResolvePropagatesBackendError(t *testing.T) {
|
||||||
r := &fakeResolver{err: errors.New("nope")}
|
r := &fakeResolver{err: errors.New("nope")}
|
||||||
c := NewCache(r, time.Minute, 10)
|
c := NewCache(r, time.Minute, 10)
|
||||||
if _, _, err := c.Resolve(context.Background(), "tok"); err == nil {
|
if _, _, _, err := c.Resolve(context.Background(), "tok"); err == nil {
|
||||||
t.Fatal("expected backend error to propagate")
|
t.Fatal("expected backend error to propagate")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,11 +66,11 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
|
|||||||
base := time.Now()
|
base := time.Now()
|
||||||
c.now = func() time.Time { return base }
|
c.now = func() time.Time { return base }
|
||||||
|
|
||||||
if _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
|
if _, _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
c.now = func() time.Time { return base.Add(2 * time.Minute) } // past TTL
|
c.now = func() time.Time { return base.Add(2 * time.Minute) } // past TTL
|
||||||
if _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
|
if _, _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if r.calls != 2 {
|
if r.calls != 2 {
|
||||||
@@ -80,9 +81,9 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
|
|||||||
func TestInvalidateForcesReResolve(t *testing.T) {
|
func TestInvalidateForcesReResolve(t *testing.T) {
|
||||||
r := &fakeResolver{uid: "user-1"}
|
r := &fakeResolver{uid: "user-1"}
|
||||||
c := NewCache(r, time.Minute, 10)
|
c := NewCache(r, time.Minute, 10)
|
||||||
_, _, _ = c.Resolve(context.Background(), "tok")
|
_, _, _, _ = c.Resolve(context.Background(), "tok")
|
||||||
c.Invalidate("tok")
|
c.Invalidate("tok")
|
||||||
_, _, _ = c.Resolve(context.Background(), "tok")
|
_, _, _, _ = c.Resolve(context.Background(), "tok")
|
||||||
if r.calls != 2 {
|
if r.calls != 2 {
|
||||||
t.Fatalf("backend calls = %d, want 2 after invalidate", r.calls)
|
t.Fatalf("backend calls = %d, want 2 after invalidate", r.calls)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
|
|||||||
if q, perr := url.ParseQuery(initData); perr == nil {
|
if q, perr := url.ParseQuery(initData); perr == nil {
|
||||||
startParam = q.Get("start_param")
|
startParam = q.Get("start_param")
|
||||||
}
|
}
|
||||||
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam)
|
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam, string(in.Subtype()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ func authVKHandler(backend *backendclient.Client, secret string) Handler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()))
|
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()), user.Subtype())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -238,11 +238,13 @@ func authGuestHandler(backend *backendclient.Client) Handler {
|
|||||||
// The guest bootstrap historically carried no payload; the detected zone is
|
// The guest bootstrap historically carried no payload; the detected zone is
|
||||||
// optional, so an absent or empty one simply yields no time-zone seed (rather
|
// optional, so an absent or empty one simply yields no time-zone seed (rather
|
||||||
// than panicking in GetRootAs* on a zero-length buffer).
|
// than panicking in GetRootAs* on a zero-length buffer).
|
||||||
var browserTz string
|
var browserTz, subtype string
|
||||||
if len(req.Payload) > 0 {
|
if len(req.Payload) > 0 {
|
||||||
browserTz = string(fb.GetRootAsGuestLoginRequest(req.Payload, 0).BrowserTz())
|
g := fb.GetRootAsGuestLoginRequest(req.Payload, 0)
|
||||||
|
browserTz = string(g.BrowserTz())
|
||||||
|
subtype = string(g.Subtype())
|
||||||
}
|
}
|
||||||
sess, err := backend.GuestAuth(ctx, browserTz)
|
sess, err := backend.GuestAuth(ctx, browserTz, subtype)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -263,7 +265,7 @@ func authEmailRequestHandler(backend *backendclient.Client) Handler {
|
|||||||
func authEmailLoginHandler(backend *backendclient.Client) Handler {
|
func authEmailLoginHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsEmailLoginRequest(req.Payload, 0)
|
in := fb.GetRootAsEmailLoginRequest(req.Payload, 0)
|
||||||
sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code()))
|
sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code()), string(in.Subtype()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,28 @@ var ErrInvalid = errors.New("vkauth: invalid vk launch params")
|
|||||||
|
|
||||||
// Identity is the user extracted from verified VK launch params. ExternalID is the
|
// Identity is the user extracted from verified VK launch params. ExternalID is the
|
||||||
// vk_user_id used as the identities external_id; Language is the vk_language hint that
|
// vk_user_id used as the identities external_id; Language is the vk_language hint that
|
||||||
// seeds a brand-new account's preferred language.
|
// seeds a brand-new account's preferred language; Platform is the raw signed
|
||||||
|
// vk_platform value (e.g. "mobile_iphone", "desktop_web"), from which Subtype derives
|
||||||
|
// the trusted device family.
|
||||||
type Identity struct {
|
type Identity struct {
|
||||||
ExternalID string
|
ExternalID string
|
||||||
Language string
|
Language string
|
||||||
|
Platform string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtype maps the signed vk_platform to the trusted device family recorded on the
|
||||||
|
// session — ios (iPhone/iPad, the store-frozen case), android, or web (desktop/mobile
|
||||||
|
// web and anything unrecognised). Because vk_platform rides inside the verified
|
||||||
|
// signature, this subtype is trustworthy, unlike a client-reported one.
|
||||||
|
func (i Identity) Subtype() string {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(i.Platform, "android"):
|
||||||
|
return "android"
|
||||||
|
case strings.Contains(i.Platform, "iphone"), strings.Contains(i.Platform, "ipad"):
|
||||||
|
return "ios"
|
||||||
|
default:
|
||||||
|
return "web"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify checks the `sign` of a VK Mini App launch query string against secret and
|
// Verify checks the `sign` of a VK Mini App launch query string against secret and
|
||||||
@@ -68,5 +86,5 @@ func Verify(params, secret string) (Identity, error) {
|
|||||||
if externalID == "" {
|
if externalID == "" {
|
||||||
return Identity{}, ErrInvalid
|
return Identity{}, ErrInvalid
|
||||||
}
|
}
|
||||||
return Identity{ExternalID: externalID, Language: values.Get("vk_language")}, nil
|
return Identity{ExternalID: externalID, Language: values.Get("vk_language"), Platform: values.Get("vk_platform")}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,33 @@ func TestVerifyValid(t *testing.T) {
|
|||||||
if id.ExternalID != "494075" || id.Language != "ru" {
|
if id.ExternalID != "494075" || id.Language != "ru" {
|
||||||
t.Fatalf("identity = %+v, want ExternalID=494075 Language=ru", id)
|
t.Fatalf("identity = %+v, want ExternalID=494075 Language=ru", id)
|
||||||
}
|
}
|
||||||
|
if id.Platform != "android" || id.Subtype() != "android" {
|
||||||
|
t.Fatalf("platform = %q subtype = %q, want android/android", id.Platform, id.Subtype())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubtype locks the vk_platform -> device-family mapping (the trusted subtype),
|
||||||
|
// notably that iPhone and iPad both surface as the store-frozen "ios" and that any
|
||||||
|
// unrecognised or empty value falls back to web.
|
||||||
|
func TestSubtype(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
platform string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"mobile_iphone", "ios"},
|
||||||
|
{"mobile_ipad", "ios"},
|
||||||
|
{"mobile_iphone_messenger", "ios"},
|
||||||
|
{"mobile_android", "android"},
|
||||||
|
{"android", "android"},
|
||||||
|
{"desktop_web", "web"},
|
||||||
|
{"mobile_web", "web"},
|
||||||
|
{"", "web"},
|
||||||
|
{"something_new", "web"},
|
||||||
|
} {
|
||||||
|
if got := (vkauth.Identity{Platform: tc.platform}).Subtype(); got != tc.want {
|
||||||
|
t.Errorf("Subtype(%q) = %q, want %q", tc.platform, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVerifyCommaValue locks the URL-encoding of the one realistic special-char VK
|
// TestVerifyCommaValue locks the URL-encoding of the one realistic special-char VK
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ table MoveRecord {
|
|||||||
table TelegramLoginRequest {
|
table TelegramLoginRequest {
|
||||||
init_data:string;
|
init_data:string;
|
||||||
browser_tz:string;
|
browser_tz:string;
|
||||||
|
// Client-reported device family (ios/android/web). Telegram's initData does not sign it, so
|
||||||
|
// the server records it best-effort and the payments gate never relies on it.
|
||||||
|
subtype:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// VKLoginRequest carries a VK Mini App launch. params is the raw query string of the
|
// VKLoginRequest carries a VK Mini App launch. params is the raw query string of the
|
||||||
@@ -128,6 +131,9 @@ table VKLoginRequest {
|
|||||||
table GuestLoginRequest {
|
table GuestLoginRequest {
|
||||||
locale:string;
|
locale:string;
|
||||||
browser_tz:string;
|
browser_tz:string;
|
||||||
|
// Client-reported device family (ios/android/web) of this direct session; best-effort
|
||||||
|
// (a direct session has no external signer).
|
||||||
|
subtype:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailRequestRequest asks the backend to send a login confirm-code to email. It
|
// EmailRequestRequest asks the backend to send a login confirm-code to email. It
|
||||||
@@ -150,6 +156,8 @@ table EmailRequestRequest {
|
|||||||
table EmailLoginRequest {
|
table EmailLoginRequest {
|
||||||
email:string;
|
email:string;
|
||||||
code:string;
|
code:string;
|
||||||
|
// Client-reported device family (ios/android/web) of this direct session; best-effort.
|
||||||
|
subtype:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailConfirmLinkRequest verifies a one-tap deeplink token from a confirmation
|
// EmailConfirmLinkRequest verifies a one-tap deeplink token from a confirmation
|
||||||
|
|||||||
@@ -57,8 +57,16 @@ func (rcv *EmailLoginRequest) Code() []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *EmailLoginRequest) Subtype() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func EmailLoginRequestStart(builder *flatbuffers.Builder) {
|
func EmailLoginRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(2)
|
builder.StartObject(3)
|
||||||
}
|
}
|
||||||
func EmailLoginRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
func EmailLoginRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
|
||||||
@@ -66,6 +74,9 @@ func EmailLoginRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.U
|
|||||||
func EmailLoginRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
|
func EmailLoginRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(code), 0)
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(code), 0)
|
||||||
}
|
}
|
||||||
|
func EmailLoginRequestAddSubtype(builder *flatbuffers.Builder, subtype flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(subtype), 0)
|
||||||
|
}
|
||||||
func EmailLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func EmailLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,16 @@ func (rcv *GuestLoginRequest) BrowserTz() []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *GuestLoginRequest) Subtype() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func GuestLoginRequestStart(builder *flatbuffers.Builder) {
|
func GuestLoginRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(2)
|
builder.StartObject(3)
|
||||||
}
|
}
|
||||||
func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers.UOffsetT) {
|
func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(locale), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(locale), 0)
|
||||||
@@ -66,6 +74,9 @@ func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers
|
|||||||
func GuestLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
func GuestLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
||||||
}
|
}
|
||||||
|
func GuestLoginRequestAddSubtype(builder *flatbuffers.Builder, subtype flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(subtype), 0)
|
||||||
|
}
|
||||||
func GuestLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func GuestLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,16 @@ func (rcv *TelegramLoginRequest) BrowserTz() []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *TelegramLoginRequest) Subtype() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TelegramLoginRequestStart(builder *flatbuffers.Builder) {
|
func TelegramLoginRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(2)
|
builder.StartObject(3)
|
||||||
}
|
}
|
||||||
func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flatbuffers.UOffsetT) {
|
func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(initData), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(initData), 0)
|
||||||
@@ -66,6 +74,9 @@ func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flat
|
|||||||
func TelegramLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
func TelegramLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
||||||
}
|
}
|
||||||
|
func TelegramLoginRequestAddSubtype(builder *flatbuffers.Builder, subtype flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(subtype), 0)
|
||||||
|
}
|
||||||
func TelegramLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func TelegramLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,15 @@ code(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subtype():string|null
|
||||||
|
subtype(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
subtype(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
static startEmailLoginRequest(builder:flatbuffers.Builder) {
|
static startEmailLoginRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(2);
|
builder.startObject(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
|
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
|
||||||
@@ -46,15 +53,20 @@ static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
|
|||||||
builder.addFieldOffset(1, codeOffset, 0);
|
builder.addFieldOffset(1, codeOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addSubtype(builder:flatbuffers.Builder, subtypeOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, subtypeOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endEmailLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endEmailLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createEmailLoginRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createEmailLoginRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset, subtypeOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
EmailLoginRequest.startEmailLoginRequest(builder);
|
EmailLoginRequest.startEmailLoginRequest(builder);
|
||||||
EmailLoginRequest.addEmail(builder, emailOffset);
|
EmailLoginRequest.addEmail(builder, emailOffset);
|
||||||
EmailLoginRequest.addCode(builder, codeOffset);
|
EmailLoginRequest.addCode(builder, codeOffset);
|
||||||
|
EmailLoginRequest.addSubtype(builder, subtypeOffset);
|
||||||
return EmailLoginRequest.endEmailLoginRequest(builder);
|
return EmailLoginRequest.endEmailLoginRequest(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,15 @@ browserTz(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subtype():string|null
|
||||||
|
subtype(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
subtype(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
static startGuestLoginRequest(builder:flatbuffers.Builder) {
|
static startGuestLoginRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(2);
|
builder.startObject(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addLocale(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset) {
|
static addLocale(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset) {
|
||||||
@@ -46,15 +53,20 @@ static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Off
|
|||||||
builder.addFieldOffset(1, browserTzOffset, 0);
|
builder.addFieldOffset(1, browserTzOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addSubtype(builder:flatbuffers.Builder, subtypeOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, subtypeOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endGuestLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endGuestLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, subtypeOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
GuestLoginRequest.startGuestLoginRequest(builder);
|
GuestLoginRequest.startGuestLoginRequest(builder);
|
||||||
GuestLoginRequest.addLocale(builder, localeOffset);
|
GuestLoginRequest.addLocale(builder, localeOffset);
|
||||||
GuestLoginRequest.addBrowserTz(builder, browserTzOffset);
|
GuestLoginRequest.addBrowserTz(builder, browserTzOffset);
|
||||||
|
GuestLoginRequest.addSubtype(builder, subtypeOffset);
|
||||||
return GuestLoginRequest.endGuestLoginRequest(builder);
|
return GuestLoginRequest.endGuestLoginRequest(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,15 @@ browserTz(optionalEncoding?:any):string|Uint8Array|null {
|
|||||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subtype():string|null
|
||||||
|
subtype(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
subtype(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
static startTelegramLoginRequest(builder:flatbuffers.Builder) {
|
static startTelegramLoginRequest(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(2);
|
builder.startObject(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addInitData(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset) {
|
static addInitData(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset) {
|
||||||
@@ -46,15 +53,20 @@ static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Off
|
|||||||
builder.addFieldOffset(1, browserTzOffset, 0);
|
builder.addFieldOffset(1, browserTzOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addSubtype(builder:flatbuffers.Builder, subtypeOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, subtypeOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endTelegramLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endTelegramLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, subtypeOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
TelegramLoginRequest.startTelegramLoginRequest(builder);
|
TelegramLoginRequest.startTelegramLoginRequest(builder);
|
||||||
TelegramLoginRequest.addInitData(builder, initDataOffset);
|
TelegramLoginRequest.addInitData(builder, initDataOffset);
|
||||||
TelegramLoginRequest.addBrowserTz(builder, browserTzOffset);
|
TelegramLoginRequest.addBrowserTz(builder, browserTzOffset);
|
||||||
|
TelegramLoginRequest.addSubtype(builder, subtypeOffset);
|
||||||
return TelegramLoginRequest.endTelegramLoginRequest(builder);
|
return TelegramLoginRequest.endTelegramLoginRequest(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,16 +110,18 @@ describe('codec', () => {
|
|||||||
|
|
||||||
it('carries the detected browser zone on every account-creating auth request', () => {
|
it('carries the detected browser zone on every account-creating auth request', () => {
|
||||||
const tg = fb.TelegramLoginRequest.getRootAsTelegramLoginRequest(
|
const tg = fb.TelegramLoginRequest.getRootAsTelegramLoginRequest(
|
||||||
new ByteBuffer(encodeTelegramLogin('init-data-blob', '+03:00')),
|
new ByteBuffer(encodeTelegramLogin('init-data-blob', '+03:00', 'ios')),
|
||||||
);
|
);
|
||||||
expect(tg.initData()).toBe('init-data-blob');
|
expect(tg.initData()).toBe('init-data-blob');
|
||||||
expect(tg.browserTz()).toBe('+03:00');
|
expect(tg.browserTz()).toBe('+03:00');
|
||||||
|
expect(tg.subtype()).toBe('ios');
|
||||||
|
|
||||||
const guest = fb.GuestLoginRequest.getRootAsGuestLoginRequest(
|
const guest = fb.GuestLoginRequest.getRootAsGuestLoginRequest(
|
||||||
new ByteBuffer(encodeGuestLogin('ru', '-05:30')),
|
new ByteBuffer(encodeGuestLogin('ru', '-05:30', 'android')),
|
||||||
);
|
);
|
||||||
expect(guest.locale()).toBe('ru');
|
expect(guest.locale()).toBe('ru');
|
||||||
expect(guest.browserTz()).toBe('-05:30');
|
expect(guest.browserTz()).toBe('-05:30');
|
||||||
|
expect(guest.subtype()).toBe('android');
|
||||||
|
|
||||||
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
|
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
|
||||||
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en', true)),
|
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en', true)),
|
||||||
|
|||||||
+17
-3
@@ -182,13 +182,19 @@ export function encodeChatPost(gameId: string, body: string): Uint8Array {
|
|||||||
return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
|
return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeTelegramLogin(initData: string, browserTz: string): Uint8Array {
|
export function encodeTelegramLogin(
|
||||||
|
initData: string,
|
||||||
|
browserTz: string,
|
||||||
|
subtype: string,
|
||||||
|
): Uint8Array {
|
||||||
const b = new Builder(512);
|
const b = new Builder(512);
|
||||||
const d = b.createString(initData);
|
const d = b.createString(initData);
|
||||||
const tz = b.createString(browserTz);
|
const tz = b.createString(browserTz);
|
||||||
|
const st = b.createString(subtype);
|
||||||
fb.TelegramLoginRequest.startTelegramLoginRequest(b);
|
fb.TelegramLoginRequest.startTelegramLoginRequest(b);
|
||||||
fb.TelegramLoginRequest.addInitData(b, d);
|
fb.TelegramLoginRequest.addInitData(b, d);
|
||||||
fb.TelegramLoginRequest.addBrowserTz(b, tz);
|
fb.TelegramLoginRequest.addBrowserTz(b, tz);
|
||||||
|
fb.TelegramLoginRequest.addSubtype(b, st);
|
||||||
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
|
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,13 +210,19 @@ export function encodeVKLogin(params: string, browserTz: string, displayName: st
|
|||||||
return finish(b, fb.VKLoginRequest.endVKLoginRequest(b));
|
return finish(b, fb.VKLoginRequest.endVKLoginRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array {
|
export function encodeGuestLogin(
|
||||||
|
locale: string,
|
||||||
|
browserTz: string,
|
||||||
|
subtype: string,
|
||||||
|
): Uint8Array {
|
||||||
const b = new Builder(64);
|
const b = new Builder(64);
|
||||||
const l = b.createString(locale);
|
const l = b.createString(locale);
|
||||||
const tz = b.createString(browserTz);
|
const tz = b.createString(browserTz);
|
||||||
|
const st = b.createString(subtype);
|
||||||
fb.GuestLoginRequest.startGuestLoginRequest(b);
|
fb.GuestLoginRequest.startGuestLoginRequest(b);
|
||||||
fb.GuestLoginRequest.addLocale(b, l);
|
fb.GuestLoginRequest.addLocale(b, l);
|
||||||
fb.GuestLoginRequest.addBrowserTz(b, tz);
|
fb.GuestLoginRequest.addBrowserTz(b, tz);
|
||||||
|
fb.GuestLoginRequest.addSubtype(b, st);
|
||||||
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
|
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,13 +244,15 @@ export function encodeEmailRequest(
|
|||||||
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
|
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeEmailLogin(email: string, code: string): Uint8Array {
|
export function encodeEmailLogin(email: string, code: string, subtype: string): Uint8Array {
|
||||||
const b = new Builder(128);
|
const b = new Builder(128);
|
||||||
const e = b.createString(email);
|
const e = b.createString(email);
|
||||||
const c = b.createString(code);
|
const c = b.createString(code);
|
||||||
|
const st = b.createString(subtype);
|
||||||
fb.EmailLoginRequest.startEmailLoginRequest(b);
|
fb.EmailLoginRequest.startEmailLoginRequest(b);
|
||||||
fb.EmailLoginRequest.addEmail(b, e);
|
fb.EmailLoginRequest.addEmail(b, e);
|
||||||
fb.EmailLoginRequest.addCode(b, c);
|
fb.EmailLoginRequest.addCode(b, c);
|
||||||
|
fb.EmailLoginRequest.addSubtype(b, st);
|
||||||
return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
|
return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { normalizeSubtype } from './platform';
|
||||||
|
|
||||||
|
describe('normalizeSubtype', () => {
|
||||||
|
it('maps iPhone and iPad variants to the store-frozen ios', () => {
|
||||||
|
expect(normalizeSubtype('ios')).toBe('ios');
|
||||||
|
expect(normalizeSubtype('iphone')).toBe('ios');
|
||||||
|
expect(normalizeSubtype('mobile_iphone')).toBe('ios');
|
||||||
|
expect(normalizeSubtype('mobile_ipad')).toBe('ios');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps android variants to android', () => {
|
||||||
|
expect(normalizeSubtype('android')).toBe('android');
|
||||||
|
expect(normalizeSubtype('mobile_android')).toBe('android');
|
||||||
|
expect(normalizeSubtype('android_x')).toBe('android');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive', () => {
|
||||||
|
expect(normalizeSubtype('IPhone')).toBe('ios');
|
||||||
|
expect(normalizeSubtype('Android')).toBe('android');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults desktop, tdesktop, web, empty and unknown values to web', () => {
|
||||||
|
expect(normalizeSubtype('web')).toBe('web');
|
||||||
|
expect(normalizeSubtype('mobile_web')).toBe('web');
|
||||||
|
expect(normalizeSubtype('desktop_web')).toBe('web');
|
||||||
|
expect(normalizeSubtype('tdesktop')).toBe('web');
|
||||||
|
expect(normalizeSubtype('')).toBe('web');
|
||||||
|
expect(normalizeSubtype('something_new')).toBe('web');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Platform subtype (device family) derivation for the trusted platform signal. The
|
||||||
|
// server records the wrapper kind (vk / telegram / direct) itself, from the validated
|
||||||
|
// establish path; the client supplies only this device subtype. For VK it is ignored
|
||||||
|
// server-side (the gateway derives a trusted subtype from the signed vk_platform); for
|
||||||
|
// Telegram and a direct (web/native) session it is client-reported and best-effort.
|
||||||
|
|
||||||
|
import { insideTelegram, telegramPlatform } from './telegram';
|
||||||
|
import { clientChannel } from './channel';
|
||||||
|
|
||||||
|
export type Subtype = 'ios' | 'android' | 'web';
|
||||||
|
|
||||||
|
// normalizeSubtype coerces a raw device string (Telegram's WebApp.platform, a
|
||||||
|
// Capacitor platform, VK's vk_platform, …) to the ios/android/web wire subtype,
|
||||||
|
// mapping any iPhone/iPad variant to ios and defaulting anything unrecognised —
|
||||||
|
// desktop, tdesktop, an empty value — to web.
|
||||||
|
export function normalizeSubtype(raw: string): Subtype {
|
||||||
|
const s = raw.toLowerCase();
|
||||||
|
if (s.includes('android')) return 'android';
|
||||||
|
if (s.includes('iphone') || s.includes('ipad') || s === 'ios') return 'ios';
|
||||||
|
return 'web';
|
||||||
|
}
|
||||||
|
|
||||||
|
// platformSubtype reports this client's best-effort device family for the current
|
||||||
|
// launch: Telegram's reported platform inside a Mini App, otherwise the Capacitor /
|
||||||
|
// web channel for a direct session. VK does not use it (server-derived from the signed
|
||||||
|
// launch params).
|
||||||
|
export function platformSubtype(): Subtype {
|
||||||
|
if (insideTelegram()) return normalizeSubtype(telegramPlatform());
|
||||||
|
return normalizeSubtype(clientChannel());
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { Gateway } from '../gen/edge/v1/edge_pb';
|
|||||||
import { GatewayError, type GatewayClient } from './client';
|
import { GatewayError, type GatewayClient } from './client';
|
||||||
import * as codec from './codec';
|
import * as codec from './codec';
|
||||||
import { browserOffset } from './profileValidation';
|
import { browserOffset } from './profileValidation';
|
||||||
|
import { platformSubtype } from './platform';
|
||||||
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
||||||
import { offlineMode } from './offline.svelte';
|
import { offlineMode } from './offline.svelte';
|
||||||
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
||||||
@@ -124,13 +125,13 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async authTelegram(initData) {
|
async authTelegram(initData) {
|
||||||
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset())));
|
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset(), platformSubtype())));
|
||||||
},
|
},
|
||||||
async authVK(params, displayName) {
|
async authVK(params, displayName) {
|
||||||
return codec.decodeSession(await exec('auth.vk', codec.encodeVKLogin(params, browserOffset(), displayName)));
|
return codec.decodeSession(await exec('auth.vk', codec.encodeVKLogin(params, browserOffset(), displayName)));
|
||||||
},
|
},
|
||||||
async authGuest(locale) {
|
async authGuest(locale) {
|
||||||
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset(), platformSubtype())));
|
||||||
},
|
},
|
||||||
async authEmailRequest(email, language, pwa) {
|
async authEmailRequest(email, language, pwa) {
|
||||||
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language, pwa));
|
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language, pwa));
|
||||||
@@ -139,7 +140,7 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token)));
|
return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token)));
|
||||||
},
|
},
|
||||||
async authEmailLogin(email, code) {
|
async authEmailLogin(email, code) {
|
||||||
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));
|
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code, platformSubtype())));
|
||||||
},
|
},
|
||||||
|
|
||||||
async profileGet() {
|
async profileGet() {
|
||||||
|
|||||||
Reference in New Issue
Block a user