diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c7a2aed..72d5f62 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 @@ -400,6 +407,9 @@ jobs: # the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below. VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }} VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} + # Rewarded-ad test stub: set TEST_VITE_ADS_STUB=1 to swap real ads for a toast on the + # contour (empty = real ads, for capturing the real VK ad result). Prod never sets it. + VITE_ADS_STUB: ${{ vars.TEST_VITE_ADS_STUB }} # VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the # compose ":-" empty default. Other unset vars likewise fall to their defaults. POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} @@ -497,6 +507,38 @@ 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 /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/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index 874f8c1..a9038de 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -99,6 +99,14 @@ jobs: GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }} GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }} + # Robokassa direct-rail (backend BACKEND_ROBOKASSA_*): the prod shop login + the pass phrases + # that sign the launch request / verify the Result callback, and the test-mode flag — a var so + # go-live is a flag flip, not a secret redeploy ("1" runs test payments against the test + # passwords; empty/"0" is live). An empty login leaves the direct rail disabled. + ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }} + ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }} + ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }} + ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }} # VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at # runtime) + the app's protected key. Both shared across contours. The redirect URL is # derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. diff --git a/.gitea/workflows/prod-rollback.yaml b/.gitea/workflows/prod-rollback.yaml index 19ca854..c461093 100644 --- a/.gitea/workflows/prod-rollback.yaml +++ b/.gitea/workflows/prod-rollback.yaml @@ -61,6 +61,12 @@ jobs: # the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL # and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }} + # Robokassa direct-rail: the rollback re-renders the same runtime env (write-prod-env.sh), so + # it must carry the same credentials or the direct rail goes dark after a rollback. + ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }} + ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }} + ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }} + ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }} VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} diff --git a/PLAN.md b/PLAN.md index 0e22e08..e3995bb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -33,11 +33,11 @@ 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 | -| E5 | Payment intake | 2 | TODO | -| E6 | Ads | 2 | TODO | -| E7 | Admin & reports | 2 | TODO | -| E8 | Guest limits | — | TODO | +| E4 | Durability (PITR) | 2 | DONE | +| E5 | Payment intake | 2 | DONE | +| E6 | Ads | 2 | DONE | +| E7 | Admin, reports & catalog | 2 | DONE | +| E8 | Guest limits | — | DONE | | E9 | Tournament fee | future | TODO | **Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3). @@ -446,8 +446,8 @@ noun agreement). Svelte whitespace/`$state` naming gotchas apply. ## E4 — Durability (PITR) -**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,30 +479,73 @@ 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. --- ## E5 — Payment intake -**Status:** TODO · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12. +**Status:** DONE · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12. + +**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice), Robokassa first. +Resolved: match the order by a Robokassa **`Shp_order`** custom parameter, not the numeric `InvId` +(an order id is a uuid); idempotency key = the order id (`provider_payment_id = order_id`); the НПД +receipt is formed **shop-side in the Robokassa cabinet**, so no `Receipt` parameter is sent; a +chargeback **never drives the balance negative** (D27 stands, `balances_chips_chk` kept), so E5 is +**schema-free** (no migration, no contour wipe). Delivered on `feature/payment-intake-robokassa`: +the offer page (`/offer/`), the order/`fund` engine (idempotent, honours an expired order), the +`internal/robokassa` adapter, the `POST /wallet/order` + internal Result-callback handlers (a D36 +confirmed-email gate on `direct`), the pending reaper, the `wallet.order` edge wire + the public +`/pay/*` routes, the Wallet purchase CTA, the contour deploy env (IsTest forced), and the +`payment_events` dispatcher — an in-app wallet-refresh push (KindNotification `"payment"`) with a +self-closing provider-return page (the payment opens in a separate window) and a return-focus +refetch fallback. The **VK Votes rail** is delivered too: the client opens +`VKWebAppShowOrderBox({item: order_id})`; a two-phase signed server callback (`get_item` → the pack +title + vote price; a chargeable `order_status_change` → the same `Fund` with source=`vk`, idempotent +on VK's own order id) is verified at the gateway with the app protected key (`GATEWAY_VK_APP_SECRET`, +already deployed) and proxied to the backend intake. The **Telegram Stars rail** is delivered on +`feature/payment-intake-tg-stars`: only the bot reaches Telegram, so the **invoice is minted by the +bot** — on the `wallet.order` path the gateway sends a new `CreateInvoice` command over the reverse +bot-link and the bot returns the `createInvoiceLink` (XTR) in its Ack, handed to the client's +`WebApp.openInvoice`. The bot answers `pre_checkout_query` via a new bot→gateway **`ValidatePreCheckout`** +unary (backed by the backend: the order must exist, be still creditable and **not already paid** — the +reusable-invoice double-pay guard — with a matching amount); the decline reason is localised to the +order account's language. A completed `successful_payment` is persisted to a pure-Go **SQLite outbox** +(`modernc.org/sqlite`) then forwarded by a new bot→gateway **`ForwardPayment`** unary into the same +`Fund` (source=`telegram`, idempotent on `telegram_payment_charge_id`, honours an expired order), +re-driven at startup and every 30 s. The rail is wired by `TELEGRAM_STARS_OUTBOX_DIR` (defaults to the +bot `/data` volume) but stays **inert until a chip pack carries an XTR price**, so seeding a Stars price +in the admin is the go-live. Finally **refunds** are delivered on `feature/payment-intake-refunds`: a +single `Refund` engine (`internal/payments`) reverses a paid order best-effort, exactly once — +idempotent on `(provider, provider_refund_id)`, revoking the funded chips **floored at 0** (never +negative, D27), and recording the unrecoverable remainder (chips already spent) as a per-account +**loss + abuse flag** in the new additive `payments.account_risk` table (read by the E7 report). The +refund ledger row's chip delta is what was actually reclaimed (the ledger stays reconcilable); the +full reversal rides in its snapshot; the order stays `paid`. **No rail pushes an unsolicited refund** +— all are admin-triggered (E7): Robokassa refund API / cabinet (auto-polling deferred — a worker not +worth it at low chargeback volume), VK via support, Telegram `refundStarPayment`. `failed` events are +not wired (no rail signals a hard post-charge server decline). The migration is **additive** (a new +table only), so E5 stays rollback-safe / no contour wipe. That closes E5. Deferred to a later stage: +hiding the ad banner on a no-ads purchase (a spend-path `NotifyBanner`, with the owner's agreement). **Goal.** Accept real money on all three rails into the payments domain: order-flow, verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher, @@ -525,12 +568,14 @@ receipts, and refunds. **TG bot outbox (`platform/telegram/`).** -- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. Add a - **SQLite** store on the bot's disk: on receipt, persist → ack the Telegram update → forward - to a backend payments-intake endpoint (internal, authenticated over the existing reverse - mTLS bot-link) → on backend ack, mark `forwarded`. Retries with backoff; re-drive - undelivered on restart. Backend intake dedups by `telegram_payment_charge_id`. -- Provide the invoice creation path (Stars) from the Mini App via the bot as needed. +- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. A **SQLite** + store on the bot's disk (`internal/outbox`): on receipt, persist → forward over the reverse + mTLS bot-link to the **gateway** (a `ForwardPayment` unary; the bot cannot dial the backend + directly) → the gateway proxies to the backend payments-intake REST → on a durable response, + mark `forwarded`. Re-drive undelivered on startup and on a 30 s tick. Backend intake dedups by + `telegram_payment_charge_id`. +- Invoice creation (Stars) is minted by the bot (`createInvoiceLink`, XTR) on the gateway's + `CreateInvoice` bot-link command, returned to the Mini App as the `openInvoice` link. **Events & notifications.** @@ -542,9 +587,13 @@ receipts, and refunds. **Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK handles Votes tax itself; TG Stars — no receipt. -**Refunds (§9).** ToS non-refundable; admin manual refund (ties to `accountdelete`); external -`refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse -flag if spent), ledger `refund` row. Ledger export-ready (reconciliation not built). +**Refunds (§9).** ToS non-refundable. **All refunds are admin-triggered** (E7): no rail pushes an +unsolicited refund — Robokassa refund API / cabinet (auto-polling deferred as a low-value worker), +VK via support, Telegram `refundStarPayment`. One `Refund` engine reverses a paid order best-effort, +exactly once (idempotent on `(provider, provider_refund_id)`): revoke floored at 0 (never negative), +unrecoverable remainder → per-account loss + abuse flag (`payments.account_risk`), a `refund` ledger +row (chip delta = revoked, full reversal in the snapshot). `failed` events are not wired (no rail +signals a hard post-charge server decline). Ledger export-ready (reconciliation not built). **Tests.** @@ -569,9 +618,42 @@ force-recreate when the Caddyfile changes. ## E6 — Ads -**Status:** TODO · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) · +**Status:** DONE · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) · mechanics: PAYMENTS §10. +**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice): **rewarded first**, +then interstitial. Baked: the interstitial cooldowns already exist in `payments.config` (E0) and the +per-origin banner suppression is already done (E2 `AdFree`), so E6 is the two ad DISPLAY paths + the +rewarded credit. **VK reality (checked live in the VK docs via Playwright):** VK Mini App ads +(`VKWebAppShowNativeAds`, both `reward` and `interstitial`) expose **only a client-side `data.result` +boolean** — no server verify, no signature. So **D29 is amended**: rewarded is **client-attested**, +guarded by a server **daily + hourly cap** (config `reward_daily_cap` / `reward_hourly_cap`, default +50 / 10) that is both anti-abuse and an economic conversion lever (limits free chips so players buy); +the cooldown state for the interstitial is **client-mirrored** (owner's pick). Delivered on +`feature/ads-rewarded` (the **rewarded** slice): the ads-network abstraction (`ui/src/lib/ads.ts`, VK +impl) + the VK bridge (`vkRewardedReady` / `vkShowRewarded`), the backend `CreditReward` (VK-only, +order-less, idempotent on a client nonce, floored by the caps, payout from config +`rewarded_payout_chips` default 0 = off), the `wallet.reward` edge op returning the updated wallet +(with `reward_chips` gating the "watch for chips" CTA), and a **contour test stub** (`VITE_ADS_STUB` → +a toast instead of a real ad; prod always real). A temporary diagnostic confirmed on the contour that +VK returns **only `{result:true}`** (no token/signature) — client-attested is final, no hardening +possible; the diagnostic is removed. The slice also **corrects the VK-iOS freeze to purchase-only** +(rewarded on VK-iOS earns chips, which the old blanket "spend freeze" then blocked from spending — +Apple forbids only *buying* in-app values, not spending or earning them; `vkFrozen()` now gates only +`CreateOrder`, not `spendableSources`, so VK-wallet chips spend on VK-iOS). Delivered on +`feature/ads-interstitial` (the **interstitial + D31** slice): the post-move fullscreen interstitial +as a **client-mirrored** gate — the backend `adsFor` puts the config cooldowns + a `suppressed` flag +(the no-ads / `no_banner` gate, same as the banner) on the profile (`Profile.ads`), and +`ui/src/lib/ads.ts` `maybeShowInterstitial` self-gates on the last-shown time per kind in +`localStorage`, showing a VK interstitial (`vkShowInterstitial`) after a **confirmed play or a hint +only** (never a pass / exchange / resign), VK-only, offline banner-only, with the same `VITE_ADS_STUB` +toast on the contour. The slice also lands **D31 step 1 (contract-code)**: the domain no longer reads +or writes the deprecated `accounts.hint_balance` / `paid_account` columns — the `Account` fields, the +dead `account.SpendHint`, `account.GrantHints` and the admin **grant-hints** action are removed, and +the in-game hint display now comes wholly from the payments benefit (`HintsAvailable`). The **columns +stay** (no migration → image rollback is DB-safe); a later contract-PR does the `DROP` once E6 is +stable on prod. + **Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video (credits chips via server verify), plus extending the existing banner suppression to per-origin. @@ -586,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. @@ -610,75 +693,177 @@ it tunes without a store release. --- -## E7 — Admin & reports +## E7 — Admin, reports & catalog -**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant), E5 (payments/refunds) · -mechanics: PAYMENTS §11. +**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. -**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`, all merged).** + +1. ~~**Per-user financial panel**~~ (#230) — read-only ledger/segments/benefits on the user card; + retired the `PaidAccount`/`HintBalance` display. +2. ~~**Catalog editor**~~ (#231) — product/atom/price CRUD + archive/unarchive + delete-if-clean + + shape validation. +3. ~~**Admin grant**~~ (#232, + the D31 hint-wallet wire cleanup that surfaced there) — raw + + by-product, refuse chips/tournament, origin-picked, `admin_grant` + snapshot. +4. ~~**Manual refund** (full order via E5 `Refund`) + **ledger CSV export**~~ — `RefundOrderFull` + (idempotent, floor-0 revoke) on each fund row; `/_gm/ledger.csv`. + +**Notes/risks.** High-blast-radius (money, ledger — append-only, trigger-enforced). No mixed-in +refactors. The catalog editor becomes the source of truth for products; the contour SQL seeds +become bootstrap-only. --- ## E8 — Guest limits -**Status:** TODO · **standalone** (game-behaviour change; can run in parallel) · depends on: -none · mechanics: PAYMENTS §6. +**Status:** DONE · **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** — DONE.~~ `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS + + gateway transcode + client codec, committed regen); the client counts active games per kind from + the lobby cache (`gamelimits.ts`) and locks a capped new-game start — an **outline 🔒** button that + opens `GameLimitModal` instead of a game, native (Telegram `showPopup`) or the in-app `Modal` + elsewhere, with two messages by tier: a **guest** sign-in funnel ("Войдите или создайте учётную + запись…", Отмена / Вход→`/settings`) and, for a **durable** account at its cap, a plain notice + ("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК). The lock lifts via the + existing profile re-fetch after a guest→durable upgrade. + - **Decision (owner-agreed): the lobby's old `at_game_limit` New-Game tab-disable + notice is + removed.** The `at_game_limit` flag (now the random-kind cap) conflicted with the per-kind lock — + it hid the New-Game screen where the lock lives, and wrongly blocked starting an unfulfilled kind. + The tab is always enabled; the per-kind lock on the start button is the only gate. The wire field + `GameList.at_game_limit` stays (unused by the client) for a later cleanup. **Tests.** -- 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`). +- unit + UI (PR2, done): the client lock logic (`gamelimits.ts` — count/cap/lock), the codec kind + + game_limits roundtrip, the gateway transcode game_limits encode, the popup builders, and a mock e2e + (`gamelimit.spec.ts`) — a capped start shows 🔒, opens the modal without navigating, and the lock + clears when the profile refetch lifts the cap. -**Done-criteria.** Guests are server-limited to 1 random + 1 vs_ai and cannot friend/invite; -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). --- @@ -687,11 +872,23 @@ on creation, not mid-game) at implementation and record it here. **Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature · 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 8b6fc3a..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, @@ -135,21 +140,38 @@ so `/internal/push-target` returns the recipient's `preferred_language` as the r language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` + `/_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 user card +also carries a **finance panel** (`payments.AccountStatement`): the account's chip balances per +funding segment, benefits per origin, the recorded refund risk, and the append-only ledger history +(newest first) — read straight from the payments tables, uncached. The **catalog editor** +(`/_gm/catalog`, `handlers_admin_catalog.go`) is the source of truth for products (D32): create / +edit / archive-unarchive (the `product.active` flag) products, their atoms and per-rail prices, and +hard-delete only a **never-transacted** product (an order/ledger reference forces archive-only, +backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The user card +also carries an admin **grant** panel: grant raw benefit atoms (hints / no-ads days / forever) or a +defined **value product** (a reward bundle, including an archived one), origin-picked; both write an +`admin_grant` ledger row via `payments.Grant` / `GrantProduct` and **refuse** a chips or `tournament` +atom (never grant currency; no tournament target yet). Each fund row in the panel carries a **Refund** +action (`payments.RefundOrderFull`): a full-order refund the operator records after refunding on the +rail — a `refund` ledger row + a floor-0 chip revoke, idempotent. A **ledger CSV export** +(`/_gm/ledger.csv`, `payments.LedgerExport`) dumps the whole append-only ledger for tax + +reconciliation. The shared wire contracts live in the sibling [`../pkg`](../pkg) module. **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/cmd/backend/main.go b/backend/cmd/backend/main.go index 31ae7ac..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,9 +317,11 @@ 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, + Robokassa: cfg.Robokassa, }) pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger) @@ -316,6 +330,13 @@ 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) + // 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) }() go func() { errc <- srv.Run(ctx) }() @@ -325,6 +346,52 @@ 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)) + } + } + } +} + +// 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/account/account.go b/backend/internal/account/account.go index 2b10fc8..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. @@ -393,6 +386,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) { @@ -548,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 @@ -649,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/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml index 4fab715..6a21d6d 100644 --- a/backend/internal/adminconsole/templates/layout.gohtml +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -15,12 +15,14 @@ Dashboard Users Games + Limits Complaints Feedback Messages 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" -}} +
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.
| Title | Status | Atoms | Prices | |
|---|---|---|---|---|
| {{.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 | ||||
| Game | Variant | Status | 🤖 | Players | Updated | |
|---|---|---|---|---|---|---|
| Game | Variant | Kind | Status | 🤖 | Players | Updated |
| {{.ID}} | {{.Variant}} | {{.Status}} | {{if .VsAI}}🤖{{end}} | {{.Players}} | {{.UpdatedAt}} | |
| no games | ||||||
| {{.ID}} | {{.Variant}} | {{.Kind}} | {{.Status}} | {{if .VsAI}}🤖{{end}} | {{.Players}} | {{.UpdatedAt}} |
| no games | ||||||