Compare commits

..

79 Commits

Author SHA1 Message Date
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 3ce460f72a Merge pull request 'feat(edge): community IP blocklist (Spamhaus DROP) at the gateway' (#246) from feature/edge-blocklist into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 24s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m11s
CI / deploy (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
2026-07-11 11:39:47 +00:00
Ilia Denisov 4ba9da6721 feat(edge): community IP blocklist (Spamhaus DROP) at the gateway
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
Refuse a client whose IP is in a curated CIDR feed with 403 in the same
abuseGuard, before the fail2ban ban. Prod-only (keys by real client IP), off by
default.

- ratelimit.Blocklist: a sorted-range IPv4 matcher (binary search) with an
  allowlist checked first; ParseDROP reads the feed; ApplyRefresh keeps the
  last-good feed on a transient fetch failure and drops it fail-open once stale
  (better to under-block than block a legitimate client on a frozen feed). A
  separate static CIDR set, not the per-IP fail2ban store.
- gateway: a refresher goroutine re-fetches every few hours (bounded fetch + size
  cap); config GATEWAY_BLOCKLIST_{ENABLED,URL,ALLOW,REFRESH,MAX_STALENESS}.
  IPv6 is not matched (a v6 client is still covered by fail2ban / the honeypot).
- observability: gateway_blocklist_blocked_total + entries/age gauges; a Grafana
  alert warns before the feed is dropped; service-overview panels.
- deploy: compose env + write-prod-env.sh + prod-deploy/rollback wiring (opt-in
  via PROD_ vars).
- docs (ARCHITECTURE, deploy/README); unit tests (match, allowlist, IPv6 skip,
  parser, fault-tolerance) + a 403 abuse-guard integration test.
2026-07-11 13:33:28 +02:00
developer ad1cc361e9 Merge pull request 'feat(bot): Telegram bot Bot API health over the bot-link (metrics + alerts)' (#245) from feature/bot-health-telemetry into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-11 10:58:01 +00:00
Ilia Denisov 2c2316fb5e chore(catalog): order the admin catalog list like the public offer
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Sales (chip packs) first, ascending by rouble price; then the chip-exchange
values, grouped and price-sorted the same way projectOfferPricing lists them, so
the /catalog console mirrors what a buyer sees. Internal cosmetics only — no
product behaviour change. The value-group order moves to a shared helper
(valueGroup) so the offer and the admin list cannot drift.
2026-07-11 12:53:37 +02:00
Ilia Denisov bb71e7b1c7 feat(bot): report Telegram bot Bot API health to the gateway over the bot-link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
The bot runs on its own host and exports no telemetry (otelcol is unreachable
from there), so it was a monitoring blind spot. Observe its Bot API health
centrally by wrapping the HTTP client — one place, no per-call-site
instrumentation — and relay it up the existing bot-link as a periodic Health
message the gateway turns into its own metrics.

- proto: add Health (delta connect / api / 429 counters + a last-ok stamp) to
  the FromBot oneof (additive, backward-compatible).
- bot: platform/telegram/internal/health wraps the Bot API HTTP client — it
  stamps liveness on any 2xx (so the getUpdates long-poll keeps it fresh even
  when idle), classifies transport/5xx failures (getUpdates vs other) and 429s,
  and honours a 429's Retry-After (bounded) so the bot backs off; a 4xx other
  than 429 is a normal per-request outcome and is not counted.
- bot-link client: flush the reporter as a Health message every 30s over a
  single-sender loop (a gRPC stream forbids concurrent Send).
- gateway: fold each report into bot_tg_errors_total{kind} and the
  bot_tg_last_ok_unix liveness gauge.
- grafana: alerts (bot disconnected, bot not reaching the Bot API, sustained
  429s) routed to the operator email — which does not go through the bot — plus
  a Telegram-bot dashboard.
- docs (ARCHITECTURE, compose comments); unit tests (observer classification,
  Retry-After, snapshot/commit, hub last-ok monotonicity).
2026-07-11 12:48:06 +02:00
developer 5c1f64c7d1 Merge pull request 'feat(offer): live catalog price list in the public offer (render sidecar)' (#244) from feature/offer-pricing-live into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m48s
2026-07-11 09:51:48 +00:00
Ilia Denisov b3cf024e9e chore(offer): fix typo
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
2026-07-11 11:45:45 +02:00
Ilia Denisov 6e120bdaa7 docs(offer): update phrasing
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m24s
2026-07-11 11:39:43 +02:00
Ilia Denisov 40acbcccdd feat(offer): sort and align the §4.4 price tables, style them for both themes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
- packs sorted by ascending rouble price;
- values grouped hints-only -> no-ads-only -> no-ads+hints -> tournament
  (the tournament group is empty until such products become sellable),
  ascending chip price within each group;
- price columns right-aligned (GFM "---:" separators);
- tables span the full content width; the name column shrinks to its content
  and never wraps;
- muted-but-visible cell borders on the dark theme, where the section rule
  colour blends into the background.
2026-07-11 11:29:41 +02:00
Ilia Denisov b54371845f harden(offer): escape admin product titles against HTML/markdown injection
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
The offer price list projects the admin-entered product title into a markdown
table cell that is rendered, unsanitised, into the public /offer/ page. Escape
the title at the projection boundary so it renders as literal text: HTML
metacharacters become entities and the markdown table pipe and link brackets are
escaped, so a title can neither inject markup nor form a javascript: link. The
committed offer prose keeps its trusted-content treatment (code-reviewed, not
runtime input).
2026-07-10 20:30:36 +02:00
Ilia Denisov b6c2598710 feat(offer): live catalog price list in the public offer, served by the render sidecar
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 16m19s
Robokassa moderation requires the public offer to list every digital good with
its price. Move /offer/ off the static landing container to the render sidecar:
it splices the live catalog price list (§4.4) into the owner-edited
ui/legal/offer_ru.md and renders it with the shared ui/src/lib/offer.ts — one
renderer, no drift, always matching the current catalog with no redeploy.

- backend: /api/v1/internal/offer/pricing (internal, off the edge allow-list)
  projects the active catalog into two markdown tables — chip packs priced per
  rail (roubles / VK votes / Telegram Stars) and chip-priced values — through
  payments.Money so no float reaches the page. Cached in memory: warmed at boot,
  marked stale on every catalog mutation, so a served render issues no query.
- renderer: GET /offer/ fetches the tables and substitutes them at the
  <#pricing_template#> marker, then renders; offer_ru.md is baked into the image
  and marked is bundled from ui. GET /offer -> 301. Only /offer/ is edge-exposed.
- caddy: route /offer/ to the sidecar; drop the now-dead landing /offer/
  handlers and the vite emit-offer plugin.
- offer: fill §4.3 (the chip-payment wording) and drop the in-page back link.
- landing footer: a feedback link (the offer's Telegram contact) beside the
  offer link.
- docs (ARCHITECTURE, FUNCTIONAL +_ru, renderer README), CI /offer/ probe,
  unit + integration + node tests.
2026-07-10 20:25:41 +02:00
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer a241e43d79 Merge pull request 'Wallet redesign: split buy/spend, compact balance, exchange confirm + insufficient-chips fix' (#242) from feature/wallet-redesign into development
CI / changes (push) Successful in 2s
CI / deploy (push) Successful in 1m56s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m12s
CI / deploy (pull_request) Has been skipped
2026-07-10 16:00:54 +00:00
Ilia Denisov 8ce986922a feat(ui): wallet storefront redesign — split buy/spend, compact balance, exchange confirm
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
Reworks the Wallet screen to be more compact and to separate the two flows, and fixes a
reported bug.

Bug: buying a value with too few chips showed the generic "Something went wrong" toast — the
backend's `insufficient_chips` (409) code was propagated correctly but had no i18n mapping, so
`errorKey` fell back to `error.generic`. Add `error.insufficient_chips` / `error.product_not_found`.
Also disable a value's Exchange button up front when the spendable balance cannot cover it (the
server stays authoritative).

Redesign:
- a compact **balance** row leads the screen — the context's own chips (🪙 N) then any linked
  other-platform chips behind that platform's logo (monospaced digits); inside a VK/TG store only
  that store's own segment shows;
- the benefits section is renamed **Active**, moved above the store, shows one inline line
  (hints + ad-free) and hides when nothing is active;
- the **store** splits by a two-way toggle into **Buy chips** (money packs + the watch-an-ad row at
  the top) and **Spend chips** (values, exchanged for chips);
- a value's action reads **Exchange** and opens a single confirmation dialog (chip spends are
  instant, with no provider window to confirm them); the existing cross-platform store-compliance
  warning folds into that same dialog when the spend would draw VK/TG chips.

No payments-model or wire change; the money→chips→values model is unchanged (verified: money buys
only chip packs, values are chips-only — "no-ads for money" is structurally impossible).

Tests: wallet e2e updated for the new layout (chromium + webkit green). Docs: FUNCTIONAL (+_ru)
wallet story rewritten. App bundle budget 126 → 127 KB (always-loaded settings hub).
2026-07-10 17:46:22 +02:00
developer 3469278260 Merge pull request 'fix(monitoring): don't false-fire the WAL-stalled alert on an idle database' (#241) from fix/pg-archive-stalled-idle-falsepositive into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 25s
CI / ui (push) Successful in 1m12s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m38s
2026-07-10 14:31:57 +00:00
Ilia Denisov c66bf1eceb fix(monitoring): pg_archive_stalled must not false-fire on an idle database
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
The "WAL archiving stalled" alert fired on prod during a quiet period. An idle Postgres
archives nothing — it never force-switches an empty WAL segment on archive_timeout — so
pg_stat_archiver_last_archive_age grows past 30 min even though archiving is perfectly
healthy (failed_count 0, no .ready segments, pg_wal flat, backups current). A false
positive: no data and no disk at risk (nothing was written, so the frozen recovery point
equals the live state).

Gate the age condition on pg_wal actually growing:

  (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16MB)

so it fires only when WAL is being produced but not archived (the real "pg_wal fills the
disk" danger; genuine archive_command failures are already caught by pg_archive_failing).
`and on()` bridges the two metrics' differing label sets (last_archive_age carries a server
label, the pg_wal gauge does not); delta() (not increase) suits the gauge. pg_stat_wal is
not exported by this postgres_exporter, so pg_wal size growth is the available signal.
Validated on prod with promtool: the expression is empty while idle.

Also fix the runbook command in both archive alerts' annotations: `pgbackrest check` needs
`--pg1-user=scrabble`, or it connects as role "root" (which does not exist) and aborts with
"no database found".
2026-07-10 16:25:01 +02:00
developer 7b4d2421e2 Merge pull request 'In-game UX: board highlight + score badge, full-width rack, bag badge, zoom setting' (#240) from feature/ingame-ux-board-highlight into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m49s
2026-07-10 14:06:20 +00:00
Ilia Denisov bf46b9492d fix(ui): one-word games must not highlight phantom cross words; review polish
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m25s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
Board-highlight bug (reported on the contour): formedGeometry walked cross words
unconditionally, so in a single-word (one-word-per-turn) game a staged tile sitting
next to a committed tile lit up a green "cross word" the engine ignores — and which
need not even be a real word (the reported "БО lights up green in a one-word ПОПА
play"). Gate the cross-word walk on the game's multipleWordsPerTurn flag. The score
(8) was already correct — a premium square under the main word.

Also, from review:
- the turn strip reads the staged play's "WORD+WORD = N" while composing a legal move,
  reverting to the turn / result text otherwise;
- the Exchange/Pass dialog shows the bag count ("In the bag: N" / "Bag is empty")
  right-aligned in the title row, via a new optional Modal `titleAside`;
- cosmetics: half the turn strip's bottom padding (the plaques below carry their own
  top pad); a top gap above the landscape rack (it sat flush under the docked history);
  more horizontal padding on tab count badges so a 2-3 digit bag count clears the pill
  ends;
- admin console: the game Summary now shows the single-word / multiple-words rule.

Tests: formed single-word case added; full unit (584) + e2e (chromium + webkit, 113
each) green; backend build + adminconsole templates parse. Docs (FUNCTIONAL +_ru,
UI_DESIGN) updated.
2026-07-10 15:58:05 +02:00
Ilia Denisov 1e1117c28e chore(ui): raise the app bundle budget to 126 KB for the in-game board UX
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
2026-07-10 15:03:30 +02:00
Ilia Denisov 77a690fcf6 feat(ui): move in-game status to the board — highlight, score badge, full-width rack
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Remove the under-board status strip and relocate its signals:
- bag count -> a badge on the exchange/pass control + the foot of the move table
- whose-turn / win-lose -> a thin strip above the score plaques
- the tentative-move caption -> the board itself: staged tiles tint green (legal) or
  pink (illegal), the board tiles a formed word runs through go a shade darker, and an
  orange score badge sits on the main word (digit sized like a tile value, clamped on-board)

The word geometry (covered cells + badge anchor) is a new pure client-side helper
(ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or
network preview path alike; the badge's number still comes from the preview score.

Rack: a seven-column grid filling the tray width in both layouts — square, full-width
tiles — with the confirm control in the fixed 7th slot.

Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the
tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted
hint word is never left off-screen.

Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated.
2026-07-10 14:58:32 +02:00
developer 0ca01133b5 Merge pull request 'Release v1.14.1 — ansible certs-dir fix + Robokassa go-live' (#239) from development into master 2026-07-10 10:58:38 +00:00
developer 2683103fc1 Merge pull request 'fix(deploy): provision the certs dir traversable by the nonroot gateway' (#238) from fix/ansible-certs-dir-traversable into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-10 10:39:06 +00:00
Ilia Denisov 2d2dd2bc47 fix(deploy): provision the certs dir traversable by the nonroot gateway
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The Ansible base-directory loop created /opt/scrabble/certs at mode 0750
(deploy:deploy), like config/dumps/images. The gateway (and backend) run as
the distroless nonroot UID 65532 — not the deploy user — so the container
cannot traverse a 0750 certs dir and fails at startup with
"mtls: load server keypair: ... permission denied", crash-looping.

This is latent: a long-running container holds the keypair in memory and
never re-reads the file, so the misconfig only bites when a container
restarts (a host reboot / redeploy). A hoster maintenance reboot exposed it
on prod — the gateway came back crash-looping while the deploy could not SSH
in mid-reboot.

Split certs out of the 0750 loop and create it 0755 (traversable). The keys
stay 0644 by design (the gateway compose relies on it); the host is
single-tenant + SSH-access-controlled, so a traversable certs dir adds no
meaningful exposure. The live prod host was already chmod-fixed by hand; this
keeps the next provisioning run from re-tightening it.
2026-07-10 12:34:21 +02:00
developer 45f0b34881 Merge pull request 'Release v1.14.0 — monetization launch (E5-E8)' (#237) from development into master 2026-07-10 10:11:02 +00:00
developer df9eace09f Merge pull request 'fix(deploy): wire the Robokassa direct rail into the prod deploy + rollback' (#236) from fix/prod-robokassa-wiring into development
CI / changes (push) Successful in 2s
CI / integration (push) Successful in 20s
CI / conformance (push) Successful in 10s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 11s
CI / ui (push) Successful in 1m10s
CI / gate (push) Successful in 0s
CI / ui (pull_request) Successful in 1m10s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-10 10:04:03 +00:00
Ilia Denisov 0a0a9e5a8d fix(deploy): wire the Robokassa direct rail into the prod deploy + rollback
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 27s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The Robokassa credentials reached only the test contour (ci.yaml → TEST_
secrets). The prod-deploy / prod-rollback workflows and write-prod-env.sh
never rendered BACKEND_ROBOKASSA_*, so on prod the shop login was empty and
the direct RUB rail stayed disabled — the PROD_BACKEND_ROBOKASSA_* secrets
went nowhere.

Export the shop login + Password1/Password2 (secrets) and a
BACKEND_ROBOKASSA_TEST flag (a variable, so go-live is a flag flip not a
secret rotation) from both prod workflows, and emit the four ROBOKASSA_*
vars from write-prod-env.sh (the shared deploy/rollback env renderer) so a
rollback keeps the rail up. The compose already maps ROBOKASSA_* →
BACKEND_ROBOKASSA_*. Document the new secrets/variable in the deploy README
and .env.example. Password3 (Robokassa's JWT-invoice API) is unused.
2026-07-10 11:59:20 +02:00
developer 0c9678c42b Merge pull request 'feat(ui): per-kind active-game limit lock on the New Game screen' (#235) from feature/e8-guest-limits-client into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 28s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
2026-07-10 09:11:40 +00:00
Ilia Denisov e40adfb0c7 fix(games): carry game kind on live events + fix the New Game lock funnel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
Address review of the active-game limit lock:

- Live-event GameViews (opponent_moved, match_found, game_started, ...) did
  not carry game_kind, so a lobby patch from an event zeroed a game's kind
  and the client's per-kind count under-counted — a capped kind read as
  free (a disabled start instead of the lock, and a wrong per-kind result).
  Thread Kind through notify.GameSummary → the event GameView.

- New Game screen refreshes the lobby games on mount so the per-kind count
  reflects the current set, not a stale cached snapshot.

- The guest funnel's login button routes to the profile screen (the account
  controls), not settings.

- Copy: the guest prompt is "sign in to use all the game's features"; the
  durable notice is "finish your active games to start a new one".

Regression tests: the notify opponent-moved payload carries kind; the client
lock locks only the reached kind (vs_ai at cap leaves random open).
2026-07-10 10:55:48 +02:00
Ilia Denisov 3306a016a0 feat(ui): per-kind active-game limit lock on the New Game screen
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 28s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
Carry the caller's per-tier active-game caps and each game's kind on the
wire (Profile.game_limits + GameView.kind, additive FBS + gateway transcode
+ client codec, committed regen). The New Game screen counts the player's
active games per kind from the lobby and locks a capped start: an outline
button with a lock that opens a funnel modal instead of a game -- a sign-in
prompt for a guest, a "finish a current game first" notice for a signed-in
account (native Telegram popup, in-app modal elsewhere). The lock lifts via
the existing profile refetch after a guest->durable upgrade.

Remove the lobby's old at_game_limit New-Game tab disable + notice: the flag
(now the random-kind cap) conflicted with the per-kind lock -- it hid the
screen where the lock lives and wrongly blocked an unfulfilled kind. The New
Game tab is always enabled; the per-kind start lock is the only gate. The
at_game_limit wire field stays (unused by the client) for a later cleanup.

Tests: client lock logic + codec kind/game_limits roundtrip + gateway
transcode encode + native popup builders (unit); a mock e2e for the lock
badge and the modal.
2026-07-10 10:09:45 +02:00
developer e45167041f Merge pull request 'feat(backend): per-tier, per-kind active-game limits with a guest funnel' (#234) from feature/e8-guest-limits into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 22s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m56s
2026-07-10 07:17:54 +00:00
Ilia Denisov ed53e25e57 feat(backend): per-tier, per-kind active-game limits with a guest funnel
CI / changes (pull_request) Successful in 5s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m41s
Cap a player's simultaneous unfinished games per kind (vs_ai, random,
friends) with independent guest and durable-account tiers, held in a new
single-row backend.config table (-1 = unlimited) behind an in-memory cache
and editable live in the admin console (/_gm/limits). Each game is tagged
with games.game_kind on creation.

This replaces the earlier flat MaxActiveQuickGames=10 combined cap: the
per-tier/kind config is the single mechanism, enforced at the same handler
gate (ensureUnderGameLimit by kind on lobby/enqueue) plus the durable
friends cap in CreateInvitation. game.Service.AtGameLimit only resolves the
tier and counts; the limit policy stays at the request edge.

Guests are now refused friend requests, friend-code redemption,
befriend-in-game and invitation creation outright (403 guest_forbidden) --
previously only the UI hid these.

Admin: a kind column in both game lists and the config editor.

Defaults: guest 1 vs_ai / 1 random / 0 friends; durable 10 / 10 / 10.
2026-07-10 09:03:57 +02:00
developer 2e5136b22a Merge pull request 'feat(admin): manual full-order refund + ledger CSV export' (#233) from feature/e7-refund-export into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m5s
2026-07-10 04:52:49 +00:00
Ilia Denisov 1bf612a087 feat(admin): manual full-order refund + ledger CSV export
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Closes the admin / reports / catalog work. Each fund row on the /_gm finance
panel gains a Refund action (payments.RefundOrderFull): a full-order refund the
operator records after refunding on the rail — a refund ledger row + a floor-0
chip revoke (never negative, D27), idempotent (a second refund reports
already-refunded). A ledger CSV export (/_gm/ledger.csv, payments.LedgerExport)
streams the whole append-only ledger for tax + reconciliation.

Tests: refund an order in full (chips revoked, a refund row), an idempotent
second refund, the CSV export shape; CSRF-guarded.
2026-07-10 06:46:18 +02:00
developer ec5c6afa23 Merge pull request 'feat(admin): admin grant — raw benefits and by-product reward bundles' (#232) from feature/e7-admin-grant into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-10 04:37:43 +00:00
Ilia Denisov 82648a4398 fix(game): show granted/bought hints in-game — finish the D31 wire removal
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
The in-game hint badge ignored the payments hint wallet: loading a game clobbered
app.profile.hintBalance with the deprecated StateView.wallet_balance (zeroed in the
D31 domain removal but left in the protocol as a dead 0, then synced over the real
balance at game load).

Finish the removal honestly. StateView carries the per-game allowance alone
(hints_remaining); the purchasable wallet lives solely on the profile and the client
adds it (lib/hints). Removed StateView.wallet_balance across every layer — backend
StateView/DTO, notify.PlayerState (live events), gateway StateResp + wire.StateView,
the client model/codec/mock/localgame — and dropped the now-unused wallet arg from
hintsRemaining. The FBS field is tombstoned `(deprecated)` (not deleted) so the vtable
slots after it stay stable across a rolling deploy; no accessor is generated. HintResult
keeps wallet_balance (the real post-spend payments balance the client adopts into the
profile). The StateView type no longer has walletBalance, so the clobber cannot return
without a compile error.

Tests: hintsRemaining (2-arg); hints.hintsLeft (allowance + live wallet, no strip); the
game state/hint integration (allowance-only HintsRemaining); gateway transcode; FBS regen.
2026-07-10 06:26:49 +02:00
Ilia Denisov d2d6955cbf feat(admin): admin grant — raw benefits and by-product reward bundles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
The /_gm user card gains a Grant panel: grant raw benefit atoms (hints /
no-ads days / forever) or a defined value product (a reward bundle, including
an archived one), origin-picked. Both write an admin_grant ledger row via
payments.Grant / GrantProduct; the by-product grant records the source
product_id + snapshot. Both refuse a chips atom (never grant currency) or a
tournament atom (no credit target yet); chips/tournament products are also
kept out of the by-product picker.

Tests: the console grant end to end (raw, by-product, refuse a chips pack,
CSRF-guarded).
2026-07-10 05:35:55 +02:00
developer c3eecf16b3 Merge pull request 'feat(admin): product catalog editor' (#231) from feature/e7-catalog-editor into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 22s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-10 03:26:26 +00:00
Ilia Denisov 0036b55618 feat(admin): product catalog editor
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The /_gm console gains a Catalog editor — the source of truth for products.
Create / edit / archive-unarchive products, their atom composition and per-rail
prices (RUB via direct / VOTE via vk / XTR via telegram / CHIP value), and
hard-delete only a never-transacted product (an order or ledger reference forces
archive-only, backed by the FK RESTRICT). The archived flag reuses the existing
product.active. Activation revalidates the sellable shape — a pack (the chips
atom ⇒ a money price per rail, chips-only) or a value (no chips ⇒ a CHIP price);
a tournament-bearing product is composable but never sellable yet.

Backed by payments AdminCatalog / CreateProduct / UpdateProduct /
SetProductActive / DeleteProduct + a pure validateProduct.

Tests: validateProduct (pack / value / tournament / duplicate / shape); the
console editor end to end (create, edit, archive, delete-if-clean, refuse a
transacted delete).
2026-07-10 05:18:54 +02:00
developer aabb32081e Merge pull request 'feat(admin): per-user finance panel — balances, benefits, risk, ledger' (#230) from feature/e7-financial-panel into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-10 03:03:45 +00:00
Ilia Denisov e089a0a997 feat(admin): per-user finance panel — balances, benefits, risk, ledger
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 28s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
The /_gm user card gains a Finance panel: an account's chip balances per
funding segment, benefits per origin (hints, no-ads until/forever), the
recorded refund risk (abuse flag + floor-0 loss), and the append-only ledger
history newest-first. Backed by a new payments.AccountStatement read straight
from the materialized tables + the ledger (uncached — an admin, rare view).

Folds the agreed admin / reports / catalog plan into PLAN.md (the PR stack:
this panel, then the catalog editor, admin grant, refund + ledger export) and
moves the tournament-entry storage design to the tournament stage.
2026-07-10 04:55:55 +02:00
developer 00d1bd33e3 Merge pull request 'feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account' (#229) from feature/ads-interstitial into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
2026-07-10 01:48:10 +00:00
Ilia Denisov 7ce8101cfa test(ads): lock the hint-pushed ad restarting the vs_ai gap (no over-serving)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
2026-07-10 03:43:57 +02:00
Ilia Denisov b5c8a04f0b fix(ads): share one interstitial cooldown timer across kinds
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Cooldowns were tracked per kind ({move,hint}), so a hint ad and a move ad
did not see each other: after a hinted move's ad the move timer was still
zero, and the next plain move fired an ad immediately — two ads within a
minute, under the cooldown. Track a single shared last-shown time; the kind
only selects the required gap (hint 1m, move 5m, vs_ai 30m) measured from the
last interstitial of any kind. A hint can still fire on its shorter gap
(D30's "independent" hint cooldown), but never stacks a second ad within a
cooldown.

Tests: a hint uses the shorter shared gap; a move does not stack onto a
just-shown hint ad.
2026-07-10 03:36:18 +02:00
Ilia Denisov 2ce80c241d fix(ads): fire the hint interstitial on the confirmed move, not on the hint
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Taking a hint fired the post-move interstitial immediately, when the hint's
preview tiles landed — interrupting the turn and reverting the board to the
rack on the ad's close while the hint stayed spent. Move the trigger to the
move confirmation: a hint applied this turn marks it (hintUsedThisTurn), and
the confirmed play fires the hint-kind interstitial (its own cooldown)
instead of the plain move one; the marker clears on any turn boundary
(applyMoveResult) so a hint-then-pass does not leak into the next move.

e2e: taking a hint fires no ad; confirming a move does.
2026-07-10 03:12:30 +02:00
Ilia Denisov 13be7c3d9a feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Interstitial video after a confirmed play or a hint, VK-only, offline
banner-only. The gate is client-mirrored: the backend puts the config
cooldowns (global 5m / vs_ai 30m / hint 1m) and a suppressed flag (the
no-ads / no_banner gate, same as the banner) on Profile.ads via adsFor;
the client self-gates on a per-kind last-shown time in localStorage, with
the VITE_ADS_STUB contour "ad fired" toast. Never fires after a pass,
exchange or resign. maybeShowInterstitial + vkShowInterstitial; the codec
and gateway transcode carry the ads block.

Also retires the deprecated accounts.hint_balance / paid_account domain
usage (expand-contract, code only — the columns stay for a later DROP so
image rollback stays DB-safe): drop the Account fields and their scan, the
dead account.SpendHint, account.GrantHints and the admin grant-hints
action; the in-game hint display now comes wholly from the payments hint
benefit. Banner eligibility and account merge no longer read the legacy
flags.

Tests: ads.test.ts (the client-mirrored gate), the codec ads round-trip,
the gateway ads transcode test, and a profile ads-config integration test
(cooldowns + suppressed under no-ads / no_banner). Docs: PAYMENTS §10 (+ru)
interstitial, the decision amends, backend README, the plan.
2026-07-10 02:48:10 +02:00
developer e08d3301bd Merge pull request 'feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps' (#228) from feature/ads-rewarded into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
2026-07-09 23:33:10 +00:00
Ilia Denisov 9acf6ab3b4 fix(payments): VK-iOS freeze is purchase-only; remove the rewarded diagnostic
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m5s
Testing the rewarded slice surfaced a contradiction: rewarded ads let a VK-iOS
user earn vk chips, but the blanket VK-iOS "spend freeze" then blocked spending
them (the wallet showed "5 (view-only)"). Apple's ToS forbids only BUYING in-app
values on VK-iOS (money -> chips), not spending or earning them — so the freeze is
corrected to purchase-only: vkFrozen() now gates only CreateOrder (the money-in
step), not spendableSources (spending). VK-wallet chips — earned via rewarded ads
or bought on the same account elsewhere (VK Android) — now spend on VK-iOS too.
(Owner's ToS finding.)

Also: the temporary contour diagnostic confirmed VK returns only {result:true} for
a rewarded view (no token/signature) — client-attested is final, no hardening
possible — so the diagnostic (the log and the diag wire field) is removed.

Docs: PAYMENTS(+ru) VK-iOS freeze + D17 amend + PLAN E6. Tests updated (gate /
wallet VK-iOS now spendable).
2026-07-10 01:27:26 +02:00
Ilia Denisov dbd76d53e8 feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The first ads slice: a voluntary rewarded video credits chips. VK Mini App ads
(VKWebAppShowNativeAds) expose only a client-side watch result — no
server-to-server verify — so the credit is client-attested, guarded by a server
daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10).
The caps are both anti-abuse (bounding a forger who skips the ad and calls the
endpoint directly) and an economic conversion lever (limiting free chips so a
player who wants more buys). D29 amended to VK's reality.

Backend: CreditReward (VK-only, order-less, idempotent on a client nonce, floored
by the caps; payout from config rewarded_payout_chips, default 0 = off) + the
wallet.reward edge op returning the updated wallet (reward_chips gates the
"watch for chips" CTA). Additive migration (two config columns).

Client: the ads-network abstraction (lib/ads.ts, VK impl) + the VK bridge
(vkRewardedReady / vkShowRewarded) + the Wallet CTA + i18n. A contour test stub
(VITE_ADS_STUB -> a toast instead of a real ad; prod always real) and a temporary
diagnostic that logs the raw VK data, so we confirm on the contour exactly what
VK returns (harden to signature-verify if it carries one).

Tests: backend integration (credit, nonce idempotency, hourly cap, disabled,
non-VK refusal) + codec unit (reward wire). Docs: PAYMENTS(+ru) §10, D29 amend,
PLAN E6. Bundle: shared budget 30->31 (reward i18n strings).
2026-07-10 00:41:42 +02:00
developer 68c937f3b6 Merge pull request 'feat(payments): refund engine (best-effort revoke, never negative)' (#227) from feature/payment-intake-refunds into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m54s
2026-07-09 21:26:33 +00:00
Ilia Denisov 21fb14facf feat(payments): refund engine (best-effort revoke, never negative)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The last money-intake slice: reverse a paid order best-effort, exactly once. All
refunds are admin-triggered (E7) — no rail pushes an unsolicited refund (Robokassa
via its refund API / cabinet, VK via support, Telegram via refundStarPayment), so
this ships the engine they all converge on, not a webhook.

The Refund method matches the paid order, appends a refund ledger row (idempotent on
(provider, provider_refund_id) — distinct from the fund's payment id, so both
coexist), and revokes the funded chips floored at 0 (never negative — D27,
balances_chips_chk). When the chips were already spent, the unrecoverable remainder
is recorded as a per-account loss + abuse flag in the new additive
payments.account_risk table (read by the E7 report). The refund ledger row's chip
delta is what was actually reclaimed (the ledger stays reconcilable); the full
reversal rides in the snapshot; the order stays paid.

Additive migration (a new table only) -> rollback-safe, no contour wipe. Robokassa
refund-status polling is deferred (a worker not worth it at low chargeback volume);
failed events are not wired (no rail signals a hard post-charge server decline).

Tests: integration (full revoke; revoke-after-spend = floor-0 + loss + abuse;
duplicate idempotent; unpaid-order guard). Docs: PAYMENTS(+ru) §9, PLAN (E5 -> DONE).
2026-07-09 23:21:10 +02:00
developer ee9924c313 Merge pull request 'feat(payments): Telegram Stars payment rail' (#226) from feature/payment-intake-tg-stars into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
2026-07-09 20:44:06 +00:00
Ilia Denisov e10f3beed5 chore(ui): raise the app bundle budget 123->125 for the payment intake rails
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
The wallet order flow plus the Telegram Stars openInvoice / VK order-box launch
and the per-button in-flight state ride the always-loaded Wallet screen, which
crossed the 123 KB gzip ceiling. Bump to 125 (matching the established
feature-driven budget history in the comment) with ~2 KB of headroom.
2026-07-09 22:37:30 +02:00
Ilia Denisov 55dbb1005e fix(ui): scope the wallet buy-button busy dim to the tapped button
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Failing after 11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Tapping one pack's Buy dimmed every buy button at once (the shared busy
flag drove disabled + the :disabled opacity on all of them), so it looked
like both packs reacted to one tap. Track the in-flight product id
(busyId): the concurrency guard stays global, but the pressed/dimmed look
is now scoped to the tapped button. Payments were never affected.
2026-07-09 22:26:28 +02:00
Ilia Denisov 219fc7c827 chore(deploy): re-roll the test contour to pick up the TEST_AWG_CONF DNS fix
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
The contour VPN sidecar's netns resolv.conf was pinned to 1.1.1.1 by a DNS=
directive in TEST_AWG_CONF, so internal names (gateway, otelcol) did not resolve
and the bot-link had been down since ~2026-06-29. The directive is removed; this
empty commit re-rolls the contour so the vpn recreates with Docker's 127.0.0.11
resolver and the bot-link (and the Stars rail over it) comes up.
2026-07-09 21:55:53 +02:00
Ilia Denisov 6e03ce0131 feat(payments): Telegram Stars payment rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
Accept real money via Telegram Stars (XTR) — the third intake rail
alongside Robokassa (direct) and VK Votes.

Only the bot reaches Telegram, so the rail funnels through the reverse
mTLS bot-link:
- the gateway mints the invoice on a CreateInvoice command (the bot
  calls createInvoiceLink, XTR; the link goes to WebApp.openInvoice);
- the bot gates each pre_checkout_query via a ValidatePreCheckout unary
  (the order must exist, be still creditable and not already paid — the
  reusable-invoice double-pay guard; the decline reason is localised to
  the order account's language);
- a completed successful_payment is queued in a durable pure-Go SQLite
  outbox and forwarded via a ForwardPayment unary, credited once
  (idempotent on telegram_payment_charge_id, honours an expired order),
  re-driven on restart and every 30s.

The rail is wired by TELEGRAM_STARS_OUTBOX_DIR (default /data) but stays
inert until a chip pack carries an XTR price, so seeding a Stars price in
the admin is the go-live.

Tests: backend integration (order->forward->credit once, duplicate,
pre_checkout gate) + bot outbox unit (idempotent, restart re-drive) +
executor createInvoice. Docs: PAYMENTS(+ru) §9, ARCHITECTURE, the
platform/telegram README, PLAN.
2026-07-09 21:35:29 +02:00
developer 5612bb624d Merge pull request 'fix(ui): VK-iOS store link from the deployment origin' (#225) from fix/vk-ios-link-origin into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m47s
2026-07-09 18:30:54 +00:00
Ilia Denisov 8230253142 fix(ui): derive the VK-iOS store link from the deployment origin
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
Replace the hardcoded erudit-game.ru host with location.origin, so the iOS
store note's "other version" link points at this deployment's own site root
(prod or the contour) without a hardcoded host or build-arg wiring.
2026-07-09 20:26:25 +02:00
developer 890a887257 Merge pull request 'feat(payments): VK Votes payment rail' (#224) from feature/payment-intake-vk into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
2026-07-09 18:23:24 +00:00
Ilia Denisov e33b9864c8 fix(ui): point the VK-iOS "other version" link at the site root
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m23s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
The landing at / lists every version's entry; /app/ dropped the player straight
into the web game. Link the iOS store note to the root instead.
2026-07-09 20:18:42 +02:00
Ilia Denisov f6aa0ba4b0 fix(ui): correct the VK-iOS purchase block + add an iOS store note
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
The VK-iOS detection used platformSubtype(), which is server-derived for VK and
so never reported iOS on the client — the pack CTA stayed active and a tap hit a
403 with a generic error. Read the device family from the signed vk_platform
launch param (normalizeSubtype(vkPlatform())) instead. Under the store header on
VK-iOS, add a note that purchases are prohibited by Apple policy, linking to the
web version (opened through VK's external-link route).
2026-07-09 20:11:35 +02:00
Ilia Denisov f48ebf2151 fix(ui): VK-iOS shows the pack CTA disabled with a toast, not a failed buy
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
Money purchases are not permitted in the VK iOS app (Apple ToS), and the backend
already refuses them (the CreateOrder VK-iOS freeze). Show the pack "Buy" muted
there and explain on tap ("purchases are not available on this platform"),
instead of letting the tap hit a 403 and a generic error toast. The storefront
still lists the packs.
2026-07-09 19:57:10 +02:00
Ilia Denisov 3bb9f10fad feat(payments): VK Votes payment rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Wire the VK Mini Apps ("голоса") rail end to end, reusing the intake engine. The
wallet.order endpoint branches by rail: a VK context opens a pending order
(provider vk) and returns its id, which the client passes to VKWebAppShowOrderBox.
VK's two-phase payment callback is verified at the gateway with the app protected
key (GATEWAY_VK_APP_SECRET) and proxied to a backend intake handler: get_item
returns the ordered pack's title and vote price; a chargeable order_status_change
credits the vk segment exactly once (the same Fund, idempotent on VK's own order
id) and records a succeeded event, so the dispatcher push refreshes the wallet.
Integration test for the VK order->credit path.
2026-07-09 19:27:57 +02:00
Ilia Denisov 3e0763463f feat(gateway): VK payment callback signature verifier
Add the vkpay package: verify a VK Mini Apps payment ("голоса") callback
signature — MD5 (mandated by VK's payment protocol) of the sig-excluded
parameters, sorted by name and concatenated key=value, with the app secret
appended; case-insensitive over the hex digest. Unit-tested (valid / tampered /
wrong-secret / missing). First piece of the VK rail; the two-phase callback
handler, the VK order branch and the client bridge follow.
2026-07-09 18:56:00 +02:00
developer 96adf98e1d Merge pull request 'feat(payments): Robokassa direct-rail payment intake' (#223) from feature/payment-intake-robokassa into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m50s
2026-07-09 16:33:12 +00:00
Ilia Denisov 3367cc2bf1 feat(payments): payment-event dispatcher + the provider-return UX
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Deliver payment_events to connected clients as an in-app wallet-refresh push: a
background dispatcher drains undispatched events and publishes a KindNotification
"payment" signal, marking each delivered; the client bumps a wallet-refresh
counter the open Wallet screen watches, re-fetching in place. A return-focus
refetch is the fallback. The Robokassa Success/Fail return now serves a
self-closing page (the payment opens in a separate window) so the customer drops
back into the live app instead of a cold start. Integration test for the event
drain/mark queue.
2026-07-09 18:26:06 +02:00
Ilia Denisov 04435a3283 docs(payments): bake the E5 direct-rail decisions + a /pay/ CI probe
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m40s
Record the E5 delivery and resolved decisions in PLAN.md (Shp_order matching,
order-id idempotency, cabinet-side receipt, never-negative → schema-free) and
mark the stage WIP. Add a CI probe asserting /pay/robokassa/result reaches the
gateway rather than the landing catch-all.
2026-07-09 17:48:29 +02:00
Ilia Denisov be0e4995f3 feat(deploy): wire the Robokassa direct rail into the contour
Map the Robokassa merchant login + Password1/Password2 into the backend
container env from the deploy secrets (TEST_BACKEND_ROBOKASSA_*), and force
IsTest on the test contour so it can never take real money. An empty login
leaves the direct order + callback endpoints unregistered.
2026-07-09 17:45:54 +02:00
Ilia Denisov 936a70ab94 feat(ui): wire the chip-pack purchase to Robokassa
Replace the disabled "Soon" pack action with a real purchase: the Wallet opens a
money order (wallet.order) and sends the player to the provider's hosted-payment
page (window.open via openExternalUrl); the chips are credited later by the
verified server callback. Add a public-offer link under the packs (paying
accepts the offer). Codec order round-trip unit test + a mock-e2e purchase test;
the Google Play stub and the chip-spend paths are unchanged.
2026-07-09 17:43:02 +02:00
Ilia Denisov 4f6c22d669 feat(gateway): the Robokassa /pay/ edge routes
Add the public /pay/robokassa/result callback proxy (rate-limited; forwards the
provider's form parameters to the backend intake, the single writer, and echoes
its "OK<InvId>" back to Robokassa) and the /pay/robokassa/{success,fail}
browser-return redirects into the app. Route /pay/* to the gateway in the
contour Caddyfile so the callback reaches the edge, not the landing catch-all.
The backend intake now returns the echo body as JSON for the gateway to relay.
2026-07-09 17:33:22 +02:00
Ilia Denisov 2a6dc5a304 feat(gateway): wire the wallet.order edge call for the direct rail
Add the WalletOrderRequest / WalletOrderResponse FlatBuffers messages and the
wallet.order Connect op: the gateway decodes the order request, forwards it to
the backend POST /wallet/order and returns the created order id plus the
provider launch URL the client opens. Regenerate the committed Go and TS
FlatBuffers code.
2026-07-09 17:30:39 +02:00
Ilia Denisov a66a5bfa08 test(payments): integration coverage for the intake credit path
Cover the order→callback→credit path over Postgres: a funded order credits its
segment exactly once; a replayed callback for the same order credits nothing
(the ledger idempotency index holds); a mismatched paid amount is refused with
no write; an expired pending order is still honoured by a late valid callback.
2026-07-09 17:04:14 +02:00
Ilia Denisov 36a5730e52 feat(payments): direct-rail order + intake handlers, config and reaper
Wire the Robokassa direct rail into the backend transport. POST
/api/v1/user/wallet/order (walletGate + a D36 confirmed-email gate for the
direct rail) opens a pending order and returns the signed Robokassa payment
URL. The internal, gateway-only /payments/robokassa/result endpoint verifies
the Result signature, credits the matched order exactly once via Fund (honoured
even if expired), records a succeeded payment event, and answers Robokassa's
"OK<InvId>". Add the Robokassa env config, an account HasConfirmedEmail check
(D36), the payment_events writer, and a periodic pending-order reaper. The
routes register only when a Robokassa merchant login is configured.
2026-07-09 16:59:34 +02:00
Ilia Denisov 7860efce48 feat(payments): order-flow and fund credit engine + the Robokassa adapter
Add the payment-intake write path (provider-agnostic) and the Robokassa
direct-rail glue, both unit-tested; transport, wire and UI follow.

- payments: extend the ledger insert to thread order_id/provider/
  provider_payment_id (spend/grant pass nil); add the order store
  (create/read/expire + a pack-price loader) and the fund credit — a
  fund ledger row + a guarded balance upsert + mark-paid in one tx,
  idempotent on the (provider, provider_payment_id) unique index, cache
  invalidated after commit. A valid callback is honoured even on an
  expired order. Service CreateOrder/Fund/ExpireOrders; Money.Major for
  the provider amount field.
- robokassa: build the signed hosted-payment URL (SHA-256, order id via
  Shp_order, InvId unused) and verify the Result callback signature
  (Password2), extracting the order and amount. Receipt/fiscalisation is
  configured shop-side, so no Receipt parameter is sent.
2026-07-09 16:48:48 +02:00
developer ca60025fbe Merge pull request 'feat(ui): public offer page at /offer/ with a landing footer link' (#222) from feature/public-offer-page into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 18s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m38s
2026-07-09 14:12:28 +00:00
Ilia Denisov 0bdc034f7a chore: offer formatting
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
2026-07-09 16:04:56 +02:00
Ilia Denisov 625fc3135a feat(ui): serve the public offer at /offer/ with a landing footer link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
The offer is the legal document a purchase accepts, needed before real-money
intake goes live. Convert the source PDF to an editable ui/legal/offer_ru.md and
render it to a standalone static dist/offer/index.html at build (a vite
emit-offer plugin using marked); the landing container serves it at /offer/,
with a bare /offer redirecting in. Add a small centered "Публичная оферта"
footer link on the landing (ru/en) and a CI probe asserting /offer/ serves the
page rather than silently falling through to the landing shell.
2026-07-09 16:03:50 +02:00
developer a118c63411 Merge pull request 'fix(deploy): run the base-backup timer's pgBackRest as the postgres role' (#221) from feature/pitr-timer-fix into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s
2026-07-09 00:07:52 +00:00
Ilia Denisov 7123ba439d fix(deploy): run the base-backup timer's pgBackRest as the postgres role
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
docker exec defaults to root, so the systemd base-backup timer connected to the
database as role "root" (then "postgres") — neither exists; the superuser role is
POSTGRES_USER (scrabble). Run the timer's pgBackRest as the postgres OS user (-u postgres,
for lock-dir/PGDATA consistency with archive-push) and connect with --pg1-user=scrabble.
archive_command (run by the postgres server process) was already correct.

Also record the point-in-time-recovery arming + restore drill in deploy/README.md
(correct the runbook commands to the same invocation, fill the drill log) and mark the
durability work done in PLAN.md.
2026-07-09 02:03:59 +02:00
238 changed files with 13297 additions and 1373 deletions
+47
View File
@@ -366,6 +366,13 @@ jobs:
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }} SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }} SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }} SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
# Direct-rail (Robokassa) sandbox intake on the contour: the test shop's merchant login +
# Password1/Password2. IsTest is forced to 1 below so the contour can never take real money
# (independent of the shop's own mode). Empty login leaves the direct rail off.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.TEST_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: "1"
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }} SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana # Operator alerts: backend admin emails (new feedback / complaints) + Grafana
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS # infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
@@ -400,6 +407,9 @@ jobs:
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below. # the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }} VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
# Rewarded-ad test stub: set TEST_VITE_ADS_STUB=1 to swap real ads for a toast on the
# contour (empty = real ads, for capturing the real VK ad result). Prod never sets it.
VITE_ADS_STUB: ${{ vars.TEST_VITE_ADS_STUB }}
# VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the # VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the
# compose ":-" empty default. Other unset vars likewise fall to their defaults. # compose ":-" empty default. Other unset vars likewise fall to their defaults.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
@@ -497,6 +507,43 @@ jobs:
docker logs --tail 50 scrabble-backend || true docker logs --tail 50 scrabble-backend || true
exit 1 exit 1
- name: Probe the /offer/ public offer page is served
run: |
set -u
# /offer/ is rendered by the render sidecar: it splices the live catalog price list
# (fetched from the backend's internal endpoint) into the committed ui/legal/offer_ru.md.
# If the @offer caddy route is missing, the request falls to the landing shell (also 200),
# and if the backend fetch fails the sidecar returns 502 — so assert offer-specific content
# (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch +
# splice ran), never just the status. Data-independent: an empty catalog still substitutes
# the marker with nothing, so "pricing_template" must be absent either way.
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
if echo "$out" | grep -q "290210610742" && ! echo "$out" | grep -q "pricing_template"; then
echo "ok: /offer/ serves the rendered offer with the price list spliced in"
else
echo "FAIL: /offer/ did not serve the rendered offer (route fell through, or the price splice failed)"
docker logs --tail 50 scrabble-renderer || true
docker logs --tail 50 scrabble-backend || true
docker logs --tail 50 scrabble-landing || true
exit 1
fi
- name: Probe the /pay/ callback route reaches the gateway
run: |
set -u
# /pay/robokassa/result must reach the gateway, not fall to the landing catch-all. An
# unsigned probe is rejected downstream, so the gateway answers a 4xx/5xx (never a 200 or
# a 404 landing.html), which proves the edge route is wired.
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/pay/robokassa/result 2>&1 || true)"
echo "$out" | grep -E "HTTP/" || true
if echo "$out" | grep -qE "HTTP/1\.1 (4|5)[0-9][0-9]"; then
echo "ok: /pay/ reaches the gateway (non-landing response)"
else
echo "FAIL: /pay/robokassa/result did not reach the gateway (landing catch-all?)"
docker logs --tail 50 scrabble-gateway || true
exit 1
fi
- name: Probe the /dict edge route reaches the gateway - name: Probe the /dict edge route reaches the gateway
run: | run: |
set -u set -u
+13
View File
@@ -99,6 +99,14 @@ jobs:
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }} TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }} GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# Robokassa direct-rail (backend BACKEND_ROBOKASSA_*): the prod shop login + the pass phrases
# that sign the launch request / verify the Result callback, and the test-mode flag — a var so
# go-live is a flag flip, not a secret redeploy ("1" runs test payments against the test
# passwords; empty/"0" is live). An empty login leaves the direct rail disabled.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at # VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
# runtime) + the app's protected key. Both shared across contours. The redirect URL is # runtime) + the app's protected key. Both shared across contours. The redirect URL is
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. # derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
@@ -107,6 +115,11 @@ jobs:
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm. # Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh. # Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# Community IP blocklist (Spamhaus DROP): opt-in. Set _ENABLED=true + _URL (the feed) once
# verified; _ALLOW is a comma-separated never-block set (own infra). Empty ⇒ off.
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY). # Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }} EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay (confirm-codes): one account for # Transactional email via the shared Selectel relay (confirm-codes): one account for
+10
View File
@@ -61,9 +61,19 @@ jobs:
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL # the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. # and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }} GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# Robokassa direct-rail: the rollback re-renders the same runtime env (write-prod-env.sh), so
# it must carry the same credentials or the direct rail goes dark after a rollback.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# Community IP blocklist — rendered on rollback too so a rollback keeps the same edge policy.
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }} EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }} SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }} SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
+276 -79
View File
@@ -33,11 +33,11 @@ status — without re-deriving decisions.
| E1 | Trusted platform signal | 1 | DONE | | E1 | Trusted platform signal | 1 | DONE |
| E2 | Currency + benefit core | 1 | DONE | | E2 | Currency + benefit core | 1 | DONE |
| E3 | Wallet UI | 1 | DONE | | E3 | Wallet UI | 1 | DONE |
| E4 | Durability (PITR) | 2 | WIP | | E4 | Durability (PITR) | 2 | DONE |
| E5 | Payment intake | 2 | TODO | | E5 | Payment intake | 2 | DONE |
| E6 | Ads | 2 | TODO | | E6 | Ads | 2 | DONE |
| E7 | Admin & reports | 2 | TODO | | E7 | Admin, reports & catalog | 2 | DONE |
| E8 | Guest limits | — | TODO | | E8 | Guest limits | — | DONE |
| E9 | Tournament fee | future | TODO | | E9 | Tournament fee | future | TODO |
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3). **Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
@@ -446,8 +446,8 @@ noun agreement). Svelte whitespace/`$state` naming gotchas apply.
## E4 — Durability (PITR) ## E4 — Durability (PITR)
**Status:** WIP — repo artifacts landed; **prod arming pending** (owner-coordinated, before **Status:** DONE — armed + restore-drilled on prod (v1.13.0, 2026-07-09). · **Release 2** ·
E5). · **Release 2** · depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4). depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4).
**Goal.** Continuous WAL archiving with point-in-time recovery, armed **before the first **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. real money** is accepted (E5 prod). Protects both money and game data.
@@ -479,30 +479,73 @@ real money** is accepted (E5 prod). Protects both money and game data.
`-n backend`, silently excluding `payments`); manual-restore runbook updated. `-n backend`, silently excluding `payments`); manual-restore runbook updated.
- Full PITR runbook + arming sequence + recorded assessment in `deploy/README.md`. - Full PITR runbook + arming sequence + recorded assessment in `deploy/README.md`.
**Prod arming (SEPARATE — owner-coordinated, before E5; NOT the artifacts PR).** Owner creates **Prod arming (completed 2026-07-09 with the v1.13.0 release).** Owner created the Selectel S3
the Selectel S3 bucket + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`); bucket (`erudite`, ru-6) + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
then promote `development → master` → `prod-deploy` (archive_mode on behind the maintenance promoted `development → master` → `prod-deploy` (archive_mode on behind the maintenance window),
window), `pgbackrest stanza-create` + first base backup + `check`, re-run Ansible with then `stanza-create` + first base backup (31.9 MB cluster → 3.7 MB in the repo) + `check`, the
`-e pitr_enabled=true`, and run the restore drill on an isolated one-shot target. Exact steps: Ansible `-e pitr_enabled=true` timer, and a restore drill on an isolated one-shot target (data
`deploy/README.md` (point-in-time recovery — arming). intact, then wiped). Exact steps: `deploy/README.md` (point-in-time recovery — arming).
**Tests.** Restore drill on an isolated one-shot instance (base + WAL → target timestamp), **Tests.** Restore drill on an isolated one-shot instance (base + WAL → target timestamp),
recorded in `deploy/README.md`. No app-level tests. Local verification: `docker compose config` recorded in `deploy/README.md`. No app-level tests. Local verification: `docker compose config`
valid + the custom PG image builds (archiving inert on the contour). valid + the custom PG image builds (archiving inert on the contour).
**Done-criteria.** Artifacts merged with archiving inert. **Fully done** at arming: WAL **Done-criteria (met).** WAL archiving live on prod PG (v1.13.0); a restore verified on an
archiving live on prod PG; a timed restore verified; cost + perf assessment reviewed; runbook isolated one-shot target; cost + perf assessment reviewed; runbook current in
current in `deploy/README.md`. `deploy/README.md`.
**Notes/risks.** The repository **cipher passphrase is unrecoverable if lost** — stored apart **Notes/risks.** The repository **cipher passphrase is unrecoverable if lost** — stored apart
from the S3 keys. Enabling `archive_mode` restarts postgres (rides the prod-deploy maintenance from the S3 keys. Manual/timer pgBackRest runs use `docker exec -u postgres … --pg1-user=scrabble`
window). Migrations stay expand-contract so image rollback remains DB-safe alongside PITR. (docker exec is root; the DB superuser role is `scrabble`, not `postgres`) — the systemd timer
+ the runbook carry this. Enabling `archive_mode` restarts postgres (rode the prod-deploy
maintenance window). Migrations stay expand-contract so image rollback remains DB-safe with PITR.
--- ---
## E5 — Payment intake ## E5 — Payment intake
**Status:** TODO · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12. **Status:** DONE · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice), Robokassa first.
Resolved: match the order by a Robokassa **`Shp_order`** custom parameter, not the numeric `InvId`
(an order id is a uuid); idempotency key = the order id (`provider_payment_id = order_id`); the НПД
receipt is formed **shop-side in the Robokassa cabinet**, so no `Receipt` parameter is sent; a
chargeback **never drives the balance negative** (D27 stands, `balances_chips_chk` kept), so E5 is
**schema-free** (no migration, no contour wipe). Delivered on `feature/payment-intake-robokassa`:
the offer page (`/offer/`), the order/`fund` engine (idempotent, honours an expired order), the
`internal/robokassa` adapter, the `POST /wallet/order` + internal Result-callback handlers (a D36
confirmed-email gate on `direct`), the pending reaper, the `wallet.order` edge wire + the public
`/pay/*` routes, the Wallet purchase CTA, the contour deploy env (IsTest forced), and the
`payment_events` dispatcher — an in-app wallet-refresh push (KindNotification `"payment"`) with a
self-closing provider-return page (the payment opens in a separate window) and a return-focus
refetch fallback. The **VK Votes rail** is delivered too: the client opens
`VKWebAppShowOrderBox({item: order_id})`; a two-phase signed server callback (`get_item` → the pack
title + vote price; a chargeable `order_status_change` → the same `Fund` with source=`vk`, idempotent
on VK's own order id) is verified at the gateway with the app protected key (`GATEWAY_VK_APP_SECRET`,
already deployed) and proxied to the backend intake. The **Telegram Stars rail** is delivered on
`feature/payment-intake-tg-stars`: only the bot reaches Telegram, so the **invoice is minted by the
bot** — on the `wallet.order` path the gateway sends a new `CreateInvoice` command over the reverse
bot-link and the bot returns the `createInvoiceLink` (XTR) in its Ack, handed to the client's
`WebApp.openInvoice`. The bot answers `pre_checkout_query` via a new bot→gateway **`ValidatePreCheckout`**
unary (backed by the backend: the order must exist, be still creditable and **not already paid** — the
reusable-invoice double-pay guard — with a matching amount); the decline reason is localised to the
order account's language. A completed `successful_payment` is persisted to a pure-Go **SQLite outbox**
(`modernc.org/sqlite`) then forwarded by a new bot→gateway **`ForwardPayment`** unary into the same
`Fund` (source=`telegram`, idempotent on `telegram_payment_charge_id`, honours an expired order),
re-driven at startup and every 30 s. The rail is wired by `TELEGRAM_STARS_OUTBOX_DIR` (defaults to the
bot `/data` volume) but stays **inert until a chip pack carries an XTR price**, so seeding a Stars price
in the admin is the go-live. Finally **refunds** are delivered on `feature/payment-intake-refunds`: a
single `Refund` engine (`internal/payments`) reverses a paid order best-effort, exactly once —
idempotent on `(provider, provider_refund_id)`, revoking the funded chips **floored at 0** (never
negative, D27), and recording the unrecoverable remainder (chips already spent) as a per-account
**loss + abuse flag** in the new additive `payments.account_risk` table (read by the E7 report). The
refund ledger row's chip delta is what was actually reclaimed (the ledger stays reconcilable); the
full reversal rides in its snapshot; the order stays `paid`. **No rail pushes an unsolicited refund**
— all are admin-triggered (E7): Robokassa refund API / cabinet (auto-polling deferred — a worker not
worth it at low chargeback volume), VK via support, Telegram `refundStarPayment`. `failed` events are
not wired (no rail signals a hard post-charge server decline). The migration is **additive** (a new
table only), so E5 stays rollback-safe / no contour wipe. That closes E5. Deferred to a later stage:
hiding the ad banner on a no-ads purchase (a spend-path `NotifyBanner`, with the owner's agreement).
**Goal.** Accept real money on all three rails into the payments domain: order-flow, **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, verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher,
@@ -525,12 +568,14 @@ receipts, and refunds.
**TG bot outbox (`platform/telegram/`).** **TG bot outbox (`platform/telegram/`).**
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. Add a - The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. A **SQLite**
**SQLite** store on the bot's disk: on receipt, persist → ack the Telegram update → forward store on the bot's disk (`internal/outbox`): on receipt, persist → forward over the reverse
to a backend payments-intake endpoint (internal, authenticated over the existing reverse mTLS bot-link to the **gateway** (a `ForwardPayment` unary; the bot cannot dial the backend
mTLS bot-link) → on backend ack, mark `forwarded`. Retries with backoff; re-drive directly) → the gateway proxies to the backend payments-intake REST → on a durable response,
undelivered on restart. Backend intake dedups by `telegram_payment_charge_id`. mark `forwarded`. Re-drive undelivered on startup and on a 30 s tick. Backend intake dedups by
- Provide the invoice creation path (Stars) from the Mini App via the bot as needed. `telegram_payment_charge_id`.
- Invoice creation (Stars) is minted by the bot (`createInvoiceLink`, XTR) on the gateway's
`CreateInvoice` bot-link command, returned to the Mini App as the `openInvoice` link.
**Events & notifications.** **Events & notifications.**
@@ -542,9 +587,13 @@ receipts, and refunds.
**Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK **Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK
handles Votes tax itself; TG Stars — no receipt. handles Votes tax itself; TG Stars — no receipt.
**Refunds (§9).** ToS non-refundable; admin manual refund (ties to `accountdelete`); external **Refunds (§9).** ToS non-refundable. **All refunds are admin-triggered** (E7): no rail pushes an
`refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse unsolicited refund — Robokassa refund API / cabinet (auto-polling deferred as a low-value worker),
flag if spent), ledger `refund` row. Ledger export-ready (reconciliation not built). VK via support, Telegram `refundStarPayment`. One `Refund` engine reverses a paid order best-effort,
exactly once (idempotent on `(provider, provider_refund_id)`): revoke floored at 0 (never negative),
unrecoverable remainder → per-account loss + abuse flag (`payments.account_risk`), a `refund` ledger
row (chip delta = revoked, full reversal in the snapshot). `failed` events are not wired (no rail
signals a hard post-charge server decline). Ledger export-ready (reconciliation not built).
**Tests.** **Tests.**
@@ -569,9 +618,42 @@ force-recreate when the Caddyfile changes.
## E6 — Ads ## E6 — Ads
**Status:** TODO · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) · **Status:** DONE · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
mechanics: PAYMENTS §10. mechanics: PAYMENTS §10.
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice): **rewarded first**,
then interstitial. Baked: the interstitial cooldowns already exist in `payments.config` (E0) and the
per-origin banner suppression is already done (E2 `AdFree`), so E6 is the two ad DISPLAY paths + the
rewarded credit. **VK reality (checked live in the VK docs via Playwright):** VK Mini App ads
(`VKWebAppShowNativeAds`, both `reward` and `interstitial`) expose **only a client-side `data.result`
boolean** — no server verify, no signature. So **D29 is amended**: rewarded is **client-attested**,
guarded by a server **daily + hourly cap** (config `reward_daily_cap` / `reward_hourly_cap`, default
50 / 10) that is both anti-abuse and an economic conversion lever (limits free chips so players buy);
the cooldown state for the interstitial is **client-mirrored** (owner's pick). Delivered on
`feature/ads-rewarded` (the **rewarded** slice): the ads-network abstraction (`ui/src/lib/ads.ts`, VK
impl) + the VK bridge (`vkRewardedReady` / `vkShowRewarded`), the backend `CreditReward` (VK-only,
order-less, idempotent on a client nonce, floored by the caps, payout from config
`rewarded_payout_chips` default 0 = off), the `wallet.reward` edge op returning the updated wallet
(with `reward_chips` gating the "watch for chips" CTA), and a **contour test stub** (`VITE_ADS_STUB` →
a toast instead of a real ad; prod always real). A temporary diagnostic confirmed on the contour that
VK returns **only `{result:true}`** (no token/signature) — client-attested is final, no hardening
possible; the diagnostic is removed. The slice also **corrects the VK-iOS freeze to purchase-only**
(rewarded on VK-iOS earns chips, which the old blanket "spend freeze" then blocked from spending —
Apple forbids only *buying* in-app values, not spending or earning them; `vkFrozen()` now gates only
`CreateOrder`, not `spendableSources`, so VK-wallet chips spend on VK-iOS). Delivered on
`feature/ads-interstitial` (the **interstitial + D31** slice): the post-move fullscreen interstitial
as a **client-mirrored** gate — the backend `adsFor` puts the config cooldowns + a `suppressed` flag
(the no-ads / `no_banner` gate, same as the banner) on the profile (`Profile.ads`), and
`ui/src/lib/ads.ts` `maybeShowInterstitial` self-gates on the last-shown time per kind in
`localStorage`, showing a VK interstitial (`vkShowInterstitial`) after a **confirmed play or a hint
only** (never a pass / exchange / resign), VK-only, offline banner-only, with the same `VITE_ADS_STUB`
toast on the contour. The slice also lands **D31 step 1 (contract-code)**: the domain no longer reads
or writes the deprecated `accounts.hint_balance` / `paid_account` columns — the `Account` fields, the
dead `account.SpendHint`, `account.GrantHints` and the admin **grant-hints** action are removed, and
the in-game hint display now comes wholly from the payments benefit (`HintsAvailable`). The **columns
stay** (no migration → image rollback is DB-safe); a later contract-PR does the `DROP` once E6 is
stable on prod.
**Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video **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 (credits chips via server verify), plus extending the existing banner suppression to
per-origin. per-origin.
@@ -586,8 +668,9 @@ per-origin.
- **Interstitial** (post-move fullscreen), configurable server values (from `payments` - **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 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; 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 offline banner-only; respect VK's own frequency caps. Cooldown state is **client-mirrored**
(per user) or client-mirrored from a server value — pick and document at implementation. (the chosen option): the server sends the cooldowns + `suppressed` on the profile and the
client self-gates on a per-kind last-shown time in `localStorage` — no per-move round-trip.
- **Banner suppression:** extend `ads.Eligible` (`backend/internal/ads/ads.go` :107) to gate - **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 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. single legacy flag. No-ads suppresses banner + interstitial; rewarded never suppressed.
@@ -610,75 +693,177 @@ it tunes without a store release.
--- ---
## E7 — Admin & reports ## E7 — Admin, reports & catalog
**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant), E5 (payments/refunds) · **Status:** DONE · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds),
mechanics: PAYMENTS §11. E6 (D31 retired the legacy `hint_balance`/`paid_account`) · mechanics: PAYMENTS §11, §12, D32.
**Goal.** The admin console financial surface: per-user report, admin grant UI, manual **Delivery & baked decisions (this planning round).** A linear PR stack into `development`.
refund UI, ledger export.
**Work (`/_gm`, `backend/internal/server/handlers_admin_console.go` + `adminconsole/`).** - **Archived product = the existing `product.active` flag** (no new column, no migration):
`active=false` **is** "archived". Its three behaviours already hold — hidden from the user
storefront (`store_catalog.go` filters `active`), an **in-flight external payment still
credits** (the `fund` credit path resolves the order and never re-checks `active`; only order
*creation* / chip *spend* require `active`), and a product with any order/ledger row **cannot
be hard-deleted** (FK `orders→product` / `ledger→product` are RESTRICT). The admin toggle is
labelled **Archive / Unarchive**.
- **Delete vs archive:** the editor offers a hard **Delete** only for a product with **no
orders and no ledger rows** (never transacted — the FK is the DB backstop); a transacted
product is **archive-only**.
- **Admin grant = raw atoms + by-product.** Keep the quick raw-atom grant (N hints / no-ads
days / forever) AND add grant-by-product: pick a defined product (including archived "reward"
bundles) and grant its atoms. Both write an `admin_grant` ledger row; by-product records
`product_id` + the snapshot. **Both refuse any set containing `chips`** (admin never grants
currency) **or `tournament`** (no credit target until E9) — an explicit refusal, never a
silent no-op.
- **Manual refund = full order only** for now (the E5 `Refund` engine takes an amount; partial
is a later add if needed).
- **Tournament stays atom-only (E0); its entry economy is E9.** The catalog editor can compose
products carrying the `tournament` atom (archived templates for E9), but granting/spending a
`tournament` atom is refused until E9 designs the storage (recurring types, each its own
benefit + price) — see E9. Adding a `benefits.tournament` counter now was rejected: the model
is multi-type, so a single column would be wrong and force a second DB break.
- The admin grant **no longer mirrors `grant-hints`** (E6/D31 removed that action); it is a
fresh action on `payments.Grant`.
- **Per-user financial panel** on the existing user card (`consoleUserDetail` :343, **Goal.** The admin console financial surface — per-user report, admin grant, manual refund,
`UserDetailView`): segment balances, payments, spends, grants, refunds, full history — ledger export — plus the **configurable product catalog editor** (D32).
read from the append-only ledger + materialized cache.
- **Admin grant** action (mirror the existing `POST /_gm/users/:id/grant-hints`): grant **Work (`/_gm`, `handlers_admin_console.go` + `adminconsole/`, `internal/payments/`).**
concrete values (no-ads days / hints), **origin picker**, **never chips**; writes an
`admin_grant` ledger row (E2 `Grant`). - **Per-user financial panel** on the user card (`consoleUserDetail`, `UserDetailView`): segment
- **Manual refund** action: admin-initiated refund of a specific order (ties to the refund chip balances `(account, source)`, benefits `(account, origin)` (hints, no-ads until/forever),
path); records a `refund` ledger row; best-effort benefit revoke. and the full append-only ledger (fund/spend/admin_grant/refund — amount, origin, product,
- **Ledger export**: CSV/JSON export of the ledger for tax reporting + future Robokassa provider, snapshot) from the ledger + materialized cache. Replaces the retired
reconciliation (export-ready schema; reconciliation itself not built). `PaidAccount`/`HintBalance` fields with the segmented view.
- **Catalog editor** (`/_gm/catalog`): list every product (active + archived) with its atoms and
per-method/currency prices; create/edit (title, atom items `atom_type→quantity`, price rows
`method+currency→amount`); **Archive/Unarchive** (`active`); **Delete** (never-transacted
only). Enforce the projection's shape: a **pack** (carries `chips`) needs a money price per
method; a **value** (no `chips`) needs a single `CHIP` price. A `tournament`-bearing product is
allowed in composition but cannot be activated for sale until E9.
- **Admin grant** action: raw atoms (hints / no-ads days / forever) and by-product (a value
product, incl. archived); **origin picker**; refuses `chips`/`tournament`; `admin_grant` ledger
row (+ `product_id`/snapshot for by-product) via `payments.Grant`.
- **Manual refund** action: refund a specific paid order **in full** — a `refund` ledger row +
best-effort floor-0 benefit revoke (E5 `Refund`).
- **Ledger export**: CSV/JSON for tax + future Robokassa reconciliation (export-ready;
reconciliation itself not built).
- Auth unchanged: gateway Basic-Auth in front of `/_gm` + backend same-origin CSRF on POSTs. - Auth unchanged: gateway Basic-Auth in front of `/_gm` + backend same-origin CSRF on POSTs.
**Tests.** **Tests.**
- unit: report view assembly; grant refuses chips; export shape. - unit: panel view assembly; catalog pack/value shape projection; grant refuses chips + tournament;
- integration: grant/refund write correct ledger rows; report reflects balances+history. editor validation (pack⇒money price, value⇒CHIP price); export shape.
- integration: grant (raw + by-product) writes the right `admin_grant` row + benefit; refund writes
a `refund` row + floor-0 revoke; catalog CRUD round-trips (create→edit→archive→delete-if-clean);
delete refused on a transacted product; panel reflects balances + benefits + history.
**Done-criteria.** An operator can see a user's full financial picture, grant concrete **Done-criteria.** An operator can: see a user's full financial picture; create/edit/archive
values (origin-picked, never chips), issue a manual refund, and export the ledger. products + prices and delete only never-transacted ones; grant concrete values raw or by-product
(origin-picked, never chips/tournament); refund an order in full; export the ledger.
**Notes/risks.** `/_gm` per-user detail already surfaces `PaidAccount`/`HintBalance` — replace **PR stack (linear into `development`, all merged).**
those with the segmented view as the legacy columns retire.
1. ~~**Per-user financial panel**~~ (#230) — read-only ledger/segments/benefits on the user card;
retired the `PaidAccount`/`HintBalance` display.
2. ~~**Catalog editor**~~ (#231) — product/atom/price CRUD + archive/unarchive + delete-if-clean +
shape validation.
3. ~~**Admin grant**~~ (#232, + the D31 hint-wallet wire cleanup that surfaced there) — raw +
by-product, refuse chips/tournament, origin-picked, `admin_grant` + snapshot.
4. ~~**Manual refund** (full order via E5 `Refund`) + **ledger CSV export**~~ — `RefundOrderFull`
(idempotent, floor-0 revoke) on each fund row; `/_gm/ledger.csv`.
**Notes/risks.** High-blast-radius (money, ledger — append-only, trigger-enforced). No mixed-in
refactors. The catalog editor becomes the source of truth for products; the contour SQL seeds
become bootstrap-only.
--- ---
## E8 — Guest limits ## E8 — Guest limits
**Status:** TODO · **standalone** (game-behaviour change; can run in parallel) · depends on: **Status:** DONE · **standalone** (game-behaviour change) · depends on: none · mechanics: PAYMENTS §6.
none · mechanics: PAYMENTS §6.
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today the **Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today UI-only),
gating is UI-only). with configurable per-tier × per-kind limits so the pressure tunes without a release.
**Finding (verified).** The friend/invitation paths do **not** check `is_guest` on the **Finding (verified).** Two gaps. (a) The friend/invitation paths do **not** check `is_guest` on the
server — only the UI hides them: `social/friends.go:50` `SendFriendRequest`, server — only the UI hides them: `social/friends.go`, `robotfriends.go`, `friendcodes.go`,
`robotfriends.go:41` `RequestInGame`, `friendcodes.go:67` `RedeemFriendCode`, `lobby/invitations.go`. A guest with a valid `X-User-ID` can call them. (b) A **pre-existing** flat
`lobby/invitations.go:208` `CreateInvitation`. A guest with a valid `X-User-ID` can call them cap already existed: `game.MaxActiveQuickGames`=10 — a **combined** cap on `active`+`open` quick
in the UI's stead. games (vs_ai+random together, friend games excluded), counted by `CountActiveQuickGames`, enforced at
the handler (`ensureUnderGameLimit`) with **409 `game_limit_reached`**, surfaced as `at_game_limit`.
E8's per-tier × per-kind config **subsumes and replaces** it (decided: option A).
**Delivery & baked decisions (this planning round).** A linear PR stack; E8 is game-behaviour, no
payments mixed in.
- **`games.game_kind smallint DEFAULT 0`** (0=unknown — pre-E8 games, never gated; 1=vs_ai; 2=random;
3=friends), set on creation (`StartVsAI`=1, `Enqueue`=2, a friend invitation=3). Existing games
stay 0 and fall outside the gate.
- **Limits are per-tier × per-kind**, in a **new single-row `backend.config`** table:
`guest_{vs_ai,random,friends}_limit` + `durable_{vs_ai,random,friends}_limit` (smallint,
**`-1`=unlimited**), seeded `(1,1,0, 10,10,10)`. A guest is capped at 1 vs_ai + 1 random (friends
is moot — the guest gate blocks friend games); a durable account is **10 per kind** — the old flat
MaxActiveQuickGames=10, now split per kind. Editable in the admin.
- **The old flat cap is removed** (option A, owner-agreed): `MaxActiveQuickGames`,
`CountActiveQuickGames`, `atGameLimit` deleted; the per-tier/kind config is the single mechanism,
enforced at the **same handler gate** (`ensureUnderGameLimit(kind)` for `enqueue`
random/vs_ai) plus the durable **friends cap** inside `CreateInvitation`. The gate stays out of the
game domain — `game.Service.AtGameLimit(account, kind)` only resolves tier + counts.
- **A hot in-memory cache** fronts the config (read once on start, invalidated on the admin edit —
mirrors the payments read-cache) so a login / game-create never queries it.
- **"Active" = `open` + `active`** (an open, unmatched random game holds a slot); the limit is on
**creation** — an existing game is grandfathered, never interrupted.
- **Limits ride the wire (forward-compat, no double break):** `Profile.game_limits`
(`GameLimits{vs_ai, random, friends}` — the caller's tier resolved server-side) + `GameView.kind`,
so the client counts active games **per kind** from its lobby and locks the right start button. On
a guest → durable upgrade (register / link) the client **re-fetches the profile** so the new
(durable) limits apply. The client picks the lock's message by tier: a **guest** → the login funnel;
a **durable** account at its cap → a plain "finish a current game first" notice.
**Work.** **Work.**
- **Server guest gate** on friend-request / redeem-code / invitation-create: refuse when the - ~~**PR1 — backend + admin (server-side)** — DONE.~~ The migration (`game_kind` + `backend.config`,
caller `is_guest` (add an `ErrGuestForbidden`-style guard, as `feedback` already has). seeded `1,1,0 / 10,10,10` + jetgen); `game_kind` set on creation and projected onto `game.Game`;
- **Active-game limits** for guests: at most **1 random-opponent game + 1 vs_ai** concurrently the **guest gate** (`ErrGuestForbidden`, mapped to **403 `guest_forbidden`**) on friend-request /
(enforce on game/invitation creation in `lobby`/`game`; today no such limit exists). redeem-code / befriend-in-game / invitation-create; the **active-limit enforce** — the old flat
- Keep the existing UI gating; this adds the server as the source of truth. `MaxActiveQuickGames` mechanism removed and replaced by `ensureUnderGameLimit(kind)` on `enqueue`
(random/vs_ai) plus the durable friends cap in `CreateInvitation`, keyed off
`game.Service.AtGameLimit`; the **`internal/gamelimits` config + hot cache** (loaded at boot,
invalidated on edit); the admin **kind column** in both game lists (`/_gm/games` + the user card)
and a **config editor** (`/_gm/limits`) for the six limits.
- ~~**PR2 — wire + client** — DONE.~~ `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS
+ gateway transcode + client codec, committed regen); the client counts active games per kind from
the lobby cache (`gamelimits.ts`) and locks a capped new-game start — an **outline 🔒** button that
opens `GameLimitModal` instead of a game, native (Telegram `showPopup`) or the in-app `Modal`
elsewhere, with two messages by tier: a **guest** sign-in funnel ("Войдите или создайте учётную
запись…", Отмена / Вход→`/settings`) and, for a **durable** account at its cap, a plain notice
("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК). The lock lifts via the
existing profile re-fetch after a guest→durable upgrade.
- **Decision (owner-agreed): the lobby's old `at_game_limit` New-Game tab-disable + notice is
removed.** The `at_game_limit` flag (now the random-kind cap) conflicted with the per-kind lock —
it hid the New-Game screen where the lock lives, and wrongly blocked starting an unfulfilled kind.
The tab is always enabled; the per-kind lock on the start button is the only gate. The wire field
`GameList.at_game_limit` stays (unused by the client) for a later cleanup.
**Tests.** **Tests.**
- integration: guest is refused friend/redeem/invitation server-side; guest blocked from a - integration (PR1, done): `game_kind` persisted per path; guest refused friend/redeem/invitation
2nd random / 2nd vs_ai; durable account unaffected. (domain + HTTP 403); guest blocked from a 2nd vs_ai / 2nd random (+ `at_game_limit`); durable on the
- UI: guest sees the funnel (existing hides + any new messaging). higher tier; the durable friends cap + config cache reflecting an admin edit; accept stays exempt.
- unit (PR1, done): the per-tier/kind limit resolution (`Cap`, `LimitsFor`).
- unit + UI (PR2, done): the client lock logic (`gamelimits.ts` — count/cap/lock), the codec kind +
game_limits roundtrip, the gateway transcode game_limits encode, the popup builders, and a mock e2e
(`gamelimit.spec.ts`) — a capped start shows 🔒, opens the modal without navigating, and the lock
clears when the profile refetch lifts the cap.
**Done-criteria.** Guests are server-limited to 1 random + 1 vs_ai and cannot friend/invite; **Done-criteria.** A guest is server-capped (default 1 vs_ai + 1 random, configurable) and cannot
durable accounts unchanged; regression that this does not break existing durable flows. friend/invite; a durable account is capped at 10 per kind (configurable); the client shows the lock +
the tier-appropriate modal and the cap lifts on registration; durable flows otherwise unchanged.
**Notes/risks.** This changes existing game behaviour — its own tests, its own PR, no **Notes/risks.** Game-behaviour change — own PRs, own tests, no payments mixed in. The limit is on
mixed-in payments changes. Decide whether a guest may finish an already-started game (limit creation (existing games grandfathered). The config cache is single-instance (matching the deploy).
on creation, not mid-game) at implementation and record it here.
--- ---
@@ -687,11 +872,23 @@ on creation, not mid-game) at implementation and record it here.
**Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature · **Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature ·
mechanics: PAYMENTS §5, §7. mechanics: PAYMENTS §5, §7.
**Goal.** Charge a chip entry fee for tournaments. The `tournament` atom + a catalog product **Goal.** A chip entry economy for tournaments. The `tournament` atom is provisioned (E0) and
are provisioned in E0/E7; the spend mechanic reuses E2 (`Spend`). The actual tournament E7's catalog editor can compose tournament products; E7 deliberately **defers the entry storage
feature and its coupling to entry are out of scope until the tournament feature exists. + credit/spend** here, to avoid guessing the schema.
**Done-criteria.** Deferredrevisit when tournaments are built. **Design to settle here (not before — avoid a double DB break).** Tournaments are expected in
**several recurring types** (daily / weekly / monthly …), each its **own benefit with its own
price** — so a single `benefits.tournament` counter is wrong. Model the entry store as
**per-tournament-type** (e.g. a `tournament_type` catalog + a per-`(account, type)` entry
balance), design the pricing, then:
- lift E7's **refusal** of granting/spending the `tournament` atom (admin grant by-product +
chip spend);
- add the **spend** (charge an entry) reusing E2 `Spend`;
- wire the coupling to the actual tournament feature (out of scope until it exists).
**Done-criteria.** Deferred — revisit when tournaments are built; this stage owns the
tournament-entry storage + pricing design.
--- ---
+33 -11
View File
@@ -33,11 +33,16 @@ real game seating the caller with an **empty opponent seat** (status `open`) or,
another player already waits for the same variant and per-turn word rule, seats the another player already waits for the same variant and per-turn word rule, seats the
caller into that open game and starts it — and caller into that open game and starts it — and
friend-game invitations (invite → accept, starting a 24 player game once every friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a invitee accepts). **Per-tier, per-kind active-game caps** limit a player's simultaneous
player's active quick games — status `active`/`open`, excluding invitation-linked friend unfinished games by kind — `vs_ai`, `random` (quick auto-match), `friends` — with separate
games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and guest and durable-account tiers held in the single-row `backend.config` table (a `-1` means
`invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation unlimited), read through an in-memory cache (`internal/gamelimits`) and tuned live in the admin
is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby. (`/_gm/limits`, no redeploy). Each game is tagged with its `games.game_kind` on creation;
`game.Service.AtGameLimit` counts the account's `active`/`open` games of a kind against the
tier's cap. The server refuses `lobby/enqueue` (random/vs_ai) with **409 `game_limit_reached`**
at the cap, and the `invitations` (friends) path enforces the durable friends cap and refuses a
**guest** outright (guests cannot use friends); accepting an invitation is exempt. The
`games.list` response carries an `at_game_limit` flag (the random-kind cap) for the lobby.
`internal/social` owns the friend graph (request/accept), `internal/social` owns the friend graph (request/accept),
per-user blocks, and per-game chat with nudges folded in as a message kind; chat per-user blocks, and per-game chat with nudges folded in as a message kind; chat
messages are length-capped, content-filtered (no links/emails/phone numbers, messages are length-capped, content-filtered (no links/emails/phone numbers,
@@ -135,21 +140,38 @@ so `/internal/push-target` returns the recipient's `preferred_language` as the r
language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` + language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` +
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional `/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0 the resolved, weighted campaign feed for an **eligible** viewer (no active **no-ads** benefit
&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs applicable in the current context and no **`no_banner`** role; the message language picked by
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire `preferred_language`); changing those inputs
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place.
The same gate drives the post-move interstitial config (`Profile.ads`, `adsFor`). The user card
also carries a **finance panel** (`payments.AccountStatement`): the account's chip balances per
funding segment, benefits per origin, the recorded refund risk, and the append-only ledger history
(newest first) — read straight from the payments tables, uncached. The **catalog editor**
(`/_gm/catalog`, `handlers_admin_catalog.go`) is the source of truth for products (D32): create /
edit / archive-unarchive (the `product.active` flag) products, their atoms and per-rail prices, and
hard-delete only a **never-transacted** product (an order/ledger reference forces archive-only,
backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The user card
also carries an admin **grant** panel: grant raw benefit atoms (hints / no-ads days / forever) or a
defined **value product** (a reward bundle, including an archived one), origin-picked; both write an
`admin_grant` ledger row via `payments.Grant` / `GrantProduct` and **refuse** a chips or `tournament`
atom (never grant currency; no tournament target yet). Each fund row in the panel carries a **Refund**
action (`payments.RefundOrderFull`): a full-order refund the operator records after refunding on the
rail — a `refund` ledger row + a floor-0 chip revoke, idempotent. A **ledger CSV export**
(`/_gm/ledger.csv`, `payments.LedgerExport`) dumps the whole append-only ledger for tax +
reconciliation. The shared wire
contracts live in the sibling [`../pkg`](../pkg) module. contracts live in the sibling [`../pkg`](../pkg) module.
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link` **Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
attached to the current account, and when the identity already has its own account attached to the current account, and when the identity already has its own account
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint the two are merged in one transaction (`internal/accountmerge`) — stats summed,
wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred, identities/games/chat/complaints transferred,
friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a
shared finished game's foreign keys hold); a shared **active** game blocks the merge. shared finished game's foreign keys hold); a shared **active** game blocks the merge.
The current account is primary, except a guest initiator whose linked identity has a The current account is primary, except a guest initiator whose linked identity has a
durable owner — then the durable account wins and a fresh session is minted for it. durable owner — then the durable account wins and a fresh session is minted for it.
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the The `accounts.merged_into`/`merged_at` columns back this. This supersedes the
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay). former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
Rate-limit observability: the gateway posts its periodic rejection Rate-limit observability: the gateway posts its periodic rejection
+74
View File
@@ -28,6 +28,7 @@ import (
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/feedback" "scrabble/backend/internal/feedback"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/link" "scrabble/backend/internal/link"
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
@@ -156,6 +157,17 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("active dictionary version", zap.String("version", games.ActiveVersion())) logger.Info("active dictionary version", zap.String("version", games.ActiveVersion()))
games.SetNotifier(hub) games.SetNotifier(hub)
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game")) games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
// Active-game limit config: the per-tier, per-kind caps in backend.config, read once into an
// in-memory cache at boot and refreshed when the admin edits them. A boot-time load fails fast if
// the single config row is missing; the game domain reads the cache on every new-game gate.
gameLimits := gamelimits.NewService(gamelimits.NewStore(db))
if err := gameLimits.Load(ctx); err != nil {
return fmt.Errorf("load game-limit config: %w", err)
}
games.SetGameLimits(gameLimits)
logger.Info("game-limit config loaded")
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval) go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
logger.Info("game turn-timeout sweeper started", logger.Info("game turn-timeout sweeper started",
zap.Duration("interval", cfg.Game.TimeoutSweepInterval)) zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
@@ -271,6 +283,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
} }
logger.Info("payments domain ready") logger.Info("payments domain ready")
// Warm the public-offer price list cache so /offer/ serves the current catalog from the first
// request; it is reprojected lazily thereafter on any catalog edit. Non-fatal — a transient
// failure here only defers the projection to the first read.
if _, err := paymentsSvc.OfferPricing(ctx); err != nil {
logger.Warn("offer pricing warm failed; will project on first request", zap.Error(err))
}
// Wire the payments surface into the domains that consume it: the online-game hint wallet // Wire the payments surface into the domains that consume it: the online-game hint wallet
// and the account-merge wallet fold. Done after the reachability check so a broken payments // and the account-merge wallet fold. Done after the reachability check so a broken payments
// schema fails boot before anything depends on it. // schema fails boot before anything depends on it.
@@ -305,9 +324,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
BanView: banView, BanView: banView,
Ads: adsSvc, Ads: adsSvc,
Payments: paymentsSvc, Payments: paymentsSvc,
GameLimits: gameLimits,
Notifier: hub, Notifier: hub,
ExportSignKey: cfg.ExportSignKey, ExportSignKey: cfg.ExportSignKey,
Renderer: renderer, Renderer: renderer,
Robokassa: cfg.Robokassa,
}) })
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger) pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
@@ -316,6 +337,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("servers starting", logger.Info("servers starting",
zap.String("http_addr", cfg.HTTPAddr), zap.String("http_addr", cfg.HTTPAddr),
zap.String("grpc_addr", cfg.GRPCAddr)) zap.String("grpc_addr", cfg.GRPCAddr))
// Sweep expired pending payment orders on a cadence (cosmetic hygiene; a late valid callback
// still credits). Runs until ctx is cancelled.
go runOrderReaper(ctx, paymentsSvc, logger)
// Deliver pending payment_events to connected clients as an in-app wallet-refresh push (the
// credit already landed in the ledger; a return-focus poll is the client-side fallback).
go runPaymentDispatcher(ctx, paymentsSvc, hub, logger)
errc := make(chan error, 2) errc := make(chan error, 2)
go func() { errc <- pushSrv.Run(ctx) }() go func() { errc <- pushSrv.Run(ctx) }()
go func() { errc <- srv.Run(ctx) }() go func() { errc <- srv.Run(ctx) }()
@@ -325,6 +353,52 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
return err return err
} }
// runOrderReaper periodically expires pending payment orders past their configured lifetime, until
// ctx is cancelled. Expiry is cosmetic: a later valid provider callback still credits an expired
// order.
func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n, err := p.ExpireOrders(ctx); err != nil {
log.Warn("order reaper: sweep failed", zap.Error(err))
} else if n > 0 {
log.Info("order reaper: expired pending orders", zap.Int("count", n))
}
}
}
}
// runPaymentDispatcher delivers pending payment_events to connected clients as a wallet-refresh
// signal (KindNotification / "payment"), marking each delivered, until ctx is cancelled. The credit
// already committed to the ledger; this is only the in-app push so an open wallet updates in place.
func runPaymentDispatcher(ctx context.Context, p *payments.Service, pub notify.Publisher, log *zap.Logger) {
t := time.NewTicker(3 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
evs, err := p.UndispatchedEvents(ctx, 50)
if err != nil {
log.Warn("payment dispatcher: read failed", zap.Error(err))
continue
}
for _, e := range evs {
pub.Publish(notify.Notification(e.AccountID, notify.NotifyPayment))
if err := p.MarkEventDispatched(ctx, e.EventID); err != nil {
log.Warn("payment dispatcher: mark failed", zap.String("event", e.EventID.String()), zap.Error(err))
}
}
}
}
}
// newMailer builds the confirm-code mailer: an SMTP relay when a host is // newMailer builds the confirm-code mailer: an SMTP relay when a host is
// configured, otherwise the development log mailer (the code is logged, not sent). // configured, otherwise the development log mailer (the code is logged, not sent).
func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer { func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer {
+16 -56
View File
@@ -43,9 +43,7 @@ var ErrNotFound = errors.New("account: not found")
// local-time window (in TimeZone) during which the player is asleep, so the // local-time window (in TimeZone) during which the player is asleep, so the
// turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's // turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's
// own sleep is anchored to its human opponent's timezone with a per-game drift, // own sleep is anchored to its human opponent's timezone with a per-game drift,
// computed in internal/robot, not from a robot account's away window.) HintBalance // computed in internal/robot, not from a robot account's away window.)
// is the player's wallet of purchasable hints, spent after a game's per-seat
// allowance.
type Account struct { type Account struct {
ID uuid.UUID ID uuid.UUID
DisplayName string DisplayName string
@@ -53,7 +51,6 @@ type Account struct {
TimeZone string TimeZone string
AwayStart time.Time AwayStart time.Time
AwayEnd time.Time AwayEnd time.Time
HintBalance int
BlockChat bool BlockChat bool
BlockFriendRequests bool BlockFriendRequests bool
// VariantPreferences is the set of game variants (engine.Variant stable labels: // VariantPreferences is the set of game variants (engine.Variant stable labels:
@@ -69,10 +66,6 @@ type Account struct {
// true (the default): the platform side-service skips out-of-app push for the // true (the default): the platform side-service skips out-of-app push for the
// account. // account.
NotificationsInAppOnly bool NotificationsInAppOnly bool
// PaidAccount marks a lifetime one-time-payment account. It is a service field
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
// never lost when accounts are consolidated.
PaidAccount bool
// MergedInto is the primary account a retired (merged) secondary points at, or // MergedInto is the primary account a retired (merged) secondary points at, or
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade // uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid. // foreign keys of a shared finished game stay valid.
@@ -393,6 +386,21 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity
return out, nil return out, nil
} }
// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail
// recovery anchor a first purchase requires (D36).
func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
ids, err := s.Identities(ctx, accountID)
if err != nil {
return false, err
}
for _, id := range ids {
if id.Kind == "email" && id.Confirmed {
return true, nil
}
}
return false, nil
}
// ListAccounts returns accounts for the admin user list, newest first, paginated // ListAccounts returns accounts for the admin user list, newest first, paginated
// by limit and offset. // by limit and offset.
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) { func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
@@ -548,52 +556,6 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account,
return modelToAccount(row), nil return modelToAccount(row), nil
} }
// SpendHint atomically decrements the account's hint wallet by one, returning
// true when a hint was spent and false when the balance was already empty. The
// guarded UPDATE keeps it safe under concurrent spends across the player's games.
func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
stmt := table.Accounts.
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
SET(table.Accounts.HintBalance.SUB(postgres.Int(1)), postgres.TimestampzT(time.Now().UTC())).
WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(id)).
AND(table.Accounts.HintBalance.GT(postgres.Int(0))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return false, fmt.Errorf("account: spend hint %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("account: spend hint rows %s: %w", id, err)
}
return n > 0, nil
}
// GrantHints adds n hints to the account's wallet and returns the new balance. n must be
// positive: the additive update can only raise the balance, never lower it, so it enforces the
// admin console's raise-only rule by construction and stays correct under a concurrent SpendHint.
// It returns ErrNotFound when no account matches.
func (s *Store) GrantHints(ctx context.Context, id uuid.UUID, n int) (int, error) {
if n <= 0 {
return 0, fmt.Errorf("account: grant hints %s: n must be positive, got %d", id, n)
}
stmt := table.Accounts.
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
SET(table.Accounts.HintBalance.ADD(postgres.Int(int64(n))), postgres.TimestampzT(time.Now().UTC())).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.HintBalance)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return 0, ErrNotFound
}
return 0, fmt.Errorf("account: grant hints %s: %w", id, err)
}
return int(row.HintBalance), nil
}
// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when // FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
// the account is not already flagged — the first sustained episode wins, and a // the account is not already flagged — the first sustained episode wins, and a
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not // re-flag after an operator clear starts a fresh timestamp. An infra marker, not
@@ -649,12 +611,10 @@ func modelToAccount(row model.Accounts) Account {
TimeZone: row.TimeZone, TimeZone: row.TimeZone,
AwayStart: row.AwayStart, AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd, AwayEnd: row.AwayEnd,
HintBalance: int(row.HintBalance),
BlockChat: row.BlockChat, BlockChat: row.BlockChat,
BlockFriendRequests: row.BlockFriendRequests, BlockFriendRequests: row.BlockFriendRequests,
IsGuest: row.IsGuest, IsGuest: row.IsGuest,
NotificationsInAppOnly: row.NotificationsInAppOnly, NotificationsInAppOnly: row.NotificationsInAppOnly,
PaidAccount: row.PaidAccount,
MergedInto: mergedInto, MergedInto: mergedInto,
FlaggedHighRateAt: flaggedHighRateAt, FlaggedHighRateAt: flaggedHighRateAt,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
@@ -15,12 +15,14 @@
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a> <a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a> <a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a> <a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
<a href="/_gm/limits"{{if eq .ActiveNav "limits"}} class="active"{{end}}>Limits</a>
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a> <a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a> <a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a> <a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a> <a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a> <a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a> <a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
<a href="/_gm/catalog"{{if eq .ActiveNav "catalog"}} class="active"{{end}}>Catalog</a>
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a> <a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a> <a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
<a href="/_gm/grafana/">Grafana ↗</a> <a href="/_gm/grafana/">Grafana ↗</a>
@@ -0,0 +1,44 @@
{{define "content" -}}
<h1>Product catalog</h1>
{{with .Data}}
<p class="note">A <strong>pack</strong> funds chips (a money price per rail — RUB via direct, VOTE via vk, XTR via telegram); a <strong>value</strong> buys benefits with chips (a CHIP price). Archived products are hidden from players but still credit an in-flight payment and can be granted. A product with transactions can only be archived, not deleted. Amounts are in minor units (RUB kopecks; VOTE/XTR/CHIP whole). The <code>tournament</code> atom is not sellable yet — keep such a product archived.</p>
<section class="panel"><h2>Add product</h2>
<form class="form col" method="post" action="/_gm/catalog">
<label>Title <input type="text" name="title" maxlength="120" required></label>
<fieldset><legend>Atoms (quantity; blank = none)</legend>
<label>Chips <input type="number" name="chips" min="0"></label>
<label>Hints <input type="number" name="hints" min="0"></label>
<label>No-ads days <input type="number" name="noads" min="0"></label>
<label>Tournament <input type="number" name="tournament" min="0"></label>
</fieldset>
<fieldset><legend>Prices (minor units; blank = none)</legend>
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0"></label>
<label>VOTE — vk <input type="number" name="price_vote" min="0"></label>
<label>XTR — telegram <input type="number" name="price_star" min="0"></label>
<label>CHIP — value <input type="number" name="price_chip" min="0"></label>
</fieldset>
<label><input type="checkbox" name="active" value="true"> Active (on sale)</label>
<div><button type="submit">Add</button></div>
</form>
</section>
<section class="panel"><h2>Products</h2>
<table class="list">
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
<tbody>
{{range .Products}}
<tr>
<td><a href="/_gm/catalog/{{.ID}}">{{.Title}}</a></td>
<td>{{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</td>
<td>{{range .Atoms}}<code>{{.Atom}}×{{.Quantity}}</code> {{end}}</td>
<td>{{range .Prices}}<code>{{.Currency}}{{if .Method}}/{{.Method}}{{end}} {{.Amount}}</code> {{end}}</td>
<td class="row-actions">
<form class="form" method="post" action="/_gm/catalog/{{.ID}}/archive"><input type="hidden" name="active" value="{{if .Active}}false{{else}}true{{end}}"><button type="submit">{{if .Active}}Archive{{else}}Unarchive{{end}}</button></form>
{{if not .Transacted}}<form class="form" method="post" action="/_gm/catalog/{{.ID}}/delete" onsubmit="return confirm('Delete this product? It has never been transacted, so this is safe and permanent.')"><button type="submit">Delete</button></form>{{end}}
</td>
</tr>
{{else}}<tr><td colspan="5"><span class="note">no products</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -8,6 +8,7 @@
<li><b>Dictionary</b> {{.DictVersion}}</li> <li><b>Dictionary</b> {{.DictVersion}}</li>
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li> <li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li> <li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
<li><b>Word rule</b> {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}</li>
<li><b>Players</b> {{.Players}}</li> <li><b>Players</b> {{.Players}}</li>
<li><b>To move</b> seat {{.ToMove}}</li> <li><b>To move</b> seat {{.ToMove}}</li>
<li><b>Moves</b> {{.MoveCount}}</li> <li><b>Moves</b> {{.MoveCount}}</li>
@@ -8,11 +8,11 @@
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a> <a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
</nav> </nav>
<table class="list"> <table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead> <thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody> <tbody>
{{range .Items}} {{range .Items}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr> <tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}} {{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
</tbody> </tbody>
</table> </table>
<nav class="pager"> <nav class="pager">
@@ -0,0 +1,19 @@
{{define "content" -}}
<h1>Active-game limits</h1>
{{with .Data}}
<p class="note">Per-tier, per-kind caps on a player's simultaneous unfinished games. <strong>-1</strong> = unlimited, <strong>0</strong> = the kind is blocked, a positive number caps concurrent games of that kind. Guests are additionally blocked from friend games outright. Changes apply immediately (no redeploy); games already in progress are never affected.</p>
<section class="panel">
<form class="form col" method="post" action="/_gm/limits">
<h2>Guest</h2>
<label>vs AI <input type="number" name="guest_vs_ai" min="-1" value="{{.GuestVsAI}}" required></label>
<label>Random <input type="number" name="guest_random" min="-1" value="{{.GuestRandom}}" required></label>
<label>Friends <input type="number" name="guest_friends" min="-1" value="{{.GuestFriends}}" required></label>
<h2>Durable account</h2>
<label>vs AI <input type="number" name="durable_vs_ai" min="-1" value="{{.DurableVsAI}}" required></label>
<label>Random <input type="number" name="durable_random" min="-1" value="{{.DurableRandom}}" required></label>
<label>Friends <input type="number" name="durable_friends" min="-1" value="{{.DurableFriends}}" required></label>
<div><button type="submit">Save</button></div>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,25 @@
{{define "content" -}}
{{with .Data}}
<p class="note"><a href="/_gm/catalog">← all products</a></p>
<h1>{{.Title}} {{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</h1>
<section class="panel"><h2>Edit</h2>
<p class="note">A zero quantity / blank price removes that atom / price. Amounts are in minor units. Saving revalidates the sellable shape when the product is active. Archive / unarchive from the <a href="/_gm/catalog">catalog list</a>.</p>
<form class="form col" method="post" action="/_gm/catalog/{{.ID}}">
<label>Title <input type="text" name="title" value="{{.Title}}" maxlength="120" required></label>
<fieldset><legend>Atoms (quantity; 0 = none)</legend>
<label>Chips <input type="number" name="chips" min="0" value="{{.Chips}}"></label>
<label>Hints <input type="number" name="hints" min="0" value="{{.Hints}}"></label>
<label>No-ads days <input type="number" name="noads" min="0" value="{{.NoAds}}"></label>
<label>Tournament <input type="number" name="tournament" min="0" value="{{.Tournament}}"></label>
</fieldset>
<fieldset><legend>Prices (minor units; 0 = none)</legend>
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0" value="{{.PriceRUB}}"></label>
<label>VOTE — vk <input type="number" name="price_vote" min="0" value="{{.PriceVote}}"></label>
<label>XTR — telegram <input type="number" name="price_star" min="0" value="{{.PriceStar}}"></label>
<label>CHIP — value <input type="number" name="price_chip" min="0" value="{{.PriceChip}}"></label>
</fieldset>
<div><button type="submit">Save</button></div>
</form>
</section>
{{end}}
{{- end}}
@@ -10,8 +10,6 @@
<li><b>Timezone</b> {{.TimeZone}}</li> <li><b>Timezone</b> {{.TimeZone}}</li>
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li> <li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li> <li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
<li><b>Hint wallet</b> {{.HintBalance}}</li>
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}} {{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}} {{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
<li><b>Created</b> {{.CreatedAt}}</li> <li><b>Created</b> {{.CreatedAt}}</li>
@@ -21,10 +19,6 @@
<button type="submit">Clear high-rate flag</button> <button type="submit">Clear high-rate flag</button>
</form> </form>
{{end}} {{end}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/grant-hints">
<label>Add hints <input type="number" name="amount" min="1" max="{{.HintGrantMax}}" value="1"></label>
<button type="submit">Grant</button>
</form>
</section> </section>
<section class="panel"><h2>Statistics</h2> <section class="panel"><h2>Statistics</h2>
{{if .HasStats}} {{if .HasStats}}
@@ -69,6 +63,51 @@
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div> <div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
</form> </form>
</section> </section>
<section class="panel"><h2>Finance</h2>
{{if .Finance.Present}}
{{if or .Finance.Segments .Finance.Benefits .Finance.Abuse .Finance.Loss}}
<ul class="kv">
{{range .Finance.Segments}}<li><b>Chips ({{.Source}})</b> {{.Chips}}</li>{{end}}
{{range .Finance.Benefits}}<li><b>Benefits ({{.Origin}})</b> {{.Hints}} hints{{if .Forever}} · no-ads forever{{else if .AdsUntil}} · no-ads until {{.AdsUntil}} (UTC){{end}}</li>{{end}}
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
</ul>
{{else}}<p class="note">no balances or benefits</p>{{end}}
<h3>Ledger</h3>
{{$uid := .ID}}
{{if .Finance.Ledger}}
<table class="list">
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th><th></th></tr></thead>
<tbody>
{{range .Finance.Ledger}}
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
{{end}}
</tbody>
</table>
<p class="note"><a href="/_gm/ledger.csv">Export the full ledger (CSV)</a> — all accounts, for tax + reconciliation.</p>
{{else}}<p class="note">no ledger entries</p>{{end}}
{{else}}<p class="note">payments not enabled</p>{{end}}
</section>
<section class="panel"><h2>Grant benefits</h2>
{{if .Grant.Present}}
<p class="note">A zero-price admin sale of a value — <strong>never chips</strong>. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.</p>
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant">
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
<label>Hints <input type="number" name="hints" min="0" value="0"></label>
<label>No-ads days <input type="number" name="noads" min="0" value="0"></label>
<label><input type="checkbox" name="forever" value="true"> No-ads forever</label>
<div><button type="submit">Grant</button></div>
</form>
{{if .Grant.Products}}
<h3>Grant a product</h3>
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant-product">
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
<label>Product <select name="product_id">{{range .Grant.Products}}<option value="{{.ID}}">{{.Title}} ({{.Summary}}){{if .Archived}} — archived{{end}}</option>{{end}}</select></label>
<div><button type="submit">Grant product</button></div>
</form>
{{else}}<p class="note">no grantable products — create a value product in the <a href="/_gm/catalog">catalog</a></p>{{end}}
{{else}}<p class="note">payments not enabled</p>{{end}}
</section>
<section class="panel"><h2>Roles</h2> <section class="panel"><h2>Roles</h2>
{{$id := .ID}} {{$id := .ID}}
{{if .Roles}} {{if .Roles}}
@@ -172,11 +211,11 @@
{{end}} {{end}}
<section class="panel"><h2>Games</h2> <section class="panel"><h2>Games</h2>
<table class="list"> <table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead> <thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody> <tbody>
{{range .Games}} {{range .Games}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr> <tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}} {{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
</tbody> </tbody>
</table> </table>
</section> </section>
+132 -5
View File
@@ -149,7 +149,6 @@ type UserDetailView struct {
TimeZone string TimeZone string
Guest bool Guest bool
NotificationsInAppOnly bool NotificationsInAppOnly bool
PaidAccount bool
// MergedInto is the primary account id when this account has been retired by a // MergedInto is the primary account id when this account has been retired by a
// merge, or empty for a live account. // merge, or empty for a live account.
MergedInto string MergedInto string
@@ -165,10 +164,6 @@ type UserDetailView struct {
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp, // FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
// empty for an unflagged account; the card shows it with the Clear action. // empty for an unflagged account; the card shows it with the Clear action.
FlaggedHighRateAt string FlaggedHighRateAt string
HintBalance int
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
// server's maxHintGrant), passed through so the policy value lives in one place.
HintGrantMax int
CreatedAt string CreatedAt string
HasStats bool HasStats bool
Stats StatsRow Stats StatsRow
@@ -200,6 +195,55 @@ type UserDetailView struct {
Blocks []RelationRow Blocks []RelationRow
BlockedBy []RelationRow BlockedBy []RelationRow
Friends []RelationRow Friends []RelationRow
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
// is false when the payments domain is unwired.
Finance FinanceView
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
// payments domain is unwired.
Grant GrantFormView
}
// FinanceView is the account's payments picture on the user card: chip balances per funding
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
// (newest first). Present is false when the payments domain is unwired.
type FinanceView struct {
Present bool
Segments []SegmentRow
Benefits []BenefitRow
// Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
Abuse bool
Loss int
Ledger []LedgerRow
}
// SegmentRow is one funding segment's chip balance.
type SegmentRow struct {
Source string
Chips int
}
// BenefitRow is one origin's benefit: the hint wallet, the ad-free expiry (pre-formatted, empty
// when none) and the lifetime ad-free flag.
type BenefitRow struct {
Origin string
Hints int
AdsUntil string
Forever bool
}
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
// delta, the product / order / provider it references (empty when none), the raw snapshot JSON and
// the pre-formatted time.
type LedgerRow struct {
Kind string
Source string
Origin string
ChipsDelta int
Product string
Order string
Provider string
Snapshot string
At string
} }
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends // RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
@@ -268,6 +312,8 @@ type GameRow struct {
UpdatedAt string UpdatedAt string
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column). // VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
VsAI bool VsAI bool
// Kind is the game's origin tag label: vs_ai / random / friends / unknown.
Kind string
} }
// GamesView is the paginated games list, optionally filtered by status. // GamesView is the paginated games list, optionally filtered by status.
@@ -277,6 +323,17 @@ type GamesView struct {
Pager Pager Pager Pager
} }
// GameLimitsView is the per-tier, per-kind active-game limit form: each field is a cap where -1
// is unlimited, 0 blocks the kind, and a positive value caps concurrent games of that kind.
type GameLimitsView struct {
GuestVsAI int
GuestRandom int
GuestFriends int
DurableVsAI int
DurableRandom int
DurableFriends int
}
// GameDetailView is one game with its seats. // GameDetailView is one game with its seats.
type GameDetailView struct { type GameDetailView struct {
ID string ID string
@@ -292,6 +349,10 @@ type GameDetailView struct {
FinishedAt string FinishedAt string
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary). // VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
VsAI bool VsAI bool
// MultipleWordsPerTurn is the game's cross-word rule: true = standard Scrabble (every cross-word
// is validated and scored), false = the single-word rule (only the main word along the play
// direction counts). Shown in the summary so an operator can tell the rule at a glance.
MultipleWordsPerTurn bool
Seats []SeatRow Seats []SeatRow
// HasRobot is true when any seat is a robot, gating the robot-target caption; // HasRobot is true when any seat is a robot, gating the robot-target caption;
// RobotTargetPct is the configured global play-to-win rate, in percent. // RobotTargetPct is the configured global play-to-win rate, in percent.
@@ -612,3 +673,69 @@ type FeedbackDetailView struct {
UserTZ string UserTZ string
Banned bool Banned bool
} }
// CatalogView is the product-catalog list page.
type CatalogView struct {
Products []ProductRow
}
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
// (Active) and the transacted flag (which forbids a hard delete).
type ProductRow struct {
ID string
Title string
Active bool
Atoms []AtomRow
Prices []PriceRow
Transacted bool
}
// AtomRow is one atom line of a product row.
type AtomRow struct {
Atom string
Quantity int
}
// PriceRow is one price of a product: the method ("" for a value's CHIP price), the currency, and
// the amount in that currency's minor units.
type PriceRow struct {
Method string
Currency string
Amount int64
}
// ProductFormView is the product edit form, pre-filled from the current composition. Atom quantities
// and prices are flattened to the fixed fields the form offers (0 = absent); Transacted disables the
// delete action.
type ProductFormView struct {
ID string
Title string
Active bool
Chips int
Hints int
NoAds int
Tournament int
PriceRUB int64
PriceVote int64
PriceStar int64
PriceChip int64
Transacted bool
}
// GrantFormView is the admin-grant panel on the user card: the origin picker and the grantable
// products (value bundles — hints / no-ads days — including archived ones; chips and tournament
// products are excluded). Present is false when the payments domain is unwired.
type GrantFormView struct {
Present bool
Origins []string
Products []GrantProductOption
}
// GrantProductOption is one grantable product in the by-product picker: its id, title, an atom
// summary, and whether it is archived (the common case for a non-public reward bundle).
type GrantProductOption struct {
ID string
Title string
Summary string
Archived bool
}
+15
View File
@@ -14,6 +14,7 @@ import (
"scrabble/backend/internal/lobby" "scrabble/backend/internal/lobby"
"scrabble/backend/internal/postgres" "scrabble/backend/internal/postgres"
"scrabble/backend/internal/ratewatch" "scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/robot" "scrabble/backend/internal/robot"
"scrabble/backend/internal/telemetry" "scrabble/backend/internal/telemetry"
) )
@@ -64,6 +65,9 @@ type Config struct {
// RendererURL is the base URL of the internal image-render sidecar (e.g. // RendererURL is the base URL of the internal image-render sidecar (e.g.
// http://renderer:8090). Empty disables the PNG export artifact. // http://renderer:8090). Empty disables the PNG export artifact.
RendererURL string RendererURL string
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
// leaves the direct order and Result-callback endpoints unregistered.
Robokassa robokassa.Config
} }
// Defaults applied when the corresponding environment variable is unset. // Defaults applied when the corresponding environment variable is unset.
@@ -153,6 +157,13 @@ func Load() (Config, error) {
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"), AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
} }
robo := robokassa.Config{
MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"),
Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"),
Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"),
IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1",
}
c := Config{ c := Config{
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr), GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
@@ -170,6 +181,7 @@ func Load() (Config, error) {
GuestRetention: guestRetention, GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"), ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"), RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
Robokassa: robo,
} }
if err := c.validate(); err != nil { if err := c.validate(); err != nil {
return Config{}, err return Config{}, err
@@ -222,6 +234,9 @@ func (c Config) validate() error {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL) return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
} }
} }
if c.Robokassa.MerchantLogin != "" && (c.Robokassa.Password1 == "" || c.Robokassa.Password2 == "") {
return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is")
}
return nil return nil
} }
+1 -1
View File
@@ -52,6 +52,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI, VsAI: g.VsAI,
Kind: int(g.Kind),
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
EndReason: g.EndReason, EndReason: g.EndReason,
Seats: seats, Seats: seats,
@@ -74,7 +75,6 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
Rack: rack, Rack: rack,
BagLen: v.BagLen, BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining, HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
} }
if includeAlphabet { if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant) tab, err := engine.AlphabetTable(v.Game.Variant)
+8 -8
View File
@@ -55,16 +55,16 @@ func TestPayloadExchangeRoundTrip(t *testing.T) {
} }
func TestHintsRemaining(t *testing.T) { func TestHintsRemaining(t *testing.T) {
cases := []struct{ allowance, used, wallet, want int }{ cases := []struct{ allowance, used, want int }{
{1, 0, 3, 4}, {1, 0, 1},
{1, 1, 3, 3}, {1, 1, 0},
{1, 2, 3, 3}, // used past allowance clamps to 0 {1, 2, 0}, // used past allowance clamps to 0
{0, 0, 5, 5}, {3, 1, 2},
{2, 1, 0, 1}, {0, 0, 0},
} }
for _, c := range cases { for _, c := range cases {
if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want { if got := hintsRemaining(c.allowance, c.used); got != c.want {
t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want) t.Errorf("hintsRemaining(%d,%d) = %d, want %d", c.allowance, c.used, got, c.want)
} }
} }
} }
+45 -19
View File
@@ -17,6 +17,7 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
"scrabble/backend/internal/payments" "scrabble/backend/internal/payments"
"scrabble/backend/internal/session" "scrabble/backend/internal/session"
@@ -57,6 +58,9 @@ type Service struct {
// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are // nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
// wallet-free and never touch it. // wallet-free and never touch it.
hintWallet HintWallet hintWallet HintWallet
// limits is the per-tier active-game cap config (cached). Set by SetGameLimits during wiring;
// when nil, no active-game limit is enforced (a game is always creatable).
limits *gamelimits.Service
// clearNudges, when set, marks the actor's pending nudges in a game read once they // clearNudges, when set, marks the actor's pending nudges in a game read once they
// have committed a move (a nudge answered by moving stops counting as unread). It is // have committed a move (a nudge answered by moving stops counting as unread). It is
// best-effort and kept as a func so the game package never imports the social package. // best-effort and kept as a func so the game package never imports the social package.
@@ -126,6 +130,37 @@ func (svc *Service) SetHintWallet(w HintWallet) {
svc.hintWallet = w svc.hintWallet = w
} }
// SetGameLimits installs the active-game limit config (cached), enabling the per-tier caps. When
// unset (nil), a game is always creatable.
func (svc *Service) SetGameLimits(l *gamelimits.Service) {
svc.limits = l
}
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind — the
// per-tier, per-kind limits held in backend.config, read from the in-memory cache. It resolves
// the caller's tier (guest vs durable) from the account, then counts its open+active games of that
// kind. It reports false (not at the limit) when the limits config is not wired, the account is nil,
// or the resolved cap is gamelimits.Unlimited. It backs the new-game gate (the handler aborts 409
// game_limit_reached) and the lobby's at-limit flag.
func (svc *Service) AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error) {
if svc.limits == nil || accountID == uuid.Nil {
return false, nil
}
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return false, err
}
limit := svc.limits.LimitsFor(acc.IsGuest).Cap(kind)
if limit == gamelimits.Unlimited {
return false, nil
}
n, err := svc.store.CountActiveByKind(ctx, accountID, kind)
if err != nil {
return false, err
}
return n >= limit, nil
}
// walletContext resolves the payments gate inputs for an account on the current request: the // walletContext resolves the payments gate inputs for an account on the current request: the
// trusted execution context (from the session platform carried on ctx; absent ⇒ untrusted) and // trusted execution context (from the session platform carried on ctx; absent ⇒ untrusted) and
// the account's present identity sources (which chip/benefit segments are awake, §6). // the account's present identity sources (which chip/benefit segments are awake, §6).
@@ -338,6 +373,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
dropoutTiles: params.DropoutTiles.String(), dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn, multipleWordsPerTurn: params.MultipleWordsPerTurn,
vsAI: params.VsAI, vsAI: params.VsAI,
kind: params.Kind,
} }
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil { if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
return Game{}, err return Game{}, err
@@ -401,6 +437,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
multipleWordsPerTurn: params.MultipleWordsPerTurn, multipleWordsPerTurn: params.MultipleWordsPerTurn,
status: StatusOpen, status: StatusOpen,
openDeadline: &deadline, openDeadline: &deadline,
kind: gamelimits.KindRandom,
} }
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a // Decide the first move now by the official draw, with the not-yet-arrived opponent as a
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves // synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
@@ -1207,7 +1244,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
return HintResult{}, err return HintResult{}, err
} }
used++ used++
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used), WalletBalance: walletAfter}, nil
} }
// Candidates returns the to-move player's legal plays for a seated player on // Candidates returns the to-move player's legal plays for a seated player on
@@ -1272,10 +1309,6 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
if !ok { if !ok {
return StateView{}, ErrNotAPlayer return StateView{}, ErrNotAPlayer
} }
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return StateView{}, err
}
unlock := svc.locks.lock(gameID) unlock := svc.locks.lock(gameID)
defer unlock() defer unlock()
@@ -1294,8 +1327,9 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
Seat: seat, Seat: seat,
Rack: g.Hand(seat), Rack: g.Hand(seat),
BagLen: g.BagLen(), BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance), // HintsRemaining is the per-seat allowance only; the purchasable wallet lives on the profile
WalletBalance: acc.HintBalance, // (payments) and the client adds it (lib/hints.hintsLeft).
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed),
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn). // vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()), HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
}, nil }, nil
@@ -1389,14 +1423,6 @@ func (svc *Service) ListForLobby(ctx context.Context, accountID uuid.UUID) ([]Ga
return kept, nil return kept, nil
} }
// CountActiveQuickGames reports how many in-progress quick games the account holds —
// the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It
// counts active and still-open quick games (including honest-AI ones) and excludes
// friend games created by invitation and finished games. See Store.CountActiveQuickGames.
func (svc *Service) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
return svc.store.CountActiveQuickGames(ctx, accountID)
}
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other // HideGame hides a finished game from accountID's own lobby (it stays visible to the other
// players); it is irreversible by design. Only a player of a finished game may hide it // players); it is irreversible by design. Only a player of a finished game may hide it
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op. // (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
@@ -1776,10 +1802,10 @@ func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, e
return svc.registry.DictBytes(variant, version) return svc.registry.DictBytes(variant, version)
} }
// hintsRemaining is a player's remaining hint budget: the unspent per-game // hintsRemaining is the unspent per-game hint allowance. The purchasable wallet is separate,
// allowance plus the profile wallet. // carried on the profile (payments), and the client adds it (lib/hints.hintsLeft).
func hintsRemaining(allowance, used, wallet int) int { func hintsRemaining(allowance, used int) int {
return max(0, allowance-used) + wallet return max(0, allowance-used)
} }
// allowedTimeout reports whether d is one of the offered move clocks. // allowedTimeout reports whether d is one of the offered move clocks.
+20 -24
View File
@@ -15,6 +15,7 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table" "scrabble/backend/internal/postgres/jet/backend/table"
) )
@@ -45,6 +46,8 @@ type gameInsert struct {
multipleWordsPerTurn bool multipleWordsPerTurn bool
// vsAI marks an honest-AI game (games.vs_ai). // vsAI marks an honest-AI game (games.vs_ai).
vsAI bool vsAI bool
// kind tags the game's origin (games.game_kind) for the active-game limits.
kind gamelimits.Kind
// status is the lifecycle state to create the game in: StatusActive for a normal // status is the lifecycle state to create the game in: StatusActive for a normal
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An // seated game, StatusOpen for an auto-match game still awaiting an opponent. An
// empty string defaults to StatusActive. // empty string defaults to StatusActive.
@@ -138,6 +141,20 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInse
}) })
} }
// CountActiveByKind counts the account's active (open or in-progress) games of the given kind — the
// per-tier active-game limit is checked against it before a new game of that kind is created.
func (s *Store) CountActiveByKind(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx,
`SELECT count(*) FROM backend.games g
JOIN backend.game_players p ON p.game_id = g.game_id
WHERE p.account_id = $1 AND g.game_kind = $2 AND g.status IN ('open', 'active')`,
accountID, int16(kind)).Scan(&n); err != nil {
return 0, fmt.Errorf("game: count active by kind %s: %w", accountID, err)
}
return n, nil
}
// insertGameTx inserts the games row and one game_players row per seat (seat 0 // insertGameTx inserts the games row and one game_players row per seat (seat 0
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is // first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty // uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
@@ -155,9 +172,9 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed, table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt, table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players, ).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI) ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind))
if _, err := gi.ExecContext(ctx, tx); err != nil { if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err) return fmt.Errorf("insert game: %w", err)
} }
@@ -501,28 +518,6 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
return out, nil return out, nil
} }
// CountActiveQuickGames counts the account's in-progress quick games — the ones the
// simultaneous-game limit (MaxActiveQuickGames) is checked against. It includes both
// active and still-open (awaiting-opponent) games, the honest-AI ones among them, and
// excludes friend games (those linked to a game_invitations row) and finished games.
// A hidden game still occupies a slot, so this is a dedicated count rather than a
// filter over ListGamesForAccount (which drops hidden games). Joining on the account's
// own seat counts each game once (an open game's empty opponent seat has no account).
func (s *Store) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
// The status literals are game.StatusActive / game.StatusOpen, matching the
// games.status CHECK in the baseline migration.
const q = `
SELECT COUNT(*) FROM backend.games g
JOIN backend.game_players gp ON gp.game_id = g.game_id
LEFT JOIN backend.game_invitations gi ON gi.game_id = g.game_id
WHERE gp.account_id = $1 AND g.status IN ('active', 'open') AND gi.game_id IS NULL`
var n int
if err := s.db.QueryRowContext(ctx, q, accountID).Scan(&n); err != nil {
return 0, fmt.Errorf("game: count active quick games: %w", err)
}
return n, nil
}
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the // HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
// game is finished and the account is a player. // game is finished and the account is a player.
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error { func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
@@ -1361,6 +1356,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
} }
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
out.VsAI = g.VsAi out.VsAI = g.VsAi
out.Kind = gamelimits.Kind(g.GameKind)
if g.EndReason != nil { if g.EndReason != nil {
out.EndReason = *g.EndReason out.EndReason = *g.EndReason
} }
+11 -16
View File
@@ -7,6 +7,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
) )
// Status values persisted in the games.status column. // Status values persisted in the games.status column.
@@ -94,15 +95,6 @@ const DefaultTurnTimeout = 24 * time.Hour
// one of AllowedTurnTimeouts (never offered in the creation UI). // one of AllowedTurnTimeouts (never offered in the creation UI).
const AIInactivityTimeout = 7 * 24 * time.Hour const AIInactivityTimeout = 7 * 24 * time.Hour
// MaxActiveQuickGames is the cap on a player's simultaneous quick games (human
// auto-match and honest-AI), counting both in-progress (StatusActive) and
// still-open, awaiting-opponent (StatusOpen) games. Friend games created by
// invitation are not counted. At the cap the backend refuses to create a new game
// of any kind — quick or by invitation — and the lobby disables "New Game";
// accepting an incoming invitation is always allowed. See
// Store.CountActiveQuickGames.
const MaxActiveQuickGames = 10
// aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded // aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
// game file shows a clean "AI" rather than the robot's human-like pool name (the in-app // game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
// UI shows 🤖 from the game's vs_ai flag). // UI shows 🤖 from the game's vs_ai flag).
@@ -125,6 +117,10 @@ type CreateParams struct {
// robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge // robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge
// and finish-time statistics are suppressed. Set by the lobby's AI-match path. // and finish-time statistics are suppressed. Set by the lobby's AI-match path.
VsAI bool VsAI bool
// Kind tags the game's origin (games.game_kind) for the active-game limits: vs_ai / random /
// friends. The lobby sets it; a zero (unknown) kind is never gated. The active-game limit itself
// is enforced at the new-game handler (Server.ensureUnderGameLimit), not here.
Kind gamelimits.Kind
} }
// Game is the persisted state of a match: the games row joined with its seats. // Game is the persisted state of a match: the games row joined with its seats.
@@ -151,6 +147,9 @@ type Game struct {
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the // VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics. // player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
VsAI bool VsAI bool
// Kind is the game's origin tag (games.game_kind) for the active-game limits: vs_ai / random /
// friends, or unknown for an untagged game. Read-only projection; set once on creation.
Kind gamelimits.Kind
} }
// Seat is one player's standing in a game. // Seat is one player's standing in a game.
@@ -211,10 +210,9 @@ type MoveResult struct {
BagLen int BagLen int
} }
// HintResult is a revealed hint and the requesting player's remaining hint // HintResult is a revealed hint with the per-seat allowance remaining (HintsRemaining) and the
// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is // purchasable hint wallet after spending one (WalletBalance, from payments). The client adopts
// the global wallet alone, so the client can keep its live wallet authoritative and // WalletBalance into the profile so the badge stays live across games (lib/hints).
// re-derive the per-game allowance (HintsRemaining - WalletBalance).
type HintResult struct { type HintResult struct {
Move engine.MoveRecord Move engine.MoveRecord
HintsRemaining int HintsRemaining int
@@ -241,9 +239,6 @@ type StateView struct {
Rack []string Rack []string
BagLen int BagLen int
HintsRemaining int HintsRemaining int
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
// it in with the per-game allowance), so the client keeps the wallet live across games.
WalletBalance int
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left // HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left
// until the idle hint unlocks (the robot's last move plus the idle window, from the server clock); // until the idle hint unlocks (the robot's last move plus the idle window, from the server clock);
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is // 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
+149
View File
@@ -0,0 +1,149 @@
// Package gamelimits holds the per-tier, per-kind active-game caps (a guest funnel) with a hot
// in-memory cache over the single-row backend.config, so a login or a game-create never queries it.
package gamelimits
import (
"context"
"database/sql"
"fmt"
"sync"
"github.com/go-jet/jet/v2/postgres"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Kind is a game's kind, mirroring backend.games.game_kind. 0 (unknown) is an untagged game, never gated.
type Kind int16
const (
KindUnknown Kind = 0
KindVsAI Kind = 1
KindRandom Kind = 2
KindFriends Kind = 3
)
// String returns the kind's label for admin game lists and logs.
func (k Kind) String() string {
switch k {
case KindVsAI:
return "vs_ai"
case KindRandom:
return "random"
case KindFriends:
return "friends"
default:
return "unknown"
}
}
// Unlimited is the limit sentinel meaning no cap.
const Unlimited = -1
// Limits is a tier's active-game caps per kind (-1 = unlimited).
type Limits struct {
VsAI int
Random int
Friends int
}
// Cap returns the limit for kind; the unknown kind is never capped.
func (l Limits) Cap(kind Kind) int {
switch kind {
case KindVsAI:
return l.VsAI
case KindRandom:
return l.Random
case KindFriends:
return l.Friends
default:
return Unlimited
}
}
// Config is the per-tier limit config (the single backend.config row).
type Config struct {
Guest Limits
Durable Limits
}
// Store reads and writes the single-row backend.config.
type Store struct{ db *sql.DB }
// NewStore constructs a Store over db.
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func (s *Store) load(ctx context.Context) (Config, error) {
var c model.Config
if err := postgres.SELECT(table.Config.AllColumns).FROM(table.Config).LIMIT(1).
QueryContext(ctx, s.db, &c); err != nil {
return Config{}, fmt.Errorf("gamelimits: load config: %w", err)
}
return Config{
Guest: Limits{VsAI: int(c.GuestVsAiLimit), Random: int(c.GuestRandomLimit), Friends: int(c.GuestFriendsLimit)},
Durable: Limits{VsAI: int(c.DurableVsAiLimit), Random: int(c.DurableRandomLimit), Friends: int(c.DurableFriendsLimit)},
}, nil
}
func (s *Store) save(ctx context.Context, c Config) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE backend.config SET guest_vs_ai_limit=$1, guest_random_limit=$2, guest_friends_limit=$3,
durable_vs_ai_limit=$4, durable_random_limit=$5, durable_friends_limit=$6 WHERE only_row`,
c.Guest.VsAI, c.Guest.Random, c.Guest.Friends, c.Durable.VsAI, c.Durable.Random, c.Durable.Friends); err != nil {
return fmt.Errorf("gamelimits: save config: %w", err)
}
return nil
}
// Service fronts the config with an in-memory cache (single-instance, matching the deploy). Load
// once at startup; Update after an admin edit refreshes it in place.
type Service struct {
store *Store
mu sync.RWMutex
cfg Config
}
// NewService constructs a Service over store. Call Load before serving.
func NewService(store *Store) *Service { return &Service{store: store} }
// Load reads the config into the cache. Call once at startup.
func (s *Service) Load(ctx context.Context) error {
c, err := s.store.load(ctx)
if err != nil {
return err
}
s.set(c)
return nil
}
func (s *Service) set(c Config) {
s.mu.Lock()
s.cfg = c
s.mu.Unlock()
}
// Get returns the cached config.
func (s *Service) Get() Config {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg
}
// LimitsFor returns the cached limits for the account tier (guest or durable).
func (s *Service) LimitsFor(isGuest bool) Limits {
c := s.Get()
if isGuest {
return c.Guest
}
return c.Durable
}
// Update saves the config and refreshes the cache in place (the admin edit).
func (s *Service) Update(ctx context.Context, c Config) error {
if err := s.store.save(ctx, c); err != nil {
return err
}
s.set(c)
return nil
}
@@ -0,0 +1,44 @@
package gamelimits
import "testing"
// TestLimitsCap checks Cap maps each kind to its field and leaves the unknown kind uncapped, so a
// untagged game (game_kind 0) is never gated.
func TestLimitsCap(t *testing.T) {
l := Limits{VsAI: 1, Random: 2, Friends: 3}
for _, tc := range []struct {
kind Kind
want int
}{
{KindVsAI, 1},
{KindRandom, 2},
{KindFriends, 3},
{KindUnknown, Unlimited},
{Kind(99), Unlimited},
} {
if got := l.Cap(tc.kind); got != tc.want {
t.Errorf("Cap(%d) = %d, want %d", tc.kind, got, tc.want)
}
}
}
// TestServiceLimitsForTier checks LimitsFor selects the guest or durable tier from the cached config.
func TestServiceLimitsForTier(t *testing.T) {
svc := NewService(nil)
svc.set(Config{
Guest: Limits{VsAI: 1, Random: 1, Friends: 0},
Durable: Limits{VsAI: 10, Random: 10, Friends: 10},
})
if got := svc.LimitsFor(true); got != (Limits{VsAI: 1, Random: 1, Friends: 0}) {
t.Errorf("guest limits = %+v, want {1 1 0}", got)
}
if got := svc.LimitsFor(false); got != (Limits{VsAI: 10, Random: 10, Friends: 10}) {
t.Errorf("durable limits = %+v, want {10 10 10}", got)
}
// The unlimited sentinel resolves through the tier too.
svc.set(Config{Durable: Limits{VsAI: Unlimited, Random: Unlimited, Friends: Unlimited}})
if got := svc.LimitsFor(false).Cap(KindVsAI); got != Unlimited {
t.Errorf("durable vs_ai cap = %d, want unlimited (-1)", got)
}
}
@@ -0,0 +1,79 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"strings"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// benefitFor returns the account's benefit on the given origin from its statement.
func benefitFor(t *testing.T, pay *payments.Service, id uuid.UUID, origin payments.Source) payments.OriginBenefit {
t.Helper()
stmt, err := pay.AccountStatement(context.Background(), id)
if err != nil {
t.Fatalf("statement: %v", err)
}
for _, b := range stmt.Benefits {
if b.Origin == origin {
return b
}
}
return payments.OriginBenefit{}
}
// TestConsoleAdminGrant drives the admin grant: a raw benefit grant, a by-product grant of a reward
// bundle, and a refusal to grant a chips pack; the create is CSRF-guarded.
func TestConsoleAdminGrant(t *testing.T) {
ctx := context.Background()
srv, _, pay := bannerServer(t)
h := srv.Handler()
id := provisionAccount(t)
const origin = "http://admin.test"
base := "http://admin.test/_gm/users/" + id.String()
// CSRF: a grant without the origin header is refused.
if code, _ := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=1", ""); code != http.StatusForbidden {
t.Fatalf("grant without origin = %d, want 403", code)
}
// Raw grant: 5 hints + 30 no-ads days to the direct origin.
if code, body := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=5&noads=30", origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
t.Fatalf("raw grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
}
if b := benefitFor(t, pay, id, payments.SourceDirect); b.Hints != 5 || b.AdsPaidUntil.IsZero() {
t.Fatalf("after raw grant: hints=%d adsUntil-zero=%v, want 5 hints + a no-ads term", b.Hints, b.AdsPaidUntil.IsZero())
}
// By-product grant: an archived reward bundle (3 hints) to vk.
reward, err := pay.CreateProduct(ctx, payments.ProductInput{
Title: "reward-3-hints", Atoms: []payments.AtomLine{{Atom: "hints", Quantity: 3}},
}, false)
if err != nil {
t.Fatalf("create reward: %v", err)
}
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=vk&product_id="+reward.String(), origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
t.Fatalf("product grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
}
if b := benefitFor(t, pay, id, payments.SourceVK); b.Hints != 3 {
t.Fatalf("after product grant: vk hints=%d, want 3", b.Hints)
}
// A chips pack cannot be granted.
pack, err := pay.CreateProduct(ctx, payments.ProductInput{
Title: "grant-pack", Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 100}},
Prices: []payments.PriceLine{{Method: "direct", Currency: payments.CurrencyRUB, Amount: 14900}},
}, true)
if err != nil {
t.Fatalf("create pack: %v", err)
}
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=direct&product_id="+pack.String(), origin); code != http.StatusOK || !strings.Contains(body, "cannot grant chips") {
t.Fatalf("chips grant = %d, has 'cannot grant chips' = %v", code, strings.Contains(body, "cannot grant chips"))
}
}
@@ -0,0 +1,77 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"strings"
"testing"
"scrabble/backend/internal/payments"
)
// TestConsoleRefundAndExport drives the manual refund and the ledger CSV export: a funded order is
// refunded in full (chips revoked, a refund row), the refund is idempotent, and the export carries
// the fund + refund rows; the refund POST is CSRF-guarded.
func TestConsoleRefundAndExport(t *testing.T) {
ctx := context.Background()
srv, _, pay := bannerServer(t)
h := srv.Handler()
id := provisionAccount(t)
const origin = "http://admin.test"
base := "http://admin.test/_gm/users/" + id.String()
// Fund a pack: 100 chips + a fund ledger row.
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("order: %v", err)
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
t.Fatalf("fund: %v", err)
}
// CSRF: a refund without the origin header is refused.
if code, _ := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), ""); code != http.StatusForbidden {
t.Fatalf("refund without origin = %d, want 403", code)
}
// Refund the order in full → 100 chips revoked (none spent).
if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
t.Fatalf("refund = %d, has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
}
stmt, err := pay.AccountStatement(ctx, id)
if err != nil {
t.Fatalf("statement: %v", err)
}
for _, sg := range stmt.Segments {
if sg.Source == payments.SourceDirect && sg.Chips != 0 {
t.Errorf("after refund: direct chips = %d, want 0", sg.Chips)
}
}
kinds := map[string]int{}
for _, e := range stmt.Ledger {
kinds[e.Kind]++
}
if kinds["fund"] != 1 || kinds["refund"] != 1 {
t.Errorf("ledger kinds = %v, want one fund + one refund", kinds)
}
// A second refund of the same order is idempotent.
if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "Already refunded") {
t.Fatalf("second refund = %d, has 'Already refunded' = %v", code, strings.Contains(body, "Already refunded"))
}
// Export the whole ledger as CSV: the header, this account, and its fund + refund rows.
code, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/ledger.csv", "", "")
if code != http.StatusOK {
t.Fatalf("export = %d, want 200", code)
}
for _, want := range []string{"created_at,account_id,kind", id.String(), ",fund,", ",refund,"} {
if !strings.Contains(body, want) {
t.Errorf("ledger CSV missing %q", want)
}
}
}
-71
View File
@@ -4,7 +4,6 @@ package inttest
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -280,76 +279,6 @@ func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
} }
} }
// TestConsoleGrantHints drives the admin hint-wallet grant end to end: the card shows the form,
// the action is CSRF-guarded, a same-origin grant adds to the wallet, a second grant adds again
// (rather than replacing), the inclusive per-grant cap is accepted, and an out-of-range or
// non-numeric amount is refused without changing the balance.
func TestConsoleGrantHints(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
id := provisionAccount(t)
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(), Accounts: accounts, Games: newGameService(), Registry: testRegistry, DictDir: dictDir(),
})
h := srv.Handler()
base := "http://admin.test/_gm/users/" + id.String()
if code, body := consoleDo(h, http.MethodGet, base, "", ""); code != http.StatusOK || !strings.Contains(body, "Add hints") {
t.Fatalf("user card = %d, has grant form = %v", code, strings.Contains(body, "Add hints"))
}
// The grant POST is CSRF-guarded like every console action.
if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", ""); code != http.StatusForbidden {
t.Fatalf("grant without origin = %d, want 403", code)
}
// A same-origin grant adds to the wallet.
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 5") {
t.Fatalf("grant 5 = %d, body has 'now 5' = %v", code, strings.Contains(body, "now 5"))
}
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 5 {
t.Fatalf("after grant 5: balance=%d err=%v, want 5", acc.HintBalance, err)
}
// A second grant adds again rather than replacing.
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=3", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 8") {
t.Fatalf("grant 3 = %d, body has 'now 8' = %v", code, strings.Contains(body, "now 8"))
}
// An out-of-range or non-numeric amount is refused; the balance is left untouched.
for _, bad := range []string{"0", "-1", "101", "x", ""} {
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount="+bad, "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Invalid amount") {
t.Fatalf("grant %q = %d, has 'Invalid amount' = %v", bad, code, strings.Contains(body, "Invalid amount"))
}
}
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 8 {
t.Fatalf("after invalid grants: balance=%d err=%v, want 8", acc.HintBalance, err)
}
// The inclusive per-grant cap (100) is accepted.
if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=100", "http://admin.test"); code != http.StatusOK {
t.Fatalf("grant 100 = %d, want 200", code)
}
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 108 {
t.Fatalf("after grant 100: balance=%d err=%v, want 108", acc.HintBalance, err)
}
}
// TestGrantHintsStore covers the wallet store method directly: an additive grant raises the
// balance, a non-positive grant is rejected, and an unknown account yields ErrNotFound.
func TestGrantHintsStore(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
id := provisionAccount(t)
if bal, err := accounts.GrantHints(ctx, id, 4); err != nil || bal != 4 {
t.Fatalf("grant 4 = (%d, %v), want (4, nil)", bal, err)
}
if bal, err := accounts.GrantHints(ctx, id, 6); err != nil || bal != 10 {
t.Fatalf("grant 6 = (%d, %v), want (10, nil)", bal, err)
}
if _, err := accounts.GrantHints(ctx, id, 0); err == nil {
t.Error("grant 0 should be rejected (non-positive)")
}
if _, err := accounts.GrantHints(ctx, uuid.New(), 1); !errors.Is(err, account.ErrNotFound) {
t.Errorf("grant unknown account = %v, want ErrNotFound", err)
}
}
// consoleDo issues a request to h, optionally with an Origin header, and returns // consoleDo issues a request to h, optionally with an Origin header, and returns
// the status and body. Form bodies are sent as application/x-www-form-urlencoded. // the status and body. Form bodies are sent as application/x-www-form-urlencoded.
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) { func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
@@ -191,7 +191,7 @@ func TestBannerMessageOwnership(t *testing.T) {
} }
} }
// profileBanner is the banner block of the profile.get JSON response. // profileBanner is the banner (and interstitial-ad) block of the profile.get JSON response.
type profileBanner struct { type profileBanner struct {
Banner *struct { Banner *struct {
Campaigns []struct { Campaigns []struct {
@@ -203,6 +203,12 @@ type profileBanner struct {
HoldMs int `json:"hold_ms"` HoldMs int `json:"hold_ms"`
} `json:"timings"` } `json:"timings"`
} `json:"banner"` } `json:"banner"`
Ads *struct {
CooldownGlobalS int `json:"cooldown_global_s"`
CooldownVsAiS int `json:"cooldown_vs_ai_s"`
CooldownHintS int `json:"cooldown_hint_s"`
Suppressed bool `json:"suppressed"`
} `json:"ads"`
} }
// TestBannerProfileEligibility checks the profile.get banner block follows // TestBannerProfileEligibility checks the profile.get banner block follows
@@ -283,6 +289,81 @@ func TestBannerProfileEligibility(t *testing.T) {
} }
} }
// TestProfileAdsConfig checks the profile.get interstitial-ad block: the seeded config cooldowns are
// carried through, and Suppressed follows the same no-ads / no_banner gate as the banner (the client
// self-gates VK-only + online on top). The no_banner role is context-independent; the no-ads benefit
// applies only in a trusted context where its segment is present.
func TestProfileAdsConfig(t *testing.T) {
ctx := context.Background()
srv, _, pay := bannerServer(t)
accounts := account.NewStore(testDB)
id := provisionAccount(t)
get := func() profileBanner {
t.Helper()
rec := userGet(t, srv, "/api/v1/user/profile", id)
if rec.Code != http.StatusOK {
t.Fatalf("profile = %d, want 200", rec.Code)
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode profile: %v", err)
}
return p
}
getTG := func() profileBanner {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil)
req.Header.Set("X-User-ID", id.String())
req.Header.Set("X-Platform", "telegram/android")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("profile = %d, want 200", rec.Code)
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode profile: %v", err)
}
return p
}
// A free account: the ads block carries the seeded config cooldowns and is not suppressed.
p := get()
if p.Ads == nil {
t.Fatal("free account: no ads block")
}
if p.Ads.CooldownGlobalS != 300 || p.Ads.CooldownVsAiS != 1800 || p.Ads.CooldownHintS != 60 {
t.Fatalf("cooldowns = %d/%d/%d, want 300/1800/60", p.Ads.CooldownGlobalS, p.Ads.CooldownVsAiS, p.Ads.CooldownHintS)
}
if p.Ads.Suppressed {
t.Fatal("free account: interstitials must not be suppressed")
}
// The no_banner role suppresses interstitials too (context-independent).
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("grant role: %v", err)
}
if p := get(); p.Ads == nil || !p.Ads.Suppressed {
t.Fatalf("no_banner role: ads=%v, want suppressed", p.Ads)
}
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("revoke role: %v", err)
}
// An active no-ads benefit suppresses interstitials in a trusted context; an untrusted context
// does not apply it (fail-closed to eligible, as for the banner).
if err := pay.Grant(ctx, id, payments.SourceTelegram, 0, 30, false); err != nil {
t.Fatalf("grant no-ads: %v", err)
}
if p := getTG(); p.Ads == nil || !p.Ads.Suppressed {
t.Fatalf("no-ads benefit (trusted): ads=%v, want suppressed", p.Ads)
}
if p := get(); p.Ads == nil || p.Ads.Suppressed {
t.Fatalf("no-ads benefit (untrusted): ads=%v, want NOT suppressed", p.Ads)
}
}
// TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the // TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the
// banner block too, so the client's profile keeps the banner instead of losing it until reload. // banner block too, so the client's profile keeps the banner instead of losing it until reload.
func TestBannerSurvivesProfileUpdate(t *testing.T) { func TestBannerSurvivesProfileUpdate(t *testing.T) {
@@ -440,10 +521,4 @@ func TestBannerUrgentBypassesEligibility(t *testing.T) {
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil { if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("revoke role: %v", err) t.Fatalf("revoke role: %v", err)
} }
// A non-empty hint wallet no longer suppresses it either.
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
t.Fatalf("grant hints: %v", err)
}
assertUrgentOnly("hints", get())
} }
@@ -0,0 +1,97 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"strings"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// productByTitle finds a catalog product by its (unique in the test) title.
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
t.Helper()
all, err := pay.AdminCatalog(context.Background())
if err != nil {
t.Fatalf("admin catalog: %v", err)
}
for _, p := range all {
if p.Title == title {
return p
}
}
t.Fatalf("product %q not found", title)
return payments.AdminProduct{}
}
// TestConsoleCatalogEditor drives the catalog editor end to end: create is CSRF-guarded; a value and
// a pack are created and edited; an invalid active product is refused; archive hides it; a
// never-transacted product deletes; a transacted product is refused deletion (archive only).
func TestConsoleCatalogEditor(t *testing.T) {
ctx := context.Background()
srv, _, pay := bannerServer(t)
h := srv.Handler()
const origin = "http://admin.test"
const catalog = "http://admin.test/_gm/catalog"
// CSRF: a create without the origin header is refused.
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=x&hints=1&price_chip=10&active=true", ""); code != http.StatusForbidden {
t.Fatalf("create without origin = %d, want 403", code)
}
// Create a value product: 5 hints for 50 chips, active.
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-hints-5&hints=5&price_chip=50&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
t.Fatalf("create value = %d, has 'Created' = %v", code, strings.Contains(body, "Created"))
}
val := productByTitle(t, pay, "cat-hints-5")
if !val.Active || len(val.Atoms) != 1 || val.Atoms[0].Atom != "hints" || val.Atoms[0].Quantity != 5 {
t.Fatalf("created value = %+v", val)
}
// An invalid active pack (chips, no money price) is refused.
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
}
if _, err := pay.AdminCatalog(ctx); err != nil {
t.Fatalf("catalog: %v", err)
}
// Edit the value: raise to 8 hints.
base := catalog + "/" + val.ID.String()
if code, _ := consoleDo(h, http.MethodPost, base, "title=cat-hints-8&hints=8&price_chip=50&active=true", origin); code != http.StatusOK {
t.Fatalf("edit = %d", code)
}
if got := productByTitle(t, pay, "cat-hints-8"); len(got.Atoms) != 1 || got.Atoms[0].Quantity != 8 {
t.Fatalf("after edit atoms = %+v, want 8 hints", got.Atoms)
}
// Archive it → hidden from the storefront (the user Catalog filters active).
if code, _ := consoleDo(h, http.MethodPost, base+"/archive", "active=false", origin); code != http.StatusOK {
t.Fatalf("archive = %d", code)
}
if productByTitle(t, pay, "cat-hints-8").Active {
t.Error("product still active after archive")
}
// Delete the never-transacted product.
if code, body := consoleDo(h, http.MethodPost, base+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Deleted") {
t.Fatalf("delete clean = %d, has 'Deleted' = %v", code, strings.Contains(body, "Deleted"))
}
// A transacted product cannot be deleted — only archived.
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=cat-pack&chips=100&price_rub=14900&active=true", origin); code != http.StatusOK {
t.Fatal("create pack failed")
}
pack := productByTitle(t, pay, "cat-pack")
if _, err := pay.CreateOrder(ctx, uuid.New(), payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, pack.ID, "robokassa"); err != nil {
t.Fatalf("create order: %v", err)
}
if code, body := consoleDo(h, http.MethodPost, catalog+"/"+pack.ID.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Cannot delete") {
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
}
}
+186 -103
View File
@@ -5,6 +5,7 @@ package inttest
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -18,59 +19,119 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/server" "scrabble/backend/internal/server"
"scrabble/backend/internal/social"
) )
// The game-limit suite covers the simultaneous quick-game cap (game.MaxActiveQuickGames): // The game-limit suite covers the per-tier, per-kind active-game caps (backend.config): the
// the counting rule (Store/Service.CountActiveQuickGames) and the HTTP gate that refuses a // game_kind tag persisted per creation path, the tier resolution + counting rule
// new game once the cap is reached, while accepting an incoming invitation stays allowed. // (game.Service.AtGameLimit), the HTTP gate + lobby at_game_limit flag, the guest gate on friend
// actions, and the config hot-cache reflecting an admin edit. Accepting an invitation stays exempt.
// TestCountActiveQuickGames checks the count includes active and open quick games (the // newGameLimits builds a gamelimits service over the shared pool and loads the seeded config
// honest-AI ones among them) and excludes finished games, friend games (created by // (guest 1/1/0, durable 10/10/10).
// invitation) and games the account is not seated in. func newGameLimits(t *testing.T) *gamelimits.Service {
func TestCountActiveQuickGames(t *testing.T) { t.Helper()
gl := gamelimits.NewService(gamelimits.NewStore(testDB))
if err := gl.Load(context.Background()); err != nil {
t.Fatalf("load game limits: %v", err)
}
return gl
}
// restoreDefaultLimits resets the single config row to the migration seed on cleanup, so a test that
// edited it never leaks its values into another (the row is a shared singleton).
func restoreDefaultLimits(t *testing.T, gl *gamelimits.Service) {
t.Helper()
t.Cleanup(func() {
_ = gl.Update(context.Background(), gamelimits.Config{
Guest: gamelimits.Limits{VsAI: 1, Random: 1, Friends: 0},
Durable: gamelimits.Limits{VsAI: 10, Random: 10, Friends: 10},
})
})
}
// gameKind reads a game's persisted game_kind tag.
func gameKind(t *testing.T, gameID uuid.UUID) int16 {
t.Helper()
var k int16
if err := testDB.QueryRowContext(context.Background(),
`SELECT game_kind FROM backend.games WHERE game_id=$1`, gameID).Scan(&k); err != nil {
t.Fatalf("read game_kind: %v", err)
}
return k
}
// mustCreateKind creates an active game seating the accounts, tagged with kind, and returns its id.
func mustCreateKind(t *testing.T, games *game.Service, seats []uuid.UUID, kind gamelimits.Kind) uuid.UUID {
t.Helper()
g, err := games.Create(context.Background(), game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Kind: kind,
})
if err != nil {
t.Fatalf("create game (kind=%d): %v", kind, err)
}
return g.ID
}
// startFriendGameID has inviter invite invitee to a friend game and the invitee accept, returning
// the started game's id.
func startFriendGameID(t *testing.T, inv *lobby.InvitationService, inviter, invitee uuid.UUID) uuid.UUID {
t.Helper()
ctx := context.Background()
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
if err != nil {
t.Fatalf("create invitation: %v", err)
}
got, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true)
if err != nil {
t.Fatalf("accept invitation: %v", err)
}
if got.GameID == nil {
t.Fatal("accepted invitation has no game id")
}
return *got.GameID
}
// TestGameKindPersisted checks each creation path stamps the right game_kind: random (auto-match)
// =2, vs_ai =1, friend (by invitation) =3.
func TestGameKindPersisted(t *testing.T) {
ctx := context.Background() ctx := context.Background()
clearOpenGames(t) clearOpenGames(t)
games := newGameService() games := newGameService()
human := provisionAccount(t) inv := newInvitationService()
opp := provisionAccount(t) human, opp := provisionAccount(t), provisionAccount(t)
if n := mustCount(t, games, human); n != 0 { rnd, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour}, time.Now().Add(time.Minute), nil)
t.Fatalf("fresh account count = %d, want 0", n) if err != nil {
t.Fatalf("open random game: %v", err)
}
if k := gameKind(t, rnd.ID); k != int16(gamelimits.KindRandom) {
t.Errorf("random game_kind = %d, want %d", k, gamelimits.KindRandom)
} }
// An open (awaiting-opponent) quick game counts. ai := mustCreateKind(t, games, []uuid.UUID{human, opp}, gamelimits.KindVsAI)
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{ if k := gameKind(t, ai); k != int16(gamelimits.KindVsAI) {
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour, t.Errorf("vs_ai game_kind = %d, want %d", k, gamelimits.KindVsAI)
}, time.Now().Add(time.Minute), nil); err != nil {
t.Fatalf("open quick game: %v", err)
} }
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 1)
mustCreateQuick(t, games, []uuid.UUID{human, opp}, true, 2)
// A finished quick game does NOT count. friend := startFriendGameID(t, inv, human, opp)
fin := mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 3) if k := gameKind(t, friend); k != int16(gamelimits.KindFriends) {
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, fin); err != nil { t.Errorf("friend game_kind = %d, want %d", k, gamelimits.KindFriends)
t.Fatalf("finish game: %v", err)
}
// A game the human is not seated in does NOT count.
mustCreateQuick(t, games, []uuid.UUID{opp, provisionAccount(t)}, false, 4)
// A friend game (created by invitation) does NOT count, even though it is active.
startFriendGame(t, human)
if n := mustCount(t, games, human); n != 3 {
t.Fatalf("active quick count = %d, want 3 (active + AI + open)", n)
} }
} }
// TestGameLimitGate drives the cap through the assembled HTTP server: under the cap the lobby // TestGuestActiveGameLimitHTTP drives the guest random cap through the assembled server: the first
// reports at_game_limit false; at the cap it flips to true and both new-game endpoints (quick // auto-match opens a game, the lobby then flags at_game_limit, and a second enqueue is refused 409
// enqueue and invitation creation) are refused with 409 game_limit_reached, while accepting an // game_limit_reached. It also checks the guest vs_ai and friends caps resolve at the domain level.
// incoming invitation is still allowed. func TestGuestActiveGameLimitHTTP(t *testing.T) {
func TestGameLimitGate(t *testing.T) {
ctx := context.Background() ctx := context.Background()
clearOpenGames(t)
gl := newGameLimits(t)
games := newGameService() games := newGameService()
games.SetGameLimits(gl)
srv := server.New(":0", server.Deps{ srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t), Logger: zaptest.NewLogger(t),
DB: testDB, DB: testDB,
@@ -78,88 +139,110 @@ func TestGameLimitGate(t *testing.T) {
Games: games, Games: games,
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0), Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
Invitations: newInvitationService(), Invitations: newInvitationService(),
GameLimits: gl,
}) })
human := provisionAccount(t) guest := provisionGuest(t)
if gamesListAtLimit(t, srv, guest) {
t.Fatal("a fresh guest must be under the random game limit")
}
// The first auto-match opens a random game (guest random cap = 1).
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusOK {
t.Fatalf("first enqueue = %d (%s), want 200", rec.Code, rec.Body.String())
}
// At the cap: the lobby flags it and a second enqueue is refused with the stable code.
if !gamesListAtLimit(t, srv, guest) {
t.Fatal("after one random game the guest must be at the limit")
}
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("second enqueue = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
}
// A guest is refused the friends flow outright at the HTTP edge (403 guest_forbidden).
opp := provisionAccount(t) opp := provisionAccount(t)
// Under the cap: the lobby reports the player is not limited.
if gamesListAtLimit(t, srv, human) {
t.Fatal("a fresh account must be under the game limit")
}
// Reach the cap with active quick games seating the human (no invitation row → quick).
for i := 0; i < game.MaxActiveQuickGames; i++ {
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, int64(i+1))
}
// At the cap: the lobby flags it and both create paths are refused with the stable code.
if !gamesListAtLimit(t, srv, human) {
t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames)
}
// erudit_ru is in the default variant preferences, so the variant gate passes and the
// game-limit gate is what fires here.
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
}
invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String()) invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String())
if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" { if rec := userPost(t, srv, "/api/v1/user/invitations", guest, invBody); rec.Code != http.StatusForbidden || errorCode(t, rec) != "guest_forbidden" {
t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec)) t.Fatalf("guest invitation = (%d, %q), want (403, guest_forbidden)", rec.Code, errorCode(t, rec))
} }
// Accepting an incoming invitation is never blocked, even at the cap: another player invites // The guest vs_ai cap is 1: one vs_ai game puts the guest at the vs_ai limit.
// the capped human, who accepts over HTTP and the game starts (friend games do not count). mustCreateKind(t, games, []uuid.UUID{guest, opp}, gamelimits.KindVsAI)
inviter := provisionAccount(t) if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindVsAI); err != nil || !at {
inv := newInvitationService() t.Fatalf("guest vs_ai at-limit = (%v, %v), want (true, nil)", at, err)
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{human}, englishInvite())
if err != nil {
t.Fatalf("create invitation: %v", err)
} }
if rec := userPost(t, srv, "/api/v1/user/invitations/"+invitation.ID.String()+"/accept", human, ""); rec.Code != http.StatusOK { // The guest friends cap is 0: a guest is always at the friends limit (the 403 gate blocks first).
t.Fatalf("accept at limit = %d (%s), want 200 — accept must bypass the cap", rec.Code, rec.Body.String()) if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindFriends); err != nil || !at {
t.Fatalf("guest friends at-limit = (%v, %v), want (true, nil)", at, err)
} }
} }
// mustCount returns the account's active-quick-game count, failing on error. // TestDurableTierHigherLimit checks a durable account resolves the durable tier, not the guest one:
func mustCount(t *testing.T, games *game.Service, id uuid.UUID) int { // one random game leaves it well under the durable cap (10).
t.Helper() func TestDurableTierHigherLimit(t *testing.T) {
n, err := games.CountActiveQuickGames(context.Background(), id)
if err != nil {
t.Fatalf("count active quick games: %v", err)
}
return n
}
// mustCreateQuick creates an active quick game (no invitation) seating the given accounts and
// returns its id; vsAI flags an honest-AI game (the service then applies the 7-day clock).
func mustCreateQuick(t *testing.T, games *game.Service, seats []uuid.UUID, vsAI bool, seed int64) uuid.UUID {
t.Helper()
p := game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed}
if vsAI {
p.VsAI = true
p.TurnTimeout = 0 // the service applies the 7-day AI inactivity clock for vs_ai games
}
g, err := games.Create(context.Background(), p)
if err != nil {
t.Fatalf("create quick game (vsAI=%v): %v", vsAI, err)
}
return g.ID
}
// startFriendGame has a fresh inviter invite the given account to a friend game and the invitee
// accept it, so the (active) friend game exists with its game_invitations row.
func startFriendGame(t *testing.T, invitee uuid.UUID) {
t.Helper()
ctx := context.Background() ctx := context.Background()
gl := newGameLimits(t)
games := newGameService()
games.SetGameLimits(gl)
durable, opp := provisionAccount(t), provisionAccount(t)
mustCreateKind(t, games, []uuid.UUID{durable, opp}, gamelimits.KindRandom)
if at, err := games.AtGameLimit(ctx, durable, gamelimits.KindRandom); err != nil || at {
t.Fatalf("durable random at-limit after one game = (%v, %v), want (false, nil)", at, err)
}
}
// TestGuestForbiddenFriendActions checks a guest is refused every friend action server-side (the UI
// hides them; this is the source of truth): creating an invitation, sending a friend request, and
// redeeming a friend code.
func TestGuestForbiddenFriendActions(t *testing.T) {
ctx := context.Background()
guest := provisionGuest(t)
other := provisionAccount(t)
inv := newInvitationService() inv := newInvitationService()
if _, err := inv.CreateInvitation(ctx, guest, []uuid.UUID{other}, englishInvite()); !errors.Is(err, lobby.ErrGuestForbidden) {
t.Fatalf("guest CreateInvitation err = %v, want ErrGuestForbidden", err)
}
soc := newSocialService()
if err := soc.SendFriendRequest(ctx, guest, other); !errors.Is(err, social.ErrGuestForbidden) {
t.Fatalf("guest SendFriendRequest err = %v, want ErrGuestForbidden", err)
}
if _, err := soc.RedeemFriendCode(ctx, guest, "000000"); !errors.Is(err, social.ErrGuestForbidden) {
t.Fatalf("guest RedeemFriendCode err = %v, want ErrGuestForbidden", err)
}
}
// TestDurableFriendsCapAndConfigCache lowers the durable friends cap to 1 through the service (the
// admin-edit path), checks a durable inviter is refused a second friend game with 409, and that
// accepting an incoming invitation is still exempt from the cap.
func TestDurableFriendsCapAndConfigCache(t *testing.T) {
ctx := context.Background()
gl := newGameLimits(t)
restoreDefaultLimits(t, gl)
cfg := gl.Get()
cfg.Durable.Friends = 1
if err := gl.Update(ctx, cfg); err != nil {
t.Fatalf("update durable friends cap: %v", err)
}
games := newGameService()
games.SetGameLimits(gl)
inv := lobby.NewInvitationService(lobby.NewStore(testDB), games, account.NewStore(testDB), newSocialService())
inviter := provisionAccount(t) inviter := provisionAccount(t)
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()) d2, d3 := provisionAccount(t), provisionAccount(t)
if err != nil {
t.Fatalf("create invitation: %v", err) // The inviter's first friend game reaches the (lowered) cap of 1.
startFriendGameID(t, inv, inviter, d2)
if at, err := games.AtGameLimit(ctx, inviter, gamelimits.KindFriends); err != nil || !at {
t.Fatalf("inviter friends at-limit = (%v, %v), want (true, nil)", at, err)
} }
if _, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true); err != nil { // A second invitation is refused: the cache reflects the edit.
t.Fatalf("accept invitation: %v", err) if _, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{d3}, englishInvite()); !errors.Is(err, game.ErrGameLimitReached) {
t.Fatalf("second invitation err = %v, want ErrGameLimitReached", err)
} }
// Accept stays exempt: someone else invites the capped inviter, who accepts and the game starts.
startFriendGameID(t, inv, d3, inviter)
} }
// gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag. // gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag.
+7 -6
View File
@@ -424,13 +424,13 @@ func TestHintPolicy(t *testing.T) {
t.Fatalf("first hint: %v", err) t.Fatalf("first hint: %v", err)
} }
// The allowance is spent before the wallet: with an empty wallet, the state now reports no // The allowance is spent before the wallet: with an empty wallet, the state now reports no
// hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0. // per-game allowance left (HintsRemaining is the allowance alone; the wallet lives on the profile).
st, err := svc.GameState(ctx, g.ID, seats[0]) st, err := svc.GameState(ctx, g.ID, seats[0])
if err != nil { if err != nil {
t.Fatalf("state: %v", err) t.Fatalf("state: %v", err)
} }
if st.HintsRemaining != 0 || st.WalletBalance != 0 { if st.HintsRemaining != 0 {
t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance) t.Errorf("after allowance hint: hints=%d, want 0", st.HintsRemaining)
} }
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) { if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err) t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
@@ -444,9 +444,10 @@ func TestHintPolicy(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("wallet hint: %v", err) t.Fatalf("wallet hint: %v", err)
} }
// The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone. // The allowance stays exhausted (HintsRemaining is the allowance alone, so 0); the wallet dropped
if res.HintsRemaining != 1 || res.WalletBalance != 1 { // 2->1 and WalletBalance carries it (the client adopts it into the profile).
t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance) if res.HintsRemaining != 0 || res.WalletBalance != 1 {
t.Errorf("wallet hint: hints=%d wallet=%d, want 0/1", res.HintsRemaining, res.WalletBalance)
} }
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total // game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
// that feeds the player's lifetime hint statistics, not just the allowance. // that feeds the player's lifetime hint statistics, not just the allowance.
@@ -4,6 +4,7 @@ package inttest
import ( import (
"context" "context"
"strings"
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
@@ -111,3 +112,44 @@ func TestPaymentsCatalogExcludesDeactivated(t *testing.T) {
t.Error("deactivated product must not appear in the storefront") t.Error("deactivated product must not appear in the storefront")
} }
} }
// TestOfferPricingReflectsCatalogEdits verifies the public-offer price list (§4.4) is projected from
// the live catalog and its cache is invalidated on a catalog mutation: a newly created pack appears
// with its per-rail prices, and archiving it through the service drops it from the next read.
func TestOfferPricingReflectsCatalogEdits(t *testing.T) {
svc := newPaymentsService()
ctx := context.Background()
title := "OfferTest " + uuid.NewString()
id, err := svc.CreateProduct(ctx, payments.ProductInput{
Title: title,
Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 50}},
Prices: []payments.PriceLine{
{Method: "direct", Currency: payments.CurrencyRUB, Amount: 20000},
{Method: "vk", Currency: payments.CurrencyVote, Amount: 30},
{Method: "telegram", Currency: payments.CurrencyStar, Amount: 100},
},
}, true)
if err != nil {
t.Fatalf("create pack: %v", err)
}
md, err := svc.OfferPricing(ctx)
if err != nil {
t.Fatalf("offer pricing: %v", err)
}
if row := "| " + title + " | 200.00 | 30 | 100 |"; !strings.Contains(md, row) {
t.Fatalf("offer pricing missing the new pack row %q\n%s", row, md)
}
// Archiving through the service marks the cache stale; the next read must reproject without it.
if err := svc.SetProductActive(ctx, id, false); err != nil {
t.Fatalf("archive: %v", err)
}
md, err = svc.OfferPricing(ctx)
if err != nil {
t.Fatalf("offer pricing after archive: %v", err)
}
if strings.Contains(md, title) {
t.Errorf("archived pack must drop from the offer pricing:\n%s", md)
}
}
@@ -0,0 +1,563 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// orderStatus reads an order's status.
func orderStatus(t *testing.T, orderID uuid.UUID) string {
t.Helper()
var status string
if err := testDB.QueryRowContext(context.Background(),
`SELECT status FROM payments.orders WHERE order_id=$1`, orderID).Scan(&status); err != nil {
t.Fatalf("read order status: %v", err)
}
return status
}
// readRisk reads an account's payment-risk row (abuse flag + accumulated loss), or (false, 0) when
// none exists.
func readRisk(t *testing.T, acc uuid.UUID) (abuse bool, loss int64) {
t.Helper()
err := testDB.QueryRowContext(context.Background(),
`SELECT abuse, loss_chips FROM payments.account_risk WHERE account_id=$1`, acc).Scan(&abuse, &loss)
if errors.Is(err, sql.ErrNoRows) {
return false, 0
}
if err != nil {
t.Fatalf("read risk: %v", err)
}
return abuse, loss
}
// TestPaymentsOrderFundCreditsOnce verifies the intake path over Postgres: creating an order then
// funding it credits the funded segment exactly once, and a replayed callback (the same order)
// credits nothing more — the ledger idempotency index holds.
func TestPaymentsOrderFundCreditsOnce(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) // 149.00 RUB funds 100 chips
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
if res.Amount.Minor() != 14900 || res.Amount.Currency() != payments.CurrencyRUB {
t.Fatalf("order amount = %s, want 149.00 RUB", res.Amount)
}
if orderStatus(t, res.OrderID) != "pending" {
t.Errorf("new order status = %s, want pending", orderStatus(t, res.OrderID))
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
if err != nil {
t.Fatalf("fund: %v", err)
}
if out.AlreadyCredited || out.Chips != 100 || out.Source != payments.SourceDirect {
t.Fatalf("fund outcome = %+v, want 100 chips to direct, not already-credited", out)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after fund = %d, want 100", got)
}
if ledgerRows(t, acc, "fund") != 1 {
t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund"))
}
if orderStatus(t, res.OrderID) != "paid" {
t.Errorf("order status after fund = %s, want paid", orderStatus(t, res.OrderID))
}
// A replayed callback for the same order is rejected by the unique index: no second credit.
out2, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
if err != nil {
t.Fatalf("duplicate fund: %v", err)
}
if !out2.AlreadyCredited {
t.Error("duplicate callback not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after duplicate = %d, want 100 (credited once)", got)
}
if ledgerRows(t, acc, "fund") != 1 {
t.Error("duplicate callback wrote a second fund ledger row")
}
}
// TestPaymentsVKOrderFundCredits exercises the VK Votes rail over Postgres: a VK order prices the
// pack in votes; the get_item lookup returns its title and vote price; a chargeable
// order_status_change credits the vk segment exactly once (idempotent on VK's own order id).
func TestPaymentsVKOrderFundCredits(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 200, methodPrice{method: "vk", currency: "VOTE", amount: 30})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("vk", "web"), []payments.Source{payments.SourceVK}, prod, "vk")
if err != nil {
t.Fatalf("create vk order: %v", err)
}
if res.Amount.Currency() != payments.CurrencyVote || res.Amount.Minor() != 30 {
t.Fatalf("vk order amount = %s, want 30 VOTE", res.Amount)
}
// get_item phase: title + vote price.
title, amount, err := svc.OrderItem(ctx, res.OrderID)
if err != nil {
t.Fatalf("order item: %v", err)
}
if title == "" || amount.Minor() != 30 || amount.Currency() != payments.CurrencyVote {
t.Errorf("order item = %q / %s, want a title + 30 VOTE", title, amount)
}
// order_status_change phase: VK's own order id is the idempotency key.
paid, _ := payments.MoneyFromMinor(30, payments.CurrencyVote)
out, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid)
if err != nil {
t.Fatalf("vk fund: %v", err)
}
if out.AlreadyCredited || out.Chips != 200 || out.Source != payments.SourceVK {
t.Fatalf("vk fund outcome = %+v, want 200 chips credited to vk", out)
}
if got := readBalance(t, acc, "vk"); got != 200 {
t.Errorf("vk balance after fund = %d, want 200", got)
}
// A duplicate VK callback (same VK order id) credits nothing more.
out2, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid)
if err != nil {
t.Fatalf("duplicate vk fund: %v", err)
}
if !out2.AlreadyCredited {
t.Error("duplicate VK callback not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "vk"); got != 200 {
t.Errorf("vk balance after duplicate = %d, want 200 (credited once)", got)
}
}
// TestPaymentsTelegramStarsRail exercises the Telegram Stars rail over Postgres: an order prices the
// pack in whole stars (XTR); a pre_checkout on the pending order is approved; the forwarded payment
// credits the telegram segment exactly once (idempotent on the Telegram charge id); and a
// pre_checkout after the order is paid is declined (a reusable invoice link paid twice).
func TestPaymentsTelegramStarsRail(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 50, methodPrice{method: "telegram", currency: "XTR", amount: 40}) // 40 stars fund 50 chips
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
if err != nil {
t.Fatalf("create telegram order: %v", err)
}
if res.Amount.Currency() != payments.CurrencyStar || res.Amount.Minor() != 40 {
t.Fatalf("telegram order amount = %s, want 40 XTR", res.Amount)
}
// pre_checkout on the pending order: approved, and it reports the account for reason localisation.
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
pc, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
if err != nil {
t.Fatalf("pre_checkout: %v", err)
}
if !pc.OK || pc.AccountID != acc {
t.Fatalf("pre_checkout = %+v, want approved for account %s", pc, acc)
}
// The forwarded successful_payment credits once, idempotent on the Telegram charge id.
out, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
if err != nil {
t.Fatalf("telegram fund: %v", err)
}
if out.AlreadyCredited || out.Chips != 50 || out.Source != payments.SourceTelegram {
t.Fatalf("telegram fund outcome = %+v, want 50 chips credited to telegram", out)
}
if got := readBalance(t, acc, "telegram"); got != 50 {
t.Errorf("telegram balance after fund = %d, want 50", got)
}
// A retried forward (same charge id, e.g. a lost ack) credits nothing more.
out2, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
if err != nil {
t.Fatalf("duplicate telegram fund: %v", err)
}
if !out2.AlreadyCredited {
t.Error("retried forward not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "telegram"); got != 50 {
t.Errorf("telegram balance after retry = %d, want 50 (credited once)", got)
}
// pre_checkout after the order is paid: declined (the reusable-link double-pay guard).
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
if err != nil {
t.Fatalf("pre_checkout after paid: %v", err)
}
if pc2.OK || pc2.Reason != payments.PreCheckoutAlreadyPaid {
t.Errorf("pre_checkout after paid = %+v, want a decline with already_paid", pc2)
}
}
// TestPaymentsTelegramPreCheckoutDeclines covers the pre_checkout decline reasons: an unknown order
// and an amount that no longer matches.
func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
// An unknown order id declines as gone, with no account to localise against.
pc, err := svc.ValidatePreCheckout(ctx, uuid.New(), starAmt)
if err != nil {
t.Fatalf("pre_checkout unknown: %v", err)
}
if pc.OK || pc.Reason != payments.PreCheckoutGone || pc.AccountID != (uuid.UUID{}) {
t.Errorf("pre_checkout unknown = %+v, want a gone decline with no account", pc)
}
// A pending order validated at the wrong amount declines as price-changed.
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "telegram", currency: "XTR", amount: 80})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
if err != nil {
t.Fatalf("create telegram order: %v", err)
}
wrong, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, wrong)
if err != nil {
t.Fatalf("pre_checkout mismatch: %v", err)
}
if pc2.OK || pc2.Reason != payments.PreCheckoutPriceChanged {
t.Errorf("pre_checkout mismatch = %+v, want a price_changed decline", pc2)
}
}
// setRewardConfig sets the rewarded payout and caps on the shared config row, restoring the defaults
// after the test (the row is a singleton shared across the sequential suite).
func setRewardConfig(t *testing.T, payout, dailyCap, hourlyCap int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
payout, dailyCap, hourlyCap); err != nil {
t.Fatalf("set reward config: %v", err)
}
t.Cleanup(func() {
_, _ = testDB.ExecContext(context.Background(),
`UPDATE payments.config SET rewarded_payout_chips=0, reward_daily_cap=50, reward_hourly_cap=10`)
})
}
// TestPaymentsRewardCredit exercises the rewarded-video credit: a watched view credits the VK segment
// the configured payout, a retried view (same nonce) credits once, and the hourly cap blocks the
// third distinct view.
func TestPaymentsRewardCredit(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
setRewardConfig(t, 5, 5, 2) // 5 chips/view, daily 5, hourly 2
vk := payments.NewContext("vk", "web")
present := []payments.Source{payments.SourceVK}
out, err := svc.CreditReward(ctx, acc, vk, present, "n1")
if err != nil {
t.Fatalf("credit: %v", err)
}
if out.Chips != 5 || out.Capped {
t.Fatalf("reward outcome = %+v, want 5 chips, not capped", out)
}
if got := readBalance(t, acc, "vk"); got != 5 {
t.Errorf("vk balance = %d, want 5", got)
}
// A retried view (same nonce) credits nothing more.
out2, err := svc.CreditReward(ctx, acc, vk, present, "n1")
if err != nil {
t.Fatalf("retry: %v", err)
}
if !out2.AlreadyCredited {
t.Error("retried view not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "vk"); got != 5 {
t.Errorf("vk balance after retry = %d, want 5 (credited once)", got)
}
// A second distinct view credits again (2 total).
if _, err := svc.CreditReward(ctx, acc, vk, present, "n2"); err != nil {
t.Fatalf("credit 2: %v", err)
}
if got := readBalance(t, acc, "vk"); got != 10 {
t.Errorf("vk balance = %d, want 10", got)
}
// The third distinct view hits the hourly cap (2) — capped, no credit.
out3, err := svc.CreditReward(ctx, acc, vk, present, "n3")
if err != nil {
t.Fatalf("credit 3: %v", err)
}
if !out3.Capped {
t.Error("third view not capped (hourly cap 2)")
}
if got := readBalance(t, acc, "vk"); got != 10 {
t.Errorf("vk balance after cap = %d, want 10 (capped view credited nothing)", got)
}
}
// TestPaymentsRewardDisabledAndContext verifies rewarded is inert when unconfigured (0 payout) and
// refused outside a VK context (rewarded is VK-only, D28).
func TestPaymentsRewardDisabledAndContext(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
setRewardConfig(t, 0, 50, 10) // payout 0 = disabled
vk := payments.NewContext("vk", "web")
out, err := svc.CreditReward(ctx, acc, vk, []payments.Source{payments.SourceVK}, "d1")
if err != nil {
t.Fatalf("credit (disabled): %v", err)
}
if out.Chips != 0 || out.Capped || out.AlreadyCredited {
t.Fatalf("disabled reward outcome = %+v, want 0 chips, not capped", out)
}
// A direct (non-VK) context is refused — rewarded is VK-only.
direct := payments.NewContext("direct", "web")
if _, err := svc.CreditReward(ctx, acc, direct, []payments.Source{payments.SourceDirect}, "d2"); !errors.Is(err, payments.ErrUntrusted) {
t.Fatalf("direct-context reward = %v, want ErrUntrusted", err)
}
}
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
// refused and credits nothing (§9: verify the amount after matching by order id).
func TestPaymentsFundAmountMismatch(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
underpaid, _ := payments.MoneyFromMinor(100, payments.CurrencyRUB)
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), underpaid); !errors.Is(err, payments.ErrAmountMismatch) {
t.Fatalf("fund = %v, want ErrAmountMismatch", err)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 (nothing credited on mismatch)", got)
}
if ledgerRows(t, acc, "fund") != 0 {
t.Error("fund ledger row written on an amount mismatch")
}
}
// TestPaymentsEventDispatchDrain verifies the payment_events dispatcher queue: a recorded event is
// returned as undispatched until marked, then drops out (so the dispatcher delivers it once).
func TestPaymentsEventDispatchDrain(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
if err := svc.RecordPaymentEvent(ctx, acc, nil, "succeeded", []byte(`{"chips":10,"source":"direct"}`)); err != nil {
t.Fatalf("record event: %v", err)
}
// testDB is shared, so filter the queue to our account.
find := func() *payments.PaymentEvent {
evs, err := svc.UndispatchedEvents(ctx, 100)
if err != nil {
t.Fatalf("undispatched: %v", err)
}
for i := range evs {
if evs[i].AccountID == acc {
return &evs[i]
}
}
return nil
}
mine := find()
if mine == nil {
t.Fatal("recorded event not in the undispatched queue")
}
if mine.Type != "succeeded" {
t.Errorf("event type = %s, want succeeded", mine.Type)
}
if err := svc.MarkEventDispatched(ctx, mine.EventID); err != nil {
t.Fatalf("mark dispatched: %v", err)
}
if find() != nil {
t.Error("event still undispatched after MarkEventDispatched")
}
}
// TestPaymentsExpiredOrderStillCredits verifies an expired pending order is still honoured by a
// later valid callback (§9/D23: expiry is cosmetic, the money is real).
func TestPaymentsExpiredOrderStillCredits(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
// Age the order well past the configured TTL, then sweep it to expired.
if _, err := testDB.ExecContext(ctx,
`UPDATE payments.orders SET created_at = now() - interval '1 day' WHERE order_id=$1`, res.OrderID); err != nil {
t.Fatalf("age order: %v", err)
}
if n, err := svc.ExpireOrders(ctx); err != nil || n < 1 {
t.Fatalf("expire orders = %d (err %v), want at least 1", n, err)
}
if orderStatus(t, res.OrderID) != "expired" {
t.Fatalf("order status = %s, want expired before the late callback", orderStatus(t, res.OrderID))
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
if err != nil {
t.Fatalf("fund after expiry: %v", err)
}
if out.AlreadyCredited || out.Chips != 100 {
t.Fatalf("fund outcome = %+v, want a fresh 100-chip credit", out)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 (expired order honoured)", got)
}
if orderStatus(t, res.OrderID) != "paid" {
t.Errorf("order status = %s, want paid after the honoured callback", orderStatus(t, res.OrderID))
}
}
// fundedOrder creates and funds an order for chips in the direct rail, returning its id and account.
func fundedOrder(t *testing.T, svc *payments.Service, chips int, priceMinor int64) (uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
acc := uuid.New()
prod := seedPackProduct(t, chips, methodPrice{method: "direct", currency: "RUB", amount: priceMinor})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
paid, _ := payments.MoneyFromMinor(priceMinor, payments.CurrencyRUB)
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
t.Fatalf("fund: %v", err)
}
return res.OrderID, acc
}
// TestPaymentsRefundFull reverses a fully-unspent order: all chips are clawed back, no loss/abuse.
func TestPaymentsRefundFull(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
orderID, acc := fundedOrder(t, svc, 100, 14900)
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-1", refunded)
if err != nil {
t.Fatalf("refund: %v", err)
}
if out.AlreadyRefunded || out.Revoked != 100 || out.Loss != 0 || out.Source != payments.SourceDirect {
t.Fatalf("refund outcome = %+v, want 100 revoked, 0 loss", out)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance after refund = %d, want 0", got)
}
if ledgerRows(t, acc, "refund") != 1 {
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
}
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
t.Errorf("risk = (%v, %d), want (false, 0) — nothing was spent", abuse, loss)
}
// The order stays 'paid' — the refund lives in the ledger, not in the order status.
if orderStatus(t, orderID) != "paid" {
t.Errorf("order status = %s, want paid (refund is ledger-only)", orderStatus(t, orderID))
}
}
// TestPaymentsRefundAfterSpend reverses an order whose chips were partly spent: the reversal floors
// at 0 (never negative), and the unrecoverable remainder is recorded as a loss + abuse flag.
func TestPaymentsRefundAfterSpend(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
orderID, acc := fundedOrder(t, svc, 100, 14900)
// Simulate 70 chips already spent, leaving 30 in the funded segment.
if _, err := testDB.ExecContext(ctx,
`UPDATE payments.balances SET chips = 30 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
t.Fatalf("simulate spend: %v", err)
}
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-2", refunded)
if err != nil {
t.Fatalf("refund: %v", err)
}
if out.Revoked != 30 || out.Loss != 70 {
t.Fatalf("refund outcome = %+v, want 30 revoked / 70 loss", out)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance after refund = %d, want 0 (floored, never negative)", got)
}
if abuse, loss := readRisk(t, acc); !abuse || loss != 70 {
t.Errorf("risk = (%v, %d), want (true, 70)", abuse, loss)
}
}
// TestPaymentsRefundIdempotent verifies a replayed refund (same provider refund id) reverses nothing
// more — the ledger idempotency index holds.
func TestPaymentsRefundIdempotent(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
orderID, acc := fundedOrder(t, svc, 100, 14900)
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded); err != nil {
t.Fatalf("first refund: %v", err)
}
// Re-credit the segment to prove the duplicate does not revoke again.
if _, err := testDB.ExecContext(ctx,
`UPDATE payments.balances SET chips = 100 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
t.Fatalf("re-credit: %v", err)
}
out2, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded)
if err != nil {
t.Fatalf("duplicate refund: %v", err)
}
if !out2.AlreadyRefunded {
t.Error("duplicate refund not flagged AlreadyRefunded")
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after duplicate refund = %d, want 100 (not revoked twice)", got)
}
if ledgerRows(t, acc, "refund") != 1 {
t.Errorf("refund ledger rows = %d, want 1 (duplicate wrote none)", ledgerRows(t, acc, "refund"))
}
}
// TestPaymentsRefundUnpaidOrder refuses to refund an order that was never funded.
func TestPaymentsRefundUnpaidOrder(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := svc.Refund(ctx, res.OrderID, "robokassa", "rk-refund-x", refunded); !errors.Is(err, payments.ErrOrderNotPaid) {
t.Fatalf("refund of a pending order = %v, want ErrOrderNotPaid", err)
}
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
t.Errorf("risk = (%v, %d), want (false, 0) — no reversal on an unpaid order", abuse, loss)
}
}
@@ -0,0 +1,101 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"strings"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// TestAccountStatement checks the admin financial statement: a funded pack shows as a chip segment
// + a fund ledger row, an admin grant as a benefit + an admin_grant row, the ledger is newest-first,
// and a clean account carries no refund risk.
func TestAccountStatement(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
// A funded pack: 100 chips to the direct segment + a fund ledger row.
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
t.Fatalf("fund: %v", err)
}
// An admin grant: 5 hints to the direct origin + an admin_grant ledger row.
if err := svc.Grant(ctx, acc, payments.SourceDirect, 5, 0, false); err != nil {
t.Fatalf("grant: %v", err)
}
stmt, err := svc.AccountStatement(ctx, acc)
if err != nil {
t.Fatalf("statement: %v", err)
}
if len(stmt.Segments) != 1 || stmt.Segments[0].Source != payments.SourceDirect || stmt.Segments[0].Chips != 100 {
t.Fatalf("segments = %+v, want 100 chips on direct", stmt.Segments)
}
if len(stmt.Benefits) != 1 || stmt.Benefits[0].Origin != payments.SourceDirect || stmt.Benefits[0].Hints != 5 {
t.Fatalf("benefits = %+v, want 5 hints on direct", stmt.Benefits)
}
if len(stmt.Ledger) != 2 {
t.Fatalf("ledger rows = %d, want 2 (fund + admin_grant)", len(stmt.Ledger))
}
// Newest-first ordering (the grant is the later write).
if stmt.Ledger[0].CreatedAt.Before(stmt.Ledger[1].CreatedAt) {
t.Error("ledger is not newest-first")
}
kinds := map[string]int{}
for _, e := range stmt.Ledger {
kinds[e.Kind]++
if e.Kind == "fund" && e.ChipsDelta != 100 {
t.Errorf("fund chips delta = %d, want 100", e.ChipsDelta)
}
}
if kinds["fund"] != 1 || kinds["admin_grant"] != 1 {
t.Fatalf("ledger kinds = %v, want one fund + one admin_grant", kinds)
}
if stmt.Risk.Present {
t.Errorf("risk = %+v, want none for a clean account", stmt.Risk)
}
}
// TestConsoleFinancePanel checks the user card renders the finance panel: a funded pack + an admin
// grant surface as the segment balance, the benefit and the ledger rows.
func TestConsoleFinancePanel(t *testing.T) {
ctx := context.Background()
srv, _, pay := bannerServer(t)
id := provisionAccount(t)
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
t.Fatalf("fund: %v", err)
}
if err := pay.Grant(ctx, id, payments.SourceDirect, 5, 0, false); err != nil {
t.Fatalf("grant: %v", err)
}
code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/users/"+id.String(), "", "")
if code != http.StatusOK {
t.Fatalf("user card = %d, want 200", code)
}
for _, want := range []string{"Finance", "Chips (direct)", "Benefits (direct)", "5 hints", "admin_grant", "fund"} {
if !strings.Contains(body, want) {
t.Errorf("finance panel missing %q", want)
}
}
}
+16
View File
@@ -15,6 +15,7 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
"scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table" "scrabble/backend/internal/postgres/jet/backend/table"
@@ -218,6 +219,20 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) { if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) {
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout) return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
} }
// A guest cannot invite: friend games are a durable-account feature. The UI hides the flow;
// this is the server source of truth.
if acc, err := svc.accounts.GetByID(ctx, inviterID); err != nil {
return Invitation{}, err
} else if acc.IsGuest {
return Invitation{}, ErrGuestForbidden
}
// A durable inviter is still capped: refuse a new friend game once the per-tier friends limit is
// reached. The guest branch above short-circuits before here, so this is the durable cap.
if atLimit, err := svc.games.AtGameLimit(ctx, inviterID, gamelimits.KindFriends); err != nil {
return Invitation{}, err
} else if atLimit {
return Invitation{}, game.ErrGameLimitReached
}
seen := map[uuid.UUID]bool{inviterID: true} seen := map[uuid.UUID]bool{inviterID: true}
// suppressed collects invitees who have blocked the inviter: the invitation is still // suppressed collects invitees who have blocked the inviter: the invitation is still
// created and persisted for them, but they are never notified and never see it (their // created and persisted for them, but they are never notified and never see it (their
@@ -336,6 +351,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
HintsPerPlayer: inv.Settings.HintsPerPlayer, HintsPerPlayer: inv.Settings.HintsPerPlayer,
DropoutTiles: inv.Settings.DropoutTiles, DropoutTiles: inv.Settings.DropoutTiles,
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn, MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
Kind: gamelimits.KindFriends,
}) })
if err != nil { if err != nil {
return err return err
+9 -1
View File
@@ -15,17 +15,22 @@ import (
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
) )
// GameCreator is the slice of the game domain the lobby needs: starting a seated // GameCreator is the slice of the game domain the lobby needs: starting a seated
// game and reading a player's initial view of it. game.Service satisfies it. // game, reading a player's initial view of it, and testing a caller's active-game
// cap. game.Service satisfies it.
type GameCreator interface { type GameCreator interface {
Create(ctx context.Context, params game.CreateParams) (game.Game, error) Create(ctx context.Context, params game.CreateParams) (game.Game, error)
// InitialState returns a seated player's full initial view of a started game, used // InitialState returns a seated player's full initial view of a started game, used
// to enrich the game_started event so the client renders the new game without a // to enrich the game_started event so the client renders the new game without a
// follow-up fetch. // follow-up fetch.
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind. The
// invitation path uses it to enforce the friends limit before opening one.
AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error)
} }
// RobotProvider supplies a robot account to substitute for a missing human in // RobotProvider supplies a robot account to substitute for a missing human in
@@ -62,6 +67,9 @@ var (
// ErrInvalidInvitation is returned for a malformed invitation (bad player // ErrInvalidInvitation is returned for a malformed invitation (bad player
// count, duplicate or self invitee, or unacceptable settings). // count, duplicate or self invitee, or unacceptable settings).
ErrInvalidInvitation = errors.New("lobby: invalid invitation") ErrInvalidInvitation = errors.New("lobby: invalid invitation")
// ErrGuestForbidden is returned when a guest attempts a durable-only action (invite a
// friend); the friend flow is gated to durable accounts.
ErrGuestForbidden = errors.New("lobby: guests cannot invite")
// ErrInvitationBlocked is returned when a block stands between the inviter and // ErrInvitationBlocked is returned when a block stands between the inviter and
// an invitee. // an invitee.
ErrInvitationBlocked = errors.New("lobby: invitation blocked between accounts") ErrInvitationBlocked = errors.New("lobby: invitation blocked between accounts")
+2
View File
@@ -10,6 +10,7 @@ import (
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
) )
@@ -141,6 +142,7 @@ func (m *Matchmaker) StartVsAI(ctx context.Context, accountID uuid.UUID, variant
if rand.IntN(2) == 1 { if rand.IntN(2) == 1 {
params.Seats = []uuid.UUID{robotID, accountID} params.Seats = []uuid.UUID{robotID, accountID}
} }
params.Kind = gamelimits.KindVsAI
g, err := m.games.Create(ctx, params) g, err := m.games.Create(ctx, params)
if err != nil { if err != nil {
return EnqueueResult{}, err return EnqueueResult{}, err
+1 -1
View File
@@ -37,6 +37,7 @@ func toWireGame(g GameSummary) wire.GameView {
TurnTimeoutSecs: g.TurnTimeoutSecs, TurnTimeoutSecs: g.TurnTimeoutSecs,
MultipleWordsPerTurn: g.MultipleWordsPerTurn, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI, VsAI: g.VsAI,
Kind: g.Kind,
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
EndReason: g.EndReason, EndReason: g.EndReason,
Seats: seats, Seats: seats,
@@ -83,7 +84,6 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
Rack: s.Rack, Rack: s.Rack,
BagLen: s.BagLen, BagLen: s.BagLen,
HintsRemaining: s.HintsRemaining, HintsRemaining: s.HintsRemaining,
WalletBalance: s.WalletBalance,
Alphabet: alphabet, Alphabet: alphabet,
}) })
} }
+4
View File
@@ -73,6 +73,10 @@ const (
// (e.g. an email was confirmed via the one-tap deeplink opened in another browser), // (e.g. an email was confirmed via the one-tap deeplink opened in another browser),
// so it re-fetches profile.get. It carries no payload. In-app only. // so it re-fetches profile.get. It carries no payload. In-app only.
NotifyProfile = "profile" NotifyProfile = "profile"
// NotifyPayment tells the client that the viewer's wallet changed from a payment-intake event
// (a credit or a refund), so it re-fetches the wallet in place. It carries no payload. In-app
// only; the payment_events dispatcher emits it after the crediting transaction commits.
NotifyPayment = "payment"
// NotifyUserBlocked confirms to the blocker that a per-user block took effect, // NotifyUserBlocked confirms to the blocker that a per-user block took effect,
// carrying the blocked account, so every one of the blocker's sessions updates the // carrying the blocked account, so every one of the blocker's sessions updates the
// in-game block/add-friend controls and the struck name in place. It is delivered // in-game block/add-friend controls and the struck name in place. It is delivered
+2 -2
View File
@@ -115,7 +115,7 @@ func TestGameOverPayloadRoundTrips(t *testing.T) {
func TestOpponentMovedPayloadRoundTrips(t *testing.T) { func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
uid, gid := uuid.New(), uuid.New() uid, gid := uuid.New(), uuid.New()
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130} move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130}
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}} summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Kind: 2, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
in := notify.OpponentMoved(uid, gid, move, summary, 42) in := notify.OpponentMoved(uid, gid, move, summary, 42)
if in.Kind != notify.KindOpponentMoved { if in.Kind != notify.KindOpponentMoved {
t.Fatalf("kind = %q", in.Kind) t.Fatalf("kind = %q", in.Kind)
@@ -132,7 +132,7 @@ func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 { if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
t.Fatalf("move wrong: %+v", m) t.Fatalf("move wrong: %+v", m)
} }
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 { if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 || g.Kind() != 2 {
t.Fatalf("game summary wrong: %+v", ev.Game(nil)) t.Fatalf("game summary wrong: %+v", ev.Game(nil))
} }
} }
+3 -1
View File
@@ -35,6 +35,9 @@ type GameSummary struct {
EndReason string EndReason string
Seats []SeatStanding Seats []SeatStanding
LastActivityUnix int64 LastActivityUnix int64
// Kind is the game's origin for the active-game limits (0 unknown, 1 vs_ai, 2 random, 3 friends);
// it rides live events so a lobby patch keeps the per-kind count correct.
Kind int
} }
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an // AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
@@ -56,7 +59,6 @@ type PlayerState struct {
Rack []int Rack []int
BagLen int BagLen int
HintsRemaining int HintsRemaining int
WalletBalance int
Alphabet []AlphabetLetter Alphabet []AlphabetLetter
} }
+269
View File
@@ -0,0 +1,269 @@
package payments
import (
"cmp"
"context"
"errors"
"fmt"
"math"
"slices"
"strings"
"github.com/google/uuid"
)
// AdminProduct is one catalog product for the admin editor: its full composition, every price, the
// archived flag (Active), and whether it has ever been transacted — which forbids a hard delete
// (orders / ledger rows reference it, and the ledger is append-only).
type AdminProduct struct {
ID uuid.UUID
Title string
Active bool
Atoms []AtomLine
Prices []PriceLine
Transacted bool
}
// AtomLine is one atom of a product: the atom type and its positive quantity.
type AtomLine struct {
Atom string
Quantity int
}
// PriceLine is one price of a product: the payment method ("" for a value's CHIP price, which is
// stored with a NULL method), the currency, and the amount in that currency's minor units.
type PriceLine struct {
Method string
Currency Currency
Amount int64
}
// ProductInput is the editable content of a product: its title, atom composition and prices.
type ProductInput struct {
Title string
Atoms []AtomLine
Prices []PriceLine
}
// knownAtoms is the fixed atom set, mirroring the catalog_atom seed / CHECK.
var knownAtoms = map[string]bool{atomChips: true, "hints": true, "noads_days": true, "tournament": true}
// validCurrency reports whether c is one of the four catalog currencies.
func validCurrency(c Currency) bool {
switch c {
case CurrencyRUB, CurrencyVote, CurrencyStar, CurrencyChip:
return true
}
return false
}
// validateProduct checks a product's composition and prices. When sellable is true (the product is
// or is becoming active) it also enforces the storefront shape: a pack (the chips atom ⇒ a money
// price per method and no CHIP price, chips-only) or a value (no chips ⇒ a single CHIP price and no
// money price). The tournament atom is never sellable (its economy is the tournament stage), so an
// active product may not carry it; an archived draft may (a template for later) and skips the shape
// check.
func validateProduct(in ProductInput, sellable bool) error {
if strings.TrimSpace(in.Title) == "" {
return errors.New("product title is required")
}
if len(in.Atoms) == 0 {
return errors.New("product needs at least one atom")
}
seen := map[string]bool{}
hasChips, hasTournament, hasBenefit := false, false, false
for _, a := range in.Atoms {
if !knownAtoms[a.Atom] {
return fmt.Errorf("unknown atom %q", a.Atom)
}
if seen[a.Atom] {
return fmt.Errorf("duplicate atom %q", a.Atom)
}
seen[a.Atom] = true
if a.Quantity <= 0 {
return fmt.Errorf("atom %q quantity must be positive", a.Atom)
}
switch a.Atom {
case atomChips:
hasChips = true
case "tournament":
hasTournament = true
default:
hasBenefit = true
}
}
priceSeen := map[string]bool{}
hasChipPrice, hasMoneyPrice := false, false
for _, p := range in.Prices {
if p.Method != "" && p.Method != string(SourceVK) && p.Method != string(SourceTelegram) && p.Method != string(SourceDirect) {
return fmt.Errorf("invalid price method %q", p.Method)
}
if !validCurrency(p.Currency) {
return fmt.Errorf("invalid currency %q", p.Currency)
}
if p.Amount < 0 {
return errors.New("price amount must be non-negative")
}
if p.Currency == CurrencyChip && p.Method != "" {
return errors.New("a CHIP price must have no method")
}
if p.Currency != CurrencyChip && p.Method == "" {
return fmt.Errorf("a %s price needs a payment method", p.Currency)
}
key := p.Method + "|" + string(p.Currency)
if priceSeen[key] {
return fmt.Errorf("duplicate price (%s, %s)", p.Method, p.Currency)
}
priceSeen[key] = true
if p.Currency == CurrencyChip {
hasChipPrice = true
} else {
hasMoneyPrice = true
}
}
if !sellable {
return nil // an archived draft may be incomplete
}
if hasTournament {
return errors.New("a product carrying the tournament atom cannot be sold yet")
}
if hasChips {
if hasBenefit {
return errors.New("a chip pack must contain only the chips atom")
}
if !hasMoneyPrice || hasChipPrice {
return errors.New("a chip pack needs a money price per method and no CHIP price")
}
} else {
if !hasChipPrice || hasMoneyPrice {
return errors.New("a value needs a single CHIP price and no money price")
}
}
return nil
}
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
return s.store.adminCatalog(ctx)
}
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
// ([projectOfferPricing]): chip packs first, ascending by rouble price; then chip-priced values,
// grouped (hints only, no-ads only, no-ads + hints, tournament) and ascending by chip price within a
// group. It is stable, so equal-keyed products keep their incoming order, and it keys archived
// products the same as active ones (the admin list shows both).
func SortAdminCatalog(products []AdminProduct) {
slices.SortStableFunc(products, func(a, b AdminProduct) int {
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
if pa {
return -1 // packs (sales) before values (chip exchange)
}
return 1
} else if pa {
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
}
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
return d
}
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
})
}
// adminIsPack reports whether the product is a chip pack (it carries the chips atom).
func adminIsPack(p AdminProduct) bool {
for _, a := range p.Atoms {
if a.Atom == atomChips {
return true
}
}
return false
}
// adminValueGroup ranks a chip-priced value into the shared listing groups (see [valueGroup]).
func adminValueGroup(p AdminProduct) int {
hasHints, hasNoAds, hasTournament := false, false, false
for _, a := range p.Atoms {
switch a.Atom {
case "hints":
hasHints = true
case "noads_days":
hasNoAds = true
case "tournament":
hasTournament = true
}
}
return valueGroup(hasHints, hasNoAds, hasTournament)
}
// adminPriceAmount returns the minor-unit amount of the product's price for the method and currency,
// or math.MaxInt64 when it carries no such price (so a misconfigured product sorts last, not first).
func adminPriceAmount(p AdminProduct, method string, cur Currency) int64 {
for _, pr := range p.Prices {
if pr.Method == method && pr.Currency == cur {
return pr.Amount
}
}
return math.MaxInt64
}
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
// product created active must satisfy the sellable shape.
func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) {
if err := validateProduct(in, active); err != nil {
return uuid.Nil, err
}
id, err := s.store.createProduct(ctx, in, active, s.clock())
if err == nil {
s.markOfferStale()
}
return id, err
}
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
// satisfy the sellable shape.
func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInput) (err error) {
active, err := s.store.productActive(ctx, id)
if err != nil {
return err
}
if err := validateProduct(in, active); err != nil {
return err
}
if err := s.store.updateProduct(ctx, id, in, s.clock()); err != nil {
return err
}
s.markOfferStale()
return nil
}
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
// sellable shape, so a draft or a tournament product cannot be put on sale.
func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active bool) error {
if active {
in, err := s.store.productInput(ctx, id)
if err != nil {
return err
}
if err := validateProduct(in, true); err != nil {
return err
}
}
if err := s.store.setProductActive(ctx, id, active, s.clock()); err != nil {
return err
}
s.markOfferStale()
return nil
}
// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
if err := s.store.deleteProduct(ctx, id); err != nil {
return err
}
s.markOfferStale()
return nil
}
@@ -0,0 +1,47 @@
package payments
import "testing"
func TestValidateProduct(t *testing.T) {
pack := ProductInput{
Title: "100 chips",
Atoms: []AtomLine{{Atom: "chips", Quantity: 100}},
Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 14900}},
}
value := ProductInput{
Title: "5 hints",
Atoms: []AtomLine{{Atom: "hints", Quantity: 5}},
Prices: []PriceLine{{Method: "", Currency: CurrencyChip, Amount: 50}},
}
tests := []struct {
name string
in ProductInput
sellable bool
wantErr bool
}{
{"valid pack", pack, true, false},
{"valid value", value, true, false},
{"pack with a benefit atom", ProductInput{Title: "x", Atoms: []AtomLine{{"chips", 100}, {"hints", 5}}, Prices: pack.Prices}, true, true},
{"pack without money price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: value.Prices}, true, true},
{"value without chip price", ProductInput{Title: "x", Atoms: value.Atoms, Prices: pack.Prices}, true, true},
{"tournament sellable is refused", ProductInput{Title: "cup", Atoms: []AtomLine{{"tournament", 1}}, Prices: value.Prices}, true, true},
{"tournament draft is allowed", ProductInput{Title: "cup", Atoms: []AtomLine{{"tournament", 1}}}, false, false},
{"unknown atom", ProductInput{Title: "x", Atoms: []AtomLine{{"gold", 1}}}, false, true},
{"duplicate atom", ProductInput{Title: "x", Atoms: []AtomLine{{"hints", 1}, {"hints", 2}}}, false, true},
{"non-positive quantity", ProductInput{Title: "x", Atoms: []AtomLine{{"hints", 0}}}, false, true},
{"chip price with a method", ProductInput{Title: "x", Atoms: value.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyChip, Amount: 50}}}, true, true},
{"money price without a method", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "", Currency: CurrencyRUB, Amount: 149}}}, true, true},
{"duplicate price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 1}, {Method: "direct", Currency: CurrencyRUB, Amount: 2}}}, true, true},
{"empty title", ProductInput{Title: " ", Atoms: value.Atoms, Prices: value.Prices}, true, true},
{"no atoms", ProductInput{Title: "x", Prices: value.Prices}, true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateProduct(tt.in, tt.sellable)
if (err != nil) != tt.wantErr {
t.Fatalf("validateProduct = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
+44
View File
@@ -134,3 +134,47 @@ func TestProjectCatalog_Empty(t *testing.T) {
t.Errorf("empty catalog projected %d products, want 0", len(got.Products)) t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
} }
} }
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
// ascending by chip price within a group. Stable and applied to active + archived alike.
func TestSortAdminCatalog(t *testing.T) {
pack := func(title string, rub int64) AdminProduct {
return AdminProduct{
Title: title,
Atoms: []AtomLine{{Atom: atomChips, Quantity: 1}},
Prices: []PriceLine{{Method: string(SourceDirect), Currency: CurrencyRUB, Amount: rub}},
}
}
value := func(title string, chips int64, atoms ...string) AdminProduct {
p := AdminProduct{Title: title, Prices: []PriceLine{{Currency: CurrencyChip, Amount: chips}}}
for _, a := range atoms {
p.Atoms = append(p.Atoms, AtomLine{Atom: a, Quantity: 1})
}
return p
}
products := []AdminProduct{
pack("packDear", 30000),
value("bundle", 500, "hints", "noads_days"),
pack("packCheap", 10000),
value("adsOnly", 150, "noads_days"),
value("hintsBig", 200, "hints"),
value("hintsSmall", 50, "hints"),
}
SortAdminCatalog(products)
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
for i, p := range products {
if p.Title != want[i] {
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], titles(products))
}
}
}
// titles extracts the product titles for a failure message.
func titles(products []AdminProduct) []string {
out := make([]string, len(products))
for i, p := range products {
out[i] = p.Title
}
return out
}
+13 -10
View File
@@ -89,8 +89,10 @@ func NewContext(kind, subtype string) Context {
// every spend/purchase and the application of any foreign origin. // every spend/purchase and the application of any foreign origin.
func (c Context) Trusted() bool { return c.Kind.Valid() } func (c Context) Trusted() bool { return c.Kind.Valid() }
// vkFrozen reports whether this is the VK-iOS spend freeze: VK context on the trusted iOS // vkFrozen reports whether this is the VK-iOS purchase freeze: VK context on the trusted iOS
// subtype. A previously bought benefit still applies there, but no spend or purchase is possible. // subtype. Apple's ToS forbids only BUYING in-app values there, so a purchase (money -> chips) is
// refused; earning chips (rewarded ads) and SPENDING chips already in the VK wallet — earned or
// bought on the same account elsewhere (e.g. VK Android) — are legal and stay allowed.
func (c Context) vkFrozen() bool { return c.Kind == SourceVK && c.Subtype == SubtypeIOS } func (c Context) vkFrozen() bool { return c.Kind == SourceVK && c.Subtype == SubtypeIOS }
// spendPriority is the fixed draw order when several segments are spendable in one context // spendPriority is the fixed draw order when several segments are spendable in one context
@@ -103,11 +105,12 @@ func has(present []Source, s Source) bool {
} }
// spendableSources returns the chip segments that may be SPENT in the context, in draw-priority // spendableSources returns the chip segments that may be SPENT in the context, in draw-priority
// order, restricted to the sources the account actually has (present). It is empty when the // order, restricted to the sources the account actually has (present). It is empty only when the
// platform is untrusted (fail-closed) or VK-iOS (frozen): inside VK/TG only the same-named // platform is untrusted (fail-closed); VK-iOS is NOT excluded — the freeze is purchase-only, so
// segment is spendable; on web/native all attached segments are, drained direct→vk→tg. // spending VK-wallet chips there is allowed. Inside VK/TG only the same-named segment is spendable;
// on web/native all attached segments are, drained direct→vk→tg.
func spendableSources(c Context, present []Source) []Source { func spendableSources(c Context, present []Source) []Source {
if !c.Trusted() || c.vkFrozen() { if !c.Trusted() {
return nil return nil
} }
switch c.Kind { switch c.Kind {
@@ -128,10 +131,10 @@ func spendableSources(c Context, present []Source) []Source {
} }
// applicableOrigins returns the benefit origins that APPLY in the context, in draw-priority // applicableOrigins returns the benefit origins that APPLY in the context, in draw-priority
// order, restricted to present sources. It differs from spendableSources in one way: VK-iOS is // order, restricted to present sources. It mirrors spendableSources (both gate only on a trusted
// NOT excluded — a benefit bought earlier still applies while spending is frozen. Inside VK/TG // platform now that the VK-iOS freeze is purchase-only). Inside VK/TG only the same-named origin
// only the same-named origin applies (a foreign, e.g. direct, origin never activates inside a // applies (a foreign, e.g. direct, origin never activates inside a store — the compliance wall);
// store — the compliance wall); on web/native direct+vk+tg all apply, drained direct→vk→tg. // on web/native direct+vk+tg all apply, drained direct→vk→tg.
func applicableOrigins(c Context, present []Source) []Source { func applicableOrigins(c Context, present []Source) []Source {
if !c.Trusted() { if !c.Trusted() {
return nil return nil
+4 -3
View File
@@ -16,7 +16,8 @@ func TestSpendableSources(t *testing.T) {
want []Source want []Source
}{ }{
{"vk android, vk present", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}}, {"vk android, vk present", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
{"vk ios frozen", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, nil}, // VK-iOS spends its own vk segment: the freeze is purchase-only, spending is allowed.
{"vk ios spends vk", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
{"vk android, vk absent", Context{Kind: SourceVK, Subtype: "android"}, []Source{SourceDirect}, nil}, {"vk android, vk absent", Context{Kind: SourceVK, Subtype: "android"}, []Source{SourceDirect}, nil},
{"telegram", Context{Kind: SourceTelegram, Subtype: "web"}, allPresent, []Source{SourceTelegram}}, {"telegram", Context{Kind: SourceTelegram, Subtype: "web"}, allPresent, []Source{SourceTelegram}},
{"telegram, tg absent", Context{Kind: SourceTelegram}, []Source{SourceVK}, nil}, {"telegram, tg absent", Context{Kind: SourceTelegram}, []Source{SourceVK}, nil},
@@ -41,8 +42,8 @@ func TestApplicableOrigins(t *testing.T) {
present []Source present []Source
want []Source want []Source
}{ }{
// A benefit still APPLIES on VK-iOS while spending is frozen. // A vk-origin benefit applies on VK-iOS (spending is allowed there — the freeze is purchase-only).
{"vk ios still applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}}, {"vk ios applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
{"vk android", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}}, {"vk android", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
{"telegram", Context{Kind: SourceTelegram}, allPresent, []Source{SourceTelegram}}, {"telegram", Context{Kind: SourceTelegram}, allPresent, []Source{SourceTelegram}},
{"direct all, priority", Context{Kind: SourceDirect}, allPresent, []Source{SourceDirect, SourceVK, SourceTelegram}}, {"direct all, priority", Context{Kind: SourceDirect}, allPresent, []Source{SourceDirect, SourceVK, SourceTelegram}},
+218
View File
@@ -0,0 +1,218 @@
package payments
import (
"cmp"
"context"
"fmt"
"math"
"slices"
"strings"
)
// pricingMarker is the token the owner-edited offer markdown (ui/legal/offer_ru.md, §4.4) carries
// where the price list belongs. The render sidecar replaces it with the markdown [Service.OfferPricing]
// returns before rendering the /offer/ page; the backend only produces the tables, never the marker.
const pricingMarker = "<#pricing_template#>"
// OfferPricing returns the public-offer price list (§4.4) as two markdown tables projected from the
// active catalog: first the chip packs (funding chips with money, priced per rail — roubles / VK
// votes / Telegram Stars), then the chip-priced values (what a player exchanges chips for). The
// result is cached in memory and reprojected only after a catalog mutation (see [Service.markOfferStale]),
// so a steady-state read issues no query — only the first read after an edit reprojects. The render
// sidecar fetches it and splices it into the offer markdown at the pricing marker.
func (s *Service) OfferPricing(ctx context.Context) (string, error) {
s.offerMu.Lock()
defer s.offerMu.Unlock()
if s.offerFresh {
return s.offerMD, nil
}
md, err := s.buildOfferPricing(ctx)
if err != nil {
return "", err
}
s.offerMD = md
s.offerFresh = true
return md, nil
}
// markOfferStale marks the cached offer price list for reprojection on the next [Service.OfferPricing]
// read. Every catalog mutation calls it; it takes no I/O, so it never fails the mutation that triggers it.
func (s *Service) markOfferStale() {
s.offerMu.Lock()
s.offerFresh = false
s.offerMu.Unlock()
}
// buildOfferPricing loads the active catalog and projects it into the offer tables.
func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
entries, err := s.store.loadCatalog(ctx)
if err != nil {
return "", err
}
return projectOfferPricing(entries), nil
}
// projectOfferPricing renders the active catalog into the two offer tables. A chip pack (it carries
// the chips atom) lists its per-rail money price; a value (no chips atom) lists its uniform chip
// price. Packs are ordered by ascending rouble price; values are grouped by what they grant (hints
// only, then no-ads only, then no-ads + hints, then tournament — see [offerValueGroup]) and, within
// each group, ordered by ascending chip price. Price columns are right-aligned. An empty section is
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
func projectOfferPricing(entries []catalogEntry) string {
var packs, values []catalogEntry
for _, e := range entries {
if isPackEntry(e) {
packs = append(packs, e)
} else {
values = append(values, e)
}
}
// Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price
// sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties.
slices.SortStableFunc(packs, func(a, b catalogEntry) int {
return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB))
})
slices.SortStableFunc(values, func(a, b catalogEntry) int {
if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 {
return d
}
return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip))
})
var b strings.Builder
if len(packs) > 0 {
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n")
b.WriteString("| --- | ---: | ---: | ---: |\n")
for _, e := range packs {
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n",
offerCell(e.title),
offerPrice(e, string(SourceDirect), CurrencyRUB),
offerPrice(e, string(SourceVK), CurrencyVote),
offerPrice(e, string(SourceTelegram), CurrencyStar),
)
}
}
if len(values) > 0 {
if len(packs) > 0 {
b.WriteString("\n")
}
b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n")
b.WriteString("| Наименование | «Фишки» |\n")
b.WriteString("| --- | ---: |\n")
for _, e := range values {
fmt.Fprintf(&b, "| %s | %s |\n", offerCell(e.title), offerPrice(e, "", CurrencyChip))
}
}
return strings.TrimRight(b.String(), "\n")
}
// offerValueGroup ranks a chip-priced value into the usage groups (see [valueGroup]).
func offerValueGroup(e catalogEntry) int {
hasHints, hasNoAds, hasTournament := false, false, false
for _, a := range e.atoms {
switch a.atomType {
case "hints":
hasHints = true
case "noads_days":
hasNoAds = true
case "tournament":
hasTournament = true
}
}
return valueGroup(hasHints, hasNoAds, hasTournament)
}
// valueGroup ranks a chip-priced value into the listing groups shared by the public offer and the
// admin catalog, in listing order: hints only (0), no-ads only (1), no-ads + hints (2), then anything
// carrying the tournament atom (3). Tournament products are not sellable yet (validateProduct forbids
// an active one), so group 3 is empty today; the rank reserves their place for when the tournament
// economy lands. A value with no recognised benefit atom sorts after the known groups (defensive —
// the catalog shape forbids it).
func valueGroup(hasHints, hasNoAds, hasTournament bool) int {
switch {
case hasTournament:
return 3
case hasHints && hasNoAds:
return 2
case hasNoAds:
return 1
case hasHints:
return 0
default:
return 4
}
}
// offerSortAmount returns the entry's price in the given method and currency for ordering, or
// math.MaxInt64 when it carries no such price, so a misconfigured row sorts last rather than leading.
func offerSortAmount(e catalogEntry, method string, cur Currency) int64 {
if amt, ok := offerAmount(e, method, cur); ok {
return amt
}
return math.MaxInt64
}
// isPackEntry reports whether the catalog entry is a chip pack — it carries the chips atom (funds
// chips with money) rather than being a chip-priced value.
func isPackEntry(e catalogEntry) bool {
for _, a := range e.atoms {
if a.atomType == atomChips {
return true
}
}
return false
}
// offerPrice formats the entry's price for the given payment method and currency as a major-unit
// string, or an em dash when the entry carries no such price.
func offerPrice(e catalogEntry, method string, cur Currency) string {
amt, ok := offerAmount(e, method, cur)
if !ok {
return "—"
}
m, err := MoneyFromMinor(amt, cur)
if err != nil {
return "—"
}
return m.Major()
}
// offerAmount returns the raw minor-unit amount of the entry's price for the payment method and
// currency, and whether such a price exists.
func offerAmount(e catalogEntry, method string, cur Currency) (int64, bool) {
for _, pr := range e.prices {
if pr.method == method && pr.currency == cur {
return pr.amount, true
}
}
return 0, false
}
// offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as
// literal text in the public offer. The title is operator input (the /_gm catalog editor) that flows
// into a markdown table cell and then through marked into the /offer/ HTML, which is deliberately not
// sanitised — so escaping here is the trust boundary. It covers HTML (no tag or entity reaches the
// page), the markdown table pipe and the row newline, and the link brackets (a title must never
// become a "javascript:" link). marked passes the entities through unchanged, so the reader sees the
// exact title. NewReplacer scans once and never re-scans its own output, so "&" → "&amp;" does not
// double-escape the entities the other rules emit.
var offerCellReplacer = strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&#39;",
"|", `\|`,
"[", `\[`,
"]", `\]`,
"\n", " ",
)
// offerCell escapes an admin-entered title for safe, literal rendering in a markdown table cell of
// the public offer (see [offerCellReplacer]).
func offerCell(s string) string {
return offerCellReplacer.Replace(s)
}
+137
View File
@@ -0,0 +1,137 @@
package payments
import (
"strings"
"testing"
"github.com/google/uuid"
)
// TestProjectOfferPricing checks the happy path: a chip pack priced on every rail and a chip-priced
// value render into the two tables, pack table first, money formatted through Money.
func TestProjectOfferPricing(t *testing.T) {
entries := []catalogEntry{
{
id: uuid.New(),
title: "50 «Фишек»",
atoms: []atomQty{{atomType: atomChips, quantity: 50}},
prices: []priceRow{
{method: string(SourceDirect), currency: CurrencyRUB, amount: 20000},
{method: string(SourceVK), currency: CurrencyVote, amount: 30},
{method: string(SourceTelegram), currency: CurrencyStar, amount: 100},
},
},
{
id: uuid.New(),
title: "200 подсказок",
atoms: []atomQty{{atomType: "hints", quantity: 200}},
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 50}},
},
}
md := projectOfferPricing(entries)
for _, want := range []string{
"| Наименование | Рубли | Голоса в VK | Stars в Telegram |",
"| --- | ---: | ---: | ---: |", // price columns right-aligned
"| 50 «Фишек» | 200.00 | 30 | 100 |",
"| Наименование | «Фишки» |",
"| --- | ---: |",
"| 200 подсказок | 50 |",
} {
if !strings.Contains(md, want) {
t.Errorf("projection missing %q\n---\n%s", want, md)
}
}
if strings.Index(md, "Приобретение") > strings.Index(md, "Использование") {
t.Errorf("the pack table must precede the values table:\n%s", md)
}
}
// TestProjectOfferPricingOrdering checks packs sort by ascending rouble price, and values sort by
// group (hints only → no-ads only → no-ads + hints) then ascending chip price within a group.
func TestProjectOfferPricingOrdering(t *testing.T) {
pack := func(title string, rub int64) catalogEntry {
return catalogEntry{
id: uuid.New(),
title: title,
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: rub}},
}
}
value := func(title string, chips int64, atoms ...string) catalogEntry {
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
for _, a := range atoms {
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
}
return e
}
// Deliberately out of order on input.
entries := []catalogEntry{
pack("packDear", 30000),
pack("packCheap", 10000),
value("bundle", 500, "hints", "noads_days"),
value("adsOnly", 150, "noads_days"),
value("hintsBig", 200, "hints"),
value("hintsSmall", 50, "hints"),
}
md := projectOfferPricing(entries)
order := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
last := -1
for _, title := range order {
i := strings.Index(md, "| "+title+" |")
if i < 0 {
t.Fatalf("row %q missing:\n%s", title, md)
}
if i < last {
t.Errorf("row %q out of order (want sequence %v):\n%s", title, order, md)
}
last = i
}
}
// TestProjectOfferPricingMissingRailAndEscaping checks a pack missing a rail shows an em dash and a
// title carrying a pipe is escaped so the table layout survives.
func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) {
entries := []catalogEntry{{
id: uuid.New(),
title: "Bonus | pack",
atoms: []atomQty{{atomType: atomChips, quantity: 10}},
// A roubles price only — no VK, no Telegram.
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: 9900}},
}}
md := projectOfferPricing(entries)
if want := `| Bonus \| pack | 99.00 | — | — |`; !strings.Contains(md, want) {
t.Errorf("want row %q in:\n%s", want, md)
}
}
// TestProjectOfferPricingEscapesHTMLAndLinks checks an admin title carrying HTML or a markdown link
// is neutralised so it cannot inject markup into the public offer: the tag becomes entities and the
// link brackets are escaped (so no "javascript:" anchor forms). The raw metacharacters must not
// survive into the projected markdown.
func TestProjectOfferPricingEscapesHTMLAndLinks(t *testing.T) {
entries := []catalogEntry{{
id: uuid.New(),
title: `<script>alert(1)</script> [x](javascript:alert(2)) & "q"`,
atoms: []atomQty{{atomType: "hints", quantity: 1}},
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 5}},
}}
md := projectOfferPricing(entries)
for _, bad := range []string{"<script>", "</script>", "[x]", `& "q"`} {
if strings.Contains(md, bad) {
t.Errorf("unescaped %q survived into the projection:\n%s", bad, md)
}
}
for _, want := range []string{"&lt;script&gt;", `\[x\]`, "&amp;", "&quot;q&quot;"} {
if !strings.Contains(md, want) {
t.Errorf("want escaped %q in:\n%s", want, md)
}
}
}
// TestProjectOfferPricingEmpty checks an empty catalog projects to the empty string (no stray table
// headers), so the offer's pricing marker is replaced with nothing.
func TestProjectOfferPricingEmpty(t *testing.T) {
if got := projectOfferPricing(nil); got != "" {
t.Errorf("empty catalog must project to empty string, got %q", got)
}
}
+11 -5
View File
@@ -147,12 +147,13 @@ func (m Money) Cmp(o Money) (int, error) {
} }
} }
// String renders the amount as "<value> <currency>", with the currency's // Major renders the amount as a decimal string without the currency, with the currency's
// fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR"). // fractional digits and no floating point (e.g. "149.50", "250") — the form a provider's amount
func (m Money) String() string { // field (Robokassa OutSum) takes.
func (m Money) Major() string {
scale := m.currency.minorPerUnit() scale := m.currency.minorPerUnit()
if scale == 1 { if scale == 1 {
return fmt.Sprintf("%d %s", m.minor, m.currency) return fmt.Sprintf("%d", m.minor)
} }
neg := m.minor < 0 neg := m.minor < 0
abs := m.minor abs := m.minor
@@ -167,5 +168,10 @@ func (m Money) String() string {
if neg { if neg {
sign = "-" sign = "-"
} }
return fmt.Sprintf("%s%d.%0*d %s", sign, abs/scale, width, abs%scale, m.currency) return fmt.Sprintf("%s%d.%0*d", sign, abs/scale, width, abs%scale)
}
// String renders the amount as "<value> <currency>" (e.g. "149.50 RUB", "250 XTR").
func (m Money) String() string {
return m.Major() + " " + string(m.currency)
} }
+41
View File
@@ -0,0 +1,41 @@
package payments
import (
"context"
"github.com/google/uuid"
)
// providerAdmin tags an operator-initiated refund in the ledger, distinct from a rail's own refund
// (robokassa / vk / telegram). The refund idempotency key (providerAdmin, order id) allows exactly
// one manual full refund per order.
const providerAdmin = "admin"
// RefundOrderFull refunds a paid order in full at the operator's request: it revokes the funded
// chips best-effort (floored at 0, never negative — D27), records a refund ledger row, and is
// idempotent (a second call reports AlreadyRefunded). The operator performs the actual money refund
// on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment); this records it.
func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (RefundOutcome, error) {
o, err := s.store.orderByID(ctx, orderID)
if err != nil {
return RefundOutcome{}, err
}
refunded, err := MoneyFromMinor(o.expectedAmount, Currency(o.currency))
if err != nil {
return RefundOutcome{}, err
}
return s.store.refund(ctx, orderID, providerAdmin, orderID.String(), refunded, s.clock())
}
// LedgerExportRow is one append-only ledger row for the tax / reconciliation export, carrying the
// account it belongs to alongside the entry fields.
type LedgerExportRow struct {
AccountID string
LedgerEntry
}
// LedgerExport reads the entire append-only ledger (all accounts, newest first) for a CSV/JSON
// export — tax reporting and future rail reconciliation. Uncached, admin-only.
func (s *Service) LedgerExport(ctx context.Context) ([]LedgerExportRow, error) {
return s.store.allLedger(ctx)
}
+60
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"sync"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -19,6 +20,13 @@ import (
type Service struct { type Service struct {
store *Store store *Store
clock func() time.Time clock func() time.Time
// offerMu guards the cached public-offer price list (§4.4). offerMD holds the projected
// markdown tables and offerFresh whether they are current; a catalog mutation clears offerFresh
// (markOfferStale) and the next OfferPricing read reprojects, so a served render issues no query.
offerMu sync.Mutex
offerMD string
offerFresh bool
} }
// NewService constructs a Service over store with a wall-clock time source. // NewService constructs a Service over store with a wall-clock time source.
@@ -156,6 +164,41 @@ func (s *Service) Grant(ctx context.Context, accountID uuid.UUID, origin Source,
return s.store.grant(ctx, accountID, origin, d, snapshot, s.clock()) return s.store.grant(ctx, accountID, origin, d, snapshot, s.clock())
} }
// GrantProduct grants a product's benefit atoms (hints, no-ads days) to an origin as a zero-price
// admin sale, recording the source product on the ledger row (auditable to it). It refuses a
// product carrying the chips atom (never granted — D16) or the tournament atom (no credit target
// yet), and one whose atoms yield no grantable benefit.
func (s *Service) GrantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID) error {
if !origin.Valid() {
return fmt.Errorf("payments: invalid grant origin %q", origin)
}
in, err := s.store.productInput(ctx, productID)
if err != nil {
return err
}
var d benefitDelta
for _, a := range in.Atoms {
switch a.Atom {
case "hints":
d.hintsAdd += a.Quantity
case "noads_days":
d.noAdsDays += a.Quantity
case atomChips:
return ErrCannotGrantChips
case "tournament":
return ErrCannotGrantTournament
}
}
if d.zero() {
return ErrNothingToGrant
}
snapshot, err := marshalGrantProduct(productID, in.Title, d)
if err != nil {
return err
}
return s.store.grantProduct(ctx, accountID, origin, productID, d, snapshot, s.clock())
}
// MergeTx merges the secondary account's segments and benefits into the primary inside the // MergeTx merges the secondary account's segments and benefits into the primary inside the
// caller's transaction (the account-merge flow). The caller invalidates the affected caches // caller's transaction (the account-merge flow). The caller invalidates the affected caches
// after committing (Invalidate). // after committing (Invalidate).
@@ -244,3 +287,20 @@ func marshalGrant(d benefitDelta) ([]byte, error) {
} }
return b, nil return b, nil
} }
// marshalGrantProduct builds the snapshot for an admin grant-by-product: the source product and the
// benefit atoms it granted (price 0).
func marshalGrantProduct(productID uuid.UUID, title string, d benefitDelta) ([]byte, error) {
atoms := map[string]int{}
if d.hintsAdd > 0 {
atoms["hints"] = d.hintsAdd
}
if d.noAdsDays > 0 {
atoms["noads_days"] = d.noAdsDays
}
b, err := json.Marshal(purchaseSnapshot{ProductID: productID.String(), Title: title, Atoms: atoms, PriceChips: 0})
if err != nil {
return nil, fmt.Errorf("payments: marshal product grant snapshot: %w", err)
}
return b, nil
}
+200
View File
@@ -0,0 +1,200 @@
package payments
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
)
// OrderResult is what CreateOrder returns to the transport: the created order id and the details a
// provider launch payload needs — the amount to charge and a human title for the payment.
type OrderResult struct {
OrderID uuid.UUID
Amount Money
Title string
}
// CreateOrder opens a pending order to fund a chip pack in the execution context's payment method,
// tagged with the provider that will settle it. It gate-checks the context (trusted, not the
// VK-iOS spend freeze) and that the method's funding segment is attached, prices the pack in the
// method's currency, then writes the order. The caller enforces any account-level precondition
// (e.g. the direct email anchor, D36) before calling — payments holds no cross-schema identity
// knowledge.
func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, productID uuid.UUID, provider string) (OrderResult, error) {
if !cxt.Trusted() || cxt.vkFrozen() {
return OrderResult{}, ErrUntrusted
}
method := cxt.Kind
if !has(present, method) {
return OrderResult{}, ErrUntrusted // the funding segment is not attached to the account
}
pack, err := s.store.loadPackForOrder(ctx, productID, method)
if err != nil {
return OrderResult{}, err
}
orderID, err := uuid.NewV7()
if err != nil {
return OrderResult{}, fmt.Errorf("payments: order id: %w", err)
}
o := newOrder{
orderID: orderID,
accountID: accountID,
platform: string(method),
productID: productID,
amount: pack.price,
origin: method,
provider: provider,
}
if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
return OrderResult{}, err
}
return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil
}
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
func (s *Service) OrderItem(ctx context.Context, orderID uuid.UUID) (title string, amount Money, err error) {
ord, err := s.store.orderByID(ctx, orderID)
if err != nil {
return "", Money{}, err
}
_, title, err = s.store.packForCredit(ctx, ord.productID)
if err != nil {
return "", Money{}, err
}
amount, err = MoneyFromMinor(ord.expectedAmount, Currency(ord.currency))
if err != nil {
return "", Money{}, err
}
return title, amount, nil
}
// Fund credits a paid order into its funded segment exactly once, from a verified provider callback
// — the single writer for every rail. It matches the order, verifies the paid amount, appends the
// fund ledger row (idempotent on (provider, provider_payment_id)), credits the balance and marks
// the order paid. A duplicate callback returns AlreadyCredited without a second credit; a valid
// callback is honoured even on an expired order (§9/D23).
func (s *Service) Fund(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, paid Money) (FundOutcome, error) {
return s.store.fund(ctx, orderID, provider, providerPaymentID, paid, s.clock())
}
// Refund reverses a paid order's credit best-effort, exactly once — for an external refund or an
// admin-initiated one (E7). It revokes the funded chips floored at 0 (never negative, D27), records
// any unrecoverable remainder as a per-account loss and abuse flag, and appends a refund ledger row
// idempotent on (provider, providerRefundID) — distinct from the fund's payment id. A duplicate
// refund returns AlreadyRefunded. The caller records the refunded payment event and performs any
// provider-side money-back (the rails have no unsolicited refund push: Robokassa via its refund API
// / cabinet, VK via support, Telegram via refundStarPayment — all admin-triggered).
func (s *Service) Refund(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string, refunded Money) (RefundOutcome, error) {
return s.store.refund(ctx, orderID, provider, providerRefundID, refunded, s.clock())
}
// Pre-checkout decline reason codes. They are language-neutral: the transport layer localises them
// to the order account's preferred language before showing the payer (the reason is displayed in the
// Telegram payment sheet).
const (
// PreCheckoutGone means no order matches — an unknown or stale invoice payload.
PreCheckoutGone = "order_gone"
// PreCheckoutAlreadyPaid means the order is already paid (a reusable invoice link paid twice).
PreCheckoutAlreadyPaid = "already_paid"
// PreCheckoutPriceChanged means the amount or currency no longer matches the order.
PreCheckoutPriceChanged = "price_changed"
)
// PreCheckoutOutcome is the pre-charge validation of a Telegram Stars order. OK approves the charge;
// otherwise Reason is a decline reason code the transport localises. AccountID is the order's account
// (for localising the reason to its preferred language); it is the zero UUID when the order is unknown.
type PreCheckoutOutcome struct {
OK bool
Reason string
AccountID uuid.UUID
}
// ValidatePreCheckout answers whether a Stars pre_checkout_query for orderID paying amount may be
// approved, before any star is charged. It approves an order that exists, is not already paid (a
// reusable invoice link paid a second time is refused here) and whose expected amount and currency
// match the invoice. A pending or honoured-expired order is approved — a late credit is honoured
// (§9/D23). A missing order or a mismatch is a clean decline with a reason code, not an error.
func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, amount Money) (PreCheckoutOutcome, error) {
ord, err := s.store.orderByID(ctx, orderID)
if errors.Is(err, ErrOrderNotFound) {
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutGone}, nil
}
if err != nil {
return PreCheckoutOutcome{}, err
}
if ord.status == "paid" {
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutAlreadyPaid, AccountID: ord.accountID}, nil
}
if amount.Currency() != Currency(ord.currency) || amount.Minor() != ord.expectedAmount {
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutPriceChanged, AccountID: ord.accountID}, nil
}
return PreCheckoutOutcome{OK: true, AccountID: ord.accountID}, nil
}
// providerVKAds tags a rewarded-video credit from the VK ads network in the ledger (distinct from
// the "vk" Votes-purchase provider), so the daily cap counts only ad credits and the report separates
// them.
const providerVKAds = "vk_ads"
// InterstitialCooldowns reports the post-move interstitial-ad cooldowns (seconds): global, vs_ai and
// the independent hint-triggered one. The client mirrors them and self-gates (client-mirrored, D30).
func (s *Service) InterstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) {
return s.store.interstitialCooldowns(ctx)
}
// RewardPayout reports the chips a rewarded-video view earns in the caller's context — the config
// payout in a trusted VK context with the VK segment attached, and 0 everywhere else (rewarded is
// VK-only, D28). The client uses it to gate the "watch for chips" button.
func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Source) (int, error) {
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
return 0, nil
}
payout, _, _, err := s.store.rewardConfig(ctx)
return payout, err
}
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
// client nonce and order-less. It credits nothing when rewarded is unconfigured (0 payout) or the
// daily cap is reached. Rewarded video is VK-only (D28) and is an ad view — not a purchase — so the
// VK-iOS purchase freeze does not apply; it requires a trusted VK context with the VK segment
// attached.
func (s *Service) CreditReward(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, nonce string) (RewardOutcome, error) {
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
return RewardOutcome{}, ErrUntrusted
}
return s.store.creditReward(ctx, accountID, SourceVK, providerVKAds, nonce, s.clock())
}
// ExpireOrders marks pending orders older than the configured lifetime as expired, returning how
// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback
// still credits — see Fund).
func (s *Service) ExpireOrders(ctx context.Context) (int, error) {
ttl, err := s.store.orderTTL(ctx)
if err != nil {
return 0, err
}
return s.store.expirePending(ctx, ttl, s.clock())
}
// RecordPaymentEvent appends a payment lifecycle event (succeeded/failed/refunded) for the
// dispatcher to deliver to the user (live stream, botlink or email).
func (s *Service) RecordPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte) error {
return s.store.insertPaymentEvent(ctx, accountID, orderID, eventType, payload, s.clock())
}
// UndispatchedEvents returns up to limit payment events awaiting delivery. The dispatcher drains
// them and marks each delivered via MarkEventDispatched.
func (s *Service) UndispatchedEvents(ctx context.Context, limit int) ([]PaymentEvent, error) {
return s.store.undispatchedEvents(ctx, limit)
}
// MarkEventDispatched stamps a payment event as delivered so it is not re-sent.
func (s *Service) MarkEventDispatched(ctx context.Context, eventID uuid.UUID) error {
return s.store.markEventDispatched(ctx, eventID, s.clock())
}
@@ -0,0 +1,42 @@
package payments
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
)
// gateOnlyService builds a Service with a fixed clock and no store, usable only for the CreateOrder
// gate rejections that return before any store access.
func gateOnlyService() *Service {
return &Service{clock: func() time.Time { return time.Unix(0, 0).UTC() }}
}
// TestCreateOrderGateRejections checks that CreateOrder fails closed — before touching the store —
// on an untrusted platform, the VK-iOS spend freeze, and a method whose funding segment the account
// does not hold.
func TestCreateOrderGateRejections(t *testing.T) {
ctx := context.Background()
acc, prod := uuid.New(), uuid.New()
present := []Source{SourceDirect, SourceVK}
cases := []struct {
name string
cxt Context
}{
{"untrusted context", Context{}},
{"vk-ios purchase freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}},
{"method segment not attached", Context{Kind: SourceTelegram}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := gateOnlyService().CreateOrder(ctx, acc, tc.cxt, present, prod, "robokassa")
if !errors.Is(err, ErrUntrusted) {
t.Fatalf("CreateOrder(%s) = %v, want ErrUntrusted", tc.name, err)
}
})
}
}
+63
View File
@@ -0,0 +1,63 @@
package payments
import (
"context"
"time"
"github.com/google/uuid"
)
// Statement is an account's full financial picture for the admin console: chip balances per funding
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
// (newest first). Read straight from the materialized tables + the ledger, uncached — an admin-only,
// rare view, not a hot path.
type Statement struct {
Segments []SegmentChips
Benefits []OriginBenefit
Risk RiskInfo
Ledger []LedgerEntry
}
// SegmentChips is one funding segment's chip balance.
type SegmentChips struct {
Source Source
Chips int
}
// OriginBenefit is one origin's benefit state: the hint wallet, the ad-free term (zero AdsPaidUntil
// when none) and the lifetime ad-free flag.
type OriginBenefit struct {
Origin Source
Hints int
AdsPaidUntil time.Time
AdsForever bool
}
// RiskInfo is the account's recorded refund risk: whether it is abuse-flagged and the unrecoverable
// chip loss accumulated by floor-0 refunds. Present is false when the account has no risk row.
type RiskInfo struct {
Present bool
Abuse bool
LossChips int
}
// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when
// the column is NULL; Snapshot is the raw purchase/refund JSON (empty when none).
type LedgerEntry struct {
Kind string
Source string
Origin string
ChipsDelta int
ProductID string
OrderID string
Provider string
ProviderPaymentID string
Snapshot string
CreatedAt time.Time
}
// AccountStatement assembles the account's financial picture (segments, benefits, risk, full ledger
// history) for the admin console, straight from the materialized tables and the ledger (uncached).
func (s *Service) AccountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
return s.store.accountStatement(ctx, accountID)
}
@@ -0,0 +1,218 @@
package payments
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/payments/model"
"scrabble/backend/internal/postgres/jet/payments/table"
)
// ErrProductTransacted is returned when a hard delete is attempted on a product an order or ledger
// row references — it must be archived instead, to keep the append-only ledger resolvable.
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
var prods []model.Product
if err := postgres.SELECT(table.Product.AllColumns).
FROM(table.Product).
ORDER_BY(table.Product.CreatedAt.ASC()).
QueryContext(ctx, s.db, &prods); err != nil {
return nil, fmt.Errorf("payments: admin list products: %w", err)
}
out := make([]AdminProduct, 0, len(prods))
for _, p := range prods {
atoms, prices, err := s.productComposition(ctx, p.ProductID)
if err != nil {
return nil, err
}
transacted, err := s.productTransacted(ctx, p.ProductID)
if err != nil {
return nil, err
}
out = append(out, AdminProduct{
ID: p.ProductID, Title: p.Title, Active: p.Active,
Atoms: atoms, Prices: prices, Transacted: transacted,
})
}
return out, nil
}
// productComposition reads one product's atom lines and price rows.
func (s *Store) productComposition(ctx context.Context, id uuid.UUID) ([]AtomLine, []PriceLine, error) {
var items []model.ProductItem
if err := postgres.SELECT(table.ProductItem.AllColumns).
FROM(table.ProductItem).
WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(id))).
ORDER_BY(table.ProductItem.AtomType.ASC()).
QueryContext(ctx, s.db, &items); err != nil {
return nil, nil, fmt.Errorf("payments: load items %s: %w", id, err)
}
atoms := make([]AtomLine, len(items))
for i, it := range items {
atoms[i] = AtomLine{Atom: it.AtomType, Quantity: int(it.Quantity)}
}
var prs []model.ProductPrice
if err := postgres.SELECT(table.ProductPrice.AllColumns).
FROM(table.ProductPrice).
WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(id))).
QueryContext(ctx, s.db, &prs); err != nil {
return nil, nil, fmt.Errorf("payments: load prices %s: %w", id, err)
}
prices := make([]PriceLine, len(prs))
for i, pr := range prs {
method := ""
if pr.Method != nil {
method = *pr.Method
}
prices[i] = PriceLine{Method: method, Currency: Currency(pr.Currency), Amount: pr.Amount}
}
return atoms, prices, nil
}
// productTransacted reports whether any order or ledger row references the product.
func (s *Store) productTransacted(ctx context.Context, id uuid.UUID) (bool, error) {
var yes bool
if err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM payments.orders WHERE product_id=$1)
OR EXISTS(SELECT 1 FROM payments.ledger WHERE product_id=$1)`, id).Scan(&yes); err != nil {
return false, fmt.Errorf("payments: product transacted %s: %w", id, err)
}
return yes, nil
}
// productActive reads a product's archived flag, ErrProductNotFound when it is missing.
func (s *Store) productActive(ctx context.Context, id uuid.UUID) (bool, error) {
var p model.Product
err := postgres.SELECT(table.Product.Active).FROM(table.Product).
WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1).
QueryContext(ctx, s.db, &p)
if errors.Is(err, qrm.ErrNoRows) {
return false, ErrProductNotFound
}
if err != nil {
return false, fmt.Errorf("payments: product active %s: %w", id, err)
}
return p.Active, nil
}
// productInput reads a product's current editable content (title, atoms, prices).
func (s *Store) productInput(ctx context.Context, id uuid.UUID) (ProductInput, error) {
var p model.Product
err := postgres.SELECT(table.Product.AllColumns).FROM(table.Product).
WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1).
QueryContext(ctx, s.db, &p)
if errors.Is(err, qrm.ErrNoRows) {
return ProductInput{}, ErrProductNotFound
}
if err != nil {
return ProductInput{}, fmt.Errorf("payments: product input %s: %w", id, err)
}
atoms, prices, err := s.productComposition(ctx, id)
if err != nil {
return ProductInput{}, err
}
return ProductInput{Title: p.Title, Atoms: atoms, Prices: prices}, nil
}
// createProduct inserts a product with its atoms and prices in one transaction, returning its id.
func (s *Store) createProduct(ctx context.Context, in ProductInput, active bool, now time.Time) (uuid.UUID, error) {
id := uuid.New()
if err := withTx(ctx, s.db, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx,
`INSERT INTO payments.product (product_id, title, active, created_at, updated_at) VALUES ($1,$2,$3,$4,$4)`,
id, in.Title, active, now); err != nil {
return fmt.Errorf("insert product: %w", err)
}
return insertComposition(ctx, tx, id, in)
}); err != nil {
return uuid.Nil, fmt.Errorf("payments: create product: %w", err)
}
return id, nil
}
// updateProduct replaces a product's title, atoms and prices in one transaction (active unchanged).
func (s *Store) updateProduct(ctx context.Context, id uuid.UUID, in ProductInput, now time.Time) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE payments.product SET title=$2, updated_at=$3 WHERE product_id=$1`, id, in.Title, now)
if err != nil {
return fmt.Errorf("payments: update product %s: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrProductNotFound
}
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_item WHERE product_id=$1`, id); err != nil {
return fmt.Errorf("payments: clear items %s: %w", id, err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_price WHERE product_id=$1`, id); err != nil {
return fmt.Errorf("payments: clear prices %s: %w", id, err)
}
return insertComposition(ctx, tx, id, in)
})
}
// insertComposition inserts a product's atom items and price rows inside tx (a value's CHIP price
// carries a NULL method).
func insertComposition(ctx context.Context, tx *sql.Tx, id uuid.UUID, in ProductInput) error {
for _, a := range in.Atoms {
if _, err := tx.ExecContext(ctx,
`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,$2,$3)`,
id, a.Atom, a.Quantity); err != nil {
return fmt.Errorf("insert item %s: %w", a.Atom, err)
}
}
for _, p := range in.Prices {
var method any
if p.Method != "" {
method = p.Method
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1,$2,$3,$4)`,
id, method, string(p.Currency), p.Amount); err != nil {
return fmt.Errorf("insert price %s: %w", p.Currency, err)
}
}
return nil
}
// setProductActive flips the archived flag.
func (s *Store) setProductActive(ctx context.Context, id uuid.UUID, active bool, now time.Time) error {
res, err := s.db.ExecContext(ctx,
`UPDATE payments.product SET active=$2, updated_at=$3 WHERE product_id=$1`, id, active, now)
if err != nil {
return fmt.Errorf("payments: set product active %s: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrProductNotFound
}
return nil
}
// deleteProduct hard-deletes a never-transacted product (its items/prices cascade); a transacted
// product is refused with ErrProductTransacted.
func (s *Store) deleteProduct(ctx context.Context, id uuid.UUID) error {
transacted, err := s.productTransacted(ctx, id)
if err != nil {
return err
}
if transacted {
return ErrProductTransacted
}
res, err := s.db.ExecContext(ctx, `DELETE FROM payments.product WHERE product_id=$1`, id)
if err != nil {
return fmt.Errorf("payments: delete product %s: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrProductNotFound
}
return nil
}
+623
View File
@@ -0,0 +1,623 @@
package payments
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"scrabble/backend/internal/postgres/jet/payments/model"
"scrabble/backend/internal/postgres/jet/payments/table"
)
// Intake errors surfaced by the order-flow and external-credit (fund) path.
var (
// ErrNotAPack means the product is not a fundable chip pack in the requested method: it
// carries no chips atom, or no price for that payment method.
ErrNotAPack = errors.New("payments: product is not a chip pack for this method")
// ErrOrderNotFound means no order matches the id (an unknown or forged callback reference).
ErrOrderNotFound = errors.New("payments: order not found")
// ErrAmountMismatch means the callback's paid amount or currency does not match the order's
// expected amount — the credit is refused (§9: verify amount after matching by order id).
ErrAmountMismatch = errors.New("payments: paid amount does not match the order")
// ErrOrderNotPaid means a refund targets an order that was never funded — there is nothing to
// reverse (guards against a spurious loss/abuse record on an unpaid order).
ErrOrderNotPaid = errors.New("payments: order is not paid")
)
// errAlreadyCredited is the internal sentinel that unwinds the fund transaction when the ledger's
// (provider, provider_payment_id) unique index rejects a duplicate callback. It is not surfaced:
// a replayed callback is a success that credits nothing.
var errAlreadyCredited = errors.New("payments: already credited")
// errAlreadyRefunded unwinds the refund transaction when the ledger idempotency index rejects a
// duplicate refund (same provider refund id). It is not surfaced: a replayed refund reverses nothing.
var errAlreadyRefunded = errors.New("payments: already refunded")
// packInfo is a chip pack resolved for an order: the product, the chips it funds and its price in
// the requested payment method's currency.
type packInfo struct {
productID uuid.UUID
title string
chips int
price Money
}
// packChips returns the quantity of the chips atom a product carries, or 0 if it has none (which
// marks it as a value, not a fundable pack).
func (s *Store) packChips(ctx context.Context, productID uuid.UUID) (int, error) {
var item model.ProductItem
err := postgres.SELECT(table.ProductItem.AllColumns).
FROM(table.ProductItem).
WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(productID)).
AND(table.ProductItem.AtomType.EQ(postgres.String(atomChips)))).
LIMIT(1).
QueryContext(ctx, s.db, &item)
if errors.Is(err, qrm.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("payments: load pack chips %s: %w", productID, err)
}
return int(item.Quantity), nil
}
// loadPackForOrder resolves an active chip pack for a new order in the given payment method: an
// active product carrying a chips atom and a price row for the method (its currency and amount are
// the order's expected amount). It rejects a missing/deactivated product (ErrProductNotFound) and
// a product that is not a pack for the method (ErrNotAPack).
func (s *Store) loadPackForOrder(ctx context.Context, productID uuid.UUID, method Source) (packInfo, error) {
var p model.Product
err := postgres.SELECT(table.Product.AllColumns).
FROM(table.Product).
WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))).
LIMIT(1).
QueryContext(ctx, s.db, &p)
if errors.Is(err, qrm.ErrNoRows) || (err == nil && !p.Active) {
return packInfo{}, ErrProductNotFound
}
if err != nil {
return packInfo{}, fmt.Errorf("payments: load product %s: %w", productID, err)
}
chips, err := s.packChips(ctx, productID)
if err != nil {
return packInfo{}, err
}
if chips <= 0 {
return packInfo{}, ErrNotAPack
}
var price model.ProductPrice
err = postgres.SELECT(table.ProductPrice.AllColumns).
FROM(table.ProductPrice).
WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(productID)).
AND(table.ProductPrice.Method.EQ(postgres.String(string(method))))).
LIMIT(1).
QueryContext(ctx, s.db, &price)
if errors.Is(err, qrm.ErrNoRows) {
return packInfo{}, ErrNotAPack
}
if err != nil {
return packInfo{}, fmt.Errorf("payments: load pack price %s: %w", productID, err)
}
money, err := MoneyFromMinor(price.Amount, Currency(price.Currency))
if err != nil {
return packInfo{}, err
}
return packInfo{productID: productID, title: p.Title, chips: chips, price: money}, nil
}
// packForCredit resolves the chips and title of an ordered pack at credit time, ignoring the
// product's active flag: the money is real, so an order is honoured even if the pack was
// deactivated after it was placed (§9/D23).
func (s *Store) packForCredit(ctx context.Context, productID uuid.UUID) (chips int, title string, err error) {
var p model.Product
e := postgres.SELECT(table.Product.Title).
FROM(table.Product).
WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))).
LIMIT(1).
QueryContext(ctx, s.db, &p)
if errors.Is(e, qrm.ErrNoRows) {
return 0, "", ErrProductNotFound
}
if e != nil {
return 0, "", fmt.Errorf("payments: load product %s: %w", productID, e)
}
chips, err = s.packChips(ctx, productID)
if err != nil {
return 0, "", err
}
if chips <= 0 {
return 0, "", ErrNotAPack
}
return chips, p.Title, nil
}
// newOrder is the intent a CreateOrder writes: a pending order for a pack, priced in the method's
// currency, tagged with the provider that will settle it.
type newOrder struct {
orderID uuid.UUID
accountID uuid.UUID
platform string
productID uuid.UUID
amount Money
origin Source
provider string
}
// createOrder inserts a pending order.
func (s *Store) createOrder(ctx context.Context, o newOrder, now time.Time) error {
stmt := table.Orders.INSERT(
table.Orders.OrderID, table.Orders.AccountID, table.Orders.Platform,
table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency,
table.Orders.Origin, table.Orders.Status, table.Orders.Provider,
table.Orders.CreatedAt, table.Orders.UpdatedAt,
).VALUES(
o.orderID, o.accountID, o.platform,
o.productID, o.amount.Minor(), string(o.amount.Currency()),
string(o.origin), "pending", o.provider,
now, now,
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("payments: create order: %w", err)
}
return nil
}
// orderRow is a stored order read back for the intake path.
type orderRow struct {
orderID uuid.UUID
accountID uuid.UUID
productID uuid.UUID
expectedAmount int64
currency string
origin string
status string
}
// orderByID reads an order, or ErrOrderNotFound.
func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, error) {
var o model.Orders
err := postgres.SELECT(table.Orders.AllColumns).
FROM(table.Orders).
WHERE(table.Orders.OrderID.EQ(postgres.UUID(orderID))).
LIMIT(1).
QueryContext(ctx, s.db, &o)
if errors.Is(err, qrm.ErrNoRows) {
return orderRow{}, ErrOrderNotFound
}
if err != nil {
return orderRow{}, fmt.Errorf("payments: load order %s: %w", orderID, err)
}
return orderRow{
orderID: o.OrderID,
accountID: o.AccountID,
productID: o.ProductID,
expectedAmount: o.ExpectedAmount,
currency: o.Currency,
origin: o.Origin,
status: o.Status,
}, nil
}
// FundOutcome reports the result of an intake credit: whose balance, which segment and how many
// chips were credited, and whether the callback was a duplicate that credited nothing.
type FundOutcome struct {
AccountID uuid.UUID
Source Source
Chips int
AlreadyCredited bool
}
// fund credits a paid order exactly once: it matches the order, verifies the amount, then in one
// transaction appends a fund ledger row (idempotent on the (provider, provider_payment_id) unique
// index), credits the funded segment's balance and marks the order paid. A duplicate callback is
// rejected by the unique index and returns AlreadyCredited with no error and no second credit. A
// valid callback is honoured even on an expired order (§9/D23). The read cache is invalidated after
// the commit, since the credit runs outside any request the payments package owns.
func (s *Store) fund(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, paid Money, now time.Time) (FundOutcome, error) {
ord, err := s.orderByID(ctx, orderID)
if err != nil {
return FundOutcome{}, err
}
if paid.Currency() != Currency(ord.currency) || paid.Minor() != ord.expectedAmount {
return FundOutcome{}, ErrAmountMismatch
}
chips, title, err := s.packForCredit(ctx, ord.productID)
if err != nil {
return FundOutcome{}, err
}
snapshot, err := marshalFundSnapshot(ord.productID, title, chips, paid)
if err != nil {
return FundOutcome{}, err
}
src := Source(ord.origin)
outcome := FundOutcome{AccountID: ord.accountID, Source: src, Chips: chips}
pv, pp := provider, providerPaymentID
productID := ord.productID
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if e := insertLedgerTx(ctx, tx, ord.accountID, "fund", &src, &src, chips, &productID, &orderID, &pv, &pp, snapshot, now); e != nil {
if isUniqueViolation(e) {
outcome.AlreadyCredited = true
return errAlreadyCredited
}
return e
}
if _, e := tx.ExecContext(ctx,
`INSERT INTO payments.balances (account_id, source, chips, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (account_id, source) DO UPDATE
SET chips = payments.balances.chips + EXCLUDED.chips, updated_at = now()`,
ord.accountID, string(src), chips); e != nil {
return fmt.Errorf("payments: credit balance %s: %w", src, e)
}
if _, e := tx.ExecContext(ctx,
`UPDATE payments.orders SET status = 'paid', provider = $2, provider_payment_id = $3, updated_at = now()
WHERE order_id = $1`,
orderID, provider, providerPaymentID); e != nil {
return fmt.Errorf("payments: mark order paid: %w", e)
}
return nil
})
if err != nil {
if errors.Is(err, errAlreadyCredited) {
return outcome, nil
}
return FundOutcome{}, err
}
s.cache.invalidate(ord.accountID)
return outcome, nil
}
// RefundOutcome reports a refund's result: whose funded segment was reversed, the chips actually
// clawed back (floored at 0), the unrecoverable remainder (a loss, when the chips were already
// spent), and whether the refund was a duplicate that reversed nothing.
type RefundOutcome struct {
AccountID uuid.UUID
Source Source
Revoked int
Loss int
AlreadyRefunded bool
}
// refund reverses a paid order's credit best-effort, exactly once. It matches the order (which must
// be paid), verifies the refunded amount, then in one transaction appends a refund ledger row
// (idempotent on the (provider, provider_payment_id) index — the refund id is distinct from the
// fund's payment id, so the two rows coexist), revokes the funded chips floored at 0 (never
// negative, D27/balances_chips_chk) and, when chips were already spent, records the unrecoverable
// remainder as a per-account loss and flips the abuse flag. A duplicate refund returns
// AlreadyRefunded with no second reversal. The ledger row's chipsDelta is what is actually
// reclaimed; the full reversal (money, original chips, loss) rides in its snapshot for the report.
func (s *Store) refund(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string, refunded Money, now time.Time) (RefundOutcome, error) {
ord, err := s.orderByID(ctx, orderID)
if err != nil {
return RefundOutcome{}, err
}
if ord.status != "paid" {
return RefundOutcome{}, ErrOrderNotPaid
}
if refunded.Currency() != Currency(ord.currency) || refunded.Minor() != ord.expectedAmount {
return RefundOutcome{}, ErrAmountMismatch
}
chips, title, err := s.packForCredit(ctx, ord.productID)
if err != nil {
return RefundOutcome{}, err
}
src := Source(ord.origin)
outcome := RefundOutcome{AccountID: ord.accountID, Source: src}
pv, pr := provider, providerRefundID
productID := ord.productID
oid := orderID
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
// Lock the funded segment and read what is left; a spent balance floors the reversal at 0.
var avail int
e := tx.QueryRowContext(ctx,
`SELECT chips FROM payments.balances WHERE account_id = $1 AND source = $2 FOR UPDATE`,
ord.accountID, string(src)).Scan(&avail)
switch {
case errors.Is(e, sql.ErrNoRows):
avail = 0
case e != nil:
return fmt.Errorf("payments: read balance for refund: %w", e)
}
revoked := min(chips, avail)
loss := chips - revoked
outcome.Revoked, outcome.Loss = revoked, loss
snapshot, e := marshalRefundSnapshot(ord.productID, title, chips, revoked, loss, refunded, providerRefundID)
if e != nil {
return e
}
if e := insertLedgerTx(ctx, tx, ord.accountID, "refund", &src, &src, -revoked, &productID, &oid, &pv, &pr, snapshot, now); e != nil {
if isUniqueViolation(e) {
outcome.AlreadyRefunded = true
return errAlreadyRefunded
}
return e
}
if revoked > 0 {
if _, e := tx.ExecContext(ctx,
`UPDATE payments.balances SET chips = chips - $3, updated_at = now()
WHERE account_id = $1 AND source = $2`,
ord.accountID, string(src), revoked); e != nil {
return fmt.Errorf("payments: revoke chips %s: %w", src, e)
}
}
if loss > 0 {
if _, e := tx.ExecContext(ctx,
`INSERT INTO payments.account_risk (account_id, abuse, loss_chips, updated_at)
VALUES ($1, true, $2, now())
ON CONFLICT (account_id) DO UPDATE
SET abuse = true, loss_chips = payments.account_risk.loss_chips + EXCLUDED.loss_chips, updated_at = now()`,
ord.accountID, int64(loss)); e != nil {
return fmt.Errorf("payments: record refund loss: %w", e)
}
}
return nil
})
if err != nil {
if errors.Is(err, errAlreadyRefunded) {
return outcome, nil
}
return RefundOutcome{}, err
}
s.cache.invalidate(ord.accountID)
return outcome, nil
}
// RewardOutcome reports a rewarded-video credit: the chips credited (0 when rewarded is unconfigured
// or the daily cap is reached), whether the daily cap blocked it, and whether it was a duplicate view
// (same client nonce) that credited nothing more.
type RewardOutcome struct {
AccountID uuid.UUID
Chips int
Capped bool
AlreadyCredited bool
}
// interstitialCooldowns reads the post-move interstitial-ad cooldowns (seconds): the global
// per-user cooldown, the longer vs_ai one, and the independent hint-triggered one. The client mirrors
// them and self-gates (E6/D30).
func (s *Store) interstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) {
var cfg model.Config
if e := postgres.SELECT(table.Config.CooldownGlobalSeconds, table.Config.CooldownVsAiSeconds, table.Config.CooldownHintSeconds).
FROM(table.Config).
LIMIT(1).
QueryContext(ctx, s.db, &cfg); e != nil {
return 0, 0, 0, fmt.Errorf("payments: read interstitial cooldowns: %w", e)
}
return int(cfg.CooldownGlobalSeconds), int(cfg.CooldownVsAiSeconds), int(cfg.CooldownHintSeconds), nil
}
// rewardConfig reads the rewarded payout (chips per view) and the per-day and per-hour caps. The
// caps are both anti-abuse (bounding a forger's free chips) and an economic conversion lever (free
// rewarded chips are limited so a player who wants more buys) — tuned in the admin.
func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
var cfg model.Config
if e := postgres.SELECT(table.Config.RewardedPayoutChips, table.Config.RewardDailyCap, table.Config.RewardHourlyCap).
FROM(table.Config).
LIMIT(1).
QueryContext(ctx, s.db, &cfg); e != nil {
return 0, 0, 0, fmt.Errorf("payments: read reward config: %w", e)
}
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
}
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
// (provider, provider_payment_id) index), so a retried view credits once, and order-less (a free
// credit, no order). The cap counts today's rewarded credits for this network (UTC day); a rare
// concurrent race may allow cap+1, which the per-user rate limiter bounds and the cap tolerates.
func (s *Store) creditReward(ctx context.Context, accountID uuid.UUID, source Source, provider, nonce string, now time.Time) (RewardOutcome, error) {
payout, dailyCap, hourlyCap, err := s.rewardConfig(ctx)
if err != nil {
return RewardOutcome{}, err
}
outcome := RewardOutcome{AccountID: accountID}
if payout <= 0 {
return outcome, nil // rewarded not configured (0 payout) — inert until the owner sets it
}
// Count this network's rewarded credits in the last day and last hour (one scan over the last
// 25 h covers both windows); either cap reached blocks the credit. A rare concurrent race may
// allow cap+1, which the per-user rate limiter bounds and the anti-abuse cap tolerates.
var today, lastHour int
if e := s.db.QueryRowContext(ctx,
`SELECT count(*) FILTER (WHERE created_at >= date_trunc('day', now())),
count(*) FILTER (WHERE created_at >= now() - interval '1 hour')
FROM payments.ledger
WHERE account_id = $1 AND kind = 'fund' AND provider = $2 AND created_at >= now() - interval '25 hours'`,
accountID, provider).Scan(&today, &lastHour); e != nil {
return RewardOutcome{}, fmt.Errorf("payments: count rewarded views: %w", e)
}
if today >= dailyCap || lastHour >= hourlyCap {
outcome.Capped = true
return outcome, nil
}
snapshot, err := marshalRewardSnapshot(payout)
if err != nil {
return RewardOutcome{}, err
}
src := source
pv, pp := provider, nonce
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if e := insertLedgerTx(ctx, tx, accountID, "fund", &src, &src, payout, nil, nil, &pv, &pp, snapshot, now); e != nil {
if isUniqueViolation(e) {
outcome.AlreadyCredited = true
return errAlreadyCredited
}
return e
}
if _, e := tx.ExecContext(ctx,
`INSERT INTO payments.balances (account_id, source, chips, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (account_id, source) DO UPDATE
SET chips = payments.balances.chips + EXCLUDED.chips, updated_at = now()`,
accountID, string(src), payout); e != nil {
return fmt.Errorf("payments: credit rewarded balance: %w", e)
}
return nil
})
if err != nil {
if errors.Is(err, errAlreadyCredited) {
return outcome, nil
}
return RewardOutcome{}, err
}
outcome.Chips = payout
s.cache.invalidate(accountID)
return outcome, nil
}
// insertPaymentEvent appends an undispatched lifecycle event (succeeded/failed/refunded) for the
// dispatcher to deliver. orderID and payload (a jsonb detail blob) are optional.
func (s *Store) insertPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte, now time.Time) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("payments: event id: %w", err)
}
var pl any = postgres.NULL
if payload != nil {
pl = string(payload)
}
stmt := table.PaymentEvents.INSERT(
table.PaymentEvents.EventID, table.PaymentEvents.AccountID, table.PaymentEvents.OrderID,
table.PaymentEvents.Type, table.PaymentEvents.Payload, table.PaymentEvents.CreatedAt,
).VALUES(id, accountID, uuidOrNull(orderID), eventType, pl, now)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("payments: insert %s event: %w", eventType, err)
}
return nil
}
// PaymentEvent is an undispatched lifecycle event the dispatcher delivers to an account.
type PaymentEvent struct {
EventID uuid.UUID
AccountID uuid.UUID
Type string
}
// undispatchedEvents reads up to limit payment events not yet delivered, oldest first.
func (s *Store) undispatchedEvents(ctx context.Context, limit int) ([]PaymentEvent, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT event_id, account_id, type FROM payments.payment_events
WHERE dispatched_at IS NULL ORDER BY created_at LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("payments: read undispatched events: %w", err)
}
defer rows.Close()
var out []PaymentEvent
for rows.Next() {
var e PaymentEvent
if err := rows.Scan(&e.EventID, &e.AccountID, &e.Type); err != nil {
return nil, fmt.Errorf("payments: scan event: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// markEventDispatched stamps an event as delivered so it is not re-sent.
func (s *Store) markEventDispatched(ctx context.Context, eventID uuid.UUID, now time.Time) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE payments.payment_events SET dispatched_at = $2 WHERE event_id = $1`, eventID, now); err != nil {
return fmt.Errorf("payments: mark event dispatched: %w", err)
}
return nil
}
// orderTTL reads the configured pending-order lifetime in whole seconds.
func (s *Store) orderTTL(ctx context.Context) (int, error) {
var cfg model.Config
if err := postgres.SELECT(table.Config.OrderTTLSeconds).
FROM(table.Config).
LIMIT(1).
QueryContext(ctx, s.db, &cfg); err != nil {
return 0, fmt.Errorf("payments: read order ttl: %w", err)
}
return int(cfg.OrderTTLSeconds), nil
}
// expirePending marks every pending order older than ttlSeconds as expired, returning how many.
// Expiry is cosmetic DB hygiene: a later valid callback still credits an expired order (§9/D23).
func (s *Store) expirePending(ctx context.Context, ttlSeconds int, now time.Time) (int, error) {
cutoff := now.Add(-time.Duration(ttlSeconds) * time.Second)
res, err := table.Orders.
UPDATE(table.Orders.Status, table.Orders.UpdatedAt).
SET(postgres.String("expired"), postgres.TimestampzT(now)).
WHERE(table.Orders.Status.EQ(postgres.String("pending")).
AND(table.Orders.CreatedAt.LT(postgres.TimestampzT(cutoff)))).
ExecContext(ctx, s.db)
if err != nil {
return 0, fmt.Errorf("payments: expire pending orders: %w", err)
}
n, _ := res.RowsAffected()
return int(n), nil
}
// marshalFundSnapshot records what a fund credited (the pack, chips and paid amount) on the ledger
// row, so history stays independent of later catalog edits (§7/D34).
func marshalFundSnapshot(productID uuid.UUID, title string, chips int, paid Money) ([]byte, error) {
b, err := json.Marshal(struct {
ProductID string `json:"product_id"`
Title string `json:"title,omitempty"`
Chips int `json:"chips"`
Amount int64 `json:"amount_minor"`
Currency string `json:"currency"`
}{productID.String(), title, chips, paid.Minor(), string(paid.Currency())})
if err != nil {
return nil, fmt.Errorf("payments: marshal fund snapshot: %w", err)
}
return b, nil
}
// marshalRefundSnapshot records the full reversal on the refund ledger row: the pack, the original
// funded chips, how many were actually reclaimed, the unrecoverable loss (already spent), the money
// refunded and the provider refund id — so the ledger stays reconcilable against the balance
// (chipsDelta = revoked) while the report still sees the whole reversal (§7/D27/D34).
func marshalRefundSnapshot(productID uuid.UUID, title string, chips, revoked, loss int, refunded Money, refundID string) ([]byte, error) {
b, err := json.Marshal(struct {
ProductID string `json:"product_id"`
Title string `json:"title,omitempty"`
Chips int `json:"chips"`
Revoked int `json:"revoked"`
Loss int `json:"loss"`
Amount int64 `json:"amount_minor"`
Currency string `json:"currency"`
RefundID string `json:"refund_id"`
}{productID.String(), title, chips, revoked, loss, refunded.Minor(), string(refunded.Currency()), refundID})
if err != nil {
return nil, fmt.Errorf("payments: marshal refund snapshot: %w", err)
}
return b, nil
}
// marshalRewardSnapshot records a rewarded-video credit on its ledger row: the marker distinguishing
// it from a paid fund, and the chips granted — so the report separates ad-earned chips from purchases.
func marshalRewardSnapshot(chips int) ([]byte, error) {
b, err := json.Marshal(struct {
Reward bool `json:"reward"`
Chips int `json:"chips"`
}{true, chips})
if err != nil {
return nil, fmt.Errorf("payments: marshal reward snapshot: %w", err)
}
return b, nil
}
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint violation (SQLSTATE
// 23505) — here, a duplicate provider callback hitting the ledger idempotency index.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
@@ -0,0 +1,132 @@
package payments
import (
"context"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/payments/model"
"scrabble/backend/internal/postgres/jet/payments/table"
)
// statementOrder is the fixed segment/origin ordering the report renders, so the panel is stable
// (the balance/benefit maps iterate randomly).
var statementOrder = []Source{SourceDirect, SourceVK, SourceTelegram}
// accountStatement reads the account's balances, benefits, refund risk and full ledger history for
// the admin report. Uncached and outside any transaction — an operator view, not a hot path.
func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
st, err := s.loadState(ctx, accountID)
if err != nil {
return Statement{}, err
}
var out Statement
for _, src := range statementOrder {
if chips, ok := st.chips[src]; ok {
out.Segments = append(out.Segments, SegmentChips{Source: src, Chips: chips})
}
if b, ok := st.benefits[src]; ok {
out.Benefits = append(out.Benefits, OriginBenefit{
Origin: src, Hints: b.hints, AdsPaidUntil: derefTime(b.adsPaidUntil), AdsForever: b.adsForever,
})
}
}
var risk model.AccountRisk
err = postgres.SELECT(table.AccountRisk.AllColumns).
FROM(table.AccountRisk).
WHERE(table.AccountRisk.AccountID.EQ(postgres.UUID(accountID))).
LIMIT(1).
QueryContext(ctx, s.db, &risk)
switch {
case err == nil:
out.Risk = RiskInfo{Present: true, Abuse: risk.Abuse, LossChips: int(risk.LossChips)}
case errors.Is(err, qrm.ErrNoRows):
// no risk row — a clean account
default:
return Statement{}, fmt.Errorf("payments: load risk %s: %w", accountID, err)
}
var rows []model.Ledger
if err := postgres.SELECT(table.Ledger.AllColumns).
FROM(table.Ledger).
WHERE(table.Ledger.AccountID.EQ(postgres.UUID(accountID))).
ORDER_BY(table.Ledger.CreatedAt.DESC()).
QueryContext(ctx, s.db, &rows); err != nil {
return Statement{}, fmt.Errorf("payments: load ledger %s: %w", accountID, err)
}
for _, r := range rows {
out.Ledger = append(out.Ledger, LedgerEntry{
Kind: r.Kind,
Source: derefStr(r.Source),
Origin: derefStr(r.Origin),
ChipsDelta: int(r.ChipsDelta),
ProductID: derefUUID(r.ProductID),
OrderID: derefUUID(r.OrderID),
Provider: derefStr(r.Provider),
ProviderPaymentID: derefStr(r.ProviderPaymentID),
Snapshot: derefStr(r.Snapshot),
CreatedAt: r.CreatedAt,
})
}
return out, nil
}
// allLedger reads the entire append-only ledger (all accounts, newest first) for the admin export.
func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) {
var rows []model.Ledger
if err := postgres.SELECT(table.Ledger.AllColumns).
FROM(table.Ledger).
ORDER_BY(table.Ledger.CreatedAt.DESC()).
QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("payments: export ledger: %w", err)
}
out := make([]LedgerExportRow, 0, len(rows))
for _, r := range rows {
out = append(out, LedgerExportRow{
AccountID: r.AccountID.String(),
LedgerEntry: LedgerEntry{
Kind: r.Kind,
Source: derefStr(r.Source),
Origin: derefStr(r.Origin),
ChipsDelta: int(r.ChipsDelta),
ProductID: derefUUID(r.ProductID),
OrderID: derefUUID(r.OrderID),
Provider: derefStr(r.Provider),
ProviderPaymentID: derefStr(r.ProviderPaymentID),
Snapshot: derefStr(r.Snapshot),
CreatedAt: r.CreatedAt,
},
})
}
return out, nil
}
// derefStr returns the pointed-to string, or "" when the pointer is nil (a NULL column).
func derefStr(p *string) string {
if p == nil {
return ""
}
return *p
}
// derefUUID renders the pointed-to UUID as a string, or "" when the pointer is nil.
func derefUUID(p *uuid.UUID) string {
if p == nil {
return ""
}
return p.String()
}
// derefTime returns the pointed-to time, or the zero time when the pointer is nil (a NULL column).
func derefTime(p *time.Time) time.Time {
if p == nil {
return time.Time{}
}
return *p
}
+43 -6
View File
@@ -26,6 +26,14 @@ var (
// ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it // ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it
// cannot be bought with chips. // cannot be bought with chips.
ErrNotAValue = errors.New("payments: product is not a chip-priced value") ErrNotAValue = errors.New("payments: product is not a chip-priced value")
// ErrCannotGrantChips means an admin grant targeted a product carrying the chips atom — the
// admin never grants currency (a gifted balance would bypass the cash desk, D16).
ErrCannotGrantChips = errors.New("payments: cannot grant chips")
// ErrCannotGrantTournament means an admin grant targeted a product carrying the tournament atom,
// which has no credit target until the tournament stage.
ErrCannotGrantTournament = errors.New("payments: cannot grant a tournament atom yet")
// ErrNothingToGrant means the product's atoms yield no grantable benefit (hints / no-ads days).
ErrNothingToGrant = errors.New("payments: product has nothing to grant")
) )
// withTx runs fn inside a transaction on db, rolling back on error or panic. // withTx runs fn inside a transaction on db, rolling back on error or panic.
@@ -230,8 +238,11 @@ func applyBenefitTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, origin
return nil return nil
} }
// insertLedgerTx appends one append-only ledger row inside tx. // insertLedgerTx appends one append-only ledger row inside tx. orderID, provider and
func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID *uuid.UUID, snapshot []byte, now time.Time) error { // providerPaymentID are set only on an intake credit (fund/refund) and are nil for a
// spend/admin_grant; a non-nil (provider, providerPaymentID) pair is guarded by the partial
// unique index, so a duplicate provider callback fails here (the idempotency key).
func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID, orderID *uuid.UUID, provider, providerPaymentID *string, snapshot []byte, now time.Time) error {
id, err := uuid.NewV7() id, err := uuid.NewV7()
if err != nil { if err != nil {
return fmt.Errorf("payments: ledger id: %w", err) return fmt.Errorf("payments: ledger id: %w", err)
@@ -246,11 +257,13 @@ func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind s
stmt := table.Ledger.INSERT( stmt := table.Ledger.INSERT(
table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind, table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind,
table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta, table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta,
table.Ledger.ProductID, table.Ledger.Snapshot, table.Ledger.CreatedAt, table.Ledger.ProductID, table.Ledger.OrderID, table.Ledger.Provider,
table.Ledger.ProviderPaymentID, table.Ledger.Snapshot, table.Ledger.CreatedAt,
).VALUES( ).VALUES(
id, accountID, kind, id, accountID, kind,
sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta), sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta),
uuidOrNull(productID), snap, now, uuidOrNull(productID), uuidOrNull(orderID), stringOrNull(provider),
stringOrNull(providerPaymentID), snap, now,
) )
if _, err := stmt.ExecContext(ctx, tx); err != nil { if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("payments: insert %s ledger: %w", kind, err) return fmt.Errorf("payments: insert %s ledger: %w", kind, err)
@@ -278,7 +291,7 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm
return ErrInsufficientChips return ErrInsufficientChips
} }
src := dr.source src := dr.source
if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, snapshot, now); err != nil { if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, nil, nil, nil, snapshot, now); err != nil {
return err return err
} }
} }
@@ -295,7 +308,23 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm
// the chosen origin, in one transaction — a zero-price sale of a value. // the chosen origin, in one transaction — a zero-price sale of a value.
func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d benefitDelta, snapshot []byte, now time.Time) error { func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d benefitDelta, snapshot []byte, now time.Time) error {
err := withTx(ctx, s.db, func(tx *sql.Tx) error { err := withTx(ctx, s.db, func(tx *sql.Tx) error {
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, snapshot, now); err != nil { if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, nil, nil, nil, snapshot, now); err != nil {
return err
}
return applyBenefitTx(ctx, tx, accountID, origin, d, now)
})
if err != nil {
return err
}
s.cache.invalidate(accountID)
return nil
}
// grantProduct is grant with the source product recorded on the ledger row (product_id), for an
// admin grant-by-product — the benefit is the product's atoms, the ledger stays auditable to it.
func (s *Store) grantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID, d benefitDelta, snapshot []byte, now time.Time) error {
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, &productID, nil, nil, nil, snapshot, now); err != nil {
return err return err
} }
return applyBenefitTx(ctx, tx, accountID, origin, d, now) return applyBenefitTx(ctx, tx, accountID, origin, d, now)
@@ -419,3 +448,11 @@ func uuidOrNull(id *uuid.UUID) postgres.Expression {
} }
return postgres.UUID(*id) return postgres.UUID(*id)
} }
// stringOrNull renders an optional string as a SQL string or NULL.
func stringOrNull(s *string) postgres.Expression {
if s == nil {
return postgres.NULL
}
return postgres.String(*s)
}
+4 -3
View File
@@ -102,10 +102,11 @@ func TestWalletSegments(t *testing.T) {
if len(got.Segments) != 1 || got.Segments[0].Source != SourceVK || !got.Segments[0].Spendable { if len(got.Segments) != 1 || got.Segments[0].Source != SourceVK || !got.Segments[0].Spendable {
t.Errorf("vk-android wallet = %+v", got.Segments) t.Errorf("vk-android wallet = %+v", got.Segments)
} }
// VK iOS: only vk shown, frozen (not spendable) but the balance is visible. // VK iOS: only vk shown, and spendable — the freeze is purchase-only, so VK-wallet chips still
// spend there (only buying more chips for money is blocked).
got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "ios"), present) got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "ios"), present)
if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || got.Segments[0].Spendable { if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || !got.Segments[0].Spendable {
t.Errorf("vk-ios wallet = %+v (want vk 50 frozen)", got.Segments) t.Errorf("vk-ios wallet = %+v (want vk 50 spendable)", got.Segments)
} }
} }
@@ -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
type Config struct {
OnlyRow bool `sql:"primary_key"`
GuestVsAiLimit int16
GuestRandomLimit int16
GuestFriendsLimit int16
DurableVsAiLimit int16
DurableRandomLimit int16
DurableFriendsLimit int16
}
@@ -33,4 +33,5 @@ type Games struct {
DropoutTiles string DropoutTiles string
MultipleWordsPerTurn bool MultipleWordsPerTurn bool
VsAi bool VsAi bool
GameKind int16
} }
@@ -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 Config = newConfigTable("backend", "config", "")
type configTable struct {
postgres.Table
// Columns
OnlyRow postgres.ColumnBool
GuestVsAiLimit postgres.ColumnInteger
GuestRandomLimit postgres.ColumnInteger
GuestFriendsLimit postgres.ColumnInteger
DurableVsAiLimit postgres.ColumnInteger
DurableRandomLimit postgres.ColumnInteger
DurableFriendsLimit 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")
GuestVsAiLimitColumn = postgres.IntegerColumn("guest_vs_ai_limit")
GuestRandomLimitColumn = postgres.IntegerColumn("guest_random_limit")
GuestFriendsLimitColumn = postgres.IntegerColumn("guest_friends_limit")
DurableVsAiLimitColumn = postgres.IntegerColumn("durable_vs_ai_limit")
DurableRandomLimitColumn = postgres.IntegerColumn("durable_random_limit")
DurableFriendsLimitColumn = postgres.IntegerColumn("durable_friends_limit")
allColumns = postgres.ColumnList{OnlyRowColumn, GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
mutableColumns = postgres.ColumnList{GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
defaultColumns = postgres.ColumnList{OnlyRowColumn, GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
)
return configTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
OnlyRow: OnlyRowColumn,
GuestVsAiLimit: GuestVsAiLimitColumn,
GuestRandomLimit: GuestRandomLimitColumn,
GuestFriendsLimit: GuestFriendsLimitColumn,
DurableVsAiLimit: DurableVsAiLimitColumn,
DurableRandomLimit: DurableRandomLimitColumn,
DurableFriendsLimit: DurableFriendsLimitColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -37,6 +37,7 @@ type gamesTable struct {
DropoutTiles postgres.ColumnString DropoutTiles postgres.ColumnString
MultipleWordsPerTurn postgres.ColumnBool MultipleWordsPerTurn postgres.ColumnBool
VsAi postgres.ColumnBool VsAi postgres.ColumnBool
GameKind postgres.ColumnInteger
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -98,9 +99,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
DropoutTilesColumn = postgres.StringColumn("dropout_tiles") DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn") MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
VsAiColumn = postgres.BoolColumn("vs_ai") VsAiColumn = postgres.BoolColumn("vs_ai")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn} GameKindColumn = postgres.IntegerColumn("game_kind")
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn} allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn} mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
) )
return gamesTable{ return gamesTable{
@@ -127,6 +129,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
DropoutTiles: DropoutTilesColumn, DropoutTiles: DropoutTilesColumn,
MultipleWordsPerTurn: MultipleWordsPerTurnColumn, MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
VsAi: VsAiColumn, VsAi: VsAiColumn,
GameKind: GameKindColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -21,6 +21,7 @@ func UseSchema(schema string) {
Blocks = Blocks.FromSchema(schema) Blocks = Blocks.FromSchema(schema)
ChatMessages = ChatMessages.FromSchema(schema) ChatMessages = ChatMessages.FromSchema(schema)
Complaints = Complaints.FromSchema(schema) Complaints = Complaints.FromSchema(schema)
Config = Config.FromSchema(schema)
DictionaryState = DictionaryState.FromSchema(schema) DictionaryState = DictionaryState.FromSchema(schema)
EmailConfirmations = EmailConfirmations.FromSchema(schema) EmailConfirmations = EmailConfirmations.FromSchema(schema)
FeedbackMessages = FeedbackMessages.FromSchema(schema) FeedbackMessages = FeedbackMessages.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 AccountRisk struct {
AccountID uuid.UUID `sql:"primary_key"`
Abuse bool
LossChips int64
UpdatedAt time.Time
}
@@ -14,4 +14,6 @@ type Config struct {
CooldownVsAiSeconds int32 CooldownVsAiSeconds int32
CooldownHintSeconds int32 CooldownHintSeconds int32
OrderTTLSeconds int32 OrderTTLSeconds int32
RewardDailyCap int32
RewardHourlyCap int32
} }
@@ -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 AccountRisk = newAccountRiskTable("payments", "account_risk", "")
type accountRiskTable struct {
postgres.Table
// Columns
AccountID postgres.ColumnString
Abuse postgres.ColumnBool
LossChips postgres.ColumnInteger
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type AccountRiskTable struct {
accountRiskTable
EXCLUDED accountRiskTable
}
// AS creates new AccountRiskTable with assigned alias
func (a AccountRiskTable) AS(alias string) *AccountRiskTable {
return newAccountRiskTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new AccountRiskTable with assigned schema name
func (a AccountRiskTable) FromSchema(schemaName string) *AccountRiskTable {
return newAccountRiskTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new AccountRiskTable with assigned table prefix
func (a AccountRiskTable) WithPrefix(prefix string) *AccountRiskTable {
return newAccountRiskTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new AccountRiskTable with assigned table suffix
func (a AccountRiskTable) WithSuffix(suffix string) *AccountRiskTable {
return newAccountRiskTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newAccountRiskTable(schemaName, tableName, alias string) *AccountRiskTable {
return &AccountRiskTable{
accountRiskTable: newAccountRiskTableImpl(schemaName, tableName, alias),
EXCLUDED: newAccountRiskTableImpl("", "excluded", ""),
}
}
func newAccountRiskTableImpl(schemaName, tableName, alias string) accountRiskTable {
var (
AccountIDColumn = postgres.StringColumn("account_id")
AbuseColumn = postgres.BoolColumn("abuse")
LossChipsColumn = postgres.IntegerColumn("loss_chips")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, AbuseColumn, LossChipsColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{AbuseColumn, LossChipsColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{AbuseColumn, LossChipsColumn, UpdatedAtColumn}
)
return accountRiskTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AccountID: AccountIDColumn,
Abuse: AbuseColumn,
LossChips: LossChipsColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -23,6 +23,8 @@ type configTable struct {
CooldownVsAiSeconds postgres.ColumnInteger CooldownVsAiSeconds postgres.ColumnInteger
CooldownHintSeconds postgres.ColumnInteger CooldownHintSeconds postgres.ColumnInteger
OrderTTLSeconds postgres.ColumnInteger OrderTTLSeconds postgres.ColumnInteger
RewardDailyCap postgres.ColumnInteger
RewardHourlyCap postgres.ColumnInteger
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -70,9 +72,11 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds") CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds") CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds") OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn} RewardDailyCapColumn = postgres.IntegerColumn("reward_daily_cap")
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn} RewardHourlyCapColumn = postgres.IntegerColumn("reward_hourly_cap")
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn} allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
) )
return configTable{ return configTable{
@@ -85,6 +89,8 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
CooldownVsAiSeconds: CooldownVsAiSecondsColumn, CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
CooldownHintSeconds: CooldownHintSecondsColumn, CooldownHintSeconds: CooldownHintSecondsColumn,
OrderTTLSeconds: OrderTTLSecondsColumn, OrderTTLSeconds: OrderTTLSecondsColumn,
RewardDailyCap: RewardDailyCapColumn,
RewardHourlyCap: RewardHourlyCapColumn,
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) {
AccountRisk = AccountRisk.FromSchema(schema)
Balances = Balances.FromSchema(schema) Balances = Balances.FromSchema(schema)
Benefits = Benefits.FromSchema(schema) Benefits = Benefits.FromSchema(schema)
CatalogAtom = CatalogAtom.FromSchema(schema) CatalogAtom = CatalogAtom.FromSchema(schema)
@@ -0,0 +1,25 @@
-- Per-account payment risk: the loss and abuse signal an external/admin refund leaves
-- behind when the refunded chips were already spent. A refund revokes chips best-effort
-- and never drives a balance negative (D27, balances_chips_chk); the unrecoverable
-- remainder is a recorded loss and flips an abuse flag the /_gm financial report reads
-- (D40, E7). Mutable per-account state (upserted on each such refund), so — unlike the
-- ledger — it carries no append-only trigger. Additive: a new table only, so goose
-- applies it forward with no rewrite of existing data (the contour is not wiped). The
-- payments role inherits ALL on it via the schema default privileges set in 00010.
-- +goose Up
CREATE TABLE payments.account_risk (
account_id uuid NOT NULL,
-- abuse flips true the first time a refund cannot fully reclaim its chips (spent).
abuse boolean DEFAULT false NOT NULL,
-- loss_chips accumulates the unrecoverable chips across such refunds (bigint: an
-- accumulator, mapped to int64 by go-jet — not the numeric->float64 trap).
loss_chips bigint DEFAULT 0 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT account_risk_pkey PRIMARY KEY (account_id),
CONSTRAINT account_risk_loss_chips_chk CHECK ((loss_chips >= 0))
);
-- +goose Down
DROP TABLE IF EXISTS payments.account_risk;
@@ -0,0 +1,22 @@
-- Rewarded-video caps: the anti-abuse ceilings on free rewarded credits per user, per
-- day and per hour. VK Mini App ads expose only a client-side watch result (no
-- server-to-server verify), so a rewarded credit is client-attested; the caps bound a
-- forger who skips the ad and calls the credit endpoint directly (the daily cap bounds the
-- total; the hourly cap smooths a burst). Chips-per-view already exists
-- (rewarded_payout_chips, default 0 = rewarded inert until the owner sets it). All are
-- config, tuned in the admin without a release. Additive columns only — applies forward via
-- goose with no data rewrite (no contour wipe), and an image rollback ignores them.
-- +goose Up
ALTER TABLE payments.config
ADD COLUMN reward_daily_cap integer DEFAULT 50 NOT NULL,
ADD COLUMN reward_hourly_cap integer DEFAULT 10 NOT NULL;
ALTER TABLE payments.config
ADD CONSTRAINT config_reward_daily_cap_chk CHECK (reward_daily_cap >= 0),
ADD CONSTRAINT config_reward_hourly_cap_chk CHECK (reward_hourly_cap >= 0);
-- +goose Down
ALTER TABLE payments.config
DROP COLUMN reward_daily_cap,
DROP COLUMN reward_hourly_cap;
@@ -0,0 +1,36 @@
-- Guest-limit foundation (E8): tag each game with its kind so the per-tier, per-kind active-game
-- limits are enforceable, and add the single-row config that holds those limits (tuned in the admin,
-- no release). It replaces the old hardcoded MaxActiveQuickGames=10 combined cap with a per-tier,
-- per-kind config. game_kind: 0=unknown (pre-E8 games, never gated), 1=vs_ai, 2=random, 3=friends —
-- set on creation. The limits are smallint with -1 = unlimited; a guest defaults to 1 vs_ai + 1
-- random (friends 0, moot — the guest gate blocks friend games), a durable account to 10 per kind
-- (the old cap, now per kind). Additive only — applies forward via goose with no data rewrite (no
-- contour wipe); an image rollback ignores the column + table.
-- +goose Up
ALTER TABLE backend.games
ADD COLUMN game_kind smallint DEFAULT 0 NOT NULL;
ALTER TABLE backend.games
ADD CONSTRAINT games_game_kind_chk CHECK (game_kind >= 0 AND game_kind <= 3);
CREATE TABLE backend.config (
only_row boolean DEFAULT true NOT NULL,
guest_vs_ai_limit smallint DEFAULT 1 NOT NULL,
guest_random_limit smallint DEFAULT 1 NOT NULL,
guest_friends_limit smallint DEFAULT 0 NOT NULL,
durable_vs_ai_limit smallint DEFAULT 10 NOT NULL,
durable_random_limit smallint DEFAULT 10 NOT NULL,
durable_friends_limit smallint DEFAULT 10 NOT NULL,
CONSTRAINT config_pkey PRIMARY KEY (only_row),
CONSTRAINT config_single_row_chk CHECK (only_row),
CONSTRAINT config_limits_chk CHECK (
guest_vs_ai_limit >= -1 AND guest_random_limit >= -1 AND guest_friends_limit >= -1 AND
durable_vs_ai_limit >= -1 AND durable_random_limit >= -1 AND durable_friends_limit >= -1)
);
INSERT INTO backend.config (only_row) VALUES (true);
-- +goose Down
DROP TABLE backend.config;
ALTER TABLE backend.games DROP COLUMN game_kind;
+104
View File
@@ -0,0 +1,104 @@
// Package robokassa builds and verifies Robokassa direct-rail (RUB) payments: it forms the signed
// hosted-payment URL a client is sent to, and verifies the Result-URL server callback that credits
// an order. It is pure provider glue — no database, no payments-domain coupling — so the payments
// domain stays provider-agnostic and this layer is unit-testable in isolation.
//
// The order is threaded through Robokassa's custom-parameter channel as Shp_order=<order id> (echoed
// back in the callback and bound into the signature), not the numeric InvId, because an order id is
// a uuid; InvId is sent as 0. Idempotency is therefore keyed on the order id at the credit site.
// Signatures use SHA-256 (configured to match the shop's technical settings); the shop's test mode
// is carried by IsTest.
package robokassa
import (
"crypto/sha256"
"encoding/hex"
"net/url"
"sort"
"strings"
"github.com/google/uuid"
)
// payEndpoint is Robokassa's hosted payment page; the signed query sends the client there.
const payEndpoint = "https://auth.robokassa.ru/Merchant/Index.aspx"
// Config is a Robokassa shop's credentials. Password1 signs the outgoing payment request;
// Password2 signs (and so verifies) the incoming Result callback. IsTest adds IsTest=1 so the shop's
// test mode simulates payments without money movement.
type Config struct {
MerchantLogin string
Password1 string
Password2 string
IsTest bool
}
// PaymentURL builds the signed hosted-payment URL for an order: amount is the OutSum decimal string
// (roubles, e.g. "149.00"), description is the human payment purpose. The order id rides as
// Shp_order and is bound into the SHA-256 signature; InvId is 0 (unused).
func (c Config) PaymentURL(orderID uuid.UUID, amount, description string) string {
q := url.Values{}
q.Set("Shp_order", orderID.String())
// Signature base: MerchantLogin:OutSum:InvId:Password1[:Shp_key=value...sorted].
base := c.MerchantLogin + ":" + amount + ":0:" + c.Password1 + shpSuffix(q)
q.Set("MerchantLogin", c.MerchantLogin)
q.Set("OutSum", amount)
q.Set("InvId", "0")
q.Set("Description", description)
q.Set("SignatureValue", sign(base))
if c.IsTest {
q.Set("IsTest", "1")
}
return payEndpoint + "?" + q.Encode()
}
// VerifyResult verifies a Result-URL callback and extracts the order it credits. It recomputes the
// SHA-256 signature OutSum:InvId:Password2[:Shp_...sorted] over the callback's own fields and
// compares it (case-insensitively) with SignatureValue. On success it returns the order id (from
// Shp_order) and the raw OutSum string the caller re-checks against the order amount; on any
// missing field, a signature mismatch or an unparseable order id it returns ok=false.
func (c Config) VerifyResult(v url.Values) (orderID uuid.UUID, outSum string, ok bool) {
outSum = v.Get("OutSum")
sig := v.Get("SignatureValue")
order := v.Get("Shp_order")
if outSum == "" || sig == "" || order == "" {
return uuid.Nil, "", false
}
// Robokassa signs with the InvId it returns in the callback; recompute with that same value.
base := outSum + ":" + v.Get("InvId") + ":" + c.Password2 + shpSuffix(v)
if !strings.EqualFold(sign(base), sig) {
return uuid.Nil, "", false
}
id, err := uuid.Parse(order)
if err != nil {
return uuid.Nil, "", false
}
return id, outSum, true
}
// shpSuffix renders the Shp_ custom parameters of v as Robokassa binds them into a signature:
// every Shp_-prefixed key, sorted alphabetically, appended as ":key=value".
func shpSuffix(v url.Values) string {
var keys []string
for k := range v {
if strings.HasPrefix(k, "Shp_") {
keys = append(keys, k)
}
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(":")
b.WriteString(k)
b.WriteString("=")
b.WriteString(v.Get(k))
}
return b.String()
}
// sign returns the lowercase hex SHA-256 of s, the hash Robokassa compares against SignatureValue.
func sign(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,85 @@
package robokassa
import (
"net/url"
"strings"
"testing"
"github.com/google/uuid"
)
func testCfg() Config {
return Config{MerchantLogin: "shop", Password1: "p1", Password2: "p2", IsTest: true}
}
// resultSig computes the Pass2 Result signature Robokassa would send for a callback carrying only
// Shp_order — the fixture the verifier must accept.
func resultSig(pass2, outSum, invID string, orderID uuid.UUID) string {
return sign(outSum + ":" + invID + ":" + pass2 + ":Shp_order=" + orderID.String())
}
func TestPaymentURL(t *testing.T) {
cfg := testCfg()
id := uuid.New()
u, err := url.Parse(cfg.PaymentURL(id, "149.00", "10 chips"))
if err != nil {
t.Fatalf("parse payment url: %v", err)
}
q := u.Query()
if got := q.Get("OutSum"); got != "149.00" {
t.Errorf("OutSum = %q, want 149.00", got)
}
if got := q.Get("InvId"); got != "0" {
t.Errorf("InvId = %q, want 0 (unused)", got)
}
if got := q.Get("Shp_order"); got != id.String() {
t.Errorf("Shp_order = %q, want the order id", got)
}
if got := q.Get("IsTest"); got != "1" {
t.Errorf("IsTest = %q, want 1 (test shop)", got)
}
want := sign("shop:149.00:0:p1:Shp_order=" + id.String())
if got := q.Get("SignatureValue"); got != want {
t.Errorf("SignatureValue = %q, want %q", got, want)
}
}
func TestVerifyResult(t *testing.T) {
cfg := testCfg()
id := uuid.New()
valid := func() url.Values {
v := url.Values{}
v.Set("OutSum", "149.00")
v.Set("InvId", "0")
v.Set("Shp_order", id.String())
// Robokassa sends the hash uppercase; the verifier must be case-insensitive.
v.Set("SignatureValue", strings.ToUpper(resultSig("p2", "149.00", "0", id)))
return v
}
gotID, gotSum, ok := cfg.VerifyResult(valid())
if !ok || gotID != id || gotSum != "149.00" {
t.Fatalf("VerifyResult(valid) = %v/%q/%v, want %v/149.00/true", gotID, gotSum, ok, id)
}
// A tampered amount breaks the signature.
tampered := valid()
tampered.Set("OutSum", "1.00")
if _, _, ok := cfg.VerifyResult(tampered); ok {
t.Error("VerifyResult accepted a tampered amount")
}
// The wrong Password2 (a forged callback) does not verify.
wrongPass := Config{MerchantLogin: "shop", Password1: "p1", Password2: "other"}
if _, _, ok := wrongPass.VerifyResult(valid()); ok {
t.Error("VerifyResult accepted a signature under the wrong password")
}
// A missing Shp_order is rejected (no order to credit).
noOrder := valid()
noOrder.Del("Shp_order")
if _, _, ok := cfg.VerifyResult(noOrder); ok {
t.Error("VerifyResult accepted a callback with no order")
}
}
+43 -3
View File
@@ -57,9 +57,9 @@ type bannerTimingsDTO struct {
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse { func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
r := profileResponseFor(acc) r := profileResponseFor(acc)
// Resolve the payments gate once (execution context + present sources) and feed it to both // Resolve the payments gate once (execution context + present sources) and feed it to both
// the hint count and the banner. The profile hint balance now comes from the payments benefit // the hint count and the banner. The profile hint balance comes from the payments benefit
// (context-aware), not the deprecated accounts.hint_balance column; on any failure the legacy // (context-aware); the deprecated accounts.hint_balance column is no longer read, so on any
// value from profileResponseFor (zeroed in production) stands. // failure the fallback from profileResponseFor is a plain 0.
cxt, present, err := s.walletGate(ctx, acc.ID) cxt, present, err := s.walletGate(ctx, acc.ID)
if err != nil { if err != nil {
s.log.Warn("profile: wallet gate failed", zap.String("account", acc.ID.String()), zap.Error(err)) s.log.Warn("profile: wallet gate failed", zap.String("account", acc.ID.String()), zap.Error(err))
@@ -71,6 +71,8 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
} }
} }
r.Banner = s.bannerFor(ctx, acc, cxt, present) r.Banner = s.bannerFor(ctx, acc, cxt, present)
r.Ads = s.adsFor(ctx, acc, cxt, present)
r.GameLimits = s.gameLimitsDTOFor(acc.IsGuest)
s.fillLinkedIdentities(ctx, &r, acc.ID) s.fillLinkedIdentities(ctx, &r, acc.ID)
r.DictVersions = s.currentDictVersions() r.DictVersions = s.currentDictVersions()
return r return r
@@ -177,6 +179,44 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account, cxt payment
} }
} }
// adsDTO is the post-move interstitial config in the profile: the client-mirrored cooldowns
// (seconds) and whether ads are suppressed in the context (a no-ads benefit applicable here, or the
// no_banner role). The client shows a VK interstitial after a confirmed move / hint only when not
// suppressed and the mirrored cooldown has elapsed.
type adsDTO struct {
CooldownGlobalS int `json:"cooldown_global_s"`
CooldownVsAiS int `json:"cooldown_vs_ai_s"`
CooldownHintS int `json:"cooldown_hint_s"`
Suppressed bool `json:"suppressed"`
}
// adsFor builds the profile interstitial-ad config: the cooldowns and whether ads are suppressed
// here (the same no-ads / no_banner gate as the banner). A read failure logs and yields a suppressed
// block (fail-safe: no interstitial), so the profile still succeeds.
func (s *Server) adsFor(ctx context.Context, acc account.Account, cxt payments.Context, present []payments.Source) *adsDTO {
if s.payments == nil {
return nil
}
global, vsAi, hint, err := s.payments.InterstitialCooldowns(ctx)
if err != nil {
s.log.Warn("profile: ad cooldowns read failed", zap.String("account", acc.ID.String()), zap.Error(err))
return &adsDTO{Suppressed: true}
}
suppressed := false
if adFree, aerr := s.payments.AdFree(ctx, acc.ID, cxt, present); aerr != nil {
s.log.Warn("profile: ad-free read failed", zap.String("account", acc.ID.String()), zap.Error(aerr))
suppressed = true // fail-safe: suppress the interstitial when eligibility is unknown
} else {
suppressed = adFree
}
if !suppressed {
if noBanner, berr := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner); berr == nil {
suppressed = noBanner
}
}
return &adsDTO{CooldownGlobalS: global, CooldownVsAiS: vsAi, CooldownHintS: hint, Suppressed: suppressed}
}
// bannerCampaignFromActive flattens a resolved campaign into its wire DTO, // bannerCampaignFromActive flattens a resolved campaign into its wire DTO,
// projecting each optional colour set into its three "#rrggbb" fields (empty when // projecting each optional colour set into its three "#rrggbb" fields (empty when
// the set is absent, so JSON omitempty drops them). // the set is absent, so JSON omitempty drops them).
+25 -3
View File
@@ -60,6 +60,11 @@ type profileResponse struct {
// see the banner (a free account with an empty hint wallet and without the // see the banner (a free account with an empty hint wallet and without the
// no_banner role), absent otherwise. See banner.go. // no_banner role), absent otherwise. See banner.go.
Banner *bannerDTO `json:"banner,omitempty"` Banner *bannerDTO `json:"banner,omitempty"`
// Ads carries the post-move interstitial config for the client's client-mirrored gate: the
// cooldowns (seconds) and whether ads are suppressed in this context (no-ads / no_banner role).
// The client shows a VK interstitial after a confirmed move / hint when not suppressed and the
// cooldown has elapsed. Always present (the client also gates VK-only + online itself).
Ads *adsDTO `json:"ads,omitempty"`
// Email is the account's confirmed email address ("" when none); TelegramLinked and // Email is the account's confirmed email address ("" when none); TelegramLinked and
// VkLinked report whether a platform identity is attached. They drive the profile's // VkLinked report whether a platform identity is attached. They drive the profile's
// link / unlink / change-email controls, and are filled outside the pure projection // link / unlink / change-email controls, and are filled outside the pure projection
@@ -73,6 +78,18 @@ type profileResponse struct {
// Filled outside the pure projection (it reads the dictionary registry), so it is empty // Filled outside the pure projection (it reads the dictionary registry), so it is empty
// for callers that build the DTO without a Server. See Server.profileResponse. // for callers that build the DTO without a Server. See Server.profileResponse.
DictVersions []dictVersion `json:"dict_versions,omitempty"` DictVersions []dictVersion `json:"dict_versions,omitempty"`
// GameLimits carries the caller's tier active-game caps per kind (-1 = unlimited); the client
// counts its active games per kind from the lobby and locks a capped New-Game start. Filled
// outside the pure projection (it reads the limits config). See Server.profileResponse.
GameLimits *gameLimitsDTO `json:"game_limits,omitempty"`
}
// gameLimitsDTO is the caller's tier active-game caps per kind (-1 = unlimited), for the client's
// per-kind New-Game lock. profileResponse.GameLimits carries it.
type gameLimitsDTO struct {
VsAI int `json:"vs_ai"`
Random int `json:"random"`
Friends int `json:"friends"`
} }
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current // dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
@@ -130,6 +147,10 @@ type gameDTO struct {
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend // VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
// are suppressed in the client. // are suppressed in the client.
VsAI bool `json:"vs_ai"` VsAI bool `json:"vs_ai"`
// Kind is the game's origin for the active-game limits (game.Kind): 0 unknown (a pre-existing
// game), 1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind to lock a capped
// New-Game start.
Kind int `json:"kind"`
MoveCount int `json:"move_count"` MoveCount int `json:"move_count"`
EndReason string `json:"end_reason"` EndReason string `json:"end_reason"`
// LastActivityUnix is the lobby sort key: the current turn's start for an active // LastActivityUnix is the lobby sort key: the current turn's start for an active
@@ -172,7 +193,6 @@ type stateDTO struct {
Rack []int `json:"rack"` Rack []int `json:"rack"`
BagLen int `json:"bag_len"` BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"` HintsRemaining int `json:"hints_remaining"`
WalletBalance int `json:"wallet_balance"`
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human // HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
// game / first move / not your turn). The client anchors a monotonic countdown to it. // game / first move / not your turn). The client anchors a monotonic countdown to it.
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"` HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
@@ -224,7 +244,9 @@ func profileResponseFor(acc account.Account) profileResponse {
TimeZone: acc.TimeZone, TimeZone: acc.TimeZone,
AwayStart: acc.AwayStart.Format(awayTimeLayout), AwayStart: acc.AwayStart.Format(awayTimeLayout),
AwayEnd: acc.AwayEnd.Format(awayTimeLayout), AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
HintBalance: acc.HintBalance, // The hint balance comes from the payments benefit; profileResponse overrides this
// with the context-aware count. This zero is the fallback when that read fails.
HintBalance: 0,
BlockChat: acc.BlockChat, BlockChat: acc.BlockChat,
BlockFriendRequests: acc.BlockFriendRequests, BlockFriendRequests: acc.BlockFriendRequests,
IsGuest: acc.IsGuest, IsGuest: acc.IsGuest,
@@ -271,6 +293,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI, VsAI: g.VsAI,
Kind: int(g.Kind),
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
EndReason: g.EndReason, EndReason: g.EndReason,
LastActivityUnix: last.Unix(), LastActivityUnix: last.Unix(),
@@ -327,7 +350,6 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
Rack: rack, Rack: rack,
BagLen: v.BagLen, BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining, HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds, HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
} }
if includeAlphabet { if includeAlphabet {
+24
View File
@@ -72,6 +72,26 @@ func (s *Server) registerRoutes() {
u.GET("/wallet", s.handleWallet) u.GET("/wallet", s.handleWallet)
u.GET("/wallet/catalog", s.handleWalletCatalog) u.GET("/wallet/catalog", s.handleWalletCatalog)
u.POST("/wallet/buy", s.handleWalletBuy) u.POST("/wallet/buy", s.handleWalletBuy)
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
u.POST("/wallet/reward", s.handleWalletReward)
// The public-offer price list (§4.4) as markdown, for the render sidecar that serves the
// /offer/ page. Internal (off the edge allow-list); called by the renderer, not the gateway.
s.internal.GET("/offer/pricing", s.handleOfferPricing)
}
if s.payments != nil {
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
// unsupported or unconfigured rail returns 501 from the handler. The provider callbacks are
// gateway-only (the single writer): the VK payment callback (both phases handled here), and
// the Robokassa Result callback when a merchant is configured.
u.POST("/wallet/order", s.handleWalletOrder)
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
// The Telegram Stars rail: the bot forwards a pre_checkout validation and a completed
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
if s.robokassa.MerchantLogin != "" {
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
}
} }
if s.links != nil { if s.links != nil {
// Account linking & merge. The request step always mails a code; // Account linking & merge. The request step always mails a code;
@@ -266,6 +286,8 @@ func statusForError(err error) (int, string) {
return http.StatusNotFound, "product_not_found" return http.StatusNotFound, "product_not_found"
case errors.Is(err, payments.ErrNotAValue): case errors.Is(err, payments.ErrNotAValue):
return http.StatusBadRequest, "not_a_value" return http.StatusBadRequest, "not_a_value"
case errors.Is(err, payments.ErrNotAPack):
return http.StatusBadRequest, "not_a_pack"
case errors.Is(err, account.ErrInvalidEmail): case errors.Is(err, account.ErrInvalidEmail):
return http.StatusBadRequest, "invalid_email" return http.StatusBadRequest, "invalid_email"
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired), case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
@@ -300,6 +322,8 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "request_declined" return http.StatusConflict, "request_declined"
case errors.Is(err, social.ErrFriendCodeInvalid): case errors.Is(err, social.ErrFriendCodeInvalid):
return http.StatusUnprocessableEntity, "friend_code_invalid" return http.StatusUnprocessableEntity, "friend_code_invalid"
case errors.Is(err, social.ErrGuestForbidden), errors.Is(err, lobby.ErrGuestForbidden):
return http.StatusForbidden, "guest_forbidden"
case errors.Is(err, feedback.ErrGuestForbidden): case errors.Is(err, feedback.ErrGuestForbidden):
return http.StatusForbidden, "feedback_guest_forbidden" return http.StatusForbidden, "feedback_guest_forbidden"
case errors.Is(err, feedback.ErrBanned): case errors.Is(err, feedback.ErrBanned):
@@ -0,0 +1,281 @@
package server
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/payments"
)
// catalogBack is the product-list path the catalog console actions return to.
const catalogBack = "/_gm/catalog"
// atomField pairs a form field with its atom type; priceField pairs a form field with the payment
// method + currency it prices, following the one-currency-per-rail mapping (direct→RUB, vk→VOTE,
// telegram→XTR) plus the value's CHIP price (no method).
var atomFields = []struct{ field, atom string }{
{"chips", "chips"}, {"hints", "hints"}, {"noads", "noads_days"}, {"tournament", "tournament"},
}
var priceFields = []struct {
field, method string
currency payments.Currency
}{
{"price_rub", "direct", payments.CurrencyRUB},
{"price_vote", "vk", payments.CurrencyVote},
{"price_star", "telegram", payments.CurrencyStar},
{"price_chip", "", payments.CurrencyChip},
}
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
// flag, and the inline create form.
func (s *Server) consoleCatalog(c *gin.Context) {
products, err := s.payments.AdminCatalog(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
payments.SortAdminCatalog(products)
var view adminconsole.CatalogView
for _, p := range products {
view.Products = append(view.Products, catalogRow(p))
}
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
}
// catalogRow projects a product into its list row.
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
for _, a := range p.Atoms {
row.Atoms = append(row.Atoms, adminconsole.AtomRow{Atom: a.Atom, Quantity: a.Quantity})
}
for _, pr := range p.Prices {
row.Prices = append(row.Prices, adminconsole.PriceRow{Method: pr.Method, Currency: string(pr.Currency), Amount: pr.Amount})
}
return row
}
// consoleCatalogDetail renders one product's edit form, pre-filled from its current composition.
func (s *Server) consoleCatalogDetail(c *gin.Context) {
id, ok := s.consoleUUID(c, catalogBack)
if !ok {
return
}
products, err := s.payments.AdminCatalog(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
for _, p := range products {
if p.ID == id {
s.renderConsole(c, "product_detail", "catalog", p.Title, productForm(p))
return
}
}
s.renderConsoleMessage(c, "Not found", "no such product", catalogBack)
}
// productForm builds the pre-filled edit form for a product.
func productForm(p payments.AdminProduct) adminconsole.ProductFormView {
fv := adminconsole.ProductFormView{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
for _, a := range p.Atoms {
switch a.Atom {
case "chips":
fv.Chips = a.Quantity
case "hints":
fv.Hints = a.Quantity
case "noads_days":
fv.NoAds = a.Quantity
case "tournament":
fv.Tournament = a.Quantity
}
}
for _, pr := range p.Prices {
switch pr.Currency {
case payments.CurrencyRUB:
fv.PriceRUB = pr.Amount
case payments.CurrencyVote:
fv.PriceVote = pr.Amount
case payments.CurrencyStar:
fv.PriceStar = pr.Amount
case payments.CurrencyChip:
fv.PriceChip = pr.Amount
}
}
return fv
}
// parseProductForm reads a product's title, atoms, prices and active flag from the submitted form.
func parseProductForm(c *gin.Context) (payments.ProductInput, bool) {
in := payments.ProductInput{Title: strings.TrimSpace(c.PostForm("title"))}
for _, a := range atomFields {
if q, err := strconv.Atoi(strings.TrimSpace(c.PostForm(a.field))); err == nil && q > 0 {
in.Atoms = append(in.Atoms, payments.AtomLine{Atom: a.atom, Quantity: q})
}
}
for _, p := range priceFields {
if amt, err := strconv.ParseInt(strings.TrimSpace(c.PostForm(p.field)), 10, 64); err == nil && amt > 0 {
in.Prices = append(in.Prices, payments.PriceLine{Method: p.method, Currency: p.currency, Amount: amt})
}
}
return in, c.PostForm("active") != ""
}
// consoleCreateProduct validates and inserts a new product from the create form.
func (s *Server) consoleCreateProduct(c *gin.Context) {
in, active := parseProductForm(c)
if _, err := s.payments.CreateProduct(c.Request.Context(), in, active); err != nil {
s.renderConsoleMessage(c, "Invalid product", err.Error(), catalogBack)
return
}
s.renderConsoleMessage(c, "Created", "the product was created", catalogBack)
}
// consoleUpdateProduct validates and replaces a product's title, atoms and prices.
func (s *Server) consoleUpdateProduct(c *gin.Context) {
id, ok := s.consoleUUID(c, catalogBack)
if !ok {
return
}
in, _ := parseProductForm(c)
back := catalogBack + "/" + id.String()
if err := s.payments.UpdateProduct(c.Request.Context(), id, in); err != nil {
s.renderConsoleMessage(c, "Invalid product", err.Error(), back)
return
}
s.renderConsoleMessage(c, "Saved", "the product was updated", back)
}
// consoleArchiveProduct archives or unarchives a product (the desired state rides in the form);
// unarchiving revalidates the sellable shape.
func (s *Server) consoleArchiveProduct(c *gin.Context) {
id, ok := s.consoleUUID(c, catalogBack)
if !ok {
return
}
active := c.PostForm("active") == "true"
if err := s.payments.SetProductActive(c.Request.Context(), id, active); err != nil {
s.renderConsoleMessage(c, "Cannot change status", err.Error(), catalogBack)
return
}
s.renderConsoleMessage(c, "Updated", "the product status was changed", catalogBack)
}
// consoleDeleteProductAction hard-deletes a never-transacted product; a transacted one is refused
// with a hint to archive instead.
func (s *Server) consoleDeleteProductAction(c *gin.Context) {
id, ok := s.consoleUUID(c, catalogBack)
if !ok {
return
}
err := s.payments.DeleteProduct(c.Request.Context(), id)
if errors.Is(err, payments.ErrProductTransacted) {
s.renderConsoleMessage(c, "Cannot delete", "this product has transactions; archive it instead", catalogBack)
return
}
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "the product was deleted", catalogBack)
}
// consoleGrant grants raw benefit atoms (hints / no-ads days / forever) to a chosen origin — a
// zero-price admin sale.
func (s *Server) consoleGrant(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
if s.payments == nil {
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
return
}
origin := payments.Source(c.PostForm("origin"))
hints, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("hints")))
noads, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("noads")))
forever := c.PostForm("forever") != ""
if err := s.payments.Grant(c.Request.Context(), id, origin, hints, noads, forever); err != nil {
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
return
}
s.publishBannerChange(id)
s.renderConsoleMessage(c, "Granted", "the benefit was granted", back)
}
// consoleGrantProduct grants a value product's atoms (a reward bundle, possibly archived) to a
// chosen origin. It refuses a product carrying chips or the tournament atom (payments enforces it).
func (s *Server) consoleGrantProduct(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
if s.payments == nil {
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
return
}
origin := payments.Source(c.PostForm("origin"))
productID, err := uuid.Parse(strings.TrimSpace(c.PostForm("product_id")))
if err != nil {
s.renderConsoleMessage(c, "Grant failed", "choose a product", back)
return
}
if err := s.payments.GrantProduct(c.Request.Context(), id, origin, productID); err != nil {
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
return
}
s.publishBannerChange(id)
s.renderConsoleMessage(c, "Granted", "the product was granted", back)
}
// grantForm builds the admin-grant panel: the origin picker and the grantable products (value
// bundles, including archived ones — chips and tournament products are excluded).
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
products, err := s.payments.AdminCatalog(ctx)
if err != nil {
return fv
}
for _, p := range products {
if grantableProduct(p) {
fv.Products = append(fv.Products, adminconsole.GrantProductOption{
ID: p.ID.String(), Title: p.Title, Summary: atomSummary(p.Atoms), Archived: !p.Active,
})
}
}
return fv
}
// grantableProduct reports whether a product can be admin-granted: it carries at least one benefit
// atom (hints / no-ads days) and no chips or tournament atom.
func grantableProduct(p payments.AdminProduct) bool {
benefit := false
for _, a := range p.Atoms {
switch a.Atom {
case "chips", "tournament":
return false
case "hints", "noads_days":
benefit = true
}
}
return benefit
}
// atomSummary renders a product's atoms as "hints×5, noads_days×30".
func atomSummary(atoms []payments.AtomLine) string {
parts := make([]string, 0, len(atoms))
for _, a := range atoms {
parts = append(parts, fmt.Sprintf("%s×%d", a.Atom, a.Quantity))
}
return strings.Join(parts, ", ")
}
@@ -21,6 +21,7 @@ import (
"scrabble/backend/internal/dictadmin" "scrabble/backend/internal/dictadmin"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/ratewatch" "scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robot" "scrabble/backend/internal/robot"
"scrabble/backend/internal/social" "scrabble/backend/internal/social"
@@ -29,10 +30,6 @@ import (
// adminPageSize is the page size of the admin console's paginated lists. // adminPageSize is the page size of the admin console's paginated lists.
const adminPageSize = 50 const adminPageSize = 50
// maxHintGrant caps a single operator hint grant. Grants are additive and can never lower a
// wallet, so a fat-fingered grant cannot be undone through this form; the cap bounds one mistake.
const maxHintGrant = 100
// registerConsole mounts the server-rendered admin console under /_gm. The gateway // registerConsole mounts the server-rendered admin console under /_gm. The gateway
// puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the // puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the
// backend trusts the gateway (as for all of /api) and adds only a same-origin guard // backend trusts the gateway (as for all of /api) and adds only a same-origin guard
@@ -56,12 +53,14 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/users/:id", s.consoleUserDetail) gm.GET("/users/:id", s.consoleUserDetail)
gm.POST("/users/:id/message", s.consoleUserMessage) gm.POST("/users/:id/message", s.consoleUserMessage)
gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag) gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag)
gm.POST("/users/:id/grant-hints", s.consoleGrantHints)
gm.POST("/users/:id/block", s.consoleBlockUser) gm.POST("/users/:id/block", s.consoleBlockUser)
gm.POST("/users/:id/unblock", s.consoleUnblockUser) gm.POST("/users/:id/unblock", s.consoleUnblockUser)
gm.POST("/users/:id/grant-role", s.consoleGrantRole) gm.POST("/users/:id/grant-role", s.consoleGrantRole)
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole) gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail) gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
gm.POST("/users/:id/grant", s.consoleGrant)
gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
gm.POST("/users/:id/refund", s.consoleRefund)
gm.POST("/users/:id/delete", s.consoleDeleteUser) gm.POST("/users/:id/delete", s.consoleDeleteUser)
gm.GET("/reasons", s.consoleReasons) gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason) gm.POST("/reasons", s.consoleCreateReason)
@@ -71,6 +70,10 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.POST("/bans/unban", s.consoleUnban) gm.POST("/bans/unban", s.consoleUnban)
gm.GET("/games", s.consoleGames) gm.GET("/games", s.consoleGames)
gm.GET("/games/:id", s.consoleGameDetail) gm.GET("/games/:id", s.consoleGameDetail)
if s.gamelimits != nil {
gm.GET("/limits", s.consoleLimits)
gm.POST("/limits", s.consoleUpdateLimits)
}
gm.GET("/complaints", s.consoleComplaints) gm.GET("/complaints", s.consoleComplaints)
gm.GET("/complaints/:id", s.consoleComplaintDetail) gm.GET("/complaints/:id", s.consoleComplaintDetail)
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint) gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
@@ -106,6 +109,15 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/banner-settings", s.consoleBannerSettings) gm.GET("/banner-settings", s.consoleBannerSettings)
gm.POST("/banner-settings", s.consoleUpdateBannerSettings) gm.POST("/banner-settings", s.consoleUpdateBannerSettings)
} }
if s.payments != nil {
gm.GET("/catalog", s.consoleCatalog)
gm.POST("/catalog", s.consoleCreateProduct)
gm.GET("/catalog/:id", s.consoleCatalogDetail)
gm.POST("/catalog/:id", s.consoleUpdateProduct)
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
gm.GET("/ledger.csv", s.consoleLedgerExport)
}
} }
// consoleDashboard renders the landing page: the top-line counts and the resident // consoleDashboard renders the landing page: the top-line counts and the resident
@@ -354,7 +366,6 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view := adminconsole.UserDetailView{ view := adminconsole.UserDetailView{
ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage, ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage,
TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly, TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly,
PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, HintGrantMax: maxHintGrant,
CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil, CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil,
} }
if acc.MergedInto != uuid.Nil { if acc.MergedInto != uuid.Nil {
@@ -442,9 +453,41 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view.Friends = relationRows(rels) view.Friends = relationRows(rels)
} }
} }
if s.payments != nil {
if stmt, err := s.payments.AccountStatement(ctx, id); err == nil {
view.Finance = financeView(stmt)
} else {
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
}
view.Grant = s.grantForm(ctx)
}
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view) s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
} }
// financeView projects an account's payments statement into the user-card finance panel, with the
// benefit expiry and ledger times pre-formatted for the logic-free template.
func financeView(stmt payments.Statement) adminconsole.FinanceView {
fv := adminconsole.FinanceView{Present: true, Abuse: stmt.Risk.Abuse, Loss: stmt.Risk.LossChips}
for _, sg := range stmt.Segments {
fv.Segments = append(fv.Segments, adminconsole.SegmentRow{Source: string(sg.Source), Chips: sg.Chips})
}
for _, b := range stmt.Benefits {
row := adminconsole.BenefitRow{Origin: string(b.Origin), Hints: b.Hints, Forever: b.AdsForever}
if !b.AdsPaidUntil.IsZero() {
row.AdsUntil = fmtTime(b.AdsPaidUntil)
}
fv.Benefits = append(fv.Benefits, row)
}
for _, e := range stmt.Ledger {
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Snapshot: e.Snapshot,
At: fmtTime(e.CreatedAt),
})
}
return fv
}
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the // relationRows maps the social graph entries to the cross-linked, date-formatted rows the
// user card renders. // user card renders.
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow { func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
@@ -523,6 +566,7 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason, Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt), MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI, FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
} }
// Resolve seats and detect robot seats; capture the human opponent's timezone, which // Resolve seats and detect robot seats; capture the human opponent's timezone, which
// anchors the robot's sleep window for the next-move ETA. // anchors the robot's sleep window for the next-move ETA.
@@ -963,30 +1007,6 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String()) s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String())
} }
// consoleGrantHints adds hints to a user's wallet. The grant is additive (raise-only): it tops a
// player up and can never lower what they already hold, so blocking a reduction is inherent rather
// than a separate guard. A single grant is bounded by maxHintGrant.
func (s *Server) consoleGrantHints(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
n, err := strconv.Atoi(trimForm(c, "amount"))
if err != nil || n < 1 || n > maxHintGrant {
s.renderConsoleMessage(c, "Invalid amount", fmt.Sprintf("enter a whole number of hints to add, between 1 and %d", maxHintGrant), back)
return
}
balance, err := s.accounts.GrantHints(c.Request.Context(), id, n)
if err != nil {
s.consoleError(c, err)
return
}
// A non-empty hint wallet removes the banner: nudge an open client to re-check.
s.publishBannerChange(id)
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back)
}
// consoleRemoveEmail deletes the account's bound email identity (and any pending // consoleRemoveEmail deletes the account's bound email identity (and any pending
// confirmations), freeing the address. It refuses to remove the account's only // confirmations), freeing the address. It refuses to remove the account's only
// identity, which would leave it unreachable. // identity, which would leave it unreachable.
@@ -1229,7 +1249,7 @@ func (s *Server) consoleUUID(c *gin.Context, back string) (uuid.UUID, bool) {
// gameRow projects a game summary into its console row. // gameRow projects a game summary into its console row.
func gameRow(g game.Game) adminconsole.GameRow { func gameRow(g game.Game) adminconsole.GameRow {
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI} return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI, Kind: g.Kind.String()}
} }
// trimForm returns the trimmed value of a posted form field. // trimForm returns the trimmed value of a posted form field.
@@ -0,0 +1,75 @@
package server
import (
"fmt"
"github.com/gin-gonic/gin"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/gamelimits"
)
// gameLimitsDTOFor resolves the caller's tier active-game caps into the profile DTO, so the client
// can lock a capped New-Game start per kind. It returns nil when the limits config is not wired.
func (s *Server) gameLimitsDTOFor(isGuest bool) *gameLimitsDTO {
if s.gamelimits == nil {
return nil
}
l := s.gamelimits.LimitsFor(isGuest)
return &gameLimitsDTO{VsAI: l.VsAI, Random: l.Random, Friends: l.Friends}
}
// consoleLimits renders the per-tier, per-kind active-game limit form (backend.config), read from
// the in-memory cache.
func (s *Server) consoleLimits(c *gin.Context) {
cfg := s.gamelimits.Get()
s.renderConsole(c, "limits", "limits", "Game limits", adminconsole.GameLimitsView{
GuestVsAI: cfg.Guest.VsAI,
GuestRandom: cfg.Guest.Random,
GuestFriends: cfg.Guest.Friends,
DurableVsAI: cfg.Durable.VsAI,
DurableRandom: cfg.Durable.Random,
DurableFriends: cfg.Durable.Friends,
})
}
// consoleUpdateLimits saves the active-game limits and refreshes the hot cache in place. Each
// field is a per-kind cap: -1 is unlimited, 0 blocks the kind, a positive value caps concurrent games.
func (s *Server) consoleUpdateLimits(c *gin.Context) {
cfg := gamelimits.Config{
Guest: gamelimits.Limits{
VsAI: atoiForm(c, "guest_vs_ai"),
Random: atoiForm(c, "guest_random"),
Friends: atoiForm(c, "guest_friends"),
},
Durable: gamelimits.Limits{
VsAI: atoiForm(c, "durable_vs_ai"),
Random: atoiForm(c, "durable_random"),
Friends: atoiForm(c, "durable_friends"),
},
}
if err := validateGameLimits(cfg); err != nil {
s.renderConsoleMessage(c, "Invalid", err.Error(), "/_gm/limits")
return
}
if err := s.gamelimits.Update(c.Request.Context(), cfg); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Saved", "game limits updated", "/_gm/limits")
}
// validateGameLimits rejects a limit below -1 (the unlimited sentinel); -1, 0 and any positive count
// are valid. It mirrors the backend.config CHECK so a bad value is refused with a clean message
// rather than a raw database error.
func validateGameLimits(cfg gamelimits.Config) error {
for _, v := range []int{
cfg.Guest.VsAI, cfg.Guest.Random, cfg.Guest.Friends,
cfg.Durable.VsAI, cfg.Durable.Random, cfg.Durable.Friends,
} {
if v < gamelimits.Unlimited {
return fmt.Errorf("a limit must be -1 (unlimited), 0, or a positive count")
}
}
return nil
}
@@ -0,0 +1,71 @@
package server
import (
"encoding/csv"
"fmt"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// consoleRefund refunds a paid order in full at the operator's request. The operator performs the
// actual money refund on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment);
// this records it — a refund ledger row and a floor-0 chip revoke (never negative, D27). Idempotent.
func (s *Server) consoleRefund(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
if s.payments == nil {
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
return
}
orderID, err := uuid.Parse(strings.TrimSpace(c.PostForm("order_id")))
if err != nil {
s.renderConsoleMessage(c, "Refund failed", "no order to refund", back)
return
}
out, err := s.payments.RefundOrderFull(c.Request.Context(), orderID)
if err != nil {
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
return
}
if out.AlreadyRefunded {
s.renderConsoleMessage(c, "Already refunded", "this order was already refunded", back)
return
}
s.publishBannerChange(id)
msg := fmt.Sprintf("revoked %d chips", out.Revoked)
if out.Loss > 0 {
msg += fmt.Sprintf("; %d chips were already spent (recorded as a loss + abuse flag)", out.Loss)
}
s.renderConsoleMessage(c, "Refunded", msg, back)
}
// consoleLedgerExport streams the entire append-only ledger as a CSV attachment for tax reporting
// and rail reconciliation. The snapshot column carries the raw purchase/refund JSON.
func (s *Server) consoleLedgerExport(c *gin.Context) {
rows, err := s.payments.LedgerExport(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{
"created_at", "account_id", "kind", "source", "origin", "chips_delta",
"product_id", "order_id", "provider", "provider_payment_id", "snapshot",
})
for _, r := range rows {
_ = w.Write([]string{
r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID, r.Kind, r.Source, r.Origin,
strconv.Itoa(r.ChipsDelta), r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Snapshot,
})
}
w.Flush()
}
+14 -22
View File
@@ -10,6 +10,7 @@ import (
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
) )
// The handlers below cover the game and chat operations the UI needs. They follow // The handlers below cover the game and chat operations the UI needs. They follow
@@ -46,10 +47,11 @@ type historyDTO struct {
} }
// gameListDTO is the caller's games (active and finished) for the lobby. AtGameLimit // gameListDTO is the caller's games (active and finished) for the lobby. AtGameLimit
// reports whether the caller has reached the simultaneous quick-game cap // reports whether the caller has reached its tier's active-game cap for the random
// (game.MaxActiveQuickGames); while it is true the lobby disables "New Game" and shows a // (quick auto-match) kind (the per-tier, per-kind limits in backend.config); while
// notice. It rides the lobby response — which the lobby re-fetches on every game event — // it is true the lobby disables "New Game" and shows a notice. It rides the lobby
// instead of a separate request. // response — which the lobby re-fetches on every game event — instead of a separate
// request.
type gameListDTO struct { type gameListDTO struct {
Games []gameDTO `json:"games"` Games []gameDTO `json:"games"`
AtGameLimit bool `json:"at_game_limit"` AtGameLimit bool `json:"at_game_limit"`
@@ -395,23 +397,13 @@ func (s *Server) handleSaveDraft(c *gin.Context) {
c.JSON(http.StatusOK, okResponse{OK: true}) c.JSON(http.StatusOK, okResponse{OK: true})
} }
// atGameLimit reports whether uid already holds the maximum number of simultaneous // ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid has reached its
// quick games (game.MaxActiveQuickGames). It backs both the lobby's at_game_limit flag // tier's active-game cap for kind (the per-tier, per-kind limits in backend.config), and reports
// and the new-game gate; friend games created by invitation are not counted. // whether the caller may proceed. It guards the quick new-game entry points — auto-match (random)
func (s *Server) atGameLimit(ctx context.Context, uid uuid.UUID) (bool, error) { // and AI (vs_ai). Friend-invitation limits are enforced in the lobby's CreateInvitation, and
n, err := s.games.CountActiveQuickGames(ctx, uid) // accepting an incoming invitation is deliberately exempt.
if err != nil { func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID, kind gamelimits.Kind) bool {
return false, err atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, kind)
}
return n >= game.MaxActiveQuickGames, nil
}
// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid is at the
// simultaneous quick-game cap, and reports whether the caller may proceed. It guards
// every new-game entry point — quick auto-match/AI and invitation creation; accepting an
// incoming invitation is deliberately exempt.
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID) bool {
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return false return false
@@ -437,7 +429,7 @@ func (s *Server) handleListGames(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
atLimit, err := s.atGameLimit(c.Request.Context(), uid) atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, gamelimits.KindRandom)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
+399
View File
@@ -0,0 +1,399 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments"
)
// Ledger/order provider tags per rail.
const (
providerRobokassa = "robokassa" // the direct (RUB) rail
providerVK = "vk" // the VK Votes rail
providerTelegram = "telegram" // the Telegram Stars (XTR) rail
)
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
type walletOrderRequest struct {
ProductID string `json:"product_id"`
}
// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
// the provider's hosted-payment URL for the direct rail (empty for VK/Telegram, which settle
// in-app). Rail names the settling rail so the gateway knows how to launch it; for the Telegram
// Stars rail the gateway mints the invoice link from InvoiceTitle and InvoiceAmount (whole stars)
// via the bot and returns it in RedirectURL.
type walletOrderResponse struct {
OrderID string `json:"order_id"`
RedirectURL string `json:"redirect_url"`
Rail string `json:"rail"`
InvoiceTitle string `json:"invoice_title,omitempty"`
InvoiceAmount int64 `json:"invoice_amount,omitempty"`
}
// handleWalletOrder opens a pending order to fund a chip pack and returns the rail's launch
// details for the client: the Robokassa hosted-payment URL (direct), the order id for
// VKWebAppShowOrderBox (VK), or the pack title and star amount the gateway mints into a Stars
// invoice link (Telegram). It enforces the wallet gate and, on the direct rail, D36 (a purchase
// requires a confirmed email anchor). No chips are credited here — only later, by the verified
// provider callback.
func (s *Server) handleWalletOrder(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
var req walletOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid request body"}})
return
}
productID, err := uuid.Parse(req.ProductID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid product id"}})
return
}
ctx := c.Request.Context()
cxt, present, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
switch cxt.Kind {
case payments.SourceDirect:
if s.robokassa.MerchantLogin == "" {
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
return
}
// D36: a direct purchase requires a confirmed email anchor.
hasEmail, err := s.accounts.HasConfirmedEmail(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
if !hasEmail {
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
return
}
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
Rail: providerRobokassa,
})
case payments.SourceVK:
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerVK)
if err != nil {
s.abortErr(c, err)
return
}
// The client passes the order id to VKWebAppShowOrderBox as the item; there is no redirect.
c.JSON(http.StatusOK, walletOrderResponse{OrderID: res.OrderID.String(), Rail: providerVK})
case payments.SourceTelegram:
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerTelegram)
if err != nil {
s.abortErr(c, err)
return
}
// The gateway mints the Stars invoice link from the title and amount via the bot (only
// the bot reaches Telegram) and returns it to the client as RedirectURL; the amount is in
// whole stars (the XTR minor unit is the star). No chips are credited until the verified
// successful_payment is forwarded back through the bot.
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
Rail: providerTelegram,
InvoiceTitle: res.Title,
InvoiceAmount: res.Amount.Minor(),
})
default:
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available yet"}})
}
}
// handleRobokassaResult is the verified Robokassa Result callback, reached only through the gateway
// (which forwards the provider's form parameters as a JSON object on the internal, gateway-only
// route). It verifies the Password2 signature, credits the matched order exactly once (idempotent,
// and honoured even if the order expired), records a succeeded event, and answers Robokassa's
// expected "OK<InvId>". A duplicate callback credits nothing but still answers OK.
func (s *Server) handleRobokassaResult(c *gin.Context) {
var params map[string]string
if err := c.ShouldBindJSON(&params); err != nil {
c.String(http.StatusBadRequest, "bad request")
return
}
v := url.Values{}
for k, val := range params {
v.Set(k, val)
}
orderID, outSum, ok := s.robokassa.VerifyResult(v)
if !ok {
s.log.Warn("robokassa result: bad signature")
c.String(http.StatusBadRequest, "bad sign")
return
}
paid, err := payments.ParseMoney(outSum, payments.CurrencyRUB)
if err != nil {
c.String(http.StatusBadRequest, "bad amount")
return
}
ctx := c.Request.Context()
outcome, err := s.payments.Fund(ctx, orderID, providerRobokassa, orderID.String(), paid)
if err != nil {
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
s.log.Warn("robokassa result rejected", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusBadRequest, "rejected")
return
}
s.log.Error("robokassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
if !outcome.AlreadyCredited {
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
// The gateway echoes response verbatim to Robokassa, which requires the body "OK<InvId>".
c.JSON(http.StatusOK, gin.H{"response": "OK" + v.Get("InvId")})
}
// vkErrorResponse builds VK's error envelope for a payment callback.
func vkErrorResponse(code int, msg string, critical bool) gin.H {
return gin.H{"error": gin.H{"error_code": code, "error_msg": msg, "critical": critical}}
}
// handleVKCallback is the VK Mini Apps payment callback, reached only through the gateway (which
// verifies the VK signature and forwards the provider's parameters as a JSON object on the internal
// route). It answers VK's two phases: get_item returns the ordered pack's title and vote price; a
// chargeable order_status_change credits the matched order exactly once (idempotent on VK's order
// id) and records a succeeded event. Both phases use VK's response envelope; the _test variants are
// the sandbox notifications and are handled identically.
func (s *Server) handleVKCallback(c *gin.Context) {
var params map[string]string
if err := c.ShouldBindJSON(&params); err != nil {
c.JSON(http.StatusOK, vkErrorResponse(1, "bad request", true))
return
}
ctx := c.Request.Context()
switch params["notification_type"] {
case "get_item", "get_item_test":
orderID, err := uuid.Parse(params["item"])
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
return
}
title, amount, err := s.payments.OrderItem(ctx, orderID)
if err != nil {
s.log.Warn("vk get_item lookup failed", zap.String("order", orderID.String()), zap.Error(err))
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
return
}
c.JSON(http.StatusOK, gin.H{"response": gin.H{
"item_id": orderID.String(),
"title": title,
"price": amount.Minor(),
}})
case "order_status_change", "order_status_change_test":
if params["status"] != "chargeable" {
c.JSON(http.StatusOK, vkErrorResponse(100, "unsupported status", false))
return
}
orderID, err := uuid.Parse(params["item"])
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
return
}
price, err := strconv.ParseInt(params["item_price"], 10, 64)
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
return
}
paid, err := payments.MoneyFromMinor(price, payments.CurrencyVote)
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
return
}
vkOrderID := params["order_id"]
outcome, err := s.payments.Fund(ctx, orderID, providerVK, vkOrderID, paid)
if err != nil {
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
s.log.Warn("vk order rejected", zap.String("order", orderID.String()), zap.Error(err))
c.JSON(http.StatusOK, vkErrorResponse(100, "cannot process the order", false))
return
}
s.log.Error("vk fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.JSON(http.StatusOK, vkErrorResponse(100, "internal error", false))
return
}
if !outcome.AlreadyCredited {
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
s.log.Error("record vk payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
// Echo VK's own order id; app_order_id is optional and our order id is a uuid, so omit it.
appOrderID, _ := strconv.ParseInt(vkOrderID, 10, 64)
c.JSON(http.StatusOK, gin.H{"response": gin.H{"order_id": appOrderID}})
default:
c.JSON(http.StatusOK, vkErrorResponse(100, "unknown notification", false))
}
}
// telegramPreCheckoutRequest is the bot's pre_checkout validation, forwarded through the gateway:
// the order in the invoice payload and the amount and currency Telegram is about to charge.
type telegramPreCheckoutRequest struct {
OrderID string `json:"order_id"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
// telegramPreCheckoutResponse tells the bot whether to approve the pre_checkout_query; Reason is a
// short message the bot surfaces to the payer on a decline.
type telegramPreCheckoutResponse struct {
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
}
// handleTelegramPreCheckout validates a Telegram Stars pre_checkout_query before the charge,
// reached only through the gateway (the bot's ValidatePreCheckout, forwarded on the internal
// route). It approves an order that exists, is not already paid (a reusable invoice link paid twice
// is refused here, before any star moves) and whose amount and currency match. A malformed
// reference is a decline, not an error.
func (s *Server) handleTelegramPreCheckout(c *gin.Context) {
var req telegramPreCheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
ctx := c.Request.Context()
orderID, err := uuid.Parse(req.OrderID)
if err != nil {
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
return
}
amount, err := payments.MoneyFromMinor(req.Amount, payments.Currency(req.Currency))
if err != nil {
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
return
}
out, err := s.payments.ValidatePreCheckout(ctx, orderID, amount)
if err != nil {
s.log.Error("telegram pre_checkout validate failed", zap.String("order", orderID.String()), zap.Error(err))
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
return
}
reason := ""
if !out.OK {
// Localise the decline to the order account's preferred language (the reason shows in the
// Telegram payment sheet). An unknown order has no account, so it falls back to English.
lang := ""
if s.accounts != nil && out.AccountID != (uuid.UUID{}) {
if acc, aerr := s.accounts.GetByID(ctx, out.AccountID); aerr == nil {
lang = acc.PreferredLanguage
}
}
reason = telegramDeclineText(out.Reason, lang)
}
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: out.OK, Reason: reason})
}
// telegramDeclineText renders a pre-checkout decline reason code in the payer's language (ru or
// anything else falls back to English), for display in the Telegram payment sheet.
func telegramDeclineText(code, lang string) string {
ru := lang == "ru"
switch code {
case payments.PreCheckoutAlreadyPaid:
if ru {
return "Этот заказ уже оплачен."
}
return "This order has already been paid."
case payments.PreCheckoutPriceChanged:
if ru {
return "Цена изменилась — начните покупку заново."
}
return "The price has changed; please start the purchase again."
default:
if ru {
return "Этот заказ больше недоступен."
}
return "This order is no longer available."
}
}
// telegramPaymentRequest is a completed Stars payment forwarded from the bot's outbox through the
// gateway: the order, the Telegram charge id (the idempotency key), the stars paid, and the payer.
type telegramPaymentRequest struct {
OrderID string `json:"order_id"`
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
Amount int64 `json:"amount"`
TelegramUserID int64 `json:"telegram_user_id"`
}
// telegramPaymentResponse reports the durable outcome to the bot: Credited is true once the order
// is credited (or already was). A false with a 200 means the payment was recorded but not creditable
// (the bot drops it); a 5xx means a transient failure the bot retries.
type telegramPaymentResponse struct {
Credited bool `json:"credited"`
}
// handleTelegramPayment credits a completed Telegram Stars payment, reached only through the gateway
// (the bot's ForwardPayment, forwarded on the internal route). It credits the matched order exactly
// once (idempotent on the Telegram charge id, honoured even if the order expired) and records a
// succeeded event. A permanent rejection (unknown order, amount mismatch) is answered 200 with
// Credited=false so the bot stops retrying a payment it cannot place; a transient failure is a 5xx
// the bot retries.
func (s *Server) handleTelegramPayment(c *gin.Context) {
var req telegramPaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
orderID, err := uuid.Parse(req.OrderID)
if err != nil {
s.log.Warn("telegram payment: bad order id", zap.String("charge", req.TelegramPaymentChargeID))
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
paid, err := payments.MoneyFromMinor(req.Amount, payments.CurrencyStar)
if err != nil {
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
ctx := c.Request.Context()
outcome, err := s.payments.Fund(ctx, orderID, providerTelegram, req.TelegramPaymentChargeID, paid)
if err != nil {
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
// The star charge already happened but the order cannot be placed; record it loudly for
// an operator and tell the bot to stop retrying (an operator refunds or credits by hand).
s.log.Error("telegram payment rejected (charge taken, not credited)",
zap.String("order", orderID.String()), zap.String("charge", req.TelegramPaymentChargeID), zap.Error(err))
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
s.log.Error("telegram fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
return
}
if !outcome.AlreadyCredited {
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
s.log.Error("record telegram payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: true})
}
@@ -133,9 +133,6 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
} }
inviteeIDs = append(inviteeIDs, id) inviteeIDs = append(inviteeIDs, id)
} }
if !s.ensureUnderGameLimit(c, uid) {
return
}
inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings) inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
+6 -3
View File
@@ -8,6 +8,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
) )
// The /api/v1/user/* endpoints require X-User-ID (RequireUserID middleware). The // The /api/v1/user/* endpoints require X-User-ID (RequireUserID middleware). The
@@ -189,13 +190,15 @@ func (s *Server) handleEnqueue(c *gin.Context) {
if !s.ensureVariantAllowed(c, uid, variant.String()) { if !s.ensureVariantAllowed(c, uid, variant.String()) {
return return
} }
if !s.ensureUnderGameLimit(c, uid) { kind := gamelimits.KindRandom
return
}
enter := s.matchmaker.Enqueue enter := s.matchmaker.Enqueue
if req.VsAI { if req.VsAI {
kind = gamelimits.KindVsAI
enter = s.matchmaker.StartVsAI enter = s.matchmaker.StartVsAI
} }
if !s.ensureUnderGameLimit(c, uid, kind) {
return
}
res, err := enter(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn) res, err := enter(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
+81 -2
View File
@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments" "scrabble/backend/internal/payments"
) )
@@ -20,11 +21,15 @@ type walletSegmentDTO struct {
// walletDTO is the user-facing wallet: the context-visible chip segments and the // walletDTO is the user-facing wallet: the context-visible chip segments and the
// context-applicable benefits (the no-ads term or forever flag, and the available hints). // context-applicable benefits (the no-ads term or forever flag, and the available hints).
// RewardChips is the chips a rewarded-video view earns in the current context (0 when rewarded is
// unavailable here — outside VK, or unconfigured); the client shows the "watch for chips" button
// only when it is positive.
type walletDTO struct { type walletDTO struct {
Segments []walletSegmentDTO `json:"segments"` Segments []walletSegmentDTO `json:"segments"`
AdsForever bool `json:"ads_forever"` AdsForever bool `json:"ads_forever"`
AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term
Hints int `json:"hints"` Hints int `json:"hints"`
RewardChips int `json:"reward_chips"`
} }
// walletBuyRequest is the POST body of a chip spend: the product to buy with chips. // walletBuyRequest is the POST body of a chip spend: the product to buy with chips.
@@ -107,8 +112,25 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
c.JSON(http.StatusOK, catalogDTOFrom(view)) c.JSON(http.StatusOK, catalogDTOFrom(view))
} }
// handleOfferPricing serves the public-offer price list (§4.4) as markdown — the two catalog tables
// projected from the active products. The render sidecar fetches it and splices it into the offer
// markdown before rendering the /offer/ page. Internal, non-public: the /api/v1/internal group is
// off the edge allow-list, and the value is served from the payments cache (no per-request query in
// the steady state). Called by the renderer, not the gateway.
func (s *Server) handleOfferPricing(c *gin.Context) {
md, err := s.payments.OfferPricing(c.Request.Context())
if err != nil {
s.log.Error("offer pricing projection failed", zap.Error(err))
c.String(http.StatusInternalServerError, "offer pricing unavailable")
return
}
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.String(http.StatusOK, md)
}
// handleWallet returns the caller's wallet — the segments and benefits visible in the current // handleWallet returns the caller's wallet — the segments and benefits visible in the current
// trusted execution context. // trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
// unconfigured), which gates the client's "watch for chips" button.
func (s *Server) handleWallet(c *gin.Context) { func (s *Server) handleWallet(c *gin.Context) {
uid, ok := userID(c) uid, ok := userID(c)
if !ok { if !ok {
@@ -125,7 +147,13 @@ func (s *Server) handleWallet(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
c.JSON(http.StatusOK, walletDTOFrom(view)) dto := walletDTOFrom(view)
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr != nil {
s.log.Warn("wallet: reward payout read failed", zap.String("account", uid.String()), zap.Error(perr))
} else {
dto.RewardChips = payout
}
c.JSON(http.StatusOK, dto)
} }
// handleWalletBuy spends chips on a chip-priced value and returns the updated wallet. It is // handleWalletBuy spends chips on a chip-priced value and returns the updated wallet. It is
@@ -162,3 +190,54 @@ func (s *Server) handleWalletBuy(c *gin.Context) {
} }
c.JSON(http.StatusOK, walletDTOFrom(view)) c.JSON(http.StatusOK, walletDTOFrom(view))
} }
// walletRewardRequest is the POST body of a rewarded-video credit: a client nonce, the idempotency
// key for a single watched view (a retry credits once).
type walletRewardRequest struct {
Nonce string `json:"nonce"`
}
// handleWalletReward credits a rewarded-video view's chips to the VK segment, client-attested and
// bounded by the config daily cap. It is VK-only and idempotent on the nonce; a reached cap answers
// reward_capped, and an unconfigured payout answers reward_unavailable. On success it returns the
// updated wallet (like a spend).
func (s *Server) handleWalletReward(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
var req walletRewardRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Nonce == "" {
abortBadRequest(c, "nonce is required")
return
}
ctx := c.Request.Context()
cxt, present, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
outcome, err := s.payments.CreditReward(ctx, uid, cxt, present, req.Nonce)
if err != nil {
s.abortErr(c, err)
return
}
if outcome.Capped {
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_capped", Message: "daily reward limit reached"}})
return
}
if outcome.Chips == 0 && !outcome.AlreadyCredited {
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_unavailable", Message: "rewarded video is not available"}})
return
}
view, err := s.payments.Wallet(ctx, uid, cxt, present)
if err != nil {
s.abortErr(c, err)
return
}
view2 := walletDTOFrom(view)
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr == nil {
view2.RewardChips = payout
}
c.JSON(http.StatusOK, view2)
}
+13
View File
@@ -25,12 +25,14 @@ import (
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
"scrabble/backend/internal/feedback" "scrabble/backend/internal/feedback"
"scrabble/backend/internal/game" "scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"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/payments"
"scrabble/backend/internal/ratewatch" "scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/render" "scrabble/backend/internal/render"
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/session" "scrabble/backend/internal/session"
"scrabble/backend/internal/social" "scrabble/backend/internal/social"
"scrabble/backend/internal/telemetry" "scrabble/backend/internal/telemetry"
@@ -99,6 +101,10 @@ type Deps struct {
// console routes are registered when the wallet surface lands. A nil Payments // console routes are registered when the wallet surface lands. A nil Payments
// omits them. // omits them.
Payments *payments.Service Payments *payments.Service
// GameLimits is the per-tier, per-kind active-game limit config, cached in memory. The
// game domain reads it through game.Service (SetGameLimits) for the new-game gate; the admin
// console reads and edits it here. A nil GameLimits omits the limits console section.
GameLimits *gamelimits.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).
@@ -109,6 +115,9 @@ type Deps struct {
// Renderer is the image-render sidecar client for the PNG export artifact. A // Renderer is the image-render sidecar client for the PNG export artifact. A
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works). // nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
Renderer *render.Client Renderer *render.Client
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
// order and Result-callback endpoints unregistered.
Robokassa robokassa.Config
} }
// Server owns the gin engine, the underlying HTTP server and the readiness // Server owns the gin engine, the underlying HTTP server and the readiness
@@ -136,6 +145,8 @@ type Server struct {
banview *banview.View banview *banview.View
ads *ads.Service ads *ads.Service
payments *payments.Service payments *payments.Service
gamelimits *gamelimits.Service
robokassa robokassa.Config
notifier notify.Publisher notifier notify.Publisher
console *adminconsole.Renderer console *adminconsole.Renderer
exportKey []byte exportKey []byte
@@ -189,6 +200,8 @@ func New(addr string, deps Deps) *Server {
banview: deps.BanView, banview: deps.BanView,
ads: deps.Ads, ads: deps.Ads,
payments: deps.Payments, payments: deps.Payments,
gamelimits: deps.GameLimits,
robokassa: deps.Robokassa,
notifier: notifier, notifier: notifier,
renderer: deps.Renderer, renderer: deps.Renderer,
http: &http.Server{Addr: addr, Handler: engine}, http: &http.Server{Addr: addr, Handler: engine},
+6
View File
@@ -65,6 +65,12 @@ func (svc *Service) IssueFriendCode(ctx context.Context, accountID uuid.UUID) (F
// ErrRequestBlocked (a block stands between the pair). A redeem bypasses any prior // ErrRequestBlocked (a block stands between the pair). A redeem bypasses any prior
// decline between the two: it clears the old row and writes a fresh friendship. // decline between the two: it clears the old row and writes a fresh friendship.
func (svc *Service) RedeemFriendCode(ctx context.Context, redeemerID uuid.UUID, code string) (uuid.UUID, error) { func (svc *Service) RedeemFriendCode(ctx context.Context, redeemerID uuid.UUID, code string) (uuid.UUID, error) {
// A guest cannot use friends: a durable-account feature (the UI hides it).
if acc, err := svc.accounts.GetByID(ctx, redeemerID); err != nil {
return uuid.UUID{}, err
} else if acc.IsGuest {
return uuid.UUID{}, ErrGuestForbidden
}
issuerID, codeID, err := svc.store.liveFriendCodeByHash(ctx, hashFriendCode(code), svc.now()) issuerID, codeID, err := svc.store.liveFriendCodeByHash(ctx, hashFriendCode(code), svc.now())
if err != nil { if err != nil {
return uuid.UUID{}, err return uuid.UUID{}, err
+7
View File
@@ -51,6 +51,13 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
if requesterID == addresseeID { if requesterID == addresseeID {
return ErrSelfRelation return ErrSelfRelation
} }
// A guest cannot use friends — friends are a durable-account feature; the UI hides the
// flow, this is the server source of truth.
if acc, err := svc.accounts.GetByID(ctx, requesterID); err != nil {
return err
} else if acc.IsGuest {
return ErrGuestForbidden
}
iBlockThem, err := svc.store.blockExists(ctx, requesterID, addresseeID) iBlockThem, err := svc.store.blockExists(ctx, requesterID, addresseeID)
if err != nil { if err != nil {
return err return err
+6
View File
@@ -42,6 +42,12 @@ func (svc *Service) RequestInGame(ctx context.Context, requesterID, addresseeID,
if requesterID == addresseeID { if requesterID == addresseeID {
return ErrSelfRelation return ErrSelfRelation
} }
// A guest cannot use friends: a durable-account feature (the UI hides it).
if acc, err := svc.accounts.GetByID(ctx, requesterID); err != nil {
return err
} else if acc.IsGuest {
return ErrGuestForbidden
}
isRobot, err := svc.accounts.IsRobot(ctx, addresseeID) isRobot, err := svc.accounts.IsRobot(ctx, addresseeID)
if err != nil { if err != nil {
return err return err
+3
View File
@@ -59,6 +59,9 @@ var (
// ErrRequestBlocked is returned when the addressee does not accept friend // ErrRequestBlocked is returned when the addressee does not accept friend
// requests (their global toggle) or a block stands between the two accounts. // requests (their global toggle) or a block stands between the two accounts.
ErrRequestBlocked = errors.New("social: the addressee is not accepting friend requests") ErrRequestBlocked = errors.New("social: the addressee is not accepting friend requests")
// ErrGuestForbidden is returned when a guest attempts a durable-only action (send a friend
// request, redeem a friend code); friends are a durable-account feature.
ErrGuestForbidden = errors.New("social: guests cannot use friends")
// ErrRequestNotFound is returned when no pending friend request matches. // ErrRequestNotFound is returned when no pending friend request matches.
ErrRequestNotFound = errors.New("social: no pending friend request") ErrRequestNotFound = errors.New("social: no pending friend request")
// ErrNoSharedGame is returned when a friend request targets someone the // ErrNoSharedGame is returned when a friend request targets someone the
+11
View File
@@ -125,6 +125,17 @@ GATEWAY_VK_APP_SECRET=
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*. # come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
GATEWAY_VK_ID_CLIENT_SECRET= GATEWAY_VK_ID_CLIENT_SECRET=
# --- Payments: Robokassa (direct RUB rail) ----------------------------------
# The shop's merchant login + the two pass phrases (Password1 signs the launch request,
# Password2 signs/verifies the Result callback). An empty login leaves the direct rail off.
# ROBOKASSA_TEST=1 runs test payments against the shop's TEST pass phrases (no real money);
# empty/0 is live. Mapped in compose to BACKEND_ROBOKASSA_*; Gitea TEST_/PROD_ secrets, with
# PROD_BACKEND_ROBOKASSA_TEST a variable so go-live is a flag flip, not a secret redeploy.
ROBOKASSA_MERCHANT_LOGIN=
ROBOKASSA_PASSWORD1=
ROBOKASSA_PASSWORD2=
ROBOKASSA_TEST=
# --- Gateway anti-abuse ------------------------------------------------------ # --- Gateway anti-abuse ------------------------------------------------------
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban # Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
# where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value # where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value
+41 -14
View File
@@ -76,6 +76,9 @@ compose binds from this directory.
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. | | `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). | | `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. | | `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. |
| `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) |
| `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. |
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC **Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
@@ -112,6 +115,9 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. | | `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. |
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. | | `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. |
| `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. | | `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. |
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). | | `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). | | `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
@@ -236,6 +242,7 @@ The main host archives Postgres continuously with **pgBackRest** to **Selectel S
(encrypted at rest, path-style addressing) so the database can be restored to any moment — (encrypted at rest, path-style addressing) so the database can be restored to any moment —
protecting the money ledger and the game state against corruption or host loss. It is the protecting the money ledger and the game state against corruption or host loss. It is the
primary recovery path; the migration-window `pg_dump` above is the secondary net. primary recovery path; the migration-window `pg_dump` above is the secondary net.
**Live on the prod main host since v1.13.0** (armed + restore-drilled 2026-07-09).
**Shape.** A daily full **base backup** (a systemd timer on the main host, `04:00`) plus **Shape.** A daily full **base backup** (a systemd timer on the main host, `04:00`) plus
**continuous WAL** archived by Postgres `archive_command` (a segment is forced at least every **continuous WAL** archived by Postgres `archive_command` (a segment is forced at least every
@@ -251,15 +258,18 @@ shipping or redeploying this stack does **not** start archiving — the artifact
armed, which is why an un-armed prod deploy can never pile WAL onto the disk. The base-backup armed, which is why an un-armed prod deploy can never pile WAL onto the disk. The base-backup
timer is provisioned by the Ansible `main` role behind `pitr_enabled` (also default off). Two timer is provisioned by the Ansible `main` role behind `pitr_enabled` (also default off). Two
Grafana alerts watch health: `WAL archiving failing` (`pg_stat_archiver_failed_count` rising) Grafana alerts watch health: `WAL archiving failing` (`pg_stat_archiver_failed_count` rising)
and `WAL archiving stalled` (`pg_stat_archiver_last_archive_age` over 30 min) — both and `WAL archiving stalled` (last archive over 30 min old **while `pg_wal_size_bytes` is growing**
absent/NaN-safe, so they stay quiet until archiving is armed. — the pg_wal-growth guard keeps an idle database, which archives nothing because it writes nothing,
from false-triggering during quiet hours) — both absent/NaN-safe, so they stay quiet until
archiving is armed.
**Assessment (owner-reviewed; the gate before the first real money).** Measured on prod **Assessment (owner-reviewed; the gate before the first real money).** Measured on prod
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At `pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
30-day retention on Selectel S3 (~2 ₽/GB·month) the archive is **under ~0.3 GB → well under 30-day retention on Selectel S3 (~2 ₽/GB·month) the archive is **under ~0.3 GB → well under
1 ₽/month** (compression halves it again); request volume is trivial. Performance impact is 1 ₽/month** (compression halves it again); request volume is trivial. Performance impact is
**negligible**: archive-push moves tiny compressed segments, and the daily full base backup **negligible**: archive-push moves tiny compressed segments, and the daily full base backup is
is a ~10 MB, sub-second job on the 2 vCPU host. Revisit both if traffic grows ~100× (watch small (the whole cluster is ~32 MB → ~3.7 MB compressed in the repo) and checkpoint-bound
(~1.5 min wall, minimal CPU/I-O) on the 2 vCPU host. Revisit if traffic grows ~100× (watch
`node_exporter` during a base backup). `node_exporter` during a base backup).
**Arming (owner-coordinated, once, before real payments).** Ships disarmed; to turn it on: **Arming (owner-coordinated, once, before real payments).** Ships disarmed; to turn it on:
@@ -276,14 +286,22 @@ is a ~10 MB, sub-second job on the 2 vCPU host. Revisit both if traffic grows ~1
2. Promote `development → master`, tag, and run **prod-deploy**. The roll recreates postgres 2. Promote `development → master`, tag, and run **prod-deploy**. The roll recreates postgres
with `archive_mode=on` behind the maintenance page. (Archive pushes fail harmlessly for the with `archive_mode=on` behind the maintenance page. (Archive pushes fail harmlessly for the
minute until step 3 creates the repository — the WAL is retained, not lost.) minute until step 3 creates the repository — the WAL is retained, not lost.)
3. On the main host, create the repository, take the first base backup, and verify: 3. On the main host, create the repository, verify archiving, take the first base backup. Run
pgBackRest **as the `postgres` OS user** (`-u postgres` — lock-dir/PGDATA consistency with
`archive-push`) and connect to the DB **as the superuser role** (`--pg1-user=scrabble`, the
`POSTGRES_USER`, not `postgres`); it inherits the container's `PGBACKREST_*` env:
```sh ```sh
docker exec scrabble-postgres pgbackrest --stanza=scrabble stanza-create docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble stanza-create
docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check
docker exec scrabble-postgres pgbackrest --stanza=scrabble check docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble --type=full backup
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble info # (info takes no --pg1-user)
``` ```
4. Enable the daily timer: `ansible-playbook site.yml -e pitr_enabled=true` (installs + starts The few `archive-push` failures logged between `archive_mode=on` and `stanza-create` are the
`pgbackrest-backup.timer` on the main host). expected transient (WAL retained, not lost); clear the counter afterwards with
`docker exec -u postgres scrabble-postgres psql -U scrabble -d scrabble -c "SELECT pg_stat_reset_shared('archiver');"`
so it does not trip the failing-archive alert.
4. Enable the daily timer: `ansible-playbook site.yml --limit main -e pitr_enabled=true`
(installs + starts `pgbackrest-backup.timer` on the main host).
5. Confirm in Grafana that the two archiving alerts are green (`last_archive_age` now tracks a 5. Confirm in Grafana that the two archiving alerts are green (`last_archive_age` now tracks a
real number, `failed_count` flat). real number, `failed_count` flat).
@@ -292,17 +310,26 @@ an **isolated, one-shot** target (a throwaway VM or a container with no ingress)
the matching Postgres major, an empty `PGDATA`, and the same `PGBACKREST_*` environment: the matching Postgres major, an empty `PGDATA`, and the same `PGBACKREST_*` environment:
```sh ```sh
pgbackrest --stanza=scrabble --type=time "--target=YYYY-MM-DD HH:MM:SS+00" --delta restore # Throwaway container from the DB image (carries pgBackRest), the live PGBACKREST_* env, an
# start Postgres; it replays WAL to the target; then verify a known row / the ledger tail # empty PGDATA and no app network; restore, recover, verify, then wipe (-v drops the data).
IMG=$(docker inspect -f '{{.Config.Image}}' scrabble-postgres)
docker exec scrabble-postgres env | grep '^PGBACKREST_' > /tmp/drill.env; echo POSTGRES_PASSWORD=drill >> /tmp/drill.env
docker run -d --name pitr-drill --env-file /tmp/drill.env --entrypoint sleep "$IMG" infinity
docker exec pitr-drill sh -c 'rm -rf /var/lib/postgresql/data/*; chown -R postgres:postgres /var/lib/postgresql/data; chmod 700 /var/lib/postgresql/data'
# --type=immediate = to the base backup's consistency point; --type=time "--target=<ts>" for PITR
docker exec -u postgres pitr-drill pgbackrest --stanza=scrabble --pg1-path=/var/lib/postgresql/data --type=immediate restore
docker exec -u postgres pitr-drill pg_ctl -D /var/lib/postgresql/data -w start
docker exec -u postgres pitr-drill psql -U scrabble -d scrabble -c "SELECT count(*) FROM backend.accounts;" # verify data intact
docker rm -fv pitr-drill; rm -f /tmp/drill.env # wipe the restored real data
``` ```
The target holds **real money + personal data** while it exists — keep it network-isolated and The target holds **real money + personal data** while it exists — keep it network-isolated and
**destroy it (wipe `PGDATA` + the instance) afterwards**. Record each drill (date, target **destroy it (wipe `PGDATA` + the instance) afterwards**. Record each drill (date, target
timestamp, outcome) here: timestamp, outcome) here:
| Date | Target timestamp | Result | | Date | Target | Result |
| --- | --- | --- | | --- | --- | --- |
| _pending first arming_ | — | — | | 2026-07-09 | latest (`--type=immediate`) | PASS — v1.13.0 arming: 31.9 MB cluster restored from the encrypted S3 repo (3.7 MB compressed), recovered to consistency, `backend.accounts` intact; drill instance wiped. |
**bot-link cert rotation:** regenerate (`deploy/gen-certs.sh /tmp/c --force`), reset the **bot-link cert rotation:** regenerate (`deploy/gen-certs.sh /tmp/c --force`), reset the
five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy
+15 -1
View File
@@ -213,6 +213,20 @@
loop: loop:
- "" - ""
- config - config
- certs
- dumps - dumps
- images - images
# The certs dir holds the reverse-mTLS bot-link keypair, bind-mounted into the gateway (and
# backend) which run as the distroless nonroot UID 65532 — not the deploy user. The dir must be
# traversable by "other" (0755) or the nonroot process cannot open the 0644 keypair and crash-loops
# at startup ("mtls: load server keypair: ... permission denied") — a latent failure that only bites
# on a container restart (e.g. a host reboot), not while a long-running container holds the keypair
# in memory. The keys themselves are 0644 by design (see the gateway compose); the host is
# single-tenant and SSH-access-controlled, so a traversable certs dir adds no meaningful exposure.
- name: Create the scrabble certs directory (traversable by the nonroot gateway UID)
ansible.builtin.file:
path: "{{ scrabble_base_dir }}/certs"
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0755"
@@ -10,6 +10,8 @@ Requires=docker.service
[Service] [Service]
Type=oneshot Type=oneshot
# docker exec runs as the image's postgres user and inherits the container's PGBACKREST_* # docker exec defaults to root, so run as the postgres OS user (-u postgres) for lock-dir and
# environment (the S3 repository + cipher), so no repository config is needed on the host. # PGDATA consistency with archive-push, and connect to the database as the superuser role via
ExecStart=/usr/bin/docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup # --pg1-user: that role is the POSTGRES_USER (scrabble), not "postgres". It inherits the
# container's PGBACKREST_* environment (the S3 repository + cipher), so no host-side config.
ExecStart=/usr/bin/docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble --type=full backup
+10 -1
View File
@@ -86,7 +86,7 @@
# The game SPA and the Connect edge are served by the gateway. Strip any # The game SPA and the Connect edge are served by the gateway. Strip any
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
# tag the honeypot block sets below (a client cannot self-tag a real request). # tag the honeypot block sets below (a client cannot self-tag a real request).
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/* @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /pay/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
handle @gateway { handle @gateway {
reverse_proxy gateway:8081 { reverse_proxy gateway:8081 {
header_up -X-Scrabble-Honeypot header_up -X-Scrabble-Honeypot
@@ -107,6 +107,15 @@
} }
} }
# The public offer page is rendered by the render sidecar: it splices the live catalog price
# list (backend, internal) into the committed ui/legal/offer_ru.md and returns the HTML. Only
# /offer/ is exposed here — the sidecar's /render stays off this allow-list, internal-only. Kept
# disjoint from the landing/app paths so the catch-all below never shadows it.
@offer path /offer /offer/*
handle @offer {
reverse_proxy renderer:8090
}
# Everything else — the public landing at / and any stray path — is static. # Everything else — the public landing at / and any stray path — is static.
handle { handle {
reverse_proxy landing:80 reverse_proxy landing:80
+10 -3
View File
@@ -3,8 +3,10 @@
# docker compose -f docker-compose.bot.yml up -d # docker compose -f docker-compose.bot.yml up -d
# #
# The bot egresses to the Bot API directly (no VPN sidecar) and dials the main host's # The bot egresses to the Bot API directly (no VPN sidecar) and dials the main host's
# published bot-link :9443 over mTLS. It exports no telemetry — otelcol lives on the # published bot-link :9443 over mTLS. It exports no OTLP telemetry — otelcol lives on the
# main host and is unreachable from here — so observe it via `docker logs` on this host. # main host and is unreachable from here — but it reports its Bot API health up the bot-link,
# which the gateway turns into metrics + alerts on the main host (docs/ARCHITECTURE.md); `docker
# logs` on this host is the local detail view.
# Values come from the prod-deploy workflow (PROD_ secrets/variables); BOT_IMAGE is the # Values come from the prod-deploy workflow (PROD_ secrets/variables); BOT_IMAGE is the
# pushed registry tag and BOTLINK_GATEWAY_ADDR is the main host's <ip>:9443. # pushed registry tag and BOTLINK_GATEWAY_ADDR is the main host's <ip>:9443.
name: scrabble-bot name: scrabble-bot
@@ -28,6 +30,10 @@ services:
# the manage-topics and delete-messages rights. # the manage-topics and delete-messages rights.
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-} TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
TELEGRAM_SUPPORT_STATE_DIR: /data TELEGRAM_SUPPORT_STATE_DIR: /data
# The Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
# Stars rail; empty disables it. The rail stays inert until a chip pack carries an XTR (Stars)
# price, so it is safe to leave on — seeding a Stars price in the admin is the real go-live.
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_DIR:-/data}
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-} TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-} TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
@@ -46,7 +52,8 @@ services:
TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info} TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
TELEGRAM_SERVICE_NAME: scrabble-telegram-bot TELEGRAM_SERVICE_NAME: scrabble-telegram-bot
# No telemetry export: otelcol is on the main host, unreachable from here. # No OTLP export: otelcol is on the main host, unreachable from here. Bot API health rides the
# bot-link instead (the gateway exposes it as metrics); see docs/ARCHITECTURE.md.
TELEGRAM_OTEL_TRACES_EXPORTER: none TELEGRAM_OTEL_TRACES_EXPORTER: none
TELEGRAM_OTEL_METRICS_EXPORTER: none TELEGRAM_OTEL_METRICS_EXPORTER: none
GOMAXPROCS: "1" GOMAXPROCS: "1"
+23
View File
@@ -84,6 +84,9 @@ services:
logging: *default-logging logging: *default-logging
environment: environment:
RENDERER_PORT: "8090" RENDERER_PORT: "8090"
# The offer page (GET /offer/) fetches the live catalog price list from the backend's internal
# endpoint and splices it into the committed offer markdown. Backend down ⇒ /offer/ returns 502.
RENDERER_BACKEND_URL: http://backend:8080
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s interval: 10s
@@ -161,6 +164,13 @@ services:
# recipient(s) (comma-separated allowed). Both empty disables the alert worker. # recipient(s) (comma-separated allowed). Both empty disables the alert worker.
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-} BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-} BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-}
# Direct-rail (Robokassa) payment intake: the merchant login + Password1/Password2 and the
# test-mode flag (deploy env: TEST_/PROD_BACKEND_ROBOKASSA_*). An empty login leaves the
# direct order and Result-callback endpoints unregistered (the rail is off).
BACKEND_ROBOKASSA_MERCHANT_LOGIN: ${ROBOKASSA_MERCHANT_LOGIN:-}
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-}
# The dictionary lives on a named volume seeded from the image on first boot # The dictionary lives on a named volume seeded from the image on first boot
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume # (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
# inherits). The admin console writes new version subdirectories here, and the # inherits). The admin console writes new version subdirectories here, and the
@@ -200,6 +210,9 @@ services:
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-} VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev} VITE_APP_VERSION: ${APP_VERSION:-dev}
# The rewarded-ad test stub (1 = a toast instead of a real ad; the test contour only, empty
# elsewhere so production shows real ads).
VITE_ADS_STUB: ${VITE_ADS_STUB:-}
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag). # Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
VERSION: ${APP_VERSION:-dev} VERSION: ${APP_VERSION:-dev}
restart: unless-stopped restart: unless-stopped
@@ -241,6 +254,12 @@ services:
# secret; empty (unset secret) leaves the trap off. # secret; empty (unset secret) leaves the trap off.
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false} GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-} GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
# Community IP blocklist (Spamhaus DROP): prod-only, off unless the real client IP is visible
# (the shared-NAT test contour would self-block). Enabled + fed the feed URL + allowlist by the
# prod deploy (write-prod-env.sh); the refresh/staleness windows use the built-in defaults.
GATEWAY_BLOCKLIST_ENABLED: ${GATEWAY_BLOCKLIST_ENABLED:-false}
GATEWAY_BLOCKLIST_URL: ${GATEWAY_BLOCKLIST_URL:-}
GATEWAY_BLOCKLIST_ALLOW: ${GATEWAY_BLOCKLIST_ALLOW:-}
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info} GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
GATEWAY_SERVICE_NAME: scrabble-gateway GATEWAY_SERVICE_NAME: scrabble-gateway
GATEWAY_OTEL_TRACES_EXPORTER: otlp GATEWAY_OTEL_TRACES_EXPORTER: otlp
@@ -378,6 +397,10 @@ services:
# set the bot must be an admin there with the manage-topics and delete-messages rights. # set the bot must be an admin there with the manage-topics and delete-messages rights.
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-} TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
TELEGRAM_SUPPORT_STATE_DIR: /data TELEGRAM_SUPPORT_STATE_DIR: /data
# The Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
# Stars rail (pre_checkout + successful_payment + the durable outbox); empty disables it. The
# rail stays inert until a chip pack carries an XTR (Stars) price, so it is safe to leave on.
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_DIR:-/data}
# The optional standalone promo bot (its own token) answering /start with a button # The optional standalone promo bot (its own token) answering /start with a button
# into the main bot's app. Empty disables it; when set it needs the main bot's # into the main bot's app. Empty disables it; when set it needs the main bot's
# @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK). # @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
+45
View File
@@ -0,0 +1,45 @@
{
"uid": "scrabble-bot",
"title": "Scrabble — Telegram bot",
"tags": ["scrabble"],
"timezone": "",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"panels": [
{
"type": "stat",
"title": "Bot connected",
"description": "botlink_connected_bots: bots currently holding the gateway bot-link (1 = healthy).",
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "max(botlink_connected_bots)" }]
},
{
"type": "stat",
"title": "Last Bot API OK (age)",
"description": "Seconds since the bot's most recent successful Bot API call. Grows unbounded if the bot wedges.",
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "time() - max(bot_tg_last_ok_unix)" }]
},
{
"type": "timeseries",
"title": "Bot API errors/s by kind",
"description": "bot_tg_errors_total by kind: connect (getUpdates), api (other sends), rate_limited (429). 429 should stay ~0.",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum by (kind) (rate(bot_tg_errors_total[5m]))", "legendFormat": "{{kind}}" }]
},
{
"type": "timeseries",
"title": "Bot-link commands/s by result",
"description": "botlink_commands_total by result: delivered / not_delivered / dropped / error. Sustained not_delivered or error means gateway sends are failing to reach Telegram.",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum by (result) (rate(botlink_commands_total[5m]))", "legendFormat": "{{result}}" }]
}
]
}
@@ -53,6 +53,31 @@
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(go_memory_used) by (service_name)", "legendFormat": "{{service_name}}" }] "targets": [{ "refId": "A", "expr": "sum(go_memory_used) by (service_name)", "legendFormat": "{{service_name}}" }]
},
{
"type": "stat",
"title": "Blocklist entries",
"description": "CIDR ranges in the active community IP blocklist feed (0 when disabled or not yet fetched).",
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 13 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "max(gateway_blocklist_entries)" }]
},
{
"type": "stat",
"title": "Blocklist feed age",
"description": "Seconds since the community IP blocklist feed was last successfully fetched. Grows if the fetch is failing; the feed is dropped fail-open once stale.",
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 13 },
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "max(gateway_blocklist_age_seconds)" }]
},
{
"type": "timeseries",
"title": "Blocklist blocks/s",
"description": "Requests refused at the edge by the community IP blocklist (gateway_blocklist_blocked_total).",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(gateway_blocklist_blocked_total[5m]))" }]
} }
] ]
} }
+124 -4
View File
@@ -108,6 +108,32 @@ groups:
labels: { severity: critical } labels: { severity: critical }
annotations: { summary: 'Edge TLS cert has under 20 days left — Caddy ACME renewal may have failed.' } annotations: { summary: 'Edge TLS cert has under 20 days left — Caddy ACME renewal may have failed.' }
# The community IP blocklist feed has not refreshed in over a day (the gateway re-fetches every
# few hours). Absent/NaN-safe: the age gauge appears only once a feed has loaded, so a disabled
# or never-fetched blocklist stays quiet. The feed itself is dropped (fail-open) at its
# max-staleness window; this warns well before that so the operator can fix the fetch.
- uid: blocklist_stale
title: IP blocklist feed not refreshing
condition: C
for: 15m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: gateway_blocklist_age_seconds, instant: true }
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [86400] }
labels: { severity: warning }
annotations: { summary: 'The community IP blocklist feed has not refreshed in over 24h — the fetch is failing; it will be dropped (fail-open) once stale. Check the gateway blocklist refresher logs and the feed URL.' }
- orgId: 1 - orgId: 1
name: scrabble-host name: scrabble-host
folder: Alerts folder: Alerts
@@ -245,8 +271,14 @@ groups:
conditions: conditions:
- evaluator: { type: gt, params: [0] } - evaluator: { type: gt, params: [0] }
labels: { severity: critical } labels: { severity: critical }
annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble check`.' } annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
# Fires only when the last archive is >30 min old AND pg_wal is actually growing (WAL is being
# produced but not archived). A genuinely idle database archives nothing — Postgres does not
# force-switch an empty segment on archive_timeout — so `last_archive_age` alone false-triggers
# during quiet hours; the pg_wal-growth guard removes that. `and on()` bridges the differing
# label sets (last_archive_age has a server label, the pg_wal gauge does not); delta() (not
# increase) suits the pg_wal_size_bytes gauge. Real archive failures are caught by pg_archive_failing.
- uid: pg_archive_stalled - uid: pg_archive_stalled
title: WAL archiving stalled title: WAL archiving stalled
condition: C condition: C
@@ -255,11 +287,11 @@ groups:
execErrState: OK execErrState: OK
data: data:
- refId: A - refId: A
relativeTimeRange: { from: 300, to: 0 } relativeTimeRange: { from: 2100, to: 0 }
datasourceUid: prometheus datasourceUid: prometheus
model: model:
refId: A refId: A
expr: pg_stat_archiver_last_archive_age expr: (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16777216)
instant: true instant: true
- refId: C - refId: C
datasourceUid: __expr__ datasourceUid: __expr__
@@ -270,4 +302,92 @@ groups:
conditions: conditions:
- evaluator: { type: gt, params: [1800] } - evaluator: { type: gt, params: [1800] }
labels: { severity: critical } labels: { severity: critical }
annotations: { summary: 'No WAL segment archived for over 30 minutes (archive_timeout is 5m) — archiving is stalled; the PITR recovery point is frozen and pg_wal may grow.' } annotations: { summary: 'No WAL archived for over 30 minutes while pg_wal keeps growing — archiving is genuinely stalled; the PITR recovery point is frozen and pg_wal will fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
# Remote Telegram bot health. The bot runs on its own host and exports no telemetry of its own; the
# gateway observes it through the bot-link (botlink_connected_bots) and the health it reports over
# that stream (bot_tg_*). All absent/NaN-safe: before a bot ever connects the metrics are absent, so
# noDataState OK keeps them quiet on a fresh contour. The alert email does NOT go through the bot, so
# a bot-down alert is deliverable.
- orgId: 1
name: scrabble-bot
folder: Alerts
interval: 1m
rules:
- uid: bot_disconnected
title: Telegram bot disconnected
condition: C
for: 5m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: botlink_connected_bots, instant: true }
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: lt, params: [1] }
labels: { severity: critical }
annotations: { summary: 'No Telegram bot is connected to the gateway bot-link — out-of-app push and admin sends are down. Check the bot host.' }
# Positive liveness: a bot is connected but its last successful Bot API call is over 5 minutes
# old — the getUpdates long-poll returns every ~minute even when idle, so a stale stamp means the
# bot is wedged (no errors, no traffic). Guarded by "and connected" so a mere disconnect (owned by
# bot_disconnected) does not double-fire.
- uid: bot_tg_stale
title: Telegram bot not reaching the Bot API
condition: C
for: 5m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 600, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: (time() - bot_tg_last_ok_unix) and on() (botlink_connected_bots >= 1)
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [300] }
labels: { severity: critical }
annotations: { summary: 'A connected Telegram bot has not reached the Bot API in over 5 minutes — the update poll is wedged. Check the bot host and Telegram reachability.' }
# 429s should be ~never once the bot honours Retry-After; any sustained rate-limiting is a symptom
# (a send loop, a misbehaving path) worth investigating.
- uid: bot_tg_rate_limited
title: Telegram bot rate-limited (429)
condition: C
for: 5m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: increase(bot_tg_errors_total{kind="rate_limited"}[15m])
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [0] }
labels: { severity: warning }
annotations: { summary: 'The Telegram bot is being rate-limited (HTTP 429). It should honour Retry-After, so sustained 429s point to a send loop or a hot path.' }

Some files were not shown because too many files have changed in this diff Show More