Commit Graph

204 Commits

Author SHA1 Message Date
Ilia Denisov 69bddaeb9a fix(admin): shorten the receipt letter, show the account id
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
The receipt letter repeated what the receipt itself states. It now carries the
amount, the moment of payment with its offset, and the link — the receipt is
the document, and it opens without authentication, so there is nothing for the
two to disagree about. Subject reworded to name what it is.

The ledger and tax-export tables labelled the account link "card", meaning the
user card. On pages about payments that reads as a bank card, which is exactly
how it was misread. Both now show the first eight characters of the account id,
so the column carries data — two rows from the same buyer are visible at a
glance — while the link still holds the whole id.
2026-07-28 16:44:44 +02:00
Ilia Denisov 4d4452596d fix(payments): stop doubling the pack size in buyer mail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Catalog titles already name the pack ("50 фишек"), so appending the chip count
produced "50 фишек — 50 фишек, 79.00 ₽". The letter now carries the title
exactly as the storefront shows it, then the price.

The tax receipt is unaffected: it states the quantity in the wording the tax
service wants, which is a separate surface with separate requirements.
2026-07-28 16:14:53 +02:00
Ilia Denisov f8efa21158 test(inttest): de-flake the no-repeat pin guard
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m51s
The bag seed comes from crypto/rand, so an opening rack occasionally has no
playable word — a legitimate engine answer that says nothing about the pin the
test is guarding. Reproduced at roughly one run in three hundred locally, and
it turned CI red once. Retry with fresh games instead: an unlucky draw no
longer decides the result, while a genuinely broken path still fails every
attempt.
2026-07-28 15:47:19 +02:00
Ilia Denisov e3c2e80a0a feat(payments): report income to «Мой налог»
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.
2026-07-28 15:40:36 +02:00
Ilia Denisov 3b744b7d2f feat(admin): an all-accounts ledger section with filters and totals
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
There was no way to see the money as a whole: the ledger was only ever rendered
inside one account's card, so "what came in last month" meant exporting the
entire CSV and reading it elsewhere.

/_gm/ledger lists every operation, newest first, filtered by date range
(defaulting to the last 30 days), by wallet, by rail, by kind and by account.
Wallet and rail are deliberately separate axes because they answer different
questions — "what happened on VK" matches the funded segment or the benefit
origin, while "what came through YooKassa" matches the settling provider, and a
chip spend has no rail at all, so it drops out of that filter by construction.

Above the table sit the totals for everything the filter matches, not merely the
page on screen, which is the point of showing them. Money is listed per currency
because the rails settle in roubles, Votes and Stars and one sum across them
would mean nothing. Row amounts come from the operation snapshot, since the
ledger's own columns count chips rather than money.

Paging, the CSV export and a refund's way back are all built from the same
encoded filter, so none of them can quietly show a different slice than the
screen. The export moves here from the user card and gains the filter along with
money columns; it is capped, because an append-only ledger grows forever and an
unbounded export would eventually time out.

The refund action moves onto the funded rows here — an operator can now find a
payment by filter without knowing whose it is first — and returns to the same
filtered view. The destination travels with the form but is only honoured when
it is a console path, so a crafted form cannot turn it into an open redirect.

The user card keeps the account's standing rather than its history: balances,
benefits, the risk flag and a lifetime summary (money paid and refunded per
currency, chips credited and spent), plus a link into the ledger scoped to that
account. Operations are rendered in one place, with filters and paging, instead
of two.

Tests: the filter axes, paging that neither repeats nor drops a row, totals that
describe the range rather than the page, money recovered from a snapshot and
absent on a spend, and the console page, the export and the card hand-off. The
existing finance-panel test now asserts the summary and that the card no longer
re-renders the rows.
2026-07-28 12:43:28 +02:00
Ilia Denisov e3961fe4ca fix(payments): a console refund that its own notification beat is not a repeat
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 2m28s
Refunding from /_gm reported "Already refunded" for a refund that had just
succeeded, which reads as "you clicked twice".

The two recording paths race. YooKassa fires refund.succeeded the moment
POST /v3/refunds returns, so the notification handler often writes the reversal
before the console's own write lands. Both name the same refund id, so the
ledger's idempotency index rejects the second — which is the mechanism working
exactly as intended: on the contour the money moved once, one refund row was
written, the balance is right and no abuse flag was raised. Only the message
was wrong about why.

The console now reports success on that path, and the notification's log line
no longer claims the refund was "issued outside the console" when it may well
have come from it.

Covered by an integration test that lands the notification first and then
refunds from the console, asserting the operator is told it succeeded and that
exactly one refund row exists.
2026-07-28 12:15:59 +02:00
Ilia Denisov 4cac09c9f3 fix(payments): drop the notification sender-IP gate; re-check on its own cadence
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
A real test payment on the contour exposed both problems at once. YooKassa
delivered the notification five times; all five were rejected because the
backend saw the sender as 10.77.0.1 — the contour sits behind a tunnel and
cannot observe real client addresses, the same reason the IP bans in this
repository are prod-only. The chips were not lost (the reconcile sweep would
have credited them), but the primary path was dead and the customer was left
watching an unchanged balance.

The address check is removed rather than made conditional. It never was the
security boundary — the confirming GET /v3/payments/{id} is — and the one thing
it bought is already bought earlier and far more tightly: the order is resolved
from the notification's metadata *before* any provider call, so a notification
naming no known order costs a single indexed read and stops there. Guessing a
live order id means guessing a uuid. Against that, an address check adds nothing
and breaks every deployment that cannot see real client addresses, while turning
any future change to YooKassa's published ranges into a silent degradation.

The second problem was mine. The reconcile threshold was keyed off the order
lifetime, so a lost notification cost the customer the full 30-minute TTL before
the chips landed. Those are different questions: the lifetime governs how long a
customer may take to pay, the re-check governs how soon we notice a lost
callback. Split apart — `payments.ReconcileAfter`, one minute, swept on every
reaper tick. The bound D49 was chosen for survives: the calls one order can
cause are still its lifetime divided by the sweep interval, a handful, not an
open-ended poll. Worst case for a failed notification drops from ~30 minutes to
~5; an order the customer is still paying for is left alone.

