Ilia Denisov 395a307eca
CI / changes (pull_request) Successful in 11s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 29s
CI / ui (pull_request) Successful in 1m27s
CI / conformance (pull_request) Successful in 19s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
feat(payments): reverse refunds issued outside the console
Two holes on the refund path, both found by asking what happens when a refund
does not come from our own `/_gm` button.

The merchant cabinet is a second entry point. An operator can refund there, and
such a refund never passes through our API — so the money went back while the
chips stayed credited, silently. Handle `refund.succeeded`: the refund is
re-read from the API (the notification body is no more evidence here than it is
for a payment), bound back to its order through the payment id recorded when
the payment was minted, and reversed through the same engine. It is idempotent
on (provider, refund id), so the event for a refund the console already
recorded reverses nothing twice.

The reversal engine is full-refund-only by design — it revokes exactly what the
pack funded and rejects any other amount — so a partial refund is recorded as
nothing at all and logged loudly for an operator. There is no non-arbitrary way
to decide how many chips a part-refund costs, and guessing would be worse than
asking a human.

Second hole: a refund can still be canceled while pending, and the ledger is
append-only. Recording on any non-empty refund id therefore risked revoking a
customer's chips for money that stayed with us, with no way to take the row
back. The console now records only a `succeeded` refund and tells the operator
to press again otherwise — the idempotency key returns the same refund rather
than paying twice.

Tests: unit (GetRefund, the refund notification envelope, a non-final status
surfaced to the caller); integration (a cabinet refund is reversed once and a
redelivery is a no-op, the event after a console refund changes nothing, a
partial refund records nothing, an unconfirmed refund reverses nothing, a
pending refund records nothing until it settles and then does).

The suite shares one database and the ledger dedupes refunds globally, so the
fake provider now mints a refund id per payment — a constant id made one test's
refund look like another's duplicate.

Decisions D50 (amended) and D52; the notification subscription list in the
deploy docs gains refund.succeeded.
2026-07-28 09:18:19 +02:00

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 injects X-User-ID), routing to backend, and an admin surface behind Basic Auth.
  • backend — internal-only service that owns every domain concern and embeds the scrabble-solver engine 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.
  • platform/* — per-platform side-services (e.g. the Telegram bot).

Documentation (sources of truth)

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.

S
Description
No description provided
Readme 36 MiB
v1.1.0 Latest
2026-06-22 07:27:41 +00:00
Languages
Go 59.4%
TypeScript 28%
Svelte 8%
JavaScript 1.7%
Go Template 1.3%
Other 1.5%