Replace Robokassa with YooKassa as the RUB direct-rail provider. The wallet
model is untouched: one `direct` segment, the same spend wall, the same
per-channel merchant shops (D42) and `shop` on the order (D44).
The two providers are not shaped alike, and that drives the change:
- Opening a purchase is now an outbound API call (`POST /v3/payments`,
single-stage capture, redirect confirmation). The order id is both the
`Idempotence-Key` and `metadata.order_id`, so a retried create cannot mint a
second payment and a notification always resolves to its order.
- YooKassa does NOT sign notifications, so the body is never evidence: it only
names a payment, which is re-read with `GET /v3/payments/{id}`, and only that
answer is acted on. Two guards ride on it — the payment's metadata must name
the order, and its `test` flag must match the shop's, so a test-shop payment
can never credit real chips. The sender address is checked against YooKassa's
published ranges first, which stops a forger turning each fabricated
notification into an outbound call of ours.
- A notification lost for good would leave the money taken and the chips unowed,
silently. The existing pending-order reaper now asks the provider about each
order that reached its expiry age carrying a payment id, and credits the ones
really paid — one request per order over its whole life, not polling.
- `payment.canceled` records a `failed` event, so a declined payment is finally
surfaced to the customer as PAYMENTS.md §9 already specified.
- The admin refund moves the money through `POST /v3/refunds` before recording
anything; a failed call records nothing, so the ledger cannot claim a refund
that did not happen, and the recorded id is the provider's own.
- YooKassa has no cabinet-side generic receipt: «Чеки от ЮKassa» registers one
only if the request carries it, so every payment and refund now sends an
itemized `receipt` to the D36 confirmed email. The VAT rate code is a deploy
variable; the settlement subject and method are constants.
Robokassa is retired, not deleted: the direct rail falls back to it when no
YooKassa shop is configured and no deployment sets its credentials, so reviving
it is a credentials change rather than a code change. Its variables are removed
from compose, .env.example, write-prod-env.sh and the three workflows, and
recorded in backend/internal/robokassa/README.md together with the cabinet
configuration and the revival steps. Ledger rows keep `provider = 'robokassa'`;
that literal is load-bearing for the idempotency index.
No migration and no wire change: `orders.provider_payment_id` already existed,
and the client is rail-agnostic.
Decisions D47-D51 (revising D41) and stage E12 are baked into the docs.
scrabble-game
Multiplatform Scrabble game. Players arrive from a platform (Telegram first; later VK/MAX/iOS/Android) or from standalone web (email / guest). The game supports English Scrabble, Russian Scrabble and Эрудит.
Components
gateway— the only public ingress: anti-abuse, platform authentication (resolves the player and injectsX-User-ID), routing tobackend, and an admin surface behind Basic Auth.backend— internal-only service that owns every domain concern and embeds thescrabble-solverengine library in-process.ui— pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC- FlatBuffers, embeddable in platform webviews and packageable to native via
Capacitor. See
ui/README.md.
- FlatBuffers, embeddable in platform webviews and packageable to native via
Capacitor. See
platform/*— per-platform side-services (e.g. the Telegram bot).
Documentation (sources of truth)
docs/ARCHITECTURE.md— global architecture, transport, security, cross-service contracts.docs/FUNCTIONAL.md(+_ru) — per-domain user stories.docs/TESTING.md— test layers and the CI gate.CLAUDE.md— project guide and development workflow.
Build & test
go build ./backend/... ./pkg/... ./gateway/... # per module (the workspace spans several)
go vet ./backend/... ./pkg/... ./gateway/...
gofmt -l . # must print nothing
go test -count=1 ./backend/... ./pkg/... ./gateway/... # unit tests
go test -tags=integration -count=1 -p=1 ./backend/... # + Postgres (needs Docker)
The integration-tagged tests start a throwaway postgres:17-alpine container
via testcontainers-go and require a reachable Docker daemon; they live in the
backend module. The wire contracts in pkg and the Connect edge in gateway
have committed generated code (regenerate dev-time with make -C pkg gen /
make -C gateway gen).
Run the backend locally
The backend now owns persistence, so it needs Postgres and applies its embedded migrations at startup:
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
go run ./backend/cmd/backend # HTTP API + probes on :8080, push gRPC on :9090
Run the gateway locally
The gateway is the public edge; point it at a running backend:
GATEWAY_BACKEND_HTTP_URL=http://localhost:8080 \
GATEWAY_BACKEND_GRPC_ADDR=localhost:9090 \
go run ./gateway/cmd/gateway # Connect/h2c edge on :8081
Key environment: BACKEND_HTTP_ADDR (default :8080), BACKEND_LOG_LEVEL
(debug|info|warn|error, default info), BACKEND_POSTGRES_DSN (required).
The full configuration surface and the go-jet regeneration step live in
backend/README.md.
Run the UI locally
cd ui && pnpm install
pnpm start # mock mode: lobby -> game with no backend, on http://localhost:5173
pnpm dev # against a running gateway (Vite proxies the RPC path to :8081)
pnpm check (type-check), pnpm test:unit (Vitest), pnpm test:e2e (Playwright
smoke vs the mock), pnpm build (static bundle). Details — including the committed
edge codegen (pnpm codegen) — are in ui/README.md.
Deploy (deploy/)
The full contour is deploy/docker-compose.yml:
backend + gateway (with the UI embedded via go:embed, baked in by its node
build stage) + Postgres + the Telegram connector (with a VPN sidecar) + an
observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a front
caddy that owns a single /_gm Basic-Auth (admin console + Grafana). The Go
services build from multi-stage distroless */Dockerfile.
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required; pulls that DAWG release artifact
docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
CI auto-deploys the test contour on a PR into — or push to — development
(.gitea/workflows/ci.yaml); the prod contour is a manual deploy after
development → master. Env reference: deploy/.env.example;
the topology and the two-contour model are in
docs/ARCHITECTURE.md §13.