Tests: the foreign-sender test is replaced by the two properties that now carry
the load — a notification naming an unknown order makes no provider call at all,
and a genuine notification is honoured whatever address it appears to come from.
Plus one pinning that a seconds-old order is not polled.

The shared bundle budget goes 31 -> 32 KB, with the reason recorded in the
script header: every user-visible string lands in that chunk and it had been
sitting 40 bytes under the cap.

Decisions D48 and D49 revised.
2026-07-28 12:00:03 +02:00
Ilia Denisov 12e616ceae feat(payments): send no fiscal receipt; keep the code switchable
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The merchant accepts payments as a sole proprietor on НПД, which is outside
54-ФЗ: there is no online cash register, YooKassa does not serve receipts for
that regime at all (checked with their support), and each operation is reported
by the merchant to «Мой налог», which issues the чек. So no `receipt` is sent
with a payment or a refund.

This also removes a failure class rather than just code: a malformed receipt
was an API error at payment creation, which broke the purchase outright.

The fiscal code is kept dormant rather than deleted. All of it now sits behind
one switch, `BACKEND_YOOKASSA_VAT_CODE`: unset — the default — builds and sends
nothing; a 54-ФЗ rate code turns «Чеки от ЮKassa» back on unchanged. The return
is foreseeable, which is why the switch exists: НПД carries an annual income
ceiling, and losing the regime puts 54-ФЗ back in force, at which point this is
a deploy-variable edit instead of writing the integration again.

The D36 email anchor still gates a direct purchase. It had two justifications —
a recovery anchor and the receipt address — and only the second is gone; without
an email a paying customer who loses the account loses the chips with it. What
was missing is that the rule was enforced but never communicated: the wallet
showed the packs to a player signed in through VK or Telegram in a browser, and
tapping Buy produced a bare "something went wrong". It now says "add an email in
your profile" in the buy tab instead, linking to the profile; spending
already-earned chips is untouched. The predicate is a pure function so it is
covered by the node-env unit tests rather than needing a browser.

Tests: the receipt-off default is pinned by an integration test asserting a
purchase carries no receipt, and the dormant path by one that switches a VAT
code on and checks the receipt reappears with the right fiscal attributes;
plus unit coverage for the enable predicate and the wallet's email rule.

A note for whoever runs the numbers next: the shared (svelte + i18n) chunk is
now 40 bytes under its 31 KB gzip budget.

Decision D51 revised.
2026-07-28 11:40:59 +02:00
Ilia Denisov 395a307eca feat(payments): reverse refunds issued outside the console
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
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
Ilia Denisov 92ba527575 feat(payments): settle the direct rail through YooKassa
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
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.
2026-07-28 08:51:31 +02:00
Ilia Denisov c3d36dda2d fix(ui): use the guest stubs' "sign in" wording, and link it in Stats
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
The New Game hint said "Register" where the Stats and Profile guest stubs say
"Sign in". Move the shared word into common.signInLink so the two screens
cannot drift, and turn the Stats stub's opening word into the same link to the
profile screen.
2026-07-27 20:45:10 +02:00
Ilia Denisov b6d88da78c feat(variants): default a registered account to Erudit + Russian Scrabble
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
New Game opened on a single variant for everyone, which reads poorly on VK
where Russian Scrabble is the familiar game. A registered account now starts
with both Russian-alphabet games, so the picker offers a real choice with no
pre-selection.

A guest stays on Erudit alone and is invited to register for the rest: the
device-local guest has no profile at all, so the client-side fallback has to
keep matching the server. Registering promotes the set, but only while the
guest still carries the untouched guest default. Existing accounts are not
backfilled — the set they carry may be a deliberate choice.

The Telegram promo start-param becomes additive rather than a replacement,
with English Scrabble withheld from a Russian-speaking arrival; otherwise the
English campaign link would have taken Russian Scrabble away from every
account it onboarded.
2026-07-27 20:38:09 +02:00
Ilia Denisov 4f4768b092 fix(rules): name a repeated word in the move preview instead of scoring it
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Composing an already-played word read as a perfectly legal move: the tiles
drew as legal, the caption showed "БРА = 6" and the  was enabled, and
only the server refused the play with the bare "illegal move" toast.

The on-device evaluator already applied the rule, but it reported the
refusal as a plain illegal play, so nothing distinguished a repeated word
from a misspelling and the caption fell back to the turn label. It now
names the word it rejected, and the game screen reads
"БРА: уже использовано" above the scores while the tiles stay marked
invalid — the composition is wrong, but for a reason the player cannot
read off the board, since the word itself is in the dictionary. The
verdict is reached on the device before the play is ever offered to the
server, in an online game exactly as in an offline one; the offline
source reports the same reason through the same field.

Only the main word is ever refused: a cross-word the game has already
used still stands (it is incidental to laying the main word) and merely
scores nothing, so the rule cannot falsely block a play in a game with
several words per turn.

The scoring of such a play is now pinned to the point on both sides: the
engine test and the client test evaluate the same position — РОТ laid
across a standing КО, completing an already-played КОТ — and assert the
same totals (10 unrestricted, 5 with the rule), so a divergence between
the two engines breaks one of them. The client test also replays the
exact test-contour position this was reported from.
2026-07-27 19:12:37 +02:00
Ilia Denisov d7337d24ea feat(rules): forbid repeating a word already on the board in Erudit
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Russian "Эрудит" treats a word laid on the board as belonging to the
game: it cannot be laid again. Neither the solver, the backend nor the
offline JS port knew the rule, so a player (and the robot) could replay a
word freely. Official Scrabble places no such restriction, so both
Scrabble variants keep playing unrestricted.

