From 7123ba439da5a3fed815d35ba2db0a4c9ec61c2e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 02:03:59 +0200 Subject: [PATCH 01/38] fix(deploy): run the base-backup timer's pgBackRest as the postgres role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PLAN.md | 30 +++++++------ deploy/README.md | 43 +++++++++++++------ .../templates/pgbackrest-backup.service.j2 | 8 ++-- 3 files changed, 52 insertions(+), 29 deletions(-) diff --git a/PLAN.md b/PLAN.md index 0e22e08..f182eb9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -33,7 +33,7 @@ status — without re-deriving decisions. | E1 | Trusted platform signal | 1 | DONE | | E2 | Currency + benefit core | 1 | DONE | | E3 | Wallet UI | 1 | DONE | -| E4 | Durability (PITR) | 2 | WIP | +| E4 | Durability (PITR) | 2 | DONE | | E5 | Payment intake | 2 | TODO | | E6 | Ads | 2 | TODO | | E7 | Admin & reports | 2 | TODO | @@ -446,8 +446,8 @@ noun agreement). Svelte whitespace/`$state` naming gotchas apply. ## E4 — Durability (PITR) -**Status:** WIP — repo artifacts landed; **prod arming pending** (owner-coordinated, before -E5). · **Release 2** · depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4). +**Status:** DONE — armed + restore-drilled on prod (v1.13.0, 2026-07-09). · **Release 2** · +depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4). **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. @@ -479,24 +479,26 @@ real money** is accepted (E5 prod). Protects both money and game data. `-n backend`, silently excluding `payments`); manual-restore runbook updated. - Full PITR runbook + arming sequence + recorded assessment in `deploy/README.md`. -**Prod arming (SEPARATE — owner-coordinated, before E5; NOT the artifacts PR).** Owner creates -the Selectel S3 bucket + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`); -then promote `development → master` → `prod-deploy` (archive_mode on behind the maintenance -window), `pgbackrest stanza-create` + first base backup + `check`, re-run Ansible with -`-e pitr_enabled=true`, and run the restore drill on an isolated one-shot target. Exact steps: -`deploy/README.md` (point-in-time recovery — arming). +**Prod arming (completed 2026-07-09 with the v1.13.0 release).** Owner created the Selectel S3 +bucket (`erudite`, ru-6) + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`); +promoted `development → master` → `prod-deploy` (archive_mode on behind the maintenance window), +then `stanza-create` + first base backup (31.9 MB cluster → 3.7 MB in the repo) + `check`, the +Ansible `-e pitr_enabled=true` timer, and a restore drill on an isolated one-shot target (data +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), 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). -**Done-criteria.** Artifacts merged with archiving inert. **Fully done** at arming: WAL -archiving live on prod PG; a timed restore verified; cost + perf assessment reviewed; runbook -current in `deploy/README.md`. +**Done-criteria (met).** WAL archiving live on prod PG (v1.13.0); a restore verified on an +isolated one-shot target; cost + perf assessment reviewed; runbook current in +`deploy/README.md`. **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 -window). Migrations stay expand-contract so image rollback remains DB-safe alongside PITR. +from the S3 keys. Manual/timer pgBackRest runs use `docker exec -u postgres … --pg1-user=scrabble` +(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. --- diff --git a/deploy/README.md b/deploy/README.md index f1dc886..c9d7170 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -236,6 +236,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 — 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. +**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 **continuous WAL** archived by Postgres `archive_command` (a segment is forced at least every @@ -258,8 +259,9 @@ absent/NaN-safe, so they stay quiet until archiving is armed. `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 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 -is a ~10 MB, sub-second job on the 2 vCPU host. Revisit both if traffic grows ~100× (watch +**negligible**: archive-push moves tiny compressed segments, and the daily full base backup is +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). **Arming (owner-coordinated, once, before real payments).** Ships disarmed; to turn it on: @@ -276,14 +278,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 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.) -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 - docker exec scrabble-postgres pgbackrest --stanza=scrabble stanza-create - docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup - docker exec scrabble-postgres pgbackrest --stanza=scrabble check + docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble stanza-create + docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=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 - `pgbackrest-backup.timer` on the main host). + The few `archive-push` failures logged between `archive_mode=on` and `stanza-create` are the + 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 real number, `failed_count` flat). @@ -292,17 +302,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: ```sh -pgbackrest --stanza=scrabble --type=time "--target=YYYY-MM-DD HH:MM:SS+00" --delta restore -# start Postgres; it replays WAL to the target; then verify a known row / the ledger tail +# Throwaway container from the DB image (carries pgBackRest), the live PGBACKREST_* env, an +# 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=" 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 **destroy it (wipe `PGDATA` + the instance) afterwards**. Record each drill (date, target 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 five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy diff --git a/deploy/ansible/roles/main/templates/pgbackrest-backup.service.j2 b/deploy/ansible/roles/main/templates/pgbackrest-backup.service.j2 index 7565a09..389d893 100644 --- a/deploy/ansible/roles/main/templates/pgbackrest-backup.service.j2 +++ b/deploy/ansible/roles/main/templates/pgbackrest-backup.service.j2 @@ -10,6 +10,8 @@ Requires=docker.service [Service] Type=oneshot -# docker exec runs as the image's postgres user and inherits the container's PGBACKREST_* -# environment (the S3 repository + cipher), so no repository config is needed on the host. -ExecStart=/usr/bin/docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup +# docker exec defaults to root, so run as the postgres OS user (-u postgres) for lock-dir and +# PGDATA consistency with archive-push, and connect to the database as the superuser role via +# --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 From 625fc3135aaad0a900062b9f1871f55baef73b25 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 16:03:50 +0200 Subject: [PATCH 02/38] feat(ui): serve the public offer at /offer/ with a landing footer link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitea/workflows/ci.yaml | 16 +++++ deploy/landing/Caddyfile | 21 +++++- docs/ARCHITECTURE.md | 3 +- ui/e2e/landing.spec.ts | 11 +++ ui/legal/offer_ru.md | 146 +++++++++++++++++++++++++++++++++++++++ ui/package.json | 1 + ui/pnpm-lock.yaml | 10 +++ ui/src/Landing.svelte | 15 +++- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/offer.test.ts | 23 ++++++ ui/src/lib/offer.ts | 93 +++++++++++++++++++++++++ ui/tsconfig.node.json | 2 +- ui/vite.config.ts | 18 +++++ 14 files changed, 355 insertions(+), 6 deletions(-) create mode 100644 ui/legal/offer_ru.md create mode 100644 ui/src/lib/offer.test.ts create mode 100644 ui/src/lib/offer.ts diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c7a2aed..811bcfa 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -497,6 +497,22 @@ jobs: docker logs --tail 50 scrabble-backend || true exit 1 + - name: Probe the /offer/ public offer page is served + run: | + set -u + # /offer/ is a static page baked into the landing image (rendered from + # ui/legal/offer_ru.md). If the landing Caddyfile stops routing it, the request + # silently falls through to the landing shell (also 200) — so assert offer-specific + # content, never just the status. + out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)" + if echo "$out" | grep -q "290210610742"; then + echo "ok: /offer/ serves the public offer page" + else + echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)" + docker logs --tail 50 scrabble-landing || true + exit 1 + fi + - name: Probe the /dict edge route reaches the gateway run: | set -u diff --git a/deploy/landing/Caddyfile b/deploy/landing/Caddyfile index 92ff744..d8747b7 100644 --- a/deploy/landing/Caddyfile +++ b/deploy/landing/Caddyfile @@ -18,10 +18,25 @@ @shell not path /assets/* header @shell Cache-Control "no-cache" + # The static public offer page, rendered from ui/legal/offer_ru.md at build + # time into dist/offer/index.html (vite emit-offer plugin). Served with its own + # index so /offer/ resolves to /srv/offer/index.html rather than falling to the + # landing shell below (whose index is landing.html). A bare /offer redirects in. + handle /offer { + redir * /offer/ permanent + } + handle /offer/* { + file_server { + index index.html + } + } + # An unknown path falls back to the landing shell (the gateway's old "/" # behaviour); "/" itself resolves through the index below. - try_files {path} /landing.html - file_server { - index landing.html + handle { + try_files {path} /landing.html + file_server { + index landing.html + } } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1dccaa8..10c6621 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1328,7 +1328,8 @@ in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth a routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered **admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the -catch-all — notably the landing at `/` — goes to the landing container. The +catch-all — notably the landing at `/`, plus the static public offer at `/offer/` +(rendered from `ui/legal/offer_ru.md` at build time) — goes to the landing container. The **Telegram validator** runs as a separate container with **no public ingress**, answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts index ebb3fe4..4ce19bf 100644 --- a/ui/e2e/landing.spec.ts +++ b/ui/e2e/landing.spec.ts @@ -58,3 +58,14 @@ test('the landing shows a web-version entry linking /app/, with a caption', asyn expect(await web.getAttribute('href')).toContain('/app/'); await expect(page.getByText('Веб-версия')).toBeVisible(); }); + +// The footer carries a public-offer link to the static /offer/ page (rendered from +// ui/legal/offer_ru.md at build; the legal document a purchase accepts). +test('the landing footer links to the public offer at /offer/', async ({ page }) => { + await page.goto('/landing.html'); + await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); // Russian by default + + const offer = page.getByRole('link', { name: 'Публичная оферта' }); + await expect(offer).toBeVisible(); + expect(await offer.getAttribute('href')).toBe('/offer/'); +}); diff --git a/ui/legal/offer_ru.md b/ui/legal/offer_ru.md new file mode 100644 index 0000000..fb5a7f5 --- /dev/null +++ b/ui/legal/offer_ru.md @@ -0,0 +1,146 @@ +# Публичная оферта + +Публичная оферта о заключении договора купли-продажи. + +## 1. Общие положения + +В настоящей Публичной оферте содержатся условия заключения Договора купли-продажи (далее по тексту — «Договор купли-продажи» и/или «Договор»). Настоящей офертой признается предложение, адресованное одному или нескольким конкретным лицам, которое достаточно определенно и выражает намерение лица, сделавшего предложение, считать себя заключившим Договор с адресатом, которым будет принято предложение. + +Совершение указанных в настоящей Оферте действий является подтверждением согласия обеих Сторон заключить Договор купли-продажи на условиях, в порядке и объеме, изложенных в настоящей Оферте. + +Нижеизложенный текст Публичной оферты является официальным публичным предложением Продавца, адресованный заинтересованному кругу лиц заключить Договор купли-продажи в соответствии с положениями пункта 2 статьи 437 Гражданского кодекса РФ. + +Договор купли-продажи считается заключенным и приобретает силу с момента совершения Сторонами действий, предусмотренных в настоящей Оферте, и, означающих безоговорочное, а также полное принятие всех условий настоящей Оферты без каких-либо изъятий или ограничений на условиях присоединения. + +### Термины и определения + +**Договор** — текст настоящей Оферты с Приложениями, являющимися неотъемлемой частью настоящей Оферты, акцептованный Покупателем путем совершения конклюдентных действий, предусмотренных настоящей Офертой. + +**Конклюдентные действия** — это поведение, которое выражает согласие с предложением контрагента заключить, изменить или расторгнуть договор. Действия состоят в полном или частичном выполнении условий, которые предложил контрагент. + +**Сайт Продавца в сети «Интернет»** — совокупность программ для электронных вычислительных машин и иной информации, содержащейся в информационной системе, доступ к которой обеспечивается посредством сети «Интернет» по доменному имени и сетевому адресу: `erudit-game.ru`. + +**Стороны Договора (Стороны)** — Продавец и Покупатель. + +**Товар** — товаром по договору купли-продажи могут быть любые вещи с соблюдением правил, предусмотренных статьей 129 Гражданского кодекса РФ. + +## 2. Предмет Договора + +**2.1.** По настоящему Договору Продавец обязуется передать вещь (Товар) в собственность Покупателя, а Покупатель обязуется принять Товар и уплатить за него определенную денежную сумму. + +**2.2.** Наименование, количество, а также ассортимент Товара, его стоимость, порядок доставки и иные условия определяются на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет» `erudit-game.ru`. + +**2.3.** Акцепт настоящей Оферты выражается в совершении конклюдентных действий, в частности: + +- действиях, связанных с регистрацией учетной записи на Сайте Продавца в сети «Интернет» при наличии необходимости регистрации учетной записи; +- путем составления и заполнения заявки на оформление заказа Товара; +- путем сообщения требуемых для заключения Договора сведений по телефону, электронной почте, указанными на сайте Продавца в сети «Интернет», в том числе, при обратном звонке Продавца по заявке Покупателя; +- оплаты Товара Покупателем. + +Данный перечень не является исчерпывающим, могут быть и другие действия, которые ясно выражают намерение лица принять предложение контрагента. + +## 3. Права и обязанности Сторон + +### 3.1. Права и обязанности Продавца + +**3.1.1.** Продавец вправе требовать оплаты Товаров и их доставки в порядке и на условиях, предусмотренных Договором; + +**3.1.2.** Отказать в заключении Договора на основании настоящей Оферты Покупателю в случае его недобросовестного поведения, в частности, в случае: + +- более 2 (Двух) отказов от Товаров надлежащего качества в течение года; +- предоставления заведомо недостоверной персональной информации; +- возврата испорченного Покупателем Товара или Товара, бывшего в употреблении; +- иных случаях недобросовестного поведения, свидетельствующих о заключении Покупателем Договора с целью злоупотребления правами, и отсутствия обычной экономической цели Договора — приобретения Товара. + +**3.1.3.** Продавец обязуется передать Покупателю Товар надлежащего качества и в надлежащей упаковке; + +**3.1.4.** Передать Товар свободным от прав третьих лиц; + +**3.1.5.** Организовать доставку Товаров Покупателю; + +**3.1.6.** Предоставить Покупателю всю необходимую информацию в соответствии с требованиями действующего законодательства РФ и настоящей Оферты; + +### 3.2. Права и обязанности Покупателя + +**3.2.1.** Покупатель вправе требовать передачи Товара в порядке и на условиях, предусмотренных Договором. + +**3.2.2.** Требовать предоставления всей необходимой информации в соответствии с требованиями действующего законодательства РФ и настоящей Оферты; + +**3.2.3.** Отказаться от Товара по основаниям, предусмотренным Договором и действующим законодательством Российской Федерации. + +**3.2.4.** Покупатель обязуется предоставить Продавцу достоверную информацию, необходимую для надлежащего исполнения Договора; + +**3.2.5.** Принять и оплатить Товар в соответствии с условиями Договора; + +**3.2.6.** Покупатель гарантирует, что все условия Договора ему понятны; Покупатель принимает условия без оговорок, а также в полном объеме. + +## 4. Цена и порядок расчетов + +**4.1.** Стоимость, а также порядок оплаты Товара определяется на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет»: `erudit-game.ru`. + +**4.2.** Все расчеты по Договору производятся в безналичном порядке. + +## 5. Обмен и возврат Товара + +**5.1.** Покупатель вправе осуществить возврат (обмен) Продавцу Товара, приобретенный дистанционным способом, за исключением перечня товаров, не подлежащих обмену и возврату согласно действующему законодательству Российской Федерации. Условия, сроки и порядок возврата Товара надлежащего и ненадлежащего качества установлены в соответствии с Гражданским кодексом РФ, Закона РФ от 07.02.1992 N 2300-1 «О защите прав потребителей», Правил, утвержденных Постановлением Правительства РФ от 31.12.2020 N 2463. + +**5.2.** Требование Покупателя об обмене либо о возврате Товара рассматривается индивидуально при условии неиспользования приобретенного товара и наличии уважительных причин (технический сбой, ошибка в описании товара). + +## 6. Конфиденциальность и безопасность + +**6.1.** При реализации настоящего Договора Стороны обеспечивают конфиденциальность и безопасность персональных данных в соответствии с актуальной редакцией ФЗ от 27.07.2006 г. № 152-ФЗ «О персональных данных» и ФЗ от 27.07.2006 г. № 149-ФЗ «Об информации, информационных технологиях и о защите информации». + +**6.2.** Стороны обязуются сохранять конфиденциальность информации, полученной в ходе исполнения настоящего Договора, и принять все возможные меры, чтобы предохранить полученную информацию от разглашения. + +**6.3.** Под конфиденциальной информацией понимается любая информация, передаваемая Продавцом и Покупателем в процессе реализации Договора и подлежащая защите, исключения указаны ниже. + +**6.4.** Такая информация может содержаться в предоставляемых Продавцом локальных нормативных актах, договорах, письмах, отчетах, аналитических материалах, результатах исследований, схемах, графиках, спецификациях и других документах, оформленных как на бумажных, так и на электронных носителях. + +## 7. Форс-мажор + +**7.1.** Стороны освобождаются от ответственности за неисполнение или ненадлежащее исполнение обязательств по Договору, если надлежащее исполнение оказалось невозможным вследствие непреодолимой силы, то есть чрезвычайных и непредотвратимых при данных условиях обстоятельств, под которыми понимаются: запретные действия властей, эпидемии, блокада, эмбарго, землетрясения, наводнения, пожары или другие стихийные бедствия. + +**7.2.** В случае наступления этих обстоятельств Сторона обязана в течение 30 (Тридцати) рабочих дней уведомить об этом другую Сторону. + +**7.3.** Документ, выданный уполномоченным государственным органом, является достаточным подтверждением наличия и продолжительности действия непреодолимой силы. + +**7.4.** Если обстоятельства непреодолимой силы продолжают действовать более 60 (Шестидесяти) рабочих дней, то каждая Сторона вправе отказаться от настоящего Договора в одностороннем порядке. + +## 8. Ответственность Сторон + +**8.1.** В случае неисполнения и/или ненадлежащего исполнения своих обязательств по Договору, Стороны несут ответственность в соответствии с условиями настоящей Оферты. + +**8.2.** Сторона, не исполнившая или ненадлежащим образом исполнившая обязательства по Договору, обязана возместить другой Стороне причиненные такими нарушениями убытки. + +## 9. Срок действия настоящей Оферты + +**9.1.** Оферта вступает в силу с момента размещения на Сайте Продавца и действует до момента её отзыва Продавцом. + +**9.2.** Продавец оставляет за собой право внести изменения в условия Оферты и/или отозвать Оферту в любой момент по своему усмотрению. Сведения об изменении или отзыве Оферты доводятся до Покупателя по выбору Продавца посредством размещения на сайте Продавца в сети «Интернет», в Личном кабинете Покупателя, либо путем направления соответствующего уведомления на электронный или почтовый адрес, указанный Покупателем при заключении Договора или в ходе его исполнения. + +**9.3.** Договор вступает в силу с момента Акцепта условий настоящей Оферты Покупателем и действует до полного исполнения Сторонами обязательств по Договору. + +**9.4.** Изменения, внесенные Продавцом в Договор и опубликованные на сайте в форме актуализированной Оферты, считаются принятыми Покупателем в полном объеме. + +## 10. Дополнительные условия + +**10.1.** Договор, его заключение и исполнение регулируется действующим законодательством Российской Федерации. Все вопросы, не урегулированные настоящей Офертой или урегулированные не полностью, регулируются в соответствии с материальным правом Российской Федерации. + +**10.2.** В случае возникновения спора, который может возникнуть между Сторонами в ходе исполнения ими своих обязательств по Договору, заключенному на условиях настоящей Оферты, Стороны обязаны урегулировать спор мирным путем до начала судебного разбирательства. + +Судебное разбирательство осуществляется в соответствии с законодательством Российской Федерации. + +Споры или разногласия, по которым Стороны не достигли договоренности, подлежат разрешению в соответствии с законодательством РФ. Досудебный порядок урегулирования спора является обязательным. + +**10.3.** В качестве языка Договора, заключаемого на условиях настоящей Оферты, а также языка, используемого при любом взаимодействии Сторон (включая ведение переписки, предоставление требований / уведомлений / разъяснений, предоставление документов и т. д.), Стороны определили русский язык. + +**10.4.** Все документы, подлежащие предоставлению в соответствии с условиями настоящей Оферты, должны быть составлены на русском языке либо иметь перевод на русский язык, удостоверенный в установленном порядке. + +**10.5.** Бездействие одной из Сторон в случае нарушения условий настоящей Оферты не лишает права заинтересованной Стороны осуществлять защиту своих интересов позднее, а также не означает отказа от своих прав в случае совершения одной из Сторон подобных либо сходных нарушений в будущем. + +**10.6.** Если на Сайте Продавца в сети «Интернет» есть ссылки на другие веб-сайты и материалы третьих лиц, такие ссылки размещены исключительно в целях информирования, и Продавец не имеет контроля в отношении содержания таких сайтов или материалов. Продавец не несет ответственность за любые убытки или ущерб, которые могут возникнуть в результате использования таких ссылок. + +## 11. Реквизиты Продавца + +Денисов Илья Аркадьевич +ИНН: 290210610742 diff --git a/ui/package.json b/ui/package.json index 448202f..2af78a8 100644 --- a/ui/package.json +++ b/ui/package.json @@ -28,6 +28,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.0", "@types/node": "^22.10.0", "core-js-bundle": "^3.49.0", + "marked": "^18.0.5", "svelte": "^5.15.0", "svelte-check": "^4.1.0", "typescript": "^5.7.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 996bd0e..d819061 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: core-js-bundle: specifier: ^3.49.0 version: 3.49.0 + marked: + specifier: ^18.0.5 + version: 18.0.5 svelte: specifier: ^5.15.0 version: 5.56.0 @@ -1628,6 +1631,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3877,6 +3885,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@18.0.5: {} + math-intrinsics@1.1.0: {} minimatch@10.2.5: diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte index 9f4bf3b..44f918a 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -123,7 +123,10 @@ -
{t('about.version', { v: __APP_VERSION__ })}
+ diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 8f5f431..3924ed3 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -275,6 +275,7 @@ export const en = { 'landing.captionTelegram': 'Telegram', 'landing.captionVK': 'VK', 'landing.captionWeb': 'Web', + 'landing.offer': 'Public offer', 'install.title': 'Install the app', 'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index ed1004c..4c58d99 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -275,6 +275,7 @@ export const ru: Record = { 'landing.captionTelegram': 'Telegram', 'landing.captionVK': 'VK', 'landing.captionWeb': 'Веб-версия', + 'landing.offer': 'Публичная оферта', 'install.title': 'Установить приложение', 'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.', diff --git a/ui/src/lib/offer.test.ts b/ui/src/lib/offer.test.ts new file mode 100644 index 0000000..a9da8c3 --- /dev/null +++ b/ui/src/lib/offer.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { renderOfferHtml } from './offer'; + +describe('renderOfferHtml', () => { + it('wraps rendered markdown in a standalone Russian HTML document', () => { + const html = renderOfferHtml('# Публичная оферта\n\nо заключении договора купли-продажи'); + expect(html).toContain(''); + expect(html).toContain('lang="ru"'); + expect(html).toContain('Публичная оферта — Эрудит'); + // The markdown heading is rendered, not left as literal source. + expect(html).toContain('

Публичная оферта

'); + expect(html).not.toContain('# Публичная оферта'); + // A back link to the landing root is always present. + expect(html).toContain('href="/"'); + }); + + it('renders headings, bold clause numbers and lists', () => { + const html = renderOfferHtml('## 2. Предмет\n\n**2.1.** Текст пункта.\n\n- первый\n- второй'); + expect(html).toContain('

2. Предмет

'); + expect(html).toContain('2.1.'); + expect(html).toContain('
  • первый
  • '); + }); +}); diff --git a/ui/src/lib/offer.ts b/ui/src/lib/offer.ts new file mode 100644 index 0000000..f6cecd9 --- /dev/null +++ b/ui/src/lib/offer.ts @@ -0,0 +1,93 @@ +import { marked } from 'marked'; + +/** + * renderOfferHtml renders the public-offer markdown source into a standalone, + * self-contained HTML document served statically at `/offer/`. The build emits + * the result as `dist/offer/index.html` (see the `emit-offer` plugin in + * `vite.config.ts`), which the landing container serves. + * + * The input `markdown` is trusted repository content (the owner-edited + * `ui/legal/offer_ru.md`), not user input, so the rendered HTML is deliberately + * not sanitised. The page carries its own minimal light/dark styling so it needs + * neither the app bundle nor `app.css`. + */ +export function renderOfferHtml(markdown: string): string { + const body = marked.parse(markdown, { async: false }) as string; + return ` + + + + + Публичная оферта — Эрудит + + + + + + +
    + ← На главную + ${body} +
    + + +`; +} diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json index 379848a..578faea 100644 --- a/ui/tsconfig.node.json +++ b/ui/tsconfig.node.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "types": ["node"] }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "src/lib/offer.ts"] } diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 5a36909..62a6217 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path'; import { defineConfig, type Plugin } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import { VitePWA } from 'vite-plugin-pwa'; +import { renderOfferHtml } from './src/lib/offer'; /** * injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its @@ -38,6 +39,22 @@ function emitPolyfills(): Plugin { }; } +/** + * emitOffer renders the public offer markdown (legal/offer_ru.md) to a standalone + * static page and emits it as dist/offer/index.html, which the landing container + * serves at /offer/ (deploy/landing/Caddyfile). The markdown is the owner-editable + * source of truth, so the page is regenerated from it on every build. + */ +function emitOffer(): Plugin { + return { + name: 'emit-offer', + generateBundle() { + const md = readFileSync(resolve(import.meta.dirname, 'legal/offer_ru.md'), 'utf8'); + this.emitFile({ type: 'asset', fileName: 'offer/index.html', source: renderOfferHtml(md) }); + }, + }; +} + // The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over // h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can // not speak h2c directly) talks to the dev server on the same origin. In `mock` @@ -60,6 +77,7 @@ export default defineConfig(({ mode }) => ({ plugins: [ svelte(), emitPolyfills(), + emitOffer(), injectBootVersion(), // App-shell precache for the offline mode: a custom (injectManifest) service worker precaches // index.html + the hashed assets so the installed web PWA cold-launches with no network. It From 0bdc034f7ae8a406111014d5ed8fc87109617abb Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 16:04:56 +0200 Subject: [PATCH 03/38] chore: offer formatting --- ui/legal/offer_ru.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/legal/offer_ru.md b/ui/legal/offer_ru.md index fb5a7f5..39af269 100644 --- a/ui/legal/offer_ru.md +++ b/ui/legal/offer_ru.md @@ -142,5 +142,4 @@ ## 11. Реквизиты Продавца -Денисов Илья Аркадьевич -ИНН: 290210610742 +Денисов Илья Аркадьевич, ИНН 290210610742. From 7860efce482c7b69843895bf20e01bf7ef5349f6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 16:48:48 +0200 Subject: [PATCH 04/38] feat(payments): order-flow and fund credit engine + the Robokassa adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/internal/payments/payments.go | 16 +- backend/internal/payments/service_intake.go | 73 ++++ .../internal/payments/service_intake_test.go | 42 +++ backend/internal/payments/store_intake.go | 324 ++++++++++++++++++ backend/internal/payments/store_wallet.go | 25 +- backend/internal/robokassa/robokassa.go | 104 ++++++ backend/internal/robokassa/robokassa_test.go | 85 +++++ 7 files changed, 658 insertions(+), 11 deletions(-) create mode 100644 backend/internal/payments/service_intake.go create mode 100644 backend/internal/payments/service_intake_test.go create mode 100644 backend/internal/payments/store_intake.go create mode 100644 backend/internal/robokassa/robokassa.go create mode 100644 backend/internal/robokassa/robokassa_test.go diff --git a/backend/internal/payments/payments.go b/backend/internal/payments/payments.go index 33e4a25..f53dff4 100644 --- a/backend/internal/payments/payments.go +++ b/backend/internal/payments/payments.go @@ -147,12 +147,13 @@ func (m Money) Cmp(o Money) (int, error) { } } -// String renders the amount as " ", with the currency's -// fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR"). -func (m Money) String() string { +// 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", "250") — the form a provider's amount +// field (Robokassa OutSum) takes. +func (m Money) Major() string { scale := m.currency.minorPerUnit() if scale == 1 { - return fmt.Sprintf("%d %s", m.minor, m.currency) + return fmt.Sprintf("%d", m.minor) } neg := m.minor < 0 abs := m.minor @@ -167,5 +168,10 @@ func (m Money) String() string { if neg { 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 " " (e.g. "149.50 RUB", "250 XTR"). +func (m Money) String() string { + return m.Major() + " " + string(m.currency) } diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go new file mode 100644 index 0000000..03dcd33 --- /dev/null +++ b/backend/internal/payments/service_intake.go @@ -0,0 +1,73 @@ +package payments + +import ( + "context" + "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 +} + +// 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()) +} + +// 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()) +} diff --git a/backend/internal/payments/service_intake_test.go b/backend/internal/payments/service_intake_test.go new file mode 100644 index 0000000..11159b1 --- /dev/null +++ b/backend/internal/payments/service_intake_test.go @@ -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 spend 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) + } + }) + } +} diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go new file mode 100644 index 0000000..9bc8796 --- /dev/null +++ b/backend/internal/payments/store_intake.go @@ -0,0 +1,324 @@ +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") +) + +// 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") + +// 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 +} + +// 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 +} + +// 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" +} diff --git a/backend/internal/payments/store_wallet.go b/backend/internal/payments/store_wallet.go index 825f1f2..59cff1b 100644 --- a/backend/internal/payments/store_wallet.go +++ b/backend/internal/payments/store_wallet.go @@ -230,8 +230,11 @@ func applyBenefitTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, origin return nil } -// insertLedgerTx appends one append-only ledger row inside tx. -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 { +// insertLedgerTx appends one append-only ledger row inside tx. orderID, provider and +// 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() if err != nil { return fmt.Errorf("payments: ledger id: %w", err) @@ -246,11 +249,13 @@ func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind s stmt := table.Ledger.INSERT( table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind, 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( id, accountID, kind, 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 { return fmt.Errorf("payments: insert %s ledger: %w", kind, err) @@ -278,7 +283,7 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm return ErrInsufficientChips } 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 } } @@ -295,7 +300,7 @@ 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. 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 { - 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) @@ -419,3 +424,11 @@ func uuidOrNull(id *uuid.UUID) postgres.Expression { } 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) +} diff --git a/backend/internal/robokassa/robokassa.go b/backend/internal/robokassa/robokassa.go new file mode 100644 index 0000000..d680255 --- /dev/null +++ b/backend/internal/robokassa/robokassa.go @@ -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= (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[:]) +} diff --git a/backend/internal/robokassa/robokassa_test.go b/backend/internal/robokassa/robokassa_test.go new file mode 100644 index 0000000..cc9f29f --- /dev/null +++ b/backend/internal/robokassa/robokassa_test.go @@ -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") + } +} From 36a5730e52b8ffeef3f2b79f002e6ee8294089b6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 16:59:34 +0200 Subject: [PATCH 05/38] 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". 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. --- backend/cmd/backend/main.go | 25 ++++ backend/internal/account/account.go | 15 +++ backend/internal/config/config.go | 15 +++ backend/internal/payments/service_intake.go | 6 + backend/internal/payments/store_intake.go | 21 ++++ backend/internal/server/handlers.go | 8 ++ backend/internal/server/handlers_intake.go | 125 ++++++++++++++++++++ backend/internal/server/server.go | 6 + 8 files changed, 221 insertions(+) create mode 100644 backend/internal/server/handlers_intake.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 31ae7ac..6c3fc64 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -308,6 +308,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { Notifier: hub, ExportSignKey: cfg.ExportSignKey, Renderer: renderer, + Robokassa: cfg.Robokassa, }) pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger) @@ -316,6 +317,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { logger.Info("servers starting", zap.String("http_addr", cfg.HTTPAddr), 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) + errc := make(chan error, 2) go func() { errc <- pushSrv.Run(ctx) }() go func() { errc <- srv.Run(ctx) }() @@ -325,6 +330,26 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { 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)) + } + } + } +} + // 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). func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer { diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 2b10fc8..7ff1018 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -393,6 +393,21 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity 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 // by limit and offset. func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2826370..f3844ee 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -14,6 +14,7 @@ import ( "scrabble/backend/internal/lobby" "scrabble/backend/internal/postgres" "scrabble/backend/internal/ratewatch" + "scrabble/backend/internal/robokassa" "scrabble/backend/internal/robot" "scrabble/backend/internal/telemetry" ) @@ -64,6 +65,9 @@ type Config struct { // RendererURL is the base URL of the internal image-render sidecar (e.g. // http://renderer:8090). Empty disables the PNG export artifact. 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. @@ -153,6 +157,13 @@ func Load() (Config, error) { 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{ HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr), @@ -170,6 +181,7 @@ func Load() (Config, error) { GuestRetention: guestRetention, ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"), RendererURL: os.Getenv("BACKEND_RENDERER_URL"), + Robokassa: robo, } if err := c.validate(); err != nil { 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) } } + 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 } diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go index 03dcd33..e57aac9 100644 --- a/backend/internal/payments/service_intake.go +++ b/backend/internal/payments/service_intake.go @@ -71,3 +71,9 @@ func (s *Service) ExpireOrders(ctx context.Context) (int, error) { } 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()) +} diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go index 9bc8796..e9b49ae 100644 --- a/backend/internal/payments/store_intake.go +++ b/backend/internal/payments/store_intake.go @@ -271,6 +271,27 @@ func (s *Store) fund(ctx context.Context, orderID uuid.UUID, provider, providerP 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 +} + // orderTTL reads the configured pending-order lifetime in whole seconds. func (s *Store) orderTTL(ctx context.Context) (int, error) { var cfg model.Config diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index c704f00..6f9a721 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -73,6 +73,12 @@ func (s *Server) registerRoutes() { u.GET("/wallet/catalog", s.handleWalletCatalog) u.POST("/wallet/buy", s.handleWalletBuy) } + if s.payments != nil && s.robokassa.MerchantLogin != "" { + // Direct-rail (Robokassa): open a pending order (user group), and receive the verified + // Result callback the gateway forwards (internal group, gateway-only — the single writer). + u.POST("/wallet/order", s.handleWalletOrder) + s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult) + } if s.links != nil { // Account linking & merge. The request step always mails a code; // a required merge is revealed only after the code is verified, and the @@ -266,6 +272,8 @@ func statusForError(err error) (int, string) { return http.StatusNotFound, "product_not_found" case errors.Is(err, payments.ErrNotAValue): return http.StatusBadRequest, "not_a_value" + case errors.Is(err, payments.ErrNotAPack): + return http.StatusBadRequest, "not_a_pack" case errors.Is(err, account.ErrInvalidEmail): return http.StatusBadRequest, "invalid_email" case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired), diff --git a/backend/internal/server/handlers_intake.go b/backend/internal/server/handlers_intake.go new file mode 100644 index 0000000..de19c44 --- /dev/null +++ b/backend/internal/server/handlers_intake.go @@ -0,0 +1,125 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + "scrabble/backend/internal/payments" +) + +// providerRobokassa is the ledger/order provider tag for the direct (RUB) rail. +const providerRobokassa = "robokassa" + +// 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 provider launch URL the client opens. +type walletOrderResponse struct { + OrderID string `json:"order_id"` + RedirectURL string `json:"redirect_url"` +} + +// handleWalletOrder opens a pending order to fund a chip pack and returns the Robokassa +// hosted-payment URL for the client to open. It is direct-rail only for now (VK/TG land later); +// it enforces the wallet gate and D36 (a direct purchase requires a confirmed email anchor). No +// chips are credited here — only later, by the verified Result 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 + } + if cxt.Kind != payments.SourceDirect { + c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available yet"}}) + return + } + 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), + }) +} + +// 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". 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(¶ms); 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)) + } + } + c.String(http.StatusOK, "OK"+v.Get("InvId")) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 9e61c48..339d735 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -31,6 +31,7 @@ import ( "scrabble/backend/internal/payments" "scrabble/backend/internal/ratewatch" "scrabble/backend/internal/render" + "scrabble/backend/internal/robokassa" "scrabble/backend/internal/session" "scrabble/backend/internal/social" "scrabble/backend/internal/telemetry" @@ -109,6 +110,9 @@ type Deps struct { // 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). 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 @@ -136,6 +140,7 @@ type Server struct { banview *banview.View ads *ads.Service payments *payments.Service + robokassa robokassa.Config notifier notify.Publisher console *adminconsole.Renderer exportKey []byte @@ -189,6 +194,7 @@ func New(addr string, deps Deps) *Server { banview: deps.BanView, ads: deps.Ads, payments: deps.Payments, + robokassa: deps.Robokassa, notifier: notifier, renderer: deps.Renderer, http: &http.Server{Addr: addr, Handler: engine}, From a66a5bfa081c8be1675845c3e94429da874e0213 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 17:04:14 +0200 Subject: [PATCH 06/38] test(payments): integration coverage for the intake credit path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../internal/inttest/payments_intake_test.go | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 backend/internal/inttest/payments_intake_test.go diff --git a/backend/internal/inttest/payments_intake_test.go b/backend/internal/inttest/payments_intake_test.go new file mode 100644 index 0000000..756d73c --- /dev/null +++ b/backend/internal/inttest/payments_intake_test.go @@ -0,0 +1,142 @@ +//go:build integration + +package inttest + +import ( + "context" + "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 +} + +// 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") + } +} + +// 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") + } +} + +// 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)) + } +} From 2a6dc5a304f9a8a04d2bd585f792755c18d02d0e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 17:30:39 +0200 Subject: [PATCH 07/38] 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. --- gateway/internal/backendclient/api.go | 18 +++++ gateway/internal/transcode/encode.go | 13 ++++ gateway/internal/transcode/transcode.go | 13 ++++ pkg/fbs/scrabble.fbs | 12 ++++ pkg/fbs/scrabblefb/WalletOrderRequest.go | 60 ++++++++++++++++ pkg/fbs/scrabblefb/WalletOrderResponse.go | 71 +++++++++++++++++++ ui/src/gen/fbs/scrabblefb.ts | 2 + .../fbs/scrabblefb/wallet-order-request.ts | 48 +++++++++++++ .../fbs/scrabblefb/wallet-order-response.ts | 60 ++++++++++++++++ 9 files changed, 297 insertions(+) create mode 100644 pkg/fbs/scrabblefb/WalletOrderRequest.go create mode 100644 pkg/fbs/scrabblefb/WalletOrderResponse.go create mode 100644 ui/src/gen/fbs/scrabblefb/wallet-order-request.ts create mode 100644 ui/src/gen/fbs/scrabblefb/wallet-order-response.ts diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 1607b67..09f3352 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -396,6 +396,24 @@ func (c *Client) WalletBuy(ctx context.Context, userID, productID string) (Walle return out, err } +// walletOrderBody is the POST body of an order: the chip pack to fund. +type walletOrderBody struct { + ProductID string `json:"product_id"` +} + +// WalletOrderResp is a created order: its id and the provider launch URL the client opens. +type WalletOrderResp struct { + OrderID string `json:"order_id"` + RedirectURL string `json:"redirect_url"` +} + +// WalletOrder opens a pending order to fund a chip pack and returns the provider launch URL. +func (c *Client) WalletOrder(ctx context.Context, userID, productID string) (WalletOrderResp, error) { + var out WalletOrderResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/order", userID, "", walletOrderBody{ProductID: productID}, &out) + return out, err +} + // CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity. type CatalogAtomResp struct { AtomType string `json:"atom_type"` diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index b99c973..a836722 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -37,6 +37,19 @@ func encodeAck(ok bool) []byte { return b.FinishedBytes() } +// encodeWalletOrder builds a WalletOrderResponse payload: the created order id and the provider +// launch URL the client opens. +func encodeWalletOrder(o backendclient.WalletOrderResp) []byte { + b := flatbuffers.NewBuilder(128) + oid := b.CreateString(o.OrderID) + url := b.CreateString(o.RedirectURL) + fb.WalletOrderResponseStart(b) + fb.WalletOrderResponseAddOrderId(b, oid) + fb.WalletOrderResponseAddRedirectUrl(b, url) + b.Finish(fb.WalletOrderResponseEnd(b)) + return b.FinishedBytes() +} + // encodeDeleteRequestResult builds an AccountDeleteRequestResult payload reporting which // deletion step-up the account uses ("email" | "phrase"). func encodeDeleteRequestResult(method string) []byte { diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 3b5c76f..0b6a399 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -55,6 +55,7 @@ const ( MsgWalletGet = "wallet.get" MsgWalletCatalog = "wallet.catalog" MsgWalletBuy = "wallet.buy" + MsgWalletOrder = "wallet.order" ) // Request is one decoded Execute call. @@ -111,6 +112,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op r.ops[MsgWalletGet] = Op{Handler: walletHandler(backend), Auth: true} r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true} r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true} + r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend), Auth: true} r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true} r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true} r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true} @@ -331,6 +333,17 @@ func walletBuyHandler(backend *backendclient.Client) Handler { } } +func walletOrderHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsWalletOrderRequest(req.Payload, 0) + o, err := backend.WalletOrder(ctx, req.UserID, string(in.ProductId())) + if err != nil { + return nil, err + } + return encodeWalletOrder(o), nil + } +} + func blockStatusHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { bs, err := backend.BlockStatus(ctx, req.UserID) diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 89e13f5..196d2ce 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -873,6 +873,18 @@ table WalletBuyRequest { product_id:string; } +// WalletOrderRequest opens a money order to fund a chip pack: the pack to buy. +table WalletOrderRequest { + product_id:string; +} + +// WalletOrderResponse returns the created order id and the provider launch URL the client opens +// (the Robokassa hosted-payment page); chips are credited later, by the verified server callback. +table WalletOrderResponse { + order_id:string; + redirect_url:string; +} + // CatalogAtom is one atom line of a storefront product: the base value type it grants // ("chips"/"hints"/"noads_days"/"tournament") and how many of it the product carries. table CatalogAtom { diff --git a/pkg/fbs/scrabblefb/WalletOrderRequest.go b/pkg/fbs/scrabblefb/WalletOrderRequest.go new file mode 100644 index 0000000..a19177d --- /dev/null +++ b/pkg/fbs/scrabblefb/WalletOrderRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type WalletOrderRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsWalletOrderRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &WalletOrderRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishWalletOrderRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsWalletOrderRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &WalletOrderRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedWalletOrderRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *WalletOrderRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *WalletOrderRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *WalletOrderRequest) ProductId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func WalletOrderRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func WalletOrderRequestAddProductId(builder *flatbuffers.Builder, productId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(productId), 0) +} +func WalletOrderRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/WalletOrderResponse.go b/pkg/fbs/scrabblefb/WalletOrderResponse.go new file mode 100644 index 0000000..b3aa961 --- /dev/null +++ b/pkg/fbs/scrabblefb/WalletOrderResponse.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type WalletOrderResponse struct { + _tab flatbuffers.Table +} + +func GetRootAsWalletOrderResponse(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderResponse { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &WalletOrderResponse{} + x.Init(buf, n+offset) + return x +} + +func FinishWalletOrderResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsWalletOrderResponse(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderResponse { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &WalletOrderResponse{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedWalletOrderResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *WalletOrderResponse) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *WalletOrderResponse) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *WalletOrderResponse) OrderId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *WalletOrderResponse) RedirectUrl() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func WalletOrderResponseStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func WalletOrderResponseAddOrderId(builder *flatbuffers.Builder, orderId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(orderId), 0) +} +func WalletOrderResponseAddRedirectUrl(builder *flatbuffers.Builder, redirectUrl flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(redirectUrl), 0) +} +func WalletOrderResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index df83fb6..c6dff38 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -86,6 +86,8 @@ export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js'; export { VKLoginRequest } from './scrabblefb/vklogin-request.js'; export { Wallet } from './scrabblefb/wallet.js'; export { WalletBuyRequest } from './scrabblefb/wallet-buy-request.js'; +export { WalletOrderRequest } from './scrabblefb/wallet-order-request.js'; +export { WalletOrderResponse } from './scrabblefb/wallet-order-response.js'; export { WalletSegment } from './scrabblefb/wallet-segment.js'; export { WordCheckResult } from './scrabblefb/word-check-result.js'; export { YourTurnEvent } from './scrabblefb/your-turn-event.js'; diff --git a/ui/src/gen/fbs/scrabblefb/wallet-order-request.ts b/ui/src/gen/fbs/scrabblefb/wallet-order-request.ts new file mode 100644 index 0000000..f4e4d8c --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/wallet-order-request.ts @@ -0,0 +1,48 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class WalletOrderRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):WalletOrderRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsWalletOrderRequest(bb:flatbuffers.ByteBuffer, obj?:WalletOrderRequest):WalletOrderRequest { + return (obj || new WalletOrderRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsWalletOrderRequest(bb:flatbuffers.ByteBuffer, obj?:WalletOrderRequest):WalletOrderRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new WalletOrderRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +productId():string|null +productId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +productId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startWalletOrderRequest(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addProductId(builder:flatbuffers.Builder, productIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, productIdOffset, 0); +} + +static endWalletOrderRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createWalletOrderRequest(builder:flatbuffers.Builder, productIdOffset:flatbuffers.Offset):flatbuffers.Offset { + WalletOrderRequest.startWalletOrderRequest(builder); + WalletOrderRequest.addProductId(builder, productIdOffset); + return WalletOrderRequest.endWalletOrderRequest(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/wallet-order-response.ts b/ui/src/gen/fbs/scrabblefb/wallet-order-response.ts new file mode 100644 index 0000000..b7314e9 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/wallet-order-response.ts @@ -0,0 +1,60 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class WalletOrderResponse { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):WalletOrderResponse { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsWalletOrderResponse(bb:flatbuffers.ByteBuffer, obj?:WalletOrderResponse):WalletOrderResponse { + return (obj || new WalletOrderResponse()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsWalletOrderResponse(bb:flatbuffers.ByteBuffer, obj?:WalletOrderResponse):WalletOrderResponse { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new WalletOrderResponse()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +orderId():string|null +orderId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +orderId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +redirectUrl():string|null +redirectUrl(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +redirectUrl(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startWalletOrderResponse(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addOrderId(builder:flatbuffers.Builder, orderIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, orderIdOffset, 0); +} + +static addRedirectUrl(builder:flatbuffers.Builder, redirectUrlOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, redirectUrlOffset, 0); +} + +static endWalletOrderResponse(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createWalletOrderResponse(builder:flatbuffers.Builder, orderIdOffset:flatbuffers.Offset, redirectUrlOffset:flatbuffers.Offset):flatbuffers.Offset { + WalletOrderResponse.startWalletOrderResponse(builder); + WalletOrderResponse.addOrderId(builder, orderIdOffset); + WalletOrderResponse.addRedirectUrl(builder, redirectUrlOffset); + return WalletOrderResponse.endWalletOrderResponse(builder); +} +} From 4f6c22d66973be027840d6e69c8e0b5c5cedf7cc Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 17:33:22 +0200 Subject: [PATCH 08/38] 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" 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. --- backend/internal/server/handlers_intake.go | 3 +- deploy/caddy/Caddyfile | 2 +- gateway/internal/backendclient/api.go | 14 +++++++ gateway/internal/connectsrv/server.go | 49 ++++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/backend/internal/server/handlers_intake.go b/backend/internal/server/handlers_intake.go index de19c44..6c3184f 100644 --- a/backend/internal/server/handlers_intake.go +++ b/backend/internal/server/handlers_intake.go @@ -121,5 +121,6 @@ func (s *Server) handleRobokassaResult(c *gin.Context) { s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err)) } } - c.String(http.StatusOK, "OK"+v.Get("InvId")) + // The gateway echoes response verbatim to Robokassa, which requires the body "OK". + c.JSON(http.StatusOK, gin.H{"response": "OK" + v.Get("InvId")}) } diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 578bd81..1b19493 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -86,7 +86,7 @@ # 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 # 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 { reverse_proxy gateway:8081 { header_up -X-Scrabble-Honeypot diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 09f3352..8d033b4 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -414,6 +414,20 @@ func (c *Client) WalletOrder(ctx context.Context, userID, productID string) (Wal return out, err } +// robokassaResultResp is the backend intake's reply: the body to echo back to Robokassa. +type robokassaResultResp struct { + Response string `json:"response"` +} + +// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the +// single writer, which verifies the signature and credits) and returns the body to echo to +// Robokassa ("OK"). params carries the provider's raw form fields. +func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) { + var out robokassaResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/robokassa/result", "", "", params, &out) + return out.Response, err +} + // CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity. type CatalogAtomResp struct { AtomType string `json:"atom_type"` diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 704de92..2aaac25 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -196,6 +196,11 @@ func (s *Server) HTTPHandler() http.Handler { // through this session-gated route (not public); see dictBytesHandler. mux.Handle("/dict/", s.dictBytesHandler()) mux.Handle("/dl/", s.exportDownloadHandler()) + // Direct-rail (Robokassa) return + callback routes: the server Result callback (the single + // crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. + mux.Handle("/pay/robokassa/result", s.robokassaResultHandler()) + mux.Handle("/pay/robokassa/success", s.robokassaRedirectHandler()) + mux.Handle("/pay/robokassa/fail", s.robokassaRedirectHandler()) // The client posts its local-move-preview adoption telemetry here (session-gated). mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) // The index.html boot guard beacons here when it turns a client away on the unsupported-engine @@ -482,6 +487,50 @@ func (s *Server) exportDownloadHandler() http.Handler { }) } +// robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single +// writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's +// "OK" to Robokassa on success; any error tells Robokassa the notification was not accepted, +// so it retries. The gateway does not verify the signature — the backend holds the secret. +func (s *Server) robokassaResultHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.backend == nil { + http.NotFound(w, r) + return + } + ip := peerIP(r.RemoteAddr, r.Header) + if !s.limiter.Allow("public:"+ip, s.publicPolicy) { + s.noteRateLimited(r.Context(), classPublic, ip, "robokassa-result") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + params := make(map[string]string, len(r.Form)) + for k := range r.Form { + params[k] = r.Form.Get(k) + } + resp, err := s.backend.RobokassaResult(r.Context(), params) + if err != nil { + s.log.Warn("robokassa result proxy failed", zap.Error(err)) + http.Error(w, "not accepted", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(resp)) + }) +} + +// robokassaRedirectHandler redirects the customer's browser back into the app after Robokassa's +// Success/Fail return. The credit rides the server Result callback, never these redirects, so this +// only navigates home; the wallet reflects the credit once the callback lands. +func (s *Server) robokassaRedirectHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/app/", http.StatusSeeOther) + }) +} + func (s *Server) dictBytesHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.backend == nil { From 936a70ab94e95aca74629c03cfa26db744a5a421 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 17:43:02 +0200 Subject: [PATCH 09/38] 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. --- ui/e2e/wallet.spec.ts | 23 +++++++++++++++++++++-- ui/src/lib/client.ts | 4 ++++ ui/src/lib/codec.test.ts | 21 +++++++++++++++++++++ ui/src/lib/codec.ts | 16 ++++++++++++++++ ui/src/lib/i18n/en.ts | 2 +- ui/src/lib/i18n/ru.ts | 2 +- ui/src/lib/mock/client.ts | 9 +++++++++ ui/src/lib/model.ts | 8 ++++++++ ui/src/lib/transport.ts | 3 +++ ui/src/screens/Wallet.svelte | 32 +++++++++++++++++++++++++++++++- 10 files changed, 115 insertions(+), 5 deletions(-) diff --git a/ui/e2e/wallet.spec.ts b/ui/e2e/wallet.spec.ts index df85476..4298fd6 100644 --- a/ui/e2e/wallet.spec.ts +++ b/ui/e2e/wallet.spec.ts @@ -37,8 +37,27 @@ test('wallet: balances, benefits and the storefront render for a durable account await expect(page.getByTestId('product')).toHaveCount(3); const pack = page.locator('[data-kind="pack"]'); await expect(pack).toContainText('₽'); - // The pack purchase (money intake) is not wired yet, so its action is the disabled 'Soon'. - await expect(pack.getByRole('button', { name: 'Soon' })).toBeDisabled(); + // The pack purchase is wired to money intake: an enabled Buy action, with the public-offer link. + await expect(pack.getByTestId('buy-pack')).toBeEnabled(); + await expect(page.getByTestId('offer')).toContainText('Public offer'); +}); + +test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => { + await loginLobby(page); + await openWallet(page); + + // The purchase opens the provider's hosted-payment page in a new tab (window.open); capture it. + await page.evaluate(() => { + (window as { __opened?: string }).__opened = ''; + window.open = ((u: string) => { + (window as { __opened?: string }).__opened = u; + return null; + }) as typeof window.open; + }); + await page.locator('[data-kind="pack"]').getByTestId('buy-pack').click(); + await expect + .poll(() => page.evaluate(() => (window as { __opened?: string }).__opened)) + .toContain('robokassa'); }); test('wallet: the tab sits between Friends and About', async ({ page }) => { diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 738466c..009a994 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -35,6 +35,7 @@ import type { Variant, WordCheckResult, Wallet, + WalletOrder, Catalog, } from './model'; @@ -147,6 +148,9 @@ export interface GatewayClient { /** walletBuy spends chips on a chip-priced value and returns the updated wallet. Gate-checked * server-side: an untrusted/frozen context or an insufficient balance is refused. */ walletBuy(productId: string): Promise; + /** walletOrder opens a money order to fund a chip pack and returns the provider launch URL the + * client opens; chips are credited later, by the verified server callback. Direct rail only. */ + walletOrder(productId: string): Promise; // --- friends --- friendsList(): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 3ef1a78..127f8bf 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -17,6 +17,8 @@ import { decodeWallet, decodeCatalog, encodeWalletBuy, + encodeWalletOrder, + decodeWalletOrder, decodeSession, decodeFeedbackState, decodeFeedbackUnread, @@ -73,6 +75,25 @@ describe('codec', () => { expect(w.hints).toBe(5); }); + it('round-trips the wallet order request and response', () => { + // order request: pack id survives the wire + const req = fb.WalletOrderRequest.getRootAsWalletOrderRequest(new ByteBuffer(encodeWalletOrder('pack-7'))); + expect(req.productId()).toBe('pack-7'); + + // order response: the created id and the provider launch URL decode back + const b = new Builder(64); + const oid = b.createString('order-123'); + const url = b.createString('https://pay.example/abc'); + fb.WalletOrderResponse.startWalletOrderResponse(b); + fb.WalletOrderResponse.addOrderId(b, oid); + fb.WalletOrderResponse.addRedirectUrl(b, url); + b.finish(fb.WalletOrderResponse.endWalletOrderResponse(b)); + + const order = decodeWalletOrder(b.asUint8Array()); + expect(order.orderId).toBe('order-123'); + expect(order.redirectUrl).toBe('https://pay.example/abc'); + }); + it('round-trips the catalog view (value + pack)', () => { const b = new Builder(256); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 38a03c0..709ec03 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -39,6 +39,7 @@ import type { MoveResult, Profile, Wallet, + WalletOrder, WalletSegment, Catalog, CatalogProduct, @@ -373,6 +374,15 @@ export function encodeWalletBuy(productId: string): Uint8Array { return finish(b, fb.WalletBuyRequest.endWalletBuyRequest(b)); } +// encodeWalletOrder wraps the pack id for a money order (POST /user/wallet/order). +export function encodeWalletOrder(productId: string): Uint8Array { + const b = new Builder(64); + const pid = b.createString(productId); + fb.WalletOrderRequest.startWalletOrderRequest(b); + fb.WalletOrderRequest.addProductId(b, pid); + return finish(b, fb.WalletOrderRequest.endWalletOrderRequest(b)); +} + // decodeWallet reads the wallet payload: the context-visible chip segments and the // context-applicable benefits. export function decodeWallet(buf: Uint8Array): Wallet { @@ -391,6 +401,12 @@ export function decodeWallet(buf: Uint8Array): Wallet { }; } +// decodeWalletOrder reads the created order: its id and the provider launch URL the client opens. +export function decodeWalletOrder(buf: Uint8Array): WalletOrder { + const o = fb.WalletOrderResponse.getRootAsWalletOrderResponse(new ByteBuffer(buf)); + return { orderId: s(o.orderId()), redirectUrl: s(o.redirectUrl()) }; +} + // decodeCatalog reads the storefront payload: the context-visible products, each a chip-priced // value or a chip pack priced in the context's payment method, with its atom composition. export function decodeCatalog(buf: Uint8Array): Catalog { diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 3924ed3..373fafe 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -242,7 +242,7 @@ export const en = { 'wallet.store': 'Store', 'wallet.empty': 'The store is empty for now', 'wallet.buy': 'Buy', - 'wallet.soon': 'Soon', + 'wallet.offer': 'Public offer', 'wallet.gpStub': 'To buy chips, install the RuStore build.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'votes', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 4c58d99..28055c1 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -242,7 +242,7 @@ export const ru: Record = { 'wallet.store': 'Магазин', 'wallet.empty': 'Магазин пока пуст', 'wallet.buy': 'Купить', - 'wallet.soon': 'Скоро', + 'wallet.offer': 'Публичная оферта', 'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'голосов', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 84b5764..2314208 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -41,6 +41,7 @@ import type { Variant, WordCheckResult, Wallet, + WalletOrder, Catalog, } from '../model'; import { valueForLetter } from '../alphabet'; @@ -225,6 +226,14 @@ export class MockGateway implements GatewayClient { } return this.cloneWallet(); } + + async walletOrder(productId: string): Promise { + const p = this.mockCatalog.products.find((x) => x.productId === productId); + if (!p || p.kind !== 'pack') throw new GatewayError('not_a_pack'); + // No real provider in mock: return a stub launch URL so the e2e can assert the purchase reaches + // the provider hand-off without leaving the app. + return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId }; + } private cloneWallet(): Wallet { return { segments: this.mockWallet.segments.map((s) => ({ ...s })), diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index e376f58..d71a9d7 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -172,6 +172,14 @@ export interface Wallet { /** One atom line of a storefront product: the base value type it grants ("chips"/"hints"/ * "noads_days"/"tournament") and how many of it the product carries. */ +// WalletOrder is a created money order to fund a chip pack: its id and the provider launch URL the +// client opens (the Robokassa hosted-payment page). Chips arrive later, by the verified server +// callback. +export interface WalletOrder { + orderId: string; + redirectUrl: string; +} + export interface CatalogAtom { atomType: string; quantity: number; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 8794deb..7a7e5d2 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -237,6 +237,9 @@ export function createTransport(baseUrl: string): GatewayClient { async walletBuy(productId: string) { return codec.decodeWallet(await exec('wallet.buy', codec.encodeWalletBuy(productId))); }, + async walletOrder(productId: string) { + return codec.decodeWalletOrder(await exec('wallet.order', codec.encodeWalletOrder(productId))); + }, async friendsList() { return codec.decodeFriendList(await exec('friends.list', codec.empty())); diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 7ade3db..23c5a29 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -6,6 +6,7 @@ import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte'; import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet'; import { isGooglePlayBuild } from '../lib/distribution'; + import { openExternalUrl } from '../lib/links'; import type { Wallet, Catalog, CatalogProduct } from '../lib/model'; // The Wallet section: the context-visible chip balances, the active benefits, and the storefront. @@ -78,6 +79,22 @@ busy = false; } } + + // onOrder funds a chip pack with money: it opens a pending order and sends the player to the + // provider's hosted-payment page. The chips are credited later, by the verified server callback; + // paying accepts the public offer (linked below the packs). + async function onOrder(p: CatalogProduct) { + if (busy) return; + busy = true; + try { + const order = await gateway.walletOrder(p.productId); + openExternalUrl(order.redirectUrl); + } catch (e) { + handleError(e); + } finally { + busy = false; + } + }
    @@ -120,9 +137,14 @@
    {p.title} {formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)} - +
    {/each} + {#if packs.length > 0} +

    + {t('wallet.offer')} +

    + {/if} {/if} {#if !loading && values.length === 0 && (gpBuild || packs.length === 0)} @@ -207,6 +229,14 @@ border: 1px dashed var(--border); border-radius: var(--radius-sm); } + .offer { + margin: 2px 0 0; + text-align: center; + font-size: 0.85rem; + } + .offer a { + color: var(--text-muted); + } .warn-body { margin: 0 0 14px; } From be0e4995f36a833f736eaeafb1f23634fcd3a984 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 17:45:54 +0200 Subject: [PATCH 10/38] 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. --- .gitea/workflows/ci.yaml | 7 +++++++ deploy/docker-compose.yml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 811bcfa..9a215d9 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -366,6 +366,13 @@ jobs: SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }} SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }} 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 }} # Operator alerts: backend admin emails (new feedback / complaints) + Grafana # infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 919fee2..024d719 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -161,6 +161,13 @@ services: # recipient(s) (comma-separated allowed). Both empty disables the alert worker. BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-} 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 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 From 04435a3283c7365ddb9aeb47aa2fbb8ee6f814b0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 17:48:29 +0200 Subject: [PATCH 11/38] docs(payments): bake the E5 direct-rail decisions + a /pay/ CI probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitea/workflows/ci.yaml | 16 ++++++++++++++++ PLAN.md | 16 ++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 9a215d9..3b97e91 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -520,6 +520,22 @@ jobs: 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 run: | set -u diff --git a/PLAN.md b/PLAN.md index f182eb9..3817dc0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -34,7 +34,7 @@ status — without re-deriving decisions. | E2 | Currency + benefit core | 1 | DONE | | E3 | Wallet UI | 1 | DONE | | E4 | Durability (PITR) | 2 | DONE | -| E5 | Payment intake | 2 | TODO | +| E5 | Payment intake | 2 | WIP | | E6 | Ads | 2 | TODO | | E7 | Admin & reports | 2 | TODO | | E8 | Guest limits | — | TODO | @@ -504,7 +504,19 @@ maintenance window). Migrations stay expand-contract so image rollback remains D ## E5 — Payment intake -**Status:** TODO · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12. +**Status:** WIP · **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, and the contour deploy env (IsTest forced). Remaining: +the `payment_events` dispatcher (delivery), then the VK and TG-Stars rails and refunds. **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, From 3367cc2bf17f7ab387752827c89d38ca64221885 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 18:26:06 +0200 Subject: [PATCH 12/38] feat(payments): payment-event dispatcher + the provider-return UX 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. --- PLAN.md | 7 +++- backend/cmd/backend/main.go | 29 ++++++++++++++ .../internal/inttest/payments_intake_test.go | 39 +++++++++++++++++++ backend/internal/notify/notify.go | 4 ++ backend/internal/payments/service_intake.go | 11 ++++++ backend/internal/payments/store_intake.go | 36 +++++++++++++++++ gateway/internal/connectsrv/server.go | 26 +++++++++---- ui/src/lib/app.svelte.ts | 10 +++++ ui/src/screens/Wallet.svelte | 23 ++++++++++- 9 files changed, 174 insertions(+), 11 deletions(-) diff --git a/PLAN.md b/PLAN.md index 3817dc0..854a31d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -515,8 +515,11 @@ chargeback **never drives the balance negative** (D27 stands, `balances_chips_ch 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, and the contour deploy env (IsTest forced). Remaining: -the `payment_events` dispatcher (delivery), then the VK and TG-Stars rails and refunds. +`/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. Remaining: the VK and TG-Stars rails and refunds; and hiding the ad banner on a +no-ads purchase (a spend-path `NotifyBanner`, deferred with the owner's agreement). **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, diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 6c3fc64..cbdbc88 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -320,6 +320,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { // 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) go func() { errc <- pushSrv.Run(ctx) }() @@ -350,6 +353,32 @@ func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) { } } +// 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 // configured, otherwise the development log mailer (the code is logged, not sent). func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer { diff --git a/backend/internal/inttest/payments_intake_test.go b/backend/internal/inttest/payments_intake_test.go index 756d73c..17b4f2b 100644 --- a/backend/internal/inttest/payments_intake_test.go +++ b/backend/internal/inttest/payments_intake_test.go @@ -101,6 +101,45 @@ func TestPaymentsFundAmountMismatch(t *testing.T) { } } +// 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) { diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index e95f0f2..8cfe7c8 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -73,6 +73,10 @@ const ( // (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. 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, // 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 diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go index e57aac9..ccf0ffe 100644 --- a/backend/internal/payments/service_intake.go +++ b/backend/internal/payments/service_intake.go @@ -77,3 +77,14 @@ func (s *Service) ExpireOrders(ctx context.Context) (int, error) { 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()) +} diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go index e9b49ae..605a6d2 100644 --- a/backend/internal/payments/store_intake.go +++ b/backend/internal/payments/store_intake.go @@ -292,6 +292,42 @@ func (s *Store) insertPaymentEvent(ctx context.Context, accountID uuid.UUID, ord 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 diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 2aaac25..9549732 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -199,8 +199,8 @@ func (s *Server) HTTPHandler() http.Handler { // Direct-rail (Robokassa) return + callback routes: the server Result callback (the single // crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. mux.Handle("/pay/robokassa/result", s.robokassaResultHandler()) - mux.Handle("/pay/robokassa/success", s.robokassaRedirectHandler()) - mux.Handle("/pay/robokassa/fail", s.robokassaRedirectHandler()) + mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята.")) + mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена.")) // The client posts its local-move-preview adoption telemetry here (session-gated). mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) // The index.html boot guard beacons here when it turns a client away on the unsupported-engine @@ -522,12 +522,24 @@ func (s *Server) robokassaResultHandler() http.Handler { }) } -// robokassaRedirectHandler redirects the customer's browser back into the app after Robokassa's -// Success/Fail return. The credit rides the server Result callback, never these redirects, so this -// only navigates home; the wallet reflects the credit once the callback lands. -func (s *Server) robokassaRedirectHandler() http.Handler { +// robokassaReturnHandler serves a minimal self-closing page for Robokassa's Success/Fail browser +// return. The payment opens in a separate window, so on return this closes that window and drops the +// customer back into the live app (never a cold app start); a link is the fallback if the window +// cannot self-close. The credit rides the server Result callback, never this redirect — the wallet +// updates in place from the payment push, with a return-focus refetch as the fallback. +func (s *Server) robokassaReturnHandler(message string) http.Handler { + page := []byte(` +Оплата + +

    ` + message + `

    +

    Можно закрыть это окно и вернуться в приложение.

    +

    Открыть приложение

    + +`) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/app/", http.StatusSeeOther) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(page) }) } diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 173f59d..8c4882b 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -137,6 +137,9 @@ export const app = $state<{ /** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined * with friend requests) and the Settings → Info badge. */ feedbackReplyUnread: boolean; + /** A monotone counter bumped when a payment-intake push signals the wallet changed; an open + * Wallet screen watches it and re-fetches in place. */ + walletRefresh: number; /** Whether to show the "outdated invite link" notice: set when a Telegram deep-link friend * code is already used/expired, so the visitor lands in the lobby with a gentle pointer to * the bot instead of a scary error on the Friends screen. */ @@ -175,6 +178,7 @@ export const app = $state<{ chatUnread: {}, messageUnread: {}, feedbackReplyUnread: false, + walletRefresh: 0, staleInvite: false, welcomeRedeem: false, resync: 0, @@ -439,6 +443,12 @@ function openStream(): void { if (e.sub === 'profile') { void refreshProfile(); } + // A payment-intake event credited (or refunded) the viewer's wallet: bump the signal an + // open Wallet screen watches, so it re-fetches in place (the return-focus poll is the + // fallback when the live stream is down). + if (e.sub === 'payment') { + app.walletRefresh++; + } void refreshNotifications(); } }, diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 23c5a29..8377342 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -1,7 +1,7 @@ @@ -172,7 +175,9 @@
    {p.title} 🪙 {p.chips} - +
    {/each} @@ -186,6 +191,7 @@ +
    + {/if} {#each values as p (p.productId)}
    {p.title} From 9acf6ab3b478e101664d83c08365dbdc544b287a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 01:27:26 +0200 Subject: [PATCH 25/38] fix(payments): VK-iOS freeze is purchase-only; remove the rewarded diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- PLAN.md | 10 +++++--- backend/internal/payments/gate.go | 23 +++++++++++-------- backend/internal/payments/gate_test.go | 7 +++--- .../internal/payments/service_intake_test.go | 2 +- backend/internal/payments/wallet_test.go | 7 +++--- backend/internal/server/handlers_wallet.go | 15 ++---------- docs/PAYMENTS.md | 10 ++++---- docs/PAYMENTS_DECISIONS_ru.md | 5 ++++ docs/PAYMENTS_ru.md | 10 ++++---- gateway/internal/backendclient/api.go | 10 ++++---- gateway/internal/transcode/transcode.go | 4 ++-- pkg/fbs/scrabble.fbs | 3 +-- pkg/fbs/scrabblefb/WalletRewardRequest.go | 13 +---------- .../fbs/scrabblefb/wallet-reward-request.ts | 16 ++----------- ui/src/lib/ads.ts | 11 ++++----- ui/src/lib/client.ts | 6 ++--- ui/src/lib/codec.test.ts | 5 ++-- ui/src/lib/codec.ts | 6 ++--- ui/src/lib/mock/client.ts | 2 +- ui/src/lib/transport.ts | 4 ++-- ui/src/lib/vk.ts | 13 +++++------ ui/src/screens/Wallet.svelte | 2 +- 22 files changed, 79 insertions(+), 105 deletions(-) diff --git a/PLAN.md b/PLAN.md index afd6114..bf7c203 100644 --- a/PLAN.md +++ b/PLAN.md @@ -635,9 +635,13 @@ impl) + the VK bridge (`vkRewardedReady` / `vkShowRewarded`), the backend `Credi 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 logs the raw VK `data` so we -confirm on the contour exactly what VK returns (harden to signature-verify if it carries one). The -legacy `paid_account` / `hint_balance` drop (D31) and the **interstitial** are the next slice. +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). The legacy `paid_account` +/ `hint_balance` drop (D31) and the **interstitial** are the next slice. **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 diff --git a/backend/internal/payments/gate.go b/backend/internal/payments/gate.go index a136536..a3965a0 100644 --- a/backend/internal/payments/gate.go +++ b/backend/internal/payments/gate.go @@ -89,8 +89,10 @@ func NewContext(kind, subtype string) Context { // every spend/purchase and the application of any foreign origin. 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 -// subtype. A previously bought benefit still applies there, but no spend or purchase is possible. +// vkFrozen reports whether this is the VK-iOS purchase freeze: VK context on the trusted iOS +// 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 } // 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 -// order, restricted to the sources the account actually has (present). It is empty when the -// platform is untrusted (fail-closed) or VK-iOS (frozen): inside VK/TG only the same-named -// segment is spendable; on web/native all attached segments are, drained direct→vk→tg. +// order, restricted to the sources the account actually has (present). It is empty only when the +// platform is untrusted (fail-closed); VK-iOS is NOT excluded — the freeze is purchase-only, so +// 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 { - if !c.Trusted() || c.vkFrozen() { + if !c.Trusted() { return nil } 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 -// order, restricted to present sources. It differs from spendableSources in one way: VK-iOS is -// NOT excluded — a benefit bought earlier still applies while spending is frozen. Inside VK/TG -// only the same-named origin applies (a foreign, e.g. direct, origin never activates inside a -// store — the compliance wall); on web/native direct+vk+tg all apply, drained direct→vk→tg. +// order, restricted to present sources. It mirrors spendableSources (both gate only on a trusted +// platform now that the VK-iOS freeze is purchase-only). Inside VK/TG only the same-named origin +// applies (a foreign, e.g. direct, origin never activates inside a store — the compliance wall); +// on web/native direct+vk+tg all apply, drained direct→vk→tg. func applicableOrigins(c Context, present []Source) []Source { if !c.Trusted() { return nil diff --git a/backend/internal/payments/gate_test.go b/backend/internal/payments/gate_test.go index 304276b..1899469 100644 --- a/backend/internal/payments/gate_test.go +++ b/backend/internal/payments/gate_test.go @@ -16,7 +16,8 @@ func TestSpendableSources(t *testing.T) { want []Source }{ {"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}, {"telegram", Context{Kind: SourceTelegram, Subtype: "web"}, allPresent, []Source{SourceTelegram}}, {"telegram, tg absent", Context{Kind: SourceTelegram}, []Source{SourceVK}, nil}, @@ -41,8 +42,8 @@ func TestApplicableOrigins(t *testing.T) { present []Source want []Source }{ - // A benefit still APPLIES on VK-iOS while spending is frozen. - {"vk ios still applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}}, + // A vk-origin benefit applies on VK-iOS (spending is allowed there — the freeze is purchase-only). + {"vk ios applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}}, {"vk android", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}}, {"telegram", Context{Kind: SourceTelegram}, allPresent, []Source{SourceTelegram}}, {"direct all, priority", Context{Kind: SourceDirect}, allPresent, []Source{SourceDirect, SourceVK, SourceTelegram}}, diff --git a/backend/internal/payments/service_intake_test.go b/backend/internal/payments/service_intake_test.go index 11159b1..a1252a6 100644 --- a/backend/internal/payments/service_intake_test.go +++ b/backend/internal/payments/service_intake_test.go @@ -28,7 +28,7 @@ func TestCreateOrderGateRejections(t *testing.T) { cxt Context }{ {"untrusted context", Context{}}, - {"vk-ios spend freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}}, + {"vk-ios purchase freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}}, {"method segment not attached", Context{Kind: SourceTelegram}}, } for _, tc := range cases { diff --git a/backend/internal/payments/wallet_test.go b/backend/internal/payments/wallet_test.go index 8fc9aaf..e0e7150 100644 --- a/backend/internal/payments/wallet_test.go +++ b/backend/internal/payments/wallet_test.go @@ -102,10 +102,11 @@ func TestWalletSegments(t *testing.T) { if len(got.Segments) != 1 || got.Segments[0].Source != SourceVK || !got.Segments[0].Spendable { 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) - 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) + if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || !got.Segments[0].Spendable { + t.Errorf("vk-ios wallet = %+v (want vk 50 spendable)", got.Segments) } } diff --git a/backend/internal/server/handlers_wallet.go b/backend/internal/server/handlers_wallet.go index 0f4ba9e..89f68e7 100644 --- a/backend/internal/server/handlers_wallet.go +++ b/backend/internal/server/handlers_wallet.go @@ -175,12 +175,10 @@ func (s *Server) handleWalletBuy(c *gin.Context) { 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) and Diag, the raw VK ad result forwarded for -// a temporary diagnostic log while we confirm exactly what VK returns on the contour. +// 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"` - Diag string `json:"diag"` } // handleWalletReward credits a rewarded-video view's chips to the VK segment, client-attested and @@ -203,15 +201,6 @@ func (s *Server) handleWalletReward(c *gin.Context) { s.abortErr(c, err) return } - // Temporary diagnostic: log the raw VK ad result (bounded — untrusted client input) to learn its - // exact shape on the contour; if it carries a verifiable token we harden past client-attested. - if req.Diag != "" { - diag := req.Diag - if len(diag) > 2000 { - diag = diag[:2000] - } - s.log.Info("rewarded ad diagnostic", zap.String("account", uid.String()), zap.String("vk_data", diag)) - } outcome, err := s.payments.CreditReward(ctx, uid, cxt, present, req.Nonce) if err != nil { s.abortErr(c, err) diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index 572e5dd..222e08d 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -82,7 +82,7 @@ leaking out to the open web) is allowed. | Execution context | Spendable chip segments | Spend priority | |---------------------|-------------------------|--------------------| | Inside VK (Android) | `vk` | — | -| Inside VK (iOS) | none — frozen (view only)| — | +| Inside VK (iOS) | `vk` (purchase-frozen) | — | | Inside Telegram | `telegram` | — | | Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg | @@ -90,9 +90,11 @@ leaking out to the open web) is allowed. invisible-as-spendable there. - On the web the store has no jurisdiction, so all attached segments are spendable, drained by priority direct → vk → tg. -- **VK iOS** is frozen for spending (Apple forbids spending virtual currency on digital - goods outside IAP inside VK on iOS): the balance is shown as a number, but no purchase or - spend is possible. A previously bought benefit still *applies* there. +- **VK iOS is a PURCHASE freeze, not a spend freeze.** Apple's ToS forbids only **buying** in-app + values there (for any currency) — so a purchase (money → chips) is refused. **Spending** VK-wallet + chips (earned via rewarded ads or bought on the same VK account elsewhere, e.g. VK Android) and + earning them are legal and stay allowed on VK iOS; a bought benefit also *applies* there. Only the + money-in step is blocked. The account is **single** (identities merge, one profile/friends/stats). The gate is **logical**: in a VK/TG context the server activates only the same-named segment. It rests diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md index 4fa0775..c808915 100644 --- a/docs/PAYMENTS_DECISIONS_ru.md +++ b/docs/PAYMENTS_DECISIONS_ru.md @@ -110,6 +110,11 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз сессии. Платформа несёт **kind** (vk/tg/direct) **+ подтип** (ios/android/web) — подтип обязателен (VK iOS заморожен). TG `initData`-валидатор уже есть (`platform/telegram/internal/initdata`). + **АМЕНД (E6, находка владельца по ToS):** VK iOS — **заморозка только ПОКУПОК** (деньги→Фишки), + а не траты. Apple запрещает там лишь **покупать** внутриигровые ценности за любую валюту; а + **тратить** Фишки VK-кошелька (заработанные rewarded-рекламой или купленные на том же VK-аккаунте + в Android) и зарабатывать их — легально и на iOS. Код: `vkFrozen()` гейтит только `CreateOrder` + (покупку), не `spendableSources` (трату). - **D18. Fail-closed:** недоверенная/неподтверждённая платформа (VK/TG-сессия без валидной подписи на старте; старая сессия без записанной платформы) → запрет трат/покупок/применения чужого origin, только просмотр. diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index 897a960..f7c73ee 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -83,7 +83,7 @@ | Контекст исполнения | Тратимые сегменты Фишек | Приоритет траты | |----------------------|---------------------------|--------------------| | Внутри VK (Android) | `vk` | — | -| Внутри VK (iOS) | нет — заморожено (только просмотр) | — | +| Внутри VK (iOS) | `vk` (заморожена покупка) | — | | Внутри Telegram | `telegram` | — | | Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg | @@ -91,9 +91,11 @@ `direct`) там невидимо как тратимое. - В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по приоритету direct → vk → tg. -- **VK iOS** заморожен для траты (Apple запрещает тратить виртуальную валюту на цифровые - товары мимо IAP внутри VK на iOS): баланс показывается числом, но покупка/трата - невозможны. Ранее купленный бенефит там всё равно *действует*. +- **VK iOS — заморозка ПОКУПОК, а не траты.** ToS Apple запрещает там только **покупать** + внутриигровые ценности (за любую валюту) — поэтому покупка (деньги → Фишки) отклоняется. А + **тратить** Фишки VK-кошелька (заработанные рекламой или купленные на том же VK-аккаунте, напр. в + VK Android) и зарабатывать их — легально и на VK iOS разрешено; купленный бенефит там тоже + *действует*. Блокируется только шаг «деньги внутрь». Аккаунт **единый** (привязки сливаются, один профиль/друзья/статистика). Гейт **логический**: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 3c2287c..28b929b 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -384,18 +384,16 @@ type walletBuyBody struct { ProductID string `json:"product_id"` } -// walletRewardBody is the rewarded-video credit request: the per-view idempotency nonce and the raw -// VK ad result forwarded for the temporary contour diagnostic. +// walletRewardBody is the rewarded-video credit request: the per-view idempotency nonce. type walletRewardBody struct { Nonce string `json:"nonce"` - Diag string `json:"diag"` } // WalletReward credits a watched rewarded video and returns the updated wallet (client-attested + a -// backend daily cap). A reached cap or unconfigured payout surfaces as an APIError domain code. -func (c *Client) WalletReward(ctx context.Context, userID, nonce, diag string) (WalletResp, error) { +// backend daily/hourly cap). A reached cap or unconfigured payout surfaces as an APIError domain code. +func (c *Client) WalletReward(ctx context.Context, userID, nonce string) (WalletResp, error) { var out WalletResp - err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/reward", userID, "", walletRewardBody{Nonce: nonce, Diag: diag}, &out) + err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/reward", userID, "", walletRewardBody{Nonce: nonce}, &out) return out, err } diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 0e3903d..e3c60e4 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -382,11 +382,11 @@ func walletOrderHandler(backend *backendclient.Client, minter InvoiceMinter) Han } // walletRewardHandler credits a watched rewarded video and returns the updated wallet. The nonce is -// the client's per-view idempotency key; diag carries the raw VK ad result for the contour diagnostic. +// the client's per-view idempotency key. func walletRewardHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsWalletRewardRequest(req.Payload, 0) - w, err := backend.WalletReward(ctx, req.UserID, string(in.Nonce()), string(in.Diag())) + w, err := backend.WalletReward(ctx, req.UserID, string(in.Nonce())) if err != nil { return nil, err } diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index c8f458b..124cfb8 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -877,10 +877,9 @@ table WalletBuyRequest { } // WalletRewardRequest credits a watched rewarded video: nonce is the per-view idempotency key (a -// retry credits once); diag carries the raw VK ad result for a temporary contour diagnostic log. +// retry credits once). table WalletRewardRequest { nonce:string; - diag:string; } // WalletOrderRequest opens a money order to fund a chip pack: the pack to buy. diff --git a/pkg/fbs/scrabblefb/WalletRewardRequest.go b/pkg/fbs/scrabblefb/WalletRewardRequest.go index 32c2a8e..2694dde 100644 --- a/pkg/fbs/scrabblefb/WalletRewardRequest.go +++ b/pkg/fbs/scrabblefb/WalletRewardRequest.go @@ -49,23 +49,12 @@ func (rcv *WalletRewardRequest) Nonce() []byte { return nil } -func (rcv *WalletRewardRequest) Diag() []byte { - o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) - if o != 0 { - return rcv._tab.ByteVector(o + rcv._tab.Pos) - } - return nil -} - func WalletRewardRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(2) + builder.StartObject(1) } func WalletRewardRequestAddNonce(builder *flatbuffers.Builder, nonce flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(nonce), 0) } -func WalletRewardRequestAddDiag(builder *flatbuffers.Builder, diag flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(diag), 0) -} func WalletRewardRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/src/gen/fbs/scrabblefb/wallet-reward-request.ts b/ui/src/gen/fbs/scrabblefb/wallet-reward-request.ts index 4e1d9cf..5353be7 100644 --- a/ui/src/gen/fbs/scrabblefb/wallet-reward-request.ts +++ b/ui/src/gen/fbs/scrabblefb/wallet-reward-request.ts @@ -27,34 +27,22 @@ nonce(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } -diag():string|null -diag(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null -diag(optionalEncoding?:any):string|Uint8Array|null { - const offset = this.bb!.__offset(this.bb_pos, 6); - return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; -} - static startWalletRewardRequest(builder:flatbuffers.Builder) { - builder.startObject(2); + builder.startObject(1); } static addNonce(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset) { builder.addFieldOffset(0, nonceOffset, 0); } -static addDiag(builder:flatbuffers.Builder, diagOffset:flatbuffers.Offset) { - builder.addFieldOffset(1, diagOffset, 0); -} - static endWalletRewardRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createWalletRewardRequest(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset, diagOffset:flatbuffers.Offset):flatbuffers.Offset { +static createWalletRewardRequest(builder:flatbuffers.Builder, nonceOffset:flatbuffers.Offset):flatbuffers.Offset { WalletRewardRequest.startWalletRewardRequest(builder); WalletRewardRequest.addNonce(builder, nonceOffset); - WalletRewardRequest.addDiag(builder, diagOffset); return WalletRewardRequest.endWalletRewardRequest(builder); } } diff --git a/ui/src/lib/ads.ts b/ui/src/lib/ads.ts index 573d76c..f68a442 100644 --- a/ui/src/lib/ads.ts +++ b/ui/src/lib/ads.ts @@ -17,12 +17,10 @@ export function adsStubEnabled(): boolean { return (import.meta.env as Record).VITE_ADS_STUB === '1'; } -/** RewardedResult is one rewarded-ad view: whether it was watched, the raw provider result as JSON - * (forwarded for the contour diagnostic), and whether it was the test stub (so the caller can show - * the "ad fired" marker toast). */ +/** RewardedResult is one rewarded-ad view: whether it was watched, and whether it was the test stub + * (so the caller can show the "ad fired" marker toast). */ export interface RewardedResult { watched: boolean; - data: string; stub: boolean; } @@ -44,8 +42,7 @@ export async function rewardedReady(): Promise { */ export async function showRewarded(): Promise { if (adsStubEnabled()) { - return { watched: true, data: JSON.stringify({ stub: true }), stub: true }; + return { watched: true, stub: true }; } - const r = await vkShowRewarded(); - return { watched: r.watched, data: r.data, stub: false }; + return { watched: await vkShowRewarded(), stub: false }; } diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index c999a03..a79ba4c 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -152,9 +152,9 @@ export interface GatewayClient { * client opens; chips are credited later, by the verified server callback. Direct rail only. */ walletOrder(productId: string): Promise; /** walletReward credits a watched rewarded video (VK ads) and returns the updated wallet. nonce is - * the per-view idempotency key; diag carries the raw VK ad result for the contour diagnostic. - * Client-attested + a server daily/hourly cap; a reached cap rejects with a domain code. */ - walletReward(nonce: string, diag: string): Promise; + * the per-view idempotency key. Client-attested + a server daily/hourly cap; a reached cap rejects + * with a domain code. */ + walletReward(nonce: string): Promise; // --- friends --- friendsList(): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 9dcc6e6..3bc24df 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -78,12 +78,11 @@ describe('codec', () => { expect(w.rewardChips).toBe(7); }); - it('round-trips the wallet reward request (nonce + diag)', () => { + it('round-trips the wallet reward request (nonce)', () => { const req = fb.WalletRewardRequest.getRootAsWalletRewardRequest( - new ByteBuffer(encodeWalletReward('nonce-9', '{"result":true}')), + new ByteBuffer(encodeWalletReward('nonce-9')), ); expect(req.nonce()).toBe('nonce-9'); - expect(req.diag()).toBe('{"result":true}'); }); it('round-trips the wallet order request and response', () => { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 801e493..f229873 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -403,14 +403,12 @@ export function decodeWallet(buf: Uint8Array): Wallet { } // encodeWalletReward wraps a watched rewarded video for crediting (POST /user/wallet/reward): the -// per-view idempotency nonce and the raw VK ad result forwarded for the contour diagnostic. -export function encodeWalletReward(nonce: string, diag: string): Uint8Array { +// per-view idempotency nonce. +export function encodeWalletReward(nonce: string): Uint8Array { const b = new Builder(64); const n = b.createString(nonce); - const d = b.createString(diag); fb.WalletRewardRequest.startWalletRewardRequest(b); fb.WalletRewardRequest.addNonce(b, n); - fb.WalletRewardRequest.addDiag(b, d); return finish(b, fb.WalletRewardRequest.endWalletRewardRequest(b)); } diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 5622a7d..0e6363c 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -236,7 +236,7 @@ export class MockGateway implements GatewayClient { return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId }; } - async walletReward(_nonce: string, _diag: string): Promise { + async walletReward(_nonce: string): Promise { // Mock rewarded credit: add the configured payout to the vk segment (no cap in the mock). const vk = this.mockWallet.segments.find((s) => s.source === 'vk'); if (vk) vk.chips += this.mockWallet.rewardChips; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 9fe973b..2c066da 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -240,8 +240,8 @@ export function createTransport(baseUrl: string): GatewayClient { async walletOrder(productId: string) { return codec.decodeWalletOrder(await exec('wallet.order', codec.encodeWalletOrder(productId))); }, - async walletReward(nonce: string, diag: string) { - return codec.decodeWallet(await exec('wallet.reward', codec.encodeWalletReward(nonce, diag))); + async walletReward(nonce: string) { + return codec.decodeWallet(await exec('wallet.reward', codec.encodeWalletReward(nonce))); }, async friendsList() { diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 6a0ab2f..bca6746 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -213,16 +213,15 @@ export async function vkRewardedReady(): Promise { /** * vkShowRewarded shows a rewarded ad (VKWebAppShowNativeAds) and reports whether it was watched - * (data.result) plus the full raw provider result as JSON — forwarded to the backend for the - * temporary contour diagnostic (to confirm exactly what VK returns). A rejected call reports - * not-watched with the error captured. + * (data.result — VK returns only this boolean, no server-verifiable token). A rejected call reports + * not-watched. */ -export async function vkShowRewarded(): Promise<{ watched: boolean; data: string }> { +export async function vkShowRewarded(): Promise { try { const data = await (await bridge()).send('VKWebAppShowNativeAds', { ad_format: 'reward' }); - return { watched: !!(data as { result?: boolean }).result, data: JSON.stringify(data) }; - } catch (e) { - return { watched: false, data: JSON.stringify({ error: String(e) }) }; + return !!(data as { result?: boolean }).result; + } catch { + return false; } } diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 57842bd..332b00a 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -63,7 +63,7 @@ } const nonce = crypto.randomUUID(); const before = wallet?.segments.find((s) => s.source === 'vk')?.chips ?? 0; - const w = await gateway.walletReward(nonce, res.data); + const w = await gateway.walletReward(nonce); wallet = w; const gained = (w.segments.find((s) => s.source === 'vk')?.chips ?? 0) - before; showToast(gained > 0 ? t('wallet.rewardCredited', { n: gained }) : t('wallet.rewardCredited', { n: w.rewardChips })); From 13be7c3d9a8c92027d90301e32701d42213e6536 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 02:48:10 +0200 Subject: [PATCH 26/38] feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PLAN.md | 24 +++- backend/README.md | 14 ++- backend/internal/account/account.go | 57 +-------- .../templates/pages/user_detail.gohtml | 6 - backend/internal/adminconsole/views.go | 13 +- backend/internal/game/service.go | 19 ++- backend/internal/inttest/admin_test.go | 71 ----------- .../internal/inttest/banner_console_test.go | 89 ++++++++++++-- backend/internal/payments/service_intake.go | 6 + backend/internal/payments/store_intake.go | 14 +++ backend/internal/server/banner.go | 45 ++++++- backend/internal/server/dto.go | 21 ++-- .../internal/server/handlers_admin_console.go | 30 ----- docs/PAYMENTS.md | 13 +- docs/PAYMENTS_DECISIONS_ru.md | 12 ++ docs/PAYMENTS_ru.md | 12 +- gateway/internal/backendclient/api.go | 11 ++ gateway/internal/transcode/encode.go | 13 ++ .../internal/transcode/transcode_ads_test.go | 65 ++++++++++ pkg/fbs/scrabble.fbs | 12 ++ pkg/fbs/scrabblefb/AdsInfo.go | 109 ++++++++++++++++ pkg/fbs/scrabblefb/Profile.go | 18 ++- ui/src/game/Game.svelte | 12 ++ ui/src/gen/fbs/scrabblefb.ts | 1 + ui/src/gen/fbs/scrabblefb/ads-info.ts | 76 ++++++++++++ ui/src/gen/fbs/scrabblefb/profile.ts | 12 +- ui/src/lib/ads.test.ts | 116 ++++++++++++++++++ ui/src/lib/ads.ts | 56 ++++++++- ui/src/lib/codec.test.ts | 36 ++++++ ui/src/lib/codec.ts | 15 +++ ui/src/lib/mock/data.ts | 2 + ui/src/lib/model.ts | 11 ++ ui/src/lib/vk.ts | 14 +++ 33 files changed, 805 insertions(+), 220 deletions(-) create mode 100644 gateway/internal/transcode/transcode_ads_test.go create mode 100644 pkg/fbs/scrabblefb/AdsInfo.go create mode 100644 ui/src/gen/fbs/scrabblefb/ads-info.ts create mode 100644 ui/src/lib/ads.test.ts diff --git a/PLAN.md b/PLAN.md index bf7c203..02352b3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -35,7 +35,7 @@ status — without re-deriving decisions. | E3 | Wallet UI | 1 | DONE | | E4 | Durability (PITR) | 2 | DONE | | E5 | Payment intake | 2 | DONE | -| E6 | Ads | 2 | WIP | +| E6 | Ads | 2 | DONE | | E7 | Admin & reports | 2 | TODO | | E8 | Guest limits | — | TODO | | E9 | Tournament fee | future | TODO | @@ -618,7 +618,7 @@ force-recreate when the Caddyfile changes. ## E6 — Ads -**Status:** WIP · **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. **Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice): **rewarded first**, @@ -640,8 +640,19 @@ VK returns **only `{result:true}`** (no token/signature) — client-attested is 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). The legacy `paid_account` -/ `hint_balance` drop (D31) and the **interstitial** are the next slice. +`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 (credits chips via server verify), plus extending the existing banner suppression to @@ -657,8 +668,9 @@ per-origin. - **Interstitial** (post-move fullscreen), configurable server values (from `payments` config): global per-user cooldown across all games (default 5 min); `vs_ai` 30 min; a hint application triggers a post-move interstitial independently with its own 1-min cooldown; - offline banner-only; respect VK's own frequency caps. Cooldown state tracked server-side - (per user) or client-mirrored from a server value — pick and document at implementation. + offline banner-only; respect VK's own frequency caps. Cooldown state is **client-mirrored** + (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 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. diff --git a/backend/README.md b/backend/README.md index 8b6fc3a..7ed70c5 100644 --- a/backend/README.md +++ b/backend/README.md @@ -135,21 +135,23 @@ 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` + `/_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 -the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0 -&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs -publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire +the resolved, weighted campaign feed for an **eligible** viewer (no active **no-ads** benefit +applicable in the current context and no **`no_banner`** role; the message language picked by +`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 shared wire contracts live in the sibling [`../pkg`](../pkg) module. **Account linking & merge** (`/api/v1/user/link/*`). `internal/link` 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 -the two are merged in one transaction (`internal/accountmerge`) — stats and the hint -wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred, +the two are merged in one transaction (`internal/accountmerge`) — stats summed, +identities/games/chat/complaints transferred, 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. 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. -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). Rate-limit observability: the gateway posts its periodic rejection diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 7ff1018..43998c9 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -43,9 +43,7 @@ var ErrNotFound = errors.New("account: not found") // 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 // 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 -// is the player's wallet of purchasable hints, spent after a game's per-seat -// allowance. +// computed in internal/robot, not from a robot account's away window.) type Account struct { ID uuid.UUID DisplayName string @@ -53,7 +51,6 @@ type Account struct { TimeZone string AwayStart time.Time AwayEnd time.Time - HintBalance int BlockChat bool BlockFriendRequests bool // 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 // account. 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 // uuid.Nil for a live account. A tombstone keeps the row so the no-cascade // foreign keys of a shared finished game stay valid. @@ -563,52 +556,6 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, 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 // 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 @@ -664,12 +611,10 @@ func modelToAccount(row model.Accounts) Account { TimeZone: row.TimeZone, AwayStart: row.AwayStart, AwayEnd: row.AwayEnd, - HintBalance: int(row.HintBalance), BlockChat: row.BlockChat, BlockFriendRequests: row.BlockFriendRequests, IsGuest: row.IsGuest, NotificationsInAppOnly: row.NotificationsInAppOnly, - PaidAccount: row.PaidAccount, MergedInto: mergedInto, FlaggedHighRateAt: flaggedHighRateAt, CreatedAt: row.CreatedAt, diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 2d300d1..0a134b5 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -10,8 +10,6 @@
  • Timezone {{.TimeZone}}
  • Guest {{if .Guest}}yes{{else}}no{{end}}
  • Push {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}
  • -
  • Paid {{if .PaidAccount}}yes{{else}}no{{end}}
  • -
  • Hint wallet {{.HintBalance}}
  • {{if .MergedInto}}
  • Merged into {{.MergedInto}}
  • {{end}} {{if .FlaggedHighRateAt}}
  • High-rate flag {{.FlaggedHighRateAt}}
  • {{end}}
  • Created {{.CreatedAt}}
  • @@ -21,10 +19,6 @@ {{end}} -
    - - -

    Statistics

    {{if .HasStats}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 5abfeff..e9d84f6 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -149,7 +149,6 @@ type UserDetailView struct { TimeZone string Guest bool NotificationsInAppOnly bool - PaidAccount bool // MergedInto is the primary account id when this account has been retired by a // merge, or empty for a live account. MergedInto string @@ -165,14 +164,10 @@ type UserDetailView struct { // FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp, // empty for an unflagged account; the card shows it with the Clear action. 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 - HasStats bool - Stats StatsRow - Identities []IdentityRow + CreatedAt string + HasStats bool + Stats StatsRow + Identities []IdentityRow // HasEmail gates the "Erase email" action; set when the account carries an email identity. HasEmail bool Games []GameRow diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 4b7dbae..5c47d0c 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -1272,10 +1272,6 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) if !ok { return StateView{}, ErrNotAPlayer } - acc, err := svc.accounts.GetByID(ctx, accountID) - if err != nil { - return StateView{}, err - } unlock := svc.locks.lock(gameID) defer unlock() @@ -1290,12 +1286,15 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) } } return StateView{ - Game: pre, - Seat: seat, - Rack: g.Hand(seat), - BagLen: g.BagLen(), - HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance), - WalletBalance: acc.HintBalance, + Game: pre, + Seat: seat, + Rack: g.Hand(seat), + BagLen: g.BagLen(), + // The hint wallet moved to payments (svc.hintWallet); the deprecated accounts.hint_balance + // is no longer read, so the wire wallet is 0 and HintsRemaining is the per-seat allowance. + // The client adds the profile's payments hint balance on top (lib/hints.hintsLeft). + HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, 0), + WalletBalance: 0, // vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn). HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()), }, nil diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go index a6c27b1..e78da7f 100644 --- a/backend/internal/inttest/admin_test.go +++ b/backend/internal/inttest/admin_test.go @@ -4,7 +4,6 @@ package inttest import ( "context" - "errors" "net/http" "net/http/httptest" "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 // 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) { diff --git a/backend/internal/inttest/banner_console_test.go b/backend/internal/inttest/banner_console_test.go index 259588b..66112e5 100644 --- a/backend/internal/inttest/banner_console_test.go +++ b/backend/internal/inttest/banner_console_test.go @@ -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 { Banner *struct { Campaigns []struct { @@ -203,6 +203,12 @@ type profileBanner struct { HoldMs int `json:"hold_ms"` } `json:"timings"` } `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 @@ -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 // banner block too, so the client's profile keeps the banner instead of losing it until reload. func TestBannerSurvivesProfileUpdate(t *testing.T) { @@ -440,10 +521,4 @@ func TestBannerUrgentBypassesEligibility(t *testing.T) { if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil { 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()) } diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go index 93a58ce..2f9bedc 100644 --- a/backend/internal/payments/service_intake.go +++ b/backend/internal/payments/service_intake.go @@ -140,6 +140,12 @@ func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, am // 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. diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go index 659254e..3a219dd 100644 --- a/backend/internal/payments/store_intake.go +++ b/backend/internal/payments/store_intake.go @@ -385,6 +385,20 @@ type RewardOutcome struct { 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. diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go index 148b03f..8b68979 100644 --- a/backend/internal/server/banner.go +++ b/backend/internal/server/banner.go @@ -57,9 +57,9 @@ type bannerTimingsDTO struct { func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse { r := profileResponseFor(acc) // 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 - // (context-aware), not the deprecated accounts.hint_balance column; on any failure the legacy - // value from profileResponseFor (zeroed in production) stands. + // the hint count and the banner. The profile hint balance comes from the payments benefit + // (context-aware); the deprecated accounts.hint_balance column is no longer read, so on any + // failure the fallback from profileResponseFor is a plain 0. cxt, present, err := s.walletGate(ctx, acc.ID) if err != nil { s.log.Warn("profile: wallet gate failed", zap.String("account", acc.ID.String()), zap.Error(err)) @@ -71,6 +71,7 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi } } r.Banner = s.bannerFor(ctx, acc, cxt, present) + r.Ads = s.adsFor(ctx, acc, cxt, present) s.fillLinkedIdentities(ctx, &r, acc.ID) r.DictVersions = s.currentDictVersions() return r @@ -177,6 +178,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, // projecting each optional colour set into its three "#rrggbb" fields (empty when // the set is absent, so JSON omitempty drops them). diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index c96cac1..7549539 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -60,6 +60,11 @@ type profileResponse struct { // see the banner (a free account with an empty hint wallet and without the // no_banner role), absent otherwise. See banner.go. 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 // 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 @@ -218,13 +223,15 @@ func sessionResponseFor(token string, acc account.Account) sessionResponse { // profileResponseFor projects an account into its profile DTO. func profileResponseFor(acc account.Account) profileResponse { return profileResponse{ - UserID: acc.ID.String(), - DisplayName: acc.DisplayName, - PreferredLanguage: acc.PreferredLanguage, - TimeZone: acc.TimeZone, - AwayStart: acc.AwayStart.Format(awayTimeLayout), - AwayEnd: acc.AwayEnd.Format(awayTimeLayout), - HintBalance: acc.HintBalance, + UserID: acc.ID.String(), + DisplayName: acc.DisplayName, + PreferredLanguage: acc.PreferredLanguage, + TimeZone: acc.TimeZone, + AwayStart: acc.AwayStart.Format(awayTimeLayout), + AwayEnd: acc.AwayEnd.Format(awayTimeLayout), + // 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, BlockFriendRequests: acc.BlockFriendRequests, IsGuest: acc.IsGuest, diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 48076fe..190a829 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -29,10 +29,6 @@ import ( // adminPageSize is the page size of the admin console's paginated lists. 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 // 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 @@ -56,7 +52,6 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/users/:id", s.consoleUserDetail) gm.POST("/users/:id/message", s.consoleUserMessage) 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/unblock", s.consoleUnblockUser) gm.POST("/users/:id/grant-role", s.consoleGrantRole) @@ -354,7 +349,6 @@ func (s *Server) consoleUserDetail(c *gin.Context) { view := adminconsole.UserDetailView{ ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage, 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, } if acc.MergedInto != uuid.Nil { @@ -963,30 +957,6 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) { 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 // confirmations), freeing the address. It refuses to remove the account's only // identity, which would leave it unreachable. diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index 222e08d..d43837b 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -280,12 +280,19 @@ not suppressed by no-ads (D9). A future network that offers a server verify slot abstraction. On the test contour a build flag (`VITE_ADS_STUB`) swaps a toast for the real ad; prod always shows real ads (a failed real ad must not credit). -**Interstitial** (post-move fullscreen), configurable server-side values: +**Interstitial** (fullscreen after a confirmed play), **VK-only**, configurable server-side +values. The gate is **client-mirrored**: the profile carries the cooldowns and a `suppressed` +flag (the same no-ads / `no_banner` gate as the banner, resolved server-side in `adsFor`), and +the client self-gates on the last-shown time **per kind** in `localStorage` — no per-move +server round-trip. The contour `VITE_ADS_STUB` swaps the same "ad fired" toast for the real ad. +Values: - Global cooldown **per user, across all games**, default **5 min**. - **`vs_ai` — 30 min** (aligned with the hint cooldown, so it does not scare casual players). -- Applying a **hint** triggers a post-move interstitial **independently** of the main - cooldown, with its own **1-min** cooldown. +- Applying a **hint** triggers an interstitial **independently** of the main cooldown, with + its own **1-min** cooldown. +- Fires **only after a confirmed play or a hint** — never after a pass, exchange or resign + (an ad for a non-scoring action would only annoy, so those are never "rewarded" with one). - Offline — banner only. - Respect VK's own frequency caps. diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md index c808915..ca32610 100644 --- a/docs/PAYMENTS_DECISIONS_ru.md +++ b/docs/PAYMENTS_DECISIONS_ru.md @@ -175,12 +175,24 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз (соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода **независимо** от основного кулдауна, со своим кулдауном **1 мин**. Оффлайн — только баннер. Уважать собственные лимиты частоты VK. + **АМЕНД (E6, по факту реализации):** гейт **зеркальный** — сервер отдаёт кулдауны и `suppressed` + в профиле (`adsFor`), клиент сам гейтит по времени последнего показа на каждый вид в `localStorage` + (без раунд-трипа на ход). Ролик показывается **только после подтверждённого хода или подсказки** — + **не** после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»). + Interstitial — **только VK** (как и rewarded). - **D31. `paid_account` тоже deprecated** → удаление из схемы (как `hint_balance`), в пользу per-origin бенефитов «без рекламы». Существующий `ads.Eligible` (`backend/internal/ads/ads.go:107`) расширяется: баннер гасится по origin-бенефиту, **применимому в текущем контексте**, а не по одному глобальному флагу. Legacy `paid_account`/`hint_balance` в проде никем не выставлены (потока покупки не было) → обнуляем/игнорируем, после релиза платежей дропаем. + **АМЕНД (E6, по факту реализации — expand-contract, шаг 1 «contract-код»):** доменное + использование обеих колонок **убрано** — поля `Account.HintBalance` / `Account.PaidAccount`, + их скан, мёртвый `account.SpendHint`, `account.GrantHints` и админ-действие + «grant-hints» (роут `/_gm/users/:id/grant-hints`, форма, `UserDetailView.HintBalance`/ + `PaidAccount`); отображение подсказок в игре теперь всегда из payments (`HintsAvailable`), + профильный баланс — из payments-бенефита. **Колонки БД пока оставлены** (без миграции — + откат образа DB-safe); их `DROP` — отдельным contract-PR, когда E6 стабилен на проде. - **D32. Каталог — конфигурируемый (БД + админка).** Базовые ценности (атомы начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод** diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index f7c73ee..32be293 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -279,12 +279,18 @@ App отдаёт только клиентский результат просм гасится «без рекламы» (D9). Сеть с серверным verify встроится за ads-абстракцией. На тест-контуре build-флаг (`VITE_ADS_STUB`) подменяет ролик тостом; прод всегда крутит настоящую рекламу. -**Полноэкранный ролик** (после хода), конфигурируемые серверные значения: +**Полноэкранный ролик** (после подтверждённого хода), **только VK**, конфигурируемые серверные +значения. Гейт **зеркалится на клиенте**: профиль несёт кулдауны и флаг `suppressed` (тот же гейт +«без рекламы» / `no_banner`, что и у баннера, считается на сервере в `adsFor`), а клиент сам +гейтит по времени последнего показа **на каждый вид** в `localStorage` — без серверного раунд-трипа +на каждый ход. Контурный `VITE_ADS_STUB` подменяет ролик тем же тостом «ad fired». Значения: - Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**. - **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов). -- Применение **подсказки** триггерит ролик после хода **независимо** от основного кулдауна, - со своим кулдауном **1 мин**. +- Применение **подсказки** триггерит ролик **независимо** от основного кулдауна, со своим + кулдауном **1 мин**. +- Показывается **только после подтверждённого хода или подсказки** — никогда после пропуска, + обмена или сдачи (ролик за не-очковое действие только раздражает, за них не «награждаем»). - Оффлайн — только баннер. - Уважать собственные лимиты частоты VK. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 28b929b..b07759c 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -37,6 +37,8 @@ type ProfileResp struct { // Banner is the advertising-banner block, present only for a viewer eligible to // see the banner. The gateway forwards it verbatim into the Profile payload. Banner *BannerResp `json:"banner,omitempty"` + // Ads is the post-move interstitial config (cooldowns + suppressed) for the client-mirrored gate. + Ads *AdsResp `json:"ads,omitempty"` // Email is the confirmed email ("" when none); TelegramLinked/VkLinked report an // attached platform identity — they drive the profile's link/unlink/change controls. Email string `json:"email"` @@ -47,6 +49,15 @@ type ProfileResp struct { DictVersions []DictVersion `json:"dict_versions,omitempty"` } +// AdsResp is the post-move interstitial config in the profile: the client-mirrored cooldowns +// (seconds) and whether ads are suppressed in the caller's context. +type AdsResp struct { + CooldownGlobalS int `json:"cooldown_global_s"` + CooldownVsAiS int `json:"cooldown_vs_ai_s"` + CooldownHintS int `json:"cooldown_hint_s"` + Suppressed bool `json:"suppressed"` +} + // DictVersion pairs a game variant's stable label with its current dictionary version. type DictVersion struct { Variant string `json:"variant"` diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 858c68a..46e3aa3 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -184,6 +184,16 @@ func encodeProfile(p backendclient.ProfileResp) []byte { } prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector) dictVersions := encodeDictVersions(b, p.DictVersions) + // The interstitial-ad config table (scalars only), built before Profile is opened. + var ads flatbuffers.UOffsetT + if p.Ads != nil { + fb.AdsInfoStart(b) + fb.AdsInfoAddCooldownGlobalS(b, int32(p.Ads.CooldownGlobalS)) + fb.AdsInfoAddCooldownVsAiS(b, int32(p.Ads.CooldownVsAiS)) + fb.AdsInfoAddCooldownHintS(b, int32(p.Ads.CooldownHintS)) + fb.AdsInfoAddSuppressed(b, p.Ads.Suppressed) + ads = fb.AdsInfoEnd(b) + } fb.ProfileStart(b) fb.ProfileAddUserId(b, uid) fb.ProfileAddDisplayName(b, name) @@ -206,6 +216,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte { if p.Banner != nil { fb.ProfileAddBanner(b, banner) } + if p.Ads != nil { + fb.ProfileAddAds(b, ads) + } b.Finish(fb.ProfileEnd(b)) return b.FinishedBytes() } diff --git a/gateway/internal/transcode/transcode_ads_test.go b/gateway/internal/transcode/transcode_ads_test.go new file mode 100644 index 0000000..810f977 --- /dev/null +++ b/gateway/internal/transcode/transcode_ads_test.go @@ -0,0 +1,65 @@ +package transcode_test + +import ( + "context" + "net/http" + "testing" + + "scrabble/gateway/internal/transcode" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// TestProfileGetEncodesAds verifies the gateway forwards the backend's interstitial-ad block +// into the Profile payload: the three cooldowns and the suppressed flag the client-mirrored gate +// reads. The encode is not exercised by the mock e2e (it bypasses the codec), so a dropped field +// would only surface here. +func TestProfileGetEncodesAds(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/user/profile" { + t.Errorf("unexpected %s %q", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` + + `"ads":{"cooldown_global_s":300,"cooldown_vs_ai_s":1800,"cooldown_hint_s":60,"suppressed":true}}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, ok := reg.Lookup(transcode.MsgProfileGet) + if !ok { + t.Fatal("profile.get not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + + ads := fb.GetRootAsProfile(payload, 0).Ads(nil) + if ads == nil { + t.Fatal("profile carries no ads block") + } + if ads.CooldownGlobalS() != 300 || ads.CooldownVsAiS() != 1800 || ads.CooldownHintS() != 60 { + t.Errorf("cooldowns = %d/%d/%d, want 300/1800/60", ads.CooldownGlobalS(), ads.CooldownVsAiS(), ads.CooldownHintS()) + } + if !ads.Suppressed() { + t.Error("suppressed = false, want true") + } +} + +// TestProfileGetNoAds verifies a profile without an ads block encodes none (the backend omits it +// only on an internal failure; normally the block is always present). +func TestProfileGetNoAds(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en"}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgProfileGet) + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if p := fb.GetRootAsProfile(payload, 0); p.Ads(nil) != nil { + t.Error("profile without an ads block unexpectedly carries one") + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 124cfb8..6189134 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -264,6 +264,18 @@ table Profile { // preload the right dictionary and pin a new local game without a separate request (added // trailing — backward-compatible). dict_versions:[DictVersion]; + // ads carries the post-move interstitial config (cooldowns + suppressed) for the client-mirrored + // gate (added trailing — backward-compatible). + ads:AdsInfo; +} + +// AdsInfo is the post-move interstitial config: the client-mirrored cooldowns (seconds) and whether +// ads are suppressed in the caller's context (a no-ads benefit here, or the no_banner role). +table AdsInfo { + cooldown_global_s:int; + cooldown_vs_ai_s:int; + cooldown_hint_s:int; + suppressed:bool; } // BlockStatus reports the caller's current manual block. The UI fetches it after any operation diff --git a/pkg/fbs/scrabblefb/AdsInfo.go b/pkg/fbs/scrabblefb/AdsInfo.go new file mode 100644 index 0000000..375457f --- /dev/null +++ b/pkg/fbs/scrabblefb/AdsInfo.go @@ -0,0 +1,109 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type AdsInfo struct { + _tab flatbuffers.Table +} + +func GetRootAsAdsInfo(buf []byte, offset flatbuffers.UOffsetT) *AdsInfo { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &AdsInfo{} + x.Init(buf, n+offset) + return x +} + +func FinishAdsInfoBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsAdsInfo(buf []byte, offset flatbuffers.UOffsetT) *AdsInfo { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &AdsInfo{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedAdsInfoBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *AdsInfo) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *AdsInfo) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *AdsInfo) CooldownGlobalS() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *AdsInfo) MutateCooldownGlobalS(n int32) bool { + return rcv._tab.MutateInt32Slot(4, n) +} + +func (rcv *AdsInfo) CooldownVsAiS() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *AdsInfo) MutateCooldownVsAiS(n int32) bool { + return rcv._tab.MutateInt32Slot(6, n) +} + +func (rcv *AdsInfo) CooldownHintS() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *AdsInfo) MutateCooldownHintS(n int32) bool { + return rcv._tab.MutateInt32Slot(8, n) +} + +func (rcv *AdsInfo) Suppressed() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *AdsInfo) MutateSuppressed(n bool) bool { + return rcv._tab.MutateBoolSlot(10, n) +} + +func AdsInfoStart(builder *flatbuffers.Builder) { + builder.StartObject(4) +} +func AdsInfoAddCooldownGlobalS(builder *flatbuffers.Builder, cooldownGlobalS int32) { + builder.PrependInt32Slot(0, cooldownGlobalS, 0) +} +func AdsInfoAddCooldownVsAiS(builder *flatbuffers.Builder, cooldownVsAiS int32) { + builder.PrependInt32Slot(1, cooldownVsAiS, 0) +} +func AdsInfoAddCooldownHintS(builder *flatbuffers.Builder, cooldownHintS int32) { + builder.PrependInt32Slot(2, cooldownHintS, 0) +} +func AdsInfoAddSuppressed(builder *flatbuffers.Builder, suppressed bool) { + builder.PrependBoolSlot(3, suppressed, false) +} +func AdsInfoEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go index 82f33d0..6b402e1 100644 --- a/pkg/fbs/scrabblefb/Profile.go +++ b/pkg/fbs/scrabblefb/Profile.go @@ -231,8 +231,21 @@ func (rcv *Profile) DictVersionsLength() int { return 0 } +func (rcv *Profile) Ads(obj *AdsInfo) *AdsInfo { + o := flatbuffers.UOffsetT(rcv._tab.Offset(38)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(AdsInfo) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + func ProfileStart(builder *flatbuffers.Builder) { - builder.StartObject(17) + builder.StartObject(18) } func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) @@ -291,6 +304,9 @@ func ProfileAddDictVersions(builder *flatbuffers.Builder, dictVersions flatbuffe func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } +func ProfileAddAds(builder *flatbuffers.Builder, ads flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(17, flatbuffers.UOffsetT(ads), 0) +} func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index e76709f..bc77ae8 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -15,6 +15,7 @@ import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; import { offlineMode } from '../lib/offline.svelte'; + import { maybeShowInterstitial } from '../lib/ads'; import { GatewayError } from '../lib/client'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; @@ -946,6 +947,12 @@ if (view?.game.hotseat) await advanceHotseat(); haptic('success'); zoomed = false; + // A confirmed move may trigger a post-move interstitial (VK, frequency-gated, client-mirrored). + // Only submitPlay earns one — never a pass / exchange / resign. Fire-and-forget. + void maybeShowInterstitial(app.profile?.ads, 'move', { + vsAi: !!view?.game.vsAi, + online: connection.online && !offlineMode.active, + }); } catch (e) { handleError(e); } finally { @@ -1007,6 +1014,11 @@ } try { const h = await source.hint(id); + // Applying a hint triggers its own post-move interstitial, independent of the move cooldown. + void maybeShowInterstitial(app.profile?.ads, 'hint', { + vsAi: !!view?.game.vsAi, + online: connection.online && !offlineMode.active, + }); if (h.move.tiles.length && view) { placement = placementFromHint(h.move.tiles, view.rack); // Scroll the (zoomed) board to the hint's placement rather than the top-left: diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 7633964..d9055d9 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -4,6 +4,7 @@ export { AccountDeleteConfirm } from './scrabblefb/account-delete-confirm.js'; export { AccountDeleteRequestResult } from './scrabblefb/account-delete-request-result.js'; export { AccountRef } from './scrabblefb/account-ref.js'; export { Ack } from './scrabblefb/ack.js'; +export { AdsInfo } from './scrabblefb/ads-info.js'; export { AlphabetEntry } from './scrabblefb/alphabet-entry.js'; export { BannerCampaign } from './scrabblefb/banner-campaign.js'; export { BannerInfo } from './scrabblefb/banner-info.js'; diff --git a/ui/src/gen/fbs/scrabblefb/ads-info.ts b/ui/src/gen/fbs/scrabblefb/ads-info.ts new file mode 100644 index 0000000..35b7cb2 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/ads-info.ts @@ -0,0 +1,76 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class AdsInfo { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):AdsInfo { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsAdsInfo(bb:flatbuffers.ByteBuffer, obj?:AdsInfo):AdsInfo { + return (obj || new AdsInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsAdsInfo(bb:flatbuffers.ByteBuffer, obj?:AdsInfo):AdsInfo { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new AdsInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +cooldownGlobalS():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +cooldownVsAiS():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +cooldownHintS():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +suppressed():boolean { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +static startAdsInfo(builder:flatbuffers.Builder) { + builder.startObject(4); +} + +static addCooldownGlobalS(builder:flatbuffers.Builder, cooldownGlobalS:number) { + builder.addFieldInt32(0, cooldownGlobalS, 0); +} + +static addCooldownVsAiS(builder:flatbuffers.Builder, cooldownVsAiS:number) { + builder.addFieldInt32(1, cooldownVsAiS, 0); +} + +static addCooldownHintS(builder:flatbuffers.Builder, cooldownHintS:number) { + builder.addFieldInt32(2, cooldownHintS, 0); +} + +static addSuppressed(builder:flatbuffers.Builder, suppressed:boolean) { + builder.addFieldInt8(3, +suppressed, +false); +} + +static endAdsInfo(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createAdsInfo(builder:flatbuffers.Builder, cooldownGlobalS:number, cooldownVsAiS:number, cooldownHintS:number, suppressed:boolean):flatbuffers.Offset { + AdsInfo.startAdsInfo(builder); + AdsInfo.addCooldownGlobalS(builder, cooldownGlobalS); + AdsInfo.addCooldownVsAiS(builder, cooldownVsAiS); + AdsInfo.addCooldownHintS(builder, cooldownHintS); + AdsInfo.addSuppressed(builder, suppressed); + return AdsInfo.endAdsInfo(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/profile.ts b/ui/src/gen/fbs/scrabblefb/profile.ts index 13ca0ed..06e536e 100644 --- a/ui/src/gen/fbs/scrabblefb/profile.ts +++ b/ui/src/gen/fbs/scrabblefb/profile.ts @@ -2,6 +2,7 @@ import * as flatbuffers from 'flatbuffers'; +import { AdsInfo } from '../scrabblefb/ads-info.js'; import { BannerInfo } from '../scrabblefb/banner-info.js'; import { DictVersion } from '../scrabblefb/dict-version.js'; @@ -135,8 +136,13 @@ dictVersionsLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } +ads(obj?:AdsInfo):AdsInfo|null { + const offset = this.bb!.__offset(this.bb_pos, 38); + return offset ? (obj || new AdsInfo()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + static startProfile(builder:flatbuffers.Builder) { - builder.startObject(17); + builder.startObject(18); } static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { @@ -231,6 +237,10 @@ static startDictVersionsVector(builder:flatbuffers.Builder, numElems:number) { builder.startVector(4, numElems, 4); } +static addAds(builder:flatbuffers.Builder, adsOffset:flatbuffers.Offset) { + builder.addFieldOffset(17, adsOffset, 0); +} + static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; diff --git a/ui/src/lib/ads.test.ts b/ui/src/lib/ads.test.ts new file mode 100644 index 0000000..74fc04a --- /dev/null +++ b/ui/src/lib/ads.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AdsConfig } from './model'; + +// Mock ads.ts's three impure imports so the client-mirrored gate is observable without a VK +// bridge, a wallet context or the Svelte toast store. ./model is type-only (erased). +const mocks = vi.hoisted(() => ({ + vkShowInterstitial: vi.fn(), + vkRewardedReady: vi.fn(), + vkShowRewarded: vi.fn(), + executionContext: vi.fn(), + showToast: vi.fn(), +})); +vi.mock('./vk', () => ({ + vkShowInterstitial: mocks.vkShowInterstitial, + vkRewardedReady: mocks.vkRewardedReady, + vkShowRewarded: mocks.vkShowRewarded, +})); +vi.mock('./wallet', () => ({ executionContext: mocks.executionContext })); +vi.mock('./app.svelte', () => ({ showToast: mocks.showToast })); + +import { maybeShowInterstitial } from './ads'; + +const ADS: AdsConfig = { cooldownGlobalS: 300, cooldownVsAiS: 1800, cooldownHintS: 60, suppressed: false }; +const ONLINE = { vsAi: false, online: true }; +// A realistic epoch base: the never-shown last time is 0, so the first show needs now >> cooldown +// (as with a real Date.now); anchoring at 0 would gate the very first call (now - 0 < cooldown). +const BASE = 1_700_000_000_000; + +beforeEach(() => { + // A minimal in-memory localStorage (node has none) so the per-kind last-shown gate persists + // across calls within a test. + const store = new Map(); + (globalThis as unknown as { localStorage: Storage }).localStorage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + key: () => null, + length: 0, + } as Storage; + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(BASE); + mocks.executionContext.mockReturnValue('vk'); + mocks.vkShowInterstitial.mockResolvedValue(true); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); + +describe('maybeShowInterstitial (client-mirrored gate)', () => { + it('does not show when the config is absent', async () => { + expect(await maybeShowInterstitial(undefined, 'move', ONLINE)).toBe(false); + expect(mocks.vkShowInterstitial).not.toHaveBeenCalled(); + }); + + it('does not show when suppressed (no-ads / no_banner)', async () => { + expect(await maybeShowInterstitial({ ...ADS, suppressed: true }, 'move', ONLINE)).toBe(false); + expect(mocks.showToast).not.toHaveBeenCalled(); + expect(mocks.vkShowInterstitial).not.toHaveBeenCalled(); + }); + + it('does not show when offline', async () => { + expect(await maybeShowInterstitial(ADS, 'move', { vsAi: false, online: false })).toBe(false); + expect(mocks.vkShowInterstitial).not.toHaveBeenCalled(); + }); + + it('does not show outside VK (real ad path)', async () => { + mocks.executionContext.mockReturnValue('web'); + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(false); + expect(mocks.vkShowInterstitial).not.toHaveBeenCalled(); + }); + + it('shows a VK interstitial inside VK when the cooldown allows', async () => { + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); + expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(1); + expect(mocks.showToast).not.toHaveBeenCalled(); + }); + + it('respects the per-kind cooldown: a second move within the window is gated', async () => { + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); + vi.setSystemTime(BASE + 299_000); // +299s < the 300s global cooldown + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(false); + vi.setSystemTime(BASE + 300_000); // the cooldown has now elapsed + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); + expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(2); + }); + + it('tracks move and hint cooldowns independently', async () => { + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); + // A hint right after a move still fires — a separate kind key with its own 60s cooldown. + expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(true); + vi.setSystemTime(BASE + 59_000); // +59s < the 60s hint cooldown + expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(false); + vi.setSystemTime(BASE + 60_000); + expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(true); + }); + + it('uses the longer vs_ai cooldown for a vs_ai move', async () => { + expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true); + vi.setSystemTime(BASE + 1_799_000); // +1799s < the 1800s vs_ai cooldown + expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(false); + vi.setSystemTime(BASE + 1_800_000); + expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true); + }); + + it('shows the test-stub toast instead of a real ad when the stub flag is set', async () => { + vi.stubEnv('VITE_ADS_STUB', '1'); + mocks.executionContext.mockReturnValue('web'); // the stub bypasses the VK gate + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); + expect(mocks.showToast).toHaveBeenCalledWith('ad fired'); + expect(mocks.vkShowInterstitial).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/lib/ads.ts b/ui/src/lib/ads.ts index f68a442..c3f2a48 100644 --- a/ui/src/lib/ads.ts +++ b/ui/src/lib/ads.ts @@ -4,7 +4,9 @@ // flow is testable without waiting for a real ad — production never sets the flag, so a real ad that // fails to load there must not credit (no stub fallback in prod). import { executionContext } from './wallet'; -import { vkRewardedReady, vkShowRewarded } from './vk'; +import { vkRewardedReady, vkShowRewarded, vkShowInterstitial } from './vk'; +import { showToast } from './app.svelte'; +import type { AdsConfig } from './model'; /** * adsStubEnabled reports the contour test stub (VITE_ADS_STUB=1). Production never sets it, so it @@ -46,3 +48,55 @@ export async function showRewarded(): Promise { } return { watched: await vkShowRewarded(), stub: false }; } + +// The post-move interstitial gate is client-mirrored: the server sends the cooldowns + suppressed in +// the profile (Profile.ads); the client tracks the last-shown time per kind in localStorage and +// self-gates. Last-shown is per-device (cleared with storage) — acceptable for a frequency gate. +const INTERSTITIAL_LAST_KEY = 'ads.interstitial.last'; + +// interstitialLast reads the last-shown epoch millis per kind (0 when absent/corrupt). +function interstitialLast(): { move: number; hint: number } { + try { + const raw = localStorage.getItem(INTERSTITIAL_LAST_KEY); + if (raw) { + const j = JSON.parse(raw) as Partial<{ move: number; hint: number }>; + return { move: j.move ?? 0, hint: j.hint ?? 0 }; + } + } catch { + /* a corrupt or unavailable store simply resets the gate */ + } + return { move: 0, hint: 0 }; +} + +function recordInterstitial(kind: 'move' | 'hint', now: number): void { + try { + const l = interstitialLast(); + l[kind] = now; + localStorage.setItem(INTERSTITIAL_LAST_KEY, JSON.stringify(l)); + } catch { + /* ignore a write failure — the worst case is one extra ad next time */ + } +} + +/** + * maybeShowInterstitial shows a post-move (`kind='move'`, global / vs_ai cooldown) or post-hint + * (`kind='hint'`, its own short cooldown) interstitial when the client-mirrored gate allows: not + * suppressed (no-ads), online, inside VK (the stub bypasses VK), and the mirrored cooldown elapsed. + * The stub (contour test) shows an "ad fired" toast. Returns whether an ad was shown. Fire-and-forget + * — the caller does not await a result to proceed. + */ +export async function maybeShowInterstitial( + ads: AdsConfig | undefined, + kind: 'move' | 'hint', + opts: { vsAi: boolean; online: boolean }, +): Promise { + if (!ads || ads.suppressed || !opts.online) return false; + const stub = adsStubEnabled(); + if (!stub && executionContext() !== 'vk') return false; + const cooldownS = kind === 'hint' ? ads.cooldownHintS : opts.vsAi ? ads.cooldownVsAiS : ads.cooldownGlobalS; + const now = Date.now(); + if (now - interstitialLast()[kind] < cooldownS * 1000) return false; + const shown = stub ? (showToast('ad fired'), true) : await vkShowInterstitial(); + if (shown) recordInterstitial(kind, now); + return shown; +} diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 3bc24df..537890b 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -814,6 +814,42 @@ describe('codec', () => { expect(decodeProfile(b.asUint8Array()).banner).toBeUndefined(); }); + it('decodes the profile interstitial-ad config', () => { + const b = new Builder(64); + const uid = b.createString('u-1'); + const ads = fb.AdsInfo.createAdsInfo(b, 300, 1800, 60, false); + fb.Profile.startProfile(b); + fb.Profile.addUserId(b, uid); + fb.Profile.addAds(b, ads); + b.finish(fb.Profile.endProfile(b)); + expect(decodeProfile(b.asUint8Array()).ads).toEqual({ + cooldownGlobalS: 300, + cooldownVsAiS: 1800, + cooldownHintS: 60, + suppressed: false, + }); + }); + + it('carries the suppressed flag through the ad config', () => { + const b = new Builder(64); + const uid = b.createString('u-1'); + const ads = fb.AdsInfo.createAdsInfo(b, 300, 1800, 60, true); + fb.Profile.startProfile(b); + fb.Profile.addUserId(b, uid); + fb.Profile.addAds(b, ads); + b.finish(fb.Profile.endProfile(b)); + expect(decodeProfile(b.asUint8Array()).ads?.suppressed).toBe(true); + }); + + it('leaves the profile ads undefined when absent', () => { + const b = new Builder(64); + const uid = b.createString('u-1'); + fb.Profile.startProfile(b); + fb.Profile.addUserId(b, uid); + b.finish(fb.Profile.endProfile(b)); + expect(decodeProfile(b.asUint8Array()).ads).toBeUndefined(); + }); + it('decodes the profile variant preferences', () => { const b = new Builder(64); const uid = b.createString('u-1'); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index f229873..f1724f9 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -14,6 +14,7 @@ import type { RobotBlockEntry, RobotFriendRequestEntry, Banner, + AdsConfig, BannerCampaign, BestMove, BestMoveTile, @@ -461,6 +462,7 @@ export function decodeProfile(buf: Uint8Array): Profile { notificationsInAppOnly: p.notificationsInAppOnly(), variantPreferences: decodeVariantPreferences(p), banner: decodeBanner(p), + ads: decodeAds(p), email: s(p.email()), telegramLinked: p.telegramLinked(), vkLinked: p.vkLinked(), @@ -497,6 +499,19 @@ function bannerTriple(bg: string | null, fg: string | null, link: string | null) return bg && fg && link ? { bg, fg, link } : null; } +// decodeAds projects the optional post-move interstitial config of a Profile, or undefined when +// absent (an old backend); the client then shows no interstitial. +function decodeAds(p: fb.Profile): AdsConfig | undefined { + const a = p.ads(); + if (!a) return undefined; + return { + cooldownGlobalS: a.cooldownGlobalS(), + cooldownVsAiS: a.cooldownVsAiS(), + cooldownHintS: a.cooldownHintS(), + suppressed: a.suppressed(), + }; +} + // decodeBanner projects the optional advertising-banner block of a Profile, or // undefined when the viewer is not eligible (the field is absent). function decodeBanner(p: fb.Profile): Banner | undefined { diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index ba5c112..143e4d2 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -45,6 +45,8 @@ export const PROFILE: Profile = { // Every variant's current dictionary version, as the backend advertises it on the profile — // the offline preloader targets `variant@version`. dictVersions: { erudit_ru: 'v1.3.0', scrabble_ru: 'v1.3.0', scrabble_en: 'v1.3.0' }, + // Post-move interstitial config (short cooldowns so the mock stub is exercisable); not suppressed. + ads: { cooldownGlobalS: 300, cooldownVsAiS: 1800, cooldownHintS: 60, suppressed: false }, }; // Seed social/account data for the mock (pnpm start + Playwright). The mock profile diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 2d02a8c..96ee9f7 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -226,6 +226,8 @@ export interface Profile { variantPreferences: Variant[]; /** The advertising-banner block, present only for a viewer eligible to see it. */ banner?: Banner; + /** The post-move interstitial-ad config for the client-mirrored gate (cooldowns + suppressed). */ + ads?: AdsConfig; /** The account's confirmed email address ("" when none). */ email: string; /** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */ @@ -237,6 +239,15 @@ export interface Profile { dictVersions: Partial>; } +/** The post-move interstitial-ad config for the client-mirrored gate: the cooldowns (seconds) and + * whether ads are suppressed in the current context (a no-ads benefit here, or the no_banner role). */ +export interface AdsConfig { + cooldownGlobalS: number; + cooldownVsAiS: number; + cooldownHintS: number; + suppressed: boolean; +} + /** Banner is the advertising-banner block of an eligible viewer's profile. */ export interface Banner { campaigns: BannerCampaign[]; diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index bca6746..68d6a5f 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -225,6 +225,20 @@ export async function vkShowRewarded(): Promise { } } +/** + * vkShowInterstitial shows a between-screens interstitial ad (VKWebAppShowNativeAds, interstitial + * format) and reports whether it was shown. It carries no reward — it is the post-move fullscreen ad. + * A rejected or unavailable call reports false. + */ +export async function vkShowInterstitial(): Promise { + try { + const data = await (await bridge()).send('VKWebAppShowNativeAds', { ad_format: 'interstitial' }); + return !!(data as { result?: boolean }).result; + } catch { + return false; + } +} + /** * vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) — * the mobile in-app file delivery, where the webview ignores . Resolves false From 2ce80c241dd6b5a1f0112342a9c07028c04d3d63 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 03:12:30 +0200 Subject: [PATCH 27/38] fix(ads): fire the hint interstitial on the confirmed move, not on the hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/e2e/game.spec.ts | 27 +++++++++++++++++++++++++++ ui/src/game/Game.svelte | 21 +++++++++++++++------ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index fd2512e..ec2f1b3 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -48,6 +48,33 @@ test('placing a tile and confirming via ✅ commits the move', async ({ page }) await expect(page.locator('.make')).toBeHidden(); }); +// Regression: taking a hint must NOT fire the post-move interstitial. Firing on the hint interrupted +// placing the preview and reverted the board on the ad's close while the hint stayed spent (the +// reported bug). In mock mode the ad stub shows an "ad fired" toast, standing in for a real VK ad. +test('taking a hint does not fire the interstitial', async ({ page }) => { + await openGame(page); + // Take a hint (the control confirms on a second tap). It produces a legal preview, so the ✅ + // control appears — proof the hint fired. + const hint = page.getByRole('button', { name: 'Hint' }); + await hint.click(); + await hint.click(); + await expect(page.locator('.make')).toBeVisible(); + // A spurious ad would have toasted by now; none may. + await page.waitForTimeout(400); + await expect(page.getByText('ad fired')).toHaveCount(0); +}); + +// The interstitial fires once the move is CONFIRMED (never on the hint / pass / exchange / resign). +test('confirming a move fires the interstitial', async ({ page }) => { + await openGame(page); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + await expect(page.locator('[data-cell].pending')).toHaveCount(1); + + await page.locator('.make').click(); + await expect(page.getByText('ad fired')).toBeVisible(); +}); + test('a placed tile is saved as a draft and restored on reopening the game', async ({ page }) => { await openGame(page); await page.locator('.rack .tile').first().click(); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index bc77ae8..f6ab404 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -94,6 +94,11 @@ let exchangeOpen = $state(false); let exchangeSel = $state([]); let resignOpen = $state(false); + // hintUsedThisTurn marks that a hint was applied on the current turn, so a confirmed play earns + // the hint-kind interstitial (its own 1-min cooldown) instead of the plain move one. Set in + // doHint when the hint's tiles land, read in commit, and cleared on any turn boundary + // (applyMoveResult) so a hint-then-pass does not leak into the next turn's move. + let hintUsedThisTurn = $state(false); let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null); // Landscape (wide) layout: when the viewport is wider than tall the game switches to a // two-column layout — the board fills the right side as a square fitted to the height (no @@ -820,6 +825,9 @@ // applyMoveResult renders the actor's own just-committed move from the response — the move, the // post-move game and the refilled rack — without a follow-up game.state + game.history. function applyMoveResult(r: MoveResult) { + // A turn boundary (play / pass / exchange / resign): clear the hint marker so it never leaks + // into the next turn. commit captures it before this runs. + hintUsedThisTurn = false; view = { game: r.game, seat: r.move.player, @@ -942,6 +950,9 @@ const sub = toSubmit(placement); if (!sub) return; busy = true; + // Capture the hint marker before applyMoveResult clears it: a move played off a hint earns the + // hint-kind interstitial (own cooldown), a plain move the move-kind one. + const usedHint = hintUsedThisTurn; try { applyMoveResult(await source.submitPlay(id, sub.tiles, variant)); if (view?.game.hotseat) await advanceHotseat(); @@ -949,7 +960,7 @@ zoomed = false; // A confirmed move may trigger a post-move interstitial (VK, frequency-gated, client-mirrored). // Only submitPlay earns one — never a pass / exchange / resign. Fire-and-forget. - void maybeShowInterstitial(app.profile?.ads, 'move', { + void maybeShowInterstitial(app.profile?.ads, usedHint ? 'hint' : 'move', { vsAi: !!view?.game.vsAi, online: connection.online && !offlineMode.active, }); @@ -1014,13 +1025,11 @@ } try { const h = await source.hint(id); - // Applying a hint triggers its own post-move interstitial, independent of the move cooldown. - void maybeShowInterstitial(app.profile?.ads, 'hint', { - vsAi: !!view?.game.vsAi, - online: connection.online && !offlineMode.active, - }); if (h.move.tiles.length && view) { placement = placementFromHint(h.move.tiles, view.rack); + // Mark the turn as hinted: the interstitial fires when the player CONFIRMS the move (commit), + // not now — showing it here would interrupt placing the preview and revert the board on close. + hintUsedThisTurn = true; // Scroll the (zoomed) board to the hint's placement rather than the top-left: // focus the centre of the laid tiles' bounding box. const p = placement.pending; From b5c8a04f0b9425e462588c9945860cd2a2462444 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 03:36:18 +0200 Subject: [PATCH 28/38] fix(ads): share one interstitial cooldown timer across kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/PAYMENTS.md | 7 ++++--- docs/PAYMENTS_DECISIONS_ru.md | 12 ++++++++---- docs/PAYMENTS_ru.md | 6 ++++-- ui/src/lib/ads.test.ts | 22 +++++++++++++++------ ui/src/lib/ads.ts | 37 ++++++++++++++++++++--------------- 5 files changed, 53 insertions(+), 31 deletions(-) diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index d43837b..15e6744 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -283,9 +283,10 @@ always shows real ads (a failed real ad must not credit). **Interstitial** (fullscreen after a confirmed play), **VK-only**, configurable server-side values. The gate is **client-mirrored**: the profile carries the cooldowns and a `suppressed` flag (the same no-ads / `no_banner` gate as the banner, resolved server-side in `adsFor`), and -the client self-gates on the last-shown time **per kind** in `localStorage` — no per-move -server round-trip. The contour `VITE_ADS_STUB` swaps the same "ad fired" toast for the real ad. -Values: +the client self-gates on a **single shared** last-shown time in `localStorage` — the kind only +picks the required gap, so a hint ad and a move ad never fire within a cooldown of each other +(one shared timer, not one per kind) — no per-move server round-trip. The contour +`VITE_ADS_STUB` swaps the same "ad fired" toast for the real ad. Values: - Global cooldown **per user, across all games**, default **5 min**. - **`vs_ai` — 30 min** (aligned with the hint cooldown, so it does not scare casual players). diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md index ca32610..5bbab57 100644 --- a/docs/PAYMENTS_DECISIONS_ru.md +++ b/docs/PAYMENTS_DECISIONS_ru.md @@ -176,10 +176,14 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз **независимо** от основного кулдауна, со своим кулдауном **1 мин**. Оффлайн — только баннер. Уважать собственные лимиты частоты VK. **АМЕНД (E6, по факту реализации):** гейт **зеркальный** — сервер отдаёт кулдауны и `suppressed` - в профиле (`adsFor`), клиент сам гейтит по времени последнего показа на каждый вид в `localStorage` - (без раунд-трипа на ход). Ролик показывается **только после подтверждённого хода или подсказки** — - **не** после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»). - Interstitial — **только VK** (как и rewarded). + в профиле (`adsFor`), клиент сам гейтит по **единому** времени последнего показа в `localStorage` + (без раунд-трипа на ход). Таймер **общий на все виды** — вид (`hint`/`move`/`vs_ai`) лишь выбирает + нужный интервал, поэтому «независимость» подсказочного кулдауна значит лишь **более короткий + интервал от последнего показа**, а не отдельный таймер: hint-ролик и move-ролик не встают подряд + (баг раздельных таймеров: после hint-ролика move-таймер оставался нулевым → следующий ход сразу + крутил рекламу). Ролик показывается **только после подтверждённого хода или подсказки** — **не** + после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»). Interstitial — + **только VK** (как и rewarded). Частота — глобальная на все игры (localStorage на устройство). - **D31. `paid_account` тоже deprecated** → удаление из схемы (как `hint_balance`), в пользу per-origin бенефитов «без рекламы». Существующий `ads.Eligible` (`backend/internal/ads/ads.go:107`) расширяется: баннер гасится по origin-бенефиту, diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index 32be293..10ae6b0 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -282,8 +282,10 @@ build-флаг (`VITE_ADS_STUB`) подменяет ролик тостом; п **Полноэкранный ролик** (после подтверждённого хода), **только VK**, конфигурируемые серверные значения. Гейт **зеркалится на клиенте**: профиль несёт кулдауны и флаг `suppressed` (тот же гейт «без рекламы» / `no_banner`, что и у баннера, считается на сервере в `adsFor`), а клиент сам -гейтит по времени последнего показа **на каждый вид** в `localStorage` — без серверного раунд-трипа -на каждый ход. Контурный `VITE_ADS_STUB` подменяет ролик тем же тостом «ad fired». Значения: +гейтит по **единому** времени последнего показа в `localStorage` — вид лишь выбирает нужный +интервал, поэтому hint-ролик и move-ролик не встают подряд в пределах кулдауна (один общий таймер, +не по одному на вид) — без серверного раунд-трипа на каждый ход. Контурный `VITE_ADS_STUB` подменяет +ролик тем же тостом «ad fired». Значения: - Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**. - **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов). diff --git a/ui/src/lib/ads.test.ts b/ui/src/lib/ads.test.ts index 74fc04a..79f9ece 100644 --- a/ui/src/lib/ads.test.ts +++ b/ui/src/lib/ads.test.ts @@ -79,7 +79,7 @@ describe('maybeShowInterstitial (client-mirrored gate)', () => { expect(mocks.showToast).not.toHaveBeenCalled(); }); - it('respects the per-kind cooldown: a second move within the window is gated', async () => { + it('respects the cooldown: a second move within the window is gated', async () => { expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); vi.setSystemTime(BASE + 299_000); // +299s < the 300s global cooldown expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(false); @@ -88,16 +88,26 @@ describe('maybeShowInterstitial (client-mirrored gate)', () => { expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(2); }); - it('tracks move and hint cooldowns independently', async () => { + it('shares one timer across kinds: a hint uses the shorter gap from the last ad', async () => { + // A move ad, then a hint: the hint is held until its own (shorter) 60s gap from THAT ad has + // elapsed — not fired at once (a per-kind timer used to let it fire immediately). expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); - // A hint right after a move still fires — a separate kind key with its own 60s cooldown. - expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(true); - vi.setSystemTime(BASE + 59_000); // +59s < the 60s hint cooldown + vi.setSystemTime(BASE + 59_000); expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(false); - vi.setSystemTime(BASE + 60_000); + vi.setSystemTime(BASE + 60_000); // the hint's 60s gap from the last ad has elapsed expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(true); }); + it('does not stack a move ad onto a just-shown hint ad (the reported bug)', async () => { + // A vs_ai hint-move fires the hint ad; a plain vs_ai move 30s later must NOT fire — the shared + // timer holds it for the vs_ai gap. Separate per-kind timers let it fire at once (the bug). + const vsAi = { vsAi: true, online: true }; + expect(await maybeShowInterstitial(ADS, 'hint', vsAi)).toBe(true); + vi.setSystemTime(BASE + 30_000); // 30s later, far under the 1800s vs_ai gap + expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(false); + expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(1); // only the hint ad fired + }); + it('uses the longer vs_ai cooldown for a vs_ai move', async () => { expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true); vi.setSystemTime(BASE + 1_799_000); // +1799s < the 1800s vs_ai cooldown diff --git a/ui/src/lib/ads.ts b/ui/src/lib/ads.ts index c3f2a48..ff555a7 100644 --- a/ui/src/lib/ads.ts +++ b/ui/src/lib/ads.ts @@ -50,29 +50,33 @@ export async function showRewarded(): Promise { } // The post-move interstitial gate is client-mirrored: the server sends the cooldowns + suppressed in -// the profile (Profile.ads); the client tracks the last-shown time per kind in localStorage and -// self-gates. Last-shown is per-device (cleared with storage) — acceptable for a frequency gate. +// the profile (Profile.ads); the client tracks the last-shown time in localStorage and self-gates. +// Last-shown is per-device (cleared with storage) — acceptable for a frequency gate. +// +// It is a SINGLE shared timestamp across kinds, not one per kind: the kind only selects the required +// gap (hint's short cooldown vs the move / vs_ai one), while the wait is always measured from the +// last interstitial of ANY kind. A per-kind timer let a hint ad and a move ad stack — after a +// hint-move ad the move timer was still zero, so the next plain move fired an ad immediately. const INTERSTITIAL_LAST_KEY = 'ads.interstitial.last'; -// interstitialLast reads the last-shown epoch millis per kind (0 when absent/corrupt). -function interstitialLast(): { move: number; hint: number } { +// interstitialLast reads the last-shown epoch millis of any interstitial (0 when absent/corrupt, or +// when a pre-shared-timer value — an object — is still stored: it decodes to 0 and self-heals). +function interstitialLast(): number { try { const raw = localStorage.getItem(INTERSTITIAL_LAST_KEY); if (raw) { - const j = JSON.parse(raw) as Partial<{ move: number; hint: number }>; - return { move: j.move ?? 0, hint: j.hint ?? 0 }; + const n = Number(raw); + return Number.isFinite(n) ? n : 0; } } catch { /* a corrupt or unavailable store simply resets the gate */ } - return { move: 0, hint: 0 }; + return 0; } -function recordInterstitial(kind: 'move' | 'hint', now: number): void { +function recordInterstitial(now: number): void { try { - const l = interstitialLast(); - l[kind] = now; - localStorage.setItem(INTERSTITIAL_LAST_KEY, JSON.stringify(l)); + localStorage.setItem(INTERSTITIAL_LAST_KEY, String(now)); } catch { /* ignore a write failure — the worst case is one extra ad next time */ } @@ -81,9 +85,10 @@ function recordInterstitial(kind: 'move' | 'hint', now: number): void { /** * maybeShowInterstitial shows a post-move (`kind='move'`, global / vs_ai cooldown) or post-hint * (`kind='hint'`, its own short cooldown) interstitial when the client-mirrored gate allows: not - * suppressed (no-ads), online, inside VK (the stub bypasses VK), and the mirrored cooldown elapsed. - * The stub (contour test) shows an "ad fired" toast. Returns whether an ad was shown. Fire-and-forget - * — the caller does not await a result to proceed. + * suppressed (no-ads), online, inside VK (the stub bypasses VK), and the required gap has elapsed + * since the last interstitial of any kind. The kind picks the gap; the timer is shared, so a hint ad + * and a move ad never fire within a cooldown of each other. The stub (contour test) shows an "ad + * fired" toast. Returns whether an ad was shown. Fire-and-forget — the caller does not await it. */ export async function maybeShowInterstitial( ads: AdsConfig | undefined, @@ -95,8 +100,8 @@ export async function maybeShowInterstitial( if (!stub && executionContext() !== 'vk') return false; const cooldownS = kind === 'hint' ? ads.cooldownHintS : opts.vsAi ? ads.cooldownVsAiS : ads.cooldownGlobalS; const now = Date.now(); - if (now - interstitialLast()[kind] < cooldownS * 1000) return false; + if (now - interstitialLast() < cooldownS * 1000) return false; const shown = stub ? (showToast('ad fired'), true) : await vkShowInterstitial(); - if (shown) recordInterstitial(kind, now); + if (shown) recordInterstitial(now); return shown; } From 7ce8101cfaf64d25fe39c1f0cc3487e1bb37e2f6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 03:43:57 +0200 Subject: [PATCH 29/38] test(ads): lock the hint-pushed ad restarting the vs_ai gap (no over-serving) --- ui/src/lib/ads.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ui/src/lib/ads.test.ts b/ui/src/lib/ads.test.ts index 79f9ece..4e5d739 100644 --- a/ui/src/lib/ads.test.ts +++ b/ui/src/lib/ads.test.ts @@ -108,6 +108,20 @@ describe('maybeShowInterstitial (client-mirrored gate)', () => { expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(1); // only the hint ad fired }); + it('a hint-pushed ad restarts the vs_ai gap, so the next plain move is not over-served', async () => { + // The scenario the owner flagged: a vs_ai ad, then 20 min later a hinted move pushes an ad on + // its short gap, then a plain move 10 min after that must NOT fire — the shared timer restarted + // the 30-min vs_ai gap at the hint ad, so 10 min is not enough (it fires only 30 min after it). + const vsAi = { vsAi: true, online: true }; + expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(true); // t0 + vi.setSystemTime(BASE + 20 * 60_000); // +20 min: a hint pushes an ad on its 1-min gap + expect(await maybeShowInterstitial(ADS, 'hint', vsAi)).toBe(true); + vi.setSystemTime(BASE + 30 * 60_000); // +10 min after the hint ad + expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(false); + vi.setSystemTime(BASE + 50 * 60_000); // +30 min after the hint ad + expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(true); + }); + it('uses the longer vs_ai cooldown for a vs_ai move', async () => { expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true); vi.setSystemTime(BASE + 1_799_000); // +1799s < the 1800s vs_ai cooldown From e089a0a997eef39c0db6fa47dce37b91f335e20d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 04:55:55 +0200 Subject: [PATCH 30/38] =?UTF-8?q?feat(admin):=20per-user=20finance=20panel?= =?UTF-8?q?=20=E2=80=94=20balances,=20benefits,=20risk,=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PLAN.md | 117 ++++++++++++++---- backend/README.md | 5 +- .../templates/pages/user_detail.gohtml | 22 ++++ backend/internal/adminconsole/views.go | 46 +++++++ .../inttest/payments_statement_test.go | 101 +++++++++++++++ backend/internal/payments/statement.go | 63 ++++++++++ backend/internal/payments/store_statement.go | 102 +++++++++++++++ .../internal/server/handlers_admin_console.go | 32 +++++ 8 files changed, 460 insertions(+), 28 deletions(-) create mode 100644 backend/internal/inttest/payments_statement_test.go create mode 100644 backend/internal/payments/statement.go create mode 100644 backend/internal/payments/store_statement.go diff --git a/PLAN.md b/PLAN.md index 02352b3..f1cf9a3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -36,7 +36,7 @@ status — without re-deriving decisions. | E4 | Durability (PITR) | 2 | DONE | | E5 | Payment intake | 2 | DONE | | E6 | Ads | 2 | DONE | -| E7 | Admin & reports | 2 | TODO | +| E7 | Admin, reports & catalog | 2 | TODO | | E8 | Guest limits | — | TODO | | E9 | Tournament fee | future | TODO | @@ -693,38 +693,89 @@ 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) · -mechanics: PAYMENTS §11. +**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds), +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 -refund UI, ledger export. +**Delivery & baked decisions (this planning round).** A linear PR stack into `development`. -**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, - `UserDetailView`): segment balances, payments, spends, grants, refunds, full history — - read from the append-only ledger + materialized cache. -- **Admin grant** action (mirror the existing `POST /_gm/users/:id/grant-hints`): grant - concrete values (no-ads days / hints), **origin picker**, **never chips**; writes an - `admin_grant` ledger row (E2 `Grant`). -- **Manual refund** action: admin-initiated refund of a specific order (ties to the refund - path); records a `refund` ledger row; best-effort benefit revoke. -- **Ledger export**: CSV/JSON export of the ledger for tax reporting + future Robokassa - reconciliation (export-ready schema; reconciliation itself not built). +**Goal.** The admin console financial surface — per-user report, admin grant, manual refund, +ledger export — plus the **configurable product catalog editor** (D32). + +**Work (`/_gm`, `handlers_admin_console.go` + `adminconsole/`, `internal/payments/`).** + +- **Per-user financial panel** on the user card (`consoleUserDetail`, `UserDetailView`): segment + chip balances `(account, source)`, benefits `(account, origin)` (hints, no-ads until/forever), + and the full append-only ledger (fund/spend/admin_grant/refund — amount, origin, product, + provider, snapshot) from the ledger + materialized cache. Replaces the retired + `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. **Tests.** -- unit: report view assembly; grant refuses chips; export shape. -- integration: grant/refund write correct ledger rows; report reflects balances+history. +- unit: panel view assembly; catalog pack/value shape projection; grant refuses chips + tournament; + 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 -values (origin-picked, never chips), issue a manual refund, and export the ledger. +**Done-criteria.** An operator can: see a user's full financial picture; create/edit/archive +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 -those with the segmented view as the legacy columns retire. +**PR stack (linear into `development`).** + +1. **Per-user financial panel** (read-only ledger/segments/benefits on the user card; retires the + `PaidAccount`/`HintBalance` display). +2. **Catalog editor** (product/atom/price CRUD + archive/unarchive + delete-if-clean + shape + validation). +3. **Admin grant** (raw + by-product, refuse chips/tournament, origin-picked, `admin_grant` + + snapshot). +4. **Manual refund** (full order via E5 `Refund`) + **ledger export** (CSV/JSON). + +**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. --- @@ -770,11 +821,23 @@ on creation, not mid-game) at implementation and record it here. **Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature · mechanics: PAYMENTS §5, §7. -**Goal.** Charge a chip entry fee for tournaments. The `tournament` atom + a catalog product -are provisioned in E0/E7; the spend mechanic reuses E2 (`Spend`). The actual tournament -feature and its coupling to entry are out of scope until the tournament feature exists. +**Goal.** A chip entry economy for tournaments. The `tournament` atom is provisioned (E0) and +E7's catalog editor can compose tournament products; E7 deliberately **defers the entry storage ++ credit/spend** here, to avoid guessing the schema. -**Done-criteria.** Deferred — revisit 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. --- diff --git a/backend/README.md b/backend/README.md index 7ed70c5..e77d444 100644 --- a/backend/README.md +++ b/backend/README.md @@ -139,7 +139,10 @@ the resolved, weighted campaign feed for an **eligible** viewer (no active **no- applicable in the current context and no **`no_banner`** role; the message language picked by `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 shared wire +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 shared wire contracts live in the sibling [`../pkg`](../pkg) module. **Account linking & merge** (`/api/v1/user/link/*`). `internal/link` diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 0a134b5..d79888e 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -63,6 +63,28 @@
    +

    Finance

    +{{if .Finance.Present}} +{{if or .Finance.Segments .Finance.Benefits .Finance.Abuse .Finance.Loss}} +
      +{{range .Finance.Segments}}
    • Chips ({{.Source}}) {{.Chips}}
    • {{end}} +{{range .Finance.Benefits}}
    • Benefits ({{.Origin}}) {{.Hints}} hints{{if .Forever}} · no-ads forever{{else if .AdsUntil}} · no-ads until {{.AdsUntil}} (UTC){{end}}
    • {{end}} +{{if or .Finance.Abuse .Finance.Loss}}
    • Refund risk {{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}
    • {{end}} +
    +{{else}}

    no balances or benefits

    {{end}} +

    Ledger

    +{{if .Finance.Ledger}} + + + +{{range .Finance.Ledger}} + +{{end}} + +
    TimeKindSourceOriginChipsOrderProviderDetail
    {{.At}}{{.Kind}}{{.Source}}{{.Origin}}{{.ChipsDelta}}{{if .Order}}{{.Order}}{{end}}{{.Provider}}{{if .Snapshot}}{{.Snapshot}}{{end}}
    +{{else}}

    no ledger entries

    {{end}} +{{else}}

    payments not enabled

    {{end}} +

    Roles

    {{$id := .ID}} {{if .Roles}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index e9d84f6..7fddc20 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -195,6 +195,52 @@ type UserDetailView struct { Blocks []RelationRow BlockedBy []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 +} + +// 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 diff --git a/backend/internal/inttest/payments_statement_test.go b/backend/internal/inttest/payments_statement_test.go new file mode 100644 index 0000000..1cc8250 --- /dev/null +++ b/backend/internal/inttest/payments_statement_test.go @@ -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) + } + } +} diff --git a/backend/internal/payments/statement.go b/backend/internal/payments/statement.go new file mode 100644 index 0000000..1ecc816 --- /dev/null +++ b/backend/internal/payments/statement.go @@ -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) +} diff --git a/backend/internal/payments/store_statement.go b/backend/internal/payments/store_statement.go new file mode 100644 index 0000000..dc18516 --- /dev/null +++ b/backend/internal/payments/store_statement.go @@ -0,0 +1,102 @@ +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 +} + +// 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 +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 190a829..a0c10a7 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -21,6 +21,7 @@ import ( "scrabble/backend/internal/dictadmin" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/payments" "scrabble/backend/internal/ratewatch" "scrabble/backend/internal/robot" "scrabble/backend/internal/social" @@ -436,9 +437,40 @@ func (s *Server) consoleUserDetail(c *gin.Context) { 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)) + } + } 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 // user card renders. func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow { From 0036b55618fdeed012ecd3da4ba31176911d115c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 05:18:54 +0200 Subject: [PATCH 31/38] feat(admin): product catalog editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/README.md | 6 +- .../adminconsole/templates/layout.gohtml | 1 + .../templates/pages/catalog.gohtml | 44 ++++ .../templates/pages/product_detail.gohtml | 25 ++ backend/internal/adminconsole/views.go | 48 ++++ .../internal/inttest/catalog_editor_test.go | 97 ++++++++ backend/internal/payments/catalog_admin.go | 191 +++++++++++++++ .../internal/payments/catalog_admin_test.go | 47 ++++ .../internal/payments/store_catalog_admin.go | 218 ++++++++++++++++++ .../internal/server/handlers_admin_catalog.go | 183 +++++++++++++++ .../internal/server/handlers_admin_console.go | 8 + 11 files changed, 867 insertions(+), 1 deletion(-) create mode 100644 backend/internal/adminconsole/templates/pages/catalog.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/product_detail.gohtml create mode 100644 backend/internal/inttest/catalog_editor_test.go create mode 100644 backend/internal/payments/catalog_admin.go create mode 100644 backend/internal/payments/catalog_admin_test.go create mode 100644 backend/internal/payments/store_catalog_admin.go create mode 100644 backend/internal/server/handlers_admin_catalog.go diff --git a/backend/README.md b/backend/README.md index e77d444..bf3adcf 100644 --- a/backend/README.md +++ b/backend/README.md @@ -142,7 +142,11 @@ publishes a `notify` `banner` re-poll signal so the client shows/hides it in pla 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 shared wire +(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 shared wire contracts live in the sibling [`../pkg`](../pkg) module. **Account linking & merge** (`/api/v1/user/link/*`). `internal/link` diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml index 4fab715..c472e03 100644 --- a/backend/internal/adminconsole/templates/layout.gohtml +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -21,6 +21,7 @@
    Throttled Reasons Banners + Catalog Dictionary Broadcast Grafana ↗ diff --git a/backend/internal/adminconsole/templates/pages/catalog.gohtml b/backend/internal/adminconsole/templates/pages/catalog.gohtml new file mode 100644 index 0000000..a738175 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/catalog.gohtml @@ -0,0 +1,44 @@ +{{define "content" -}} +

    Product catalog

    +{{with .Data}} +

    A pack funds chips (a money price per rail — RUB via direct, VOTE via vk, XTR via telegram); a value 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 tournament atom is not sellable yet — keep such a product archived.

    +

    Add product

    +
    + +
    Atoms (quantity; blank = none) + + + + +
    +
    Prices (minor units; blank = none) + + + + +
    + +
    +
    +
    +

    Products

    + + + +{{range .Products}} + + + + + + + +{{else}}{{end}} + +
    TitleStatusAtomsPrices
    {{.Title}}{{if .Active}}active{{else}}archived{{end}}{{if .Transacted}} transacted{{end}}{{range .Atoms}}{{.Atom}}×{{.Quantity}} {{end}}{{range .Prices}}{{.Currency}}{{if .Method}}/{{.Method}}{{end}} {{.Amount}} {{end}} +
    +{{if not .Transacted}}
    {{end}} +
    no products
    +
    +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/product_detail.gohtml b/backend/internal/adminconsole/templates/pages/product_detail.gohtml new file mode 100644 index 0000000..369a042 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/product_detail.gohtml @@ -0,0 +1,25 @@ +{{define "content" -}} +{{with .Data}} +

    ← all products

    +

    {{.Title}} {{if .Active}}active{{else}}archived{{end}}{{if .Transacted}} transacted{{end}}

    +

    Edit

    +

    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 catalog list.

    +
    + +
    Atoms (quantity; 0 = none) + + + + +
    +
    Prices (minor units; 0 = none) + + + + +
    +
    +
    +
    +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 7fddc20..c1d5e66 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -653,3 +653,51 @@ type FeedbackDetailView struct { UserTZ string 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 +} diff --git a/backend/internal/inttest/catalog_editor_test.go b/backend/internal/inttest/catalog_editor_test.go new file mode 100644 index 0000000..02379a7 --- /dev/null +++ b/backend/internal/inttest/catalog_editor_test.go @@ -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")) + } +} diff --git a/backend/internal/payments/catalog_admin.go b/backend/internal/payments/catalog_admin.go new file mode 100644 index 0000000..999cbfb --- /dev/null +++ b/backend/internal/payments/catalog_admin.go @@ -0,0 +1,191 @@ +package payments + +import ( + "context" + "errors" + "fmt" + "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) +} + +// 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 + } + return s.store.createProduct(ctx, in, active, s.clock()) +} + +// 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 + } + return s.store.updateProduct(ctx, id, in, s.clock()) +} + +// 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 + } + } + return s.store.setProductActive(ctx, id, active, s.clock()) +} + +// 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 { + return s.store.deleteProduct(ctx, id) +} diff --git a/backend/internal/payments/catalog_admin_test.go b/backend/internal/payments/catalog_admin_test.go new file mode 100644 index 0000000..7a60a80 --- /dev/null +++ b/backend/internal/payments/catalog_admin_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/payments/store_catalog_admin.go b/backend/internal/payments/store_catalog_admin.go new file mode 100644 index 0000000..c63f8c1 --- /dev/null +++ b/backend/internal/payments/store_catalog_admin.go @@ -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 +} diff --git a/backend/internal/server/handlers_admin_catalog.go b/backend/internal/server/handlers_admin_catalog.go new file mode 100644 index 0000000..bae1644 --- /dev/null +++ b/backend/internal/server/handlers_admin_catalog.go @@ -0,0 +1,183 @@ +package server + +import ( + "errors" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "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 + } + 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) +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index a0c10a7..1853fd7 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -102,6 +102,14 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/banner-settings", s.consoleBannerSettings) 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) + } } // consoleDashboard renders the landing page: the top-line counts and the resident From d2d6955cbffb596cc15fac3809d46e12909a6ce9 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 05:35:55 +0200 Subject: [PATCH 32/38] =?UTF-8?q?feat(admin):=20admin=20grant=20=E2=80=94?= =?UTF-8?q?=20raw=20benefits=20and=20by-product=20reward=20bundles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/README.md | 6 +- .../templates/pages/user_detail.gohtml | 20 ++++ backend/internal/adminconsole/views.go | 21 ++++ backend/internal/inttest/admin_grant_test.go | 79 +++++++++++++++ backend/internal/payments/service.go | 52 ++++++++++ backend/internal/payments/store_wallet.go | 24 +++++ .../internal/server/handlers_admin_catalog.go | 95 +++++++++++++++++++ .../internal/server/handlers_admin_console.go | 3 + docs/PAYMENTS.md | 6 +- docs/PAYMENTS_ru.md | 7 +- 10 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 backend/internal/inttest/admin_grant_test.go diff --git a/backend/README.md b/backend/README.md index bf3adcf..083dcc3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -146,7 +146,11 @@ funding segment, benefits per origin, the recorded refund risk, and the append-o (`/_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 shared wire +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). The shared wire contracts live in the sibling [`../pkg`](../pkg) module. **Account linking & merge** (`/api/v1/user/link/*`). `internal/link` diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index d79888e..98a425a 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -85,6 +85,26 @@ {{else}}

    no ledger entries

    {{end}} {{else}}

    payments not enabled

    {{end}}
    +

    Grant benefits

    +{{if .Grant.Present}} +

    A zero-price admin sale of a value — never chips. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.

    +
    + + + + +
    +
    +{{if .Grant.Products}} +

    Grant a product

    +
    + + +
    +
    +{{else}}

    no grantable products — create a value product in the catalog

    {{end}} +{{else}}

    payments not enabled

    {{end}} +

    Roles

    {{$id := .ID}} {{if .Roles}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index c1d5e66..2a34c3e 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -198,6 +198,9 @@ type UserDetailView struct { // 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 @@ -701,3 +704,21 @@ type ProductFormView struct { 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 +} diff --git a/backend/internal/inttest/admin_grant_test.go b/backend/internal/inttest/admin_grant_test.go new file mode 100644 index 0000000..b690b2f --- /dev/null +++ b/backend/internal/inttest/admin_grant_test.go @@ -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")) + } +} diff --git a/backend/internal/payments/service.go b/backend/internal/payments/service.go index 5a8b177..a0c55ee 100644 --- a/backend/internal/payments/service.go +++ b/backend/internal/payments/service.go @@ -156,6 +156,41 @@ func (s *Service) Grant(ctx context.Context, accountID uuid.UUID, origin Source, 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 // caller's transaction (the account-merge flow). The caller invalidates the affected caches // after committing (Invalidate). @@ -244,3 +279,20 @@ func marshalGrant(d benefitDelta) ([]byte, error) { } 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 +} diff --git a/backend/internal/payments/store_wallet.go b/backend/internal/payments/store_wallet.go index 59cff1b..d348cd3 100644 --- a/backend/internal/payments/store_wallet.go +++ b/backend/internal/payments/store_wallet.go @@ -26,6 +26,14 @@ var ( // ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it // cannot be bought with chips. 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. @@ -312,6 +320,22 @@ func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d 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 applyBenefitTx(ctx, tx, accountID, origin, d, now) + }) + if err != nil { + return err + } + s.cache.invalidate(accountID) + return nil +} + // consumeHint decrements one hint from the first applicable origin (in the given priority order) // that has one, with a guarded update. It returns whether a hint was spent. func (s *Store) consumeHint(ctx context.Context, accountID uuid.UUID, origins []Source, now time.Time) (bool, error) { diff --git a/backend/internal/server/handlers_admin_catalog.go b/backend/internal/server/handlers_admin_catalog.go index bae1644..aca135b 100644 --- a/backend/internal/server/handlers_admin_catalog.go +++ b/backend/internal/server/handlers_admin_catalog.go @@ -1,11 +1,14 @@ 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" @@ -181,3 +184,95 @@ func (s *Server) consoleDeleteProductAction(c *gin.Context) { } 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, ", ") +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 1853fd7..ef997a2 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -58,6 +58,8 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.POST("/users/:id/grant-role", s.consoleGrantRole) gm.POST("/users/:id/revoke-role", s.consoleRevokeRole) 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/delete", s.consoleDeleteUser) gm.GET("/reasons", s.consoleReasons) gm.POST("/reasons", s.consoleCreateReason) @@ -451,6 +453,7 @@ func (s *Server) consoleUserDetail(c *gin.Context) { } 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) } diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index 15e6744..eadad8f 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -319,10 +319,12 @@ cache. Identity-presence (which segments are awake, §6) is supplied by the call here, so unlink/re-link takes effect immediately. **Admin rewards.** An admin grants **concrete values only** (no-ads / hints) — **never -chips** (a gifted currency balance = a store cash-desk bypass). The admin **picks the +chips** (a gifted currency balance = a store cash-desk bypass). It grants either raw atoms or a +**defined value product** (a reward bundle, which may be archived — hidden from the store but +grantable); both refuse a `chips` or `tournament` atom. The admin **picks the origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume = low risk, `origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0 -chips — full audit of rewards. +chips (the by-product grant records the source `product_id` + snapshot) — full audit of rewards. **Per-user financial report** in the admin console `/_gm` — segment balances, payments, spends, grants, refunds, full history — as an extension of the existing user card diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index 10ae6b0..66d6363 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -318,10 +318,13 @@ in-process кэш сегментов и бенефитов по ключу-ак вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу. **Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы / -подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Админ +подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Выдаёт либо +сырыми атомами, либо **готовым продуктом-ценностью** (набор-награда, возможно архивный — скрыт +из магазина, но выдаётся); оба отказывают на атоме `chips` или `tournament`. Админ **выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk` точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала -типа `admin_grant`, цена 0 Фишек — полный аудит наград. +типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) — +полный аудит наград. **Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты, гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`, From 82648a439848eb25f8e08d825ce2402327a94848 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 06:26:49 +0200 Subject: [PATCH 33/38] =?UTF-8?q?fix(game):=20show=20granted/bought=20hint?= =?UTF-8?q?s=20in-game=20=E2=80=94=20finish=20the=20D31=20wire=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/internal/game/eventwire.go | 1 - backend/internal/game/helpers_test.go | 16 ++++++------ backend/internal/game/service.go | 18 ++++++-------- backend/internal/game/types.go | 10 +++----- backend/internal/inttest/game_test.go | 13 +++++----- backend/internal/notify/encode.go | 1 - backend/internal/notify/payload.go | 1 - backend/internal/server/dto.go | 2 -- gateway/internal/backendclient/api.go | 1 - gateway/internal/transcode/encode.go | 1 - gateway/internal/transcode/transcode_test.go | 6 ++--- pkg/fbs/scrabble.fbs | 18 ++++++-------- pkg/fbs/scrabblefb/StateView.go | 15 ----------- pkg/wire/build.go | 2 -- ui/src/game/Game.svelte | 10 ++++---- ui/src/gen/fbs/scrabblefb/state-view.ts | 12 +-------- ui/src/lib/codec.ts | 1 - ui/src/lib/gamecache.test.ts | 2 +- ui/src/lib/gamedelta.test.ts | 8 +++--- ui/src/lib/hints.test.ts | 26 +++++++++----------- ui/src/lib/hints.ts | 23 +++++++---------- ui/src/lib/localgame/source.ts | 1 - ui/src/lib/mock/client.ts | 9 +++---- ui/src/lib/model.ts | 7 ++---- ui/src/lib/preload.test.ts | 2 +- 25 files changed, 76 insertions(+), 130 deletions(-) diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go index 6b65685..c9d2b31 100644 --- a/backend/internal/game/eventwire.go +++ b/backend/internal/game/eventwire.go @@ -74,7 +74,6 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play Rack: rack, BagLen: v.BagLen, HintsRemaining: v.HintsRemaining, - WalletBalance: v.WalletBalance, } if includeAlphabet { tab, err := engine.AlphabetTable(v.Game.Variant) diff --git a/backend/internal/game/helpers_test.go b/backend/internal/game/helpers_test.go index 0cca088..c19b994 100644 --- a/backend/internal/game/helpers_test.go +++ b/backend/internal/game/helpers_test.go @@ -55,16 +55,16 @@ func TestPayloadExchangeRoundTrip(t *testing.T) { } func TestHintsRemaining(t *testing.T) { - cases := []struct{ allowance, used, wallet, want int }{ - {1, 0, 3, 4}, - {1, 1, 3, 3}, - {1, 2, 3, 3}, // used past allowance clamps to 0 - {0, 0, 5, 5}, - {2, 1, 0, 1}, + cases := []struct{ allowance, used, want int }{ + {1, 0, 1}, + {1, 1, 0}, + {1, 2, 0}, // used past allowance clamps to 0 + {3, 1, 2}, + {0, 0, 0}, } for _, c := range cases { - if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want { - t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", 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, want %d", c.allowance, c.used, got, c.want) } } } diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 5c47d0c..b6c90f8 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -1207,7 +1207,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint return HintResult{}, err } 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 @@ -1290,11 +1290,9 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) Seat: seat, Rack: g.Hand(seat), BagLen: g.BagLen(), - // The hint wallet moved to payments (svc.hintWallet); the deprecated accounts.hint_balance - // is no longer read, so the wire wallet is 0 and HintsRemaining is the per-seat allowance. - // The client adds the profile's payments hint balance on top (lib/hints.hintsLeft). - HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, 0), - WalletBalance: 0, + // HintsRemaining is the per-seat allowance only; the purchasable wallet lives on the profile + // (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). HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()), }, nil @@ -1775,10 +1773,10 @@ func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, e return svc.registry.DictBytes(variant, version) } -// hintsRemaining is a player's remaining hint budget: the unspent per-game -// allowance plus the profile wallet. -func hintsRemaining(allowance, used, wallet int) int { - return max(0, allowance-used) + wallet +// hintsRemaining is the unspent per-game hint allowance. The purchasable wallet is separate, +// carried on the profile (payments), and the client adds it (lib/hints.hintsLeft). +func hintsRemaining(allowance, used int) int { + return max(0, allowance-used) } // allowedTimeout reports whether d is one of the offered move clocks. diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 26b173e..bdade84 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -211,10 +211,9 @@ type MoveResult struct { BagLen int } -// HintResult is a revealed hint and the requesting player's remaining hint -// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is -// the global wallet alone, so the client can keep its live wallet authoritative and -// re-derive the per-game allowance (HintsRemaining - WalletBalance). +// HintResult is a revealed hint with the per-seat allowance remaining (HintsRemaining) and the +// purchasable hint wallet after spending one (WalletBalance, from payments). The client adopts +// WalletBalance into the profile so the badge stays live across games (lib/hints). type HintResult struct { Move engine.MoveRecord HintsRemaining int @@ -241,9 +240,6 @@ type StateView struct { Rack []string BagLen 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 // 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 diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 423805b..18fe3f9 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -424,13 +424,13 @@ func TestHintPolicy(t *testing.T) { t.Fatalf("first hint: %v", err) } // 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]) if err != nil { t.Fatalf("state: %v", err) } - if st.HintsRemaining != 0 || st.WalletBalance != 0 { - t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance) + if st.HintsRemaining != 0 { + 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) { t.Fatalf("second hint = %v, want ErrNoHintsLeft", err) @@ -444,9 +444,10 @@ func TestHintPolicy(t *testing.T) { if err != nil { t.Fatalf("wallet hint: %v", err) } - // The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone. - if res.HintsRemaining != 1 || res.WalletBalance != 1 { - t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance) + // The allowance stays exhausted (HintsRemaining is the allowance alone, so 0); the wallet dropped + // 2->1 and WalletBalance carries it (the client adopts it into the profile). + 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 // that feeds the player's lifetime hint statistics, not just the allowance. diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go index 5c03192..d2b1914 100644 --- a/backend/internal/notify/encode.go +++ b/backend/internal/notify/encode.go @@ -83,7 +83,6 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT Rack: s.Rack, BagLen: s.BagLen, HintsRemaining: s.HintsRemaining, - WalletBalance: s.WalletBalance, Alphabet: alphabet, }) } diff --git a/backend/internal/notify/payload.go b/backend/internal/notify/payload.go index 18c9e27..abb4c95 100644 --- a/backend/internal/notify/payload.go +++ b/backend/internal/notify/payload.go @@ -56,7 +56,6 @@ type PlayerState struct { Rack []int BagLen int HintsRemaining int - WalletBalance int Alphabet []AlphabetLetter } diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 7549539..634d583 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -177,7 +177,6 @@ type stateDTO struct { Rack []int `json:"rack"` BagLen int `json:"bag_len"` 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 // game / first move / not your turn). The client anchors a monotonic countdown to it. HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"` @@ -334,7 +333,6 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) { Rack: rack, BagLen: v.BagLen, HintsRemaining: v.HintsRemaining, - WalletBalance: v.WalletBalance, HintUnlockLeftSeconds: v.HintUnlockLeftSeconds, } if includeAlphabet { diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index b07759c..e31b533 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -197,7 +197,6 @@ type StateResp struct { Rack []int `json:"rack"` BagLen int `json:"bag_len"` HintsRemaining int `json:"hints_remaining"` - WalletBalance int `json:"wallet_balance"` HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"` Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"` } diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 46e3aa3..77a9c83 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -405,7 +405,6 @@ func toWireState(s backendclient.StateResp) wire.StateView { Rack: s.Rack, BagLen: s.BagLen, HintsRemaining: s.HintsRemaining, - WalletBalance: s.WalletBalance, HintUnlockLeftSeconds: s.HintUnlockLeftSeconds, Alphabet: alphabet, } diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index 32a2ca2..267b60e 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) { if r.URL.Path != "/api/v1/user/games/g-1/state" { t.Errorf("unexpected path %q", r.URL.Path) } - _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3,"hint_unlock_left_seconds":1200}`)) + _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"hint_unlock_left_seconds":1200}`)) }) defer cleanup() @@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) { t.Fatalf("handler: %v", err) } st := fb.GetRootAsStateView(payload, 0) - if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 { - t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance(), st.HintUnlockLeftSeconds()) + if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.HintUnlockLeftSeconds() != 1200 { + t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.HintUnlockLeftSeconds()) } game := st.Game(nil) if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 6189134..5530869 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -330,12 +330,11 @@ table StateView { bag_len:int; hints_remaining:int; alphabet:[AlphabetEntry]; - // wallet_balance is the requesting player's global hint-wallet balance, sent apart from - // hints_remaining (which folds the wallet in with the per-game allowance) so the client can - // separate the two: the per-game allowance remaining is hints_remaining - wallet_balance, and - // the wallet is a single global figure the client keeps live across games (added trailing — - // backward-compatible). - wallet_balance:int; + // wallet_balance is deprecated (D31): the purchasable hint wallet moved to the profile (payments), + // and hints_remaining now carries the per-game allowance alone. The field is tombstoned rather than + // deleted so the vtable slots of the fields after it stay stable across a rolling deploy (an old + // SPA served before the deploy must keep reading a new gateway correctly). No accessor is generated. + wallet_balance:int (deprecated); // hint_unlock_left_seconds is, for a vs_ai game, how many seconds until the idle hint unlocks // (the robot's last move plus the idle window, computed from the SERVER clock online / the device // clock offline, capped at the window and floored at 0); 0 for a human's first move (no robot move @@ -393,10 +392,9 @@ table ComplaintRequest { note:string; } -// HintResult is the top-ranked move plus the remaining hint budget. wallet_balance is the -// global hint-wallet balance after spending (see StateView.wallet_balance), so the client -// refreshes its live wallet and re-derives the per-game allowance (added trailing — -// backward-compatible). +// HintResult is the top-ranked move plus hints_remaining (the per-game allowance alone) and +// wallet_balance (the purchasable hint wallet after spending, from payments). The client adopts +// wallet_balance into the profile so the badge stays live across games (see lib/hints). table HintResult { move:MoveRecord; hints_remaining:int; diff --git a/pkg/fbs/scrabblefb/StateView.go b/pkg/fbs/scrabblefb/StateView.go index 2957e44..9cf7ae9 100644 --- a/pkg/fbs/scrabblefb/StateView.go +++ b/pkg/fbs/scrabblefb/StateView.go @@ -144,18 +144,6 @@ func (rcv *StateView) AlphabetLength() int { return 0 } -func (rcv *StateView) WalletBalance() int32 { - o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) - if o != 0 { - return rcv._tab.GetInt32(o + rcv._tab.Pos) - } - return 0 -} - -func (rcv *StateView) MutateWalletBalance(n int32) bool { - return rcv._tab.MutateInt32Slot(16, n) -} - func (rcv *StateView) HintUnlockLeftSeconds() int32 { o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) if o != 0 { @@ -195,9 +183,6 @@ func StateViewAddAlphabet(builder *flatbuffers.Builder, alphabet flatbuffers.UOf func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } -func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) { - builder.PrependInt32Slot(6, walletBalance, 0) -} func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) { builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0) } diff --git a/pkg/wire/build.go b/pkg/wire/build.go index 51f1081..1412ee7 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -92,7 +92,6 @@ type StateView struct { Rack []int BagLen int HintsRemaining int - WalletBalance int HintUnlockLeftSeconds int Alphabet []AlphabetEntry } @@ -256,7 +255,6 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT { fb.StateViewAddRack(b, rack) fb.StateViewAddBagLen(b, int32(s.BagLen)) fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining)) - fb.StateViewAddWalletBalance(b, int32(s.WalletBalance)) fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds)) if hasAlphabet { fb.StateViewAddAlphabet(b, alphabet) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index f6ab404..bf16d99 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -251,7 +251,8 @@ view = st; // Anchor the vs_ai idle-hint countdown to the freshly fetched seconds-left (0 = open / non-vs_ai). armHintGate(st.game.vsAi ? (st.hintUnlockLeftSeconds ?? 0) : 0); - syncWallet(st.walletBalance); + // The game state no longer carries the hint wallet (it lives on the profile); do not touch + // app.profile.hintBalance here — that would clobber the authoritative payments balance. // Seed the unread flag from the authoritative state (the live stream only raises it). seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages); moves = hist.moves; @@ -833,10 +834,9 @@ seat: r.move.player, rack: r.rack, bagLen: r.bagLen, - // A move is not a hint, so the per-game allowance and the wallet are unchanged: carry both - // forward (their difference is the stable allowance; the badge adds the live wallet). + // A move is not a hint, so the per-game allowance is unchanged: carry it forward. The badge + // adds the live wallet from the profile. hintsRemaining: view?.hintsRemaining ?? 0, - walletBalance: view?.walletBalance ?? 0, }; // The move result is an authoritative per-viewer view: a nudge the actor just answered by // moving is already cleared server-side, so reconcile the unread flag from it. @@ -1047,7 +1047,7 @@ recenter++; } if (isCoarse() && !landscape) zoomed = true; - view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance }; + view = { ...view, hintsRemaining: h.hintsRemaining }; syncWallet(h.walletBalance); // The hint is the engine's own top-ranked, fully scored legal move: reuse it as the // preview instead of a redundant evaluate (same engine call, same placement). Cancel any diff --git a/ui/src/gen/fbs/scrabblefb/state-view.ts b/ui/src/gen/fbs/scrabblefb/state-view.ts index 6d546fd..91eb112 100644 --- a/ui/src/gen/fbs/scrabblefb/state-view.ts +++ b/ui/src/gen/fbs/scrabblefb/state-view.ts @@ -69,11 +69,6 @@ alphabetLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } -walletBalance():number { - const offset = this.bb!.__offset(this.bb_pos, 16); - return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; -} - hintUnlockLeftSeconds():number { const offset = this.bb!.__offset(this.bb_pos, 18); return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; @@ -131,10 +126,6 @@ static startAlphabetVector(builder:flatbuffers.Builder, numElems:number) { builder.startVector(4, numElems, 4); } -static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) { - builder.addFieldInt32(6, walletBalance, 0); -} - static addHintUnlockLeftSeconds(builder:flatbuffers.Builder, hintUnlockLeftSeconds:number) { builder.addFieldInt32(7, hintUnlockLeftSeconds, 0); } @@ -144,7 +135,7 @@ static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset { return offset; } -static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number, hintUnlockLeftSeconds:number):flatbuffers.Offset { +static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, hintUnlockLeftSeconds:number):flatbuffers.Offset { StateView.startStateView(builder); StateView.addGame(builder, gameOffset); StateView.addSeat(builder, seat); @@ -152,7 +143,6 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse StateView.addBagLen(builder, bagLen); StateView.addHintsRemaining(builder, hintsRemaining); StateView.addAlphabet(builder, alphabetOffset); - StateView.addWalletBalance(builder, walletBalance); StateView.addHintUnlockLeftSeconds(builder, hintUnlockLeftSeconds); return StateView.endStateView(builder); } diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index f1724f9..f99fde5 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -575,7 +575,6 @@ function decodeStateViewTable(v: fb.StateView): StateView { rack, bagLen: v.bagLen(), hintsRemaining: v.hintsRemaining(), - walletBalance: v.walletBalance(), hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(), }; } diff --git a/ui/src/lib/gamecache.test.ts b/ui/src/lib/gamecache.test.ts index 6b8e4fd..5cc4788 100644 --- a/ui/src/lib/gamecache.test.ts +++ b/ui/src/lib/gamecache.test.ts @@ -23,7 +23,7 @@ function gameView(id: string): GameView { } function view(id: string, rack: string[] = ['A', 'B']): StateView { - return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1, walletBalance: 0 }; + return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1 }; } function move(player: number): MoveRecord { diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index 67fcaf0..7c6be9a 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -28,7 +28,7 @@ function move(player: number): MoveRecord { } function cache(moveCount: number, seat = 0, over = false): CachedGame { - const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }; + const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }; return { view, moves: [] }; } @@ -38,7 +38,7 @@ function delta(moveCount: number, player: number, bagLen = 47): MoveDelta { describe('seedInitialState', () => { it('wraps an initial view with an empty journal', () => { - const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2, walletBalance: 0 }; + const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 }; expect(seedInitialState(view)).toEqual({ view, moves: [] }); }); }); @@ -153,12 +153,12 @@ describe('applyOpponentJoined', () => { { seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false }, { seat: 1, accountId: 'opp', displayName: 'Opp', score: 0, hintsUsed: 0, isWinner: false }, ] }; - return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0, walletBalance: 0 }; + return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0 }; } it("adopts the joined seats and status while preserving the cached rack and moves", () => { // The cached open game is still "searching": empty seats, status open, the starter's own rack. - const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }, moves: [move(0)] }; + const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }, moves: [move(0)] }; const res = applyOpponentJoined(cached, joinedState()); expect(res?.view.game.status).toBe('active'); expect(res?.view.game.seats).toHaveLength(2); diff --git a/ui/src/lib/hints.test.ts b/ui/src/lib/hints.test.ts index cb81f4b..9f83d66 100644 --- a/ui/src/lib/hints.test.ts +++ b/ui/src/lib/hints.test.ts @@ -1,33 +1,31 @@ import { describe, expect, it } from 'vitest'; import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from './hints'; -// view carries only the two fields hintsLeft reads. -const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance }); +// view carries only the field hintsLeft reads (the per-game allowance). +const view = (hintsRemaining: number) => ({ hintsRemaining }); describe('hintsLeft', () => { it('is zero without a view', () => { expect(hintsLeft(null, 5)).toBe(0); }); - it('adds the per-game allowance to the live wallet (fresh view)', () => { - // hints_remaining 4 = allowance 1 + wallet 3; live wallet matches the snapshot → 1 + 3. - expect(hintsLeft(view(4, 3), 3)).toBe(4); + it('adds the per-game allowance to the live wallet', () => { + expect(hintsLeft(view(1), 3)).toBe(4); // allowance 1 + wallet 3 }); - it('reflects the LIVE wallet, not the per-game snapshot (the staleness fix)', () => { - // The view was fetched when the wallet was 3 (allowance 1), but a wallet hint was since spent - // in another game, so the live wallet is 2: the count must drop to 1 + 2 = 3, not stay at 4. - expect(hintsLeft(view(4, 3), 2)).toBe(3); + it('reflects the LIVE wallet, not the view (the staleness fix)', () => { + // A wallet hint spent in another game since this view was fetched → the live wallet is 2, so the + // count is 1 + 2 = 3. The wallet is always passed live (from the profile), never read off the view. + expect(hintsLeft(view(1), 2)).toBe(3); }); it('shows just the wallet when the per-game allowance is used up', () => { - // allowance 0 (hints_remaining 3 == snapshot wallet 3); live wallet 3 → 0 + 3. - expect(hintsLeft(view(3, 3), 3)).toBe(3); + expect(hintsLeft(view(0), 3)).toBe(3); // allowance 0 + wallet 3 }); - it('clamps a non-negative allowance and wallet', () => { - expect(hintsLeft(view(2, 3), 0)).toBe(0); - expect(hintsLeft(view(1, 0), -5)).toBe(1); + it('clamps negatives', () => { + expect(hintsLeft(view(1), -5)).toBe(1); // a negative wallet clamps to 0 + expect(hintsLeft(view(-2), 3)).toBe(3); // a negative allowance clamps to 0 }); }); diff --git a/ui/src/lib/hints.ts b/ui/src/lib/hints.ts index 9b200c7..07726bd 100644 --- a/ui/src/lib/hints.ts +++ b/ui/src/lib/hints.ts @@ -1,28 +1,23 @@ // Hint-count derivation, kept out of the .svelte component so it is unit-testable. // -// The badge shows the per-game hint allowance remaining plus the player's global hint -// wallet. The server's hints_remaining folds the two together, but the wallet is global — -// shared across every game — so caching the combined number per game makes it go stale the -// moment a wallet hint is spent in another game. We therefore split it: the per-game -// allowance is hints_remaining - wallet_balance (both from the same fetch, so it is stable -// and cacheable), and the wallet is read live from the global profile, never the per-game -// snapshot. +// The badge shows the per-game hint allowance remaining plus the player's global hint wallet. The +// view's hints_remaining is the per-game allowance alone; the wallet is global (shared across every +// game) and read live from the profile (payments), never the per-game snapshot — so a wallet hint +// spent in another game stays reflected here. import type { StateView } from './model'; /** - * hintsLeft is the hint count for the badge: the per-game allowance remaining (the view's - * hints_remaining minus the wallet snapshot baked into that same view) plus the live global - * wallet balance. Passing the live wallet (not view.walletBalance) is what keeps the count - * correct when a wallet hint was spent in another game since this view was fetched. + * hintsLeft is the hint count for the badge: the per-game allowance remaining (view.hintsRemaining) + * plus the live global wallet balance (passed in from the profile, so it stays correct when a wallet + * hint was spent in another game since this view was fetched). */ export function hintsLeft( - view: Pick | null, + view: Pick | null, walletBalance: number, ): number { if (!view) return 0; - const allowance = Math.max(0, view.hintsRemaining - view.walletBalance); - return allowance + Math.max(0, walletBalance); + return Math.max(0, view.hintsRemaining) + Math.max(0, walletBalance); } /** HINT_GATE_MS is the idle time a vs_ai player must be stuck on a turn before a hint unlocks. */ diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 5f890ed..a10e93d 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -435,7 +435,6 @@ export class LocalSource implements GameLoopSource { rack, bagLen: entry.game.bagLength, hintsRemaining: 1, - walletBalance: 0, hintUnlockLeftSeconds: this.hintUnlockLeft(entry), locked, }; diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 0e6363c..b8c1393 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -408,10 +408,9 @@ export class MockGateway implements GatewayClient { seat: this.mySeat(g), rack: [...g.rack], bagLen: g.bagLen, - // g.hintsRemaining is the per-game allowance; the wallet is the shared profile balance. - // hints_remaining folds the two together (as the backend does), walletBalance is the wallet. - hintsRemaining: g.hintsRemaining + this.profile.hintBalance, - walletBalance: this.profile.hintBalance, + // hintsRemaining is the per-game allowance alone; the wallet lives on the profile and the + // client adds it (lib/hints). + hintsRemaining: g.hintsRemaining, }; } @@ -530,7 +529,7 @@ export class MockGateway implements GatewayClient { score: valueForLetter(g.view.variant, letter), total: 0, }, - hintsRemaining: g.hintsRemaining + this.profile.hintBalance, + hintsRemaining: g.hintsRemaining, walletBalance: this.profile.hintBalance, }; } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 96ee9f7..d309e42 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -78,17 +78,14 @@ export interface MoveRecord { total: number; } -/** A seated player's private view of a game. hintsRemaining folds the per-game allowance - * together with the global wallet; walletBalance is the wallet alone, so the client can - * derive the per-game allowance (hintsRemaining - walletBalance) and keep the wallet live - * across games (see lib/hints). */ +/** A seated player's private view of a game. hintsRemaining is the per-game allowance alone; the + * purchasable hint wallet lives on the profile (payments), and the client adds it (see lib/hints). */ export interface StateView { game: GameView; seat: number; rack: string[]; bagLen: number; hintsRemaining: number; - walletBalance: number; /** For a vs_ai game, the seconds until the idle hint unlocks (computed by the source: the SERVER * clock online, the device clock offline) — 0/undefined while open (the human's first move, or a * non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt, diff --git a/ui/src/lib/preload.test.ts b/ui/src/lib/preload.test.ts index 9a53d18..6f2ab7e 100644 --- a/ui/src/lib/preload.test.ts +++ b/ui/src/lib/preload.test.ts @@ -35,7 +35,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView { } function stateView(id: string): StateView { - return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }; + return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1 }; } beforeEach(() => { From 1bf612a0874566aca01ec800dd247546c33b6662 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 06:46:18 +0200 Subject: [PATCH 34/38] feat(admin): manual full-order refund + ledger CSV export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PLAN.md | 21 ++--- backend/README.md | 6 +- .../templates/pages/user_detail.gohtml | 7 +- backend/internal/inttest/admin_refund_test.go | 77 +++++++++++++++++++ backend/internal/payments/refund_admin.go | 41 ++++++++++ backend/internal/payments/store_statement.go | 30 ++++++++ .../internal/server/handlers_admin_console.go | 2 + .../internal/server/handlers_admin_refund.go | 71 +++++++++++++++++ 8 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 backend/internal/inttest/admin_refund_test.go create mode 100644 backend/internal/payments/refund_admin.go create mode 100644 backend/internal/server/handlers_admin_refund.go diff --git a/PLAN.md b/PLAN.md index f1cf9a3..55a4138 100644 --- a/PLAN.md +++ b/PLAN.md @@ -36,7 +36,7 @@ status — without re-deriving decisions. | E4 | Durability (PITR) | 2 | DONE | | E5 | Payment intake | 2 | DONE | | E6 | Ads | 2 | DONE | -| E7 | Admin, reports & catalog | 2 | TODO | +| E7 | Admin, reports & catalog | 2 | DONE | | E8 | Guest limits | — | TODO | | E9 | Tournament fee | future | TODO | @@ -695,7 +695,7 @@ it tunes without a store release. ## E7 — Admin, reports & catalog -**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds), +**Status:** DONE · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds), E6 (D31 retired the legacy `hint_balance`/`paid_account`) · mechanics: PAYMENTS §11, §12, D32. **Delivery & baked decisions (this planning round).** A linear PR stack into `development`. @@ -763,15 +763,16 @@ ledger export — plus the **configurable product catalog editor** (D32). 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. -**PR stack (linear into `development`).** +**PR stack (linear into `development`, all merged).** -1. **Per-user financial panel** (read-only ledger/segments/benefits on the user card; retires the - `PaidAccount`/`HintBalance` display). -2. **Catalog editor** (product/atom/price CRUD + archive/unarchive + delete-if-clean + shape - validation). -3. **Admin grant** (raw + by-product, refuse chips/tournament, origin-picked, `admin_grant` + - snapshot). -4. **Manual refund** (full order via E5 `Refund`) + **ledger export** (CSV/JSON). +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 diff --git a/backend/README.md b/backend/README.md index 083dcc3..6a7e1c0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -150,7 +150,11 @@ backed by the FK); a `tournament`-bearing product is composable but not sellable 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). The shared wire +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. **Account linking & merge** (`/api/v1/user/link/*`). `internal/link` diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 98a425a..b5a4ec7 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -73,15 +73,18 @@ {{else}}

    no balances or benefits

    {{end}}

    Ledger

    +{{$uid := .ID}} {{if .Finance.Ledger}} - + {{range .Finance.Ledger}} - + + {{end}}
    TimeKindSourceOriginChipsOrderProviderDetail
    TimeKindSourceOriginChipsOrderProviderDetail
    {{.At}}{{.Kind}}{{.Source}}{{.Origin}}{{.ChipsDelta}}{{if .Order}}{{.Order}}{{end}}{{.Provider}}{{if .Snapshot}}{{.Snapshot}}{{end}}
    {{.At}}{{.Kind}}{{.Source}}{{.Origin}}{{.ChipsDelta}}{{if .Order}}{{.Order}}{{end}}{{.Provider}}{{if .Snapshot}}{{.Snapshot}}{{end}}{{if and (eq .Kind "fund") .Order}}
    {{end}}
    +

    Export the full ledger (CSV) — all accounts, for tax + reconciliation.

    {{else}}

    no ledger entries

    {{end}} {{else}}

    payments not enabled

    {{end}}
    diff --git a/backend/internal/inttest/admin_refund_test.go b/backend/internal/inttest/admin_refund_test.go new file mode 100644 index 0000000..bb03d80 --- /dev/null +++ b/backend/internal/inttest/admin_refund_test.go @@ -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) + } + } +} diff --git a/backend/internal/payments/refund_admin.go b/backend/internal/payments/refund_admin.go new file mode 100644 index 0000000..56a0829 --- /dev/null +++ b/backend/internal/payments/refund_admin.go @@ -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) +} diff --git a/backend/internal/payments/store_statement.go b/backend/internal/payments/store_statement.go index dc18516..33cb623 100644 --- a/backend/internal/payments/store_statement.go +++ b/backend/internal/payments/store_statement.go @@ -77,6 +77,36 @@ func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Stat 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 { diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index ef997a2..ac7b17e 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -60,6 +60,7 @@ func (s *Server) registerConsole(router *gin.Engine) { 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.GET("/reasons", s.consoleReasons) gm.POST("/reasons", s.consoleCreateReason) @@ -111,6 +112,7 @@ func (s *Server) registerConsole(router *gin.Engine) { 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) } } diff --git a/backend/internal/server/handlers_admin_refund.go b/backend/internal/server/handlers_admin_refund.go new file mode 100644 index 0000000..b804f88 --- /dev/null +++ b/backend/internal/server/handlers_admin_refund.go @@ -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() +} From ed53e25e578e9c12d7e2f313a921c8be5ff186b2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 09:03:57 +0200 Subject: [PATCH 35/38] feat(backend): per-tier, per-kind active-game limits with a guest funnel 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. --- PLAN.md | 87 ++++-- backend/README.md | 15 +- backend/cmd/backend/main.go | 13 + .../adminconsole/templates/layout.gohtml | 1 + .../adminconsole/templates/pages/games.gohtml | 6 +- .../templates/pages/limits.gohtml | 19 ++ .../templates/pages/user_detail.gohtml | 6 +- backend/internal/adminconsole/views.go | 13 + backend/internal/game/service.go | 45 ++- backend/internal/game/store.go | 44 ++- backend/internal/game/types.go | 17 +- backend/internal/gamelimits/gamelimits.go | 149 +++++++++ .../internal/gamelimits/gamelimits_test.go | 44 +++ backend/internal/inttest/game_limit_test.go | 289 +++++++++++------- backend/internal/lobby/invitations.go | 16 + backend/internal/lobby/lobby.go | 10 +- backend/internal/lobby/matchmaker.go | 2 + .../postgres/jet/backend/model/config.go | 18 ++ .../postgres/jet/backend/model/games.go | 1 + .../postgres/jet/backend/table/config.go | 96 ++++++ .../postgres/jet/backend/table/games.go | 9 +- .../jet/backend/table/table_use_schema.go | 1 + .../migrations/00014_guest_limits.sql | 36 +++ backend/internal/server/handlers.go | 2 + .../internal/server/handlers_admin_console.go | 6 +- .../internal/server/handlers_admin_limits.go | 65 ++++ backend/internal/server/handlers_game.go | 36 +-- .../internal/server/handlers_invitations.go | 3 - backend/internal/server/handlers_user.go | 9 +- backend/internal/server/server.go | 7 + backend/internal/social/friendcodes.go | 6 + backend/internal/social/friends.go | 7 + backend/internal/social/robotfriends.go | 6 + backend/internal/social/social.go | 3 + docs/ARCHITECTURE.md | 33 +- 35 files changed, 897 insertions(+), 223 deletions(-) create mode 100644 backend/internal/adminconsole/templates/pages/limits.gohtml create mode 100644 backend/internal/gamelimits/gamelimits.go create mode 100644 backend/internal/gamelimits/gamelimits_test.go create mode 100644 backend/internal/postgres/jet/backend/model/config.go create mode 100644 backend/internal/postgres/jet/backend/table/config.go create mode 100644 backend/internal/postgres/migrations/00014_guest_limits.sql create mode 100644 backend/internal/server/handlers_admin_limits.go diff --git a/PLAN.md b/PLAN.md index 55a4138..4557aa2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -37,7 +37,7 @@ status — without re-deriving decisions. | E5 | Payment intake | 2 | DONE | | E6 | Ads | 2 | DONE | | E7 | Admin, reports & catalog | 2 | DONE | -| E8 | Guest limits | — | TODO | +| E8 | Guest limits | — | WIP | | E9 | Tournament fee | future | TODO | **Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3). @@ -782,38 +782,79 @@ become bootstrap-only. ## E8 — Guest limits -**Status:** TODO · **standalone** (game-behaviour change; can run in parallel) · depends on: -none · mechanics: PAYMENTS §6. +**Status:** WIP · **standalone** (game-behaviour change) · depends on: none · mechanics: PAYMENTS §6. -**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today the -gating is UI-only). +**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today 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 -server — only the UI hides them: `social/friends.go:50` `SendFriendRequest`, -`robotfriends.go:41` `RequestInGame`, `friendcodes.go:67` `RedeemFriendCode`, -`lobby/invitations.go:208` `CreateInvitation`. A guest with a valid `X-User-ID` can call them -in the UI's stead. +**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`, `robotfriends.go`, `friendcodes.go`, +`lobby/invitations.go`. A guest with a valid `X-User-ID` can call them. (b) A **pre-existing** flat +cap already existed: `game.MaxActiveQuickGames`=10 — a **combined** cap on `active`+`open` quick +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.** -- **Server guest gate** on friend-request / redeem-code / invitation-create: refuse when the - caller `is_guest` (add an `ErrGuestForbidden`-style guard, as `feedback` already has). -- **Active-game limits** for guests: at most **1 random-opponent game + 1 vs_ai** concurrently - (enforce on game/invitation creation in `lobby`/`game`; today no such limit exists). -- Keep the existing UI gating; this adds the server as the source of truth. +- ~~**PR1 — backend + admin (server-side)** — DONE.~~ The migration (`game_kind` + `backend.config`, + seeded `1,1,0 / 10,10,10` + jetgen); `game_kind` set on creation and projected onto `game.Game`; + the **guest gate** (`ErrGuestForbidden`, mapped to **403 `guest_forbidden`**) on friend-request / + redeem-code / befriend-in-game / invitation-create; the **active-limit enforce** — the old flat + `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:** `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS + + transcode + codec); the client per-kind active count from the lobby; the **lock badge** on a capped + new-game start button, with two messages by tier — a **guest** login-funnel modal ("Войдите или + создайте учётную запись…", Отмена / Вход→profile) and, for a **durable** account at its cap, a plain + notice ("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК; **native** popup in + Telegram — no gesture-gated API follows it); the profile re-fetch after a guest→durable upgrade. **Tests.** -- integration: guest is refused friend/redeem/invitation server-side; guest blocked from a - 2nd random / 2nd vs_ai; durable account unaffected. -- UI: guest sees the funnel (existing hides + any new messaging). +- integration (PR1, done): `game_kind` persisted per path; guest refused friend/redeem/invitation + (domain + HTTP 403); guest blocked from a 2nd vs_ai / 2nd random (+ `at_game_limit`); durable on the + 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`). +- UI (PR2): the lock + the two modals on a capped button (mock); the profile re-fetch lifts the lock + after register. -**Done-criteria.** Guests are server-limited to 1 random + 1 vs_ai and cannot friend/invite; -durable accounts unchanged; regression that this does not break existing durable flows. +**Done-criteria.** A guest is server-capped (default 1 vs_ai + 1 random, configurable) and cannot +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 -mixed-in payments changes. Decide whether a guest may finish an already-started game (limit -on creation, not mid-game) at implementation and record it here. +**Notes/risks.** Game-behaviour change — own PRs, own tests, no payments mixed in. The limit is on +creation (existing games grandfathered). The config cache is single-instance (matching the deploy). --- diff --git a/backend/README.md b/backend/README.md index 6a7e1c0..2db76cb 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 caller into that open game and starts it — and friend-game invitations (invite → accept, starting a 2–4 player game once every -invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a -player's active quick games — status `active`/`open`, excluding invitation-linked friend -games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and -`invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation -is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby. +invitee accepts). **Per-tier, per-kind active-game caps** limit a player's simultaneous +unfinished games by kind — `vs_ai`, `random` (quick auto-match), `friends` — with separate +guest and durable-account tiers held in the single-row `backend.config` table (a `-1` means +unlimited), read through an in-memory cache (`internal/gamelimits`) and tuned live in the admin +(`/_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), 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, diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index cbdbc88..fa2ac40 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -28,6 +28,7 @@ import ( "scrabble/backend/internal/engine" "scrabble/backend/internal/feedback" "scrabble/backend/internal/game" + "scrabble/backend/internal/gamelimits" "scrabble/backend/internal/link" "scrabble/backend/internal/lobby" "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())) games.SetNotifier(hub) 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) logger.Info("game turn-timeout sweeper started", zap.Duration("interval", cfg.Game.TimeoutSweepInterval)) @@ -305,6 +317,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { BanView: banView, Ads: adsSvc, Payments: paymentsSvc, + GameLimits: gameLimits, Notifier: hub, ExportSignKey: cfg.ExportSignKey, Renderer: renderer, diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml index c472e03..6a21d6d 100644 --- a/backend/internal/adminconsole/templates/layout.gohtml +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -15,6 +15,7 @@ Dashboard Users Games + Limits Complaints Feedback Messages diff --git a/backend/internal/adminconsole/templates/pages/games.gohtml b/backend/internal/adminconsole/templates/pages/games.gohtml index e6ab08e..c7bea24 100644 --- a/backend/internal/adminconsole/templates/pages/games.gohtml +++ b/backend/internal/adminconsole/templates/pages/games.gohtml @@ -8,11 +8,11 @@ finished - + {{range .Items}} - -{{else}}{{end}} + +{{else}}{{end}}
    GameVariantStatus🤖PlayersUpdated
    GameVariantKindStatus🤖PlayersUpdated
    {{.ID}}{{.Variant}}{{.Status}}{{if .VsAI}}🤖{{end}}{{.Players}}{{.UpdatedAt}}
    no games
    {{.ID}}{{.Variant}}{{.Kind}}{{.Status}}{{if .VsAI}}🤖{{end}}{{.Players}}{{.UpdatedAt}}
    no games
    {#if declineTarget} @@ -411,7 +398,7 @@ {#snippet tabbar()} - + class:locked={autoLocked} + disabled={(autoLocked ? false : !selectedAuto) || (!connection.online && !offlineMode.active) || starting} + onclick={() => (autoLocked ? (limitOpen = true) : selectedAuto && find(selectedAuto))} + >{#if autoLocked}🔒 {/if}{t('new.start')} + (limitOpen = false)} + onlogin={() => { limitOpen = false; navigate('/settings'); }} + /> {:else if offlineMode.active} @@ -660,6 +679,12 @@ .invite:disabled { opacity: 0.5; } + /* A capped start (the active-game limit): an outline instead of the filled CTA, with a 🔒, so it + reads as gated rather than ready. Tapping it opens the funnel modal, never a game. */ + .invite.locked { + background: transparent; + color: var(--accent); + } .searchhint { margin: 0; text-align: center; From e40adfb0c770d6f7e53394d180e72284895b97fa Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 10:55:48 +0200 Subject: [PATCH 37/38] fix(games): carry game kind on live events + fix the New Game lock funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/internal/game/eventwire.go | 1 + backend/internal/notify/encode.go | 1 + backend/internal/notify/notify_test.go | 4 ++-- backend/internal/notify/payload.go | 3 +++ ui/e2e/gamelimit.spec.ts | 2 +- ui/src/lib/gamelimits.test.ts | 8 ++++++++ ui/src/lib/i18n/en.ts | 4 ++-- ui/src/lib/i18n/ru.ts | 4 ++-- ui/src/screens/NewGame.svelte | 19 ++++++++++++++++--- 9 files changed, 36 insertions(+), 10 deletions(-) diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go index c9d2b31..54262d2 100644 --- a/backend/internal/game/eventwire.go +++ b/backend/internal/game/eventwire.go @@ -52,6 +52,7 @@ func gameSummary(g Game, names []string) notify.GameSummary { TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), MultipleWordsPerTurn: g.MultipleWordsPerTurn, VsAI: g.VsAI, + Kind: int(g.Kind), MoveCount: g.MoveCount, EndReason: g.EndReason, Seats: seats, diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go index d2b1914..eb9109f 100644 --- a/backend/internal/notify/encode.go +++ b/backend/internal/notify/encode.go @@ -37,6 +37,7 @@ func toWireGame(g GameSummary) wire.GameView { TurnTimeoutSecs: g.TurnTimeoutSecs, MultipleWordsPerTurn: g.MultipleWordsPerTurn, VsAI: g.VsAI, + Kind: g.Kind, MoveCount: g.MoveCount, EndReason: g.EndReason, Seats: seats, diff --git a/backend/internal/notify/notify_test.go b/backend/internal/notify/notify_test.go index 5b34d8c..fb7e26f 100644 --- a/backend/internal/notify/notify_test.go +++ b/backend/internal/notify/notify_test.go @@ -115,7 +115,7 @@ func TestGameOverPayloadRoundTrips(t *testing.T) { func TestOpponentMovedPayloadRoundTrips(t *testing.T) { uid, gid := uuid.New(), uuid.New() 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) if in.Kind != notify.KindOpponentMoved { 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 { 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)) } } diff --git a/backend/internal/notify/payload.go b/backend/internal/notify/payload.go index abb4c95..37641e5 100644 --- a/backend/internal/notify/payload.go +++ b/backend/internal/notify/payload.go @@ -35,6 +35,9 @@ type GameSummary struct { EndReason string Seats []SeatStanding 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 diff --git a/ui/e2e/gamelimit.spec.ts b/ui/e2e/gamelimit.spec.ts index 3e54f54..00b5a6c 100644 --- a/ui/e2e/gamelimit.spec.ts +++ b/ui/e2e/gamelimit.spec.ts @@ -28,7 +28,7 @@ test('new game: a start locks at its per-kind cap and the funnel modal opens', a // Tapping the locked start opens the modal instead of a game, and does not navigate away. await start.click(); - const notice = page.getByText(/reached the limit of simultaneous games/i); + const notice = page.getByText(/reached the limit/i); await expect(notice).toBeVisible(); await expect(page).toHaveURL(/\/new$/); await page.getByRole('button', { name: /^ok$/i }).click(); diff --git a/ui/src/lib/gamelimits.test.ts b/ui/src/lib/gamelimits.test.ts index 0098d50..12aa3c2 100644 --- a/ui/src/lib/gamelimits.test.ts +++ b/ui/src/lib/gamelimits.test.ts @@ -74,4 +74,12 @@ describe('isKindLocked', () => { it('a durable account stays well under its higher cap', () => { expect(isKindLocked([game(GameKind.random, 'active')], durable, GameKind.random)).toBe(false); }); + + it('locks only the reached kind: at the vs_ai cap, random with no games stays open', () => { + const limits = { vsAi: 3, random: 2, friends: 1 }; + const games = [game(GameKind.vsAi, 'active'), game(GameKind.vsAi, 'active'), game(GameKind.vsAi, 'active')]; + expect(isKindLocked(games, limits, GameKind.vsAi)).toBe(true); + expect(isKindLocked(games, limits, GameKind.random)).toBe(false); + expect(isKindLocked(games, limits, GameKind.friends)).toBe(false); + }); }); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 4453d39..97e7def 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -380,9 +380,9 @@ export const en = { 'new.start': 'Start game', 'new.invited': 'Invitation sent.', 'new.limitGuestTitle': 'More games?', - 'new.limitGuestBody': 'Sign in or create an account to play several games at once.', + 'new.limitGuestBody': 'Sign in or create an account to use all the game features.', 'new.limitDurableTitle': 'Game limit', - 'new.limitDurableBody': 'You have reached the limit of simultaneous games — finish a current one first.', + 'new.limitDurableBody': 'You have reached the limit. Finish your active games to start a new one.', 'new.limitLogin': 'Sign in', 'new.noFriends': 'Add friends first to invite them.', 'new.opponentAI': 'AI', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index c35230b..63e1403 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -380,9 +380,9 @@ export const ru: Record = { 'new.start': 'Начать игру', 'new.invited': 'Приглашение отправлено.', 'new.limitGuestTitle': 'Больше партий?', - 'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы играть несколько партий одновременно.', + 'new.limitGuestBody': 'Войдите или создайте учётную запись, чтобы воспользоваться всеми функциями игры.', 'new.limitDurableTitle': 'Лимит игр', - 'new.limitDurableBody': 'Вы достигли лимита одновременных игр, сначала завершите текущие.', + 'new.limitDurableBody': 'Вы достигли лимита. Доиграйте активные партии, чтобы начать новую.', 'new.limitLogin': 'Вход', 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', 'new.opponentAI': 'ИИ', diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index d93fe4b..54a4c4f 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -18,7 +18,7 @@ import { validDisplayName } from '../lib/profileValidation'; import { verifyPin, type PinLock } from '../lib/pin'; import { commitName, rosterReady, buildSeats, shuffleSeeded, type RosterRow } from '../lib/roster'; - import type { AccountRef, Variant } from '../lib/model'; + import type { AccountRef, GameView, Variant } from '../lib/model'; import { availableVariants, VARIANT_FLAG, @@ -60,7 +60,10 @@ // funnel modal instead of enqueuing (the server also refuses it); offline never locks (local // games are uncapped). The lock lifts when the profile is re-fetched after a guest→durable upgrade. const autoKind = $derived(opponent === 'ai' ? GameKind.vsAi : GameKind.random); - const autoLocked = $derived(!offlineMode.active && isKindLocked(getLobby(false)?.games ?? [], app.profile?.gameLimits, autoKind)); + // The player's current games for the per-kind lock: seeded from the lobby snapshot for an instant + // render, then refreshed on mount (below) so a game created since the last lobby visit is counted. + let lobbyGames = $state(getLobby(false)?.games ?? []); + const autoLocked = $derived(!offlineMode.active && isKindLocked(lobbyGames, app.profile?.gameLimits, autoKind)); let limitOpen = $state(false); // --- auto-match --- @@ -129,6 +132,16 @@ ); onMount(async () => { + // Refresh the lobby games so the per-kind lock counts the current set — the cached snapshot can + // lag a game created since the last lobby visit. Online only; the lock never applies offline, and + // the server enforces every cap regardless. + if (!offlineMode.active && connection.online) { + try { + lobbyGames = (await gateway.gamesList()).games; + } catch { + // Keep the cached seed on a transient failure. + } + } // Offline mode offers only the local vs_ai flow (the friends section is hidden), and the network // is off, so skip the friend fetch — it would be refused by the transport and toast an error. if (guest || offlineMode.active) return; @@ -349,7 +362,7 @@ open={limitOpen} {guest} onclose={() => (limitOpen = false)} - onlogin={() => { limitOpen = false; navigate('/settings'); }} + onlogin={() => { limitOpen = false; navigate('/profile'); }} /> {:else if offlineMode.active}