Merge pull request 'Release v1.14.0 — monetization launch (E5-E8)' (#237) from development into master
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+33
-11
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
|
||||
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
||||
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
||||
<a href="/_gm/limits"{{if eq .ActiveNav "limits"}} class="active"{{end}}>Limits</a>
|
||||
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
||||
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
|
||||
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
||||
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
|
||||
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
|
||||
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
|
||||
<a href="/_gm/catalog"{{if eq .ActiveNav "catalog"}} class="active"{{end}}>Catalog</a>
|
||||
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{{define "content" -}}
|
||||
<h1>Product catalog</h1>
|
||||
{{with .Data}}
|
||||
<p class="note">A <strong>pack</strong> funds chips (a money price per rail — RUB via direct, VOTE via vk, XTR via telegram); a <strong>value</strong> buys benefits with chips (a CHIP price). Archived products are hidden from players but still credit an in-flight payment and can be granted. A product with transactions can only be archived, not deleted. Amounts are in minor units (RUB kopecks; VOTE/XTR/CHIP whole). The <code>tournament</code> atom is not sellable yet — keep such a product archived.</p>
|
||||
<section class="panel"><h2>Add product</h2>
|
||||
<form class="form col" method="post" action="/_gm/catalog">
|
||||
<label>Title <input type="text" name="title" maxlength="120" required></label>
|
||||
<fieldset><legend>Atoms (quantity; blank = none)</legend>
|
||||
<label>Chips <input type="number" name="chips" min="0"></label>
|
||||
<label>Hints <input type="number" name="hints" min="0"></label>
|
||||
<label>No-ads days <input type="number" name="noads" min="0"></label>
|
||||
<label>Tournament <input type="number" name="tournament" min="0"></label>
|
||||
</fieldset>
|
||||
<fieldset><legend>Prices (minor units; blank = none)</legend>
|
||||
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0"></label>
|
||||
<label>VOTE — vk <input type="number" name="price_vote" min="0"></label>
|
||||
<label>XTR — telegram <input type="number" name="price_star" min="0"></label>
|
||||
<label>CHIP — value <input type="number" name="price_chip" min="0"></label>
|
||||
</fieldset>
|
||||
<label><input type="checkbox" name="active" value="true"> Active (on sale)</label>
|
||||
<div><button type="submit">Add</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Products</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Products}}
|
||||
<tr>
|
||||
<td><a href="/_gm/catalog/{{.ID}}">{{.Title}}</a></td>
|
||||
<td>{{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</td>
|
||||
<td>{{range .Atoms}}<code>{{.Atom}}×{{.Quantity}}</code> {{end}}</td>
|
||||
<td>{{range .Prices}}<code>{{.Currency}}{{if .Method}}/{{.Method}}{{end}} {{.Amount}}</code> {{end}}</td>
|
||||
<td class="row-actions">
|
||||
<form class="form" method="post" action="/_gm/catalog/{{.ID}}/archive"><input type="hidden" name="active" value="{{if .Active}}false{{else}}true{{end}}"><button type="submit">{{if .Active}}Archive{{else}}Unarchive{{end}}</button></form>
|
||||
{{if not .Transacted}}<form class="form" method="post" action="/_gm/catalog/{{.ID}}/delete" onsubmit="return confirm('Delete this product? It has never been transacted, so this is safe and permanent.')"><button type="submit">Delete</button></form>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no products</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -8,11 +8,11 @@
|
||||
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
||||
</nav>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{{define "content" -}}
|
||||
<h1>Active-game limits</h1>
|
||||
{{with .Data}}
|
||||
<p class="note">Per-tier, per-kind caps on a player's simultaneous unfinished games. <strong>-1</strong> = unlimited, <strong>0</strong> = the kind is blocked, a positive number caps concurrent games of that kind. Guests are additionally blocked from friend games outright. Changes apply immediately (no redeploy); games already in progress are never affected.</p>
|
||||
<section class="panel">
|
||||
<form class="form col" method="post" action="/_gm/limits">
|
||||
<h2>Guest</h2>
|
||||
<label>vs AI <input type="number" name="guest_vs_ai" min="-1" value="{{.GuestVsAI}}" required></label>
|
||||
<label>Random <input type="number" name="guest_random" min="-1" value="{{.GuestRandom}}" required></label>
|
||||
<label>Friends <input type="number" name="guest_friends" min="-1" value="{{.GuestFriends}}" required></label>
|
||||
<h2>Durable account</h2>
|
||||
<label>vs AI <input type="number" name="durable_vs_ai" min="-1" value="{{.DurableVsAI}}" required></label>
|
||||
<label>Random <input type="number" name="durable_random" min="-1" value="{{.DurableRandom}}" required></label>
|
||||
<label>Friends <input type="number" name="durable_friends" min="-1" value="{{.DurableFriends}}" required></label>
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,25 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<p class="note"><a href="/_gm/catalog">← all products</a></p>
|
||||
<h1>{{.Title}} {{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</h1>
|
||||
<section class="panel"><h2>Edit</h2>
|
||||
<p class="note">A zero quantity / blank price removes that atom / price. Amounts are in minor units. Saving revalidates the sellable shape when the product is active. Archive / unarchive from the <a href="/_gm/catalog">catalog list</a>.</p>
|
||||
<form class="form col" method="post" action="/_gm/catalog/{{.ID}}">
|
||||
<label>Title <input type="text" name="title" value="{{.Title}}" maxlength="120" required></label>
|
||||
<fieldset><legend>Atoms (quantity; 0 = none)</legend>
|
||||
<label>Chips <input type="number" name="chips" min="0" value="{{.Chips}}"></label>
|
||||
<label>Hints <input type="number" name="hints" min="0" value="{{.Hints}}"></label>
|
||||
<label>No-ads days <input type="number" name="noads" min="0" value="{{.NoAds}}"></label>
|
||||
<label>Tournament <input type="number" name="tournament" min="0" value="{{.Tournament}}"></label>
|
||||
</fieldset>
|
||||
<fieldset><legend>Prices (minor units; 0 = none)</legend>
|
||||
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0" value="{{.PriceRUB}}"></label>
|
||||
<label>VOTE — vk <input type="number" name="price_vote" min="0" value="{{.PriceVote}}"></label>
|
||||
<label>XTR — telegram <input type="number" name="price_star" min="0" value="{{.PriceStar}}"></label>
|
||||
<label>CHIP — value <input type="number" name="price_chip" min="0" value="{{.PriceChip}}"></label>
|
||||
</fieldset>
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -10,8 +10,6 @@
|
||||
<li><b>Timezone</b> {{.TimeZone}}</li>
|
||||
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
|
||||
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
||||
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
|
||||
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
|
||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||
@@ -21,10 +19,6 @@
|
||||
<button type="submit">Clear high-rate flag</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form class="form" method="post" action="/_gm/users/{{.ID}}/grant-hints">
|
||||
<label>Add hints <input type="number" name="amount" min="1" max="{{.HintGrantMax}}" value="1"></label>
|
||||
<button type="submit">Grant</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Statistics</h2>
|
||||
{{if .HasStats}}
|
||||
@@ -69,6 +63,51 @@
|
||||
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Finance</h2>
|
||||
{{if .Finance.Present}}
|
||||
{{if or .Finance.Segments .Finance.Benefits .Finance.Abuse .Finance.Loss}}
|
||||
<ul class="kv">
|
||||
{{range .Finance.Segments}}<li><b>Chips ({{.Source}})</b> {{.Chips}}</li>{{end}}
|
||||
{{range .Finance.Benefits}}<li><b>Benefits ({{.Origin}})</b> {{.Hints}} hints{{if .Forever}} · no-ads forever{{else if .AdsUntil}} · no-ads until {{.AdsUntil}} (UTC){{end}}</li>{{end}}
|
||||
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
|
||||
</ul>
|
||||
{{else}}<p class="note">no balances or benefits</p>{{end}}
|
||||
<h3>Ledger</h3>
|
||||
{{$uid := .ID}}
|
||||
{{if .Finance.Ledger}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Finance.Ledger}}
|
||||
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
|
||||
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="note"><a href="/_gm/ledger.csv">Export the full ledger (CSV)</a> — all accounts, for tax + reconciliation.</p>
|
||||
{{else}}<p class="note">no ledger entries</p>{{end}}
|
||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Grant benefits</h2>
|
||||
{{if .Grant.Present}}
|
||||
<p class="note">A zero-price admin sale of a value — <strong>never chips</strong>. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.</p>
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant">
|
||||
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||
<label>Hints <input type="number" name="hints" min="0" value="0"></label>
|
||||
<label>No-ads days <input type="number" name="noads" min="0" value="0"></label>
|
||||
<label><input type="checkbox" name="forever" value="true"> No-ads forever</label>
|
||||
<div><button type="submit">Grant</button></div>
|
||||
</form>
|
||||
{{if .Grant.Products}}
|
||||
<h3>Grant a product</h3>
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant-product">
|
||||
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||
<label>Product <select name="product_id">{{range .Grant.Products}}<option value="{{.ID}}">{{.Title}} ({{.Summary}}){{if .Archived}} — archived{{end}}</option>{{end}}</select></label>
|
||||
<div><button type="submit">Grant product</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">no grantable products — create a value product in the <a href="/_gm/catalog">catalog</a></p>{{end}}
|
||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||
</section>
|
||||
<section class="panel"><h2>Roles</h2>
|
||||
{{$id := .ID}}
|
||||
{{if .Roles}}
|
||||
@@ -172,11 +211,11 @@
|
||||
{{end}}
|
||||
<section class="panel"><h2>Games</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Games}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -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
|
||||
@@ -200,6 +195,55 @@ 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
|
||||
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
|
||||
// payments domain is unwired.
|
||||
Grant GrantFormView
|
||||
}
|
||||
|
||||
// FinanceView is the account's payments picture on the user card: chip balances per funding
|
||||
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
|
||||
// (newest first). Present is false when the payments domain is unwired.
|
||||
type FinanceView struct {
|
||||
Present bool
|
||||
Segments []SegmentRow
|
||||
Benefits []BenefitRow
|
||||
// Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
|
||||
Abuse bool
|
||||
Loss int
|
||||
Ledger []LedgerRow
|
||||
}
|
||||
|
||||
// SegmentRow is one funding segment's chip balance.
|
||||
type SegmentRow struct {
|
||||
Source string
|
||||
Chips int
|
||||
}
|
||||
|
||||
// BenefitRow is one origin's benefit: the hint wallet, the ad-free expiry (pre-formatted, empty
|
||||
// when none) and the lifetime ad-free flag.
|
||||
type BenefitRow struct {
|
||||
Origin string
|
||||
Hints int
|
||||
AdsUntil string
|
||||
Forever bool
|
||||
}
|
||||
|
||||
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
|
||||
// delta, the product / order / provider it references (empty when none), the raw snapshot JSON and
|
||||
// the pre-formatted time.
|
||||
type LedgerRow struct {
|
||||
Kind string
|
||||
Source string
|
||||
Origin string
|
||||
ChipsDelta int
|
||||
Product string
|
||||
Order string
|
||||
Provider string
|
||||
Snapshot string
|
||||
At string
|
||||
}
|
||||
|
||||
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
|
||||
@@ -268,6 +312,8 @@ type GameRow struct {
|
||||
UpdatedAt string
|
||||
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
|
||||
VsAI bool
|
||||
// Kind is the game's origin tag label: vs_ai / random / friends / unknown.
|
||||
Kind string
|
||||
}
|
||||
|
||||
// GamesView is the paginated games list, optionally filtered by status.
|
||||
@@ -277,6 +323,17 @@ type GamesView struct {
|
||||
Pager Pager
|
||||
}
|
||||
|
||||
// GameLimitsView is the per-tier, per-kind active-game limit form: each field is a cap where -1
|
||||
// is unlimited, 0 blocks the kind, and a positive value caps concurrent games of that kind.
|
||||
type GameLimitsView struct {
|
||||
GuestVsAI int
|
||||
GuestRandom int
|
||||
GuestFriends int
|
||||
DurableVsAI int
|
||||
DurableRandom int
|
||||
DurableFriends int
|
||||
}
|
||||
|
||||
// GameDetailView is one game with its seats.
|
||||
type GameDetailView struct {
|
||||
ID string
|
||||
@@ -612,3 +669,69 @@ 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -74,7 +75,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/session"
|
||||
@@ -57,6 +58,9 @@ type Service struct {
|
||||
// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
|
||||
// wallet-free and never touch it.
|
||||
hintWallet HintWallet
|
||||
// limits is the per-tier active-game cap config (cached). Set by SetGameLimits during wiring;
|
||||
// when nil, no active-game limit is enforced (a game is always creatable).
|
||||
limits *gamelimits.Service
|
||||
// clearNudges, when set, marks the actor's pending nudges in a game read once they
|
||||
// have committed a move (a nudge answered by moving stops counting as unread). It is
|
||||
// best-effort and kept as a func so the game package never imports the social package.
|
||||
@@ -126,6 +130,37 @@ func (svc *Service) SetHintWallet(w HintWallet) {
|
||||
svc.hintWallet = w
|
||||
}
|
||||
|
||||
// SetGameLimits installs the active-game limit config (cached), enabling the per-tier caps. When
|
||||
// unset (nil), a game is always creatable.
|
||||
func (svc *Service) SetGameLimits(l *gamelimits.Service) {
|
||||
svc.limits = l
|
||||
}
|
||||
|
||||
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind — the
|
||||
// per-tier, per-kind limits held in backend.config, read from the in-memory cache. It resolves
|
||||
// the caller's tier (guest vs durable) from the account, then counts its open+active games of that
|
||||
// kind. It reports false (not at the limit) when the limits config is not wired, the account is nil,
|
||||
// or the resolved cap is gamelimits.Unlimited. It backs the new-game gate (the handler aborts 409
|
||||
// game_limit_reached) and the lobby's at-limit flag.
|
||||
func (svc *Service) AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error) {
|
||||
if svc.limits == nil || accountID == uuid.Nil {
|
||||
return false, nil
|
||||
}
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
limit := svc.limits.LimitsFor(acc.IsGuest).Cap(kind)
|
||||
if limit == gamelimits.Unlimited {
|
||||
return false, nil
|
||||
}
|
||||
n, err := svc.store.CountActiveByKind(ctx, accountID, kind)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n >= limit, nil
|
||||
}
|
||||
|
||||
// walletContext resolves the payments gate inputs for an account on the current request: the
|
||||
// trusted execution context (from the session platform carried on ctx; absent ⇒ untrusted) and
|
||||
// the account's present identity sources (which chip/benefit segments are awake, §6).
|
||||
@@ -338,6 +373,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
dropoutTiles: params.DropoutTiles.String(),
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
vsAI: params.VsAI,
|
||||
kind: params.Kind,
|
||||
}
|
||||
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
|
||||
return Game{}, err
|
||||
@@ -401,6 +437,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
status: StatusOpen,
|
||||
openDeadline: &deadline,
|
||||
kind: gamelimits.KindRandom,
|
||||
}
|
||||
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a
|
||||
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
|
||||
@@ -1207,7 +1244,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
|
||||
@@ -1272,10 +1309,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 +1323,13 @@ 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(),
|
||||
// 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
|
||||
@@ -1389,14 +1423,6 @@ func (svc *Service) ListForLobby(ctx context.Context, accountID uuid.UUID) ([]Ga
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// CountActiveQuickGames reports how many in-progress quick games the account holds —
|
||||
// the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It
|
||||
// counts active and still-open quick games (including honest-AI ones) and excludes
|
||||
// friend games created by invitation and finished games. See Store.CountActiveQuickGames.
|
||||
func (svc *Service) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
|
||||
return svc.store.CountActiveQuickGames(ctx, accountID)
|
||||
}
|
||||
|
||||
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
|
||||
// players); it is irreversible by design. Only a player of a finished game may hide it
|
||||
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
|
||||
@@ -1776,10 +1802,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.
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
@@ -45,6 +46,8 @@ type gameInsert struct {
|
||||
multipleWordsPerTurn bool
|
||||
// vsAI marks an honest-AI game (games.vs_ai).
|
||||
vsAI bool
|
||||
// kind tags the game's origin (games.game_kind) for the active-game limits.
|
||||
kind gamelimits.Kind
|
||||
// status is the lifecycle state to create the game in: StatusActive for a normal
|
||||
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
|
||||
// empty string defaults to StatusActive.
|
||||
@@ -138,6 +141,20 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInse
|
||||
})
|
||||
}
|
||||
|
||||
// CountActiveByKind counts the account's active (open or in-progress) games of the given kind — the
|
||||
// per-tier active-game limit is checked against it before a new game of that kind is created.
|
||||
func (s *Store) CountActiveByKind(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (int, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM backend.games g
|
||||
JOIN backend.game_players p ON p.game_id = g.game_id
|
||||
WHERE p.account_id = $1 AND g.game_kind = $2 AND g.status IN ('open', 'active')`,
|
||||
accountID, int16(kind)).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("game: count active by kind %s: %w", accountID, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// insertGameTx inserts the games row and one game_players row per seat (seat 0
|
||||
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
|
||||
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
|
||||
@@ -155,9 +172,9 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
|
||||
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
||||
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
|
||||
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
|
||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi,
|
||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind,
|
||||
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
|
||||
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI)
|
||||
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind))
|
||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert game: %w", err)
|
||||
}
|
||||
@@ -501,28 +518,6 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CountActiveQuickGames counts the account's in-progress quick games — the ones the
|
||||
// simultaneous-game limit (MaxActiveQuickGames) is checked against. It includes both
|
||||
// active and still-open (awaiting-opponent) games, the honest-AI ones among them, and
|
||||
// excludes friend games (those linked to a game_invitations row) and finished games.
|
||||
// A hidden game still occupies a slot, so this is a dedicated count rather than a
|
||||
// filter over ListGamesForAccount (which drops hidden games). Joining on the account's
|
||||
// own seat counts each game once (an open game's empty opponent seat has no account).
|
||||
func (s *Store) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
|
||||
// The status literals are game.StatusActive / game.StatusOpen, matching the
|
||||
// games.status CHECK in the baseline migration.
|
||||
const q = `
|
||||
SELECT COUNT(*) FROM backend.games g
|
||||
JOIN backend.game_players gp ON gp.game_id = g.game_id
|
||||
LEFT JOIN backend.game_invitations gi ON gi.game_id = g.game_id
|
||||
WHERE gp.account_id = $1 AND g.status IN ('active', 'open') AND gi.game_id IS NULL`
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx, q, accountID).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("game: count active quick games: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
|
||||
// game is finished and the account is a player.
|
||||
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
|
||||
@@ -1361,6 +1356,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
||||
}
|
||||
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
|
||||
out.VsAI = g.VsAi
|
||||
out.Kind = gamelimits.Kind(g.GameKind)
|
||||
if g.EndReason != nil {
|
||||
out.EndReason = *g.EndReason
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
)
|
||||
|
||||
// Status values persisted in the games.status column.
|
||||
@@ -94,15 +95,6 @@ const DefaultTurnTimeout = 24 * time.Hour
|
||||
// one of AllowedTurnTimeouts (never offered in the creation UI).
|
||||
const AIInactivityTimeout = 7 * 24 * time.Hour
|
||||
|
||||
// MaxActiveQuickGames is the cap on a player's simultaneous quick games (human
|
||||
// auto-match and honest-AI), counting both in-progress (StatusActive) and
|
||||
// still-open, awaiting-opponent (StatusOpen) games. Friend games created by
|
||||
// invitation are not counted. At the cap the backend refuses to create a new game
|
||||
// of any kind — quick or by invitation — and the lobby disables "New Game";
|
||||
// accepting an incoming invitation is always allowed. See
|
||||
// Store.CountActiveQuickGames.
|
||||
const MaxActiveQuickGames = 10
|
||||
|
||||
// aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
|
||||
// game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
|
||||
// UI shows 🤖 from the game's vs_ai flag).
|
||||
@@ -125,6 +117,10 @@ type CreateParams struct {
|
||||
// robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge
|
||||
// and finish-time statistics are suppressed. Set by the lobby's AI-match path.
|
||||
VsAI bool
|
||||
// Kind tags the game's origin (games.game_kind) for the active-game limits: vs_ai / random /
|
||||
// friends. The lobby sets it; a zero (unknown) kind is never gated. The active-game limit itself
|
||||
// is enforced at the new-game handler (Server.ensureUnderGameLimit), not here.
|
||||
Kind gamelimits.Kind
|
||||
}
|
||||
|
||||
// Game is the persisted state of a match: the games row joined with its seats.
|
||||
@@ -151,6 +147,9 @@ type Game struct {
|
||||
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
|
||||
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
|
||||
VsAI bool
|
||||
// Kind is the game's origin tag (games.game_kind) for the active-game limits: vs_ai / random /
|
||||
// friends, or unknown for an untagged game. Read-only projection; set once on creation.
|
||||
Kind gamelimits.Kind
|
||||
}
|
||||
|
||||
// Seat is one player's standing in a game.
|
||||
@@ -211,10 +210,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 +239,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
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Package gamelimits holds the per-tier, per-kind active-game caps (a guest funnel) with a hot
|
||||
// in-memory cache over the single-row backend.config, so a login or a game-create never queries it.
|
||||
package gamelimits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// Kind is a game's kind, mirroring backend.games.game_kind. 0 (unknown) is an untagged game, never gated.
|
||||
type Kind int16
|
||||
|
||||
const (
|
||||
KindUnknown Kind = 0
|
||||
KindVsAI Kind = 1
|
||||
KindRandom Kind = 2
|
||||
KindFriends Kind = 3
|
||||
)
|
||||
|
||||
// String returns the kind's label for admin game lists and logs.
|
||||
func (k Kind) String() string {
|
||||
switch k {
|
||||
case KindVsAI:
|
||||
return "vs_ai"
|
||||
case KindRandom:
|
||||
return "random"
|
||||
case KindFriends:
|
||||
return "friends"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Unlimited is the limit sentinel meaning no cap.
|
||||
const Unlimited = -1
|
||||
|
||||
// Limits is a tier's active-game caps per kind (-1 = unlimited).
|
||||
type Limits struct {
|
||||
VsAI int
|
||||
Random int
|
||||
Friends int
|
||||
}
|
||||
|
||||
// Cap returns the limit for kind; the unknown kind is never capped.
|
||||
func (l Limits) Cap(kind Kind) int {
|
||||
switch kind {
|
||||
case KindVsAI:
|
||||
return l.VsAI
|
||||
case KindRandom:
|
||||
return l.Random
|
||||
case KindFriends:
|
||||
return l.Friends
|
||||
default:
|
||||
return Unlimited
|
||||
}
|
||||
}
|
||||
|
||||
// Config is the per-tier limit config (the single backend.config row).
|
||||
type Config struct {
|
||||
Guest Limits
|
||||
Durable Limits
|
||||
}
|
||||
|
||||
// Store reads and writes the single-row backend.config.
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
// NewStore constructs a Store over db.
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
func (s *Store) load(ctx context.Context) (Config, error) {
|
||||
var c model.Config
|
||||
if err := postgres.SELECT(table.Config.AllColumns).FROM(table.Config).LIMIT(1).
|
||||
QueryContext(ctx, s.db, &c); err != nil {
|
||||
return Config{}, fmt.Errorf("gamelimits: load config: %w", err)
|
||||
}
|
||||
return Config{
|
||||
Guest: Limits{VsAI: int(c.GuestVsAiLimit), Random: int(c.GuestRandomLimit), Friends: int(c.GuestFriendsLimit)},
|
||||
Durable: Limits{VsAI: int(c.DurableVsAiLimit), Random: int(c.DurableRandomLimit), Friends: int(c.DurableFriendsLimit)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) save(ctx context.Context, c Config) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE backend.config SET guest_vs_ai_limit=$1, guest_random_limit=$2, guest_friends_limit=$3,
|
||||
durable_vs_ai_limit=$4, durable_random_limit=$5, durable_friends_limit=$6 WHERE only_row`,
|
||||
c.Guest.VsAI, c.Guest.Random, c.Guest.Friends, c.Durable.VsAI, c.Durable.Random, c.Durable.Friends); err != nil {
|
||||
return fmt.Errorf("gamelimits: save config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Service fronts the config with an in-memory cache (single-instance, matching the deploy). Load
|
||||
// once at startup; Update after an admin edit refreshes it in place.
|
||||
type Service struct {
|
||||
store *Store
|
||||
mu sync.RWMutex
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// NewService constructs a Service over store. Call Load before serving.
|
||||
func NewService(store *Store) *Service { return &Service{store: store} }
|
||||
|
||||
// Load reads the config into the cache. Call once at startup.
|
||||
func (s *Service) Load(ctx context.Context) error {
|
||||
c, err := s.store.load(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.set(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) set(c Config) {
|
||||
s.mu.Lock()
|
||||
s.cfg = c
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get returns the cached config.
|
||||
func (s *Service) Get() Config {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
// LimitsFor returns the cached limits for the account tier (guest or durable).
|
||||
func (s *Service) LimitsFor(isGuest bool) Limits {
|
||||
c := s.Get()
|
||||
if isGuest {
|
||||
return c.Guest
|
||||
}
|
||||
return c.Durable
|
||||
}
|
||||
|
||||
// Update saves the config and refreshes the cache in place (the admin edit).
|
||||
func (s *Service) Update(ctx context.Context, c Config) error {
|
||||
if err := s.store.save(ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
s.set(c)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package gamelimits
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLimitsCap checks Cap maps each kind to its field and leaves the unknown kind uncapped, so a
|
||||
// untagged game (game_kind 0) is never gated.
|
||||
func TestLimitsCap(t *testing.T) {
|
||||
l := Limits{VsAI: 1, Random: 2, Friends: 3}
|
||||
for _, tc := range []struct {
|
||||
kind Kind
|
||||
want int
|
||||
}{
|
||||
{KindVsAI, 1},
|
||||
{KindRandom, 2},
|
||||
{KindFriends, 3},
|
||||
{KindUnknown, Unlimited},
|
||||
{Kind(99), Unlimited},
|
||||
} {
|
||||
if got := l.Cap(tc.kind); got != tc.want {
|
||||
t.Errorf("Cap(%d) = %d, want %d", tc.kind, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceLimitsForTier checks LimitsFor selects the guest or durable tier from the cached config.
|
||||
func TestServiceLimitsForTier(t *testing.T) {
|
||||
svc := NewService(nil)
|
||||
svc.set(Config{
|
||||
Guest: Limits{VsAI: 1, Random: 1, Friends: 0},
|
||||
Durable: Limits{VsAI: 10, Random: 10, Friends: 10},
|
||||
})
|
||||
|
||||
if got := svc.LimitsFor(true); got != (Limits{VsAI: 1, Random: 1, Friends: 0}) {
|
||||
t.Errorf("guest limits = %+v, want {1 1 0}", got)
|
||||
}
|
||||
if got := svc.LimitsFor(false); got != (Limits{VsAI: 10, Random: 10, Friends: 10}) {
|
||||
t.Errorf("durable limits = %+v, want {10 10 10}", got)
|
||||
}
|
||||
// The unlimited sentinel resolves through the tier too.
|
||||
svc.set(Config{Durable: Limits{VsAI: Unlimited, Random: Unlimited, Friends: Unlimited}})
|
||||
if got := svc.LimitsFor(false).Cap(KindVsAI); got != Unlimited {
|
||||
t.Errorf("durable vs_ai cap = %d, want unlimited (-1)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// benefitFor returns the account's benefit on the given origin from its statement.
|
||||
func benefitFor(t *testing.T, pay *payments.Service, id uuid.UUID, origin payments.Source) payments.OriginBenefit {
|
||||
t.Helper()
|
||||
stmt, err := pay.AccountStatement(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("statement: %v", err)
|
||||
}
|
||||
for _, b := range stmt.Benefits {
|
||||
if b.Origin == origin {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return payments.OriginBenefit{}
|
||||
}
|
||||
|
||||
// TestConsoleAdminGrant drives the admin grant: a raw benefit grant, a by-product grant of a reward
|
||||
// bundle, and a refusal to grant a chips pack; the create is CSRF-guarded.
|
||||
func TestConsoleAdminGrant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
id := provisionAccount(t)
|
||||
const origin = "http://admin.test"
|
||||
base := "http://admin.test/_gm/users/" + id.String()
|
||||
|
||||
// CSRF: a grant without the origin header is refused.
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=1", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("grant without origin = %d, want 403", code)
|
||||
}
|
||||
|
||||
// Raw grant: 5 hints + 30 no-ads days to the direct origin.
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=5&noads=30", origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
|
||||
t.Fatalf("raw grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
|
||||
}
|
||||
if b := benefitFor(t, pay, id, payments.SourceDirect); b.Hints != 5 || b.AdsPaidUntil.IsZero() {
|
||||
t.Fatalf("after raw grant: hints=%d adsUntil-zero=%v, want 5 hints + a no-ads term", b.Hints, b.AdsPaidUntil.IsZero())
|
||||
}
|
||||
|
||||
// By-product grant: an archived reward bundle (3 hints) to vk.
|
||||
reward, err := pay.CreateProduct(ctx, payments.ProductInput{
|
||||
Title: "reward-3-hints", Atoms: []payments.AtomLine{{Atom: "hints", Quantity: 3}},
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("create reward: %v", err)
|
||||
}
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=vk&product_id="+reward.String(), origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
|
||||
t.Fatalf("product grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
|
||||
}
|
||||
if b := benefitFor(t, pay, id, payments.SourceVK); b.Hints != 3 {
|
||||
t.Fatalf("after product grant: vk hints=%d, want 3", b.Hints)
|
||||
}
|
||||
|
||||
// A chips pack cannot be granted.
|
||||
pack, err := pay.CreateProduct(ctx, payments.ProductInput{
|
||||
Title: "grant-pack", Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 100}},
|
||||
Prices: []payments.PriceLine{{Method: "direct", Currency: payments.CurrencyRUB, Amount: 14900}},
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("create pack: %v", err)
|
||||
}
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=direct&product_id="+pack.String(), origin); code != http.StatusOK || !strings.Contains(body, "cannot grant chips") {
|
||||
t.Fatalf("chips grant = %d, has 'cannot grant chips' = %v", code, strings.Contains(body, "cannot grant chips"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// TestConsoleRefundAndExport drives the manual refund and the ledger CSV export: a funded order is
|
||||
// refunded in full (chips revoked, a refund row), the refund is idempotent, and the export carries
|
||||
// the fund + refund rows; the refund POST is CSRF-guarded.
|
||||
func TestConsoleRefundAndExport(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
id := provisionAccount(t)
|
||||
const origin = "http://admin.test"
|
||||
base := "http://admin.test/_gm/users/" + id.String()
|
||||
|
||||
// Fund a pack: 100 chips + a fund ledger row.
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("order: %v", err)
|
||||
}
|
||||
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
|
||||
// CSRF: a refund without the origin header is refused.
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), ""); code != http.StatusForbidden {
|
||||
t.Fatalf("refund without origin = %d, want 403", code)
|
||||
}
|
||||
|
||||
// Refund the order in full → 100 chips revoked (none spent).
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
|
||||
t.Fatalf("refund = %d, has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
|
||||
}
|
||||
stmt, err := pay.AccountStatement(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("statement: %v", err)
|
||||
}
|
||||
for _, sg := range stmt.Segments {
|
||||
if sg.Source == payments.SourceDirect && sg.Chips != 0 {
|
||||
t.Errorf("after refund: direct chips = %d, want 0", sg.Chips)
|
||||
}
|
||||
}
|
||||
kinds := map[string]int{}
|
||||
for _, e := range stmt.Ledger {
|
||||
kinds[e.Kind]++
|
||||
}
|
||||
if kinds["fund"] != 1 || kinds["refund"] != 1 {
|
||||
t.Errorf("ledger kinds = %v, want one fund + one refund", kinds)
|
||||
}
|
||||
|
||||
// A second refund of the same order is idempotent.
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "Already refunded") {
|
||||
t.Fatalf("second refund = %d, has 'Already refunded' = %v", code, strings.Contains(body, "Already refunded"))
|
||||
}
|
||||
|
||||
// Export the whole ledger as CSV: the header, this account, and its fund + refund rows.
|
||||
code, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/ledger.csv", "", "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("export = %d, want 200", code)
|
||||
}
|
||||
for _, want := range []string{"created_at,account_id,kind", id.String(), ",fund,", ",refund,"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("ledger CSV missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package inttest
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -18,59 +19,119 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/server"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
// The game-limit suite covers the simultaneous quick-game cap (game.MaxActiveQuickGames):
|
||||
// the counting rule (Store/Service.CountActiveQuickGames) and the HTTP gate that refuses a
|
||||
// new game once the cap is reached, while accepting an incoming invitation stays allowed.
|
||||
// The game-limit suite covers the per-tier, per-kind active-game caps (backend.config): the
|
||||
// game_kind tag persisted per creation path, the tier resolution + counting rule
|
||||
// (game.Service.AtGameLimit), the HTTP gate + lobby at_game_limit flag, the guest gate on friend
|
||||
// actions, and the config hot-cache reflecting an admin edit. Accepting an invitation stays exempt.
|
||||
|
||||
// TestCountActiveQuickGames checks the count includes active and open quick games (the
|
||||
// honest-AI ones among them) and excludes finished games, friend games (created by
|
||||
// invitation) and games the account is not seated in.
|
||||
func TestCountActiveQuickGames(t *testing.T) {
|
||||
// newGameLimits builds a gamelimits service over the shared pool and loads the seeded config
|
||||
// (guest 1/1/0, durable 10/10/10).
|
||||
func newGameLimits(t *testing.T) *gamelimits.Service {
|
||||
t.Helper()
|
||||
gl := gamelimits.NewService(gamelimits.NewStore(testDB))
|
||||
if err := gl.Load(context.Background()); err != nil {
|
||||
t.Fatalf("load game limits: %v", err)
|
||||
}
|
||||
return gl
|
||||
}
|
||||
|
||||
// restoreDefaultLimits resets the single config row to the migration seed on cleanup, so a test that
|
||||
// edited it never leaks its values into another (the row is a shared singleton).
|
||||
func restoreDefaultLimits(t *testing.T, gl *gamelimits.Service) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
_ = gl.Update(context.Background(), gamelimits.Config{
|
||||
Guest: gamelimits.Limits{VsAI: 1, Random: 1, Friends: 0},
|
||||
Durable: gamelimits.Limits{VsAI: 10, Random: 10, Friends: 10},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// gameKind reads a game's persisted game_kind tag.
|
||||
func gameKind(t *testing.T, gameID uuid.UUID) int16 {
|
||||
t.Helper()
|
||||
var k int16
|
||||
if err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT game_kind FROM backend.games WHERE game_id=$1`, gameID).Scan(&k); err != nil {
|
||||
t.Fatalf("read game_kind: %v", err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// mustCreateKind creates an active game seating the accounts, tagged with kind, and returns its id.
|
||||
func mustCreateKind(t *testing.T, games *game.Service, seats []uuid.UUID, kind gamelimits.Kind) uuid.UUID {
|
||||
t.Helper()
|
||||
g, err := games.Create(context.Background(), game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Kind: kind,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create game (kind=%d): %v", kind, err)
|
||||
}
|
||||
return g.ID
|
||||
}
|
||||
|
||||
// startFriendGameID has inviter invite invitee to a friend game and the invitee accept, returning
|
||||
// the started game's id.
|
||||
func startFriendGameID(t *testing.T, inv *lobby.InvitationService, inviter, invitee uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create invitation: %v", err)
|
||||
}
|
||||
got, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true)
|
||||
if err != nil {
|
||||
t.Fatalf("accept invitation: %v", err)
|
||||
}
|
||||
if got.GameID == nil {
|
||||
t.Fatal("accepted invitation has no game id")
|
||||
}
|
||||
return *got.GameID
|
||||
}
|
||||
|
||||
// TestGameKindPersisted checks each creation path stamps the right game_kind: random (auto-match)
|
||||
// =2, vs_ai =1, friend (by invitation) =3.
|
||||
func TestGameKindPersisted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
games := newGameService()
|
||||
human := provisionAccount(t)
|
||||
opp := provisionAccount(t)
|
||||
inv := newInvitationService()
|
||||
human, opp := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
if n := mustCount(t, games, human); n != 0 {
|
||||
t.Fatalf("fresh account count = %d, want 0", n)
|
||||
rnd, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour}, time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open random game: %v", err)
|
||||
}
|
||||
if k := gameKind(t, rnd.ID); k != int16(gamelimits.KindRandom) {
|
||||
t.Errorf("random game_kind = %d, want %d", k, gamelimits.KindRandom)
|
||||
}
|
||||
|
||||
// An open (awaiting-opponent) quick game counts.
|
||||
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
|
||||
}, time.Now().Add(time.Minute), nil); err != nil {
|
||||
t.Fatalf("open quick game: %v", err)
|
||||
ai := mustCreateKind(t, games, []uuid.UUID{human, opp}, gamelimits.KindVsAI)
|
||||
if k := gameKind(t, ai); k != int16(gamelimits.KindVsAI) {
|
||||
t.Errorf("vs_ai game_kind = %d, want %d", k, gamelimits.KindVsAI)
|
||||
}
|
||||
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
|
||||
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 1)
|
||||
mustCreateQuick(t, games, []uuid.UUID{human, opp}, true, 2)
|
||||
|
||||
// A finished quick game does NOT count.
|
||||
fin := mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 3)
|
||||
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, fin); err != nil {
|
||||
t.Fatalf("finish game: %v", err)
|
||||
}
|
||||
// A game the human is not seated in does NOT count.
|
||||
mustCreateQuick(t, games, []uuid.UUID{opp, provisionAccount(t)}, false, 4)
|
||||
// A friend game (created by invitation) does NOT count, even though it is active.
|
||||
startFriendGame(t, human)
|
||||
|
||||
if n := mustCount(t, games, human); n != 3 {
|
||||
t.Fatalf("active quick count = %d, want 3 (active + AI + open)", n)
|
||||
friend := startFriendGameID(t, inv, human, opp)
|
||||
if k := gameKind(t, friend); k != int16(gamelimits.KindFriends) {
|
||||
t.Errorf("friend game_kind = %d, want %d", k, gamelimits.KindFriends)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameLimitGate drives the cap through the assembled HTTP server: under the cap the lobby
|
||||
// reports at_game_limit false; at the cap it flips to true and both new-game endpoints (quick
|
||||
// enqueue and invitation creation) are refused with 409 game_limit_reached, while accepting an
|
||||
// incoming invitation is still allowed.
|
||||
func TestGameLimitGate(t *testing.T) {
|
||||
// TestGuestActiveGameLimitHTTP drives the guest random cap through the assembled server: the first
|
||||
// auto-match opens a game, the lobby then flags at_game_limit, and a second enqueue is refused 409
|
||||
// game_limit_reached. It also checks the guest vs_ai and friends caps resolve at the domain level.
|
||||
func TestGuestActiveGameLimitHTTP(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
gl := newGameLimits(t)
|
||||
games := newGameService()
|
||||
games.SetGameLimits(gl)
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zaptest.NewLogger(t),
|
||||
DB: testDB,
|
||||
@@ -78,88 +139,110 @@ func TestGameLimitGate(t *testing.T) {
|
||||
Games: games,
|
||||
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
|
||||
Invitations: newInvitationService(),
|
||||
GameLimits: gl,
|
||||
})
|
||||
|
||||
human := provisionAccount(t)
|
||||
guest := provisionGuest(t)
|
||||
if gamesListAtLimit(t, srv, guest) {
|
||||
t.Fatal("a fresh guest must be under the random game limit")
|
||||
}
|
||||
// The first auto-match opens a random game (guest random cap = 1).
|
||||
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("first enqueue = %d (%s), want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
// At the cap: the lobby flags it and a second enqueue is refused with the stable code.
|
||||
if !gamesListAtLimit(t, srv, guest) {
|
||||
t.Fatal("after one random game the guest must be at the limit")
|
||||
}
|
||||
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
||||
t.Fatalf("second enqueue = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
||||
}
|
||||
|
||||
// A guest is refused the friends flow outright at the HTTP edge (403 guest_forbidden).
|
||||
opp := provisionAccount(t)
|
||||
|
||||
// Under the cap: the lobby reports the player is not limited.
|
||||
if gamesListAtLimit(t, srv, human) {
|
||||
t.Fatal("a fresh account must be under the game limit")
|
||||
}
|
||||
|
||||
// Reach the cap with active quick games seating the human (no invitation row → quick).
|
||||
for i := 0; i < game.MaxActiveQuickGames; i++ {
|
||||
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, int64(i+1))
|
||||
}
|
||||
|
||||
// At the cap: the lobby flags it and both create paths are refused with the stable code.
|
||||
if !gamesListAtLimit(t, srv, human) {
|
||||
t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames)
|
||||
}
|
||||
// erudit_ru is in the default variant preferences, so the variant gate passes and the
|
||||
// game-limit gate is what fires here.
|
||||
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
||||
t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
||||
}
|
||||
invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String())
|
||||
if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
|
||||
t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
|
||||
if rec := userPost(t, srv, "/api/v1/user/invitations", guest, invBody); rec.Code != http.StatusForbidden || errorCode(t, rec) != "guest_forbidden" {
|
||||
t.Fatalf("guest invitation = (%d, %q), want (403, guest_forbidden)", rec.Code, errorCode(t, rec))
|
||||
}
|
||||
|
||||
// Accepting an incoming invitation is never blocked, even at the cap: another player invites
|
||||
// the capped human, who accepts over HTTP and the game starts (friend games do not count).
|
||||
inviter := provisionAccount(t)
|
||||
inv := newInvitationService()
|
||||
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{human}, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create invitation: %v", err)
|
||||
// The guest vs_ai cap is 1: one vs_ai game puts the guest at the vs_ai limit.
|
||||
mustCreateKind(t, games, []uuid.UUID{guest, opp}, gamelimits.KindVsAI)
|
||||
if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindVsAI); err != nil || !at {
|
||||
t.Fatalf("guest vs_ai at-limit = (%v, %v), want (true, nil)", at, err)
|
||||
}
|
||||
if rec := userPost(t, srv, "/api/v1/user/invitations/"+invitation.ID.String()+"/accept", human, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("accept at limit = %d (%s), want 200 — accept must bypass the cap", rec.Code, rec.Body.String())
|
||||
// The guest friends cap is 0: a guest is always at the friends limit (the 403 gate blocks first).
|
||||
if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindFriends); err != nil || !at {
|
||||
t.Fatalf("guest friends at-limit = (%v, %v), want (true, nil)", at, err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustCount returns the account's active-quick-game count, failing on error.
|
||||
func mustCount(t *testing.T, games *game.Service, id uuid.UUID) int {
|
||||
t.Helper()
|
||||
n, err := games.CountActiveQuickGames(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("count active quick games: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// mustCreateQuick creates an active quick game (no invitation) seating the given accounts and
|
||||
// returns its id; vsAI flags an honest-AI game (the service then applies the 7-day clock).
|
||||
func mustCreateQuick(t *testing.T, games *game.Service, seats []uuid.UUID, vsAI bool, seed int64) uuid.UUID {
|
||||
t.Helper()
|
||||
p := game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed}
|
||||
if vsAI {
|
||||
p.VsAI = true
|
||||
p.TurnTimeout = 0 // the service applies the 7-day AI inactivity clock for vs_ai games
|
||||
}
|
||||
g, err := games.Create(context.Background(), p)
|
||||
if err != nil {
|
||||
t.Fatalf("create quick game (vsAI=%v): %v", vsAI, err)
|
||||
}
|
||||
return g.ID
|
||||
}
|
||||
|
||||
// startFriendGame has a fresh inviter invite the given account to a friend game and the invitee
|
||||
// accept it, so the (active) friend game exists with its game_invitations row.
|
||||
func startFriendGame(t *testing.T, invitee uuid.UUID) {
|
||||
t.Helper()
|
||||
// TestDurableTierHigherLimit checks a durable account resolves the durable tier, not the guest one:
|
||||
// one random game leaves it well under the durable cap (10).
|
||||
func TestDurableTierHigherLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gl := newGameLimits(t)
|
||||
games := newGameService()
|
||||
games.SetGameLimits(gl)
|
||||
durable, opp := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
mustCreateKind(t, games, []uuid.UUID{durable, opp}, gamelimits.KindRandom)
|
||||
if at, err := games.AtGameLimit(ctx, durable, gamelimits.KindRandom); err != nil || at {
|
||||
t.Fatalf("durable random at-limit after one game = (%v, %v), want (false, nil)", at, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuestForbiddenFriendActions checks a guest is refused every friend action server-side (the UI
|
||||
// hides them; this is the source of truth): creating an invitation, sending a friend request, and
|
||||
// redeeming a friend code.
|
||||
func TestGuestForbiddenFriendActions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
guest := provisionGuest(t)
|
||||
other := provisionAccount(t)
|
||||
|
||||
inv := newInvitationService()
|
||||
if _, err := inv.CreateInvitation(ctx, guest, []uuid.UUID{other}, englishInvite()); !errors.Is(err, lobby.ErrGuestForbidden) {
|
||||
t.Fatalf("guest CreateInvitation err = %v, want ErrGuestForbidden", err)
|
||||
}
|
||||
|
||||
soc := newSocialService()
|
||||
if err := soc.SendFriendRequest(ctx, guest, other); !errors.Is(err, social.ErrGuestForbidden) {
|
||||
t.Fatalf("guest SendFriendRequest err = %v, want ErrGuestForbidden", err)
|
||||
}
|
||||
if _, err := soc.RedeemFriendCode(ctx, guest, "000000"); !errors.Is(err, social.ErrGuestForbidden) {
|
||||
t.Fatalf("guest RedeemFriendCode err = %v, want ErrGuestForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDurableFriendsCapAndConfigCache lowers the durable friends cap to 1 through the service (the
|
||||
// admin-edit path), checks a durable inviter is refused a second friend game with 409, and that
|
||||
// accepting an incoming invitation is still exempt from the cap.
|
||||
func TestDurableFriendsCapAndConfigCache(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gl := newGameLimits(t)
|
||||
restoreDefaultLimits(t, gl)
|
||||
cfg := gl.Get()
|
||||
cfg.Durable.Friends = 1
|
||||
if err := gl.Update(ctx, cfg); err != nil {
|
||||
t.Fatalf("update durable friends cap: %v", err)
|
||||
}
|
||||
games := newGameService()
|
||||
games.SetGameLimits(gl)
|
||||
inv := lobby.NewInvitationService(lobby.NewStore(testDB), games, account.NewStore(testDB), newSocialService())
|
||||
|
||||
inviter := provisionAccount(t)
|
||||
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create invitation: %v", err)
|
||||
d2, d3 := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
// The inviter's first friend game reaches the (lowered) cap of 1.
|
||||
startFriendGameID(t, inv, inviter, d2)
|
||||
if at, err := games.AtGameLimit(ctx, inviter, gamelimits.KindFriends); err != nil || !at {
|
||||
t.Fatalf("inviter friends at-limit = (%v, %v), want (true, nil)", at, err)
|
||||
}
|
||||
if _, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true); err != nil {
|
||||
t.Fatalf("accept invitation: %v", err)
|
||||
// A second invitation is refused: the cache reflects the edit.
|
||||
if _, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{d3}, englishInvite()); !errors.Is(err, game.ErrGameLimitReached) {
|
||||
t.Fatalf("second invitation err = %v, want ErrGameLimitReached", err)
|
||||
}
|
||||
// Accept stays exempt: someone else invites the capped inviter, who accepts and the game starts.
|
||||
startFriendGameID(t, inv, d3, inviter)
|
||||
}
|
||||
|
||||
// gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// orderStatus reads an order's status.
|
||||
func orderStatus(t *testing.T, orderID uuid.UUID) string {
|
||||
t.Helper()
|
||||
var status string
|
||||
if err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT status FROM payments.orders WHERE order_id=$1`, orderID).Scan(&status); err != nil {
|
||||
t.Fatalf("read order status: %v", err)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// readRisk reads an account's payment-risk row (abuse flag + accumulated loss), or (false, 0) when
|
||||
// none exists.
|
||||
func readRisk(t *testing.T, acc uuid.UUID) (abuse bool, loss int64) {
|
||||
t.Helper()
|
||||
err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT abuse, loss_chips FROM payments.account_risk WHERE account_id=$1`, acc).Scan(&abuse, &loss)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, 0
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read risk: %v", err)
|
||||
}
|
||||
return abuse, loss
|
||||
}
|
||||
|
||||
// TestPaymentsOrderFundCreditsOnce verifies the intake path over Postgres: creating an order then
|
||||
// funding it credits the funded segment exactly once, and a replayed callback (the same order)
|
||||
// credits nothing more — the ledger idempotency index holds.
|
||||
func TestPaymentsOrderFundCreditsOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) // 149.00 RUB funds 100 chips
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
if res.Amount.Minor() != 14900 || res.Amount.Currency() != payments.CurrencyRUB {
|
||||
t.Fatalf("order amount = %s, want 149.00 RUB", res.Amount)
|
||||
}
|
||||
if orderStatus(t, res.OrderID) != "pending" {
|
||||
t.Errorf("new order status = %s, want pending", orderStatus(t, res.OrderID))
|
||||
}
|
||||
|
||||
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
|
||||
if err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
if out.AlreadyCredited || out.Chips != 100 || out.Source != payments.SourceDirect {
|
||||
t.Fatalf("fund outcome = %+v, want 100 chips to direct, not already-credited", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance after fund = %d, want 100", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "fund") != 1 {
|
||||
t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund"))
|
||||
}
|
||||
if orderStatus(t, res.OrderID) != "paid" {
|
||||
t.Errorf("order status after fund = %s, want paid", orderStatus(t, res.OrderID))
|
||||
}
|
||||
|
||||
// A replayed callback for the same order is rejected by the unique index: no second credit.
|
||||
out2, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate fund: %v", err)
|
||||
}
|
||||
if !out2.AlreadyCredited {
|
||||
t.Error("duplicate callback not flagged AlreadyCredited")
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance after duplicate = %d, want 100 (credited once)", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "fund") != 1 {
|
||||
t.Error("duplicate callback wrote a second fund ledger row")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsVKOrderFundCredits exercises the VK Votes rail over Postgres: a VK order prices the
|
||||
// pack in votes; the get_item lookup returns its title and vote price; a chargeable
|
||||
// order_status_change credits the vk segment exactly once (idempotent on VK's own order id).
|
||||
func TestPaymentsVKOrderFundCredits(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 200, methodPrice{method: "vk", currency: "VOTE", amount: 30})
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("vk", "web"), []payments.Source{payments.SourceVK}, prod, "vk")
|
||||
if err != nil {
|
||||
t.Fatalf("create vk order: %v", err)
|
||||
}
|
||||
if res.Amount.Currency() != payments.CurrencyVote || res.Amount.Minor() != 30 {
|
||||
t.Fatalf("vk order amount = %s, want 30 VOTE", res.Amount)
|
||||
}
|
||||
|
||||
// get_item phase: title + vote price.
|
||||
title, amount, err := svc.OrderItem(ctx, res.OrderID)
|
||||
if err != nil {
|
||||
t.Fatalf("order item: %v", err)
|
||||
}
|
||||
if title == "" || amount.Minor() != 30 || amount.Currency() != payments.CurrencyVote {
|
||||
t.Errorf("order item = %q / %s, want a title + 30 VOTE", title, amount)
|
||||
}
|
||||
|
||||
// order_status_change phase: VK's own order id is the idempotency key.
|
||||
paid, _ := payments.MoneyFromMinor(30, payments.CurrencyVote)
|
||||
out, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid)
|
||||
if err != nil {
|
||||
t.Fatalf("vk fund: %v", err)
|
||||
}
|
||||
if out.AlreadyCredited || out.Chips != 200 || out.Source != payments.SourceVK {
|
||||
t.Fatalf("vk fund outcome = %+v, want 200 chips credited to vk", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 200 {
|
||||
t.Errorf("vk balance after fund = %d, want 200", got)
|
||||
}
|
||||
|
||||
// A duplicate VK callback (same VK order id) credits nothing more.
|
||||
out2, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate vk fund: %v", err)
|
||||
}
|
||||
if !out2.AlreadyCredited {
|
||||
t.Error("duplicate VK callback not flagged AlreadyCredited")
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 200 {
|
||||
t.Errorf("vk balance after duplicate = %d, want 200 (credited once)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsTelegramStarsRail exercises the Telegram Stars rail over Postgres: an order prices the
|
||||
// pack in whole stars (XTR); a pre_checkout on the pending order is approved; the forwarded payment
|
||||
// credits the telegram segment exactly once (idempotent on the Telegram charge id); and a
|
||||
// pre_checkout after the order is paid is declined (a reusable invoice link paid twice).
|
||||
func TestPaymentsTelegramStarsRail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 50, methodPrice{method: "telegram", currency: "XTR", amount: 40}) // 40 stars fund 50 chips
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
|
||||
if err != nil {
|
||||
t.Fatalf("create telegram order: %v", err)
|
||||
}
|
||||
if res.Amount.Currency() != payments.CurrencyStar || res.Amount.Minor() != 40 {
|
||||
t.Fatalf("telegram order amount = %s, want 40 XTR", res.Amount)
|
||||
}
|
||||
|
||||
// pre_checkout on the pending order: approved, and it reports the account for reason localisation.
|
||||
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
|
||||
pc, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout: %v", err)
|
||||
}
|
||||
if !pc.OK || pc.AccountID != acc {
|
||||
t.Fatalf("pre_checkout = %+v, want approved for account %s", pc, acc)
|
||||
}
|
||||
|
||||
// The forwarded successful_payment credits once, idempotent on the Telegram charge id.
|
||||
out, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("telegram fund: %v", err)
|
||||
}
|
||||
if out.AlreadyCredited || out.Chips != 50 || out.Source != payments.SourceTelegram {
|
||||
t.Fatalf("telegram fund outcome = %+v, want 50 chips credited to telegram", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "telegram"); got != 50 {
|
||||
t.Errorf("telegram balance after fund = %d, want 50", got)
|
||||
}
|
||||
|
||||
// A retried forward (same charge id, e.g. a lost ack) credits nothing more.
|
||||
out2, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate telegram fund: %v", err)
|
||||
}
|
||||
if !out2.AlreadyCredited {
|
||||
t.Error("retried forward not flagged AlreadyCredited")
|
||||
}
|
||||
if got := readBalance(t, acc, "telegram"); got != 50 {
|
||||
t.Errorf("telegram balance after retry = %d, want 50 (credited once)", got)
|
||||
}
|
||||
|
||||
// pre_checkout after the order is paid: declined (the reusable-link double-pay guard).
|
||||
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout after paid: %v", err)
|
||||
}
|
||||
if pc2.OK || pc2.Reason != payments.PreCheckoutAlreadyPaid {
|
||||
t.Errorf("pre_checkout after paid = %+v, want a decline with already_paid", pc2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsTelegramPreCheckoutDeclines covers the pre_checkout decline reasons: an unknown order
|
||||
// and an amount that no longer matches.
|
||||
func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
|
||||
|
||||
// An unknown order id declines as gone, with no account to localise against.
|
||||
pc, err := svc.ValidatePreCheckout(ctx, uuid.New(), starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout unknown: %v", err)
|
||||
}
|
||||
if pc.OK || pc.Reason != payments.PreCheckoutGone || pc.AccountID != (uuid.UUID{}) {
|
||||
t.Errorf("pre_checkout unknown = %+v, want a gone decline with no account", pc)
|
||||
}
|
||||
|
||||
// A pending order validated at the wrong amount declines as price-changed.
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "telegram", currency: "XTR", amount: 80})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
|
||||
if err != nil {
|
||||
t.Fatalf("create telegram order: %v", err)
|
||||
}
|
||||
wrong, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
|
||||
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, wrong)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout mismatch: %v", err)
|
||||
}
|
||||
if pc2.OK || pc2.Reason != payments.PreCheckoutPriceChanged {
|
||||
t.Errorf("pre_checkout mismatch = %+v, want a price_changed decline", pc2)
|
||||
}
|
||||
}
|
||||
|
||||
// setRewardConfig sets the rewarded payout and caps on the shared config row, restoring the defaults
|
||||
// after the test (the row is a singleton shared across the sequential suite).
|
||||
func setRewardConfig(t *testing.T, payout, dailyCap, hourlyCap int) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
|
||||
payout, dailyCap, hourlyCap); err != nil {
|
||||
t.Fatalf("set reward config: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = testDB.ExecContext(context.Background(),
|
||||
`UPDATE payments.config SET rewarded_payout_chips=0, reward_daily_cap=50, reward_hourly_cap=10`)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPaymentsRewardCredit exercises the rewarded-video credit: a watched view credits the VK segment
|
||||
// the configured payout, a retried view (same nonce) credits once, and the hourly cap blocks the
|
||||
// third distinct view.
|
||||
func TestPaymentsRewardCredit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
setRewardConfig(t, 5, 5, 2) // 5 chips/view, daily 5, hourly 2
|
||||
vk := payments.NewContext("vk", "web")
|
||||
present := []payments.Source{payments.SourceVK}
|
||||
|
||||
out, err := svc.CreditReward(ctx, acc, vk, present, "n1")
|
||||
if err != nil {
|
||||
t.Fatalf("credit: %v", err)
|
||||
}
|
||||
if out.Chips != 5 || out.Capped {
|
||||
t.Fatalf("reward outcome = %+v, want 5 chips, not capped", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 5 {
|
||||
t.Errorf("vk balance = %d, want 5", got)
|
||||
}
|
||||
|
||||
// A retried view (same nonce) credits nothing more.
|
||||
out2, err := svc.CreditReward(ctx, acc, vk, present, "n1")
|
||||
if err != nil {
|
||||
t.Fatalf("retry: %v", err)
|
||||
}
|
||||
if !out2.AlreadyCredited {
|
||||
t.Error("retried view not flagged AlreadyCredited")
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 5 {
|
||||
t.Errorf("vk balance after retry = %d, want 5 (credited once)", got)
|
||||
}
|
||||
|
||||
// A second distinct view credits again (2 total).
|
||||
if _, err := svc.CreditReward(ctx, acc, vk, present, "n2"); err != nil {
|
||||
t.Fatalf("credit 2: %v", err)
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 10 {
|
||||
t.Errorf("vk balance = %d, want 10", got)
|
||||
}
|
||||
|
||||
// The third distinct view hits the hourly cap (2) — capped, no credit.
|
||||
out3, err := svc.CreditReward(ctx, acc, vk, present, "n3")
|
||||
if err != nil {
|
||||
t.Fatalf("credit 3: %v", err)
|
||||
}
|
||||
if !out3.Capped {
|
||||
t.Error("third view not capped (hourly cap 2)")
|
||||
}
|
||||
if got := readBalance(t, acc, "vk"); got != 10 {
|
||||
t.Errorf("vk balance after cap = %d, want 10 (capped view credited nothing)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRewardDisabledAndContext verifies rewarded is inert when unconfigured (0 payout) and
|
||||
// refused outside a VK context (rewarded is VK-only, D28).
|
||||
func TestPaymentsRewardDisabledAndContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
setRewardConfig(t, 0, 50, 10) // payout 0 = disabled
|
||||
|
||||
vk := payments.NewContext("vk", "web")
|
||||
out, err := svc.CreditReward(ctx, acc, vk, []payments.Source{payments.SourceVK}, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("credit (disabled): %v", err)
|
||||
}
|
||||
if out.Chips != 0 || out.Capped || out.AlreadyCredited {
|
||||
t.Fatalf("disabled reward outcome = %+v, want 0 chips, not capped", out)
|
||||
}
|
||||
|
||||
// A direct (non-VK) context is refused — rewarded is VK-only.
|
||||
direct := payments.NewContext("direct", "web")
|
||||
if _, err := svc.CreditReward(ctx, acc, direct, []payments.Source{payments.SourceDirect}, "d2"); !errors.Is(err, payments.ErrUntrusted) {
|
||||
t.Fatalf("direct-context reward = %v, want ErrUntrusted", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
|
||||
// refused and credits nothing (§9: verify the amount after matching by order id).
|
||||
func TestPaymentsFundAmountMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
underpaid, _ := payments.MoneyFromMinor(100, payments.CurrencyRUB)
|
||||
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), underpaid); !errors.Is(err, payments.ErrAmountMismatch) {
|
||||
t.Fatalf("fund = %v, want ErrAmountMismatch", err)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance = %d, want 0 (nothing credited on mismatch)", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "fund") != 0 {
|
||||
t.Error("fund ledger row written on an amount mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsEventDispatchDrain verifies the payment_events dispatcher queue: a recorded event is
|
||||
// returned as undispatched until marked, then drops out (so the dispatcher delivers it once).
|
||||
func TestPaymentsEventDispatchDrain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
if err := svc.RecordPaymentEvent(ctx, acc, nil, "succeeded", []byte(`{"chips":10,"source":"direct"}`)); err != nil {
|
||||
t.Fatalf("record event: %v", err)
|
||||
}
|
||||
|
||||
// testDB is shared, so filter the queue to our account.
|
||||
find := func() *payments.PaymentEvent {
|
||||
evs, err := svc.UndispatchedEvents(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("undispatched: %v", err)
|
||||
}
|
||||
for i := range evs {
|
||||
if evs[i].AccountID == acc {
|
||||
return &evs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
mine := find()
|
||||
if mine == nil {
|
||||
t.Fatal("recorded event not in the undispatched queue")
|
||||
}
|
||||
if mine.Type != "succeeded" {
|
||||
t.Errorf("event type = %s, want succeeded", mine.Type)
|
||||
}
|
||||
if err := svc.MarkEventDispatched(ctx, mine.EventID); err != nil {
|
||||
t.Fatalf("mark dispatched: %v", err)
|
||||
}
|
||||
if find() != nil {
|
||||
t.Error("event still undispatched after MarkEventDispatched")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsExpiredOrderStillCredits verifies an expired pending order is still honoured by a
|
||||
// later valid callback (§9/D23: expiry is cosmetic, the money is real).
|
||||
func TestPaymentsExpiredOrderStillCredits(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
// Age the order well past the configured TTL, then sweep it to expired.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE payments.orders SET created_at = now() - interval '1 day' WHERE order_id=$1`, res.OrderID); err != nil {
|
||||
t.Fatalf("age order: %v", err)
|
||||
}
|
||||
if n, err := svc.ExpireOrders(ctx); err != nil || n < 1 {
|
||||
t.Fatalf("expire orders = %d (err %v), want at least 1", n, err)
|
||||
}
|
||||
if orderStatus(t, res.OrderID) != "expired" {
|
||||
t.Fatalf("order status = %s, want expired before the late callback", orderStatus(t, res.OrderID))
|
||||
}
|
||||
|
||||
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
|
||||
if err != nil {
|
||||
t.Fatalf("fund after expiry: %v", err)
|
||||
}
|
||||
if out.AlreadyCredited || out.Chips != 100 {
|
||||
t.Fatalf("fund outcome = %+v, want a fresh 100-chip credit", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance = %d, want 100 (expired order honoured)", got)
|
||||
}
|
||||
if orderStatus(t, res.OrderID) != "paid" {
|
||||
t.Errorf("order status = %s, want paid after the honoured callback", orderStatus(t, res.OrderID))
|
||||
}
|
||||
}
|
||||
|
||||
// fundedOrder creates and funds an order for chips in the direct rail, returning its id and account.
|
||||
func fundedOrder(t *testing.T, svc *payments.Service, chips int, priceMinor int64) (uuid.UUID, uuid.UUID) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, chips, methodPrice{method: "direct", currency: "RUB", amount: priceMinor})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
paid, _ := payments.MoneyFromMinor(priceMinor, payments.CurrencyRUB)
|
||||
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
return res.OrderID, acc
|
||||
}
|
||||
|
||||
// TestPaymentsRefundFull reverses a fully-unspent order: all chips are clawed back, no loss/abuse.
|
||||
func TestPaymentsRefundFull(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
orderID, acc := fundedOrder(t, svc, 100, 14900)
|
||||
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-1", refunded)
|
||||
if err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
if out.AlreadyRefunded || out.Revoked != 100 || out.Loss != 0 || out.Source != payments.SourceDirect {
|
||||
t.Fatalf("refund outcome = %+v, want 100 revoked, 0 loss", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance after refund = %d, want 0", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "refund") != 1 {
|
||||
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
|
||||
}
|
||||
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
|
||||
t.Errorf("risk = (%v, %d), want (false, 0) — nothing was spent", abuse, loss)
|
||||
}
|
||||
// The order stays 'paid' — the refund lives in the ledger, not in the order status.
|
||||
if orderStatus(t, orderID) != "paid" {
|
||||
t.Errorf("order status = %s, want paid (refund is ledger-only)", orderStatus(t, orderID))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRefundAfterSpend reverses an order whose chips were partly spent: the reversal floors
|
||||
// at 0 (never negative), and the unrecoverable remainder is recorded as a loss + abuse flag.
|
||||
func TestPaymentsRefundAfterSpend(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
orderID, acc := fundedOrder(t, svc, 100, 14900)
|
||||
|
||||
// Simulate 70 chips already spent, leaving 30 in the funded segment.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE payments.balances SET chips = 30 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
|
||||
t.Fatalf("simulate spend: %v", err)
|
||||
}
|
||||
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-2", refunded)
|
||||
if err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
if out.Revoked != 30 || out.Loss != 70 {
|
||||
t.Fatalf("refund outcome = %+v, want 30 revoked / 70 loss", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance after refund = %d, want 0 (floored, never negative)", got)
|
||||
}
|
||||
if abuse, loss := readRisk(t, acc); !abuse || loss != 70 {
|
||||
t.Errorf("risk = (%v, %d), want (true, 70)", abuse, loss)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRefundIdempotent verifies a replayed refund (same provider refund id) reverses nothing
|
||||
// more — the ledger idempotency index holds.
|
||||
func TestPaymentsRefundIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
orderID, acc := fundedOrder(t, svc, 100, 14900)
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
|
||||
if _, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded); err != nil {
|
||||
t.Fatalf("first refund: %v", err)
|
||||
}
|
||||
// Re-credit the segment to prove the duplicate does not revoke again.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE payments.balances SET chips = 100 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
|
||||
t.Fatalf("re-credit: %v", err)
|
||||
}
|
||||
out2, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate refund: %v", err)
|
||||
}
|
||||
if !out2.AlreadyRefunded {
|
||||
t.Error("duplicate refund not flagged AlreadyRefunded")
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance after duplicate refund = %d, want 100 (not revoked twice)", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "refund") != 1 {
|
||||
t.Errorf("refund ledger rows = %d, want 1 (duplicate wrote none)", ledgerRows(t, acc, "refund"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRefundUnpaidOrder refuses to refund an order that was never funded.
|
||||
func TestPaymentsRefundUnpaidOrder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
if _, err := svc.Refund(ctx, res.OrderID, "robokassa", "rk-refund-x", refunded); !errors.Is(err, payments.ErrOrderNotPaid) {
|
||||
t.Fatalf("refund of a pending order = %v, want ErrOrderNotPaid", err)
|
||||
}
|
||||
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
|
||||
t.Errorf("risk = (%v, %d), want (false, 0) — no reversal on an unpaid order", abuse, loss)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// TestAccountStatement checks the admin financial statement: a funded pack shows as a chip segment
|
||||
// + a fund ledger row, an admin grant as a benefit + an admin_grant row, the ledger is newest-first,
|
||||
// and a clean account carries no refund risk.
|
||||
func TestAccountStatement(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
|
||||
// A funded pack: 100 chips to the direct segment + a fund ledger row.
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
// An admin grant: 5 hints to the direct origin + an admin_grant ledger row.
|
||||
if err := svc.Grant(ctx, acc, payments.SourceDirect, 5, 0, false); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
|
||||
stmt, err := svc.AccountStatement(ctx, acc)
|
||||
if err != nil {
|
||||
t.Fatalf("statement: %v", err)
|
||||
}
|
||||
|
||||
if len(stmt.Segments) != 1 || stmt.Segments[0].Source != payments.SourceDirect || stmt.Segments[0].Chips != 100 {
|
||||
t.Fatalf("segments = %+v, want 100 chips on direct", stmt.Segments)
|
||||
}
|
||||
if len(stmt.Benefits) != 1 || stmt.Benefits[0].Origin != payments.SourceDirect || stmt.Benefits[0].Hints != 5 {
|
||||
t.Fatalf("benefits = %+v, want 5 hints on direct", stmt.Benefits)
|
||||
}
|
||||
if len(stmt.Ledger) != 2 {
|
||||
t.Fatalf("ledger rows = %d, want 2 (fund + admin_grant)", len(stmt.Ledger))
|
||||
}
|
||||
// Newest-first ordering (the grant is the later write).
|
||||
if stmt.Ledger[0].CreatedAt.Before(stmt.Ledger[1].CreatedAt) {
|
||||
t.Error("ledger is not newest-first")
|
||||
}
|
||||
kinds := map[string]int{}
|
||||
for _, e := range stmt.Ledger {
|
||||
kinds[e.Kind]++
|
||||
if e.Kind == "fund" && e.ChipsDelta != 100 {
|
||||
t.Errorf("fund chips delta = %d, want 100", e.ChipsDelta)
|
||||
}
|
||||
}
|
||||
if kinds["fund"] != 1 || kinds["admin_grant"] != 1 {
|
||||
t.Fatalf("ledger kinds = %v, want one fund + one admin_grant", kinds)
|
||||
}
|
||||
if stmt.Risk.Present {
|
||||
t.Errorf("risk = %+v, want none for a clean account", stmt.Risk)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleFinancePanel checks the user card renders the finance panel: a funded pack + an admin
|
||||
// grant surface as the segment balance, the benefit and the ledger rows.
|
||||
func TestConsoleFinancePanel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
id := provisionAccount(t)
|
||||
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
if err := pay.Grant(ctx, id, payments.SourceDirect, 5, 0, false); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
|
||||
code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/users/"+id.String(), "", "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("user card = %d, want 200", code)
|
||||
}
|
||||
for _, want := range []string{"Finance", "Chips (direct)", "Benefits (direct)", "5 hints", "admin_grant", "fund"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("finance panel missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
@@ -218,6 +219,20 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
|
||||
if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) {
|
||||
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
|
||||
}
|
||||
// A guest cannot invite: friend games are a durable-account feature. The UI hides the flow;
|
||||
// this is the server source of truth.
|
||||
if acc, err := svc.accounts.GetByID(ctx, inviterID); err != nil {
|
||||
return Invitation{}, err
|
||||
} else if acc.IsGuest {
|
||||
return Invitation{}, ErrGuestForbidden
|
||||
}
|
||||
// A durable inviter is still capped: refuse a new friend game once the per-tier friends limit is
|
||||
// reached. The guest branch above short-circuits before here, so this is the durable cap.
|
||||
if atLimit, err := svc.games.AtGameLimit(ctx, inviterID, gamelimits.KindFriends); err != nil {
|
||||
return Invitation{}, err
|
||||
} else if atLimit {
|
||||
return Invitation{}, game.ErrGameLimitReached
|
||||
}
|
||||
seen := map[uuid.UUID]bool{inviterID: true}
|
||||
// suppressed collects invitees who have blocked the inviter: the invitation is still
|
||||
// created and persisted for them, but they are never notified and never see it (their
|
||||
@@ -336,6 +351,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
|
||||
HintsPerPlayer: inv.Settings.HintsPerPlayer,
|
||||
DropoutTiles: inv.Settings.DropoutTiles,
|
||||
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
|
||||
Kind: gamelimits.KindFriends,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -15,17 +15,22 @@ import (
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// GameCreator is the slice of the game domain the lobby needs: starting a seated
|
||||
// game and reading a player's initial view of it. game.Service satisfies it.
|
||||
// game, reading a player's initial view of it, and testing a caller's active-game
|
||||
// cap. game.Service satisfies it.
|
||||
type GameCreator interface {
|
||||
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
||||
// InitialState returns a seated player's full initial view of a started game, used
|
||||
// to enrich the game_started event so the client renders the new game without a
|
||||
// follow-up fetch.
|
||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind. The
|
||||
// invitation path uses it to enforce the friends limit before opening one.
|
||||
AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error)
|
||||
}
|
||||
|
||||
// RobotProvider supplies a robot account to substitute for a missing human in
|
||||
@@ -62,6 +67,9 @@ var (
|
||||
// ErrInvalidInvitation is returned for a malformed invitation (bad player
|
||||
// count, duplicate or self invitee, or unacceptable settings).
|
||||
ErrInvalidInvitation = errors.New("lobby: invalid invitation")
|
||||
// ErrGuestForbidden is returned when a guest attempts a durable-only action (invite a
|
||||
// friend); the friend flow is gated to durable accounts.
|
||||
ErrGuestForbidden = errors.New("lobby: guests cannot invite")
|
||||
// ErrInvitationBlocked is returned when a block stands between the inviter and
|
||||
// an invitee.
|
||||
ErrInvitationBlocked = errors.New("lobby: invitation blocked between accounts")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
@@ -141,6 +142,7 @@ func (m *Matchmaker) StartVsAI(ctx context.Context, accountID uuid.UUID, variant
|
||||
if rand.IntN(2) == 1 {
|
||||
params.Seats = []uuid.UUID{robotID, accountID}
|
||||
}
|
||||
params.Kind = gamelimits.KindVsAI
|
||||
g, err := m.games.Create(ctx, params)
|
||||
if err != nil {
|
||||
return EnqueueResult{}, err
|
||||
|
||||
@@ -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,
|
||||
@@ -83,7 +84,6 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
|
||||
Rack: s.Rack,
|
||||
BagLen: s.BagLen,
|
||||
HintsRemaining: s.HintsRemaining,
|
||||
WalletBalance: s.WalletBalance,
|
||||
Alphabet: alphabet,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -56,7 +59,6 @@ type PlayerState struct {
|
||||
Rack []int
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
WalletBalance int
|
||||
Alphabet []AlphabetLetter
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}},
|
||||
|
||||
@@ -147,12 +147,13 @@ func (m Money) Cmp(o Money) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// String renders the amount as "<value> <currency>", with the currency's
|
||||
// fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR").
|
||||
func (m Money) String() string {
|
||||
// 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 "<value> <currency>" (e.g. "149.50 RUB", "250 XTR").
|
||||
func (m Money) String() string {
|
||||
return m.Major() + " " + string(m.currency)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// OrderResult is what CreateOrder returns to the transport: the created order id and the details a
|
||||
// provider launch payload needs — the amount to charge and a human title for the payment.
|
||||
type OrderResult struct {
|
||||
OrderID uuid.UUID
|
||||
Amount Money
|
||||
Title string
|
||||
}
|
||||
|
||||
// CreateOrder opens a pending order to fund a chip pack in the execution context's payment method,
|
||||
// tagged with the provider that will settle it. It gate-checks the context (trusted, not the
|
||||
// VK-iOS spend freeze) and that the method's funding segment is attached, prices the pack in the
|
||||
// method's currency, then writes the order. The caller enforces any account-level precondition
|
||||
// (e.g. the direct email anchor, D36) before calling — payments holds no cross-schema identity
|
||||
// knowledge.
|
||||
func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, productID uuid.UUID, provider string) (OrderResult, error) {
|
||||
if !cxt.Trusted() || cxt.vkFrozen() {
|
||||
return OrderResult{}, ErrUntrusted
|
||||
}
|
||||
method := cxt.Kind
|
||||
if !has(present, method) {
|
||||
return OrderResult{}, ErrUntrusted // the funding segment is not attached to the account
|
||||
}
|
||||
pack, err := s.store.loadPackForOrder(ctx, productID, method)
|
||||
if err != nil {
|
||||
return OrderResult{}, err
|
||||
}
|
||||
orderID, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return OrderResult{}, fmt.Errorf("payments: order id: %w", err)
|
||||
}
|
||||
o := newOrder{
|
||||
orderID: orderID,
|
||||
accountID: accountID,
|
||||
platform: string(method),
|
||||
productID: productID,
|
||||
amount: pack.price,
|
||||
origin: method,
|
||||
provider: provider,
|
||||
}
|
||||
if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
|
||||
return OrderResult{}, err
|
||||
}
|
||||
return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil
|
||||
}
|
||||
|
||||
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
|
||||
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
|
||||
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
|
||||
func (s *Service) OrderItem(ctx context.Context, orderID uuid.UUID) (title string, amount Money, err error) {
|
||||
ord, err := s.store.orderByID(ctx, orderID)
|
||||
if err != nil {
|
||||
return "", Money{}, err
|
||||
}
|
||||
_, title, err = s.store.packForCredit(ctx, ord.productID)
|
||||
if err != nil {
|
||||
return "", Money{}, err
|
||||
}
|
||||
amount, err = MoneyFromMinor(ord.expectedAmount, Currency(ord.currency))
|
||||
if err != nil {
|
||||
return "", Money{}, err
|
||||
}
|
||||
return title, amount, nil
|
||||
}
|
||||
|
||||
// Fund credits a paid order into its funded segment exactly once, from a verified provider callback
|
||||
// — the single writer for every rail. It matches the order, verifies the paid amount, appends the
|
||||
// fund ledger row (idempotent on (provider, provider_payment_id)), credits the balance and marks
|
||||
// the order paid. A duplicate callback returns AlreadyCredited without a second credit; a valid
|
||||
// callback is honoured even on an expired order (§9/D23).
|
||||
func (s *Service) Fund(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, paid Money) (FundOutcome, error) {
|
||||
return s.store.fund(ctx, orderID, provider, providerPaymentID, paid, s.clock())
|
||||
}
|
||||
|
||||
// Refund reverses a paid order's credit best-effort, exactly once — for an external refund or an
|
||||
// admin-initiated one (E7). It revokes the funded chips floored at 0 (never negative, D27), records
|
||||
// any unrecoverable remainder as a per-account loss and abuse flag, and appends a refund ledger row
|
||||
// idempotent on (provider, providerRefundID) — distinct from the fund's payment id. A duplicate
|
||||
// refund returns AlreadyRefunded. The caller records the refunded payment event and performs any
|
||||
// provider-side money-back (the rails have no unsolicited refund push: Robokassa via its refund API
|
||||
// / cabinet, VK via support, Telegram via refundStarPayment — all admin-triggered).
|
||||
func (s *Service) Refund(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string, refunded Money) (RefundOutcome, error) {
|
||||
return s.store.refund(ctx, orderID, provider, providerRefundID, refunded, s.clock())
|
||||
}
|
||||
|
||||
// Pre-checkout decline reason codes. They are language-neutral: the transport layer localises them
|
||||
// to the order account's preferred language before showing the payer (the reason is displayed in the
|
||||
// Telegram payment sheet).
|
||||
const (
|
||||
// PreCheckoutGone means no order matches — an unknown or stale invoice payload.
|
||||
PreCheckoutGone = "order_gone"
|
||||
// PreCheckoutAlreadyPaid means the order is already paid (a reusable invoice link paid twice).
|
||||
PreCheckoutAlreadyPaid = "already_paid"
|
||||
// PreCheckoutPriceChanged means the amount or currency no longer matches the order.
|
||||
PreCheckoutPriceChanged = "price_changed"
|
||||
)
|
||||
|
||||
// PreCheckoutOutcome is the pre-charge validation of a Telegram Stars order. OK approves the charge;
|
||||
// otherwise Reason is a decline reason code the transport localises. AccountID is the order's account
|
||||
// (for localising the reason to its preferred language); it is the zero UUID when the order is unknown.
|
||||
type PreCheckoutOutcome struct {
|
||||
OK bool
|
||||
Reason string
|
||||
AccountID uuid.UUID
|
||||
}
|
||||
|
||||
// ValidatePreCheckout answers whether a Stars pre_checkout_query for orderID paying amount may be
|
||||
// approved, before any star is charged. It approves an order that exists, is not already paid (a
|
||||
// reusable invoice link paid a second time is refused here) and whose expected amount and currency
|
||||
// match the invoice. A pending or honoured-expired order is approved — a late credit is honoured
|
||||
// (§9/D23). A missing order or a mismatch is a clean decline with a reason code, not an error.
|
||||
func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, amount Money) (PreCheckoutOutcome, error) {
|
||||
ord, err := s.store.orderByID(ctx, orderID)
|
||||
if errors.Is(err, ErrOrderNotFound) {
|
||||
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutGone}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return PreCheckoutOutcome{}, err
|
||||
}
|
||||
if ord.status == "paid" {
|
||||
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutAlreadyPaid, AccountID: ord.accountID}, nil
|
||||
}
|
||||
if amount.Currency() != Currency(ord.currency) || amount.Minor() != ord.expectedAmount {
|
||||
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutPriceChanged, AccountID: ord.accountID}, nil
|
||||
}
|
||||
return PreCheckoutOutcome{OK: true, AccountID: ord.accountID}, nil
|
||||
}
|
||||
|
||||
// providerVKAds tags a rewarded-video credit from the VK ads network in the ledger (distinct from
|
||||
// the "vk" Votes-purchase provider), so the daily cap counts only ad credits and the report separates
|
||||
// them.
|
||||
const providerVKAds = "vk_ads"
|
||||
|
||||
// InterstitialCooldowns reports the post-move interstitial-ad cooldowns (seconds): global, vs_ai and
|
||||
// the independent hint-triggered one. The client mirrors them and self-gates (client-mirrored, D30).
|
||||
func (s *Service) InterstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) {
|
||||
return s.store.interstitialCooldowns(ctx)
|
||||
}
|
||||
|
||||
// RewardPayout reports the chips a rewarded-video view earns in the caller's context — the config
|
||||
// payout in a trusted VK context with the VK segment attached, and 0 everywhere else (rewarded is
|
||||
// VK-only, D28). The client uses it to gate the "watch for chips" button.
|
||||
func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Source) (int, error) {
|
||||
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
|
||||
return 0, nil
|
||||
}
|
||||
payout, _, _, err := s.store.rewardConfig(ctx)
|
||||
return payout, err
|
||||
}
|
||||
|
||||
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
||||
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
||||
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
||||
// client nonce and order-less. It credits nothing when rewarded is unconfigured (0 payout) or the
|
||||
// daily cap is reached. Rewarded video is VK-only (D28) and is an ad view — not a purchase — so the
|
||||
// VK-iOS purchase freeze does not apply; it requires a trusted VK context with the VK segment
|
||||
// attached.
|
||||
func (s *Service) CreditReward(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, nonce string) (RewardOutcome, error) {
|
||||
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
|
||||
return RewardOutcome{}, ErrUntrusted
|
||||
}
|
||||
return s.store.creditReward(ctx, accountID, SourceVK, providerVKAds, nonce, s.clock())
|
||||
}
|
||||
|
||||
// ExpireOrders marks pending orders older than the configured lifetime as expired, returning how
|
||||
// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback
|
||||
// still credits — see Fund).
|
||||
func (s *Service) ExpireOrders(ctx context.Context) (int, error) {
|
||||
ttl, err := s.store.orderTTL(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.store.expirePending(ctx, ttl, s.clock())
|
||||
}
|
||||
|
||||
// RecordPaymentEvent appends a payment lifecycle event (succeeded/failed/refunded) for the
|
||||
// dispatcher to deliver to the user (live stream, botlink or email).
|
||||
func (s *Service) RecordPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte) error {
|
||||
return s.store.insertPaymentEvent(ctx, accountID, orderID, eventType, payload, s.clock())
|
||||
}
|
||||
|
||||
// UndispatchedEvents returns up to limit payment events awaiting delivery. The dispatcher drains
|
||||
// them and marks each delivered via MarkEventDispatched.
|
||||
func (s *Service) UndispatchedEvents(ctx context.Context, limit int) ([]PaymentEvent, error) {
|
||||
return s.store.undispatchedEvents(ctx, limit)
|
||||
}
|
||||
|
||||
// MarkEventDispatched stamps a payment event as delivered so it is not re-sent.
|
||||
func (s *Service) MarkEventDispatched(ctx context.Context, eventID uuid.UUID) error {
|
||||
return s.store.markEventDispatched(ctx, eventID, s.clock())
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// gateOnlyService builds a Service with a fixed clock and no store, usable only for the CreateOrder
|
||||
// gate rejections that return before any store access.
|
||||
func gateOnlyService() *Service {
|
||||
return &Service{clock: func() time.Time { return time.Unix(0, 0).UTC() }}
|
||||
}
|
||||
|
||||
// TestCreateOrderGateRejections checks that CreateOrder fails closed — before touching the store —
|
||||
// on an untrusted platform, the VK-iOS spend freeze, and a method whose funding segment the account
|
||||
// does not hold.
|
||||
func TestCreateOrderGateRejections(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
acc, prod := uuid.New(), uuid.New()
|
||||
present := []Source{SourceDirect, SourceVK}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cxt Context
|
||||
}{
|
||||
{"untrusted context", Context{}},
|
||||
{"vk-ios purchase freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}},
|
||||
{"method segment not attached", Context{Kind: SourceTelegram}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := gateOnlyService().CreateOrder(ctx, acc, tc.cxt, present, prod, "robokassa")
|
||||
if !errors.Is(err, ErrUntrusted) {
|
||||
t.Fatalf("CreateOrder(%s) = %v, want ErrUntrusted", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Statement is an account's full financial picture for the admin console: chip balances per funding
|
||||
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
|
||||
// (newest first). Read straight from the materialized tables + the ledger, uncached — an admin-only,
|
||||
// rare view, not a hot path.
|
||||
type Statement struct {
|
||||
Segments []SegmentChips
|
||||
Benefits []OriginBenefit
|
||||
Risk RiskInfo
|
||||
Ledger []LedgerEntry
|
||||
}
|
||||
|
||||
// SegmentChips is one funding segment's chip balance.
|
||||
type SegmentChips struct {
|
||||
Source Source
|
||||
Chips int
|
||||
}
|
||||
|
||||
// OriginBenefit is one origin's benefit state: the hint wallet, the ad-free term (zero AdsPaidUntil
|
||||
// when none) and the lifetime ad-free flag.
|
||||
type OriginBenefit struct {
|
||||
Origin Source
|
||||
Hints int
|
||||
AdsPaidUntil time.Time
|
||||
AdsForever bool
|
||||
}
|
||||
|
||||
// RiskInfo is the account's recorded refund risk: whether it is abuse-flagged and the unrecoverable
|
||||
// chip loss accumulated by floor-0 refunds. Present is false when the account has no risk row.
|
||||
type RiskInfo struct {
|
||||
Present bool
|
||||
Abuse bool
|
||||
LossChips int
|
||||
}
|
||||
|
||||
// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when
|
||||
// the column is NULL; Snapshot is the raw purchase/refund JSON (empty when none).
|
||||
type LedgerEntry struct {
|
||||
Kind string
|
||||
Source string
|
||||
Origin string
|
||||
ChipsDelta int
|
||||
ProductID string
|
||||
OrderID string
|
||||
Provider string
|
||||
ProviderPaymentID string
|
||||
Snapshot string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AccountStatement assembles the account's financial picture (segments, benefits, risk, full ledger
|
||||
// history) for the admin console, straight from the materialized tables and the ledger (uncached).
|
||||
func (s *Service) AccountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
|
||||
return s.store.accountStatement(ctx, accountID)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/payments/model"
|
||||
"scrabble/backend/internal/postgres/jet/payments/table"
|
||||
)
|
||||
|
||||
// ErrProductTransacted is returned when a hard delete is attempted on a product an order or ledger
|
||||
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
||||
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
||||
|
||||
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
|
||||
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
||||
var prods []model.Product
|
||||
if err := postgres.SELECT(table.Product.AllColumns).
|
||||
FROM(table.Product).
|
||||
ORDER_BY(table.Product.CreatedAt.ASC()).
|
||||
QueryContext(ctx, s.db, &prods); err != nil {
|
||||
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
||||
}
|
||||
out := make([]AdminProduct, 0, len(prods))
|
||||
for _, p := range prods {
|
||||
atoms, prices, err := s.productComposition(ctx, p.ProductID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transacted, err := s.productTransacted(ctx, p.ProductID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, AdminProduct{
|
||||
ID: p.ProductID, Title: p.Title, Active: p.Active,
|
||||
Atoms: atoms, Prices: prices, Transacted: transacted,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// productComposition reads one product's atom lines and price rows.
|
||||
func (s *Store) productComposition(ctx context.Context, id uuid.UUID) ([]AtomLine, []PriceLine, error) {
|
||||
var items []model.ProductItem
|
||||
if err := postgres.SELECT(table.ProductItem.AllColumns).
|
||||
FROM(table.ProductItem).
|
||||
WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(id))).
|
||||
ORDER_BY(table.ProductItem.AtomType.ASC()).
|
||||
QueryContext(ctx, s.db, &items); err != nil {
|
||||
return nil, nil, fmt.Errorf("payments: load items %s: %w", id, err)
|
||||
}
|
||||
atoms := make([]AtomLine, len(items))
|
||||
for i, it := range items {
|
||||
atoms[i] = AtomLine{Atom: it.AtomType, Quantity: int(it.Quantity)}
|
||||
}
|
||||
var prs []model.ProductPrice
|
||||
if err := postgres.SELECT(table.ProductPrice.AllColumns).
|
||||
FROM(table.ProductPrice).
|
||||
WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(id))).
|
||||
QueryContext(ctx, s.db, &prs); err != nil {
|
||||
return nil, nil, fmt.Errorf("payments: load prices %s: %w", id, err)
|
||||
}
|
||||
prices := make([]PriceLine, len(prs))
|
||||
for i, pr := range prs {
|
||||
method := ""
|
||||
if pr.Method != nil {
|
||||
method = *pr.Method
|
||||
}
|
||||
prices[i] = PriceLine{Method: method, Currency: Currency(pr.Currency), Amount: pr.Amount}
|
||||
}
|
||||
return atoms, prices, nil
|
||||
}
|
||||
|
||||
// productTransacted reports whether any order or ledger row references the product.
|
||||
func (s *Store) productTransacted(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
var yes bool
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM payments.orders WHERE product_id=$1)
|
||||
OR EXISTS(SELECT 1 FROM payments.ledger WHERE product_id=$1)`, id).Scan(&yes); err != nil {
|
||||
return false, fmt.Errorf("payments: product transacted %s: %w", id, err)
|
||||
}
|
||||
return yes, nil
|
||||
}
|
||||
|
||||
// productActive reads a product's archived flag, ErrProductNotFound when it is missing.
|
||||
func (s *Store) productActive(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
var p model.Product
|
||||
err := postgres.SELECT(table.Product.Active).FROM(table.Product).
|
||||
WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1).
|
||||
QueryContext(ctx, s.db, &p)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return false, ErrProductNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("payments: product active %s: %w", id, err)
|
||||
}
|
||||
return p.Active, nil
|
||||
}
|
||||
|
||||
// productInput reads a product's current editable content (title, atoms, prices).
|
||||
func (s *Store) productInput(ctx context.Context, id uuid.UUID) (ProductInput, error) {
|
||||
var p model.Product
|
||||
err := postgres.SELECT(table.Product.AllColumns).FROM(table.Product).
|
||||
WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1).
|
||||
QueryContext(ctx, s.db, &p)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return ProductInput{}, ErrProductNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ProductInput{}, fmt.Errorf("payments: product input %s: %w", id, err)
|
||||
}
|
||||
atoms, prices, err := s.productComposition(ctx, id)
|
||||
if err != nil {
|
||||
return ProductInput{}, err
|
||||
}
|
||||
return ProductInput{Title: p.Title, Atoms: atoms, Prices: prices}, nil
|
||||
}
|
||||
|
||||
// createProduct inserts a product with its atoms and prices in one transaction, returning its id.
|
||||
func (s *Store) createProduct(ctx context.Context, in ProductInput, active bool, now time.Time) (uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
if err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO payments.product (product_id, title, active, created_at, updated_at) VALUES ($1,$2,$3,$4,$4)`,
|
||||
id, in.Title, active, now); err != nil {
|
||||
return fmt.Errorf("insert product: %w", err)
|
||||
}
|
||||
return insertComposition(ctx, tx, id, in)
|
||||
}); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("payments: create product: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// updateProduct replaces a product's title, atoms and prices in one transaction (active unchanged).
|
||||
func (s *Store) updateProduct(ctx context.Context, id uuid.UUID, in ProductInput, now time.Time) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE payments.product SET title=$2, updated_at=$3 WHERE product_id=$1`, id, in.Title, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("payments: update product %s: %w", id, err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrProductNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_item WHERE product_id=$1`, id); err != nil {
|
||||
return fmt.Errorf("payments: clear items %s: %w", id, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_price WHERE product_id=$1`, id); err != nil {
|
||||
return fmt.Errorf("payments: clear prices %s: %w", id, err)
|
||||
}
|
||||
return insertComposition(ctx, tx, id, in)
|
||||
})
|
||||
}
|
||||
|
||||
// insertComposition inserts a product's atom items and price rows inside tx (a value's CHIP price
|
||||
// carries a NULL method).
|
||||
func insertComposition(ctx context.Context, tx *sql.Tx, id uuid.UUID, in ProductInput) error {
|
||||
for _, a := range in.Atoms {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,$2,$3)`,
|
||||
id, a.Atom, a.Quantity); err != nil {
|
||||
return fmt.Errorf("insert item %s: %w", a.Atom, err)
|
||||
}
|
||||
}
|
||||
for _, p := range in.Prices {
|
||||
var method any
|
||||
if p.Method != "" {
|
||||
method = p.Method
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1,$2,$3,$4)`,
|
||||
id, method, string(p.Currency), p.Amount); err != nil {
|
||||
return fmt.Errorf("insert price %s: %w", p.Currency, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setProductActive flips the archived flag.
|
||||
func (s *Store) setProductActive(ctx context.Context, id uuid.UUID, active bool, now time.Time) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE payments.product SET active=$2, updated_at=$3 WHERE product_id=$1`, id, active, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("payments: set product active %s: %w", id, err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrProductNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteProduct hard-deletes a never-transacted product (its items/prices cascade); a transacted
|
||||
// product is refused with ErrProductTransacted.
|
||||
func (s *Store) deleteProduct(ctx context.Context, id uuid.UUID) error {
|
||||
transacted, err := s.productTransacted(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if transacted {
|
||||
return ErrProductTransacted
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM payments.product WHERE product_id=$1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("payments: delete product %s: %w", id, err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrProductNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/payments/model"
|
||||
"scrabble/backend/internal/postgres/jet/payments/table"
|
||||
)
|
||||
|
||||
// Intake errors surfaced by the order-flow and external-credit (fund) path.
|
||||
var (
|
||||
// ErrNotAPack means the product is not a fundable chip pack in the requested method: it
|
||||
// carries no chips atom, or no price for that payment method.
|
||||
ErrNotAPack = errors.New("payments: product is not a chip pack for this method")
|
||||
// ErrOrderNotFound means no order matches the id (an unknown or forged callback reference).
|
||||
ErrOrderNotFound = errors.New("payments: order not found")
|
||||
// ErrAmountMismatch means the callback's paid amount or currency does not match the order's
|
||||
// expected amount — the credit is refused (§9: verify amount after matching by order id).
|
||||
ErrAmountMismatch = errors.New("payments: paid amount does not match the order")
|
||||
// ErrOrderNotPaid means a refund targets an order that was never funded — there is nothing to
|
||||
// reverse (guards against a spurious loss/abuse record on an unpaid order).
|
||||
ErrOrderNotPaid = errors.New("payments: order is not paid")
|
||||
)
|
||||
|
||||
// errAlreadyCredited is the internal sentinel that unwinds the fund transaction when the ledger's
|
||||
// (provider, provider_payment_id) unique index rejects a duplicate callback. It is not surfaced:
|
||||
// a replayed callback is a success that credits nothing.
|
||||
var errAlreadyCredited = errors.New("payments: already credited")
|
||||
|
||||
// errAlreadyRefunded unwinds the refund transaction when the ledger idempotency index rejects a
|
||||
// duplicate refund (same provider refund id). It is not surfaced: a replayed refund reverses nothing.
|
||||
var errAlreadyRefunded = errors.New("payments: already refunded")
|
||||
|
||||
// packInfo is a chip pack resolved for an order: the product, the chips it funds and its price in
|
||||
// the requested payment method's currency.
|
||||
type packInfo struct {
|
||||
productID uuid.UUID
|
||||
title string
|
||||
chips int
|
||||
price Money
|
||||
}
|
||||
|
||||
// packChips returns the quantity of the chips atom a product carries, or 0 if it has none (which
|
||||
// marks it as a value, not a fundable pack).
|
||||
func (s *Store) packChips(ctx context.Context, productID uuid.UUID) (int, error) {
|
||||
var item model.ProductItem
|
||||
err := postgres.SELECT(table.ProductItem.AllColumns).
|
||||
FROM(table.ProductItem).
|
||||
WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(productID)).
|
||||
AND(table.ProductItem.AtomType.EQ(postgres.String(atomChips)))).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &item)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("payments: load pack chips %s: %w", productID, err)
|
||||
}
|
||||
return int(item.Quantity), nil
|
||||
}
|
||||
|
||||
// loadPackForOrder resolves an active chip pack for a new order in the given payment method: an
|
||||
// active product carrying a chips atom and a price row for the method (its currency and amount are
|
||||
// the order's expected amount). It rejects a missing/deactivated product (ErrProductNotFound) and
|
||||
// a product that is not a pack for the method (ErrNotAPack).
|
||||
func (s *Store) loadPackForOrder(ctx context.Context, productID uuid.UUID, method Source) (packInfo, error) {
|
||||
var p model.Product
|
||||
err := postgres.SELECT(table.Product.AllColumns).
|
||||
FROM(table.Product).
|
||||
WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &p)
|
||||
if errors.Is(err, qrm.ErrNoRows) || (err == nil && !p.Active) {
|
||||
return packInfo{}, ErrProductNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return packInfo{}, fmt.Errorf("payments: load product %s: %w", productID, err)
|
||||
}
|
||||
|
||||
chips, err := s.packChips(ctx, productID)
|
||||
if err != nil {
|
||||
return packInfo{}, err
|
||||
}
|
||||
if chips <= 0 {
|
||||
return packInfo{}, ErrNotAPack
|
||||
}
|
||||
|
||||
var price model.ProductPrice
|
||||
err = postgres.SELECT(table.ProductPrice.AllColumns).
|
||||
FROM(table.ProductPrice).
|
||||
WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(productID)).
|
||||
AND(table.ProductPrice.Method.EQ(postgres.String(string(method))))).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &price)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return packInfo{}, ErrNotAPack
|
||||
}
|
||||
if err != nil {
|
||||
return packInfo{}, fmt.Errorf("payments: load pack price %s: %w", productID, err)
|
||||
}
|
||||
money, err := MoneyFromMinor(price.Amount, Currency(price.Currency))
|
||||
if err != nil {
|
||||
return packInfo{}, err
|
||||
}
|
||||
return packInfo{productID: productID, title: p.Title, chips: chips, price: money}, nil
|
||||
}
|
||||
|
||||
// packForCredit resolves the chips and title of an ordered pack at credit time, ignoring the
|
||||
// product's active flag: the money is real, so an order is honoured even if the pack was
|
||||
// deactivated after it was placed (§9/D23).
|
||||
func (s *Store) packForCredit(ctx context.Context, productID uuid.UUID) (chips int, title string, err error) {
|
||||
var p model.Product
|
||||
e := postgres.SELECT(table.Product.Title).
|
||||
FROM(table.Product).
|
||||
WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &p)
|
||||
if errors.Is(e, qrm.ErrNoRows) {
|
||||
return 0, "", ErrProductNotFound
|
||||
}
|
||||
if e != nil {
|
||||
return 0, "", fmt.Errorf("payments: load product %s: %w", productID, e)
|
||||
}
|
||||
chips, err = s.packChips(ctx, productID)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if chips <= 0 {
|
||||
return 0, "", ErrNotAPack
|
||||
}
|
||||
return chips, p.Title, nil
|
||||
}
|
||||
|
||||
// newOrder is the intent a CreateOrder writes: a pending order for a pack, priced in the method's
|
||||
// currency, tagged with the provider that will settle it.
|
||||
type newOrder struct {
|
||||
orderID uuid.UUID
|
||||
accountID uuid.UUID
|
||||
platform string
|
||||
productID uuid.UUID
|
||||
amount Money
|
||||
origin Source
|
||||
provider string
|
||||
}
|
||||
|
||||
// createOrder inserts a pending order.
|
||||
func (s *Store) createOrder(ctx context.Context, o newOrder, now time.Time) error {
|
||||
stmt := table.Orders.INSERT(
|
||||
table.Orders.OrderID, table.Orders.AccountID, table.Orders.Platform,
|
||||
table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency,
|
||||
table.Orders.Origin, table.Orders.Status, table.Orders.Provider,
|
||||
table.Orders.CreatedAt, table.Orders.UpdatedAt,
|
||||
).VALUES(
|
||||
o.orderID, o.accountID, o.platform,
|
||||
o.productID, o.amount.Minor(), string(o.amount.Currency()),
|
||||
string(o.origin), "pending", o.provider,
|
||||
now, now,
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("payments: create order: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orderRow is a stored order read back for the intake path.
|
||||
type orderRow struct {
|
||||
orderID uuid.UUID
|
||||
accountID uuid.UUID
|
||||
productID uuid.UUID
|
||||
expectedAmount int64
|
||||
currency string
|
||||
origin string
|
||||
status string
|
||||
}
|
||||
|
||||
// orderByID reads an order, or ErrOrderNotFound.
|
||||
func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, error) {
|
||||
var o model.Orders
|
||||
err := postgres.SELECT(table.Orders.AllColumns).
|
||||
FROM(table.Orders).
|
||||
WHERE(table.Orders.OrderID.EQ(postgres.UUID(orderID))).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &o)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return orderRow{}, ErrOrderNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return orderRow{}, fmt.Errorf("payments: load order %s: %w", orderID, err)
|
||||
}
|
||||
return orderRow{
|
||||
orderID: o.OrderID,
|
||||
accountID: o.AccountID,
|
||||
productID: o.ProductID,
|
||||
expectedAmount: o.ExpectedAmount,
|
||||
currency: o.Currency,
|
||||
origin: o.Origin,
|
||||
status: o.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FundOutcome reports the result of an intake credit: whose balance, which segment and how many
|
||||
// chips were credited, and whether the callback was a duplicate that credited nothing.
|
||||
type FundOutcome struct {
|
||||
AccountID uuid.UUID
|
||||
Source Source
|
||||
Chips int
|
||||
AlreadyCredited bool
|
||||
}
|
||||
|
||||
// fund credits a paid order exactly once: it matches the order, verifies the amount, then in one
|
||||
// transaction appends a fund ledger row (idempotent on the (provider, provider_payment_id) unique
|
||||
// index), credits the funded segment's balance and marks the order paid. A duplicate callback is
|
||||
// rejected by the unique index and returns AlreadyCredited with no error and no second credit. A
|
||||
// valid callback is honoured even on an expired order (§9/D23). The read cache is invalidated after
|
||||
// the commit, since the credit runs outside any request the payments package owns.
|
||||
func (s *Store) fund(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, paid Money, now time.Time) (FundOutcome, error) {
|
||||
ord, err := s.orderByID(ctx, orderID)
|
||||
if err != nil {
|
||||
return FundOutcome{}, err
|
||||
}
|
||||
if paid.Currency() != Currency(ord.currency) || paid.Minor() != ord.expectedAmount {
|
||||
return FundOutcome{}, ErrAmountMismatch
|
||||
}
|
||||
chips, title, err := s.packForCredit(ctx, ord.productID)
|
||||
if err != nil {
|
||||
return FundOutcome{}, err
|
||||
}
|
||||
snapshot, err := marshalFundSnapshot(ord.productID, title, chips, paid)
|
||||
if err != nil {
|
||||
return FundOutcome{}, err
|
||||
}
|
||||
|
||||
src := Source(ord.origin)
|
||||
outcome := FundOutcome{AccountID: ord.accountID, Source: src, Chips: chips}
|
||||
pv, pp := provider, providerPaymentID
|
||||
productID := ord.productID
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if e := insertLedgerTx(ctx, tx, ord.accountID, "fund", &src, &src, chips, &productID, &orderID, &pv, &pp, snapshot, now); e != nil {
|
||||
if isUniqueViolation(e) {
|
||||
outcome.AlreadyCredited = true
|
||||
return errAlreadyCredited
|
||||
}
|
||||
return e
|
||||
}
|
||||
if _, e := tx.ExecContext(ctx,
|
||||
`INSERT INTO payments.balances (account_id, source, chips, updated_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (account_id, source) DO UPDATE
|
||||
SET chips = payments.balances.chips + EXCLUDED.chips, updated_at = now()`,
|
||||
ord.accountID, string(src), chips); e != nil {
|
||||
return fmt.Errorf("payments: credit balance %s: %w", src, e)
|
||||
}
|
||||
if _, e := tx.ExecContext(ctx,
|
||||
`UPDATE payments.orders SET status = 'paid', provider = $2, provider_payment_id = $3, updated_at = now()
|
||||
WHERE order_id = $1`,
|
||||
orderID, provider, providerPaymentID); e != nil {
|
||||
return fmt.Errorf("payments: mark order paid: %w", e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, errAlreadyCredited) {
|
||||
return outcome, nil
|
||||
}
|
||||
return FundOutcome{}, err
|
||||
}
|
||||
s.cache.invalidate(ord.accountID)
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// RefundOutcome reports a refund's result: whose funded segment was reversed, the chips actually
|
||||
// clawed back (floored at 0), the unrecoverable remainder (a loss, when the chips were already
|
||||
// spent), and whether the refund was a duplicate that reversed nothing.
|
||||
type RefundOutcome struct {
|
||||
AccountID uuid.UUID
|
||||
Source Source
|
||||
Revoked int
|
||||
Loss int
|
||||
AlreadyRefunded bool
|
||||
}
|
||||
|
||||
// refund reverses a paid order's credit best-effort, exactly once. It matches the order (which must
|
||||
// be paid), verifies the refunded amount, then in one transaction appends a refund ledger row
|
||||
// (idempotent on the (provider, provider_payment_id) index — the refund id is distinct from the
|
||||
// fund's payment id, so the two rows coexist), revokes the funded chips floored at 0 (never
|
||||
// negative, D27/balances_chips_chk) and, when chips were already spent, records the unrecoverable
|
||||
// remainder as a per-account loss and flips the abuse flag. A duplicate refund returns
|
||||
// AlreadyRefunded with no second reversal. The ledger row's chipsDelta is what is actually
|
||||
// reclaimed; the full reversal (money, original chips, loss) rides in its snapshot for the report.
|
||||
func (s *Store) refund(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string, refunded Money, now time.Time) (RefundOutcome, error) {
|
||||
ord, err := s.orderByID(ctx, orderID)
|
||||
if err != nil {
|
||||
return RefundOutcome{}, err
|
||||
}
|
||||
if ord.status != "paid" {
|
||||
return RefundOutcome{}, ErrOrderNotPaid
|
||||
}
|
||||
if refunded.Currency() != Currency(ord.currency) || refunded.Minor() != ord.expectedAmount {
|
||||
return RefundOutcome{}, ErrAmountMismatch
|
||||
}
|
||||
chips, title, err := s.packForCredit(ctx, ord.productID)
|
||||
if err != nil {
|
||||
return RefundOutcome{}, err
|
||||
}
|
||||
|
||||
src := Source(ord.origin)
|
||||
outcome := RefundOutcome{AccountID: ord.accountID, Source: src}
|
||||
pv, pr := provider, providerRefundID
|
||||
productID := ord.productID
|
||||
oid := orderID
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
// Lock the funded segment and read what is left; a spent balance floors the reversal at 0.
|
||||
var avail int
|
||||
e := tx.QueryRowContext(ctx,
|
||||
`SELECT chips FROM payments.balances WHERE account_id = $1 AND source = $2 FOR UPDATE`,
|
||||
ord.accountID, string(src)).Scan(&avail)
|
||||
switch {
|
||||
case errors.Is(e, sql.ErrNoRows):
|
||||
avail = 0
|
||||
case e != nil:
|
||||
return fmt.Errorf("payments: read balance for refund: %w", e)
|
||||
}
|
||||
revoked := min(chips, avail)
|
||||
loss := chips - revoked
|
||||
outcome.Revoked, outcome.Loss = revoked, loss
|
||||
|
||||
snapshot, e := marshalRefundSnapshot(ord.productID, title, chips, revoked, loss, refunded, providerRefundID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e := insertLedgerTx(ctx, tx, ord.accountID, "refund", &src, &src, -revoked, &productID, &oid, &pv, &pr, snapshot, now); e != nil {
|
||||
if isUniqueViolation(e) {
|
||||
outcome.AlreadyRefunded = true
|
||||
return errAlreadyRefunded
|
||||
}
|
||||
return e
|
||||
}
|
||||
if revoked > 0 {
|
||||
if _, e := tx.ExecContext(ctx,
|
||||
`UPDATE payments.balances SET chips = chips - $3, updated_at = now()
|
||||
WHERE account_id = $1 AND source = $2`,
|
||||
ord.accountID, string(src), revoked); e != nil {
|
||||
return fmt.Errorf("payments: revoke chips %s: %w", src, e)
|
||||
}
|
||||
}
|
||||
if loss > 0 {
|
||||
if _, e := tx.ExecContext(ctx,
|
||||
`INSERT INTO payments.account_risk (account_id, abuse, loss_chips, updated_at)
|
||||
VALUES ($1, true, $2, now())
|
||||
ON CONFLICT (account_id) DO UPDATE
|
||||
SET abuse = true, loss_chips = payments.account_risk.loss_chips + EXCLUDED.loss_chips, updated_at = now()`,
|
||||
ord.accountID, int64(loss)); e != nil {
|
||||
return fmt.Errorf("payments: record refund loss: %w", e)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, errAlreadyRefunded) {
|
||||
return outcome, nil
|
||||
}
|
||||
return RefundOutcome{}, err
|
||||
}
|
||||
s.cache.invalidate(ord.accountID)
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// RewardOutcome reports a rewarded-video credit: the chips credited (0 when rewarded is unconfigured
|
||||
// or the daily cap is reached), whether the daily cap blocked it, and whether it was a duplicate view
|
||||
// (same client nonce) that credited nothing more.
|
||||
type RewardOutcome struct {
|
||||
AccountID uuid.UUID
|
||||
Chips int
|
||||
Capped bool
|
||||
AlreadyCredited bool
|
||||
}
|
||||
|
||||
// interstitialCooldowns reads the post-move interstitial-ad cooldowns (seconds): the global
|
||||
// per-user cooldown, the longer vs_ai one, and the independent hint-triggered one. The client mirrors
|
||||
// them and self-gates (E6/D30).
|
||||
func (s *Store) interstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) {
|
||||
var cfg model.Config
|
||||
if e := postgres.SELECT(table.Config.CooldownGlobalSeconds, table.Config.CooldownVsAiSeconds, table.Config.CooldownHintSeconds).
|
||||
FROM(table.Config).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &cfg); e != nil {
|
||||
return 0, 0, 0, fmt.Errorf("payments: read interstitial cooldowns: %w", e)
|
||||
}
|
||||
return int(cfg.CooldownGlobalSeconds), int(cfg.CooldownVsAiSeconds), int(cfg.CooldownHintSeconds), nil
|
||||
}
|
||||
|
||||
// rewardConfig reads the rewarded payout (chips per view) and the per-day and per-hour caps. The
|
||||
// caps are both anti-abuse (bounding a forger's free chips) and an economic conversion lever (free
|
||||
// rewarded chips are limited so a player who wants more buys) — tuned in the admin.
|
||||
func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
|
||||
var cfg model.Config
|
||||
if e := postgres.SELECT(table.Config.RewardedPayoutChips, table.Config.RewardDailyCap, table.Config.RewardHourlyCap).
|
||||
FROM(table.Config).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &cfg); e != nil {
|
||||
return 0, 0, 0, fmt.Errorf("payments: read reward config: %w", e)
|
||||
}
|
||||
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
|
||||
}
|
||||
|
||||
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
|
||||
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
|
||||
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
|
||||
// (provider, provider_payment_id) index), so a retried view credits once, and order-less (a free
|
||||
// credit, no order). The cap counts today's rewarded credits for this network (UTC day); a rare
|
||||
// concurrent race may allow cap+1, which the per-user rate limiter bounds and the cap tolerates.
|
||||
func (s *Store) creditReward(ctx context.Context, accountID uuid.UUID, source Source, provider, nonce string, now time.Time) (RewardOutcome, error) {
|
||||
payout, dailyCap, hourlyCap, err := s.rewardConfig(ctx)
|
||||
if err != nil {
|
||||
return RewardOutcome{}, err
|
||||
}
|
||||
outcome := RewardOutcome{AccountID: accountID}
|
||||
if payout <= 0 {
|
||||
return outcome, nil // rewarded not configured (0 payout) — inert until the owner sets it
|
||||
}
|
||||
// Count this network's rewarded credits in the last day and last hour (one scan over the last
|
||||
// 25 h covers both windows); either cap reached blocks the credit. A rare concurrent race may
|
||||
// allow cap+1, which the per-user rate limiter bounds and the anti-abuse cap tolerates.
|
||||
var today, lastHour int
|
||||
if e := s.db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FILTER (WHERE created_at >= date_trunc('day', now())),
|
||||
count(*) FILTER (WHERE created_at >= now() - interval '1 hour')
|
||||
FROM payments.ledger
|
||||
WHERE account_id = $1 AND kind = 'fund' AND provider = $2 AND created_at >= now() - interval '25 hours'`,
|
||||
accountID, provider).Scan(&today, &lastHour); e != nil {
|
||||
return RewardOutcome{}, fmt.Errorf("payments: count rewarded views: %w", e)
|
||||
}
|
||||
if today >= dailyCap || lastHour >= hourlyCap {
|
||||
outcome.Capped = true
|
||||
return outcome, nil
|
||||
}
|
||||
snapshot, err := marshalRewardSnapshot(payout)
|
||||
if err != nil {
|
||||
return RewardOutcome{}, err
|
||||
}
|
||||
src := source
|
||||
pv, pp := provider, nonce
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if e := insertLedgerTx(ctx, tx, accountID, "fund", &src, &src, payout, nil, nil, &pv, &pp, snapshot, now); e != nil {
|
||||
if isUniqueViolation(e) {
|
||||
outcome.AlreadyCredited = true
|
||||
return errAlreadyCredited
|
||||
}
|
||||
return e
|
||||
}
|
||||
if _, e := tx.ExecContext(ctx,
|
||||
`INSERT INTO payments.balances (account_id, source, chips, updated_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (account_id, source) DO UPDATE
|
||||
SET chips = payments.balances.chips + EXCLUDED.chips, updated_at = now()`,
|
||||
accountID, string(src), payout); e != nil {
|
||||
return fmt.Errorf("payments: credit rewarded balance: %w", e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, errAlreadyCredited) {
|
||||
return outcome, nil
|
||||
}
|
||||
return RewardOutcome{}, err
|
||||
}
|
||||
outcome.Chips = payout
|
||||
s.cache.invalidate(accountID)
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// insertPaymentEvent appends an undispatched lifecycle event (succeeded/failed/refunded) for the
|
||||
// dispatcher to deliver. orderID and payload (a jsonb detail blob) are optional.
|
||||
func (s *Store) insertPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte, now time.Time) error {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return fmt.Errorf("payments: event id: %w", err)
|
||||
}
|
||||
var pl any = postgres.NULL
|
||||
if payload != nil {
|
||||
pl = string(payload)
|
||||
}
|
||||
stmt := table.PaymentEvents.INSERT(
|
||||
table.PaymentEvents.EventID, table.PaymentEvents.AccountID, table.PaymentEvents.OrderID,
|
||||
table.PaymentEvents.Type, table.PaymentEvents.Payload, table.PaymentEvents.CreatedAt,
|
||||
).VALUES(id, accountID, uuidOrNull(orderID), eventType, pl, now)
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("payments: insert %s event: %w", eventType, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PaymentEvent is an undispatched lifecycle event the dispatcher delivers to an account.
|
||||
type PaymentEvent struct {
|
||||
EventID uuid.UUID
|
||||
AccountID uuid.UUID
|
||||
Type string
|
||||
}
|
||||
|
||||
// undispatchedEvents reads up to limit payment events not yet delivered, oldest first.
|
||||
func (s *Store) undispatchedEvents(ctx context.Context, limit int) ([]PaymentEvent, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT event_id, account_id, type FROM payments.payment_events
|
||||
WHERE dispatched_at IS NULL ORDER BY created_at LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payments: read undispatched events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PaymentEvent
|
||||
for rows.Next() {
|
||||
var e PaymentEvent
|
||||
if err := rows.Scan(&e.EventID, &e.AccountID, &e.Type); err != nil {
|
||||
return nil, fmt.Errorf("payments: scan event: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// markEventDispatched stamps an event as delivered so it is not re-sent.
|
||||
func (s *Store) markEventDispatched(ctx context.Context, eventID uuid.UUID, now time.Time) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE payments.payment_events SET dispatched_at = $2 WHERE event_id = $1`, eventID, now); err != nil {
|
||||
return fmt.Errorf("payments: mark event dispatched: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orderTTL reads the configured pending-order lifetime in whole seconds.
|
||||
func (s *Store) orderTTL(ctx context.Context) (int, error) {
|
||||
var cfg model.Config
|
||||
if err := postgres.SELECT(table.Config.OrderTTLSeconds).
|
||||
FROM(table.Config).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &cfg); err != nil {
|
||||
return 0, fmt.Errorf("payments: read order ttl: %w", err)
|
||||
}
|
||||
return int(cfg.OrderTTLSeconds), nil
|
||||
}
|
||||
|
||||
// expirePending marks every pending order older than ttlSeconds as expired, returning how many.
|
||||
// Expiry is cosmetic DB hygiene: a later valid callback still credits an expired order (§9/D23).
|
||||
func (s *Store) expirePending(ctx context.Context, ttlSeconds int, now time.Time) (int, error) {
|
||||
cutoff := now.Add(-time.Duration(ttlSeconds) * time.Second)
|
||||
res, err := table.Orders.
|
||||
UPDATE(table.Orders.Status, table.Orders.UpdatedAt).
|
||||
SET(postgres.String("expired"), postgres.TimestampzT(now)).
|
||||
WHERE(table.Orders.Status.EQ(postgres.String("pending")).
|
||||
AND(table.Orders.CreatedAt.LT(postgres.TimestampzT(cutoff)))).
|
||||
ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("payments: expire pending orders: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// marshalFundSnapshot records what a fund credited (the pack, chips and paid amount) on the ledger
|
||||
// row, so history stays independent of later catalog edits (§7/D34).
|
||||
func marshalFundSnapshot(productID uuid.UUID, title string, chips int, paid Money) ([]byte, error) {
|
||||
b, err := json.Marshal(struct {
|
||||
ProductID string `json:"product_id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Chips int `json:"chips"`
|
||||
Amount int64 `json:"amount_minor"`
|
||||
Currency string `json:"currency"`
|
||||
}{productID.String(), title, chips, paid.Minor(), string(paid.Currency())})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payments: marshal fund snapshot: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// marshalRefundSnapshot records the full reversal on the refund ledger row: the pack, the original
|
||||
// funded chips, how many were actually reclaimed, the unrecoverable loss (already spent), the money
|
||||
// refunded and the provider refund id — so the ledger stays reconcilable against the balance
|
||||
// (chipsDelta = revoked) while the report still sees the whole reversal (§7/D27/D34).
|
||||
func marshalRefundSnapshot(productID uuid.UUID, title string, chips, revoked, loss int, refunded Money, refundID string) ([]byte, error) {
|
||||
b, err := json.Marshal(struct {
|
||||
ProductID string `json:"product_id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Chips int `json:"chips"`
|
||||
Revoked int `json:"revoked"`
|
||||
Loss int `json:"loss"`
|
||||
Amount int64 `json:"amount_minor"`
|
||||
Currency string `json:"currency"`
|
||||
RefundID string `json:"refund_id"`
|
||||
}{productID.String(), title, chips, revoked, loss, refunded.Minor(), string(refunded.Currency()), refundID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payments: marshal refund snapshot: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// marshalRewardSnapshot records a rewarded-video credit on its ledger row: the marker distinguishing
|
||||
// it from a paid fund, and the chips granted — so the report separates ad-earned chips from purchases.
|
||||
func marshalRewardSnapshot(chips int) ([]byte, error) {
|
||||
b, err := json.Marshal(struct {
|
||||
Reward bool `json:"reward"`
|
||||
Chips int `json:"chips"`
|
||||
}{true, chips})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payments: marshal reward snapshot: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint violation (SQLSTATE
|
||||
// 23505) — here, a duplicate provider callback hitting the ledger idempotency index.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/payments/model"
|
||||
"scrabble/backend/internal/postgres/jet/payments/table"
|
||||
)
|
||||
|
||||
// statementOrder is the fixed segment/origin ordering the report renders, so the panel is stable
|
||||
// (the balance/benefit maps iterate randomly).
|
||||
var statementOrder = []Source{SourceDirect, SourceVK, SourceTelegram}
|
||||
|
||||
// accountStatement reads the account's balances, benefits, refund risk and full ledger history for
|
||||
// the admin report. Uncached and outside any transaction — an operator view, not a hot path.
|
||||
func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
|
||||
st, err := s.loadState(ctx, accountID)
|
||||
if err != nil {
|
||||
return Statement{}, err
|
||||
}
|
||||
var out Statement
|
||||
for _, src := range statementOrder {
|
||||
if chips, ok := st.chips[src]; ok {
|
||||
out.Segments = append(out.Segments, SegmentChips{Source: src, Chips: chips})
|
||||
}
|
||||
if b, ok := st.benefits[src]; ok {
|
||||
out.Benefits = append(out.Benefits, OriginBenefit{
|
||||
Origin: src, Hints: b.hints, AdsPaidUntil: derefTime(b.adsPaidUntil), AdsForever: b.adsForever,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var risk model.AccountRisk
|
||||
err = postgres.SELECT(table.AccountRisk.AllColumns).
|
||||
FROM(table.AccountRisk).
|
||||
WHERE(table.AccountRisk.AccountID.EQ(postgres.UUID(accountID))).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &risk)
|
||||
switch {
|
||||
case err == nil:
|
||||
out.Risk = RiskInfo{Present: true, Abuse: risk.Abuse, LossChips: int(risk.LossChips)}
|
||||
case errors.Is(err, qrm.ErrNoRows):
|
||||
// no risk row — a clean account
|
||||
default:
|
||||
return Statement{}, fmt.Errorf("payments: load risk %s: %w", accountID, err)
|
||||
}
|
||||
|
||||
var rows []model.Ledger
|
||||
if err := postgres.SELECT(table.Ledger.AllColumns).
|
||||
FROM(table.Ledger).
|
||||
WHERE(table.Ledger.AccountID.EQ(postgres.UUID(accountID))).
|
||||
ORDER_BY(table.Ledger.CreatedAt.DESC()).
|
||||
QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return Statement{}, fmt.Errorf("payments: load ledger %s: %w", accountID, err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
out.Ledger = append(out.Ledger, LedgerEntry{
|
||||
Kind: r.Kind,
|
||||
Source: derefStr(r.Source),
|
||||
Origin: derefStr(r.Origin),
|
||||
ChipsDelta: int(r.ChipsDelta),
|
||||
ProductID: derefUUID(r.ProductID),
|
||||
OrderID: derefUUID(r.OrderID),
|
||||
Provider: derefStr(r.Provider),
|
||||
ProviderPaymentID: derefStr(r.ProviderPaymentID),
|
||||
Snapshot: derefStr(r.Snapshot),
|
||||
CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// allLedger reads the entire append-only ledger (all accounts, newest first) for the admin export.
|
||||
func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) {
|
||||
var rows []model.Ledger
|
||||
if err := postgres.SELECT(table.Ledger.AllColumns).
|
||||
FROM(table.Ledger).
|
||||
ORDER_BY(table.Ledger.CreatedAt.DESC()).
|
||||
QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("payments: export ledger: %w", err)
|
||||
}
|
||||
out := make([]LedgerExportRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, LedgerExportRow{
|
||||
AccountID: r.AccountID.String(),
|
||||
LedgerEntry: LedgerEntry{
|
||||
Kind: r.Kind,
|
||||
Source: derefStr(r.Source),
|
||||
Origin: derefStr(r.Origin),
|
||||
ChipsDelta: int(r.ChipsDelta),
|
||||
ProductID: derefUUID(r.ProductID),
|
||||
OrderID: derefUUID(r.OrderID),
|
||||
Provider: derefStr(r.Provider),
|
||||
ProviderPaymentID: derefStr(r.ProviderPaymentID),
|
||||
Snapshot: derefStr(r.Snapshot),
|
||||
CreatedAt: r.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// derefStr returns the pointed-to string, or "" when the pointer is nil (a NULL column).
|
||||
func derefStr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// derefUUID renders the pointed-to UUID as a string, or "" when the pointer is nil.
|
||||
func derefUUID(p *uuid.UUID) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.String()
|
||||
}
|
||||
|
||||
// derefTime returns the pointed-to time, or the zero time when the pointer is nil (a NULL column).
|
||||
func derefTime(p *time.Time) time.Time {
|
||||
if p == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return *p
|
||||
}
|
||||
@@ -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.
|
||||
@@ -230,8 +238,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 +257,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 +291,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 +308,23 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm
|
||||
// the chosen origin, in one transaction — a zero-price sale of a value.
|
||||
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)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.cache.invalidate(accountID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// grantProduct is grant with the source product recorded on the ledger row (product_id), for an
|
||||
// admin grant-by-product — the benefit is the product's atoms, the ledger stays auditable to it.
|
||||
func (s *Store) grantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID, d benefitDelta, snapshot []byte, now time.Time) error {
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, &productID, nil, nil, nil, snapshot, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return applyBenefitTx(ctx, tx, accountID, origin, d, now)
|
||||
@@ -419,3 +448,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
type Config struct {
|
||||
OnlyRow bool `sql:"primary_key"`
|
||||
GuestVsAiLimit int16
|
||||
GuestRandomLimit int16
|
||||
GuestFriendsLimit int16
|
||||
DurableVsAiLimit int16
|
||||
DurableRandomLimit int16
|
||||
DurableFriendsLimit int16
|
||||
}
|
||||
@@ -33,4 +33,5 @@ type Games struct {
|
||||
DropoutTiles string
|
||||
MultipleWordsPerTurn bool
|
||||
VsAi bool
|
||||
GameKind int16
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Config = newConfigTable("backend", "config", "")
|
||||
|
||||
type configTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
OnlyRow postgres.ColumnBool
|
||||
GuestVsAiLimit postgres.ColumnInteger
|
||||
GuestRandomLimit postgres.ColumnInteger
|
||||
GuestFriendsLimit postgres.ColumnInteger
|
||||
DurableVsAiLimit postgres.ColumnInteger
|
||||
DurableRandomLimit postgres.ColumnInteger
|
||||
DurableFriendsLimit postgres.ColumnInteger
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type ConfigTable struct {
|
||||
configTable
|
||||
|
||||
EXCLUDED configTable
|
||||
}
|
||||
|
||||
// AS creates new ConfigTable with assigned alias
|
||||
func (a ConfigTable) AS(alias string) *ConfigTable {
|
||||
return newConfigTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new ConfigTable with assigned schema name
|
||||
func (a ConfigTable) FromSchema(schemaName string) *ConfigTable {
|
||||
return newConfigTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new ConfigTable with assigned table prefix
|
||||
func (a ConfigTable) WithPrefix(prefix string) *ConfigTable {
|
||||
return newConfigTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new ConfigTable with assigned table suffix
|
||||
func (a ConfigTable) WithSuffix(suffix string) *ConfigTable {
|
||||
return newConfigTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newConfigTable(schemaName, tableName, alias string) *ConfigTable {
|
||||
return &ConfigTable{
|
||||
configTable: newConfigTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newConfigTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigTableImpl(schemaName, tableName, alias string) configTable {
|
||||
var (
|
||||
OnlyRowColumn = postgres.BoolColumn("only_row")
|
||||
GuestVsAiLimitColumn = postgres.IntegerColumn("guest_vs_ai_limit")
|
||||
GuestRandomLimitColumn = postgres.IntegerColumn("guest_random_limit")
|
||||
GuestFriendsLimitColumn = postgres.IntegerColumn("guest_friends_limit")
|
||||
DurableVsAiLimitColumn = postgres.IntegerColumn("durable_vs_ai_limit")
|
||||
DurableRandomLimitColumn = postgres.IntegerColumn("durable_random_limit")
|
||||
DurableFriendsLimitColumn = postgres.IntegerColumn("durable_friends_limit")
|
||||
allColumns = postgres.ColumnList{OnlyRowColumn, GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
|
||||
mutableColumns = postgres.ColumnList{GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
|
||||
defaultColumns = postgres.ColumnList{OnlyRowColumn, GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
|
||||
)
|
||||
|
||||
return configTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
OnlyRow: OnlyRowColumn,
|
||||
GuestVsAiLimit: GuestVsAiLimitColumn,
|
||||
GuestRandomLimit: GuestRandomLimitColumn,
|
||||
GuestFriendsLimit: GuestFriendsLimitColumn,
|
||||
DurableVsAiLimit: DurableVsAiLimitColumn,
|
||||
DurableRandomLimit: DurableRandomLimitColumn,
|
||||
DurableFriendsLimit: DurableFriendsLimitColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ type gamesTable struct {
|
||||
DropoutTiles postgres.ColumnString
|
||||
MultipleWordsPerTurn postgres.ColumnBool
|
||||
VsAi postgres.ColumnBool
|
||||
GameKind postgres.ColumnInteger
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -98,9 +99,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
||||
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
|
||||
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
|
||||
VsAiColumn = postgres.BoolColumn("vs_ai")
|
||||
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
|
||||
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
|
||||
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
|
||||
GameKindColumn = postgres.IntegerColumn("game_kind")
|
||||
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
|
||||
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
|
||||
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
|
||||
)
|
||||
|
||||
return gamesTable{
|
||||
@@ -127,6 +129,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
||||
DropoutTiles: DropoutTilesColumn,
|
||||
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
|
||||
VsAi: VsAiColumn,
|
||||
GameKind: GameKindColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -21,6 +21,7 @@ func UseSchema(schema string) {
|
||||
Blocks = Blocks.FromSchema(schema)
|
||||
ChatMessages = ChatMessages.FromSchema(schema)
|
||||
Complaints = Complaints.FromSchema(schema)
|
||||
Config = Config.FromSchema(schema)
|
||||
DictionaryState = DictionaryState.FromSchema(schema)
|
||||
EmailConfirmations = EmailConfirmations.FromSchema(schema)
|
||||
FeedbackMessages = FeedbackMessages.FromSchema(schema)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AccountRisk struct {
|
||||
AccountID uuid.UUID `sql:"primary_key"`
|
||||
Abuse bool
|
||||
LossChips int64
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -14,4 +14,6 @@ type Config struct {
|
||||
CooldownVsAiSeconds int32
|
||||
CooldownHintSeconds int32
|
||||
OrderTTLSeconds int32
|
||||
RewardDailyCap int32
|
||||
RewardHourlyCap int32
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var AccountRisk = newAccountRiskTable("payments", "account_risk", "")
|
||||
|
||||
type accountRiskTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
AccountID postgres.ColumnString
|
||||
Abuse postgres.ColumnBool
|
||||
LossChips postgres.ColumnInteger
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type AccountRiskTable struct {
|
||||
accountRiskTable
|
||||
|
||||
EXCLUDED accountRiskTable
|
||||
}
|
||||
|
||||
// AS creates new AccountRiskTable with assigned alias
|
||||
func (a AccountRiskTable) AS(alias string) *AccountRiskTable {
|
||||
return newAccountRiskTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new AccountRiskTable with assigned schema name
|
||||
func (a AccountRiskTable) FromSchema(schemaName string) *AccountRiskTable {
|
||||
return newAccountRiskTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new AccountRiskTable with assigned table prefix
|
||||
func (a AccountRiskTable) WithPrefix(prefix string) *AccountRiskTable {
|
||||
return newAccountRiskTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new AccountRiskTable with assigned table suffix
|
||||
func (a AccountRiskTable) WithSuffix(suffix string) *AccountRiskTable {
|
||||
return newAccountRiskTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newAccountRiskTable(schemaName, tableName, alias string) *AccountRiskTable {
|
||||
return &AccountRiskTable{
|
||||
accountRiskTable: newAccountRiskTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newAccountRiskTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newAccountRiskTableImpl(schemaName, tableName, alias string) accountRiskTable {
|
||||
var (
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
AbuseColumn = postgres.BoolColumn("abuse")
|
||||
LossChipsColumn = postgres.IntegerColumn("loss_chips")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, AbuseColumn, LossChipsColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{AbuseColumn, LossChipsColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{AbuseColumn, LossChipsColumn, UpdatedAtColumn}
|
||||
)
|
||||
|
||||
return accountRiskTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
AccountID: AccountIDColumn,
|
||||
Abuse: AbuseColumn,
|
||||
LossChips: LossChipsColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ type configTable struct {
|
||||
CooldownVsAiSeconds postgres.ColumnInteger
|
||||
CooldownHintSeconds postgres.ColumnInteger
|
||||
OrderTTLSeconds postgres.ColumnInteger
|
||||
RewardDailyCap postgres.ColumnInteger
|
||||
RewardHourlyCap postgres.ColumnInteger
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -70,9 +72,11 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
|
||||
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
|
||||
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
|
||||
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
|
||||
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||
RewardDailyCapColumn = postgres.IntegerColumn("reward_daily_cap")
|
||||
RewardHourlyCapColumn = postgres.IntegerColumn("reward_hourly_cap")
|
||||
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
|
||||
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
|
||||
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
|
||||
)
|
||||
|
||||
return configTable{
|
||||
@@ -85,6 +89,8 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
|
||||
CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
|
||||
CooldownHintSeconds: CooldownHintSecondsColumn,
|
||||
OrderTTLSeconds: OrderTTLSecondsColumn,
|
||||
RewardDailyCap: RewardDailyCapColumn,
|
||||
RewardHourlyCap: RewardHourlyCapColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -10,6 +10,7 @@ package table
|
||||
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
||||
// this method only once at the beginning of the program.
|
||||
func UseSchema(schema string) {
|
||||
AccountRisk = AccountRisk.FromSchema(schema)
|
||||
Balances = Balances.FromSchema(schema)
|
||||
Benefits = Benefits.FromSchema(schema)
|
||||
CatalogAtom = CatalogAtom.FromSchema(schema)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Per-account payment risk: the loss and abuse signal an external/admin refund leaves
|
||||
-- behind when the refunded chips were already spent. A refund revokes chips best-effort
|
||||
-- and never drives a balance negative (D27, balances_chips_chk); the unrecoverable
|
||||
-- remainder is a recorded loss and flips an abuse flag the /_gm financial report reads
|
||||
-- (D40, E7). Mutable per-account state (upserted on each such refund), so — unlike the
|
||||
-- ledger — it carries no append-only trigger. Additive: a new table only, so goose
|
||||
-- applies it forward with no rewrite of existing data (the contour is not wiped). The
|
||||
-- payments role inherits ALL on it via the schema default privileges set in 00010.
|
||||
-- +goose Up
|
||||
|
||||
CREATE TABLE payments.account_risk (
|
||||
account_id uuid NOT NULL,
|
||||
-- abuse flips true the first time a refund cannot fully reclaim its chips (spent).
|
||||
abuse boolean DEFAULT false NOT NULL,
|
||||
-- loss_chips accumulates the unrecoverable chips across such refunds (bigint: an
|
||||
-- accumulator, mapped to int64 by go-jet — not the numeric->float64 trap).
|
||||
loss_chips bigint DEFAULT 0 NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT account_risk_pkey PRIMARY KEY (account_id),
|
||||
CONSTRAINT account_risk_loss_chips_chk CHECK ((loss_chips >= 0))
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DROP TABLE IF EXISTS payments.account_risk;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Rewarded-video caps: the anti-abuse ceilings on free rewarded credits per user, per
|
||||
-- day and per hour. VK Mini App ads expose only a client-side watch result (no
|
||||
-- server-to-server verify), so a rewarded credit is client-attested; the caps bound a
|
||||
-- forger who skips the ad and calls the credit endpoint directly (the daily cap bounds the
|
||||
-- total; the hourly cap smooths a burst). Chips-per-view already exists
|
||||
-- (rewarded_payout_chips, default 0 = rewarded inert until the owner sets it). All are
|
||||
-- config, tuned in the admin without a release. Additive columns only — applies forward via
|
||||
-- goose with no data rewrite (no contour wipe), and an image rollback ignores them.
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE payments.config
|
||||
ADD COLUMN reward_daily_cap integer DEFAULT 50 NOT NULL,
|
||||
ADD COLUMN reward_hourly_cap integer DEFAULT 10 NOT NULL;
|
||||
ALTER TABLE payments.config
|
||||
ADD CONSTRAINT config_reward_daily_cap_chk CHECK (reward_daily_cap >= 0),
|
||||
ADD CONSTRAINT config_reward_hourly_cap_chk CHECK (reward_hourly_cap >= 0);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE payments.config
|
||||
DROP COLUMN reward_daily_cap,
|
||||
DROP COLUMN reward_hourly_cap;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Guest-limit foundation (E8): tag each game with its kind so the per-tier, per-kind active-game
|
||||
-- limits are enforceable, and add the single-row config that holds those limits (tuned in the admin,
|
||||
-- no release). It replaces the old hardcoded MaxActiveQuickGames=10 combined cap with a per-tier,
|
||||
-- per-kind config. game_kind: 0=unknown (pre-E8 games, never gated), 1=vs_ai, 2=random, 3=friends —
|
||||
-- set on creation. The limits are smallint with -1 = unlimited; a guest defaults to 1 vs_ai + 1
|
||||
-- random (friends 0, moot — the guest gate blocks friend games), a durable account to 10 per kind
|
||||
-- (the old cap, now per kind). Additive only — applies forward via goose with no data rewrite (no
|
||||
-- contour wipe); an image rollback ignores the column + table.
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE backend.games
|
||||
ADD COLUMN game_kind smallint DEFAULT 0 NOT NULL;
|
||||
ALTER TABLE backend.games
|
||||
ADD CONSTRAINT games_game_kind_chk CHECK (game_kind >= 0 AND game_kind <= 3);
|
||||
|
||||
CREATE TABLE backend.config (
|
||||
only_row boolean DEFAULT true NOT NULL,
|
||||
guest_vs_ai_limit smallint DEFAULT 1 NOT NULL,
|
||||
guest_random_limit smallint DEFAULT 1 NOT NULL,
|
||||
guest_friends_limit smallint DEFAULT 0 NOT NULL,
|
||||
durable_vs_ai_limit smallint DEFAULT 10 NOT NULL,
|
||||
durable_random_limit smallint DEFAULT 10 NOT NULL,
|
||||
durable_friends_limit smallint DEFAULT 10 NOT NULL,
|
||||
CONSTRAINT config_pkey PRIMARY KEY (only_row),
|
||||
CONSTRAINT config_single_row_chk CHECK (only_row),
|
||||
CONSTRAINT config_limits_chk CHECK (
|
||||
guest_vs_ai_limit >= -1 AND guest_random_limit >= -1 AND guest_friends_limit >= -1 AND
|
||||
durable_vs_ai_limit >= -1 AND durable_random_limit >= -1 AND durable_friends_limit >= -1)
|
||||
);
|
||||
|
||||
INSERT INTO backend.config (only_row) VALUES (true);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DROP TABLE backend.config;
|
||||
ALTER TABLE backend.games DROP COLUMN game_kind;
|
||||
@@ -0,0 +1,104 @@
|
||||
// Package robokassa builds and verifies Robokassa direct-rail (RUB) payments: it forms the signed
|
||||
// hosted-payment URL a client is sent to, and verifies the Result-URL server callback that credits
|
||||
// an order. It is pure provider glue — no database, no payments-domain coupling — so the payments
|
||||
// domain stays provider-agnostic and this layer is unit-testable in isolation.
|
||||
//
|
||||
// The order is threaded through Robokassa's custom-parameter channel as Shp_order=<order id> (echoed
|
||||
// back in the callback and bound into the signature), not the numeric InvId, because an order id is
|
||||
// a uuid; InvId is sent as 0. Idempotency is therefore keyed on the order id at the credit site.
|
||||
// Signatures use SHA-256 (configured to match the shop's technical settings); the shop's test mode
|
||||
// is carried by IsTest.
|
||||
package robokassa
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// payEndpoint is Robokassa's hosted payment page; the signed query sends the client there.
|
||||
const payEndpoint = "https://auth.robokassa.ru/Merchant/Index.aspx"
|
||||
|
||||
// Config is a Robokassa shop's credentials. Password1 signs the outgoing payment request;
|
||||
// Password2 signs (and so verifies) the incoming Result callback. IsTest adds IsTest=1 so the shop's
|
||||
// test mode simulates payments without money movement.
|
||||
type Config struct {
|
||||
MerchantLogin string
|
||||
Password1 string
|
||||
Password2 string
|
||||
IsTest bool
|
||||
}
|
||||
|
||||
// PaymentURL builds the signed hosted-payment URL for an order: amount is the OutSum decimal string
|
||||
// (roubles, e.g. "149.00"), description is the human payment purpose. The order id rides as
|
||||
// Shp_order and is bound into the SHA-256 signature; InvId is 0 (unused).
|
||||
func (c Config) PaymentURL(orderID uuid.UUID, amount, description string) string {
|
||||
q := url.Values{}
|
||||
q.Set("Shp_order", orderID.String())
|
||||
// Signature base: MerchantLogin:OutSum:InvId:Password1[:Shp_key=value...sorted].
|
||||
base := c.MerchantLogin + ":" + amount + ":0:" + c.Password1 + shpSuffix(q)
|
||||
|
||||
q.Set("MerchantLogin", c.MerchantLogin)
|
||||
q.Set("OutSum", amount)
|
||||
q.Set("InvId", "0")
|
||||
q.Set("Description", description)
|
||||
q.Set("SignatureValue", sign(base))
|
||||
if c.IsTest {
|
||||
q.Set("IsTest", "1")
|
||||
}
|
||||
return payEndpoint + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// VerifyResult verifies a Result-URL callback and extracts the order it credits. It recomputes the
|
||||
// SHA-256 signature OutSum:InvId:Password2[:Shp_...sorted] over the callback's own fields and
|
||||
// compares it (case-insensitively) with SignatureValue. On success it returns the order id (from
|
||||
// Shp_order) and the raw OutSum string the caller re-checks against the order amount; on any
|
||||
// missing field, a signature mismatch or an unparseable order id it returns ok=false.
|
||||
func (c Config) VerifyResult(v url.Values) (orderID uuid.UUID, outSum string, ok bool) {
|
||||
outSum = v.Get("OutSum")
|
||||
sig := v.Get("SignatureValue")
|
||||
order := v.Get("Shp_order")
|
||||
if outSum == "" || sig == "" || order == "" {
|
||||
return uuid.Nil, "", false
|
||||
}
|
||||
// Robokassa signs with the InvId it returns in the callback; recompute with that same value.
|
||||
base := outSum + ":" + v.Get("InvId") + ":" + c.Password2 + shpSuffix(v)
|
||||
if !strings.EqualFold(sign(base), sig) {
|
||||
return uuid.Nil, "", false
|
||||
}
|
||||
id, err := uuid.Parse(order)
|
||||
if err != nil {
|
||||
return uuid.Nil, "", false
|
||||
}
|
||||
return id, outSum, true
|
||||
}
|
||||
|
||||
// shpSuffix renders the Shp_ custom parameters of v as Robokassa binds them into a signature:
|
||||
// every Shp_-prefixed key, sorted alphabetically, appended as ":key=value".
|
||||
func shpSuffix(v url.Values) string {
|
||||
var keys []string
|
||||
for k := range v {
|
||||
if strings.HasPrefix(k, "Shp_") {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b strings.Builder
|
||||
for _, k := range keys {
|
||||
b.WriteString(":")
|
||||
b.WriteString(k)
|
||||
b.WriteString("=")
|
||||
b.WriteString(v.Get(k))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// sign returns the lowercase hex SHA-256 of s, the hash Robokassa compares against SignatureValue.
|
||||
func sign(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package robokassa
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func testCfg() Config {
|
||||
return Config{MerchantLogin: "shop", Password1: "p1", Password2: "p2", IsTest: true}
|
||||
}
|
||||
|
||||
// resultSig computes the Pass2 Result signature Robokassa would send for a callback carrying only
|
||||
// Shp_order — the fixture the verifier must accept.
|
||||
func resultSig(pass2, outSum, invID string, orderID uuid.UUID) string {
|
||||
return sign(outSum + ":" + invID + ":" + pass2 + ":Shp_order=" + orderID.String())
|
||||
}
|
||||
|
||||
func TestPaymentURL(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
id := uuid.New()
|
||||
u, err := url.Parse(cfg.PaymentURL(id, "149.00", "10 chips"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse payment url: %v", err)
|
||||
}
|
||||
q := u.Query()
|
||||
if got := q.Get("OutSum"); got != "149.00" {
|
||||
t.Errorf("OutSum = %q, want 149.00", got)
|
||||
}
|
||||
if got := q.Get("InvId"); got != "0" {
|
||||
t.Errorf("InvId = %q, want 0 (unused)", got)
|
||||
}
|
||||
if got := q.Get("Shp_order"); got != id.String() {
|
||||
t.Errorf("Shp_order = %q, want the order id", got)
|
||||
}
|
||||
if got := q.Get("IsTest"); got != "1" {
|
||||
t.Errorf("IsTest = %q, want 1 (test shop)", got)
|
||||
}
|
||||
want := sign("shop:149.00:0:p1:Shp_order=" + id.String())
|
||||
if got := q.Get("SignatureValue"); got != want {
|
||||
t.Errorf("SignatureValue = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyResult(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
id := uuid.New()
|
||||
|
||||
valid := func() url.Values {
|
||||
v := url.Values{}
|
||||
v.Set("OutSum", "149.00")
|
||||
v.Set("InvId", "0")
|
||||
v.Set("Shp_order", id.String())
|
||||
// Robokassa sends the hash uppercase; the verifier must be case-insensitive.
|
||||
v.Set("SignatureValue", strings.ToUpper(resultSig("p2", "149.00", "0", id)))
|
||||
return v
|
||||
}
|
||||
|
||||
gotID, gotSum, ok := cfg.VerifyResult(valid())
|
||||
if !ok || gotID != id || gotSum != "149.00" {
|
||||
t.Fatalf("VerifyResult(valid) = %v/%q/%v, want %v/149.00/true", gotID, gotSum, ok, id)
|
||||
}
|
||||
|
||||
// A tampered amount breaks the signature.
|
||||
tampered := valid()
|
||||
tampered.Set("OutSum", "1.00")
|
||||
if _, _, ok := cfg.VerifyResult(tampered); ok {
|
||||
t.Error("VerifyResult accepted a tampered amount")
|
||||
}
|
||||
|
||||
// The wrong Password2 (a forged callback) does not verify.
|
||||
wrongPass := Config{MerchantLogin: "shop", Password1: "p1", Password2: "other"}
|
||||
if _, _, ok := wrongPass.VerifyResult(valid()); ok {
|
||||
t.Error("VerifyResult accepted a signature under the wrong password")
|
||||
}
|
||||
|
||||
// A missing Shp_order is rejected (no order to credit).
|
||||
noOrder := valid()
|
||||
noOrder.Del("Shp_order")
|
||||
if _, _, ok := cfg.VerifyResult(noOrder); ok {
|
||||
t.Error("VerifyResult accepted a callback with no order")
|
||||
}
|
||||
}
|
||||
@@ -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,8 @@ 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)
|
||||
r.GameLimits = s.gameLimitsDTOFor(acc.IsGuest)
|
||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||
r.DictVersions = s.currentDictVersions()
|
||||
return r
|
||||
@@ -177,6 +179,44 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account, cxt payment
|
||||
}
|
||||
}
|
||||
|
||||
// adsDTO is the post-move interstitial config in the profile: the client-mirrored cooldowns
|
||||
// (seconds) and whether ads are suppressed in the context (a no-ads benefit applicable here, or the
|
||||
// no_banner role). The client shows a VK interstitial after a confirmed move / hint only when not
|
||||
// suppressed and the mirrored cooldown has elapsed.
|
||||
type adsDTO struct {
|
||||
CooldownGlobalS int `json:"cooldown_global_s"`
|
||||
CooldownVsAiS int `json:"cooldown_vs_ai_s"`
|
||||
CooldownHintS int `json:"cooldown_hint_s"`
|
||||
Suppressed bool `json:"suppressed"`
|
||||
}
|
||||
|
||||
// adsFor builds the profile interstitial-ad config: the cooldowns and whether ads are suppressed
|
||||
// here (the same no-ads / no_banner gate as the banner). A read failure logs and yields a suppressed
|
||||
// block (fail-safe: no interstitial), so the profile still succeeds.
|
||||
func (s *Server) adsFor(ctx context.Context, acc account.Account, cxt payments.Context, present []payments.Source) *adsDTO {
|
||||
if s.payments == nil {
|
||||
return nil
|
||||
}
|
||||
global, vsAi, hint, err := s.payments.InterstitialCooldowns(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("profile: ad cooldowns read failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
return &adsDTO{Suppressed: true}
|
||||
}
|
||||
suppressed := false
|
||||
if adFree, aerr := s.payments.AdFree(ctx, acc.ID, cxt, present); aerr != nil {
|
||||
s.log.Warn("profile: ad-free read failed", zap.String("account", acc.ID.String()), zap.Error(aerr))
|
||||
suppressed = true // fail-safe: suppress the interstitial when eligibility is unknown
|
||||
} else {
|
||||
suppressed = adFree
|
||||
}
|
||||
if !suppressed {
|
||||
if noBanner, berr := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner); berr == nil {
|
||||
suppressed = noBanner
|
||||
}
|
||||
}
|
||||
return &adsDTO{CooldownGlobalS: global, CooldownVsAiS: vsAi, CooldownHintS: hint, Suppressed: suppressed}
|
||||
}
|
||||
|
||||
// bannerCampaignFromActive flattens a resolved campaign into its wire DTO,
|
||||
// projecting each optional colour set into its three "#rrggbb" fields (empty when
|
||||
// the set is absent, so JSON omitempty drops them).
|
||||
|
||||
@@ -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
|
||||
@@ -73,6 +78,18 @@ type profileResponse struct {
|
||||
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
|
||||
// for callers that build the DTO without a Server. See Server.profileResponse.
|
||||
DictVersions []dictVersion `json:"dict_versions,omitempty"`
|
||||
// GameLimits carries the caller's tier active-game caps per kind (-1 = unlimited); the client
|
||||
// counts its active games per kind from the lobby and locks a capped New-Game start. Filled
|
||||
// outside the pure projection (it reads the limits config). See Server.profileResponse.
|
||||
GameLimits *gameLimitsDTO `json:"game_limits,omitempty"`
|
||||
}
|
||||
|
||||
// gameLimitsDTO is the caller's tier active-game caps per kind (-1 = unlimited), for the client's
|
||||
// per-kind New-Game lock. profileResponse.GameLimits carries it.
|
||||
type gameLimitsDTO struct {
|
||||
VsAI int `json:"vs_ai"`
|
||||
Random int `json:"random"`
|
||||
Friends int `json:"friends"`
|
||||
}
|
||||
|
||||
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
|
||||
@@ -129,7 +146,11 @@ type gameDTO struct {
|
||||
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
|
||||
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
|
||||
// are suppressed in the client.
|
||||
VsAI bool `json:"vs_ai"`
|
||||
VsAI bool `json:"vs_ai"`
|
||||
// Kind is the game's origin for the active-game limits (game.Kind): 0 unknown (a pre-existing
|
||||
// game), 1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind to lock a capped
|
||||
// New-Game start.
|
||||
Kind int `json:"kind"`
|
||||
MoveCount int `json:"move_count"`
|
||||
EndReason string `json:"end_reason"`
|
||||
// LastActivityUnix is the lobby sort key: the current turn's start for an active
|
||||
@@ -172,7 +193,6 @@ type stateDTO struct {
|
||||
Rack []int `json:"rack"`
|
||||
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"`
|
||||
@@ -218,13 +238,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,
|
||||
@@ -271,6 +293,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
|
||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||
VsAI: g.VsAI,
|
||||
Kind: int(g.Kind),
|
||||
MoveCount: g.MoveCount,
|
||||
EndReason: g.EndReason,
|
||||
LastActivityUnix: last.Unix(),
|
||||
@@ -327,7 +350,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 {
|
||||
|
||||
@@ -72,6 +72,23 @@ func (s *Server) registerRoutes() {
|
||||
u.GET("/wallet", s.handleWallet)
|
||||
u.GET("/wallet/catalog", s.handleWalletCatalog)
|
||||
u.POST("/wallet/buy", s.handleWalletBuy)
|
||||
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
|
||||
u.POST("/wallet/reward", s.handleWalletReward)
|
||||
}
|
||||
if s.payments != nil {
|
||||
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
||||
// unsupported or unconfigured rail returns 501 from the handler. The provider callbacks are
|
||||
// gateway-only (the single writer): the VK payment callback (both phases handled here), and
|
||||
// the Robokassa Result callback when a merchant is configured.
|
||||
u.POST("/wallet/order", s.handleWalletOrder)
|
||||
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
|
||||
// The Telegram Stars rail: the bot forwards a pre_checkout validation and a completed
|
||||
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
|
||||
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
|
||||
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
|
||||
if s.robokassa.MerchantLogin != "" {
|
||||
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
||||
}
|
||||
}
|
||||
if s.links != nil {
|
||||
// Account linking & merge. The request step always mails a code;
|
||||
@@ -266,6 +283,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),
|
||||
@@ -300,6 +319,8 @@ func statusForError(err error) (int, string) {
|
||||
return http.StatusConflict, "request_declined"
|
||||
case errors.Is(err, social.ErrFriendCodeInvalid):
|
||||
return http.StatusUnprocessableEntity, "friend_code_invalid"
|
||||
case errors.Is(err, social.ErrGuestForbidden), errors.Is(err, lobby.ErrGuestForbidden):
|
||||
return http.StatusForbidden, "guest_forbidden"
|
||||
case errors.Is(err, feedback.ErrGuestForbidden):
|
||||
return http.StatusForbidden, "feedback_guest_forbidden"
|
||||
case errors.Is(err, feedback.ErrBanned):
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/adminconsole"
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// catalogBack is the product-list path the catalog console actions return to.
|
||||
const catalogBack = "/_gm/catalog"
|
||||
|
||||
// atomField pairs a form field with its atom type; priceField pairs a form field with the payment
|
||||
// method + currency it prices, following the one-currency-per-rail mapping (direct→RUB, vk→VOTE,
|
||||
// telegram→XTR) plus the value's CHIP price (no method).
|
||||
var atomFields = []struct{ field, atom string }{
|
||||
{"chips", "chips"}, {"hints", "hints"}, {"noads", "noads_days"}, {"tournament", "tournament"},
|
||||
}
|
||||
var priceFields = []struct {
|
||||
field, method string
|
||||
currency payments.Currency
|
||||
}{
|
||||
{"price_rub", "direct", payments.CurrencyRUB},
|
||||
{"price_vote", "vk", payments.CurrencyVote},
|
||||
{"price_star", "telegram", payments.CurrencyStar},
|
||||
{"price_chip", "", payments.CurrencyChip},
|
||||
}
|
||||
|
||||
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
|
||||
// flag, and the inline create form.
|
||||
func (s *Server) consoleCatalog(c *gin.Context) {
|
||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
var view adminconsole.CatalogView
|
||||
for _, p := range products {
|
||||
view.Products = append(view.Products, catalogRow(p))
|
||||
}
|
||||
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
||||
}
|
||||
|
||||
// catalogRow projects a product into its list row.
|
||||
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
||||
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
||||
for _, a := range p.Atoms {
|
||||
row.Atoms = append(row.Atoms, adminconsole.AtomRow{Atom: a.Atom, Quantity: a.Quantity})
|
||||
}
|
||||
for _, pr := range p.Prices {
|
||||
row.Prices = append(row.Prices, adminconsole.PriceRow{Method: pr.Method, Currency: string(pr.Currency), Amount: pr.Amount})
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// consoleCatalogDetail renders one product's edit form, pre-filled from its current composition.
|
||||
func (s *Server) consoleCatalogDetail(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, catalogBack)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
for _, p := range products {
|
||||
if p.ID == id {
|
||||
s.renderConsole(c, "product_detail", "catalog", p.Title, productForm(p))
|
||||
return
|
||||
}
|
||||
}
|
||||
s.renderConsoleMessage(c, "Not found", "no such product", catalogBack)
|
||||
}
|
||||
|
||||
// productForm builds the pre-filled edit form for a product.
|
||||
func productForm(p payments.AdminProduct) adminconsole.ProductFormView {
|
||||
fv := adminconsole.ProductFormView{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
||||
for _, a := range p.Atoms {
|
||||
switch a.Atom {
|
||||
case "chips":
|
||||
fv.Chips = a.Quantity
|
||||
case "hints":
|
||||
fv.Hints = a.Quantity
|
||||
case "noads_days":
|
||||
fv.NoAds = a.Quantity
|
||||
case "tournament":
|
||||
fv.Tournament = a.Quantity
|
||||
}
|
||||
}
|
||||
for _, pr := range p.Prices {
|
||||
switch pr.Currency {
|
||||
case payments.CurrencyRUB:
|
||||
fv.PriceRUB = pr.Amount
|
||||
case payments.CurrencyVote:
|
||||
fv.PriceVote = pr.Amount
|
||||
case payments.CurrencyStar:
|
||||
fv.PriceStar = pr.Amount
|
||||
case payments.CurrencyChip:
|
||||
fv.PriceChip = pr.Amount
|
||||
}
|
||||
}
|
||||
return fv
|
||||
}
|
||||
|
||||
// parseProductForm reads a product's title, atoms, prices and active flag from the submitted form.
|
||||
func parseProductForm(c *gin.Context) (payments.ProductInput, bool) {
|
||||
in := payments.ProductInput{Title: strings.TrimSpace(c.PostForm("title"))}
|
||||
for _, a := range atomFields {
|
||||
if q, err := strconv.Atoi(strings.TrimSpace(c.PostForm(a.field))); err == nil && q > 0 {
|
||||
in.Atoms = append(in.Atoms, payments.AtomLine{Atom: a.atom, Quantity: q})
|
||||
}
|
||||
}
|
||||
for _, p := range priceFields {
|
||||
if amt, err := strconv.ParseInt(strings.TrimSpace(c.PostForm(p.field)), 10, 64); err == nil && amt > 0 {
|
||||
in.Prices = append(in.Prices, payments.PriceLine{Method: p.method, Currency: p.currency, Amount: amt})
|
||||
}
|
||||
}
|
||||
return in, c.PostForm("active") != ""
|
||||
}
|
||||
|
||||
// consoleCreateProduct validates and inserts a new product from the create form.
|
||||
func (s *Server) consoleCreateProduct(c *gin.Context) {
|
||||
in, active := parseProductForm(c)
|
||||
if _, err := s.payments.CreateProduct(c.Request.Context(), in, active); err != nil {
|
||||
s.renderConsoleMessage(c, "Invalid product", err.Error(), catalogBack)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Created", "the product was created", catalogBack)
|
||||
}
|
||||
|
||||
// consoleUpdateProduct validates and replaces a product's title, atoms and prices.
|
||||
func (s *Server) consoleUpdateProduct(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, catalogBack)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, _ := parseProductForm(c)
|
||||
back := catalogBack + "/" + id.String()
|
||||
if err := s.payments.UpdateProduct(c.Request.Context(), id, in); err != nil {
|
||||
s.renderConsoleMessage(c, "Invalid product", err.Error(), back)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Saved", "the product was updated", back)
|
||||
}
|
||||
|
||||
// consoleArchiveProduct archives or unarchives a product (the desired state rides in the form);
|
||||
// unarchiving revalidates the sellable shape.
|
||||
func (s *Server) consoleArchiveProduct(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, catalogBack)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
active := c.PostForm("active") == "true"
|
||||
if err := s.payments.SetProductActive(c.Request.Context(), id, active); err != nil {
|
||||
s.renderConsoleMessage(c, "Cannot change status", err.Error(), catalogBack)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Updated", "the product status was changed", catalogBack)
|
||||
}
|
||||
|
||||
// consoleDeleteProductAction hard-deletes a never-transacted product; a transacted one is refused
|
||||
// with a hint to archive instead.
|
||||
func (s *Server) consoleDeleteProductAction(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, catalogBack)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err := s.payments.DeleteProduct(c.Request.Context(), id)
|
||||
if errors.Is(err, payments.ErrProductTransacted) {
|
||||
s.renderConsoleMessage(c, "Cannot delete", "this product has transactions; archive it instead", catalogBack)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Deleted", "the product was deleted", catalogBack)
|
||||
}
|
||||
|
||||
// consoleGrant grants raw benefit atoms (hints / no-ads days / forever) to a chosen origin — a
|
||||
// zero-price admin sale.
|
||||
func (s *Server) consoleGrant(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + id.String()
|
||||
if s.payments == nil {
|
||||
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||
return
|
||||
}
|
||||
origin := payments.Source(c.PostForm("origin"))
|
||||
hints, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("hints")))
|
||||
noads, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("noads")))
|
||||
forever := c.PostForm("forever") != ""
|
||||
if err := s.payments.Grant(c.Request.Context(), id, origin, hints, noads, forever); err != nil {
|
||||
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
|
||||
return
|
||||
}
|
||||
s.publishBannerChange(id)
|
||||
s.renderConsoleMessage(c, "Granted", "the benefit was granted", back)
|
||||
}
|
||||
|
||||
// consoleGrantProduct grants a value product's atoms (a reward bundle, possibly archived) to a
|
||||
// chosen origin. It refuses a product carrying chips or the tournament atom (payments enforces it).
|
||||
func (s *Server) consoleGrantProduct(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + id.String()
|
||||
if s.payments == nil {
|
||||
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||
return
|
||||
}
|
||||
origin := payments.Source(c.PostForm("origin"))
|
||||
productID, err := uuid.Parse(strings.TrimSpace(c.PostForm("product_id")))
|
||||
if err != nil {
|
||||
s.renderConsoleMessage(c, "Grant failed", "choose a product", back)
|
||||
return
|
||||
}
|
||||
if err := s.payments.GrantProduct(c.Request.Context(), id, origin, productID); err != nil {
|
||||
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
|
||||
return
|
||||
}
|
||||
s.publishBannerChange(id)
|
||||
s.renderConsoleMessage(c, "Granted", "the product was granted", back)
|
||||
}
|
||||
|
||||
// grantForm builds the admin-grant panel: the origin picker and the grantable products (value
|
||||
// bundles, including archived ones — chips and tournament products are excluded).
|
||||
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
||||
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
||||
products, err := s.payments.AdminCatalog(ctx)
|
||||
if err != nil {
|
||||
return fv
|
||||
}
|
||||
for _, p := range products {
|
||||
if grantableProduct(p) {
|
||||
fv.Products = append(fv.Products, adminconsole.GrantProductOption{
|
||||
ID: p.ID.String(), Title: p.Title, Summary: atomSummary(p.Atoms), Archived: !p.Active,
|
||||
})
|
||||
}
|
||||
}
|
||||
return fv
|
||||
}
|
||||
|
||||
// grantableProduct reports whether a product can be admin-granted: it carries at least one benefit
|
||||
// atom (hints / no-ads days) and no chips or tournament atom.
|
||||
func grantableProduct(p payments.AdminProduct) bool {
|
||||
benefit := false
|
||||
for _, a := range p.Atoms {
|
||||
switch a.Atom {
|
||||
case "chips", "tournament":
|
||||
return false
|
||||
case "hints", "noads_days":
|
||||
benefit = true
|
||||
}
|
||||
}
|
||||
return benefit
|
||||
}
|
||||
|
||||
// atomSummary renders a product's atoms as "hints×5, noads_days×30".
|
||||
func atomSummary(atoms []payments.AtomLine) string {
|
||||
parts := make([]string, 0, len(atoms))
|
||||
for _, a := range atoms {
|
||||
parts = append(parts, fmt.Sprintf("%s×%d", a.Atom, a.Quantity))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"scrabble/backend/internal/dictadmin"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/social"
|
||||
@@ -29,10 +30,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,12 +53,14 @@ 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)
|
||||
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/refund", s.consoleRefund)
|
||||
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
||||
gm.GET("/reasons", s.consoleReasons)
|
||||
gm.POST("/reasons", s.consoleCreateReason)
|
||||
@@ -71,6 +70,10 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.POST("/bans/unban", s.consoleUnban)
|
||||
gm.GET("/games", s.consoleGames)
|
||||
gm.GET("/games/:id", s.consoleGameDetail)
|
||||
if s.gamelimits != nil {
|
||||
gm.GET("/limits", s.consoleLimits)
|
||||
gm.POST("/limits", s.consoleUpdateLimits)
|
||||
}
|
||||
gm.GET("/complaints", s.consoleComplaints)
|
||||
gm.GET("/complaints/:id", s.consoleComplaintDetail)
|
||||
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
|
||||
@@ -106,6 +109,15 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.GET("/banner-settings", s.consoleBannerSettings)
|
||||
gm.POST("/banner-settings", s.consoleUpdateBannerSettings)
|
||||
}
|
||||
if s.payments != nil {
|
||||
gm.GET("/catalog", s.consoleCatalog)
|
||||
gm.POST("/catalog", s.consoleCreateProduct)
|
||||
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
|
||||
gm.GET("/ledger.csv", s.consoleLedgerExport)
|
||||
}
|
||||
}
|
||||
|
||||
// consoleDashboard renders the landing page: the top-line counts and the resident
|
||||
@@ -354,7 +366,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 {
|
||||
@@ -442,9 +453,41 @@ 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))
|
||||
}
|
||||
view.Grant = s.grantForm(ctx)
|
||||
}
|
||||
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 {
|
||||
@@ -963,30 +1006,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.
|
||||
@@ -1229,7 +1248,7 @@ func (s *Server) consoleUUID(c *gin.Context, back string) (uuid.UUID, bool) {
|
||||
|
||||
// gameRow projects a game summary into its console row.
|
||||
func gameRow(g game.Game) adminconsole.GameRow {
|
||||
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI}
|
||||
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI, Kind: g.Kind.String()}
|
||||
}
|
||||
|
||||
// trimForm returns the trimmed value of a posted form field.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"scrabble/backend/internal/adminconsole"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
)
|
||||
|
||||
// gameLimitsDTOFor resolves the caller's tier active-game caps into the profile DTO, so the client
|
||||
// can lock a capped New-Game start per kind. It returns nil when the limits config is not wired.
|
||||
func (s *Server) gameLimitsDTOFor(isGuest bool) *gameLimitsDTO {
|
||||
if s.gamelimits == nil {
|
||||
return nil
|
||||
}
|
||||
l := s.gamelimits.LimitsFor(isGuest)
|
||||
return &gameLimitsDTO{VsAI: l.VsAI, Random: l.Random, Friends: l.Friends}
|
||||
}
|
||||
|
||||
// consoleLimits renders the per-tier, per-kind active-game limit form (backend.config), read from
|
||||
// the in-memory cache.
|
||||
func (s *Server) consoleLimits(c *gin.Context) {
|
||||
cfg := s.gamelimits.Get()
|
||||
s.renderConsole(c, "limits", "limits", "Game limits", adminconsole.GameLimitsView{
|
||||
GuestVsAI: cfg.Guest.VsAI,
|
||||
GuestRandom: cfg.Guest.Random,
|
||||
GuestFriends: cfg.Guest.Friends,
|
||||
DurableVsAI: cfg.Durable.VsAI,
|
||||
DurableRandom: cfg.Durable.Random,
|
||||
DurableFriends: cfg.Durable.Friends,
|
||||
})
|
||||
}
|
||||
|
||||
// consoleUpdateLimits saves the active-game limits and refreshes the hot cache in place. Each
|
||||
// field is a per-kind cap: -1 is unlimited, 0 blocks the kind, a positive value caps concurrent games.
|
||||
func (s *Server) consoleUpdateLimits(c *gin.Context) {
|
||||
cfg := gamelimits.Config{
|
||||
Guest: gamelimits.Limits{
|
||||
VsAI: atoiForm(c, "guest_vs_ai"),
|
||||
Random: atoiForm(c, "guest_random"),
|
||||
Friends: atoiForm(c, "guest_friends"),
|
||||
},
|
||||
Durable: gamelimits.Limits{
|
||||
VsAI: atoiForm(c, "durable_vs_ai"),
|
||||
Random: atoiForm(c, "durable_random"),
|
||||
Friends: atoiForm(c, "durable_friends"),
|
||||
},
|
||||
}
|
||||
if err := validateGameLimits(cfg); err != nil {
|
||||
s.renderConsoleMessage(c, "Invalid", err.Error(), "/_gm/limits")
|
||||
return
|
||||
}
|
||||
if err := s.gamelimits.Update(c.Request.Context(), cfg); err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Saved", "game limits updated", "/_gm/limits")
|
||||
}
|
||||
|
||||
// validateGameLimits rejects a limit below -1 (the unlimited sentinel); -1, 0 and any positive count
|
||||
// are valid. It mirrors the backend.config CHECK so a bad value is refused with a clean message
|
||||
// rather than a raw database error.
|
||||
func validateGameLimits(cfg gamelimits.Config) error {
|
||||
for _, v := range []int{
|
||||
cfg.Guest.VsAI, cfg.Guest.Random, cfg.Guest.Friends,
|
||||
cfg.Durable.VsAI, cfg.Durable.Random, cfg.Durable.Friends,
|
||||
} {
|
||||
if v < gamelimits.Unlimited {
|
||||
return fmt.Errorf("a limit must be -1 (unlimited), 0, or a positive count")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// consoleRefund refunds a paid order in full at the operator's request. The operator performs the
|
||||
// actual money refund on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment);
|
||||
// this records it — a refund ledger row and a floor-0 chip revoke (never negative, D27). Idempotent.
|
||||
func (s *Server) consoleRefund(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + id.String()
|
||||
if s.payments == nil {
|
||||
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||
return
|
||||
}
|
||||
orderID, err := uuid.Parse(strings.TrimSpace(c.PostForm("order_id")))
|
||||
if err != nil {
|
||||
s.renderConsoleMessage(c, "Refund failed", "no order to refund", back)
|
||||
return
|
||||
}
|
||||
out, err := s.payments.RefundOrderFull(c.Request.Context(), orderID)
|
||||
if err != nil {
|
||||
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
|
||||
return
|
||||
}
|
||||
if out.AlreadyRefunded {
|
||||
s.renderConsoleMessage(c, "Already refunded", "this order was already refunded", back)
|
||||
return
|
||||
}
|
||||
s.publishBannerChange(id)
|
||||
msg := fmt.Sprintf("revoked %d chips", out.Revoked)
|
||||
if out.Loss > 0 {
|
||||
msg += fmt.Sprintf("; %d chips were already spent (recorded as a loss + abuse flag)", out.Loss)
|
||||
}
|
||||
s.renderConsoleMessage(c, "Refunded", msg, back)
|
||||
}
|
||||
|
||||
// consoleLedgerExport streams the entire append-only ledger as a CSV attachment for tax reporting
|
||||
// and rail reconciliation. The snapshot column carries the raw purchase/refund JSON.
|
||||
func (s *Server) consoleLedgerExport(c *gin.Context) {
|
||||
rows, err := s.payments.LedgerExport(c.Request.Context())
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
|
||||
w := csv.NewWriter(c.Writer)
|
||||
_ = w.Write([]string{
|
||||
"created_at", "account_id", "kind", "source", "origin", "chips_delta",
|
||||
"product_id", "order_id", "provider", "provider_payment_id", "snapshot",
|
||||
})
|
||||
for _, r := range rows {
|
||||
_ = w.Write([]string{
|
||||
r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID, r.Kind, r.Source, r.Origin,
|
||||
strconv.Itoa(r.ChipsDelta), r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Snapshot,
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
)
|
||||
|
||||
// The handlers below cover the game and chat operations the UI needs. They follow
|
||||
@@ -46,10 +47,11 @@ type historyDTO struct {
|
||||
}
|
||||
|
||||
// gameListDTO is the caller's games (active and finished) for the lobby. AtGameLimit
|
||||
// reports whether the caller has reached the simultaneous quick-game cap
|
||||
// (game.MaxActiveQuickGames); while it is true the lobby disables "New Game" and shows a
|
||||
// notice. It rides the lobby response — which the lobby re-fetches on every game event —
|
||||
// instead of a separate request.
|
||||
// reports whether the caller has reached its tier's active-game cap for the random
|
||||
// (quick auto-match) kind (the per-tier, per-kind limits in backend.config); while
|
||||
// it is true the lobby disables "New Game" and shows a notice. It rides the lobby
|
||||
// response — which the lobby re-fetches on every game event — instead of a separate
|
||||
// request.
|
||||
type gameListDTO struct {
|
||||
Games []gameDTO `json:"games"`
|
||||
AtGameLimit bool `json:"at_game_limit"`
|
||||
@@ -395,23 +397,13 @@ func (s *Server) handleSaveDraft(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, okResponse{OK: true})
|
||||
}
|
||||
|
||||
// atGameLimit reports whether uid already holds the maximum number of simultaneous
|
||||
// quick games (game.MaxActiveQuickGames). It backs both the lobby's at_game_limit flag
|
||||
// and the new-game gate; friend games created by invitation are not counted.
|
||||
func (s *Server) atGameLimit(ctx context.Context, uid uuid.UUID) (bool, error) {
|
||||
n, err := s.games.CountActiveQuickGames(ctx, uid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n >= game.MaxActiveQuickGames, nil
|
||||
}
|
||||
|
||||
// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid is at the
|
||||
// simultaneous quick-game cap, and reports whether the caller may proceed. It guards
|
||||
// every new-game entry point — quick auto-match/AI and invitation creation; accepting an
|
||||
// incoming invitation is deliberately exempt.
|
||||
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID) bool {
|
||||
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
|
||||
// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid has reached its
|
||||
// tier's active-game cap for kind (the per-tier, per-kind limits in backend.config), and reports
|
||||
// whether the caller may proceed. It guards the quick new-game entry points — auto-match (random)
|
||||
// and AI (vs_ai). Friend-invitation limits are enforced in the lobby's CreateInvitation, and
|
||||
// accepting an incoming invitation is deliberately exempt.
|
||||
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID, kind gamelimits.Kind) bool {
|
||||
atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, kind)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return false
|
||||
@@ -437,7 +429,7 @@ func (s *Server) handleListGames(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
|
||||
atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, gamelimits.KindRandom)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// Ledger/order provider tags per rail.
|
||||
const (
|
||||
providerRobokassa = "robokassa" // the direct (RUB) rail
|
||||
providerVK = "vk" // the VK Votes rail
|
||||
providerTelegram = "telegram" // the Telegram Stars (XTR) rail
|
||||
)
|
||||
|
||||
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
|
||||
type walletOrderRequest struct {
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
|
||||
// the provider's hosted-payment URL for the direct rail (empty for VK/Telegram, which settle
|
||||
// in-app). Rail names the settling rail so the gateway knows how to launch it; for the Telegram
|
||||
// Stars rail the gateway mints the invoice link from InvoiceTitle and InvoiceAmount (whole stars)
|
||||
// via the bot and returns it in RedirectURL.
|
||||
type walletOrderResponse struct {
|
||||
OrderID string `json:"order_id"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
Rail string `json:"rail"`
|
||||
InvoiceTitle string `json:"invoice_title,omitempty"`
|
||||
InvoiceAmount int64 `json:"invoice_amount,omitempty"`
|
||||
}
|
||||
|
||||
// handleWalletOrder opens a pending order to fund a chip pack and returns the rail's launch
|
||||
// details for the client: the Robokassa hosted-payment URL (direct), the order id for
|
||||
// VKWebAppShowOrderBox (VK), or the pack title and star amount the gateway mints into a Stars
|
||||
// invoice link (Telegram). It enforces the wallet gate and, on the direct rail, D36 (a purchase
|
||||
// requires a confirmed email anchor). No chips are credited here — only later, by the verified
|
||||
// provider callback.
|
||||
func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req walletOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid request body"}})
|
||||
return
|
||||
}
|
||||
productID, err := uuid.Parse(req.ProductID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid product id"}})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
cxt, present, err := s.walletGate(ctx, uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
switch cxt.Kind {
|
||||
case payments.SourceDirect:
|
||||
if s.robokassa.MerchantLogin == "" {
|
||||
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
|
||||
return
|
||||
}
|
||||
// D36: a direct purchase requires a confirmed email anchor.
|
||||
hasEmail, err := s.accounts.HasConfirmedEmail(ctx, uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
if !hasEmail {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
|
||||
return
|
||||
}
|
||||
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, walletOrderResponse{
|
||||
OrderID: res.OrderID.String(),
|
||||
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
||||
Rail: providerRobokassa,
|
||||
})
|
||||
case payments.SourceVK:
|
||||
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerVK)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
// The client passes the order id to VKWebAppShowOrderBox as the item; there is no redirect.
|
||||
c.JSON(http.StatusOK, walletOrderResponse{OrderID: res.OrderID.String(), Rail: providerVK})
|
||||
case payments.SourceTelegram:
|
||||
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerTelegram)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
// The gateway mints the Stars invoice link from the title and amount via the bot (only
|
||||
// the bot reaches Telegram) and returns it to the client as RedirectURL; the amount is in
|
||||
// whole stars (the XTR minor unit is the star). No chips are credited until the verified
|
||||
// successful_payment is forwarded back through the bot.
|
||||
c.JSON(http.StatusOK, walletOrderResponse{
|
||||
OrderID: res.OrderID.String(),
|
||||
Rail: providerTelegram,
|
||||
InvoiceTitle: res.Title,
|
||||
InvoiceAmount: res.Amount.Minor(),
|
||||
})
|
||||
default:
|
||||
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available yet"}})
|
||||
}
|
||||
}
|
||||
|
||||
// handleRobokassaResult is the verified Robokassa Result callback, reached only through the gateway
|
||||
// (which forwards the provider's form parameters as a JSON object on the internal, gateway-only
|
||||
// route). It verifies the Password2 signature, credits the matched order exactly once (idempotent,
|
||||
// and honoured even if the order expired), records a succeeded event, and answers Robokassa's
|
||||
// expected "OK<InvId>". A duplicate callback credits nothing but still answers OK.
|
||||
func (s *Server) handleRobokassaResult(c *gin.Context) {
|
||||
var params map[string]string
|
||||
if err := c.ShouldBindJSON(¶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))
|
||||
}
|
||||
}
|
||||
// The gateway echoes response verbatim to Robokassa, which requires the body "OK<InvId>".
|
||||
c.JSON(http.StatusOK, gin.H{"response": "OK" + v.Get("InvId")})
|
||||
}
|
||||
|
||||
// vkErrorResponse builds VK's error envelope for a payment callback.
|
||||
func vkErrorResponse(code int, msg string, critical bool) gin.H {
|
||||
return gin.H{"error": gin.H{"error_code": code, "error_msg": msg, "critical": critical}}
|
||||
}
|
||||
|
||||
// handleVKCallback is the VK Mini Apps payment callback, reached only through the gateway (which
|
||||
// verifies the VK signature and forwards the provider's parameters as a JSON object on the internal
|
||||
// route). It answers VK's two phases: get_item returns the ordered pack's title and vote price; a
|
||||
// chargeable order_status_change credits the matched order exactly once (idempotent on VK's order
|
||||
// id) and records a succeeded event. Both phases use VK's response envelope; the _test variants are
|
||||
// the sandbox notifications and are handled identically.
|
||||
func (s *Server) handleVKCallback(c *gin.Context) {
|
||||
var params map[string]string
|
||||
if err := c.ShouldBindJSON(¶ms); err != nil {
|
||||
c.JSON(http.StatusOK, vkErrorResponse(1, "bad request", true))
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
switch params["notification_type"] {
|
||||
case "get_item", "get_item_test":
|
||||
orderID, err := uuid.Parse(params["item"])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
|
||||
return
|
||||
}
|
||||
title, amount, err := s.payments.OrderItem(ctx, orderID)
|
||||
if err != nil {
|
||||
s.log.Warn("vk get_item lookup failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"response": gin.H{
|
||||
"item_id": orderID.String(),
|
||||
"title": title,
|
||||
"price": amount.Minor(),
|
||||
}})
|
||||
case "order_status_change", "order_status_change_test":
|
||||
if params["status"] != "chargeable" {
|
||||
c.JSON(http.StatusOK, vkErrorResponse(100, "unsupported status", false))
|
||||
return
|
||||
}
|
||||
orderID, err := uuid.Parse(params["item"])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
|
||||
return
|
||||
}
|
||||
price, err := strconv.ParseInt(params["item_price"], 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
|
||||
return
|
||||
}
|
||||
paid, err := payments.MoneyFromMinor(price, payments.CurrencyVote)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
|
||||
return
|
||||
}
|
||||
vkOrderID := params["order_id"]
|
||||
outcome, err := s.payments.Fund(ctx, orderID, providerVK, vkOrderID, paid)
|
||||
if err != nil {
|
||||
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
|
||||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
|
||||
s.log.Warn("vk order rejected", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.JSON(http.StatusOK, vkErrorResponse(100, "cannot process the order", false))
|
||||
return
|
||||
}
|
||||
s.log.Error("vk fund failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.JSON(http.StatusOK, vkErrorResponse(100, "internal error", false))
|
||||
return
|
||||
}
|
||||
if !outcome.AlreadyCredited {
|
||||
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
|
||||
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
|
||||
s.log.Error("record vk payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
// Echo VK's own order id; app_order_id is optional and our order id is a uuid, so omit it.
|
||||
appOrderID, _ := strconv.ParseInt(vkOrderID, 10, 64)
|
||||
c.JSON(http.StatusOK, gin.H{"response": gin.H{"order_id": appOrderID}})
|
||||
default:
|
||||
c.JSON(http.StatusOK, vkErrorResponse(100, "unknown notification", false))
|
||||
}
|
||||
}
|
||||
|
||||
// telegramPreCheckoutRequest is the bot's pre_checkout validation, forwarded through the gateway:
|
||||
// the order in the invoice payload and the amount and currency Telegram is about to charge.
|
||||
type telegramPreCheckoutRequest struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// telegramPreCheckoutResponse tells the bot whether to approve the pre_checkout_query; Reason is a
|
||||
// short message the bot surfaces to the payer on a decline.
|
||||
type telegramPreCheckoutResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// handleTelegramPreCheckout validates a Telegram Stars pre_checkout_query before the charge,
|
||||
// reached only through the gateway (the bot's ValidatePreCheckout, forwarded on the internal
|
||||
// route). It approves an order that exists, is not already paid (a reusable invoice link paid twice
|
||||
// is refused here, before any star moves) and whose amount and currency match. A malformed
|
||||
// reference is a decline, not an error.
|
||||
func (s *Server) handleTelegramPreCheckout(c *gin.Context) {
|
||||
var req telegramPreCheckoutRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
orderID, err := uuid.Parse(req.OrderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
|
||||
return
|
||||
}
|
||||
amount, err := payments.MoneyFromMinor(req.Amount, payments.Currency(req.Currency))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
|
||||
return
|
||||
}
|
||||
out, err := s.payments.ValidatePreCheckout(ctx, orderID, amount)
|
||||
if err != nil {
|
||||
s.log.Error("telegram pre_checkout validate failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
|
||||
return
|
||||
}
|
||||
reason := ""
|
||||
if !out.OK {
|
||||
// Localise the decline to the order account's preferred language (the reason shows in the
|
||||
// Telegram payment sheet). An unknown order has no account, so it falls back to English.
|
||||
lang := ""
|
||||
if s.accounts != nil && out.AccountID != (uuid.UUID{}) {
|
||||
if acc, aerr := s.accounts.GetByID(ctx, out.AccountID); aerr == nil {
|
||||
lang = acc.PreferredLanguage
|
||||
}
|
||||
}
|
||||
reason = telegramDeclineText(out.Reason, lang)
|
||||
}
|
||||
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: out.OK, Reason: reason})
|
||||
}
|
||||
|
||||
// telegramDeclineText renders a pre-checkout decline reason code in the payer's language (ru or
|
||||
// anything else falls back to English), for display in the Telegram payment sheet.
|
||||
func telegramDeclineText(code, lang string) string {
|
||||
ru := lang == "ru"
|
||||
switch code {
|
||||
case payments.PreCheckoutAlreadyPaid:
|
||||
if ru {
|
||||
return "Этот заказ уже оплачен."
|
||||
}
|
||||
return "This order has already been paid."
|
||||
case payments.PreCheckoutPriceChanged:
|
||||
if ru {
|
||||
return "Цена изменилась — начните покупку заново."
|
||||
}
|
||||
return "The price has changed; please start the purchase again."
|
||||
default:
|
||||
if ru {
|
||||
return "Этот заказ больше недоступен."
|
||||
}
|
||||
return "This order is no longer available."
|
||||
}
|
||||
}
|
||||
|
||||
// telegramPaymentRequest is a completed Stars payment forwarded from the bot's outbox through the
|
||||
// gateway: the order, the Telegram charge id (the idempotency key), the stars paid, and the payer.
|
||||
type telegramPaymentRequest struct {
|
||||
OrderID string `json:"order_id"`
|
||||
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
TelegramUserID int64 `json:"telegram_user_id"`
|
||||
}
|
||||
|
||||
// telegramPaymentResponse reports the durable outcome to the bot: Credited is true once the order
|
||||
// is credited (or already was). A false with a 200 means the payment was recorded but not creditable
|
||||
// (the bot drops it); a 5xx means a transient failure the bot retries.
|
||||
type telegramPaymentResponse struct {
|
||||
Credited bool `json:"credited"`
|
||||
}
|
||||
|
||||
// handleTelegramPayment credits a completed Telegram Stars payment, reached only through the gateway
|
||||
// (the bot's ForwardPayment, forwarded on the internal route). It credits the matched order exactly
|
||||
// once (idempotent on the Telegram charge id, honoured even if the order expired) and records a
|
||||
// succeeded event. A permanent rejection (unknown order, amount mismatch) is answered 200 with
|
||||
// Credited=false so the bot stops retrying a payment it cannot place; a transient failure is a 5xx
|
||||
// the bot retries.
|
||||
func (s *Server) handleTelegramPayment(c *gin.Context) {
|
||||
var req telegramPaymentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
abortBadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
orderID, err := uuid.Parse(req.OrderID)
|
||||
if err != nil {
|
||||
s.log.Warn("telegram payment: bad order id", zap.String("charge", req.TelegramPaymentChargeID))
|
||||
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
|
||||
return
|
||||
}
|
||||
paid, err := payments.MoneyFromMinor(req.Amount, payments.CurrencyStar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
outcome, err := s.payments.Fund(ctx, orderID, providerTelegram, req.TelegramPaymentChargeID, paid)
|
||||
if err != nil {
|
||||
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
|
||||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
|
||||
// The star charge already happened but the order cannot be placed; record it loudly for
|
||||
// an operator and tell the bot to stop retrying (an operator refunds or credits by hand).
|
||||
s.log.Error("telegram payment rejected (charge taken, not credited)",
|
||||
zap.String("order", orderID.String()), zap.String("charge", req.TelegramPaymentChargeID), zap.Error(err))
|
||||
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
|
||||
return
|
||||
}
|
||||
s.log.Error("telegram fund failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
|
||||
return
|
||||
}
|
||||
if !outcome.AlreadyCredited {
|
||||
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
|
||||
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
|
||||
s.log.Error("record telegram payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: true})
|
||||
}
|
||||
@@ -133,9 +133,6 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
|
||||
}
|
||||
inviteeIDs = append(inviteeIDs, id)
|
||||
}
|
||||
if !s.ensureUnderGameLimit(c, uid) {
|
||||
return
|
||||
}
|
||||
inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/gamelimits"
|
||||
)
|
||||
|
||||
// The /api/v1/user/* endpoints require X-User-ID (RequireUserID middleware). The
|
||||
@@ -189,13 +190,15 @@ func (s *Server) handleEnqueue(c *gin.Context) {
|
||||
if !s.ensureVariantAllowed(c, uid, variant.String()) {
|
||||
return
|
||||
}
|
||||
if !s.ensureUnderGameLimit(c, uid) {
|
||||
return
|
||||
}
|
||||
kind := gamelimits.KindRandom
|
||||
enter := s.matchmaker.Enqueue
|
||||
if req.VsAI {
|
||||
kind = gamelimits.KindVsAI
|
||||
enter = s.matchmaker.StartVsAI
|
||||
}
|
||||
if !s.ensureUnderGameLimit(c, uid, kind) {
|
||||
return
|
||||
}
|
||||
res, err := enter(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
@@ -20,11 +21,15 @@ type walletSegmentDTO struct {
|
||||
|
||||
// walletDTO is the user-facing wallet: the context-visible chip segments and the
|
||||
// context-applicable benefits (the no-ads term or forever flag, and the available hints).
|
||||
// RewardChips is the chips a rewarded-video view earns in the current context (0 when rewarded is
|
||||
// unavailable here — outside VK, or unconfigured); the client shows the "watch for chips" button
|
||||
// only when it is positive.
|
||||
type walletDTO struct {
|
||||
Segments []walletSegmentDTO `json:"segments"`
|
||||
AdsForever bool `json:"ads_forever"`
|
||||
AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term
|
||||
Hints int `json:"hints"`
|
||||
RewardChips int `json:"reward_chips"`
|
||||
}
|
||||
|
||||
// walletBuyRequest is the POST body of a chip spend: the product to buy with chips.
|
||||
@@ -108,7 +113,8 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
|
||||
}
|
||||
|
||||
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
|
||||
// trusted execution context.
|
||||
// trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
|
||||
// unconfigured), which gates the client's "watch for chips" button.
|
||||
func (s *Server) handleWallet(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
@@ -125,7 +131,13 @@ func (s *Server) handleWallet(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, walletDTOFrom(view))
|
||||
dto := walletDTOFrom(view)
|
||||
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr != nil {
|
||||
s.log.Warn("wallet: reward payout read failed", zap.String("account", uid.String()), zap.Error(perr))
|
||||
} else {
|
||||
dto.RewardChips = payout
|
||||
}
|
||||
c.JSON(http.StatusOK, dto)
|
||||
}
|
||||
|
||||
// handleWalletBuy spends chips on a chip-priced value and returns the updated wallet. It is
|
||||
@@ -162,3 +174,54 @@ 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).
|
||||
type walletRewardRequest struct {
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
// handleWalletReward credits a rewarded-video view's chips to the VK segment, client-attested and
|
||||
// bounded by the config daily cap. It is VK-only and idempotent on the nonce; a reached cap answers
|
||||
// reward_capped, and an unconfigured payout answers reward_unavailable. On success it returns the
|
||||
// updated wallet (like a spend).
|
||||
func (s *Server) handleWalletReward(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req walletRewardRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Nonce == "" {
|
||||
abortBadRequest(c, "nonce is required")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
cxt, present, err := s.walletGate(ctx, uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
outcome, err := s.payments.CreditReward(ctx, uid, cxt, present, req.Nonce)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
if outcome.Capped {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_capped", Message: "daily reward limit reached"}})
|
||||
return
|
||||
}
|
||||
if outcome.Chips == 0 && !outcome.AlreadyCredited {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_unavailable", Message: "rewarded video is not available"}})
|
||||
return
|
||||
}
|
||||
view, err := s.payments.Wallet(ctx, uid, cxt, present)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
view2 := walletDTOFrom(view)
|
||||
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr == nil {
|
||||
view2.RewardChips = payout
|
||||
}
|
||||
c.JSON(http.StatusOK, view2)
|
||||
}
|
||||
|
||||
@@ -25,12 +25,14 @@ 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"
|
||||
"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"
|
||||
@@ -99,6 +101,10 @@ type Deps struct {
|
||||
// console routes are registered when the wallet surface lands. A nil Payments
|
||||
// omits them.
|
||||
Payments *payments.Service
|
||||
// GameLimits is the per-tier, per-kind active-game limit config, cached in memory. The
|
||||
// game domain reads it through game.Service (SetGameLimits) for the new-game gate; the admin
|
||||
// console reads and edits it here. A nil GameLimits omits the limits console section.
|
||||
GameLimits *gamelimits.Service
|
||||
// Notifier publishes live-event intents — here the banner-eligibility re-poll
|
||||
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
||||
// them (notify.Nop).
|
||||
@@ -109,6 +115,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 +145,8 @@ type Server struct {
|
||||
banview *banview.View
|
||||
ads *ads.Service
|
||||
payments *payments.Service
|
||||
gamelimits *gamelimits.Service
|
||||
robokassa robokassa.Config
|
||||
notifier notify.Publisher
|
||||
console *adminconsole.Renderer
|
||||
exportKey []byte
|
||||
@@ -189,6 +200,8 @@ func New(addr string, deps Deps) *Server {
|
||||
banview: deps.BanView,
|
||||
ads: deps.Ads,
|
||||
payments: deps.Payments,
|
||||
gamelimits: deps.GameLimits,
|
||||
robokassa: deps.Robokassa,
|
||||
notifier: notifier,
|
||||
renderer: deps.Renderer,
|
||||
http: &http.Server{Addr: addr, Handler: engine},
|
||||
|
||||
@@ -65,6 +65,12 @@ func (svc *Service) IssueFriendCode(ctx context.Context, accountID uuid.UUID) (F
|
||||
// ErrRequestBlocked (a block stands between the pair). A redeem bypasses any prior
|
||||
// decline between the two: it clears the old row and writes a fresh friendship.
|
||||
func (svc *Service) RedeemFriendCode(ctx context.Context, redeemerID uuid.UUID, code string) (uuid.UUID, error) {
|
||||
// A guest cannot use friends: a durable-account feature (the UI hides it).
|
||||
if acc, err := svc.accounts.GetByID(ctx, redeemerID); err != nil {
|
||||
return uuid.UUID{}, err
|
||||
} else if acc.IsGuest {
|
||||
return uuid.UUID{}, ErrGuestForbidden
|
||||
}
|
||||
issuerID, codeID, err := svc.store.liveFriendCodeByHash(ctx, hashFriendCode(code), svc.now())
|
||||
if err != nil {
|
||||
return uuid.UUID{}, err
|
||||
|
||||
@@ -51,6 +51,13 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
|
||||
if requesterID == addresseeID {
|
||||
return ErrSelfRelation
|
||||
}
|
||||
// A guest cannot use friends — friends are a durable-account feature; the UI hides the
|
||||
// flow, this is the server source of truth.
|
||||
if acc, err := svc.accounts.GetByID(ctx, requesterID); err != nil {
|
||||
return err
|
||||
} else if acc.IsGuest {
|
||||
return ErrGuestForbidden
|
||||
}
|
||||
iBlockThem, err := svc.store.blockExists(ctx, requesterID, addresseeID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -42,6 +42,12 @@ func (svc *Service) RequestInGame(ctx context.Context, requesterID, addresseeID,
|
||||
if requesterID == addresseeID {
|
||||
return ErrSelfRelation
|
||||
}
|
||||
// A guest cannot use friends: a durable-account feature (the UI hides it).
|
||||
if acc, err := svc.accounts.GetByID(ctx, requesterID); err != nil {
|
||||
return err
|
||||
} else if acc.IsGuest {
|
||||
return ErrGuestForbidden
|
||||
}
|
||||
isRobot, err := svc.accounts.IsRobot(ctx, addresseeID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -59,6 +59,9 @@ var (
|
||||
// ErrRequestBlocked is returned when the addressee does not accept friend
|
||||
// requests (their global toggle) or a block stands between the two accounts.
|
||||
ErrRequestBlocked = errors.New("social: the addressee is not accepting friend requests")
|
||||
// ErrGuestForbidden is returned when a guest attempts a durable-only action (send a friend
|
||||
// request, redeem a friend code); friends are a durable-account feature.
|
||||
ErrGuestForbidden = errors.New("social: guests cannot use friends")
|
||||
// ErrRequestNotFound is returned when no pending friend request matches.
|
||||
ErrRequestNotFound = errors.New("social: no pending friend request")
|
||||
// ErrNoSharedGame is returned when a friend request targets someone the
|
||||
|
||||
@@ -125,6 +125,17 @@ GATEWAY_VK_APP_SECRET=
|
||||
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
||||
GATEWAY_VK_ID_CLIENT_SECRET=
|
||||
|
||||
# --- Payments: Robokassa (direct RUB rail) ----------------------------------
|
||||
# The shop's merchant login + the two pass phrases (Password1 signs the launch request,
|
||||
# Password2 signs/verifies the Result callback). An empty login leaves the direct rail off.
|
||||
# ROBOKASSA_TEST=1 runs test payments against the shop's TEST pass phrases (no real money);
|
||||
# empty/0 is live. Mapped in compose to BACKEND_ROBOKASSA_*; Gitea TEST_/PROD_ secrets, with
|
||||
# PROD_BACKEND_ROBOKASSA_TEST a variable so go-live is a flag flip, not a secret redeploy.
|
||||
ROBOKASSA_MERCHANT_LOGIN=
|
||||
ROBOKASSA_PASSWORD1=
|
||||
ROBOKASSA_PASSWORD2=
|
||||
ROBOKASSA_TEST=
|
||||
|
||||
# --- Gateway anti-abuse ------------------------------------------------------
|
||||
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
||||
# where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value
|
||||
|
||||
+34
-12
@@ -76,6 +76,9 @@ compose binds from this directory.
|
||||
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
||||
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
|
||||
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
||||
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. |
|
||||
| `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) |
|
||||
| `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. |
|
||||
|
||||
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
||||
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
||||
@@ -236,6 +239,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 +262,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 +281,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 +305,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=<ts>" for PITR
|
||||
docker exec -u postgres pitr-drill pgbackrest --stanza=scrabble --pg1-path=/var/lib/postgresql/data --type=immediate restore
|
||||
docker exec -u postgres pitr-drill pg_ctl -D /var/lib/postgresql/data -w start
|
||||
docker exec -u postgres pitr-drill psql -U scrabble -d scrabble -c "SELECT count(*) FROM backend.accounts;" # verify data intact
|
||||
docker rm -fv pitr-drill; rm -f /tmp/drill.env # wipe the restored real data
|
||||
```
|
||||
|
||||
The target holds **real money + personal data** while it exists — keep it network-isolated and
|
||||
**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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,10 @@ services:
|
||||
# the manage-topics and delete-messages rights.
|
||||
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
|
||||
TELEGRAM_SUPPORT_STATE_DIR: /data
|
||||
# The Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
|
||||
# Stars rail; empty disables it. The rail stays inert until a chip pack carries an XTR (Stars)
|
||||
# price, so it is safe to leave on — seeding a Stars price in the admin is the real go-live.
|
||||
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_DIR:-/data}
|
||||
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
|
||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
||||
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
|
||||
|
||||
@@ -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
|
||||
@@ -200,6 +207,9 @@ services:
|
||||
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
|
||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||
# The rewarded-ad test stub (1 = a toast instead of a real ad; the test contour only, empty
|
||||
# elsewhere so production shows real ads).
|
||||
VITE_ADS_STUB: ${VITE_ADS_STUB:-}
|
||||
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
|
||||
VERSION: ${APP_VERSION:-dev}
|
||||
restart: unless-stopped
|
||||
@@ -378,6 +388,10 @@ services:
|
||||
# set the bot must be an admin there with the manage-topics and delete-messages rights.
|
||||
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
|
||||
TELEGRAM_SUPPORT_STATE_DIR: /data
|
||||
# The Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
|
||||
# Stars rail (pre_checkout + successful_payment + the durable outbox); empty disables it. The
|
||||
# rail stays inert until a chip pack carries an XTR (Stars) price, so it is safe to leave on.
|
||||
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_DIR:-/data}
|
||||
# The optional standalone promo bot (its own token) answering /start with a button
|
||||
# into the main bot's app. Empty disables it; when set it needs the main bot's
|
||||
# @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ export APP_VERSION='$APP_VERSION'
|
||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||
export ROBOKASSA_MERCHANT_LOGIN='$ROBOKASSA_MERCHANT_LOGIN'
|
||||
export ROBOKASSA_PASSWORD1='$ROBOKASSA_PASSWORD1'
|
||||
export ROBOKASSA_PASSWORD2='$ROBOKASSA_PASSWORD2'
|
||||
export ROBOKASSA_TEST='$ROBOKASSA_TEST'
|
||||
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
|
||||
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
|
||||
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
|
||||
|
||||
+33
-14
@@ -650,18 +650,30 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
|
||||
game is `open` the starter may move on their turn, but resign, chat and nudge are
|
||||
refused (no opponent yet) and the lobby and opponent card show a "searching for
|
||||
opponent" placeholder.
|
||||
- **Simultaneous-game cap**: a player may hold at most `game.MaxActiveQuickGames`
|
||||
(**10**) active quick games. `game.Service.CountActiveQuickGames` counts the games
|
||||
seating the account in status `active` or `open` **without** a linked
|
||||
`game_invitations` row — friend games are excluded, and hidden games still occupy a
|
||||
slot, so it is a dedicated count rather than a filter over the lobby list. The backend
|
||||
**gate** (`Server.ensureUnderGameLimit`) refuses **both** new-game entry points at the
|
||||
cap — `POST /lobby/enqueue` and `POST /invitations` — with **409 `game_limit_reached`**;
|
||||
**accepting** an invitation (`POST /invitations/:id/accept`) is never gated, so friend
|
||||
games are capped only at initiation. The lobby learns the state from a boolean
|
||||
**`at_game_limit`** carried on the `games.list` response — the lobby already re-fetches
|
||||
that on entry and on every game event, so the flag needs no separate request or
|
||||
per-event payload; while it is set the client disables **New Game** and shows a notice.
|
||||
- **Active-game caps (per tier, per kind)**: a player's simultaneous unfinished games are
|
||||
capped per **kind** — `vs_ai`, `random` (quick auto-match), `friends` — with independent
|
||||
**guest** and **durable-account** tiers. The caps live in the single-row `backend.config`
|
||||
table (`-1` = unlimited), read once at boot into an in-memory cache (`internal/gamelimits`)
|
||||
and refreshed in place when an operator edits them in the admin (`/_gm/limits`) — so a
|
||||
login or game-create never queries the table. The seeded defaults are guest **1 vs_ai / 1
|
||||
random / 0 friends** and durable **10 / 10 / 10** (this replaced the earlier flat
|
||||
`MaxActiveQuickGames`=10 combined cap). Each game is tagged with `games.game_kind` on
|
||||
creation (0 = an untagged game, never gated). `game.Service.AtGameLimit` resolves the
|
||||
account's tier, then counts its `active`/`open` games of the kind (hidden games still
|
||||
occupy a slot) against the cap. The gate (`Server.ensureUnderGameLimit`) refuses
|
||||
`POST /lobby/enqueue` — random or vs_ai per the request — with **409 `game_limit_reached`**;
|
||||
the `POST /invitations` (friends) path enforces the durable friends cap the same way and
|
||||
refuses a **guest** outright with **403** (guests cannot use friends — the same guest gate
|
||||
covers friend requests and friend-code redemption). **Accepting** an invitation
|
||||
(`POST /invitations/:id/accept`) is never gated, so friend games are capped only at
|
||||
initiation. The client counts its lobby games per kind against the per-tier caps it reads on
|
||||
the profile (`Profile.game_limits`, carrying `GameView.kind`), and locks a capped kind's
|
||||
New-Game start — a 🔒 outline button that opens a prompt instead of a game: a sign-in funnel
|
||||
for a guest, a "finish a current game first" notice for a durable account (native inside
|
||||
Telegram, an in-app modal elsewhere), lifting when the profile is re-fetched after a
|
||||
guest→durable upgrade. The `games.list` `at_game_limit` boolean (now the random-kind cap) is
|
||||
still carried but no longer drives the UI — the per-kind start lock superseded the old
|
||||
New-Game-tab disable.
|
||||
- **Friends**: two add paths over one `friendships` table. A **one-time
|
||||
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
|
||||
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
|
||||
@@ -971,7 +983,13 @@ the console; the backend calls them on the **gateway's bot-link relay**, which f
|
||||
to the bot and **awaits its delivery ack** (so the console still reports delivered/not). Beyond
|
||||
messages the same bot-link carries a **chat-gate control path** — a `ChatGate` command sets a user's
|
||||
write access in the moderated discussion chat and the bot's unary `ResolveChatEligibility` resolves a
|
||||
joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). An optional
|
||||
joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). It also
|
||||
carries the **Telegram Stars payment path** (§payments): a `CreateInvoice` command has the bot mint a
|
||||
`createInvoiceLink` (XTR) and return it in the Ack; the bot's unary `ValidatePreCheckout` gates a
|
||||
`pre_checkout_query` against the intake (declining an already-paid reusable invoice before the
|
||||
charge); and its unary `ForwardPayment` delivers a completed `successful_payment` — durably queued in
|
||||
a bot-side **SQLite outbox** (`platform/telegram/internal/outbox`, re-driven on restart) — which the
|
||||
gateway proxies to the backend intake, credited once (idempotent on `telegram_payment_charge_id`). An optional
|
||||
**standalone promo bot** runs in the bot container (`TELEGRAM_PROMO_BOT_TOKEN`): a second bot
|
||||
answering `/start` with a URL button into the **main** bot's Mini App (`?startapp`, since a `web_app`
|
||||
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
|
||||
@@ -1328,7 +1346,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
|
||||
|
||||
+11
-8
@@ -190,14 +190,17 @@ Mini App, the link only opens the app and the recipient enters the copied code b
|
||||
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||
expires after seven days.
|
||||
|
||||
**Simultaneous-game cap.** A player may hold at most **10 active quick games** at once —
|
||||
counting both in-progress and still-searching auto-match/AI games, but **not** friend games
|
||||
created by invitation. At the cap the **New Game** button is disabled (greyed) and a plain,
|
||||
low-emphasis line under the lists reads *"You've reached the simultaneous games limit."*; both
|
||||
clear automatically once an active game finishes and the count drops below ten. The cap blocks
|
||||
**starting** any game (a quick game or a friend invitation, which share the New Game entry), but
|
||||
**accepting an incoming invitation is never blocked** — so friend games are capped from the other
|
||||
end (you cannot initiate one while at the cap) while you can always join a game you are invited to.
|
||||
**Active-game caps (per kind).** A player's simultaneous unfinished games are capped **per kind** —
|
||||
against the AI, in a random match, and by friend invitation — with separate limits for a **guest**
|
||||
and a signed-in (durable) account, tuned by the operator without a release. A **guest** may hold **1**
|
||||
game against the AI and **1** random match at once and cannot start friend games at all; a signed-in
|
||||
account holds up to **10** of each kind. Only in-progress and still-searching games count; a finished
|
||||
game frees its slot, and games already in progress are never interrupted. On the **New Game** screen a
|
||||
start whose kind is at its cap shows a **🔒** and, when tapped, opens a short prompt instead of a game:
|
||||
a **guest** is invited to sign in or create an account (to play several games at once), a signed-in
|
||||
player is told to finish a current game first. **Accepting an incoming invitation is never blocked** —
|
||||
friend games are capped only when you start one. The server enforces every cap regardless of the
|
||||
client, and refuses a guest's friend request, friend code or invitation outright.
|
||||
|
||||
### Playing a game
|
||||
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
||||
|
||||
+12
-9
@@ -197,15 +197,18 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||
ответа приглашение протухает через семь дней.
|
||||
|
||||
**Лимит одновременных партий.** Игрок может держать одновременно не более **10 активных
|
||||
быстрых партий** — считаются и идущие, и ещё ищущие соперника авто-подбор/AI-партии, но **не**
|
||||
игры с друзьями, созданные приглашением. На лимите кнопка **«Новая игра»** становится неактивной
|
||||
(серой), а под списками появляется ненавязчивая строка *«Вы достигли лимита одновременных
|
||||
партий»*; и кнопка, и надпись снимаются автоматически, как только активная партия завершается и
|
||||
счётчик опускается ниже десяти. Лимит запрещает **создавать** любую партию (быструю или
|
||||
приглашение в игру с друзьями — у них общий вход «Новая игра»), но **принимать входящее
|
||||
приглашение никогда не запрещено** — так игры с друзьями ограничиваются «с другого конца» (на
|
||||
лимите их нельзя инициировать), а присоединиться к партии по приглашению можно всегда.
|
||||
**Лимит одновременных партий (по видам).** Число одновременных незавершённых партий игрока
|
||||
ограничено **по видам** — против AI, в случайном подборе и по приглашению друзей — с раздельными
|
||||
лимитами для **гостя** и для вошедшего (постоянного) аккаунта; оператор настраивает их без релиза.
|
||||
**Гость** может держать **1** партию против AI и **1** случайную одновременно и вовсе не может
|
||||
создавать игры с друзьями; вошедший аккаунт — до **10** каждого вида. Считаются только идущие и ещё
|
||||
ищущие соперника партии; завершённая освобождает слот, а уже идущие партии никогда не прерываются.
|
||||
На экране **«Новая игра»** старт того вида, что достиг лимита, показывает **🔒** и по нажатию
|
||||
открывает короткое сообщение вместо партии: **гостю** предлагают войти или создать учётную запись
|
||||
(чтобы играть несколько партий сразу), вошедшему — сначала завершить текущую. **Принимать входящее
|
||||
приглашение никогда не запрещено** — игры с друзьями ограничиваются только при создании. Сервер
|
||||
применяет все лимиты независимо от клиента и напрямую отклоняет заявку в друзья, код друга или
|
||||
приглашение от гостя.
|
||||
|
||||
### Игровой процесс
|
||||
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
|
||||
|
||||
+63
-26
@@ -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
|
||||
@@ -219,12 +221,21 @@ the amount, credits, marks `paid`. **Idempotency:** dedup by `(provider, provide
|
||||
valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation —
|
||||
the money is real, the chips are owed). The user sees only successful purchases.
|
||||
|
||||
**TG bot outbox.** `successful_payment` reaches the bot only (Bot API, not the Mini App), and
|
||||
the bot host is weak and can lose connectivity, so the bot is a durable link. Store-and-
|
||||
forward on **SQLite** on the bot's disk: receive → store → ack the Telegram update → forward
|
||||
to payments (idempotent, dedup by `telegram_payment_charge_id`) → ack → mark `forwarded`.
|
||||
Retries with backoff; re-drives undelivered on restart. At-least-once delivery + idempotent
|
||||
intake = credited exactly once.
|
||||
**TG Stars.** Only the **bot** reaches Telegram, so the whole rail funnels through the reverse
|
||||
mTLS **bot-link** (bot ↔ gateway; the bot cannot dial the backend). The invoice is minted by the
|
||||
bot: on the order path the gateway sends a `CreateInvoice` command and the bot returns a
|
||||
`createInvoiceLink` (XTR) in its Ack, which the Mini App opens with `WebApp.openInvoice`. Before any
|
||||
star moves the bot answers `pre_checkout_query` via a bot→gateway `ValidatePreCheckout` unary
|
||||
(backed by the intake): approve only if the order exists, is still creditable and is **not already
|
||||
paid** — a Stars invoice link is reusable, so this gate is the one place a repeat payment is stopped
|
||||
before the charge; the decline reason is localised to the order account's language.
|
||||
|
||||
`successful_payment` reaches the bot only (Bot API, not the Mini App), and the bot host is weak and
|
||||
can lose connectivity, so the bot is a durable link. Store-and-forward on **SQLite** on the bot's
|
||||
disk (`internal/outbox`): persist on receipt (idempotent on `telegram_payment_charge_id`) → forward
|
||||
over the bot-link (a `ForwardPayment` unary; the gateway proxies to the intake) → on a durable
|
||||
response, mark `forwarded`. Re-drives undelivered on restart and on a periodic tick. At-least-once
|
||||
delivery + idempotent intake (dedup by `telegram_payment_charge_id`) = credited exactly once.
|
||||
|
||||
**Events.** The payments domain writes `payment_events` (succeeded / failed / refunded); a
|
||||
dispatcher fans out over channels — the live gRPC stream if the user is in-app, else the
|
||||
@@ -232,14 +243,22 @@ existing `botlink` push / email relay. "Payment failed" (an **active** provider
|
||||
an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (email / bot
|
||||
message).
|
||||
|
||||
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. An admin may
|
||||
issue a **manual** refund (edge case: a user demands one shortly after paying / closes their
|
||||
account — tie into `accountdelete`, which already preserves messages). **External** refunds
|
||||
(chargeback / store decision / TG / VK) are honoured: the system takes the `refunded` event,
|
||||
**best-effort** revokes the benefit (never going negative; if chips were already spent, it
|
||||
records the loss + an abuse flag), and writes to the ledger. The ledger is **export-ready**
|
||||
for future tax reporting and Robokassa reconciliation (reconciliation itself is not built
|
||||
yet; the schema stays compatible).
|
||||
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. Refunds are
|
||||
**admin-triggered** (the E7 console), since no rail pushes an unsolicited refund: Robokassa
|
||||
refunds run through its refund API / merchant cabinet (auto-polling a rail's refund status is a
|
||||
deferred worker, not worth it at low chargeback volume), VK refunds are handled by support, and
|
||||
Telegram Stars refunds are issued with `refundStarPayment`. All of them converge on one engine —
|
||||
the `Refund` method (`internal/payments`): it matches the paid order, appends a **refund** ledger
|
||||
row (idempotent on `(provider, provider_refund_id)` — the refund id is distinct from the fund's
|
||||
payment id, so the two rows coexist under the same partial-unique index), and **best-effort revokes
|
||||
the funded chips floored at 0** (never negative — D27, `balances_chips_chk`). When the chips were
|
||||
already spent, the unrecoverable remainder is recorded as a per-account **loss + abuse flag**
|
||||
(`payments.account_risk`, read by the E7 report). The refund ledger row's chip delta is what was
|
||||
actually reclaimed, so the ledger stays reconcilable against the balance; the **full** reversal
|
||||
(money, original chips, loss) rides in the row's snapshot. The order stays `paid` — the refund lives
|
||||
in the ledger + a `refunded` payment event, not in the order status. A duplicate refund reverses
|
||||
nothing. The ledger is **export-ready** for future tax reporting and reconciliation (the reconciler
|
||||
itself is not built yet; the schema stays compatible).
|
||||
|
||||
## 10. Ads
|
||||
|
||||
@@ -249,16 +268,32 @@ ruble-paying in-app network exists. The ad provider is behind an **abstraction**
|
||||
network for other platforms slots in without rework. Crypto-payout networks (AdsGram/AdMob)
|
||||
are rejected — no legal ruble income for a self-employed (НПД) developer.
|
||||
|
||||
**Rewarded** (voluntary video for chips) credits chips through payments on the network's
|
||||
**server verify callback** (client not believed, like a payment). On-launch anti-fraud is
|
||||
the provider's verify only (no own daily cap yet; the abstraction allows adding caps later).
|
||||
**Rewarded** (voluntary video for chips) credits chips through payments. **VK Mini App ads expose
|
||||
only a client-side watch result** (`VKWebAppShowNativeAds` → `data.result`) — there is no
|
||||
server-to-server verify — so a rewarded credit is **client-attested** (D29 amended: server verify is
|
||||
not possible on VK). The anti-abuse (and economic) guard is a **server-side daily and hourly cap**
|
||||
(config `reward_daily_cap` / `reward_hourly_cap`, default 50 / 10), which bounds a forger who skips
|
||||
the ad and calls the credit endpoint directly and, as importantly, limits free rewarded chips so a
|
||||
player who wants more **buys**. The credit is idempotent on a client nonce and order-less; the payout
|
||||
is config (`rewarded_payout_chips`, default 0 = rewarded off until set). Rewarded is VK-only and is
|
||||
not suppressed by no-ads (D9). A future network that offers a server verify slots in behind the ads
|
||||
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 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).
|
||||
- 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.
|
||||
|
||||
@@ -284,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
|
||||
|
||||
@@ -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, только просмотр.
|
||||
@@ -158,20 +163,40 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
закладываем **абстракцией** (будущая крутилка для других платформ встроится без
|
||||
переделки). Крипто-провайдеры (AdsGram/AdMob) отвергнуты — нет легального рублёвого
|
||||
дохода самозанятому (санкции + НПД не учитывает крипту).
|
||||
- **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments по
|
||||
**серверному verify-колбэку** рекламной сети (клиенту не верим, как платёж). Не
|
||||
- **D29. Rewarded (добровольный ролик за Фишки)** начисляет Фишки через payments. Не
|
||||
гасится «без рекламы». Сколько Фишек за просмотр — в блоке экономики.
|
||||
**АМЕНД (E6, по факту реализации):** у VK Mini App серверного verify-колбэка **нет** —
|
||||
`VKWebAppShowNativeAds` отдаёт только клиентский `data.result`. Поэтому начисление
|
||||
**client-attested**, а защита (и экономический рычаг) — **серверный дневной и часовой кап**
|
||||
(config `reward_daily_cap` / `reward_hourly_cap`, дефолт 50 / 10) + идемпотентность по клиентскому
|
||||
nonce. Сеть с серверным verify встроится за ads-абстракцией позже.
|
||||
- **D30. Частота навязанного interstitial** (конфигурируемые серверные значения):
|
||||
глобальный кулдаун **на юзера сквозь все партии**, дефолт **5 мин**; **vs_ai — 30 мин**
|
||||
(соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода
|
||||
**независимо** от основного кулдауна, со своим кулдауном **1 мин**. Оффлайн — только
|
||||
баннер. Уважать собственные лимиты частоты VK.
|
||||
**АМЕНД (E6, по факту реализации):** гейт **зеркальный** — сервер отдаёт кулдауны и `suppressed`
|
||||
в профиле (`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-бенефиту,
|
||||
**применимому в текущем контексте**, а не по одному глобальному флагу. 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-метод**
|
||||
|
||||
+62
-27
@@ -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 сервер активирует только одноимённый сегмент. Держится на
|
||||
@@ -220,12 +222,22 @@ provider_payment_id)`.
|
||||
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
|
||||
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.
|
||||
|
||||
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а
|
||||
хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на
|
||||
**SQLite** на диске бота: получил → сохранил → подтвердил апдейт Telegram → форвардит в
|
||||
платёжный домен (идемпотентно, дедуп по `telegram_payment_charge_id`) → ack → пометил
|
||||
`forwarded`. Ретраи с backoff; дореталивает недоставленное при рестарте. Доставка
|
||||
at-least-once + идемпотентный приём = начисление ровно один раз.
|
||||
**TG Stars.** До Telegram дотягивается только **бот**, поэтому весь рельс идёт через обратный
|
||||
mTLS **bot-link** (бот ↔ gateway; напрямую к бэкенду бот не ходит). Инвойс создаёт бот: на пути
|
||||
заказа gateway шлёт команду `CreateInvoice`, а бот возвращает `createInvoiceLink` (XTR) в Ack,
|
||||
который Mini App открывает через `WebApp.openInvoice`. До списания звёзд бот отвечает на
|
||||
`pre_checkout_query` через унарный вызов бот→gateway `ValidatePreCheckout` (за ним — приём): одобрить,
|
||||
только если заказ существует, ещё оплачиваем и **не оплачен ранее** — ссылка Stars-инвойса
|
||||
переиспользуема, так что этот гейт — единственное место, где повторная оплата отсекается **до**
|
||||
списания; текст отказа локализован в язык аккаунта заказа.
|
||||
|
||||
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а хост бота
|
||||
слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на **SQLite** на диске
|
||||
бота (`internal/outbox`): сохранил при получении (идемпотентно по `telegram_payment_charge_id`) →
|
||||
форвардит по bot-link (унарный `ForwardPayment`; gateway проксирует в приём) → при durable-ответе
|
||||
пометил `forwarded`. Дореталивает недоставленное при рестарте и по периодическому тику. Доставка
|
||||
at-least-once + идемпотентный приём (дедуп по `telegram_payment_charge_id`) = начисление ровно один
|
||||
раз.
|
||||
|
||||
**События.** Платёжный домен пишет `payment_events` (succeeded / failed / refunded);
|
||||
диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе
|
||||
@@ -233,14 +245,20 @@ at-least-once + идемпотентный приём = начисление р
|
||||
брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в
|
||||
бота).
|
||||
|
||||
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Админ может сделать
|
||||
**ручной** возврат (крайний случай: пользователь требует вскоре после оплаты / закрывает
|
||||
аккаунт — связано с `accountdelete`, где уже сохраняются сообщения). **Внешние** возвраты
|
||||
(чарджбек / решение стора / TG / VK) обрабатываются: система принимает событие `refunded`,
|
||||
**по возможности** отзывает бенефит (в минус не уходим; если Фишки уже потрачены —
|
||||
фиксирует убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций
|
||||
**спроектирован экспортопригодным** для будущей налоговой отчётности и сверки с Robokassa
|
||||
(саму сверку пока не строим; схема остаётся совместимой).
|
||||
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует
|
||||
админ** (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК
|
||||
(авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через
|
||||
поддержку, TG Stars — вызовом `refundStarPayment`. Все сходятся на одном движке — метод `Refund`
|
||||
(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по
|
||||
`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки
|
||||
сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с
|
||||
полом 0** (в минус не уходим — D27, `balances_chips_chk`). Если Фишки уже потрачены, невозвратный
|
||||
остаток фиксируется как **убыток + флаг злоупотребления** per-account (`payments.account_risk`, читает
|
||||
отчёт E7). Дельта Фишек в refund-строке — то, что реально отозвано, поэтому журнал остаётся сверяемым
|
||||
с балансом; **полный** реверс (деньги, исходные Фишки, убыток) лежит в snapshot строки. Заказ остаётся
|
||||
`paid` — возврат живёт в журнале + событии `refunded`, не в статусе заказа. Повторный возврат не
|
||||
отзывает ничего. Журнал операций **спроектирован экспортопригодным** для будущей налоговой отчётности
|
||||
и сверки (саму сверку пока не строим; схема остаётся совместимой).
|
||||
|
||||
## 10. Реклама
|
||||
|
||||
@@ -250,17 +268,31 @@ web/native/TG держат только существующий наш **тек
|
||||
платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального
|
||||
рублёвого дохода самозанятому (НПД).
|
||||
|
||||
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен по
|
||||
**серверному verify-колбэку** сети (клиенту не верим, как платежу). Антифрод на старте — только
|
||||
verify провайдера (своего дневного потолка пока нет; абстракция позволит добавить лимиты
|
||||
позже).
|
||||
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен. **VK Mini
|
||||
App отдаёт только клиентский результат просмотра** (`VKWebAppShowNativeAds` → `data.result`) —
|
||||
серверной проверки нет — поэтому начисление **client-attested** (D29 амендим: server-verify у VK
|
||||
невозможен). Защита — **серверный дневной и часовой кап** (config `reward_daily_cap` /
|
||||
`reward_hourly_cap`, дефолт 50 / 10): он ограничивает читера, который пропускает ролик и дёргает
|
||||
эндпоинт напрямую, и — не менее важно — лимитирует бесплатные Фишки, чтобы желающий больше **покупал**.
|
||||
Начисление идемпотентно по клиентскому nonce и order-less; выплата — config
|
||||
(`rewarded_payout_chips`, дефолт 0 = ролик выключен, пока не задан). Rewarded только в VK и не
|
||||
гасится «без рекламы» (D9). Сеть с серверным verify встроится за ads-абстракцией. На тест-контуре
|
||||
build-флаг (`VITE_ADS_STUB`) подменяет ролик тостом; прод всегда крутит настоящую рекламу.
|
||||
|
||||
**Полноэкранный ролик** (после хода), конфигурируемые серверные значения:
|
||||
**Полноэкранный ролик** (после подтверждённого хода), **только VK**, конфигурируемые серверные
|
||||
значения. Гейт **зеркалится на клиенте**: профиль несёт кулдауны и флаг `suppressed` (тот же гейт
|
||||
«без рекламы» / `no_banner`, что и у баннера, считается на сервере в `adsFor`), а клиент сам
|
||||
гейтит по **единому** времени последнего показа в `localStorage` — вид лишь выбирает нужный
|
||||
интервал, поэтому hint-ролик и move-ролик не встают подряд в пределах кулдауна (один общий таймер,
|
||||
не по одному на вид) — без серверного раунд-трипа на каждый ход. Контурный `VITE_ADS_STUB` подменяет
|
||||
ролик тем же тостом «ad fired». Значения:
|
||||
|
||||
- Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**.
|
||||
- **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов).
|
||||
- Применение **подсказки** триггерит ролик после хода **независимо** от основного кулдауна,
|
||||
со своим кулдауном **1 мин**.
|
||||
- Применение **подсказки** триггерит ролик **независимо** от основного кулдауна, со своим
|
||||
кулдауном **1 мин**.
|
||||
- Показывается **только после подтверждённого хода или подсказки** — никогда после пропуска,
|
||||
обмена или сдачи (ролик за не-очковое действие только раздражает, за них не «награждаем»).
|
||||
- Оффлайн — только баннер.
|
||||
- Уважать собственные лимиты частоты VK.
|
||||
|
||||
@@ -286,10 +318,13 @@ in-process кэш сегментов и бенефитов по ключу-ак
|
||||
вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу.
|
||||
|
||||
**Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы /
|
||||
подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Админ
|
||||
подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Выдаёт либо
|
||||
сырыми атомами, либо **готовым продуктом-ценностью** (набор-награда, возможно архивный — скрыт
|
||||
из магазина, но выдаётся); оба отказывают на атоме `chips` или `tournament`. Админ
|
||||
**выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk`
|
||||
точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала
|
||||
типа `admin_grant`, цена 0 Фишек — полный аудит наград.
|
||||
типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) —
|
||||
полный аудит наград.
|
||||
|
||||
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
|
||||
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
|
||||
|
||||
+9
-6
@@ -347,12 +347,15 @@ its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence). An
|
||||
lives ~2 s, drifting up by about the tab-bar height as it fades (a plain fade, no travel, under
|
||||
reduce-motion); an **error** toast dwells longer (~4 s). Either dismisses instantly on tap.
|
||||
|
||||
**Simultaneous-game cap.** When the player is at the active-quick-game cap (`games.list`
|
||||
`at_game_limit`), the lobby's **New Game** tab is **disabled** (greyed via the shared
|
||||
`.tab:disabled` opacity) and a plain, low-emphasis line — muted, centred, no accent — sits
|
||||
under the lists with the localized "limit reached" notice. Both follow the flag carried in the
|
||||
cached lobby snapshot, so they render in their last-known state on entry (the button stays
|
||||
enabled on the first, uncached load) and flip in place when an event refreshes the lobby.
|
||||
**Active-game caps.** A player's simultaneous unfinished games are capped per kind (vs AI / random /
|
||||
friends) against the caller's per-tier limits (`Profile.game_limits`), counted client-side from the
|
||||
lobby games. The lobby's **New Game** tab is **always enabled** — the lock lives on the per-kind
|
||||
start, not the tab. On the **New Game** screen a start whose kind is at its cap renders as an
|
||||
**outline** button with a **🔒** (not the filled accent CTA), so it reads as gated rather than ready;
|
||||
tapped, it opens a short prompt instead of a game — inside Telegram a **native popup**, elsewhere the
|
||||
in-app **Modal**. A **guest** sees a sign-in funnel (Cancel / Sign in → the account screen); a
|
||||
signed-in account at its cap sees a plain "finish a current game first" notice (OK). The lock lifts in
|
||||
place when the profile is re-fetched after a guest→durable upgrade.
|
||||
|
||||
## Social, account & history surfaces
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user