The rule applies in two ways. A play whose main word is already on the
board is illegal, and is neither accepted nor generated. A play whose
perpendicular cross-word is already there stands — that word is
incidental to laying the main word — but scores nothing. The set of
played words is the game's own move journal, main words and cross-words
alike, compared decoded, so a word spelled with a blank is the same word.

It lives in the game layer, not the solver: only a game knows its
history, and the solver stays stateless and standard-rules. The backend
applies it at submit, at the move preview and over generated moves
(filtering and re-ranking them, so neither the robot nor the hint can
offer a play the engine would then refuse); the client port does the same
for the offline engine and for the on-device preview of an online game.

The rule is pinned per game (games.no_repeat_words, set from the variant
at creation) rather than keyed on the variant, because a game is replayed
from its journal on every open. Applied retroactively it would make an
already-played repeat illegal — closing that game as a draw — and would
rescore a play whose cross-word repeats an earlier word, shifting a live
game's totals. Games created before the rule keep playing without it. The
flag rides the wire as a trailing field because the client's preview must
score the way the server does, and offline games pin the same answer in
their own record.
2026-07-27 18:33:56 +02:00
Ilia Denisov 9471341a0e feat(social): hide social controls on a deleted opponent's seat
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
A deleted account keeps its seats in every shared game, so its opponents
still saw the add-friend and block controls on the scoreboard (deletion
drops the friendship, so the 🤝 even reappeared for a former friend) and
a chat composer nobody was behind. A friend request sent that way was
accepted by the server and stayed pending forever.

The per-viewer game views now mark such a seat (SeatView.deleted,
resolved beside the seat display names by a batch accounts.deleted_at
lookup) and the client hides every control aimed at it: add-friend,
block, and the chat composer (message + nudge) once no reachable
opponent is left. Live events carry the game domain's seat standings and
so leave the mark unset, so the delta reducers preserve the cached one.
SendFriendRequest and Block against a tombstone are refused with
social.ErrAccountDeleted (410 account_deleted) — the source of truth for
an older client.
2026-07-27 17:06:10 +02:00
Ilia Denisov f60327c1ce fix(payments): reject HTML-special chars in product titles (VK payment guard)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
A product title containing & < > " ' silently breaks VK purchases: VK
HTML-escapes the character in its order_status_change payment-callback echo
(e.g. " -> &quot;) but signs the notification over a different form, so the
gateway's signature check no longer matches ("vk callback: bad signature")
and the purchase never confirms — while get_item (whose request carries no
title) still passes.

validateProduct now rejects those characters at the admin catalog editor —
the single write path for CreateProduct/UpdateProduct/SetProductActive — so a
title can't reintroduce the break. Reject, not transform: the operator retypes
a clean title (« » guillemets, not straight quotes), so the stored value stays
identical everywhere (storefront, wallet, VK box).

Docs: docs/PAYMENTS.md §7 + agent field notes; Go Doc on validateProduct.
Test: validateProduct HTML-special-char cases. No wire/schema change.
2026-07-16 20:57:04 +02:00
Ilia Denisov 74026223ee feat(account): give a fresh guest a distinct "Guest######" display name
Every guest showed a bare "Guest" to opponents (e.g. in a random match). Mint the display name
as "Guest" + a random six-digit suffix at provisioning, so guests are distinguishable. Not an
identifier and not unique — a label only, so collisions are harmless.
2026-07-15 02:17:13 +02:00
Ilia Denisov da25eac070 feat(link): auto-merge a guest initiator into its durable account (seamless sign-in)
When a guest links an identity (email/TG/VK) that already belongs to a durable account,
the guest-primary rule already made the durable account the survivor and switched the
session — but the client still showed an irreversible 'merge two accounts?' confirmation,
which is nonsense from the user's side (they are simply signing into their account). The
confirm step now merges inline for a GUEST initiator and returns the completed merge (the
switched token); a durable initiator still gets the explicit confirmation (consolidating
two real accounts is consequential). The active-game guard still refuses, surfaced as the
clear error.merge_active_game_conflict message rather than swallowed.

Also fixes the merged-away device-local games: their human/host seat was recorded under
the retired account id, so after the switch the lobby and game header could not identify
'me' and showed every seat as an opponent (the game still played — turn logic is
seat-index based). applyLinkResult now re-points local game seats from the retired id to
the survivor (repointLocalGameSeats).

Docs: FUNCTIONAL.md (+_ru).
2026-07-15 00:34:58 +02:00
Ilia Denisov 48614e622d feat(dict): make the build's dictionary version deploy-authoritative
The dictionary lives on a persistent volume seeded from the image once and never
re-seeded, so a redeploy that bumped BACKEND_DICT_VERSION left the new DAWGs
unreachable (the volume mount shadows the image copy) and the active version stuck
at whatever the admin console last installed — a native offline-first client then
fetched the server's older pinned version over the network instead of using its
bundled dict.

On boot the backend now DELIVERS the build version onto the volume add-only, from a
second unshadowed image copy at BACKEND_DICT_SEED_DIR, into DICT_DIR/<version>/ when
absent (the flat seed and prior uploads untouched, so in-flight games keep theirs),
and InitActiveVersion makes the build version active — except a console-installed
version NEWER than the build (compared numerically) is not downgraded by a restart.
The .seed_version guard still protects the flat seed's label (a bump is a new
subdirectory, never a relabel). The console stays for out-of-band updates.

