e3c2e80a0a
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Failing after 24s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
The direct rail runs on НПД, where the provider neither files with the tax service nor issues a receipt — so nobody was doing it. This registers each rouble purchase, annuls its receipt on a refund, and hands the buyer the receipt by email. Two properties of the (unofficial) lknpd API shape the design. Registering an income takes no idempotency key, so an error does not mean nothing happened: the service name is frozen before the call and carries a marker from the tail of the order id, and after a failure the taxpayer's income list is searched for that exact name. Found means filed; not found halts the queue for a human, because declaring an income twice is as wrong as not declaring it. And faults are classified rather than logged: a token is renewed silently, a throttle backs off, an outage retries, but three unfixable rejections take the rail out of service — a changed format must not become thousands of requests overnight. The console button and the worker share one RunBatch. Automatic mode is armed from the console, not from configuration, so the operator can watch a run go through by hand first. A daily watchdog runs whether or not it is armed, since the case it exists for is the export being off. An idle queue issues no call at all — not even an authentication. No payment path changed: the purchase letter rides the existing payment-event outbox on its own cursor, the receipt and annulment letters ride the export row. Decisions D53-D60.
326 lines
26 KiB
Markdown
326 lines
26 KiB
Markdown
# Agent field notes — scrabble-game
|
||
|
||
Non-obvious, hard-won project knowledge that is **not** in the main docs (`CLAUDE.md`, `docs/*`,
|
||
`deploy/README.md`). Kept in the repo so it travels with a clone to any host. These are working
|
||
memory, not a spec — **verify any named file / flag / function against the current code before acting
|
||
on it**, and prefer the authoritative docs where they overlap. Add to this file as new gotchas turn up.
|
||
|
||
## Codegen & build
|
||
|
||
- **jetgen churns everything.** `backend/cmd/jetgen` regenerates go-jet code for *all* tables and may
|
||
reorder output. After running it, revert the churn on tables you did not touch and commit only your
|
||
table's change.
|
||
- **flatc is version-pinned** (`pkg/Makefile`, `REQUIRED_FLATC = 23.5.26`, hard-checked). A different
|
||
flatc silently churns the generated wire code and can flip wire defaults. Never regenerate FBS with
|
||
another flatc version.
|
||
- **gopls lags codegen.** Right after an FBS/jet regen, gopls shows phantom "undefined" errors. Trust
|
||
`go build` / `go test`, not the editor squiggles.
|
||
- **go-jet type mapping:** SQL `numeric` → `float64`, `interval` → `string`. Never store money as
|
||
`numeric` (float precision loss) — use **bigint minor units + a `Money` type**; store durations as
|
||
**int seconds**, not `interval`.
|
||
- **`go mod tidy` chokes on dot-free local module paths** (`scrabble/...`). Hand-edit `go.mod` when a
|
||
bump is needed. The solver (`../scrabble-solver`) is consumed via `go.work` replace locally, but a
|
||
prod bump goes through a solver **PR → master + a published tag**, not a local replace.
|
||
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
|
||
Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
|
||
- **The UI job's last step is a size gate, not a test:** `cd ui && node scripts/bundle-size.mjs`
|
||
(gzip budgets per entry, `BUDGET` in that file). `pnpm build` succeeding says nothing about it, and
|
||
the app entry usually sits within a few hundred bytes of its cap, so *any* feature touching an
|
||
always-loaded screen can fail CI green-on-everything-else. Run it, and **rebuild first** — the
|
||
script measures whatever is in `dist/`, so a stale build silently measures the wrong branch. Put
|
||
new logic behind an existing lazy import where it belongs; raising `BUDGET` is allowed but the
|
||
file's header comment records the reason for every past raise — keep that up, and ask the owner.
|
||
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
|
||
`${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
|
||
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
|
||
pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`.
|
||
|
||
## Native Android build (Capacitor)
|
||
|
||
The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
|
||
`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code:
|
||
|
||
- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle
|
||
run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula**
|
||
`brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable
|
||
non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so
|
||
`/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`.
|
||
- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0**
|
||
(`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer
|
||
`android-36.1` does NOT satisfy compileSdk 36.
|
||
- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install
|
||
--cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`).
|
||
Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific).
|
||
- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android &&
|
||
ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug`
|
||
→ `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap`
|
||
(dodges the corepack flake).
|
||
- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`;
|
||
`emulator -avd <name>`, `adb install -r <apk>`, `adb shell monkey -p ru.eruditgame.app -c
|
||
android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep`
|
||
→ run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a
|
||
full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
|
||
each tap advances one step — then Hint places a suggested word; commit → the robot replies).
|
||
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
|
||
edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
|
||
On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
|
||
under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
|
||
button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
|
||
Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
|
||
`insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
|
||
`--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
|
||
that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
|
||
(2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
|
||
**header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
|
||
EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
|
||
(`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
|
||
per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
|
||
is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
|
||
BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
|
||
`adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
|
||
| head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
|
||
element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
|
||
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
|
||
uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
|
||
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
|
||
keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT
|
||
`scrabble-solver/dawg` (those are the solver's pinned test fixtures).
|
||
|
||
## Wire / schema evolution (client ↔ gateway ↔ backend)
|
||
|
||
- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
|
||
delete or reorder a mid-table field — **deprecate** it (`(deprecated)`); deleting shifts field IDs
|
||
and breaks older readers.
|
||
- **Retiring a domain field must also retire the WIRE field it fed** — by deprecation, not deletion,
|
||
and do not just zero it: a dead `0` the client dutifully syncs will clobber the real value.
|
||
- **The gateway transcodes FBS ↔ backend JSON DTOs in lockstep.** A wire change usually means editing
|
||
both the FBS schema and the backend DTO.
|
||
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST
|
||
DTOs. Change both or they drift.
|
||
- **The client codec UPPER-CASES history words** (`lib/codec.ts` `decodeMove`) for display, while the
|
||
engine/evaluator decodes candidate words to lower. Anything comparing the two must case-fold, or it
|
||
silently never matches — this is how the Erudit no-repeat rule shipped dead on the online path
|
||
while working offline (offline compares alphabet-index keys, so it has no case at all). A test that
|
||
hand-builds such a set instead of deriving it the way the screen does will pass vacuously.
|
||
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
|
||
then bumps it from the live event. Don't expect it on the shared broadcast.
|
||
|
||
## UI / Svelte 5
|
||
|
||
- **Never name a `$state` variable `state`.** `svelte-check` then misreads `$state` as a store
|
||
subscription. Rename (e.g. `view`).
|
||
- **Svelte trims literal edge whitespace** in markup. To keep a separator space, emit an expression:
|
||
`{' : '}`.
|
||
- **No global `.btn` / `.ghost` button classes.** Style buttons per-component with scoped CSS + design
|
||
tokens (mirror `NewGame`'s `.invite`).
|
||
- **A `$state` proxy fails structured-clone** when persisted to IndexedDB. Snapshot at the call site
|
||
with `$state.snapshot(...)` before storing.
|
||
|
||
## Platform / WebView quirks (Telegram, VK, iOS, native, old Android)
|
||
|
||
- **Telegram `showPopup` eats the user-activation.** Share / clipboard called from inside a
|
||
`showPopup` callback fail (no gesture). Use your own `Modal` for gesture-gated Web APIs.
|
||
- **iOS Telegram `<a download blob:>` navigates away** and strands the SPA. Deliver files by **Web
|
||
Share on mobile**, and only use a `<a download>` Blob path on **desktop**.
|
||
- **Android TG/VK WebViews** lack `navigator.share` and ignore `<a download>`. Deliver a
|
||
client-generated file by **copying it to the clipboard**.
|
||
- **VK Android WebView ignores `target=_blank`.** Open external links through `lib/links.ts`
|
||
(routes to `vk.com/away.php`). Verify on-device.
|
||
- **Per-platform file-delivery last hop differs** (TG / VK / plain browser) — there is a delivery
|
||
matrix; do not re-litigate it without new on-device facts.
|
||
- **Old Android System WebView floor ≈ Chrome 67.** The bundle targets **es2019** (esbuild lowers
|
||
syntax), a conditional `core-js` polyfill loads only on old engines, and `index.html` has a boot
|
||
gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also a `vmin`
|
||
glyph fallback for old rendering.
|
||
- **iOS WKWebView overscroll and Telegram swipe-to-close are not reproducible in Playwright.** Verify
|
||
those live on the deployed contour, not in the e2e.
|
||
- **Telegram Desktop Mini App shows a persistent bottom-right loader** — that's the long-lived
|
||
Subscribe stream, cosmetic, deliberately left as-is.
|
||
|
||
## Testing
|
||
|
||
- **UI test layers:** vitest (node env, pure logic, **no jsdom**) + Playwright **mock** e2e. The mock
|
||
e2e **bypasses the codec**, so wire/codec bugs need **codec unit tests**, not e2e coverage.
|
||
- **Mock overlay blocks e2e.** The cold-load overlay must be instant under the mock build or it
|
||
intercepts Playwright taps.
|
||
- **Mock tile pools lack a blank `'?'`.** The seeded game `G1` hard-codes one; flip `G1`'s variant to
|
||
eyeball per-variant tiles.
|
||
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
|
||
plugin-config gotcha in wiring them).
|
||
- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies
|
||
with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
|
||
**CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
|
||
browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are
|
||
already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
|
||
fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
|
||
its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
|
||
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
|
||
`@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
|
||
**replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
|
||
`window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
|
||
`Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
|
||
`window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
|
||
`initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
|
||
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
|
||
Use **testcontainers** for container-backed tests.
|
||
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
|
||
or the service crash-loops on start.
|
||
|
||
## Deploy / test contour (operational)
|
||
|
||
- **The TEST contour runs on THIS dev host.** Inspect it via `docker` / Prometheus. A host-side
|
||
`curl` to caddy **hangs** (NAT hairpin) — don't debug the edge that way.
|
||
- **The contour's client IP is the home-router SNAT address**, not the real external IP. Correct in
|
||
prod, not a bug — which is why the IP ban / blocklist are **prod-only**.
|
||
- **The contour is one shared env, last-deploy-wins.** Keep a multi-PR batch a **linear stack** (one
|
||
PR, one deploy) so deploys don't clobber each other.
|
||
- **A schema/wire PR breaks the contour** until a `DROP SCHEMA` + backend restart. Note such a
|
||
prerelease step in `PRERELEASE.md`.
|
||
- **A contour DB wipe resets the account but not the client's stored locale**; the reconciler then
|
||
syncs the stale locale, masking a fresh TG `language_code` seed. Clear client prefs too when testing
|
||
locale.
|
||
- **`DNS=` in `TEST_AWG_CONF` pins the VPN netns to 1.1.1.1** → internal names go NXDOMAIN → the
|
||
bot-link silently dies. Diagnose from inside the netns.
|
||
- **Swap one contour service to a local image** without deploy secrets via a busybox-in-the-netns
|
||
socket-inspect trick (single-service recreate).
|
||
- **A rolling deploy did NOT recreate caddy on a config-only change.** Force `--force-recreate` for
|
||
caddy; don't trust "deploy green" for an edge-config change.
|
||
- **A new gateway edge route MUST be added to the Caddyfile `@gateway` matcher** or it falls through
|
||
to the landing catch-all. Add a CI probe for the route.
|
||
- **Prod caddy logs warnings only, no access log.** Trace a request via the backend "http request"
|
||
telemetry log or Tempo, not caddy.
|
||
- **Confirm a release is live without SSH** by grepping the served SPA: `__APP_VERSION__` inside
|
||
`/assets/main-*.js`.
|
||
- **"App hangs on load" was a dead HTTP/3 advert:** caddy sent `Alt-Svc: h3` with no UDP/443 open;
|
||
clients cache it ~30 days. Fix with `Alt-Svc: clear`.
|
||
- **Config-poison deploy loop:** a crash-looping config-mounted service + a missing bind source
|
||
produces a root-owned directory that then fails deploys. Break it by removing the root-owned dir.
|
||
- **Maintenance-window contract** (planned-deploy 503): a marker header, `/_gm` exempt, the flag spans
|
||
the whole roll, the SPA overlay reloads on recovery. Don't break these invariants.
|
||
- **A new dictionary goes live via a `/_gm/dictionary` upload, NOT a redeploy.** In-flight games keep
|
||
their pinned version. The **owner** does the upload.
|
||
- **Renderer deploy job flakes** on the skia-canvas GitHub binary download when its lockfile changes —
|
||
re-run, it's not your code.
|
||
|
||
## Repo workflow
|
||
|
||
- **PR-based, zero issues.** Work is tracked via PRs + `PRERELEASE.md`. "заведи задачу" means *do it*
|
||
/ add a plan line — not file a tracker issue.
|
||
- **`tea` CLI for all Gitea ops** (PRs, secrets, variables, dispatch). `gh` does **not** work here. The
|
||
agent **cannot self-approve** a PR but **can merge** it after the owner approves. Watch the
|
||
stale-mergeable trap (re-check mergeability right before merging).
|
||
- **Watch every push/merge/deploy to green** with `python3 ~/.claude/bin/gitea-ci-watch.py`, launched
|
||
**bare** under a background task. It polls run-level conclusions; its `ALL GREEN` already covers the
|
||
gated deploy job. Pass `--no-runs 600` when the runner is busy. A **merge** is the most-forgotten
|
||
case — watch the post-merge runs too.
|
||
- **After a merge, switch to the merged-into branch** (`development`/`master`), pull, and prune the
|
||
local feature branch.
|
||
- **The contour deploy probe checks the backend `/readyz`**; a PR deploy builds the PR's own code; a
|
||
wedged contour can be recovered by recreating the host container set.
|
||
|
||
## Domain semantics (not obvious from the code)
|
||
|
||
- **Account deletion must NOT delete any user messages** — including feedback / support. Interview the
|
||
owner on every deletion point before wiring it.
|
||
- **`account.time_zone` is `NOT NULL DEFAULT 'UTC'`, seeded from a detected ±HH:MM offset.** An email
|
||
account row is created at the **code-request** step, not at confirmation.
|
||
- **The robot has two distinct time windows:** a **sleep window** (~00:00–07:00, gates its moves and
|
||
nudges) vs the **player away window** (turn-timeout only). "окно отсутствия" in dialogue = the
|
||
**sleep** window.
|
||
|
||
## Production topology
|
||
|
||
- **Two prod hosts.** `main` — full stack + ACME/edge (Selectel); `tg` — Telegram bot only (vdsina).
|
||
SSH aliases `scrabble-main-ops` / `scrabble-tg-ops`. Deploy is **manual dispatch only**, rolling +
|
||
health-gated + auto-rollback. Hosts are provisioned by `deploy/ansible/` (inventory + vars there —
|
||
the source of truth for IPs/roles).
|
||
- **The agent has targeted root SSH** to the hosts (and may run the prod Ansible). Surface every
|
||
owner-side step **before** a deploy, not after.
|
||
|
||
## Feature-area pointers (state lives in the linked docs/plans, not here)
|
||
|
||
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
|
||
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies.
|
||
- **YooKassa (the direct rail since v1.x) does NOT sign its webhooks.** Authenticity is the
|
||
confirming `GET /v3/payments/{id}` — never the notification body. Two extra guards ride on the
|
||
confirmed object: its `metadata.order_id` must name the order, and its `test` flag must match the
|
||
shop's `IsTest` (a test-shop payment must never credit real chips). Answer 200 for anything
|
||
durably decided, 5xx only to be redelivered (YooKassa retries 24h).
|
||
- **Do NOT gate the notification on the sender IP** — tried, reverted. The contour sits behind a
|
||
tunnel and sees every caller as an internal address (`10.77.0.1`), so the allowlist rejected every
|
||
genuine notification and the credit fell back to the reconcile sweep. Same root cause as the
|
||
IP bans being prod-only. It bought nothing anyway: the order is resolved from the notification
|
||
metadata **before** any provider call, so a forged id costs one indexed read, and guessing a live
|
||
order id means guessing a uuid.
|
||
- **The console refund and the `refund.succeeded` notification race, benignly.** YooKassa fires the
|
||
event the moment `POST /v3/refunds` returns, so the notification often records the reversal before
|
||
the console's own write lands. Both name the same refund id, so the ledger's idempotency index
|
||
catches the second and nothing is revoked twice — but the console must not then report "already
|
||
refunded", which reads as "you clicked twice" for a refund that just succeeded.
|
||
- **Never key a re-check interval off the order TTL.** The order lifetime answers "how long may a
|
||
customer take to pay"; the re-check answers "how soon do we notice a lost callback". Tying them
|
||
together made a failed notification cost the customer the full 30-minute TTL before the chips
|
||
landed. `payments.ReconcileAfter` is its own constant for that reason.
|
||
- **Subscribe to `refund.succeeded`, not just the payment events.** The YooKassa cabinet lets an
|
||
operator refund without touching our API, so without that event the money goes back while the
|
||
chips stay credited. The reversal engine is **full-refund-only** (it rejects any amount other than
|
||
the order's), so a partial refund must record nothing and be logged for a human — there is no
|
||
non-arbitrary chip cost for a part-refund. Also: a refund is only recorded in status `succeeded`;
|
||
a `pending` one can still be canceled and the ledger is append-only.
|
||
- **YooKassa DOES have a test mode** — a separate *test shop* (own shopId/secret, test cards, up to
|
||
20 of them), and webhooks/receipts/refunds all work there. The whole rail is verifiable on the
|
||
contour without real money; don't plan around "no test payments".
|
||
- **We send NO receipt: the merchant is on НПД, which is outside 54-ФЗ**, and YooKassa does not
|
||
serve receipts for that regime (checked with their support). Reporting goes to «Мой налог».
|
||
The fiscal code is kept dormant behind `BACKEND_YOOKASSA_VAT_CODE` — unset = send nothing, a
|
||
54-ФЗ rate code turns «Чеки от ЮKassa» back on (the НПД income ceiling makes that foreseeable).
|
||
Gotcha if you ever switch it on: `payment_subject`/`payment_mode`/`vat_code` are fields *inside*
|
||
`receipt.items[]` (54-ФЗ tags 1212/1214/1199), documented under the receipts section, not the
|
||
payment API — easy to miss; and a malformed receipt is an API error at payment creation, so it
|
||
breaks purchases rather than silently skipping the fiscal document.
|
||
- **The gateway cannot import `backend/internal/*`** (separate module + Go's internal rule), so a
|
||
security list shared by both hops either lives in `pkg/` or is enforced backend-side. The YooKassa
|
||
sender allowlist is enforced in the backend, with the gateway forwarding the real peer IP as
|
||
`X-Forwarded-For` — do not duplicate the CIDR list into the gateway.
|
||
- **«Мой налог» (НПД) has NO test environment.** Every call lands on the owner's real tax
|
||
account, so the rail is verified against an `httptest` stub plus one real purchase the owner
|
||
makes himself. Its API (`lknpd.nalog.ru`) is reverse-engineered — no contract, no sandbox.
|
||
- **`POST /api/v1/income` is NOT idempotent.** A timeout does not mean nothing was filed. The
|
||
only recovery is searching the taxpayer's income list by the exact `services[].name`, so that
|
||
name is frozen in the DB *before* the call and carries a marker from the **tail** of the order
|
||
id. Do not "simplify" it to the head: our order ids are UUIDv7, whose leading hex digits are a
|
||
millisecond clock — the first eight are shared by every order in the same ~65 seconds, which
|
||
would make two purchases indistinguishable to the probe. An unresolved outcome halts the
|
||
whole queue on purpose; a blind retry is the one thing that must never happen.
|
||
- **The receipt's time zone decides the tax month.** The ledger is UTC; `BACKEND_MYNALOG_TZ`
|
||
(default `Europe/Moscow`) is what a payment is filed under, so a 23:30 UTC payment on the last
|
||
of the month belongs to the *next* one.
|
||
- **A bank card is an электронное средство платежа**, so ст. 14 ч. 3 ФЗ-422 wants the receipt
|
||
at settlement; the до-9-го-числа delay applies only to settlements *without* an ЭСП. Do not
|
||
"relax" the cadence on the strength of the 9th — the automatic mode satisfies both readings,
|
||
which is why the ambiguity never had to be settled.
|
||
- **A bank does not auto-register income for an ИП на НПД** unless that service is explicitly
|
||
enabled with the bank. Money landing on the account is not a чек. VK payouts are therefore a
|
||
separate, still-manual stream (and VK is a legal entity: 6%, its INN in the receipt).
|
||
- **Robokassa is retired but wired.** The direct rail resolves YooKassa → else Robokassa, and no
|
||
deployment sets the Robokassa credentials. Reviving it is a credentials change, not a code change;
|
||
`backend/internal/robokassa/README.md` holds the retired vars, cabinet URLs and the known gap
|
||
(the per-channel `…_WEB_*`/`…_ANDROID_*` vars never reached any workflow).
|
||
- **VK payment title trap:** a product title with an HTML-special char (`& < > " '`) silently kills
|
||
VK purchases. VK HTML-escapes it in the `order_status_change` callback echo (`"`→`"`) but
|
||
signs a different form → the gateway logs `vk callback: bad signature` and rejects it (get_item
|
||
passes — its request carries no title). `validateProduct` now forbids those chars; keep titles on
|
||
« » guillemets. Diagnose a raw VK callback body by tcpdumping the plaintext caddy→gateway hop
|
||
(`tcp port 8081`, the h2c DATA frame is unencrypted); VK retries `order_status_change` for ~30 min.
|
||
- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in
|
||
`deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with
|
||
`--pg1-user=scrabble`.
|
||
- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports**
|
||
of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native
|
||
offline-first bundles the dictionaries.
|
||
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini
|
||
App, server-side confidential code exchange.
|
||
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
|
||
- **Native Android** — Capacitor bundle model, client-version gate, offline-first, RuStore; the
|
||
design lives in `docs/ARCHITECTURE.md` (§2 gate, §3 identity, §13 native build).
|