Docs: ARCHITECTURE.md §5, deploy/README.md, seedmarker.go. Tests: engine.DeliverVersion
(add-only, idempotent, flat-seed skip), compareDictVersions, and the dictionary-update
integration test's deploy-delivers path.
2026-07-15 00:21:20 +02:00
Ilia Denisov f8aeec8008 fix(account): seed a fresh guest's interface language from the client locale
A guest was provisioned with only its detected time zone; its PreferredLanguage
fell to the 'en' column default. The client already sends its locale in the guest
login (GuestLoginRequest.locale), but the gateway dropped it and ProvisionGuest
took no language — so a Russian user's brand-new native guest got English
language-dependent server content (the ad banner, bot messages) until the client's
later language reconcile. Read the locale in the gateway guest handler, thread it
through GuestAuth -> ProvisionGuest, and seed PreferredLanguage from it (validated;
an unsupported/absent value keeps the 'en' default). Wire field already present —
no schema change.
2026-07-15 00:06:23 +02:00
Ilia Denisov 1507ceb793 feat(payments): live payment-availability kill switch + per-account override
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
Let an operator disable purchases live from the admin — a whole rail/channel or
one account — and show the user a localized reason on their next attempt, so a
provider outage or a misconfig is explained instead of a silent dead button.

- rail kill switch (payments.rail_status, per rail direct:web / direct:android /
  vk / telegram): enabled + a per-language message, edited on the catalog page.
  Fail-open — a rail with no row stays enabled, so payments are never accidentally
  killed. The intake gate (CanPurchase in handleWalletOrder, before the order)
  returns payment_unavailable + the localized message, orthogonal to the security
  gates.
- per-account override (payments.account_payment_override, a row only for
  non-default): allow / deny / default, edited on the user card. "allow" bypasses
  ONLY the ops rail switch, never the security gates (trusted platform, the email
  anchor, the VK-iOS freeze, the min client version).
- wire: an additive ExecuteResponse.message envelope field (frozen-contract-safe);
  the gateway forwards a backend domain-error message; the client shows it on a
  payment_unavailable buy attempt.
- admin: rail toggles on the catalog page, the override control on the user card.
- tests: the pure gate (unit, TDD), the store + gate + override end-to-end
  (integration, migration 00016), the client (svelte-check / vitest).
- docs: PAYMENTS (+ru), the decisions log (D45/D46). Fiscalization stays
  cabinet-side (owner decision) — no itemized-receipt code.

Contour-safe: additive migration (two new tables, no wipe), the wire add is
additive, and fail-open so nothing is disabled until an operator acts.
2026-07-14 16:29:41 +02:00
Ilia Denisov eef90a152e feat(payments): per-channel Robokassa shops on the direct rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
Split the single Robokassa direct rail into one merchant shop per channel
(web / android; ios later), chosen by the trusted X-Platform subtype, while
every shop still credits the one `direct` wallet — merchant-account separation
for accounting and receipts, not a new wallet.

- config: a channel-keyed shops registry (web seeds from the legacy vars or
  _WEB_*, android from _ANDROID_*); an empty shop leaves the rail dormant;
  per-shop validation.
- intake: the order picks its shop by subtype (unknown falls back to web); the
  per-shop Result callback is verified by that shop's own Password2 at
  /pay/robokassa/result/<channel> (the gateway extracts the channel; Caddy's
  /pay/* glob already forwards it — no Caddyfile change).
- persistence: an additive `shop` column on the order (migration 00015),
  recorded from the payment context, surfaced per entry in the admin report.
- standalone apps sign in by email only, so a direct purchase keeps its email
  anchor.
- docs: PAYMENTS (+ru) topology, deploy env vars + compose mapping, the
  decisions log (D41 revised for the ИП / 54-ФЗ move; D42-D44), the plan.

Contour-safe: dormant until shops are configured; the migration is additive
(no wipe); no client wire change. Fiscalization (Receipt/Email) and the gateway
`direct/android` subtype follow when the ИП / RuStore are live.
2026-07-14 14:57:34 +02:00
Ilia Denisov ff55d5de83 fix(gateway): forward the real client IP to the backend on every call
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
The backend recorded 172.19.0.9 — the gateway's own docker connection address — as
the client IP for all users: the account's last-login IP shown in the admin console,
and it never reached the backend access log. The gateway forwarded the client IP as
X-Forwarded-For only on chat/feedback calls; every other backend call (including the
profile fetch that stamps last_login_ip) sent none, so the backend fell back to the
peer address.

Carry the client IP on the request context (WithClientIP, mirroring WithPlatform) and
set it once per request in the Connect edge, so the backend client injects
X-Forwarded-For on every downstream REST call. Also add the resolved client IP to the
backend access log.

Test: WithClientIP rides a non-chat call (Profile) as X-Forwarded-For, and is absent
when no IP is set. Docs updated (ARCHITECTURE gateway↔backend + edge).
2026-07-14 11:23:32 +02:00
Ilia Denisov eb7fa98426 feat(payments): edit the rewarded-ads config from the admin catalog page
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m57s
The "watch for chips" rewarded payout and its per-day / per-hour caps live in the
shared payments.config row and had no admin surface — they could only be changed by
SQL, contrary to D32 (the rewarded rate is meant to be admin-configurable). Add a
"Rewarded ads" form on the /_gm/catalog page: RewardConfig / SetRewardConfig on the
service (non-negative validated), a setRewardConfig store writer (the singleton
config row), the consoleSetReward handler + POST /_gm/catalog/reward route, and the
form pre-filled from the current config. No migration — the columns already exist.

The reward rate is a config value, not a sellable catalog atom (so a "no-ads forever"
style product is still out of scope by D32/D33).

Test: an integration test sets the config, checks the page pre-fill, and refuses a
negative value.
2026-07-14 10:40:27 +02:00
Ilia Denisov b6af682381 refactor(payments): one canonical catalog order for storefront, offer, admin; admin active/all toggle
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
The wallet storefront listed products in creation order — its purchases and its
chip-exchange values were unsorted, unlike the admin console and the public offer.
Extract the ordering (chip packs first by rouble price, then chip-priced values
grouped hints → no-ads → combo → tournament and by chip price) into one comparator,
compareCatalogRank over a small rank key, and apply it at all three sites
(projectCatalog, projectOfferPricing, SortAdminCatalog) — so the subgroup ranking
(valueGroup) and the full order now live in one place. The rewarded "watch for
chips" CTA is a wallet-driven element rendered above the packs, so it is unaffected
and stays on top.

Also add the admin catalog active/all toggle: AdminCatalog takes includeInactive,
the console shows active products by default and ?all=1 lists archived ones too
(the detail and grant forms keep loading all products).

Tests: a storefront canonical-order unit test (reproduced the unsorted bug first);
an integration test for the active/all toggle. Existing offer/admin order tests
unchanged — behaviour preserved.
2026-07-14 10:23:42 +02:00
Ilia Denisov 2c2316fb5e chore(catalog): order the admin catalog list like the public offer
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Sales (chip packs) first, ascending by rouble price; then the chip-exchange
values, grouped and price-sorted the same way projectOfferPricing lists them, so
the /catalog console mirrors what a buyer sees. Internal cosmetics only — no
product behaviour change. The value-group order moves to a shared helper
(valueGroup) so the offer and the admin list cannot drift.
2026-07-11 12:53:37 +02:00
Ilia Denisov 40acbcccdd feat(offer): sort and align the §4.4 price tables, style them for both themes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
- packs sorted by ascending rouble price;
- values grouped hints-only -> no-ads-only -> no-ads+hints -> tournament
  (the tournament group is empty until such products become sellable),
  ascending chip price within each group;
- price columns right-aligned (GFM "---:" separators);
- tables span the full content width; the name column shrinks to its content
  and never wraps;
- muted-but-visible cell borders on the dark theme, where the section rule
  colour blends into the background.
2026-07-11 11:29:41 +02:00
Ilia Denisov b54371845f harden(offer): escape admin product titles against HTML/markdown injection
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
The offer price list projects the admin-entered product title into a markdown
table cell that is rendered, unsanitised, into the public /offer/ page. Escape
the title at the projection boundary so it renders as literal text: HTML
metacharacters become entities and the markdown table pipe and link brackets are
escaped, so a title can neither inject markup nor form a javascript: link. The
committed offer prose keeps its trusted-content treatment (code-reviewed, not
runtime input).
2026-07-10 20:30:36 +02:00
Ilia Denisov b6c2598710 feat(offer): live catalog price list in the public offer, served by the render sidecar
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 16m19s
Robokassa moderation requires the public offer to list every digital good with
its price. Move /offer/ off the static landing container to the render sidecar:
it splices the live catalog price list (§4.4) into the owner-edited
ui/legal/offer_ru.md and renders it with the shared ui/src/lib/offer.ts — one
renderer, no drift, always matching the current catalog with no redeploy.

- backend: /api/v1/internal/offer/pricing (internal, off the edge allow-list)
  projects the active catalog into two markdown tables — chip packs priced per
  rail (roubles / VK votes / Telegram Stars) and chip-priced values — through
  payments.Money so no float reaches the page. Cached in memory: warmed at boot,
  marked stale on every catalog mutation, so a served render issues no query.
- renderer: GET /offer/ fetches the tables and substitutes them at the
  <#pricing_template#> marker, then renders; offer_ru.md is baked into the image
  and marked is bundled from ui. GET /offer -> 301. Only /offer/ is edge-exposed.
- caddy: route /offer/ to the sidecar; drop the now-dead landing /offer/
  handlers and the vite emit-offer plugin.
- offer: fill §4.3 (the chip-payment wording) and drop the in-page back link.
- landing footer: a feedback link (the offer's Telegram contact) beside the
  offer link.
- docs (ARCHITECTURE, FUNCTIONAL +_ru, renderer README), CI /offer/ probe,
  unit + integration + node tests.
2026-07-10 20:25:41 +02:00
Ilia Denisov bf46b9492d fix(ui): one-word games must not highlight phantom cross words; review polish
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m25s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
Board-highlight bug (reported on the contour): formedGeometry walked cross words
unconditionally, so in a single-word (one-word-per-turn) game a staged tile sitting
next to a committed tile lit up a green "cross word" the engine ignores — and which
need not even be a real word (the reported "БО lights up green in a one-word ПОПА
play"). Gate the cross-word walk on the game's multipleWordsPerTurn flag. The score
(8) was already correct — a premium square under the main word.

Also, from review:
- the turn strip reads the staged play's "WORD+WORD = N" while composing a legal move,
  reverting to the turn / result text otherwise;
- the Exchange/Pass dialog shows the bag count ("In the bag: N" / "Bag is empty")
  right-aligned in the title row, via a new optional Modal `titleAside`;
- cosmetics: half the turn strip's bottom padding (the plaques below carry their own
  top pad); a top gap above the landscape rack (it sat flush under the docked history);
  more horizontal padding on tab count badges so a 2-3 digit bag count clears the pill
  ends;
- admin console: the game Summary now shows the single-word / multiple-words rule.

Tests: formed single-word case added; full unit (584) + e2e (chromium + webkit, 113
each) green; backend build + adminconsole templates parse. Docs (FUNCTIONAL +_ru,
UI_DESIGN) updated.
2026-07-10 15:58:05 +02:00
Ilia Denisov e40adfb0c7 fix(games): carry game kind on live events + fix the New Game lock funnel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
Address review of the active-game limit lock:

- Live-event GameViews (opponent_moved, match_found, game_started, ...) did
  not carry game_kind, so a lobby patch from an event zeroed a game's kind
  and the client's per-kind count under-counted — a capped kind read as
  free (a disabled start instead of the lock, and a wrong per-kind result).
  Thread Kind through notify.GameSummary → the event GameView.

- New Game screen refreshes the lobby games on mount so the per-kind count
  reflects the current set, not a stale cached snapshot.

- The guest funnel's login button routes to the profile screen (the account
  controls), not settings.

- Copy: the guest prompt is "sign in to use all the game's features"; the
  durable notice is "finish your active games to start a new one".

Regression tests: the notify opponent-moved payload carries kind; the client
lock locks only the reached kind (vs_ai at cap leaves random open).
2026-07-10 10:55:48 +02:00
Ilia Denisov 3306a016a0 feat(ui): per-kind active-game limit lock on the New Game screen
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 28s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
Carry the caller's per-tier active-game caps and each game's kind on the
wire (Profile.game_limits + GameView.kind, additive FBS + gateway transcode
+ client codec, committed regen). The New Game screen counts the player's
active games per kind from the lobby and locks a capped start: an outline
button with a lock that opens a funnel modal instead of a game -- a sign-in
prompt for a guest, a "finish a current game first" notice for a signed-in
account (native Telegram popup, in-app modal elsewhere). The lock lifts via
the existing profile refetch after a guest->durable upgrade.

Remove the lobby's old at_game_limit New-Game tab disable + notice: the flag
(now the random-kind cap) conflicted with the per-kind lock -- it hid the
screen where the lock lives and wrongly blocked an unfulfilled kind. The New
Game tab is always enabled; the per-kind start lock is the only gate. The
at_game_limit wire field stays (unused by the client) for a later cleanup.

Tests: client lock logic + codec kind/game_limits roundtrip + gateway
transcode encode + native popup builders (unit); a mock e2e for the lock
badge and the modal.
2026-07-10 10:09:45 +02:00
Ilia Denisov ed53e25e57 feat(backend): per-tier, per-kind active-game limits with a guest funnel
CI / changes (pull_request) Successful in 5s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m41s
Cap a player's simultaneous unfinished games per kind (vs_ai, random,
friends) with independent guest and durable-account tiers, held in a new
single-row backend.config table (-1 = unlimited) behind an in-memory cache
and editable live in the admin console (/_gm/limits). Each game is tagged
with games.game_kind on creation.

This replaces the earlier flat MaxActiveQuickGames=10 combined cap: the
per-tier/kind config is the single mechanism, enforced at the same handler
gate (ensureUnderGameLimit by kind on lobby/enqueue) plus the durable
friends cap in CreateInvitation. game.Service.AtGameLimit only resolves the
tier and counts; the limit policy stays at the request edge.

Guests are now refused friend requests, friend-code redemption,
befriend-in-game and invitation creation outright (403 guest_forbidden) --
previously only the UI hid these.

Admin: a kind column in both game lists and the config editor.

Defaults: guest 1 vs_ai / 1 random / 0 friends; durable 10 / 10 / 10.
2026-07-10 09:03:57 +02:00
Ilia Denisov 1bf612a087 feat(admin): manual full-order refund + ledger CSV export
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Closes the admin / reports / catalog work. Each fund row on the /_gm finance
panel gains 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 (never negative, D27), idempotent (a second refund reports
already-refunded). A ledger CSV export (/_gm/ledger.csv, payments.LedgerExport)
streams the whole append-only ledger for tax + reconciliation.

Tests: refund an order in full (chips revoked, a refund row), an idempotent
second refund, the CSV export shape; CSRF-guarded.
2026-07-10 06:46:18 +02:00
Ilia Denisov 82648a4398 fix(game): show granted/bought hints in-game — finish the D31 wire removal
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
The in-game hint badge ignored the payments hint wallet: loading a game clobbered
app.profile.hintBalance with the deprecated StateView.wallet_balance (zeroed in the
D31 domain removal but left in the protocol as a dead 0, then synced over the real
balance at game load).

Finish the removal honestly. StateView carries the per-game allowance alone
(hints_remaining); the purchasable wallet lives solely on the profile and the client
adds it (lib/hints). Removed StateView.wallet_balance across every layer — backend
StateView/DTO, notify.PlayerState (live events), gateway StateResp + wire.StateView,
the client model/codec/mock/localgame — and dropped the now-unused wallet arg from
hintsRemaining. The FBS field is tombstoned `(deprecated)` (not deleted) so the vtable
slots after it stay stable across a rolling deploy; no accessor is generated. HintResult
keeps wallet_balance (the real post-spend payments balance the client adopts into the
profile). The StateView type no longer has walletBalance, so the clobber cannot return
without a compile error.

Tests: hintsRemaining (2-arg); hints.hintsLeft (allowance + live wallet, no strip); the
game state/hint integration (allowance-only HintsRemaining); gateway transcode; FBS regen.
2026-07-10 06:26:49 +02:00
Ilia Denisov d2d6955cbf feat(admin): admin grant — raw benefits and by-product reward bundles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
The /_gm user card gains a 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; the by-product grant records the source
product_id + snapshot. Both refuse a chips atom (never grant currency) or a
tournament atom (no credit target yet); chips/tournament products are also
kept out of the by-product picker.

Tests: the console grant end to end (raw, by-product, refuse a chips pack,
CSRF-guarded).
2026-07-10 05:35:55 +02:00
Ilia Denisov 0036b55618 feat(admin): product catalog editor
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The /_gm console gains a Catalog editor — the source of truth for products.
Create / edit / archive-unarchive products, their atom composition and per-rail
prices (RUB via direct / VOTE via vk / XTR via telegram / CHIP value), and
hard-delete only a never-transacted product (an order or ledger reference forces
archive-only, backed by the FK RESTRICT). The archived flag reuses the existing
product.active. Activation revalidates the sellable shape — a pack (the chips
atom ⇒ a money price per rail, chips-only) or a value (no chips ⇒ a CHIP price);
a tournament-bearing product is composable but never sellable yet.

Backed by payments AdminCatalog / CreateProduct / UpdateProduct /
SetProductActive / DeleteProduct + a pure validateProduct.

Tests: validateProduct (pack / value / tournament / duplicate / shape); the
console editor end to end (create, edit, archive, delete-if-clean, refuse a
transacted delete).
2026-07-10 05:18:54 +02:00
Ilia Denisov e089a0a997 feat(admin): per-user finance panel — balances, benefits, risk, ledger
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 28s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
The /_gm user card gains a Finance panel: an account's chip balances per
funding segment, benefits per origin (hints, no-ads until/forever), the
recorded refund risk (abuse flag + floor-0 loss), and the append-only ledger
history newest-first. Backed by a new payments.AccountStatement read straight
from the materialized tables + the ledger (uncached — an admin, rare view).

Folds the agreed admin / reports / catalog plan into PLAN.md (the PR stack:
this panel, then the catalog editor, admin grant, refund + ledger export) and
moves the tournament-entry storage design to the tournament stage.
2026-07-10 04:55:55 +02:00
Ilia Denisov 13be7c3d9a feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Interstitial video after a confirmed play or a hint, VK-only, offline
banner-only. The gate is client-mirrored: the backend puts the config
cooldowns (global 5m / vs_ai 30m / hint 1m) and a suppressed flag (the
no-ads / no_banner gate, same as the banner) on Profile.ads via adsFor;
the client self-gates on a per-kind last-shown time in localStorage, with
the VITE_ADS_STUB contour "ad fired" toast. Never fires after a pass,
exchange or resign. maybeShowInterstitial + vkShowInterstitial; the codec
and gateway transcode carry the ads block.

Also retires the deprecated accounts.hint_balance / paid_account domain
usage (expand-contract, code only — the columns stay for a later DROP so
image rollback stays DB-safe): drop the Account fields and their scan, the
dead account.SpendHint, account.GrantHints and the admin grant-hints
action; the in-game hint display now comes wholly from the payments hint
benefit. Banner eligibility and account merge no longer read the legacy
flags.

Tests: ads.test.ts (the client-mirrored gate), the codec ads round-trip,
the gateway ads transcode test, and a profile ads-config integration test
(cooldowns + suppressed under no-ads / no_banner). Docs: PAYMENTS §10 (+ru)
interstitial, the decision amends, backend README, the plan.
2026-07-10 02:48:10 +02:00
Ilia Denisov 9acf6ab3b4 fix(payments): VK-iOS freeze is purchase-only; remove the rewarded diagnostic
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m5s
Testing the rewarded slice surfaced a contradiction: rewarded ads let a VK-iOS
user earn vk chips, but the blanket VK-iOS "spend freeze" then blocked spending
them (the wallet showed "5 (view-only)"). Apple's ToS forbids only BUYING in-app
values on VK-iOS (money -> chips), not spending or earning them — so the freeze is
corrected to purchase-only: vkFrozen() now gates only CreateOrder (the money-in
step), not spendableSources (spending). VK-wallet chips — earned via rewarded ads
or bought on the same account elsewhere (VK Android) — now spend on VK-iOS too.
(Owner's ToS finding.)

Also: the temporary contour diagnostic confirmed VK returns only {result:true} for
a rewarded view (no token/signature) — client-attested is final, no hardening
possible — so the diagnostic (the log and the diag wire field) is removed.

Docs: PAYMENTS(+ru) VK-iOS freeze + D17 amend + PLAN E6. Tests updated (gate /
wallet VK-iOS now spendable).
2026-07-10 01:27:26 +02:00
Ilia Denisov dbd76d53e8 feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The first ads slice: a voluntary rewarded video credits chips. VK Mini App ads
(VKWebAppShowNativeAds) expose only a client-side watch result — no
server-to-server verify — so the credit is client-attested, guarded by a server
daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10).
The caps are both anti-abuse (bounding a forger who skips the ad and calls the
endpoint directly) and an economic conversion lever (limiting free chips so a
player who wants more buys). D29 amended to VK's reality.

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 (reward_chips gates the
"watch for chips" CTA). Additive migration (two config columns).

Client: the ads-network abstraction (lib/ads.ts, VK impl) + the VK bridge
(vkRewardedReady / vkShowRewarded) + the Wallet CTA + i18n. A contour test stub
(VITE_ADS_STUB -> a toast instead of a real ad; prod always real) and a temporary
diagnostic that logs the raw VK data, so we confirm on the contour exactly what
VK returns (harden to signature-verify if it carries one).

Tests: backend integration (credit, nonce idempotency, hourly cap, disabled,
non-VK refusal) + codec unit (reward wire). Docs: PAYMENTS(+ru) §10, D29 amend,
PLAN E6. Bundle: shared budget 30->31 (reward i18n strings).
2026-07-10 00:41:42 +02:00
Ilia Denisov 21fb14facf feat(payments): refund engine (best-effort revoke, never negative)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The last money-intake slice: reverse a paid order best-effort, exactly once. All
refunds are admin-triggered (E7) — no rail pushes an unsolicited refund (Robokassa
via its refund API / cabinet, VK via support, Telegram via refundStarPayment), so
this ships the engine they all converge on, not a webhook.

The Refund method matches the paid order, appends a refund ledger row (idempotent on
(provider, provider_refund_id) — distinct from the fund's payment id, so both
coexist), and 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 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 the snapshot; the order stays paid.

Additive migration (a new table only) -> rollback-safe, no contour wipe. Robokassa
refund-status polling is deferred (a worker not worth it at low chargeback volume);
failed events are not wired (no rail signals a hard post-charge server decline).

Tests: integration (full revoke; revoke-after-spend = floor-0 + loss + abuse;
duplicate idempotent; unpaid-order guard). Docs: PAYMENTS(+ru) §9, PLAN (E5 -> DONE).
2026-07-09 23:21:10 +02:00
Ilia Denisov 6e03ce0131 feat(payments): Telegram Stars payment rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
Accept real money via Telegram Stars (XTR) — the third intake rail
alongside Robokassa (direct) and VK Votes.

Only the bot reaches Telegram, so the rail funnels through the reverse
mTLS bot-link:
- the gateway mints the invoice on a CreateInvoice command (the bot
  calls createInvoiceLink, XTR; the link goes to WebApp.openInvoice);
- the bot gates each pre_checkout_query via a ValidatePreCheckout unary
  (the order must exist, be still creditable and not already paid — the
  reusable-invoice double-pay guard; the decline reason is localised to
  the order account's language);
- a completed successful_payment is queued in a durable pure-Go SQLite
  outbox and forwarded via a ForwardPayment unary, credited once
  (idempotent on telegram_payment_charge_id, honours an expired order),
  re-driven on restart and every 30s.

The rail is wired by TELEGRAM_STARS_OUTBOX_DIR (default /data) but stays
inert until a chip pack carries an XTR price, so seeding a Stars price in
the admin is the go-live.

Tests: backend integration (order->forward->credit once, duplicate,
pre_checkout gate) + bot outbox unit (idempotent, restart re-drive) +
executor createInvoice. Docs: PAYMENTS(+ru) §9, ARCHITECTURE, the
platform/telegram README, PLAN.
2026-07-09 21:35:29 +02:00
Ilia Denisov 3bb9f10fad feat(payments): VK Votes payment rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Wire the VK Mini Apps ("голоса") rail end to end, reusing the intake engine. The
wallet.order endpoint branches by rail: a VK context opens a pending order
(provider vk) and returns its id, which the client passes to VKWebAppShowOrderBox.
VK's two-phase payment callback is verified at the gateway with the app protected
key (GATEWAY_VK_APP_SECRET) and proxied to a backend intake handler: get_item
returns the ordered pack's title and vote price; a chargeable order_status_change
credits the vk segment exactly once (the same Fund, idempotent on VK's own order
id) and records a succeeded event, so the dispatcher push refreshes the wallet.
Integration test for the VK order->credit path.
2026-07-09 19:27:57 +02:00
Ilia Denisov 3367cc2bf1 feat(payments): payment-event dispatcher + the provider-return UX
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
Deliver payment_events to connected clients as an in-app wallet-refresh push: a
background dispatcher drains undispatched events and publishes a KindNotification
"payment" signal, marking each delivered; the client bumps a wallet-refresh
counter the open Wallet screen watches, re-fetching in place. A return-focus
refetch is the fallback. The Robokassa Success/Fail return now serves a
self-closing page (the payment opens in a separate window) so the customer drops
back into the live app instead of a cold start. Integration test for the event
drain/mark queue.
2026-07-09 18:26:06 +02:00
Ilia Denisov 4f6c22d669 feat(gateway): the Robokassa /pay/ edge routes
Add the public /pay/robokassa/result callback proxy (rate-limited; forwards the
provider's form parameters to the backend intake, the single writer, and echoes
its "OK<InvId>" back to Robokassa) and the /pay/robokassa/{success,fail}
browser-return redirects into the app. Route /pay/* to the gateway in the
contour Caddyfile so the callback reaches the edge, not the landing catch-all.
The backend intake now returns the echo body as JSON for the gateway to relay.
2026-07-09 17:33:22 +02:00
Ilia Denisov a66a5bfa08 test(payments): integration coverage for the intake credit path
Cover the order→callback→credit path over Postgres: a funded order credits its
segment exactly once; a replayed callback for the same order credits nothing
(the ledger idempotency index holds); a mismatched paid amount is refused with
no write; an expired pending order is still honoured by a late valid callback.
2026-07-09 17:04:14 +02:00
Ilia Denisov 36a5730e52 feat(payments): direct-rail order + intake handlers, config and reaper
Wire the Robokassa direct rail into the backend transport. POST
/api/v1/user/wallet/order (walletGate + a D36 confirmed-email gate for the
direct rail) opens a pending order and returns the signed Robokassa payment
URL. The internal, gateway-only /payments/robokassa/result endpoint verifies
the Result signature, credits the matched order exactly once via Fund (honoured
even if expired), records a succeeded payment event, and answers Robokassa's
"OK<InvId>". Add the Robokassa env config, an account HasConfirmedEmail check
(D36), the payment_events writer, and a periodic pending-order reaper. The
routes register only when a Robokassa merchant login is configured.
2026-07-09 16:59:34 +02:00
Ilia Denisov 7860efce48 feat(payments): order-flow and fund credit engine + the Robokassa adapter
Add the payment-intake write path (provider-agnostic) and the Robokassa
direct-rail glue, both unit-tested; transport, wire and UI follow.

- payments: extend the ledger insert to thread order_id/provider/
  provider_payment_id (spend/grant pass nil); add the order store
  (create/read/expire + a pack-price loader) and the fund credit — a
  fund ledger row + a guarded balance upsert + mark-paid in one tx,
  idempotent on the (provider, provider_payment_id) unique index, cache
  invalidated after commit. A valid callback is honoured even on an
  expired order. Service CreateOrder/Fund/ExpireOrders; Money.Major for
  the provider amount field.
- robokassa: build the signed hosted-payment URL (SHA-256, order id via
  Shp_order, InvId unused) and verify the Result callback signature
  (Password2), extracting the order and amount. Receipt/fiscalisation is
  configured shop-side, so no Receipt parameter is sent.
2026-07-09 16:48:48 +02:00
Ilia Denisov 4aa6174968 feat(payments): wallet screen with balances, benefits and storefront
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
Add the "Кошелёк" section to the settings hub: context-visible chip
balances, active benefits (no-ads term/forever, hints) and a storefront of
chip-priced values and money-priced chip packs. Guests have no wallet; the
Google Play build hides the money purchases behind a RuStore stub; a web
purchase that would draw VK/Telegram chips warns first.

Add the catalog read path the storefront needs — a context-projected
GET /api/v1/user/wallet/catalog (payments service + store, gateway op
wallet.catalog, FBS Catalog/CatalogProduct/CatalogAtom, client decode) —
plus the client leg for the existing wallet.get/buy ops. Value spends reuse
the existing spend path; the chip-pack purchase (money order flow) arrives
with payment intake, so its action is a disabled placeholder for now.

Covered by Go unit (catalog projection) + integration (/wallet/catalog over
Postgres), vitest (formatting, spendable selection, web-spend warning, GP
flag, codec + gateway encode round-trips) and Playwright mock e2e (render,
guest-hidden, GP stub, warning) on Chromium + WebKit.
2026-07-08 12:37:32 +02:00