Merge pull request 'release: YooKassa direct rail (v1.26.0)' (#294) from development into master

This commit was merged in pull request #294.
This commit is contained in:
2026-07-28 11:06:39 +00:00
53 changed files with 4418 additions and 251 deletions
+45
View File
@@ -241,6 +241,51 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0E9). 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.
- **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 (`"`→`&quot;`) but
signs a different form → the gateway logs `vk callback: bad signature` and rejects it (get_item
+14 -12
View File
@@ -371,13 +371,14 @@ jobs:
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
# Direct-rail (Robokassa) sandbox intake on the contour: the test shop's merchant login +
# Password1/Password2. IsTest is forced to 1 below so the contour can never take real money
# (independent of the shop's own mode). Empty login leaves the direct rail off.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.TEST_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: "1"
# Direct-rail (YooKassa) intake on the contour: the credentials of a YooKassa TEST shop,
# which takes test cards and moves no real money. YOOKASSA_WEB_TEST is forced to 1 so the
# contour refuses to credit anything but a test payment, whatever credentials are set.
# Empty credentials leave the direct rail off.
YOOKASSA_WEB_SHOP_ID: ${{ secrets.TEST_BACKEND_YOOKASSA_WEB_SHOP_ID }}
YOOKASSA_WEB_SECRET_KEY: ${{ secrets.TEST_BACKEND_YOOKASSA_WEB_SECRET_KEY }}
YOOKASSA_WEB_TEST: "1"
YOOKASSA_VAT_CODE: ${{ vars.TEST_BACKEND_YOOKASSA_VAT_CODE }}
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
@@ -576,15 +577,16 @@ jobs:
- name: Probe the /pay/ callback route reaches the gateway
run: |
set -u
# /pay/robokassa/result must reach the gateway, not fall to the landing catch-all. An
# unsigned probe is rejected downstream, so the gateway answers a 4xx/5xx (never a 200 or
# a 404 landing.html), which proves the edge route is wired.
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/pay/robokassa/result 2>&1 || true)"
# /pay/yookassa/notify must reach the gateway, not fall to the landing catch-all. The probe
# is a bare GET from an address that is not one of YooKassa's senders, so it is rejected
# downstream and the gateway answers a 4xx/5xx (never a 200 or a 404 landing.html), which
# proves the edge route is wired.
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/pay/yookassa/notify 2>&1 || true)"
echo "$out" | grep -E "HTTP/" || true
if echo "$out" | grep -qE "HTTP/1\.1 (4|5)[0-9][0-9]"; then
echo "ok: /pay/ reaches the gateway (non-landing response)"
else
echo "FAIL: /pay/robokassa/result did not reach the gateway (landing catch-all?)"
echo "FAIL: /pay/yookassa/notify did not reach the gateway (landing catch-all?)"
docker logs --tail 50 scrabble-gateway || true
exit 1
fi
+12 -8
View File
@@ -99,14 +99,18 @@ jobs:
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# Robokassa direct-rail (backend BACKEND_ROBOKASSA_*): the prod shop login + the pass phrases
# that sign the launch request / verify the Result callback, and the test-mode flag — a var so
# go-live is a flag flip, not a secret redeploy ("1" runs test payments against the test
# passwords; empty/"0" is live). An empty login leaves the direct rail disabled.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
# YooKassa direct rail (backend BACKEND_YOOKASSA_*), one merchant shop per channel: the shop
# id + secret key, and a test-shop flag — a var, so pointing prod at a test shop is a flag
# flip rather than a secret rotation ("1" refuses to credit anything but a test payment;
# empty/"0" is live). Empty credentials leave that channel — or the whole rail disabled.
# The VAT rate code on every receipt line is a var too, so a rate change needs no code change.
YOOKASSA_WEB_SHOP_ID: ${{ secrets.PROD_BACKEND_YOOKASSA_WEB_SHOP_ID }}
YOOKASSA_WEB_SECRET_KEY: ${{ secrets.PROD_BACKEND_YOOKASSA_WEB_SECRET_KEY }}
YOOKASSA_WEB_TEST: ${{ vars.PROD_BACKEND_YOOKASSA_WEB_TEST }}
YOOKASSA_ANDROID_SHOP_ID: ${{ secrets.PROD_BACKEND_YOOKASSA_ANDROID_SHOP_ID }}
YOOKASSA_ANDROID_SECRET_KEY: ${{ secrets.PROD_BACKEND_YOOKASSA_ANDROID_SECRET_KEY }}
YOOKASSA_ANDROID_TEST: ${{ vars.PROD_BACKEND_YOOKASSA_ANDROID_TEST }}
YOOKASSA_VAT_CODE: ${{ vars.PROD_BACKEND_YOOKASSA_VAT_CODE }}
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
+8 -5
View File
@@ -61,12 +61,15 @@ jobs:
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# Robokassa direct-rail: the rollback re-renders the same runtime env (write-prod-env.sh), so
# YooKassa direct rail: the rollback re-renders the same runtime env (write-prod-env.sh), so
# it must carry the same credentials or the direct rail goes dark after a rollback.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
YOOKASSA_WEB_SHOP_ID: ${{ secrets.PROD_BACKEND_YOOKASSA_WEB_SHOP_ID }}
YOOKASSA_WEB_SECRET_KEY: ${{ secrets.PROD_BACKEND_YOOKASSA_WEB_SECRET_KEY }}
YOOKASSA_WEB_TEST: ${{ vars.PROD_BACKEND_YOOKASSA_WEB_TEST }}
YOOKASSA_ANDROID_SHOP_ID: ${{ secrets.PROD_BACKEND_YOOKASSA_ANDROID_SHOP_ID }}
YOOKASSA_ANDROID_SECRET_KEY: ${{ secrets.PROD_BACKEND_YOOKASSA_ANDROID_SECRET_KEY }}
YOOKASSA_ANDROID_TEST: ${{ vars.PROD_BACKEND_YOOKASSA_ANDROID_TEST }}
YOOKASSA_VAT_CODE: ${{ vars.PROD_BACKEND_YOOKASSA_VAT_CODE }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
+66 -2
View File
@@ -3,7 +3,7 @@
Technical, step-by-step implementation of the monetization domain. Business mechanics and
the rationale for every rule live in [`docs/PAYMENTS.md`](docs/PAYMENTS.md) (RU mirror
[`docs/PAYMENTS_ru.md`](docs/PAYMENTS_ru.md)); the frozen owner agreements they both derive
from (the `D1``D41` decisions log) live in
from (the `D1``D51` decisions log) live in
[`docs/PAYMENTS_DECISIONS_ru.md`](docs/PAYMENTS_DECISIONS_ru.md) — the authority when a rule
is disputed. This file is the *how*. Each stage is written to be self-sufficient: returning
to it gives full context — goal, exact touch-points, tests, done-criteria, and current
@@ -11,7 +11,7 @@ status — without re-deriving decisions.
## How to use this file
- **Stage granularity (E0E9) is fixed.** Do not split or merge stages. Plan each stage
- **Stage granularity (E0E12) is fixed.** Do not split or merge stages. Plan each stage
densely enough to execute whole.
- **Never leak stage ids into the product.** Code, comments, commit messages, PR
titles/descriptions must read as finalized feature copy — never "E5", "stage 3", etc.
@@ -41,6 +41,7 @@ status — without re-deriving decisions.
| E9 | Tournament fee | future | TODO |
| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | DONE |
| E11 | Payment availability kill switch + per-account override | 2 | DONE |
| E12 | YooKassa direct rail (Robokassa retired) | 2+ | DONE |
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
@@ -1012,6 +1013,69 @@ so nothing is disabled until an operator acts.
---
## E12 — YooKassa direct rail (Robokassa retired)
**Status:** DONE · **Release 2+ (money-live)** · depends on: E5 (intake/`Fund`, the order flow), E7
(the admin refund + report), E10 (per-channel shops), E11 (the kill switch) · mechanics: PAYMENTS §2,
§9, §11, §12; decisions D47D51 (revising D41).
**Delivered.** The `direct` (RUB) rail settles through **YooKassa** instead of Robokassa. The wallet
model is untouched — one `direct` segment, the same spend wall, the same per-channel shops (D42) and
`shop` on the order (D44). Robokassa is **dormant, not deleted**: the direct rail falls back to it
when no YooKassa shop is configured, so reviving it is a credentials change (D47).
- **Provider glue** — `backend/internal/yookassa`: an API client (`CreatePayment` / `GetPayment` /
`CreateRefund`, HTTP Basic, `Idempotence-Key`), a per-channel `Shops` registry with `ByShopID` for
attributing a notification, the notification envelope + the sender-IP allowlist, and the fiscal
receipt builder. No DB, no payments-domain coupling — the same shape as the `robokassa` package.
- **Order** — `handleWalletOrder` prefers a YooKassa shop and otherwise falls through to Robokassa.
It creates the payment (order id as both `Idempotence-Key` and `metadata.order_id`), records the
provider payment id on the order (`AttachProviderPayment`; no migration — the column existed) and
returns `confirmation_url`. D36's email anchor now also feeds the receipt (`ConfirmedEmail`).
- **Notification** — `/pay/yookassa/notify` (gateway: rate limit + raw-body proxy; backend: the
confirming `GetPayment`). Nothing in the body is acted on (D48). Guards: the payment's metadata must
name the order, and its `test` flag must match the shop's. The sender address is deliberately not
checked (D48 rev) — it adds no security, and the order lookup that precedes any provider call is
the tighter guard against a forger driving traffic at the provider through us.
`payment.canceled` records a `failed` event. 200 = durably decided, 5xx = redeliver.
- **Reconcile** — `runOrderReaper` asks the provider about each pending order older than
`payments.ReconcileAfter` (a minute) carrying a payment id, and credits the ones really paid
(D49 rev — the threshold is deliberately not the order lifetime, which answers a different
question and made a failed notification cost the customer half an hour).
- **Refund** — the `/_gm` button calls `POST /v3/refunds` first and records only on a **succeeded**
refund, under the provider's own refund id (D50); a failure — or a still-`pending` refund — records
nothing, and pressing again is safe. `refund.succeeded` is handled too (D52), because the merchant
cabinet can issue a refund that never passes through our API; a **partial** refund records nothing
and is logged for an operator (the engine is full-refund-only).
- **Fiscalization** — **no receipt is sent** (D51 rev): the merchant is on НПД, outside 54-ФЗ, and
YooKassa does not serve receipts for that regime; reporting goes to «Мой налог» instead (its own
task — the data it needs is already recorded, bar a per-operation "reported" marker). The fiscal
code stays dormant behind `BACKEND_YOOKASSA_VAT_CODE`: unset sends nothing, a 54-ФЗ rate code turns
«Чеки от ЮKassa» back on — the path a lost НПД regime (annual income ceiling) would take.
- **Email anchor** — D36 still gates a direct purchase (the recovery anchor stands on its own now
that the receipt address is gone). The wallet now says so **before** the buy tap: a player signed in
through VK/Telegram in a browser sees "add an email in your profile" instead of a bare error.
- **Config/deploy** — `BACKEND_YOOKASSA_{WEB,ANDROID}_{SHOP_ID,SECRET_KEY,TEST}` +
`BACKEND_YOOKASSA_VAT_CODE`; every `ROBOKASSA_*` mapping removed from compose, `.env.example`,
`write-prod-env.sh` and the three workflows, and recorded in `backend/internal/robokassa/README.md`.
The CI edge probe now targets `/pay/yookassa/notify`.
- **Tests** — unit (`yookassa`: request building, receipt, idempotence key, error retryability, shop
registry, IP allowlist); integration (`payments_yookassa_test.go`: credit-once + redelivery, a
forged notification credits nothing, a foreign sender is refused, a live payment on a test shop is
refused, a decline records `failed`, the reconcile sweep credits a lost notification and leaves an
unpaid order alone, the refund moves money then records — and records nothing when it fails, the
order path mints a payment with a receipt, D36 still gates the rail, a cabinet refund is reversed
once, the event after a console refund is a no-op, a partial refund changes nothing, an unconfirmed
refund reverses nothing, and a pending refund records nothing until it settles).
**Contour-safe:** no migration, no wire change; the client is rail-agnostic (only the mock URL and an
e2e assertion name a provider).
**Verified on the contour** against a YooKassa **test shop** (test mode exists — a separate test shop
with its own credentials and test cards), then in prod with one real payment.
---
## Verification & CI (all stages)
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
+16 -4
View File
@@ -335,7 +335,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Notifier: hub,
ExportSignKey: cfg.ExportSignKey,
Renderer: renderer,
Robokassa: cfg.Robokassa,
YooKassa: cfg.YooKassa,
YooKassaVatCode: cfg.YooKassaVatCode,
PublicBaseURL: cfg.PublicBaseURL,
Robokassa: cfg.Robokassa,
})
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
@@ -345,8 +349,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.String("http_addr", cfg.HTTPAddr),
zap.String("grpc_addr", cfg.GRPCAddr))
// Sweep expired pending payment orders on a cadence (cosmetic hygiene; a late valid callback
// still credits). Runs until ctx is cancelled.
go runOrderReaper(ctx, paymentsSvc, logger)
// still credits), asking the provider about each one first. Runs until ctx is cancelled.
go runOrderReaper(ctx, paymentsSvc, srv, logger)
// Deliver pending payment_events to connected clients as an in-app wallet-refresh push (the
// credit already landed in the ledger; a return-focus poll is the client-side fallback).
go runPaymentDispatcher(ctx, paymentsSvc, hub, logger)
@@ -363,7 +367,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// runOrderReaper periodically expires pending payment orders past their configured lifetime, until
// ctx is cancelled. Expiry is cosmetic: a later valid provider callback still credits an expired
// order.
func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) {
//
// Before writing an order off it asks the provider what really happened to it — the one status check
// each order gets, and the safety net for a notification that was lost for good (YooKassa redelivers
// for 24 hours, so only a permanently lost one reaches here). A rail with no such check contributes
// nothing to this pass.
func runOrderReaper(ctx context.Context, p *payments.Service, srv *server.Server, log *zap.Logger) {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
@@ -371,6 +380,9 @@ func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) {
case <-ctx.Done():
return
case <-t.C:
if n := srv.ReconcileYooKassaOrders(ctx); n > 0 {
log.Info("order reaper: credited orders confirmed by the provider", zap.Int("count", n))
}
if n, err := p.ExpireOrders(ctx); err != nil {
log.Warn("order reaper: sweep failed", zap.Error(err))
} else if n > 0 {
+11 -3
View File
@@ -397,16 +397,24 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity
// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail
// recovery anchor a first purchase requires (D36).
func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
_, ok, err := s.ConfirmedEmail(ctx, accountID)
return ok, err
}
// ConfirmedEmail returns the account's confirmed email address, the oldest one first when several
// exist. Besides being the direct-rail recovery anchor (D36) it is where the rail's fiscal receipt
// is delivered, so the purchase path reads the address itself rather than only its presence.
func (s *Store) ConfirmedEmail(ctx context.Context, accountID uuid.UUID) (string, bool, error) {
ids, err := s.Identities(ctx, accountID)
if err != nil {
return false, err
return "", false, err
}
for _, id := range ids {
if id.Kind == "email" && id.Confirmed {
return true, nil
return id.ExternalID, true, nil
}
}
return false, nil
return "", false, nil
}
// ListAccounts returns accounts for the admin user list, newest first, paginated
@@ -23,6 +23,7 @@
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
<a href="/_gm/catalog"{{if eq .ActiveNav "catalog"}} class="active"{{end}}>Catalog</a>
<a href="/_gm/ledger"{{if eq .ActiveNav "ledger"}} class="active"{{end}}>Ledger</a>
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
<a href="/_gm/grafana/">Grafana ↗</a>
@@ -0,0 +1,64 @@
{{define "content" -}}
<h1>Ledger</h1>
{{with .Data}}
<form class="form" method="get" action="/_gm/ledger">
<label>From <input type="date" name="from" value="{{.From}}"></label>
<label>To <input type="date" name="to" value="{{.To}}"></label>
<select name="kind">
<option value="">any kind</option>
{{range .Kinds}}<option value="{{.}}"{{if eq . $.Data.Kind}} selected{{end}}>{{.}}</option>{{end}}
</select>
<select name="wallet">
<option value="">any wallet</option>
{{range .Wallets}}<option value="{{.}}"{{if eq . $.Data.Wallet}} selected{{end}}>{{.}}</option>{{end}}
</select>
<select name="provider">
<option value="">any rail</option>
{{range .Providers}}<option value="{{.}}"{{if eq . $.Data.Provider}} selected{{end}}>{{.}}</option>{{end}}
</select>
<input name="user" value="{{.UserID}}" placeholder="user id" size="36">
<button type="submit">Filter</button>
<a class="export" href="/_gm/ledger.csv?{{.FilterQuery}}">Export CSV ↓</a>
</form>
<p class="note">Defaults to the last 30 days. The wallet filter matches the funded segment or the
benefit origin; the rail filter matches the settling provider, so chip spends (which have no rail)
drop out of it. <a href="/_gm/ledger">clear filters</a></p>
<section class="panel">
<h2>Totals for the filtered range</h2>
<p>
Money in: {{range .Totals.MoneyIn}}<strong>{{.}}</strong> {{else}}<span class="note">none</span>{{end}}
· Refunded: {{range .Totals.MoneyRefunded}}<strong>{{.}}</strong> {{else}}<span class="note">none</span>{{end}}
· Chips credited: <strong>{{.Totals.ChipsIn}}</strong>
· Chips spent: <strong>{{.Totals.ChipsOut}}</strong>
</p>
</section>
<table class="list">
<thead><tr><th>Time</th><th>Account</th><th>Kind</th><th>Money</th><th>Chips</th><th>What</th><th>Wallet</th><th>Rail</th><th>Shop</th><th></th></tr></thead>
<tbody>
{{range .Rows}}
<tr>
<td>{{.At}}</td>
<td><a href="/_gm/users/{{.AccountID}}">card</a></td>
<td>{{.Kind}}</td>
<td>{{.Money}}</td>
<td>{{.ChipsDelta}}</td>
<td>{{.Title}}{{if .Order}} <code>{{.Order}}</code>{{end}}</td>
<td>{{.Source}}{{if and .Origin (ne .Origin .Source)}} → {{.Origin}}{{end}}</td>
<td>{{.Provider}}</td>
<td>{{.Shop}}</td>
<td>{{if .Refundable}}<form class="form" method="post" action="/_gm/users/{{.AccountID}}/refund" onsubmit="return confirm('Refund this order in full? On the direct rail this moves the money back through the provider and then revokes the chips (floored at 0); on the other rails refund on the rail first.')"><input type="hidden" name="order_id" value="{{.Order}}"><input type="hidden" name="back" value="/_gm/ledger?{{$.Data.FilterQuery}}"><button type="submit">Refund</button></form>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="10"><span class="note">no operations in this range</span></td></tr>
{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/ledger?{{.FilterQuery}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/ledger?{{.FilterQuery}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
@@ -82,20 +82,14 @@
</select></label>
<div><button type="submit">Save</button></div>
</form>
<h3>Ledger</h3>
{{$uid := .ID}}
{{if .Finance.Ledger}}
<table class="list">
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Shop</th><th>Detail</th><th></th></tr></thead>
<tbody>
{{range .Finance.Ledger}}
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{.Shop}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
{{end}}
</tbody>
</table>
<p class="note"><a href="/_gm/ledger.csv">Export the full ledger (CSV)</a> — all accounts, for tax + reconciliation.</p>
{{else}}<p class="note">no ledger entries</p>{{end}}
<h3>Totals</h3>
<p>
Paid: {{range .Finance.Paid}}<strong>{{.}}</strong> {{else}}<span class="note">nothing</span>{{end}}
· Refunded: {{range .Finance.Refunded}}<strong>{{.}}</strong> {{else}}<span class="note">nothing</span>{{end}}
· Chips credited: <strong>{{.Finance.ChipsBought}}</strong>
· Chips spent: <strong>{{.Finance.ChipsSpent}}</strong>
</p>
<p class="note"><a href="/_gm/ledger?{{.Finance.LedgerQuery}}">Open this account's operations in the ledger →</a> — every entry, with filters, paging and the refund action.</p>
{{else}}<p class="note">payments not enabled</p>{{end}}
</section>
<section class="panel"><h2>Grant benefits</h2>
+65 -21
View File
@@ -207,16 +207,26 @@ type UserDetailView struct {
}
// FinanceView is the account's payments picture on the user card: chip balances per funding
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
// (newest first). Present is false when the payments domain is unwired.
// segment, benefits per origin, the recorded refund risk, and a short lifetime summary. The
// operations themselves live in the ledger section, which this links to pre-filtered to the
// account — the card answers "where does this account stand", not "what happened when".
// Present is false when the payments domain is unwired.
type FinanceView struct {
Present bool
Segments []SegmentRow
Benefits []BenefitRow
// Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
Abuse bool
Loss int
Ledger []LedgerRow
Abuse bool
Loss int
// Paid is the money the account has spent, per currency, already formatted; Refunded is what
// came back. ChipsBought counts every chip ever credited (purchases, rewarded views, grants) and
// ChipsSpent every chip spent on a value.
Paid []string
Refunded []string
ChipsBought int
ChipsSpent int
// LedgerQuery is the ledger-section query string that shows this account's operations.
LedgerQuery template.URL
}
// SegmentRow is one funding segment's chip balance.
@@ -234,22 +244,6 @@ type BenefitRow struct {
Forever bool
}
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
// delta, the product / order / provider / direct-rail shop it references (empty when none), the raw
// snapshot JSON and the pre-formatted time.
type LedgerRow struct {
Kind string
Source string
Origin string
ChipsDelta int
Product string
Order string
Provider string
Shop string
Snapshot string
At string
}
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
type RelationRow struct {
@@ -764,3 +758,53 @@ type GrantProductOption struct {
Summary string
Archived bool
}
// LedgerView is the all-accounts financial ledger: one page of operations, the totals for
// everything the current filter matches (not merely the page), and the filter state itself so the
// form renders back what the operator asked for. FilterQuery is those filters URL-encoded, carried
// into the pager, the CSV export and the refund's return link so every one of them stays on the
// same slice of the ledger (see MessagesView.FilterQuery).
type LedgerView struct {
Rows []LedgerRow
Pager Pager
Totals LedgerTotalsRow
From string
To string
UserID string
Kind string
Wallet string
Provider string
Kinds []string
Wallets []string
Providers []string
FilterQuery template.URL
}
// LedgerRow is one operation in the ledger list. Money is the amount the customer paid or was
// refunded, already formatted with its currency, and empty for a row that moved no money (a chip
// spend, an admin grant, a rewarded-video credit). Refundable marks a funded order the operator may
// still reverse, which is what puts the Refund button on that row.
type LedgerRow struct {
At string
AccountID string
Kind string
Source string
Origin string
ChipsDelta int
Money string
Title string
Order string
Provider string
Shop string
Refundable bool
}
// LedgerTotalsRow is the summary above the table, over everything the filter matches. Money is
// listed per currency because the rails settle in different ones and one sum across them would mean
// nothing.
type LedgerTotalsRow struct {
MoneyIn []string
MoneyRefunded []string
ChipsIn int
ChipsOut int
}
+60 -5
View File
@@ -17,6 +17,7 @@ import (
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/telemetry"
"scrabble/backend/internal/yookassa"
)
// Config holds the backend's runtime configuration.
@@ -65,8 +66,18 @@ type Config struct {
// RendererURL is the base URL of the internal image-render sidecar (e.g.
// http://renderer:8090). Empty disables the PNG export artifact.
RendererURL string
// Robokassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
// (D42). An empty set leaves the direct order and Result-callback endpoints unregistered.
// YooKassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
// (D42). An empty set leaves the direct order and notification endpoints unregistered and falls
// the direct rail back to Robokassa.
YooKassa yookassa.Shops
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
// Unset (0) sends no receipt at all — the state a merchant outside 54-ФЗ runs in, which is the
// default; setting a rate turns fiscal receipts back on without a code change.
YooKassaVatCode int
// Robokassa configures the retired direct-rail payment provider — one merchant shop per channel
// (D42). It is dormant: no deployment sets its credentials, so the set is empty and the rail
// resolves to YooKassa. Kept wired so restoring Robokassa is a credentials change, not a code
// change — see backend/internal/robokassa/README.md.
Robokassa robokassa.Shops
}
@@ -158,9 +169,26 @@ func Load() (Config, error) {
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
}
// Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed
// the web channel so existing deploys keep working; the per-channel vars add the rest. A shop
// with no MerchantLogin is dropped (the rail stays dormant when none is configured).
// YooKassa direct rail: one merchant shop per channel (D42). A shop missing either credential is
// dropped, so the rail stays dormant until a channel is fully configured.
ykShops := yookassa.Shops{}
for channel, prefix := range map[string]string{
yookassa.ChannelWeb: "BACKEND_YOOKASSA_WEB",
yookassa.ChannelAndroid: "BACKEND_YOOKASSA_ANDROID",
} {
if shop := yookassaShop(prefix); shop.Configured() {
ykShops[channel] = shop
}
}
vatCode, err := envInt("BACKEND_YOOKASSA_VAT_CODE", yookassa.VatCodeOff)
if err != nil {
return Config{}, err
}
// Robokassa direct rail: retired but kept wired (see backend/internal/robokassa/README.md). No
// deployment sets these, so the set is empty and the direct rail resolves to YooKassa; restoring
// the credentials revives it without a code change. The legacy single-shop vars seed the web
// channel; the per-channel vars add the rest.
shops := robokassa.Shops{}
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
if web.MerchantLogin == "" {
@@ -190,6 +218,8 @@ func Load() (Config, error) {
GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
YooKassa: ykShops,
YooKassaVatCode: vatCode,
Robokassa: shops,
}
if err := c.validate(); err != nil {
@@ -248,6 +278,19 @@ func (c Config) validate() error {
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
}
}
if !yookassa.ValidVatCode(c.YooKassaVatCode) {
return fmt.Errorf("config: BACKEND_YOOKASSA_VAT_CODE %d is not a 54-ФЗ VAT rate code (1..%d); leave it unset to send no receipts", c.YooKassaVatCode, yookassa.VatCodeMax)
}
if c.YooKassa.Configured() {
// The YooKassa rail sends the customer to a hosted payment page and needs an absolute return
// URL to bring them back, which only the public base URL can supply.
if c.PublicBaseURL == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when a YooKassa shop is configured")
}
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
}
}
return nil
}
@@ -288,6 +331,18 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) {
return d, nil
}
// yookassaShop reads a YooKassa shop's credentials from the environment under prefix (e.g.
// "BACKEND_YOOKASSA_WEB" → _SHOP_ID / _SECRET_KEY / _TEST). _TEST marks a test shop, which lets the
// intake refuse to credit a live payment against test credentials and vice versa. A shop missing
// either credential yields a Config the caller drops.
func yookassaShop(prefix string) yookassa.Config {
return yookassa.Config{
ShopID: os.Getenv(prefix + "_SHOP_ID"),
SecretKey: os.Getenv(prefix + "_SECRET_KEY"),
IsTest: os.Getenv(prefix+"_TEST") == "1",
}
}
// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g.
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
// MerchantLogin yields a zero Config the caller drops.
@@ -0,0 +1,263 @@
//go:build integration
package inttest
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// seedLedgerRow writes one ledger row directly, at a chosen age, so a test can stand up a history
// that spans a date range without waiting for one.
func seedLedgerRow(t *testing.T, acc uuid.UUID, kind, source, provider string, chips int, snapshot string, age time.Duration) {
t.Helper()
var providerPaymentID any
if provider != "" {
providerPaymentID = "pp-" + uuid.NewString()
}
if _, err := testDB.ExecContext(context.Background(), `
INSERT INTO payments.ledger (ledger_id, account_id, kind, source, origin, chips_delta,
provider, provider_payment_id, snapshot, created_at)
VALUES ($1,$2,$3,$4,$4,$5,NULLIF($6,''),$7,$8::jsonb, now() - $9::interval)`,
uuid.New(), acc, kind, source, chips, provider, providerPaymentID, snapshot,
fmt.Sprintf("%d seconds", int(age.Seconds()))); err != nil {
t.Fatalf("seed ledger row: %v", err)
}
}
// ledgerReport reads a filtered page through the service, the way the console does.
func ledgerReport(t *testing.T, pay *payments.Service, f payments.LedgerFilter, limit, offset int) payments.LedgerReport {
t.Helper()
rep, err := pay.LedgerReportPage(context.Background(), f, limit, offset)
if err != nil {
t.Fatalf("ledger report: %v", err)
}
return rep
}
// TestLedgerReportFilters covers each filter axis over one seeded history, including the property
// that matters most for an operator: the totals describe the whole filtered range, not the page.
func TestLedgerReportFilters(t *testing.T) {
acc, other := provisionAccount(t), provisionAccount(t)
// Two recent purchases on different rails, one older purchase outside a narrow range, a spend
// (no rail at all) and a refund.
seedLedgerRow(t, acc, "fund", "direct", "yookassa", 100, `{"amount_minor":14900,"currency":"RUB","title":"100 chips"}`, time.Hour)
seedLedgerRow(t, acc, "fund", "vk", "vk", 50, `{"amount_minor":50,"currency":"VOTE","title":"50 chips"}`, 2*time.Hour)
seedLedgerRow(t, other, "fund", "direct", "yookassa", 30, `{"amount_minor":4900,"currency":"RUB","title":"30 chips"}`, 40*24*time.Hour)
seedLedgerRow(t, acc, "spend", "direct", "", -20, `{"title":"hints"}`, 30*time.Minute)
seedLedgerRow(t, acc, "refund", "direct", "yookassa", -100, `{"amount_minor":14900,"currency":"RUB"}`, 10*time.Minute)
pay := newPaymentsService()
// Scoped to this test's own account: the suite shares one database and other tests seed recent
// ledger rows, so an unscoped count would be a coin toss.
recent := payments.LedgerFilter{From: time.Now().Add(-24 * time.Hour), AccountID: acc}
t.Run("date range excludes older rows", func(t *testing.T) {
rep := ledgerReport(t, pay, recent, 100, 0)
for _, r := range rep.Rows {
if r.AccountID == other {
t.Error("a row from outside the range was returned")
}
}
})
t.Run("account", func(t *testing.T) {
rep := ledgerReport(t, pay, payments.LedgerFilter{AccountID: other}, 100, 0)
if rep.Total != 1 {
t.Fatalf("rows for the other account = %d, want 1", rep.Total)
}
rep = ledgerReport(t, pay, payments.LedgerFilter{AccountID: acc}, 100, 0)
if rep.Total != 4 {
t.Fatalf("rows for the account = %d, want 4", rep.Total)
}
})
t.Run("kind", func(t *testing.T) {
f := recent
f.Kind = "spend"
rep := ledgerReport(t, pay, f, 100, 0)
for _, r := range rep.Rows {
if r.Kind != "spend" {
t.Errorf("kind filter returned a %q row", r.Kind)
}
}
if rep.Total == 0 {
t.Error("kind filter matched nothing")
}
})
t.Run("wallet", func(t *testing.T) {
f := recent
f.Wallet = "vk"
rep := ledgerReport(t, pay, f, 100, 0)
if rep.Total != 1 {
t.Fatalf("vk wallet rows = %d, want 1", rep.Total)
}
})
t.Run("rail excludes spends, which have none", func(t *testing.T) {
f := recent
f.Provider = "yookassa"
rep := ledgerReport(t, pay, f, 100, 0)
for _, r := range rep.Rows {
if r.Kind == "spend" {
t.Error("a chip spend matched a rail filter, but a spend has no rail")
}
}
})
t.Run("totals cover the range, not the page", func(t *testing.T) {
f := recent
full := ledgerReport(t, pay, f, 100, 0)
firstPage := ledgerReport(t, pay, f, 1, 0)
if len(firstPage.Rows) != 1 {
t.Fatalf("page rows = %d, want 1", len(firstPage.Rows))
}
if firstPage.Total != full.Total {
t.Errorf("page total = %d, want the full match count %d", firstPage.Total, full.Total)
}
if firstPage.Totals.ChipsIn != full.Totals.ChipsIn || firstPage.Totals.ChipsOut != full.Totals.ChipsOut {
t.Errorf("page totals = %+v, want the whole range's %+v", firstPage.Totals, full.Totals)
}
// 100 + 50 credited, 20 spent + 100 refunded revoked.
if full.Totals.ChipsIn != 150 || full.Totals.ChipsOut != 120 {
t.Errorf("chip totals = in %d / out %d, want 150 / 120", full.Totals.ChipsIn, full.Totals.ChipsOut)
}
// Money is kept per currency; summing roubles and Votes together would be meaningless.
var rub, vote bool
for _, m := range full.Totals.MoneyIn {
switch m.Currency() {
case payments.CurrencyRUB:
rub = true
if m.Minor() != 14900 {
t.Errorf("RUB in = %d, want 14900", m.Minor())
}
case payments.CurrencyVote:
vote = true
}
}
if !rub || !vote {
t.Errorf("money in = %v, want both a RUB and a VOTE total", full.Totals.MoneyIn)
}
if len(full.Totals.MoneyRefunded) != 1 || full.Totals.MoneyRefunded[0].Minor() != 14900 {
t.Errorf("money refunded = %v, want one 149.00 RUB total", full.Totals.MoneyRefunded)
}
})
t.Run("money is recovered from the snapshot", func(t *testing.T) {
f := recent
f.Kind = "fund"
f.Wallet = "direct"
rep := ledgerReport(t, pay, f, 100, 0)
if len(rep.Rows) != 1 {
t.Fatalf("rows = %d, want 1", len(rep.Rows))
}
r := rep.Rows[0]
if !r.HasMoney() || r.Money.Minor() != 14900 || r.Money.Currency() != payments.CurrencyRUB {
t.Errorf("row money = %v, want 149.00 RUB", r.Money)
}
})
t.Run("a spend carries no money", func(t *testing.T) {
f := recent
f.Kind = "spend"
rep := ledgerReport(t, pay, f, 100, 0)
if len(rep.Rows) != 1 {
t.Fatalf("rows = %d, want 1", len(rep.Rows))
}
if rep.Rows[0].HasMoney() {
t.Errorf("a chip spend reported money %v", rep.Rows[0].Money)
}
})
}
// TestLedgerReportPaging checks paging walks the range without repeating or dropping a row.
func TestLedgerReportPaging(t *testing.T) {
acc := provisionAccount(t)
for i := range 5 {
seedLedgerRow(t, acc, "fund", "direct", "yookassa", 10,
`{"amount_minor":1000,"currency":"RUB"}`, time.Duration(i+1)*time.Minute)
}
pay := newPaymentsService()
f := payments.LedgerFilter{AccountID: acc}
seen := map[string]bool{}
for offset := 0; offset < 5; offset += 2 {
rep := ledgerReport(t, pay, f, 2, offset)
if rep.Total != 5 {
t.Fatalf("total = %d, want 5 at every offset", rep.Total)
}
for _, r := range rep.Rows {
key := r.ProviderPaymentID
if seen[key] {
t.Errorf("row %s appeared on two pages", key)
}
seen[key] = true
}
}
if len(seen) != 5 {
t.Errorf("walked %d rows across the pages, want 5", len(seen))
}
}
// TestLedgerConsolePageAndExport drives the console: the page renders under a filter, the CSV export
// honours that same filter, and the user card links to the ledger scoped to the account instead of
// listing the rows itself.
func TestLedgerConsolePageAndExport(t *testing.T) {
srv, _, _ := bannerServer(t)
acc, other := provisionAccount(t), provisionAccount(t)
seedLedgerRow(t, acc, "fund", "direct", "yookassa", 100, `{"amount_minor":14900,"currency":"RUB","title":"Pack A"}`, time.Hour)
seedLedgerRow(t, other, "fund", "vk", "vk", 50, `{"amount_minor":50,"currency":"VOTE","title":"Pack B"}`, time.Hour)
rec := consoleGet(t, srv, "/_gm/ledger")
if rec.Code != 200 {
t.Fatalf("ledger page = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"Pack A", "Pack B", "149.00 RUB", "Totals for the filtered range"} {
if !strings.Contains(body, want) {
t.Errorf("ledger page does not show %q", want)
}
}
// Filtering to one account must drop the other's operation from the page.
rec = consoleGet(t, srv, "/_gm/ledger?user="+acc.String())
if body := rec.Body.String(); !strings.Contains(body, "Pack A") || strings.Contains(body, "Pack B") {
t.Error("the user filter did not scope the ledger page to one account")
}
// The pager links must carry the filters, or paging would silently widen the view.
if body := rec.Body.String(); strings.Contains(body, "page=") && !strings.Contains(body, "user="+acc.String()+"&amp;page=") {
t.Error("a pager link dropped the active filters")
}
// The export honours the same filter as the screen it is linked from.
rec = consoleGet(t, srv, "/_gm/ledger.csv?user="+acc.String())
if rec.Code != 200 {
t.Fatalf("ledger export = %d, want 200", rec.Code)
}
csv := rec.Body.String()
if !strings.Contains(csv, "Pack A") || strings.Contains(csv, "Pack B") {
t.Error("the CSV export ignored the filter")
}
if !strings.Contains(csv, "amount_minor,currency") {
t.Error("the CSV export has no money columns")
}
// The user card is a summary now: totals and a link into the ledger, not the rows themselves.
rec = consoleGet(t, srv, "/_gm/users/"+acc.String())
card := rec.Body.String()
if !strings.Contains(card, "/_gm/ledger?user="+acc.String()) {
t.Error("the user card does not link to its ledger slice")
}
if !strings.Contains(card, "Chips credited") {
t.Error("the user card shows no totals summary")
}
}
@@ -70,7 +70,8 @@ func TestAccountStatement(t *testing.T) {
}
// TestConsoleFinancePanel checks the user card renders the finance panel: a funded pack + an admin
// grant surface as the segment balance, the benefit and the ledger rows.
// grant surface as the segment balance, the benefit and the lifetime summary. The operations
// themselves belong to the ledger section, which the card links to scoped to this account.
func TestConsoleFinancePanel(t *testing.T) {
ctx := context.Background()
srv, _, pay := bannerServer(t)
@@ -93,9 +94,17 @@ func TestConsoleFinancePanel(t *testing.T) {
if code != http.StatusOK {
t.Fatalf("user card = %d, want 200", code)
}
for _, want := range []string{"Finance", "Chips (direct)", "Benefits (direct)", "5 hints", "admin_grant", "fund"} {
for _, want := range []string{
"Finance", "Chips (direct)", "Benefits (direct)", "5 hints",
"Chips credited", "149.00 RUB", // the lifetime summary: chips in, money paid
"/_gm/ledger?user=" + id.String(), // the hand-off to the operations view
} {
if !strings.Contains(body, want) {
t.Errorf("finance panel missing %q", want)
}
}
// The card must not re-render the operations the ledger section owns.
if strings.Contains(body, "admin_grant") {
t.Error("the user card still lists ledger rows; they belong to /_gm/ledger now")
}
}
@@ -0,0 +1,866 @@
//go:build integration
package inttest
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/server"
"scrabble/backend/internal/yookassa"
)
// The test shop the fake API answers for. IsTest is true throughout, matching a YooKassa test shop.
const (
ykShopID = "100500"
ykSecretKey = "test_secret"
// ykSenderIP is inside YooKassa's published notification ranges; the intake rejects anything else.
ykSenderIP = "185.71.76.1"
)
// fakeYooKassa is a stand-in for the YooKassa API. Tests set the payment it reports and whether a
// refund succeeds, then assert on what the intake did with that answer.
type fakeYooKassa struct {
mu sync.Mutex
// payments answers GET /payments/{id}; a missing id is a 404, as it is for a payment we never made.
payments map[string]yookassa.Payment
// refundFails makes POST /refunds answer 500, standing in for a provider that did not move money.
refundFails bool
// refundStatus is the status a created refund reports; empty means succeeded.
refundStatus string
// refundCalls counts the refunds actually requested.
refundCalls int
// paymentGets counts the confirming reads, so a test can assert a forged notification never
// reached the provider at all.
paymentGets int
// refunds answers GET /refunds/{id}; a missing id is a 404.
refunds map[string]yookassa.Refund
// createdIdempotenceKey records the key sent on the last create-payment call.
createdIdempotenceKey string
// lastReceipt records the receipt object of the last create-payment call.
lastReceipt map[string]any
}
// newFakeYooKassa starts the fake API and returns it with a shop config pointed at it.
func newFakeYooKassa(t *testing.T) (*fakeYooKassa, yookassa.Config) {
t.Helper()
f := &fakeYooKassa{payments: map[string]yookassa.Payment{}, refunds: map[string]yookassa.Refund{}}
srv := httptest.NewServer(http.HandlerFunc(f.serve))
t.Cleanup(srv.Close)
return f, yookassa.Config{ShopID: ykShopID, SecretKey: ykSecretKey, IsTest: true, BaseURL: srv.URL}
}
func (f *fakeYooKassa) serve(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/payments":
var body struct {
Amount yookassa.Amount `json:"amount"`
Metadata map[string]string `json:"metadata"`
Receipt map[string]any `json:"receipt"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
f.createdIdempotenceKey = r.Header.Get("Idempotence-Key")
f.lastReceipt = body.Receipt
id := "pay-" + body.Metadata[yookassa.MetadataOrderID]
p := yookassa.Payment{
ID: id, Status: yookassa.StatusPending, Test: true, Amount: body.Amount,
Confirmation: yookassa.Confirmation{Type: yookassa.ConfirmationRedirect, ConfirmationURL: "https://yoomoney.test/pay/" + id},
Metadata: body.Metadata,
Recipient: yookassa.Recipient{AccountID: ykShopID},
}
f.payments[id] = p
_ = json.NewEncoder(w).Encode(p)
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/payments/"):
f.paymentGets++
p, ok := f.payments[strings.TrimPrefix(r.URL.Path, "/payments/")]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"type":"error","description":"not found"}`))
return
}
_ = json.NewEncoder(w).Encode(p)
case r.Method == http.MethodPost && r.URL.Path == "/refunds":
if f.refundFails {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"type":"error","description":"acquirer unavailable"}`))
return
}
f.refundCalls++
var body yookassa.RefundRequest
_ = json.NewDecoder(r.Body).Decode(&body)
status := f.refundStatus
if status == "" {
status = yookassa.StatusSucceeded
}
// The ledger dedupes refunds on (provider, refund id) across the whole database, so the id has
// to be unique per payment or one test's refund looks like another's duplicate.
refund := yookassa.Refund{ID: "refund-" + body.PaymentID, Status: status, PaymentID: body.PaymentID, Amount: body.Amount}
f.refunds[refund.ID] = refund
_ = json.NewEncoder(w).Encode(refund)
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/refunds/"):
refund, ok := f.refunds[strings.TrimPrefix(r.URL.Path, "/refunds/")]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"type":"error","description":"not found"}`))
return
}
_ = json.NewEncoder(w).Encode(refund)
default:
w.WriteHeader(http.StatusNotFound)
}
}
// setRefund publishes a refund the API will report — how a test stands up a refund that was issued
// somewhere other than through our own API, such as in the merchant cabinet.
func (f *fakeYooKassa) setRefund(id string, refund yookassa.Refund) {
f.mu.Lock()
defer f.mu.Unlock()
refund.ID = id
f.refunds[id] = refund
}
// setPayment overwrites what the API reports for a payment — how a test says "the money did (or did
// not) really move", independently of what a notification claims.
func (f *fakeYooKassa) setPayment(id string, mutate func(p *yookassa.Payment)) {
f.mu.Lock()
defer f.mu.Unlock()
p := f.payments[id]
p.ID = id
mutate(&p)
f.payments[id] = p
}
// yookassaServer builds a backend server whose direct rail is the fake YooKassa shop.
//
// The suite shares one database, and the kill-switch test leaves the direct rail disabled, so the
// rail status is reset first: these tests are about the provider, not about ops availability, and
// fail-open means an absent row is an enabled rail.
func yookassaServer(t *testing.T, shop yookassa.Config) (*server.Server, *payments.Service) {
// Receipts off, as in production: the merchant is outside 54-ФЗ.
return yookassaServerWithVat(t, shop, yookassa.VatCodeOff)
}
// yookassaServerWithVat is yookassaServer with an explicit VAT rate code, for exercising the fiscal
// receipt path that stays dormant until a rate is configured.
func yookassaServerWithVat(t *testing.T, shop yookassa.Config, vatCode int) (*server.Server, *payments.Service) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`DELETE FROM payments.rail_status WHERE rail LIKE 'direct%'`); err != nil {
t.Fatalf("reset direct rail status: %v", err)
}
paySvc := newPaymentsService()
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(),
Accounts: account.NewStore(testDB),
Games: newGameService(),
Registry: testRegistry,
DictDir: dictDir(),
Payments: paySvc,
YooKassa: yookassa.Shops{yookassa.ChannelWeb: shop},
YooKassaVatCode: vatCode,
PublicBaseURL: "https://scrabble.test",
})
return srv, paySvc
}
// postYooKassaNotify posts a notification body to the intake as the given sender, and reports the status code.
func postYooKassaNotify(t *testing.T, srv *server.Server, senderIP, event, paymentID, orderID string) int {
t.Helper()
body := fmt.Sprintf(`{"type":"notification","event":%q,"object":{"id":%q,"status":"succeeded",
"recipient":{"account_id":%q},"metadata":{"order_id":%q}}}`, event, paymentID, ykShopID, orderID)
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/payments/yookassa/notify", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", senderIP)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec.Code
}
// seedYooKassaOrder opens a paid-for-real order: a pending order with the provider payment id
// attached, exactly as the order path leaves it once YooKassa has minted the payment.
func seedYooKassaOrder(t *testing.T, f *fakeYooKassa, pay *payments.Service, acc uuid.UUID) (uuid.UUID, string) {
t.Helper()
ctx := context.Background()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := pay.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "yookassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
paymentID := "pay-" + res.OrderID.String()
f.setPayment(paymentID, func(p *yookassa.Payment) {
p.Status = yookassa.StatusSucceeded
p.Paid = true
p.Test = true
p.Amount = yookassa.Amount{Value: "149.00", Currency: "RUB"}
p.Metadata = map[string]string{yookassa.MetadataOrderID: res.OrderID.String()}
p.Recipient = yookassa.Recipient{AccountID: ykShopID}
})
if err := pay.AttachProviderPayment(ctx, res.OrderID, "yookassa", paymentID); err != nil {
t.Fatalf("attach provider payment: %v", err)
}
return res.OrderID, paymentID
}
// TestYooKassaNotifyCreditsOnce drives the crediting path: a succeeded notification credits the
// order exactly once, and a redelivery credits nothing more.
func TestYooKassaNotifyCreditsOnce(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100", got)
}
if orderStatus(t, orderID) != "paid" {
t.Errorf("order status = %s, want paid", orderStatus(t, orderID))
}
// YooKassa redelivers until it gets a 200; a redelivery must credit nothing and still answer 200.
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("redelivered notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after redelivery = %d, want 100 (credited once)", got)
}
if ledgerRows(t, acc, "fund") != 1 {
t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund"))
}
}
// TestYooKassaNotifyIsNotEvidence is the security property of the whole rail: notifications are
// unsigned, so a fabricated "succeeded" credits nothing when the confirming read says otherwise.
func TestYooKassaNotifyIsNotEvidence(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
// The provider says the payment never completed, whatever the notification claims.
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusPending; p.Paid = false })
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 — a forged notification credited chips", got)
}
// A payment we never made: the confirming read 404s, so there is nothing to credit.
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, "pay-invented", orderID.String()); code != http.StatusOK {
t.Fatalf("notify for an unknown payment = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 — an invented payment credited chips", got)
}
}
// TestYooKassaNotifyForAnUnknownOrderCostsNoProviderCall pins what actually keeps a forger from
// using this endpoint to drive traffic at the provider on our behalf. The sender address is not
// checked — it cannot be, in a deployment that sees only its own tunnel — so the guard is that the
// order is resolved from the notification first: an id matching no order costs one indexed read and
// stops there. Guessing a live order id means guessing a uuid.
func TestYooKassaNotifyForAnUnknownOrderCostsNoProviderCall(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, _ := yookassaServer(t, shop)
before := f.paymentGets
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, "pay-forged", uuid.NewString()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
if f.paymentGets != before {
t.Errorf("confirming reads = %d, want %d — a forged notification reached the provider", f.paymentGets, before)
}
}
// TestYooKassaNotifyIsAcceptedFromAnyAddress: the contour (and any deployment behind a tunnel) sees
// only its own internal address as the sender, so a genuine notification must still be honoured.
func TestYooKassaNotifyIsAcceptedFromAnyAddress(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
if code := postYooKassaNotify(t, srv, "10.77.0.1", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 — a genuine notification was refused on its source address", got)
}
}
// TestYooKassaNotifyRejectsLiveOnTestShop is the guard that keeps play money out of a live ledger and
// real money out of a test one: a payment whose test flag disagrees with the shop's is not credited.
func TestYooKassaNotifyRejectsLiveOnTestShop(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop) // the shop is a test shop
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Test = false }) // a live payment
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 — a live payment credited against test credentials", got)
}
}
// TestYooKassaCanceledRecordsFailure checks an active decline records a failed payment event and
// credits nothing, so the customer learns the attempt did not go through.
func TestYooKassaCanceledRecordsFailure(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
f.setPayment(paymentID, func(p *yookassa.Payment) {
p.Status = yookassa.StatusCanceled
p.Paid = false
p.CancellationDetails = &yookassa.Cancellation{Party: "payment_network", Reason: "insufficient_funds"}
})
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentCanceled, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 — a canceled payment credited chips", got)
}
if orderStatus(t, orderID) != "pending" {
t.Errorf("order status = %s, want pending (a decline does not settle the order)", orderStatus(t, orderID))
}
evs, err := pay.UndispatchedEvents(context.Background(), 50)
if err != nil {
t.Fatalf("read events: %v", err)
}
found := false
for _, e := range evs {
if e.AccountID == acc && e.Type == "failed" {
found = true
}
}
if !found {
t.Error("no failed payment event recorded for a declined payment")
}
}
// TestYooKassaReconcileCreditsLostNotification is the safety net: when no notification ever arrives,
// the expiry-time check asks the provider and credits an order that was in fact paid.
func TestYooKassaReconcileCreditsLostNotification(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, _ := seedYooKassaOrder(t, f, pay, acc)
// Age the order just past the re-check threshold — minutes, not the order's full lifetime — with
// no notification ever delivered.
if _, err := testDB.ExecContext(context.Background(),
`UPDATE payments.orders SET created_at = now() - interval '2 minutes' WHERE order_id=$1`, orderID); err != nil {
t.Fatalf("age order: %v", err)
}
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 1 {
t.Fatalf("reconciled %d orders, want 1", n)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 — the safety net did not credit a paid order", got)
}
if orderStatus(t, orderID) != "paid" {
t.Errorf("order status = %s, want paid", orderStatus(t, orderID))
}
// A second sweep finds nothing left to do — the order is no longer pending.
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
t.Errorf("second sweep credited %d orders, want 0", n)
}
}
// TestYooKassaReconcileSkipsFreshOrders keeps the sweep off an order the customer is still paying
// for: it re-checks only orders older than ReconcileAfter, so opening a payment page does not start
// polling the provider straight away.
func TestYooKassaReconcileSkipsFreshOrders(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
seedYooKassaOrder(t, f, pay, acc) // just created
before := f.paymentGets
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
t.Errorf("reconciled %d orders, want 0 (the order is seconds old)", n)
}
if f.paymentGets != before {
t.Errorf("confirming reads = %d, want %d — a fresh order was polled", f.paymentGets, before)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0", got)
}
}
// TestYooKassaReconcileLeavesUnpaidOrders checks the sweep does not invent a credit for an order the
// customer abandoned.
func TestYooKassaReconcileLeavesUnpaidOrders(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusCanceled; p.Paid = false })
if _, err := testDB.ExecContext(context.Background(),
`UPDATE payments.orders SET created_at = now() - interval '2 days' WHERE order_id=$1`, orderID); err != nil {
t.Fatalf("age order: %v", err)
}
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
t.Errorf("reconciled %d orders, want 0 (the payment was never completed)", n)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0", got)
}
}
// TestYooKassaConsoleRefundMovesMoneyThenRecords drives the operator refund end to end: the provider
// refund is requested first and the ledger records the provider's own refund id.
func TestYooKassaConsoleRefundMovesMoneyThenRecords(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
h := srv.Handler()
base := "http://admin.test/_gm/users/" + acc.String()
code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
if code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
t.Fatalf("refund = %d, body has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
}
if f.refundCalls != 1 {
t.Errorf("provider refunds requested = %d, want 1", f.refundCalls)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance after refund = %d, want 0", got)
}
// The ledger records the provider's own refund id, which is what keeps it reconcilable against
// YooKassa's records — and is distinct from the payment id, so both rows coexist.
var provider, refundID string
if err := testDB.QueryRowContext(context.Background(),
`SELECT provider, provider_payment_id FROM payments.ledger WHERE account_id=$1 AND kind='refund'`,
acc).Scan(&provider, &refundID); err != nil {
t.Fatalf("read refund ledger row: %v", err)
}
if provider != "yookassa" || refundID != "refund-"+paymentID {
t.Errorf("refund ledger row = (%s, %s), want (yookassa, refund-%s)", provider, refundID, paymentID)
}
}
// TestYooKassaRefundRecordsNothingWhenTheProviderFails is the invariant that keeps the ledger honest:
// if the money did not move, nothing is written.
func TestYooKassaRefundRecordsNothingWhenTheProviderFails(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
f.refundFails = true
h := srv.Handler()
base := "http://admin.test/_gm/users/" + acc.String()
_, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
if !strings.Contains(body, "nothing was recorded") {
t.Errorf("refund failure message = %q, want it to say nothing was recorded", body)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 — chips were revoked without the money moving", got)
}
if ledgerRows(t, acc, "refund") != 0 {
t.Error("a refund ledger row was written although the provider did not refund")
}
}
// seedBuyerWithEmail provisions an account with a confirmed email — the D36 anchor a direct purchase
// requires — and returns the account and the address.
func seedBuyerWithEmail(t *testing.T) (uuid.UUID, string) {
t.Helper()
acc := provisionAccount(t)
// The full id, not a prefix: account ids are uuid v7, whose leading hex digits are the top bits
// of a millisecond timestamp and so are identical for accounts made moments apart.
email := "buyer-" + acc.String() + "@example.test"
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
uuid.New(), acc, email); err != nil {
t.Fatalf("seed email identity: %v", err)
}
return acc, email
}
// postWalletOrder opens a purchase over HTTP as the account, on the direct/web platform.
func postWalletOrder(t *testing.T, srv *server.Server, acc, productID uuid.UUID) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, productID)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", acc.String())
req.Header.Set("X-Platform", "direct/web")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
// TestYooKassaOrderSendsNoReceipt pins the fiscal decision: the merchant is outside 54-ФЗ, so a
// purchase must carry no receipt at all — the provider registers nothing and the merchant reports
// each operation itself.
func TestYooKassaOrderSendsNoReceipt(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, _ := yookassaServer(t, shop)
acc, _ := seedBuyerWithEmail(t)
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
rec := postWalletOrder(t, srv, acc, prod)
if rec.Code != http.StatusOK {
t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
}
if f.lastReceipt != nil {
t.Errorf("the purchase carried a receipt (%v); receipts are off outside 54-ФЗ", f.lastReceipt)
}
}
// TestYooKassaReceiptTurnsOnWithAVatCode proves the dormant fiscal path still works, so a lost НПД
// regime is a deploy-variable change rather than a code change.
func TestYooKassaReceiptTurnsOnWithAVatCode(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, _ := yookassaServerWithVat(t, shop, yookassa.VatCodeNone)
acc, email := seedBuyerWithEmail(t)
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
if rec := postWalletOrder(t, srv, acc, prod); rec.Code != http.StatusOK {
t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
}
if f.lastReceipt == nil {
t.Fatal("no receipt sent although a VAT rate code is configured")
}
customer, _ := f.lastReceipt["customer"].(map[string]any)
if customer["email"] != email {
t.Errorf("receipt customer = %v, want the confirmed email %q", customer, email)
}
items, _ := f.lastReceipt["items"].([]any)
if len(items) != 1 {
t.Fatalf("receipt items = %d, want 1", len(items))
}
item, _ := items[0].(map[string]any)
if item["vat_code"] != float64(yookassa.VatCodeNone) || item["payment_subject"] != yookassa.PaymentSubjectService {
t.Errorf("receipt fiscal attributes = %v", item)
}
}
// TestYooKassaOrderMintsAPayment drives the purchase path over HTTP: the order endpoint calls the
// provider, threads the order id through, and hands the client the hosted payment page.
func TestYooKassaOrderMintsAPayment(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, _ := yookassaServer(t, shop)
acc := provisionAccount(t)
const email = "buyer@example.test"
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
uuid.New(), acc, email); err != nil {
t.Fatalf("seed email identity: %v", err)
}
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", acc.String())
req.Header.Set("X-Platform", "direct/web")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
}
var out struct {
OrderID string `json:"order_id"`
RedirectURL string `json:"redirect_url"`
Rail string `json:"rail"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode order response: %v", err)
}
if out.Rail != "yookassa" || !strings.HasPrefix(out.RedirectURL, "https://yoomoney.test/pay/") {
t.Errorf("order response = %+v, want the yookassa rail and its hosted payment page", out)
}
// The order id is both the idempotency key (so a retried create cannot mint a second payment)
// and the metadata that later resolves a notification to this order.
if f.createdIdempotenceKey != out.OrderID {
t.Errorf("Idempotence-Key = %q, want the order id %q", f.createdIdempotenceKey, out.OrderID)
}
// The payment id is recorded on the order, which is what the safety net and a refund need.
orderID, err := uuid.Parse(out.OrderID)
if err != nil {
t.Fatalf("parse order id: %v", err)
}
var paymentID string
if err := testDB.QueryRowContext(context.Background(),
`SELECT provider_payment_id FROM payments.orders WHERE order_id=$1`, orderID).Scan(&paymentID); err != nil {
t.Fatalf("read order payment id: %v", err)
}
if paymentID != "pay-"+out.OrderID {
t.Errorf("order provider_payment_id = %q, want the minted payment id", paymentID)
}
}
// TestYooKassaOrderRequiresAnEmailAnchor keeps D36 in force on the new rail: without a confirmed
// address there is no recovery anchor and nowhere to deliver the fiscal receipt, so no payment is
// minted at all.
func TestYooKassaOrderRequiresAnEmailAnchor(t *testing.T) {
_, shop := newFakeYooKassa(t)
srv, _ := yookassaServer(t, shop)
acc := provisionAccount(t) // a telegram identity only — no confirmed email
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", acc.String())
req.Header.Set("X-Platform", "direct/web")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "email_required") {
t.Fatalf("order = %d (%s), want 403 email_required", rec.Code, rec.Body.String())
}
}
// postYooKassaRefundNotify posts a refund notification to the intake and reports the status code.
func postYooKassaRefundNotify(t *testing.T, srv *server.Server, refundID, paymentID string) int {
t.Helper()
body := fmt.Sprintf(`{"type":"notification","event":%q,"object":{"id":%q,"status":"succeeded","payment_id":%q}}`,
yookassa.EventRefundSucceeded, refundID, paymentID)
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/payments/yookassa/notify", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", ykSenderIP)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec.Code
}
// fundedYooKassaOrder seeds an order and credits it, leaving an account with chips to reverse.
func fundedYooKassaOrder(t *testing.T, f *fakeYooKassa, srv *server.Server, pay *payments.Service, acc uuid.UUID) (uuid.UUID, string) {
t.Helper()
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
t.Fatalf("notify = %d, want 200", code)
}
return orderID, paymentID
}
// TestYooKassaCabinetRefundIsReversed is why the refund event is handled at all: an operator can
// refund in the merchant cabinet, which never passes through our API, so without this the money would
// go back while the chips stayed credited.
func TestYooKassaCabinetRefundIsReversed(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
if got := readBalance(t, acc, "direct"); got != 100 {
t.Fatalf("balance before the refund = %d, want 100", got)
}
// The refund exists at the provider, issued outside our API.
f.setRefund("cabinet-refund-1", yookassa.Refund{
Status: yookassa.StatusSucceeded, PaymentID: paymentID,
Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
})
if code := postYooKassaRefundNotify(t, srv, "cabinet-refund-1", paymentID); code != http.StatusOK {
t.Fatalf("refund notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 — a cabinet refund did not revoke the chips", got)
}
if f.refundCalls != 0 {
t.Errorf("provider refunds requested = %d, want 0 — the money had already moved", f.refundCalls)
}
var provider, refundID string
if err := testDB.QueryRowContext(context.Background(),
`SELECT provider, provider_payment_id FROM payments.ledger WHERE account_id=$1 AND kind='refund'`,
acc).Scan(&provider, &refundID); err != nil {
t.Fatalf("read refund ledger row: %v", err)
}
if provider != "yookassa" || refundID != "cabinet-refund-1" {
t.Errorf("refund ledger row = (%s, %s), want (yookassa, cabinet-refund-1)", provider, refundID)
}
// A redelivery reverses nothing a second time.
if code := postYooKassaRefundNotify(t, srv, "cabinet-refund-1", paymentID); code != http.StatusOK {
t.Fatalf("redelivered refund notify = %d, want 200", code)
}
if ledgerRows(t, acc, "refund") != 1 {
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
}
}
// TestYooKassaRefundNotifyAfterConsoleRefundIsANoOp checks the two refund entry points cannot double
// up: the event for a refund the console already recorded reverses nothing again.
func TestYooKassaRefundNotifyAfterConsoleRefundIsANoOp(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
base := "http://admin.test/_gm/users/" + acc.String()
if code, _ := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test"); code != http.StatusOK {
t.Fatalf("console refund = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Fatalf("balance after the console refund = %d, want 0", got)
}
// YooKassa then notifies about that same refund.
if code := postYooKassaRefundNotify(t, srv, "refund-"+paymentID, paymentID); code != http.StatusOK {
t.Fatalf("refund notify = %d, want 200", code)
}
if ledgerRows(t, acc, "refund") != 1 {
t.Errorf("refund ledger rows = %d, want 1 (reversed once)", ledgerRows(t, acc, "refund"))
}
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
t.Errorf("risk = (abuse %v, loss %d), want none — the second reversal ran anyway", abuse, loss)
}
}
// TestYooKassaPartialRefundIsLeftToAnOperator: the reversal engine revokes exactly what the pack
// funded and rejects any other amount, so a partial refund cannot be recorded without guessing how
// many chips it costs. It must change nothing rather than guess.
func TestYooKassaPartialRefundIsLeftToAnOperator(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
f.setRefund("partial-1", yookassa.Refund{
Status: yookassa.StatusSucceeded, PaymentID: paymentID,
Amount: yookassa.Amount{Value: "50.00", Currency: "RUB"}, // part of the 149.00 order
})
if code := postYooKassaRefundNotify(t, srv, "partial-1", paymentID); code != http.StatusOK {
t.Fatalf("refund notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 — a partial refund revoked chips", got)
}
if ledgerRows(t, acc, "refund") != 0 {
t.Error("a partial refund wrote a refund ledger row")
}
}
// TestYooKassaRefundNotifyIsNotEvidence: as with a payment, the body only names a refund. One the
// provider does not confirm reverses nothing.
func TestYooKassaRefundNotifyIsNotEvidence(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
// A refund the provider has never heard of.
if code := postYooKassaRefundNotify(t, srv, "invented-refund", paymentID); code != http.StatusOK {
t.Fatalf("refund notify = %d, want 200", code)
}
// One that exists but is not final.
f.setRefund("pending-refund", yookassa.Refund{
Status: yookassa.StatusPending, PaymentID: paymentID,
Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
})
if code := postYooKassaRefundNotify(t, srv, "pending-refund", paymentID); code != http.StatusOK {
t.Fatalf("refund notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 — an unconfirmed refund revoked chips", got)
}
if ledgerRows(t, acc, "refund") != 0 {
t.Error("an unconfirmed refund wrote a refund ledger row")
}
}
// TestYooKassaPendingRefundRecordsNothing: a refund can still be canceled while pending and the
// ledger is append-only, so the console records only a completed one. Retrying is the way out.
func TestYooKassaPendingRefundRecordsNothing(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, _ := fundedYooKassaOrder(t, f, srv, pay, acc)
f.refundStatus = yookassa.StatusPending
base := "http://admin.test/_gm/users/" + acc.String()
_, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
if !strings.Contains(body, "not completed the refund yet") || !strings.Contains(body, "nothing was recorded") {
t.Errorf("message = %q, want it to say the refund is not final and nothing was recorded", body)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 — chips were revoked for an unsettled refund", got)
}
if ledgerRows(t, acc, "refund") != 0 {
t.Error("a pending refund wrote a refund ledger row")
}
// Once the provider settles it, pressing again records the reversal — the idempotency key returns
// the same refund rather than paying twice.
f.refundStatus = yookassa.StatusSucceeded
if code, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
t.Fatalf("retried refund = %d, body has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0", got)
}
}
// TestYooKassaConsoleRefundAfterItsOwnNotification covers the race the two recording paths really
// run: the console asks the provider to refund, the provider fires refund.succeeded at once, and the
// notification records the reversal before the console gets to. Both name the same refund id, so the
// idempotency index catches the second write and nothing is revoked twice — but the operator must be
// told the refund succeeded, not that they clicked twice.
func TestYooKassaConsoleRefundAfterItsOwnNotification(t *testing.T) {
f, shop := newFakeYooKassa(t)
srv, pay := yookassaServer(t, shop)
acc := provisionAccount(t)
orderID, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
// The provider's notification lands first, for the refund the console is about to create.
refundID := "refund-" + paymentID
f.setRefund(refundID, yookassa.Refund{
Status: yookassa.StatusSucceeded, PaymentID: paymentID,
Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
})
if code := postYooKassaRefundNotify(t, srv, refundID, paymentID); code != http.StatusOK {
t.Fatalf("refund notify = %d, want 200", code)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Fatalf("balance after the notification = %d, want 0", got)
}
base := "http://admin.test/_gm/users/" + acc.String()
code, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
if code != http.StatusOK {
t.Fatalf("console refund = %d, want 200", code)
}
if strings.Contains(body, "Already refunded") {
t.Errorf("the console reported a repeat click for a refund it had just made successfully: %q", body)
}
if !strings.Contains(body, "Refunded") {
t.Errorf("console message = %q, want it to report the refund succeeded", body)
}
if ledgerRows(t, acc, "refund") != 1 {
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
}
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
t.Errorf("risk = (abuse %v, loss %d), want none", abuse, loss)
}
}
+18 -5
View File
@@ -11,11 +11,24 @@ import (
// one manual full refund per order.
const providerAdmin = "admin"
// RefundOrderFull refunds a paid order in full at the operator's request: it revokes the funded
// chips best-effort (floored at 0, never negative — D27), records a refund ledger row, and is
// idempotent (a second call reports AlreadyRefunded). The operator performs the actual money refund
// on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment); this records it.
// RefundOrderFull refunds a paid order in full at the operator's request, recording the reversal
// only. It is for the rails that have no refund API of our own to call: the operator performs the
// actual money refund there by hand (VK support, Telegram refundStarPayment, the Robokassa cabinet),
// and this records it under the operator's own idempotency key.
func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (RefundOutcome, error) {
return s.RefundOrderFullAs(ctx, orderID, providerAdmin, orderID.String())
}
// RefundOrderFullAs refunds a paid order in full and records it under the given provider and refund
// id: it revokes the funded chips best-effort (floored at 0, never negative — D27), appends a refund
// ledger row and is idempotent on (provider, providerRefundID), so a second call reports
// AlreadyRefunded. Callers whose rail moved the money through an API pass that rail's own refund id,
// which keeps the ledger reconcilable against the provider's records; the refund id is distinct from
// the fund's payment id, so the two rows coexist under the same partial-unique index.
//
// The provider-side money movement must already have succeeded when this is called — the ledger must
// never claim a refund that did not happen.
func (s *Service) RefundOrderFullAs(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string) (RefundOutcome, error) {
o, err := s.store.orderByID(ctx, orderID)
if err != nil {
return RefundOutcome{}, err
@@ -24,7 +37,7 @@ func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (Refun
if err != nil {
return RefundOutcome{}, err
}
return s.store.refund(ctx, orderID, providerAdmin, orderID.String(), refunded, s.clock())
return s.store.refund(ctx, orderID, provider, providerRefundID, refunded, s.clock())
}
// LedgerExportRow is one append-only ledger row for the tax / reconciliation export, carrying the
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
@@ -64,6 +65,101 @@ func directShop(cxt Context) string {
return ""
}
// AttachProviderPayment records the provider's own payment identifier on a pending order, right
// after the provider mints it and before the customer has paid. Two later paths depend on it: the
// reconcile sweep, which asks the provider what became of an order no callback ever confirmed, and a
// refund, which must address the original payment. It does not credit anything and does not change
// the order status.
func (s *Service) AttachProviderPayment(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string) error {
return s.store.attachProviderPayment(ctx, orderID, provider, providerPaymentID, s.clock())
}
// OrderRef identifies a stored order to a provider: which rail settles it, that rail's own payment
// id, the merchant shop channel it was issued through, and the amount and account it belongs to. It
// is what the reconcile sweep and the refund path need without giving them the whole order.
type OrderRef struct {
OrderID uuid.UUID
AccountID uuid.UUID
Provider string
PaymentID string
Shop string
Amount Money
Status string
}
// orderRef projects a stored order onto an OrderRef.
func orderRef(o orderRow) (OrderRef, error) {
amount, err := MoneyFromMinor(o.expectedAmount, Currency(o.currency))
if err != nil {
return OrderRef{}, err
}
return OrderRef{
OrderID: o.orderID,
AccountID: o.accountID,
Provider: o.provider,
PaymentID: o.paymentID,
Shop: o.shop,
Amount: amount,
Status: o.status,
}, nil
}
// OrderProviderRef reads how an order reaches its provider — the rail, that rail's payment id and
// the shop it was issued through. The refund path uses it to call the right merchant account.
func (s *Service) OrderProviderRef(ctx context.Context, orderID uuid.UUID) (OrderRef, error) {
o, err := s.store.orderByID(ctx, orderID)
if err != nil {
return OrderRef{}, err
}
return orderRef(o)
}
// OrderByProviderPayment reads the order a provider's payment id belongs to. It resolves a provider
// event that names only its own payment — such as a refund notification — back to an order.
func (s *Service) OrderByProviderPayment(ctx context.Context, provider, providerPaymentID string) (OrderRef, error) {
o, err := s.store.orderByProviderPayment(ctx, provider, providerPaymentID)
if err != nil {
return OrderRef{}, err
}
return orderRef(o)
}
// reconcileBatch bounds one reconcile sweep, so a backlog cannot turn a periodic tick into a long
// run of provider calls.
const reconcileBatch = 50
// ReconcileAfter is how old a pending order must be before the provider is asked what became of it.
// It is deliberately NOT the order lifetime: that governs when an unpaid order is written off, which
// answers "how long may a customer take to pay", a different question from "how soon should we
// notice a lost callback". Tying the two together would make a customer wait a full lifetime for
// chips whenever the notification path fails. A minute is long enough that an order the customer is
// still paying for is not polled, and short enough that a failure costs minutes, not half an hour.
const ReconcileAfter = time.Minute
// PendingForReconcile returns the pending orders old enough to re-check that carry a provider payment
// id — the ones where the money may well have moved but no callback ever told us. The caller asks the
// provider for each one's real outcome. Orders that never reached a payment are not returned: there
// is nothing to ask about, and neither are orders younger than ReconcileAfter, so an order the
// customer is still paying for is left alone.
//
// The sweep repeats until the order settles or is written off, which bounds the provider calls one
// order can cause to its lifetime divided by the sweep interval — a handful, not an open-ended poll.
func (s *Service) PendingForReconcile(ctx context.Context) ([]OrderRef, error) {
rows, err := s.store.pendingForReconcile(ctx, int(ReconcileAfter.Seconds()), s.clock(), reconcileBatch)
if err != nil {
return nil, err
}
out := make([]OrderRef, 0, len(rows))
for _, r := range rows {
ref, err := orderRef(r)
if err != nil {
return nil, err
}
out = append(out, ref)
}
return out, nil
}
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
+98
View File
@@ -2,6 +2,7 @@ package payments
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
@@ -63,3 +64,100 @@ type LedgerEntry struct {
func (s *Service) AccountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
return s.store.accountStatement(ctx, accountID)
}
// LedgerFilter narrows the operator's ledger report. A zero value matches everything; each set field
// narrows further. From is inclusive and To exclusive, so a day range does not double-count a row on
// the boundary.
//
// Wallet and Provider are deliberately separate axes, because they answer different questions.
// Wallet ("what happened on VK") matches the row's funded segment or the origin a benefit was bought
// in; Provider ("what came through YooKassa") matches the rail that settled it — and a chip spend has
// no rail at all, so a provider filter excludes spends by construction.
type LedgerFilter struct {
From time.Time
To time.Time
AccountID uuid.UUID
Kind string
Wallet string
Provider string
}
// LedgerReportRow is one ledger row for the operator report: the entry itself, the account it
// belongs to (the per-account report already knows that, the all-accounts one does not) and the
// money the row moved, recovered from the snapshot — the ledger's own columns count chips.
type LedgerReportRow struct {
LedgerEntry
AccountID uuid.UUID
// Money is what the customer actually paid or was refunded, or the zero Money for a row that
// moved no money (a spend, an admin grant, a rewarded-video credit).
Money Money
}
// HasMoney reports whether the row moved real money.
func (r LedgerReportRow) HasMoney() bool { return r.Money.Currency() != "" }
// LedgerTotals sums everything a filter matches, not merely the page on screen. Money is listed per
// currency because the rails settle in different ones (roubles, Votes, Stars) and summing across
// them would be meaningless.
type LedgerTotals struct {
MoneyIn []Money
MoneyRefunded []Money
ChipsIn int
ChipsOut int
}
// LedgerReport is one page of the filtered ledger plus the totals for the whole filtered range and
// how many rows it matches.
type LedgerReport struct {
Rows []LedgerReportRow
Totals LedgerTotals
Total int
}
// exportLimit caps the CSV export. The ledger is append-only and grows forever, so an unbounded
// export would eventually time out; the filter is the way to narrow a real accounting export.
const exportLimit = 100_000
// LedgerReportPage reads one page of the filtered ledger together with the totals for everything the
// filter matches. It is the all-accounts operator view: uncached, admin-only, not a hot path.
func (s *Service) LedgerReportPage(ctx context.Context, f LedgerFilter, limit, offset int) (LedgerReport, error) {
rows, total, err := s.store.ledgerPage(ctx, f, limit, offset)
if err != nil {
return LedgerReport{}, err
}
totals, err := s.store.ledgerTotals(ctx, f)
if err != nil {
return LedgerReport{}, err
}
return LedgerReport{Rows: rows, Totals: totals, Total: total}, nil
}
// FilteredLedgerExport reads the whole filtered ledger for the CSV export, capped at exportLimit.
func (s *Service) FilteredLedgerExport(ctx context.Context, f LedgerFilter) ([]LedgerReportRow, error) {
return s.store.filteredLedger(ctx, f)
}
// MoneyFromLedgerSnapshot recovers the money a ledger row moved from its snapshot, returning the
// zero Money when the row carries none (a spend, a grant) or the snapshot predates the fields. It is
// exported for callers folding their own totals out of a Statement's history.
func MoneyFromLedgerSnapshot(snapshot string) Money { return moneyFromSnapshot(snapshot) }
// moneyFromSnapshot recovers the money a ledger row moved from its snapshot, returning the zero
// Money when the row carries none (a spend, a grant) or the snapshot predates the fields.
func moneyFromSnapshot(snapshot string) Money {
if snapshot == "" {
return Money{}
}
var snap struct {
Amount int64 `json:"amount_minor"`
Currency string `json:"currency"`
}
if err := json.Unmarshal([]byte(snapshot), &snap); err != nil || snap.Currency == "" {
return Money{}
}
m, err := MoneyFromMinor(snap.Amount, Currency(snap.Currency))
if err != nil {
return Money{}
}
return m
}
+86 -1
View File
@@ -182,6 +182,9 @@ type orderRow struct {
currency string
origin string
status string
provider string
paymentID string
shop string
}
// orderByID reads an order, or ErrOrderNotFound.
@@ -198,6 +201,12 @@ func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, err
if err != nil {
return orderRow{}, fmt.Errorf("payments: load order %s: %w", orderID, err)
}
return newOrderRow(o), nil
}
// newOrderRow projects a stored order onto the intake's view of it, flattening the nullable provider
// columns to their empty strings.
func newOrderRow(o model.Orders) orderRow {
return orderRow{
orderID: o.OrderID,
accountID: o.AccountID,
@@ -206,7 +215,83 @@ func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, err
currency: o.Currency,
origin: o.Origin,
status: o.Status,
}, nil
provider: derefString(o.Provider),
paymentID: derefString(o.ProviderPaymentID),
shop: o.Shop,
}
}
// derefString reads a nullable text column as a plain string, treating NULL as empty.
func derefString(p *string) string {
if p == nil {
return ""
}
return *p
}
// orderByProviderPayment reads the order a provider's payment id belongs to, or ErrOrderNotFound.
// It is how a provider event that names only its own payment — a refund notification, say — is
// resolved back to an order.
func (s *Store) orderByProviderPayment(ctx context.Context, provider, providerPaymentID string) (orderRow, error) {
if provider == "" || providerPaymentID == "" {
return orderRow{}, ErrOrderNotFound
}
var o model.Orders
err := postgres.SELECT(table.Orders.AllColumns).
FROM(table.Orders).
WHERE(table.Orders.Provider.EQ(postgres.String(provider)).
AND(table.Orders.ProviderPaymentID.EQ(postgres.String(providerPaymentID)))).
LIMIT(1).
QueryContext(ctx, s.db, &o)
if errors.Is(err, qrm.ErrNoRows) {
return orderRow{}, ErrOrderNotFound
}
if err != nil {
return orderRow{}, fmt.Errorf("payments: load order by %s payment %s: %w", provider, providerPaymentID, err)
}
return newOrderRow(o), nil
}
// attachProviderPayment records the provider's own payment id on a pending order, as soon as the
// provider mints it. It is what later lets an unattended order be re-checked against the provider
// and a refund address the right payment; fund overwrites it with the same value when the callback
// lands. It never changes the order status.
func (s *Store) attachProviderPayment(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, now time.Time) error {
_, err := table.Orders.
UPDATE(table.Orders.Provider, table.Orders.ProviderPaymentID, table.Orders.UpdatedAt).
SET(postgres.String(provider), postgres.String(providerPaymentID), postgres.TimestampzT(now)).
WHERE(table.Orders.OrderID.EQ(postgres.UUID(orderID))).
ExecContext(ctx, s.db)
if err != nil {
return fmt.Errorf("payments: attach provider payment to order %s: %w", orderID, err)
}
return nil
}
// pendingForReconcile reads the pending orders older than ageSeconds that carry a provider payment
// id — the ones whose real outcome is still unknown to us because no callback ever arrived. The
// caller asks the provider what happened. Orders with no provider payment id are skipped: the
// customer never got as far as a payment.
func (s *Store) pendingForReconcile(ctx context.Context, ageSeconds int, now time.Time, limit int) ([]orderRow, error) {
cutoff := now.Add(-time.Duration(ageSeconds) * time.Second)
var rows []model.Orders
err := postgres.SELECT(table.Orders.AllColumns).
FROM(table.Orders).
WHERE(table.Orders.Status.EQ(postgres.String("pending")).
AND(table.Orders.CreatedAt.LT(postgres.TimestampzT(cutoff))).
AND(table.Orders.ProviderPaymentID.IS_NOT_NULL()).
AND(table.Orders.ProviderPaymentID.NOT_EQ(postgres.String("")))).
ORDER_BY(table.Orders.CreatedAt.ASC()).
LIMIT(int64(limit)).
QueryContext(ctx, s.db, &rows)
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
return nil, fmt.Errorf("payments: load orders for reconcile: %w", err)
}
out := make([]orderRow, 0, len(rows))
for _, r := range rows {
out = append(out, newOrderRow(r))
}
return out, nil
}
// FundOutcome reports the result of an intake credit: whose balance, which segment and how many
+179
View File
@@ -0,0 +1,179 @@
package payments
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// ledgerColumns is the projection every ledger report row is read through, joined to the order so a
// row carries the merchant channel its payment used.
const ledgerColumns = `l.account_id, l.kind, l.source, l.origin, l.chips_delta, l.product_id,
l.order_id, l.provider, l.provider_payment_id, COALESCE(o.shop, ''), l.snapshot, l.created_at`
// ledgerWhere renders a LedgerFilter as a SQL predicate plus its arguments. Every value is bound as
// a parameter; only the fixed fragments are concatenated.
//
// The wallet filter deliberately matches either side of a row. A row names up to two wallets — the
// segment whose chips moved (source) and, for a benefit purchase, where that benefit was bought
// (origin) — and an operator asking "what happened on VK" means both.
func ledgerWhere(f LedgerFilter) (string, []any) {
var clauses []string
var args []any
add := func(clause string, v any) {
args = append(args, v)
clauses = append(clauses, fmt.Sprintf(clause, len(args)))
}
if !f.From.IsZero() {
add("l.created_at >= $%d", f.From)
}
if !f.To.IsZero() {
add("l.created_at < $%d", f.To)
}
if f.AccountID != uuid.Nil {
add("l.account_id = $%d", f.AccountID)
}
if f.Kind != "" {
add("l.kind = $%d", f.Kind)
}
if f.Provider != "" {
add("l.provider = $%d", f.Provider)
}
if f.Wallet != "" {
args = append(args, f.Wallet)
clauses = append(clauses, fmt.Sprintf("(l.source = $%d OR l.origin = $%d)", len(args), len(args)))
}
if len(clauses) == 0 {
return "", nil
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
// ledgerPage reads one page of the filtered ledger, newest first, together with how many rows the
// filter matches in total (which is what the pager needs — the page itself cannot report it).
func (s *Store) ledgerPage(ctx context.Context, f LedgerFilter, limit, offset int) ([]LedgerReportRow, int, error) {
where, args := ledgerWhere(f)
var total int
if err := s.db.QueryRowContext(ctx,
`SELECT count(*) FROM payments.ledger l`+where, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("payments: count ledger: %w", err)
}
q := `SELECT ` + ledgerColumns + `
FROM payments.ledger l
LEFT JOIN payments.orders o ON o.order_id = l.order_id` + where + `
ORDER BY l.created_at DESC, l.ledger_id DESC
LIMIT $%d OFFSET $%d`
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, fmt.Sprintf(q, len(args)-1, len(args)), args...)
if err != nil {
return nil, 0, fmt.Errorf("payments: read ledger page: %w", err)
}
defer rows.Close()
var out []LedgerReportRow
for rows.Next() {
var (
r LedgerReportRow
accountID uuid.UUID
source, origin, provider, paymentID sql.NullString
productID, orderID sql.NullString
snapshot sql.NullString
shop string
chipsDelta int32
createdAt time.Time
)
if err := rows.Scan(&accountID, &r.Kind, &source, &origin, &chipsDelta, &productID,
&orderID, &provider, &paymentID, &shop, &snapshot, &createdAt); err != nil {
return nil, 0, fmt.Errorf("payments: scan ledger row: %w", err)
}
r.AccountID = accountID
r.LedgerEntry = LedgerEntry{
Kind: r.Kind,
Source: source.String,
Origin: origin.String,
ChipsDelta: int(chipsDelta),
ProductID: productID.String,
OrderID: orderID.String,
Provider: provider.String,
ProviderPaymentID: paymentID.String,
Shop: shop,
Snapshot: snapshot.String,
CreatedAt: createdAt,
}
r.Money = moneyFromSnapshot(snapshot.String)
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("payments: read ledger page: %w", err)
}
return out, total, nil
}
// ledgerTotals sums what the filter matches — across every matching row, not just the page on
// screen, which is the point of showing them at all. Money is summed per currency from the row
// snapshot, since the ledger's own columns count chips, not money.
func (s *Store) ledgerTotals(ctx context.Context, f LedgerFilter) (LedgerTotals, error) {
where, args := ledgerWhere(f)
out := LedgerTotals{}
if err := s.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(l.chips_delta) FILTER (WHERE l.chips_delta > 0), 0),
COALESCE(-SUM(l.chips_delta) FILTER (WHERE l.chips_delta < 0), 0)
FROM payments.ledger l`+where, args...).Scan(&out.ChipsIn, &out.ChipsOut); err != nil {
return LedgerTotals{}, fmt.Errorf("payments: sum ledger chips: %w", err)
}
// The money predicate is folded into the same WHERE, because the filter's own clause list may be
// empty and an "AND ..." tacked onto a missing WHERE is a syntax error.
moneyWhere := where
const moneyOnly = `l.kind IN ('fund','refund') AND l.snapshot->>'amount_minor' IS NOT NULL`
if moneyWhere == "" {
moneyWhere = " WHERE " + moneyOnly
} else {
moneyWhere += " AND " + moneyOnly
}
rows, err := s.db.QueryContext(ctx, `
SELECT l.kind, l.snapshot->>'currency',
COALESCE(SUM((l.snapshot->>'amount_minor')::bigint), 0)
FROM payments.ledger l`+moneyWhere+`
GROUP BY 1, 2`, args...)
if err != nil {
return LedgerTotals{}, fmt.Errorf("payments: sum ledger money: %w", err)
}
defer rows.Close()
for rows.Next() {
var kind string
var currency sql.NullString
var minor int64
if err := rows.Scan(&kind, &currency, &minor); err != nil {
return LedgerTotals{}, fmt.Errorf("payments: scan ledger money: %w", err)
}
m, err := MoneyFromMinor(minor, Currency(currency.String))
if err != nil {
continue // an unknown currency in an old snapshot must not break the report
}
switch kind {
case "fund":
out.MoneyIn = append(out.MoneyIn, m)
case "refund":
out.MoneyRefunded = append(out.MoneyRefunded, m)
}
}
if err := rows.Err(); err != nil {
return LedgerTotals{}, fmt.Errorf("payments: sum ledger money: %w", err)
}
return out, nil
}
// filteredLedger reads the whole filtered ledger for the CSV export — unpaginated by design, since
// an export of the page on screen would be useless for accounting.
func (s *Store) filteredLedger(ctx context.Context, f LedgerFilter) ([]LedgerReportRow, error) {
rows, _, err := s.ledgerPage(ctx, f, exportLimit, 0)
return rows, err
}
+85
View File
@@ -0,0 +1,85 @@
# Robokassa — the retired direct rail
Robokassa used to settle the **`direct`** (RUB) rail. It was replaced by
[YooKassa](../yookassa) and is now **dormant**: the code, its tests and its wiring are all still
here, but **no deployment sets its credentials**, so `Shops.Configured()` is false, the Result
callback route is never registered, and the direct rail resolves to YooKassa.
The code is kept because the merchant relationship could come back. This file records the operational
knowledge that used to live in `deploy/` and `.gitea/workflows/` and was removed from there so the
live configuration stays free of dead variables.
## How the rail is chosen
`handleWalletOrder` (`backend/internal/server/handlers_intake.go`) resolves the direct rail in this
order:
1. a configured YooKassa shop for the platform channel → YooKassa;
2. otherwise a configured Robokassa shop → **this rail**;
3. otherwise `501 rail_unavailable`.
So reviving Robokassa is a **credentials change, not a code change**: restore the environment block
below and the rail comes back on the next deploy. If both providers are configured, YooKassa wins.
## Environment variables (retired)
The backend still reads all of these. They were removed from `deploy/docker-compose.yml`,
`deploy/.env.example`, `deploy/write-prod-env.sh`, `deploy/README.md` and the three workflows.
| Backend variable (inside the container) | Meaning |
| --- | --- |
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | Shop login. Empty ⇒ the shop is dropped. Legacy single-shop form: it seeds the **web** channel. |
| `BACKEND_ROBOKASSA_PASSWORD1` | Pass phrase #1 — signs the outgoing payment request. |
| `BACKEND_ROBOKASSA_PASSWORD2` | Pass phrase #2 — signs and so verifies the incoming Result callback. |
| `BACKEND_ROBOKASSA_TEST` | `1` adds `IsTest=1`, so the shop simulates payments against its **test** pass phrases and no money moves. Empty/`0` is live. |
| `BACKEND_ROBOKASSA_WEB_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | The per-channel **web** shop; overrides the legacy set above. |
| `BACKEND_ROBOKASSA_ANDROID_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | The per-channel **android** (RuStore) shop (D42). |
Password3 (Robokassa's JWT-invoice API) was never used.
The three-step naming this repo uses was: a Gitea secret/variable `PROD_BACKEND_ROBOKASSA_PASSWORD1`
→ mapped by the workflow to `ROBOKASSA_PASSWORD1` → mapped by compose to
`BACKEND_ROBOKASSA_PASSWORD1` inside the container.
| Contour | Gitea entries that fed the rail |
| --- | --- |
| test | secrets `TEST_BACKEND_ROBOKASSA_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2}`; `ROBOKASSA_TEST` was hard-coded to `"1"` in `ci.yaml` so the contour could never take real money whatever the shop's own mode. |
| prod | secrets `PROD_BACKEND_ROBOKASSA_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2}`; **variable** `PROD_BACKEND_ROBOKASSA_TEST`, deliberately a variable so go-live was a flag flip rather than a secret rotation. Mirrored in `prod-rollback.yaml` so a rollback did not dark the rail. |
**Known gap, if the rail is ever revived:** the per-channel `…_WEB_*` / `…_ANDROID_*` variables
existed in compose and in `config.go` but were **never** carried by any workflow or by
`write-prod-env.sh` — only the four legacy variables reached prod. A revival that wants the
multi-shop split must add them to the deploy plumbing too.
## Cabinet configuration
Set in the Robokassa ЛКК, per shop:
- **Result URL** — `https://<host>/pay/robokassa/result/<channel>` (`…/web`, `…/android`). The bare
`…/pay/robokassa/result` is the legacy path and is verified as the **web** shop. Method: POST.
Each shop's callback is verified only by its own Password2 (`Shops.Verifier` does not fall back).
- **Success URL** — `https://<host>/pay/robokassa/success`.
- **Fail URL** — `https://<host>/pay/robokassa/fail`.
- **Signature algorithm** — SHA-256 (the code hard-codes it; a shop set to MD5 will not verify).
- **54-ФЗ** — fiscalization was cabinet-side through Robokassa's cloud cash register (kassa + ОФД +
СНО configured by the owner), so no receipt data was ever sent from code. YooKassa does **not**
work this way: it registers a receipt only if the request carries one.
The gateway routes (`gateway/internal/connectsrv/server.go`) are still registered, so the cabinet
URLs above stay reachable; only the backend has no shop to verify against.
## Protocol notes
- The order id is a UUID and Robokassa's `InvId` is numeric, so the order rides the custom-parameter
channel as **`Shp_order`** and `InvId` is always sent as `0`. Idempotency at the credit site is
therefore keyed on the order id, not on a provider payment id.
- Outgoing signature base: `MerchantLogin:OutSum:InvId:Password1[:Shp_key=value…sorted]`.
- Incoming (Result) signature base: `OutSum:InvId:Password2[:Shp_key=value…sorted]`, compared
case-insensitively.
- The Result callback must be answered with the body `OK<InvId>`, which the gateway echoes verbatim.
## What is *not* reversible by configuration
Ledger rows written while this rail was live carry `provider = 'robokassa'`. That literal is
load-bearing for the `(provider, provider_payment_id)` idempotency index and must never be renamed or
reused for another provider.
+6
View File
@@ -3,6 +3,12 @@
// an order. It is pure provider glue — no database, no payments-domain coupling — so the payments
// domain stays provider-agnostic and this layer is unit-testable in isolation.
//
// This rail is RETIRED: YooKassa settles the direct rail now, and no deployment sets these
// credentials, so the shop set is empty and the rail is dormant. It stays wired as the fallback the
// direct rail resolves to when YooKassa has no shop, which makes reviving it a credentials change
// rather than a code change. README.md in this directory records the retired environment variables,
// the cabinet configuration and how to bring the rail back.
//
// The order is threaded through Robokassa's custom-parameter channel as Shp_order=<order id> (echoed
// back in the callback and bound into the signature), not the numeric InvId, because an order id is
// a uuid; InvId is sent as 0. Idempotency is therefore keyed on the order id at the credit site.
+7 -3
View File
@@ -79,16 +79,20 @@ func (s *Server) registerRoutes() {
s.internal.GET("/offer/pricing", s.handleOfferPricing)
}
if s.payments != nil {
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
// The money order endpoint dispatches by rail (direct → YooKassa, vk → VK); an
// unsupported or unconfigured rail returns 501 from the handler. The provider callbacks are
// gateway-only (the single writer): the VK payment callback (both phases handled here), and
// the Robokassa Result callback when a merchant is configured.
// gateway-only (the single writer): the VK payment callback (both phases handled here), the
// YooKassa notification, and the retired Robokassa Result callback if a merchant is ever
// configured again.
u.POST("/wallet/order", s.handleWalletOrder)
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
// The Telegram Stars rail: the bot forwards a pre_checkout validation and a completed
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
if s.yookassa.Configured() {
s.internal.POST("/payments/yookassa/notify", s.handleYooKassaNotify)
}
if s.robokassa.Configured() {
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
}
@@ -8,6 +8,7 @@ import (
"html/template"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
@@ -119,6 +120,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.POST("/catalog/:id", s.consoleUpdateProduct)
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
gm.GET("/ledger", s.consoleLedger)
gm.GET("/ledger.csv", s.consoleLedgerExport)
}
}
@@ -458,7 +460,7 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
}
if s.payments != nil {
if stmt, err := s.payments.AccountStatement(ctx, id); err == nil {
view.Finance = financeView(stmt)
view.Finance = financeView(id, stmt)
} else {
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
}
@@ -470,9 +472,11 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
// financeView projects an account's payments statement into the user-card finance panel, with the
// benefit expiry and ledger times pre-formatted for the logic-free template.
func financeView(stmt payments.Statement) adminconsole.FinanceView {
// financeView projects an account's payments statement into the user-card finance panel: where the
// account stands (balances, benefits, lifetime money and chip totals), not what happened when. The
// operations themselves are the ledger section's job, which the panel links to pre-filtered to this
// account — one place renders them, with filters and paging, instead of two.
func financeView(id uuid.UUID, stmt payments.Statement) adminconsole.FinanceView {
fv := adminconsole.FinanceView{Present: true, Abuse: stmt.Risk.Abuse, Loss: stmt.Risk.LossChips}
for _, sg := range stmt.Segments {
fv.Segments = append(fv.Segments, adminconsole.SegmentRow{Source: string(sg.Source), Chips: sg.Chips})
@@ -484,16 +488,52 @@ func financeView(stmt payments.Statement) adminconsole.FinanceView {
}
fv.Benefits = append(fv.Benefits, row)
}
// The lifetime totals are folded from the history the statement already carries, so the summary
// costs no extra query. Money is kept per currency: the rails settle in roubles, Votes and Stars,
// and one sum across them would mean nothing.
paid, refunded := map[payments.Currency]int64{}, map[payments.Currency]int64{}
for _, e := range stmt.Ledger {
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Shop: e.Shop, Snapshot: e.Snapshot,
At: fmtTime(e.CreatedAt),
})
if e.ChipsDelta > 0 {
fv.ChipsBought += e.ChipsDelta
} else if e.Kind == "spend" {
fv.ChipsSpent -= e.ChipsDelta
}
m := payments.MoneyFromLedgerSnapshot(e.Snapshot)
if m.Currency() == "" {
continue
}
switch e.Kind {
case "fund":
paid[m.Currency()] += m.Minor()
case "refund":
refunded[m.Currency()] += m.Minor()
}
}
fv.Paid = fmtMoneyTotals(paid)
fv.Refunded = fmtMoneyTotals(refunded)
fv.LedgerQuery = template.URL(url.Values{"user": {id.String()}}.Encode())
return fv
}
// fmtMoneyTotals renders per-currency minor-unit sums as display strings, in a stable order so the
// panel does not reshuffle between reloads (map iteration is random).
func fmtMoneyTotals(totals map[payments.Currency]int64) []string {
currencies := make([]string, 0, len(totals))
for c := range totals {
currencies = append(currencies, string(c))
}
sort.Strings(currencies)
out := make([]string, 0, len(currencies))
for _, c := range currencies {
m, err := payments.MoneyFromMinor(totals[payments.Currency(c)], payments.Currency(c))
if err != nil {
continue
}
out = append(out, fmtMoney(m))
}
return out
}
// overrideName renders a purchase override as the form/select value ("default"/"allow"/"deny").
func overrideName(ov payments.PurchaseOverride) string {
switch ov {
@@ -0,0 +1,212 @@
package server
import (
"encoding/csv"
"encoding/json"
"fmt"
"html/template"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/payments"
)
// ledgerPageSize is how many operations one ledger page shows.
const ledgerPageSize = 100
// ledgerDefaultDays is how far back the ledger looks when the operator has not chosen a range. A
// month is the reporting period that matters (it is what a tax filing covers) and it keeps the
// default view bounded on a ledger that only ever grows.
const ledgerDefaultDays = 30
// ledgerDateFormat is the date form the filter accepts and renders (an <input type="date"> value).
const ledgerDateFormat = "2006-01-02"
// ledgerKinds, ledgerWallets and ledgerProviders are the filter's fixed option lists. They are
// spelled out rather than derived from the data so the form is stable on an empty ledger and an
// operator can see what exists at all.
var (
ledgerKinds = []string{"fund", "spend", "admin_grant", "refund"}
ledgerWallets = []string{string(payments.SourceDirect), string(payments.SourceVK), string(payments.SourceTelegram)}
ledgerProviders = []string{providerYooKassa, providerRobokassa, providerVK, providerTelegram, "vk_ads", "admin"}
)
// ledgerFilterFrom reads the ledger filter out of the query string, defaulting the range to the last
// ledgerDefaultDays. An unparseable date or id is treated as unset rather than as an error: a
// hand-edited URL should narrow nothing, not break the page.
func ledgerFilterFrom(c *gin.Context, now time.Time) (payments.LedgerFilter, adminconsole.LedgerView) {
view := adminconsole.LedgerView{
Kinds: ledgerKinds,
Wallets: ledgerWallets,
Providers: ledgerProviders,
}
f := payments.LedgerFilter{}
from, to := strings.TrimSpace(c.Query("from")), strings.TrimSpace(c.Query("to"))
if from == "" && to == "" {
from = now.AddDate(0, 0, -ledgerDefaultDays).Format(ledgerDateFormat)
}
if t, err := time.Parse(ledgerDateFormat, from); err == nil {
f.From, view.From = t, from
}
if t, err := time.Parse(ledgerDateFormat, to); err == nil {
// The range is inclusive of the chosen end date, so it runs to the start of the next day.
f.To, view.To = t.AddDate(0, 0, 1), to
}
if id, err := uuid.Parse(strings.TrimSpace(c.Query("user"))); err == nil {
f.AccountID, view.UserID = id, id.String()
}
if v := c.Query("kind"); slicesHas(ledgerKinds, v) {
f.Kind, view.Kind = v, v
}
if v := c.Query("wallet"); slicesHas(ledgerWallets, v) {
f.Wallet, view.Wallet = v, v
}
if v := c.Query("provider"); slicesHas(ledgerProviders, v) {
f.Provider, view.Provider = v, v
}
view.FilterQuery = template.URL(ledgerFilterQuery(view))
return f, view
}
// slicesHas reports whether v is one of the allowed options. The filter only ever accepts a value
// from its own list, so a crafted query string cannot reach the store with something unexpected.
func slicesHas(allowed []string, v string) bool {
for _, a := range allowed {
if a == v {
return true
}
}
return false
}
// ledgerFilterQuery renders the active filters as an escaped query fragment. Every link that must
// stay on the same slice of the ledger — the pager, the CSV export, a refund's way back — is built
// from it, which is what keeps them in step with the form.
func ledgerFilterQuery(v adminconsole.LedgerView) string {
q := url.Values{}
for key, val := range map[string]string{
"from": v.From, "to": v.To, "user": v.UserID,
"kind": v.Kind, "wallet": v.Wallet, "provider": v.Provider,
} {
if val != "" {
q.Set(key, val)
}
}
return q.Encode()
}
// consoleLedger renders the all-accounts financial ledger: one page of operations under the current
// filter, and the totals for everything that filter matches — the picture no per-account card can
// give. The page number rides the same query string as the filters, so paging never silently widens
// the view.
func (s *Server) consoleLedger(c *gin.Context) {
f, view := ledgerFilterFrom(c, time.Now())
page, _ := strconv.Atoi(c.Query("page"))
if page < 1 {
page = 1
}
report, err := s.payments.LedgerReportPage(c.Request.Context(), f, ledgerPageSize, (page-1)*ledgerPageSize)
if err != nil {
s.consoleError(c, err)
return
}
view.Pager = adminconsole.NewPager(page, ledgerPageSize, report.Total)
view.Totals = ledgerTotalsRow(report.Totals)
for _, r := range report.Rows {
view.Rows = append(view.Rows, ledgerRow(r))
}
s.renderConsole(c, "ledger", "ledger", "Ledger", view)
}
// ledgerRow projects one report row for the logic-free template: times and money pre-formatted, the
// product title lifted out of the snapshot, and the refund affordance decided here rather than in
// the template — only a funded order can be reversed.
func ledgerRow(r payments.LedgerReportRow) adminconsole.LedgerRow {
row := adminconsole.LedgerRow{
At: fmtTime(r.CreatedAt),
AccountID: r.AccountID.String(),
Kind: r.Kind,
Source: r.Source,
Origin: r.Origin,
ChipsDelta: r.ChipsDelta,
Order: r.OrderID,
Provider: r.Provider,
Shop: r.Shop,
Refundable: r.Kind == "fund" && r.OrderID != "",
Title: snapshotTitle(r.Snapshot),
}
if r.HasMoney() {
row.Money = fmtMoney(r.Money)
}
return row
}
// ledgerTotalsRow pre-formats the period totals for the template.
func ledgerTotalsRow(t payments.LedgerTotals) adminconsole.LedgerTotalsRow {
out := adminconsole.LedgerTotalsRow{ChipsIn: t.ChipsIn, ChipsOut: t.ChipsOut}
for _, m := range t.MoneyIn {
out.MoneyIn = append(out.MoneyIn, fmtMoney(m))
}
for _, m := range t.MoneyRefunded {
out.MoneyRefunded = append(out.MoneyRefunded, fmtMoney(m))
}
return out
}
// fmtMoney renders an amount with its currency, e.g. "149.00 RUB".
func fmtMoney(m payments.Money) string {
return fmt.Sprintf("%s %s", m.Major(), m.Currency())
}
// snapshotTitle lifts the sold product's title out of a ledger row snapshot, so the table names what
// was bought instead of only its id. It returns "" when the snapshot carries no title.
func snapshotTitle(snapshot string) string {
if snapshot == "" {
return ""
}
var snap struct {
Title string `json:"title"`
}
if err := json.Unmarshal([]byte(snapshot), &snap); err != nil {
return ""
}
return snap.Title
}
// consoleLedgerExport streams the filtered ledger as a CSV attachment for tax reporting and rail
// reconciliation. It honours the same filters as the page it is linked from — an export that
// silently covered a different range than the screen would be worse than no export.
func (s *Server) consoleLedgerExport(c *gin.Context) {
f, _ := ledgerFilterFrom(c, time.Now())
rows, err := s.payments.FilteredLedgerExport(c.Request.Context(), f)
if err != nil {
s.consoleError(c, err)
return
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{
"created_at", "account_id", "kind", "source", "origin", "chips_delta",
"amount_minor", "currency", "product_id", "order_id", "provider", "provider_payment_id", "shop", "snapshot",
})
for _, r := range rows {
amount, currency := "", ""
if r.HasMoney() {
amount, currency = strconv.FormatInt(r.Money.Minor(), 10), string(r.Money.Currency())
}
_ = w.Write([]string{
r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID.String(), r.Kind, r.Source, r.Origin,
strconv.Itoa(r.ChipsDelta), amount, currency,
r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Shop, r.Snapshot,
})
}
w.Flush()
}
@@ -1,25 +1,30 @@
package server
import (
"encoding/csv"
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments"
)
// consoleRefund refunds a paid order in full at the operator's request. The operator performs the
// actual money refund on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment);
// this records it — a refund ledger row and a floor-0 chip revoke (never negative, D27). Idempotent.
// consoleRefund refunds a paid order in full at the operator's request: it records the reversal — a
// refund ledger row and a floor-0 chip revoke (never negative, D27) — and, on the direct rail, moves
// the money back through the provider's refund API first, so one click does the whole job and the
// ledger cannot claim a refund the provider never made. The rails with no refund API of ours (VK
// support, Telegram refundStarPayment, the Robokassa cabinet) still need the operator to move the
// money by hand; this records that. Idempotent either way.
func (s *Server) consoleRefund(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
back := consoleReturnTo(c.PostForm("back"), "/_gm/users/"+id.String())
if s.payments == nil {
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
return
@@ -29,12 +34,27 @@ func (s *Server) consoleRefund(c *gin.Context) {
s.renderConsoleMessage(c, "Refund failed", "no order to refund", back)
return
}
out, err := s.payments.RefundOrderFull(c.Request.Context(), orderID)
ctx := c.Request.Context()
ref, err := s.payments.OrderProviderRef(ctx, orderID)
if err != nil {
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
return
}
out, err := s.refundOrder(ctx, ref)
if err != nil {
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
return
}
if out.AlreadyRefunded {
if ref.Provider == providerYooKassa {
// The provider refunded the money on this very click, so this is a success, not a repeat.
// The reversal was already in the ledger because the provider's own refund notification
// reached us first — it names the same refund id, which is exactly why the idempotency
// index caught it and nothing was revoked twice. Saying "already refunded" here would read
// as "you clicked twice".
s.renderConsoleMessage(c, "Refunded", "the money is back with the customer; the reversal was already recorded, so nothing changed just now", back)
return
}
s.renderConsoleMessage(c, "Already refunded", "this order was already refunded", back)
return
}
@@ -46,26 +66,39 @@ func (s *Server) consoleRefund(c *gin.Context) {
s.renderConsoleMessage(c, "Refunded", msg, back)
}
// consoleLedgerExport streams the entire append-only ledger as a CSV attachment for tax reporting
// and rail reconciliation. The snapshot column carries the raw purchase/refund JSON.
func (s *Server) consoleLedgerExport(c *gin.Context) {
rows, err := s.payments.LedgerExport(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
// consoleReturnTo picks where the result page links back to: the caller's requested destination when
// it is a console page, else the fallback. A refund can be started from the account card or from the
// ledger (where the operator wants to land back on the same filtered slice), so the destination
// travels with the form — but only ever as a console-relative path, never as an arbitrary URL a
// crafted form could point elsewhere.
func consoleReturnTo(requested, fallback string) string {
if strings.HasPrefix(requested, "/_gm/") && !strings.HasPrefix(requested, "/_gm//") {
return requested
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{
"created_at", "account_id", "kind", "source", "origin", "chips_delta",
"product_id", "order_id", "provider", "provider_payment_id", "snapshot",
})
for _, r := range rows {
_ = w.Write([]string{
r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID, r.Kind, r.Source, r.Origin,
strconv.Itoa(r.ChipsDelta), r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Snapshot,
})
}
w.Flush()
return fallback
}
// refundOrder reverses a paid order, moving the money back through the rail's own refund API when
// there is one. The provider call comes first and a failure aborts with nothing recorded: the ledger
// must never claim a refund that did not happen. The reversal is then recorded under the provider's
// own refund id, which keeps the ledger reconcilable against the provider's records.
//
// A rail with no refund API of ours records under the operator's key instead, and the operator moves
// the money by hand (VK support, Telegram refundStarPayment, the Robokassa cabinet).
func (s *Server) refundOrder(ctx context.Context, ref payments.OrderRef) (payments.RefundOutcome, error) {
if ref.Provider != providerYooKassa {
return s.payments.RefundOrderFull(ctx, ref.OrderID)
}
refundID, err := s.refundYooKassa(ctx, ref)
if errors.Is(err, errRefundNotFinal) {
// The money is on its way but not settled. Nothing is recorded yet; pressing again is safe —
// the idempotency key returns the same refund rather than paying a second time.
s.log.Warn("yookassa refund not final", zap.String("order", ref.OrderID.String()), zap.Error(err))
return payments.RefundOutcome{}, fmt.Errorf("%w — nothing was recorded; try again in a minute", err)
}
if err != nil {
s.log.Error("yookassa refund failed", zap.String("order", ref.OrderID.String()), zap.Error(err))
return payments.RefundOutcome{}, fmt.Errorf("the provider did not refund the payment, nothing was recorded: %w", err)
}
return s.payments.RefundOrderFullAs(ctx, ref.OrderID, providerYooKassa, refundID)
}
+28 -9
View File
@@ -15,20 +15,28 @@ import (
"scrabble/backend/internal/robokassa"
)
// Ledger/order provider tags per rail.
// Ledger/order provider tags per rail. providerRobokassa is retired but never renamed or reused:
// historical ledger rows carry it, and it is load-bearing for the (provider, provider_payment_id)
// idempotency index.
const (
providerRobokassa = "robokassa" // the direct (RUB) rail
providerYooKassa = "yookassa" // the direct (RUB) rail
providerRobokassa = "robokassa" // the retired direct (RUB) rail
providerVK = "vk" // the VK Votes rail
providerTelegram = "telegram" // the Telegram Stars (XTR) rail
)
// yookassaReturnPath is where YooKassa returns the customer's browser after the hosted payment page.
// The credit never rides this redirect — it rides the server notification — so the page only closes
// the payment window and drops the customer back into the live app.
const yookassaReturnPath = "/pay/yookassa/return"
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
type walletOrderRequest struct {
ProductID string `json:"product_id"`
}
// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
// the provider's hosted-payment URL for the direct rail (empty for VK/Telegram, which settle
// the provider's hosted-payment page for the direct rail (empty for VK/Telegram, which settle
// in-app). Rail names the settling rail so the gateway knows how to launch it; for the Telegram
// Stars rail the gateway mints the invoice link from InvoiceTitle and InvoiceAmount (whole stars)
// via the bot and returns it in RedirectURL.
@@ -41,7 +49,7 @@ type walletOrderResponse struct {
}
// handleWalletOrder opens a pending order to fund a chip pack and returns the rail's launch
// details for the client: the Robokassa hosted-payment URL (direct), the order id for
// details for the client: the provider's hosted-payment URL (direct), the order id for
// VKWebAppShowOrderBox (VK), or the pack title and star amount the gateway mints into a Stars
// invoice link (Telegram). It enforces the wallet gate and, on the direct rail, D36 (a purchase
// requires a confirmed email anchor). No chips are credited here — only later, by the verified
@@ -84,13 +92,20 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
}
switch cxt.Kind {
case payments.SourceDirect:
shop, ok := s.robokassa.Shop(cxt.Subtype)
if !ok {
// YooKassa settles the direct rail. Robokassa is retired and normally unconfigured, so it is
// only reached when YooKassa has no shop for this channel — which is what makes reviving the
// old rail a credentials change rather than a code change. The rail is resolved before any
// account-level gate, so an unconfigured rail still reads as unavailable rather than as
// something the customer could fix.
ykShop, onYooKassa := s.yookassa.Shop(cxt.Subtype)
rkShop, onRobokassa := s.robokassa.Shop(cxt.Subtype)
if !onYooKassa && !onRobokassa {
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
return
}
// D36: a direct purchase requires a confirmed email anchor.
hasEmail, err := s.accounts.HasConfirmedEmail(ctx, uid)
// D36: a direct purchase requires a confirmed email anchor, which is also where the fiscal
// receipt is delivered.
email, hasEmail, err := s.accounts.ConfirmedEmail(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
@@ -99,6 +114,10 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
return
}
if onYooKassa {
s.orderYooKassa(c, uid, cxt, present, productID, ykShop, email)
return
}
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
if err != nil {
s.abortErr(c, err)
@@ -106,7 +125,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
}
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
RedirectURL: shop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
RedirectURL: rkShop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
Rail: providerRobokassa,
})
case payments.SourceVK:
@@ -0,0 +1,473 @@
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/yookassa"
)
// maxNotifyBytes caps the notification body the backend will read. The gateway already forwards only
// what it read from the provider; this is the backend's own ceiling on an untrusted body.
const maxNotifyBytes = 1 << 20
// yooKassaReceipt builds the fiscal receipt for a sale, or nil when receipts are off. They are off
// by default: the merchant runs on НПД, which is outside 54-ФЗ, so nothing is registered through the
// provider and each operation is reported to «Мой налог» instead. Setting a VAT rate code turns them
// back on — the switch a lost НПД regime would need — without touching this code.
func (s *Server) yooKassaReceipt(email, title string, amount yookassa.Amount) *yookassa.Receipt {
if !yookassa.ReceiptEnabled(s.vatCode) {
return nil
}
return yookassa.SingleItemReceipt(email, title, amount, s.vatCode)
}
// orderYooKassa opens a pending order on the direct rail and mints the YooKassa payment the customer
// is redirected to. shop is the merchant shop the caller resolved for this platform channel, and
// email is the account's confirmed address (D36) — the recovery anchor a direct purchase requires,
// and the delivery address of a fiscal receipt when receipts are switched on. No chips are credited
// here — only later, by the verified notification (or the reconcile sweep).
func (s *Server) orderYooKassa(c *gin.Context, uid uuid.UUID, cxt payments.Context, present []payments.Source, productID uuid.UUID, shop yookassa.Config, email string) {
ctx := c.Request.Context()
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerYooKassa)
if err != nil {
s.abortErr(c, err)
return
}
amount := yookassa.Amount{Value: res.Amount.Major(), Currency: string(res.Amount.Currency())}
payment, err := shop.CreatePayment(ctx, yookassa.PaymentRequest{
Amount: amount,
Capture: true,
Confirmation: yookassa.Confirmation{
Type: yookassa.ConfirmationRedirect,
ReturnURL: strings.TrimSuffix(s.publicURL, "/") + yookassaReturnPath,
},
Description: yookassa.TruncateDescription(res.Title),
Metadata: map[string]string{yookassa.MetadataOrderID: res.OrderID.String()},
// Off unless a VAT rate is configured: the merchant is outside 54-ФЗ and reports each
// operation itself, so no fiscal receipt is registered through the provider.
Receipt: s.yooKassaReceipt(email, res.Title, amount),
}, res.OrderID.String())
if err != nil {
// The order stays pending and unpaid; it expires on its own. The customer sees the generic
// error, and the log carries the provider's own description (which names the offending
// parameter when a receipt is at fault).
s.log.Error("yookassa create payment failed", zap.String("order", res.OrderID.String()), zap.Error(err))
c.AbortWithStatusJSON(http.StatusBadGateway, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available right now"}})
return
}
if payment.Confirmation.ConfirmationURL == "" {
s.log.Error("yookassa payment has no confirmation url",
zap.String("order", res.OrderID.String()), zap.String("payment", payment.ID))
c.AbortWithStatusJSON(http.StatusBadGateway, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available right now"}})
return
}
// Record the provider's payment id so the reconcile sweep and any refund can address it. A
// failure here is logged and tolerated: the credit path matches on the order id carried in the
// payment metadata, so the purchase still completes — only the safety net and refunds lose their
// handle on this one order.
if err := s.payments.AttachProviderPayment(ctx, res.OrderID, providerYooKassa, payment.ID); err != nil {
s.log.Error("yookassa attach payment id failed",
zap.String("order", res.OrderID.String()), zap.String("payment", payment.ID), zap.Error(err))
}
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
RedirectURL: payment.Confirmation.ConfirmationURL,
Rail: providerYooKassa,
})
}
// handleYooKassaNotify is the YooKassa webhook, reached only through the gateway (which checks the
// sender address and forwards the raw JSON body on the internal, gateway-only route).
//
// YooKassa does not sign notifications, so the body is never evidence: it only names a payment to
// re-read. Everything acted on comes from the confirming GetPayment. A succeeded payment credits its
// order exactly once (idempotent, and honoured even if the order expired); a canceled one records a
// failed event so the customer is told the attempt did not go through.
//
// The sender address is deliberately NOT checked. It would not add security — the confirming read is
// the whole boundary — and the one thing it did buy, keeping a forger from turning each fabricated
// notification into an outbound call of ours, is already bought earlier and far more tightly: the
// order is resolved from the notification's metadata first, and an id that matches no order costs a
// single indexed read and nothing else. Guessing a live order id means guessing a uuid. Meanwhile an
// address check is actively harmful in a deployment that cannot observe real client addresses (a
// contour behind a tunnel sees only its own), where it rejects genuine notifications.
//
// The reply tells the provider whether to redeliver: 200 for anything durably decided — including a
// duplicate and a permanent rejection, which no retry could fix — and 5xx only for a transient
// failure we want redelivered (YooKassa retries for 24 hours).
func (s *Server) handleYooKassaNotify(c *gin.Context) {
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, maxNotifyBytes))
if err != nil {
c.String(http.StatusInternalServerError, "error")
return
}
note, err := yookassa.ParseNotification(raw)
if err != nil {
s.log.Warn("yookassa notify: malformed body", zap.Error(err))
c.String(http.StatusOK, "ignored")
return
}
switch note.Event {
case yookassa.EventPaymentSucceeded, yookassa.EventPaymentCanceled:
case yookassa.EventRefundSucceeded:
s.handleYooKassaRefundNotification(c, note)
return
default:
// A subscription we do not act on.
c.String(http.StatusOK, "ignored")
return
}
claimed, err := note.Payment()
if err != nil {
s.log.Warn("yookassa notify: unusable payment object", zap.String("event", note.Event), zap.Error(err))
c.String(http.StatusOK, "ignored")
return
}
orderID, err := uuid.Parse(claimed.OrderID())
if err != nil {
s.log.Warn("yookassa notify: no order in payment metadata", zap.String("payment", claimed.ID))
c.String(http.StatusOK, "ignored")
return
}
ctx := c.Request.Context()
ref, err := s.payments.OrderProviderRef(ctx, orderID)
if errors.Is(err, payments.ErrOrderNotFound) {
s.log.Warn("yookassa notify: unknown order", zap.String("order", orderID.String()), zap.String("payment", claimed.ID))
c.String(http.StatusOK, "ignored")
return
}
if err != nil {
s.log.Error("yookassa notify: order lookup failed", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
payment, shop, err := s.confirmYooKassaPayment(ctx, claimed.ID, claimed.Recipient.AccountID, ref)
if err != nil {
var apiErr *yookassa.APIError
if errors.As(err, &apiErr) && !apiErr.Retryable() {
// The provider says this payment is not ours or not readable — a forged or stale
// notification. Nothing to redeliver.
s.log.Warn("yookassa notify: payment not confirmed",
zap.String("order", orderID.String()), zap.String("payment", claimed.ID), zap.Error(err))
c.String(http.StatusOK, "ignored")
return
}
s.log.Error("yookassa notify: confirm failed",
zap.String("order", orderID.String()), zap.String("payment", claimed.ID), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
if !s.yooKassaPaymentMatchesOrder(payment, shop, orderID) {
c.String(http.StatusOK, "ignored")
return
}
switch payment.Status {
case yookassa.StatusSucceeded:
if err := s.creditYooKassaPayment(ctx, orderID, payment); err != nil {
if isPermanentFundError(err) {
s.log.Warn("yookassa notify rejected", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusOK, "rejected")
return
}
s.log.Error("yookassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
case yookassa.StatusCanceled:
// An active decline (§9): the customer is told the attempt failed. An order abandoned without
// a decline never reaches here — it just expires, invisibly.
s.recordYooKassaFailure(ctx, ref.AccountID, orderID, payment)
default:
// Still pending: the notification raced ahead of the payment, or was forged. Either way the
// reconcile sweep will settle the order, so there is nothing to redeliver.
s.log.Info("yookassa notify: payment not final",
zap.String("order", orderID.String()), zap.String("status", payment.Status))
}
c.String(http.StatusOK, "ok")
}
// handleYooKassaRefundNotification reverses a refund the provider reports as completed. Its reason
// for existing is the merchant cabinet: an operator can refund there, and such a refund never passes
// through our API, so without this the money would go back while the chips stayed credited.
//
// As with a payment, the body is only a hint — the refund is re-read from the API before anything is
// recorded. The reversal is idempotent on (yookassa, refund id), so the notification for a refund the
// console already recorded reverses nothing a second time.
func (s *Server) handleYooKassaRefundNotification(c *gin.Context, note yookassa.Notification) {
claimed, err := note.Refund()
if err != nil {
s.log.Warn("yookassa notify: unusable refund object", zap.Error(err))
c.String(http.StatusOK, "ignored")
return
}
ctx := c.Request.Context()
// The refund object names the payment, not our order, so the order is found by the payment id we
// recorded when the payment was minted. The claim is untrusted, but it only selects which shop's
// credentials perform the confirming read; the confirmed refund is then bound back to this order.
ref, err := s.payments.OrderByProviderPayment(ctx, providerYooKassa, claimed.PaymentID)
if errors.Is(err, payments.ErrOrderNotFound) {
s.log.Warn("yookassa refund notify: no order for the payment",
zap.String("payment", claimed.PaymentID), zap.String("refund", claimed.ID))
c.String(http.StatusOK, "ignored")
return
}
if err != nil {
s.log.Error("yookassa refund notify: order lookup failed", zap.String("payment", claimed.PaymentID), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
shop, ok := s.yookassa.Shop(ref.Shop)
if !ok {
s.log.Warn("yookassa refund notify: no shop for the order's channel", zap.String("order", ref.OrderID.String()))
c.String(http.StatusOK, "ignored")
return
}
refund, err := shop.GetRefund(ctx, claimed.ID)
if err != nil {
var apiErr *yookassa.APIError
if errors.As(err, &apiErr) && !apiErr.Retryable() {
s.log.Warn("yookassa refund notify: refund not confirmed",
zap.String("order", ref.OrderID.String()), zap.String("refund", claimed.ID), zap.Error(err))
c.String(http.StatusOK, "ignored")
return
}
s.log.Error("yookassa refund notify: confirm failed",
zap.String("order", ref.OrderID.String()), zap.String("refund", claimed.ID), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
if refund.Status != yookassa.StatusSucceeded || refund.PaymentID != ref.PaymentID {
s.log.Warn("yookassa refund notify: confirmed refund does not settle this order",
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
zap.String("status", refund.Status), zap.String("refund_payment", refund.PaymentID))
c.String(http.StatusOK, "ignored")
return
}
// The reversal engine is full-refund-only by design: it revokes exactly what the pack funded and
// rejects any other amount. A partial refund therefore cannot be recorded without guessing how
// many chips it should cost, so it is left to an operator — loudly, because the money has moved.
paid, err := payments.ParseMoney(refund.Amount.Value, payments.Currency(refund.Amount.Currency))
if err != nil || paid.Currency() != ref.Amount.Currency() || paid.Minor() != ref.Amount.Minor() {
s.log.Error("yookassa refund notify: partial refund needs an operator; nothing was reversed",
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
zap.String("refunded", refund.Amount.Value), zap.String("order_amount", ref.Amount.Major()))
c.String(http.StatusOK, "ignored")
return
}
out, err := s.payments.RefundOrderFullAs(ctx, ref.OrderID, providerYooKassa, refund.ID)
if err != nil {
if errors.Is(err, payments.ErrOrderNotPaid) {
s.log.Warn("yookassa refund notify: the order was never credited",
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID))
c.String(http.StatusOK, "ignored")
return
}
s.log.Error("yookassa refund notify: reversal failed",
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
if !out.AlreadyRefunded {
// This is the first record of the refund. It is usually one an operator made in the merchant
// cabinet, but it can equally be a console refund whose notification outran the console's own
// write — both name the same refund id, so either way it is recorded exactly once.
s.log.Info("yookassa refund notify: reversed a refund",
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
zap.Int("revoked", out.Revoked), zap.Int("loss", out.Loss))
}
c.String(http.StatusOK, "ok")
}
// confirmYooKassaPayment re-reads a payment from the API — the authenticity check for the whole rail,
// since notifications are unsigned. The shop is chosen by the shop id the payment claims to have been
// paid to, falling back to the merchant channel recorded on the order; a claim naming a shop we do
// not run is not honoured with our own credentials.
func (s *Server) confirmYooKassaPayment(ctx context.Context, paymentID, claimedShopID string, ref payments.OrderRef) (yookassa.Payment, yookassa.Config, error) {
_, shop, ok := s.yookassa.ByShopID(claimedShopID)
if !ok {
shop, ok = s.yookassa.Shop(ref.Shop)
}
if !ok {
return yookassa.Payment{}, yookassa.Config{}, yookassa.ErrUnconfigured
}
payment, err := shop.GetPayment(ctx, paymentID)
return payment, shop, err
}
// yooKassaPaymentMatchesOrder checks the confirmed payment really belongs to the order being
// credited and to the kind of shop we think it is. It rejects a payment whose metadata names a
// different order, and — the guard that keeps play money out of the live ledger — a live payment
// arriving on test credentials or a test payment on live ones.
func (s *Server) yooKassaPaymentMatchesOrder(payment yookassa.Payment, shop yookassa.Config, orderID uuid.UUID) bool {
if payment.OrderID() != orderID.String() {
s.log.Warn("yookassa: confirmed payment belongs to another order",
zap.String("order", orderID.String()), zap.String("payment", payment.ID),
zap.String("payment_order", payment.OrderID()))
return false
}
if payment.Test != shop.IsTest {
s.log.Error("yookassa: payment test flag does not match the shop; refusing to credit",
zap.String("order", orderID.String()), zap.String("payment", payment.ID),
zap.Bool("payment_test", payment.Test), zap.Bool("shop_test", shop.IsTest))
return false
}
return true
}
// creditYooKassaPayment credits a confirmed succeeded payment to its order exactly once and records
// the succeeded event a duplicate must not re-emit.
func (s *Server) creditYooKassaPayment(ctx context.Context, orderID uuid.UUID, payment yookassa.Payment) error {
paid, err := payments.ParseMoney(payment.Amount.Value, payments.Currency(payment.Amount.Currency))
if err != nil {
return err
}
outcome, err := s.payments.Fund(ctx, orderID, providerYooKassa, payment.ID, paid)
if err != nil {
return err
}
if outcome.AlreadyCredited {
return nil
}
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
// The credit is already committed; only the in-app notification is lost, and the wallet still
// refreshes on the client's return.
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
return nil
}
// recordYooKassaFailure records the failed payment event for an actively declined payment, so the
// customer learns the attempt did not go through instead of watching a balance that never moves.
func (s *Server) recordYooKassaFailure(ctx context.Context, accountID uuid.UUID, orderID uuid.UUID, payment yookassa.Payment) {
reason := ""
if payment.CancellationDetails != nil {
reason = payment.CancellationDetails.Reason
}
s.log.Info("yookassa payment canceled",
zap.String("order", orderID.String()), zap.String("payment", payment.ID), zap.String("reason", reason))
payload, _ := json.Marshal(map[string]any{"reason": reason})
if err := s.payments.RecordPaymentEvent(ctx, accountID, &orderID, "failed", payload); err != nil {
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
// isPermanentFundError reports whether a credit failed for a reason no retry can fix — an order that
// does not exist, an amount that does not match, or a product that is no longer a chip pack.
func isPermanentFundError(err error) bool {
return errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound)
}
// refundYooKassa returns the money for a paid order through the YooKassa refund API and reports the
// provider's own refund id, which the ledger then records. It sends the order id as the idempotency
// key, so a repeated attempt returns the original refund instead of paying twice, and a refund
// receipt when receipts are switched on (the rail registers one the same way it does for a payment).
//
// It is called before anything is recorded: if the money does not move, nothing is written, and the
// ledger never claims a refund that did not happen.
func (s *Server) refundYooKassa(ctx context.Context, ref payments.OrderRef) (string, error) {
shop, ok := s.yookassa.Shop(ref.Shop)
if !ok {
return "", yookassa.ErrUnconfigured
}
if ref.PaymentID == "" {
return "", errors.New("the order carries no provider payment id to refund")
}
title, _, err := s.payments.OrderItem(ctx, ref.OrderID)
if err != nil {
return "", err
}
// The buyer's address is read only to deliver a refund receipt; with receipts off there is
// nothing to deliver and nothing to read.
email := ""
if yookassa.ReceiptEnabled(s.vatCode) {
if email, _, err = s.accounts.ConfirmedEmail(ctx, ref.AccountID); err != nil {
return "", err
}
}
amount := yookassa.Amount{Value: ref.Amount.Major(), Currency: string(ref.Amount.Currency())}
refund, err := shop.CreateRefund(ctx, yookassa.RefundRequest{
PaymentID: ref.PaymentID,
Amount: amount,
Receipt: s.yooKassaReceipt(email, title, amount),
}, ref.OrderID.String())
if err != nil {
return "", err
}
if refund.ID == "" {
return "", errors.New("the provider returned a refund with no id")
}
// Only a completed refund is recorded. A refund can still be canceled while pending, and the
// ledger is append-only, so recording early would mean revoking a customer's chips for money that
// then stayed with us — with no way to take the row back.
if refund.Status != yookassa.StatusSucceeded {
return "", fmt.Errorf("%w (status %s)", errRefundNotFinal, refund.Status)
}
return refund.ID, nil
}
// errRefundNotFinal reports that the provider accepted the refund but has not completed it. Retrying
// is safe and is the way out: the idempotency key returns the same refund rather than paying twice,
// so the operator can press again until it settles.
var errRefundNotFinal = errors.New("the provider has not completed the refund yet")
// ReconcileYooKassaOrders asks YooKassa what became of every pending order that reached its expiry
// age while carrying a payment id, and credits the ones that were in fact paid. It is the safety net
// for a notification that was lost for good: YooKassa redelivers for 24 hours, so a short outage
// heals itself, but a notification that never arrived at all would otherwise leave the money taken
// and the chips unowed. It runs once per order, just before the order is written off as expired.
//
// It returns how many orders it credited. Failures are logged and skipped: the next sweep retries.
func (s *Server) ReconcileYooKassaOrders(ctx context.Context) int {
if !s.yookassa.Configured() || s.payments == nil {
return 0
}
refs, err := s.payments.PendingForReconcile(ctx)
if err != nil {
s.log.Warn("yookassa reconcile: read pending orders failed", zap.Error(err))
return 0
}
credited := 0
for _, ref := range refs {
if ref.Provider != providerYooKassa || ref.PaymentID == "" {
continue
}
shop, ok := s.yookassa.Shop(ref.Shop)
if !ok {
continue
}
payment, err := shop.GetPayment(ctx, ref.PaymentID)
if err != nil {
s.log.Warn("yookassa reconcile: payment lookup failed",
zap.String("order", ref.OrderID.String()), zap.String("payment", ref.PaymentID), zap.Error(err))
continue
}
if payment.Status != yookassa.StatusSucceeded || !s.yooKassaPaymentMatchesOrder(payment, shop, ref.OrderID) {
continue
}
if err := s.creditYooKassaPayment(ctx, ref.OrderID, payment); err != nil {
s.log.Error("yookassa reconcile: credit failed",
zap.String("order", ref.OrderID.String()), zap.Error(err))
continue
}
s.log.Info("yookassa reconcile: credited an order no notification confirmed",
zap.String("order", ref.OrderID.String()), zap.String("payment", payment.ID))
credited++
}
return credited
}
+20 -2
View File
@@ -36,6 +36,7 @@ import (
"scrabble/backend/internal/session"
"scrabble/backend/internal/social"
"scrabble/backend/internal/telemetry"
"scrabble/backend/internal/yookassa"
)
// shutdownTimeout bounds how long Run waits for in-flight requests to finish
@@ -115,8 +116,19 @@ type Deps struct {
// Renderer is the image-render sidecar client for the PNG export artifact. A
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
Renderer *render.Client
// Robokassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
// set leaves the order and Result-callback endpoints unregistered.
// YooKassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
// set leaves the order and notification endpoints unregistered and falls the direct rail back to
// Robokassa.
YooKassa yookassa.Shops
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
// Unset sends no receipt at all — the state a merchant outside 54-ФЗ runs in.
YooKassaVatCode int
// PublicBaseURL is the canonical https origin the payment return URL is built from. It is
// required whenever YooKassa is configured.
PublicBaseURL string
// Robokassa configures the retired direct-rail provider — one merchant shop per channel. No
// deployment sets it, so the set is empty and the direct rail resolves to YooKassa; it stays
// wired so restoring the rail is a credentials change (backend/internal/robokassa/README.md).
Robokassa robokassa.Shops
}
@@ -146,6 +158,9 @@ type Server struct {
ads *ads.Service
payments *payments.Service
gamelimits *gamelimits.Service
yookassa yookassa.Shops
vatCode int
publicURL string
robokassa robokassa.Shops
notifier notify.Publisher
console *adminconsole.Renderer
@@ -201,6 +216,9 @@ func New(addr string, deps Deps) *Server {
ads: deps.Ads,
payments: deps.Payments,
gamelimits: deps.GameLimits,
yookassa: deps.YooKassa,
vatCode: deps.YooKassaVatCode,
publicURL: deps.PublicBaseURL,
robokassa: deps.Robokassa,
notifier: notifier,
renderer: deps.Renderer,
+72
View File
@@ -0,0 +1,72 @@
package yookassa
import (
"encoding/json"
"fmt"
)
// Notification events this integration subscribes to. An event name is "<object>.<status>": the
// object whose status changed and the status it entered.
const (
// EventPaymentSucceeded means the money was taken and the order may be credited.
EventPaymentSucceeded = "payment.succeeded"
// EventPaymentCanceled means the payment was actively declined or abandoned; nothing is credited
// and the payer is told the attempt failed.
EventPaymentCanceled = "payment.canceled"
// EventRefundSucceeded reports a completed refund. It matters because the merchant cabinet is a
// second way to issue one: a refund made there never passes through our API, so without this
// event the money would go back while the chips stayed credited.
EventRefundSucceeded = "refund.succeeded"
)
// notificationType is the fixed value of a notification envelope's type field.
const notificationType = "notification"
// Notification is an incoming webhook envelope. Object is left raw because its shape depends on the
// event — a payment for payment.*, a refund for refund.* — and because nothing in it may be acted on
// before GetPayment confirms it: YooKassa does not sign notifications.
type Notification struct {
Type string `json:"type"`
Event string `json:"event"`
Object json.RawMessage `json:"object"`
}
// ParseNotification decodes a webhook body and checks the envelope is a notification with an event.
// It deliberately validates nothing else: the body is untrusted input whose only job is to name an
// object to re-read from the API.
func ParseNotification(body []byte) (Notification, error) {
var n Notification
if err := json.Unmarshal(body, &n); err != nil {
return Notification{}, fmt.Errorf("yookassa: decode notification: %w", err)
}
if n.Type != notificationType || n.Event == "" {
return Notification{}, fmt.Errorf("yookassa: not a notification envelope (type %q, event %q)", n.Type, n.Event)
}
return n, nil
}
// Payment decodes the notification's object as a payment. Use it only for payment.* events; it
// yields the payment id to re-read, never the payment state to act on.
func (n Notification) Payment() (Payment, error) {
var p Payment
if err := json.Unmarshal(n.Object, &p); err != nil {
return Payment{}, fmt.Errorf("yookassa: decode notification payment: %w", err)
}
if p.ID == "" {
return Payment{}, fmt.Errorf("yookassa: notification payment has no id")
}
return p, nil
}
// Refund decodes the notification's object as a refund. Use it only for refund.* events; it yields
// the refund id to re-read, never the refund state to act on.
func (n Notification) Refund() (Refund, error) {
var r Refund
if err := json.Unmarshal(n.Object, &r); err != nil {
return Refund{}, fmt.Errorf("yookassa: decode notification refund: %w", err)
}
if r.ID == "" {
return Refund{}, fmt.Errorf("yookassa: notification refund has no id")
}
return r, nil
}
+80
View File
@@ -0,0 +1,80 @@
package yookassa
import (
"testing"
)
func TestParseNotification(t *testing.T) {
n, err := ParseNotification([]byte(`{"type":"notification","event":"payment.succeeded",
"object":{"id":"pay-1","status":"succeeded","paid":true,"metadata":{"order_id":"0197-order"}}}`))
if err != nil {
t.Fatalf("parse notification: %v", err)
}
if n.Event != EventPaymentSucceeded {
t.Errorf("event = %q, want %q", n.Event, EventPaymentSucceeded)
}
p, err := n.Payment()
if err != nil {
t.Fatalf("decode notification payment: %v", err)
}
if p.ID != "pay-1" {
t.Errorf("payment id = %q, want pay-1", p.ID)
}
if p.OrderID() != "0197-order" {
t.Errorf("order id = %q, want the order carried in metadata", p.OrderID())
}
}
func TestParseNotificationRejectsNonEnvelopes(t *testing.T) {
for name, body := range map[string]string{
"not json": `nonsense`,
"wrong type": `{"type":"payment","event":"payment.succeeded","object":{"id":"pay-1"}}`,
"no event": `{"type":"notification","object":{"id":"pay-1"}}`,
"empty object": `{}`,
} {
if _, err := ParseNotification([]byte(body)); err == nil {
t.Errorf("%s: accepted as a notification envelope", name)
}
}
}
func TestNotificationPaymentNeedsAnID(t *testing.T) {
// Without an id there is nothing to re-read from the API, and the body itself is never evidence.
n, err := ParseNotification([]byte(`{"type":"notification","event":"payment.succeeded","object":{"status":"succeeded"}}`))
if err != nil {
t.Fatalf("parse notification: %v", err)
}
if _, err := n.Payment(); err == nil {
t.Error("a payment object with no id was accepted")
}
}
func TestParseRefundNotification(t *testing.T) {
n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded",
"object":{"id":"refund-1","status":"succeeded","payment_id":"pay-1",
"amount":{"value":"149.00","currency":"RUB"}}}`))
if err != nil {
t.Fatalf("parse notification: %v", err)
}
if n.Event != EventRefundSucceeded {
t.Errorf("event = %q, want %q", n.Event, EventRefundSucceeded)
}
r, err := n.Refund()
if err != nil {
t.Fatalf("decode notification refund: %v", err)
}
// The refund names the payment, not our order — that is how it is resolved back to one.
if r.ID != "refund-1" || r.PaymentID != "pay-1" {
t.Errorf("refund = %+v, want refund-1 for pay-1", r)
}
}
func TestNotificationRefundNeedsAnID(t *testing.T) {
n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded","object":{"status":"succeeded"}}`))
if err != nil {
t.Fatalf("parse notification: %v", err)
}
if _, err := n.Refund(); err == nil {
t.Error("a refund object with no id was accepted")
}
}
+95
View File
@@ -0,0 +1,95 @@
package yookassa
// Fiscal receipt support for «Чеки от ЮKassa» (54-ФЗ). YooKassa registers a receipt itself — no cash
// register to rent, no OFD contract — but only if the payment and refund requests carry a receipt
// object.
//
// It is DORMANT: the merchant runs on НПД, which is outside 54-ФЗ, so no receipt is sent and the
// merchant reports each operation to «Мой налог» instead. The code stays because the switch back is
// foreseeable — НПД has an annual income ceiling, and losing the regime puts 54-ФЗ back in force —
// and turning it on is then setting one deploy variable rather than writing this again. Everything
// here is inert while BACKEND_YOOKASSA_VAT_CODE is unset (see ReceiptEnabled). Reference:
// https://yookassa.ru/developers/payment-acceptance/receipts/54fz/yoomoney/parameters-values
const (
// PaymentSubjectService is the settlement subject (54-ФЗ tag 1212) — what the money is taken
// for. Chips are sold as a service ("Услуга").
PaymentSubjectService = "service"
// PaymentModeFullPayment is the settlement method (54-ФЗ tag 1214) — the payer pays and receives
// the goods at once ("Полный расчет"). «Чеки от ЮKassa» accept only this and full_prepayment;
// partial prepayment, advance and credit are not supported.
PaymentModeFullPayment = "full_payment"
// VatCodeNone is the VAT rate code (54-ФЗ tag 1199) for «Без НДС» — the rate a sole proprietor on
// УСН or ПСН charges. The deployed value is configurable because the rate is the one receipt
// attribute that genuinely changes (Russia raised the main rate on 1 Jan 2026, adding codes 11
// and 12).
VatCodeNone = 1
// VatCodeOff is the configured value that means "send no receipt at all" — the state a merchant
// outside 54-ФЗ runs in. It is the default, so receipts stay off until a rate is deliberately set.
VatCodeOff = 0
// VatCodeMax is the highest VAT rate code the API accepts; codes run 1..12.
VatCodeMax = 12
)
// Customer is the payer's contact data for delivering the receipt. «Чеки от ЮKassa» deliver only by
// email (no SMS), so Email is required — the direct rail's confirmed email anchor supplies it.
type Customer struct {
Email string `json:"email,omitempty"`
}
// ReceiptItem is one line of a fiscal receipt: what was sold, how much of it, for how much, and the
// three fiscal attributes that classify it.
type ReceiptItem struct {
Description string `json:"description"`
Quantity float64 `json:"quantity"`
Amount Amount `json:"amount"`
VatCode int `json:"vat_code"`
PaymentSubject string `json:"payment_subject"`
PaymentMode string `json:"payment_mode"`
}
// Receipt is the fiscal receipt data sent alongside a payment or a refund. tax_system_code is
// deliberately absent: YooKassa ignores it for «Чеки от ЮKassa».
type Receipt struct {
Customer Customer `json:"customer"`
Items []ReceiptItem `json:"items"`
}
// maxDescriptionRunes is the API's limit for both a payment description and a receipt line
// description (54-ФЗ tag 1030). A longer title is truncated rather than rejected, so an over-long
// product name cannot block a purchase.
const maxDescriptionRunes = 128
// SingleItemReceipt builds a one-line receipt for selling title at amount to email. Chip packs are
// always a single line bought once, so quantity is 1 and the line amount is the whole payment.
func SingleItemReceipt(email, title string, amount Amount, vatCode int) *Receipt {
return &Receipt{
Customer: Customer{Email: email},
Items: []ReceiptItem{{
Description: TruncateDescription(title),
Quantity: 1,
Amount: amount,
VatCode: vatCode,
PaymentSubject: PaymentSubjectService,
PaymentMode: PaymentModeFullPayment,
}},
}
}
// TruncateDescription shortens s to the API's description limit, counting runes rather than bytes so
// a Cyrillic title is not cut mid-character.
func TruncateDescription(s string) string {
r := []rune(s)
if len(r) <= maxDescriptionRunes {
return s
}
return string(r[:maxDescriptionRunes])
}
// ValidVatCode reports whether code is a rate the API accepts (1..12) or VatCodeOff, which disables
// receipts altogether.
func ValidVatCode(code int) bool { return code == VatCodeOff || (code >= 1 && code <= VatCodeMax) }
// ReceiptEnabled reports whether a configured VAT rate code asks for fiscal receipts. A merchant
// outside 54-ФЗ leaves the code unset and no receipt is built or sent.
func ReceiptEnabled(vatCode int) bool { return vatCode != VatCodeOff }
+58
View File
@@ -0,0 +1,58 @@
package yookassa
// Shops is a set of YooKassa merchant shops keyed by channel — the subtype of the trusted X-Platform
// signal for the direct rail ("web", "android"; "ios" later). The direct rail routes a payment to the
// per-channel shop for separate merchant accounts, accounting and receipts, while every shop still
// credits the one direct wallet (docs/PAYMENTS_DECISIONS_ru.md D42). An empty set leaves the direct
// order and notification endpoints unregistered.
type Shops map[string]Config
// Channel constants name the direct-rail X-Platform subtypes a shop is keyed by. ChannelWeb is the
// default: an unknown or empty channel routes here on the order side.
const (
ChannelWeb = "web"
ChannelAndroid = "android"
)
// Shop returns the shop that issues an order for channel, falling back to the web shop when channel
// is unknown, empty or not configured. The fallback is safe: routing only chooses the merchant
// account and receipt, never the credited wallet (always direct, D42), so a mis-attributed channel
// costs at most accounting accuracy, not money. The second result is false when neither the channel
// nor the web shop is configured.
func (s Shops) Shop(channel string) (Config, bool) {
if channel != "" {
if c, ok := s[channel]; ok && c.Configured() {
return c, true
}
}
if c, ok := s[ChannelWeb]; ok && c.Configured() {
return c, true
}
return Config{}, false
}
// ByShopID returns the shop with the given YooKassa shop id, which is how an incoming notification is
// attributed to one of several configured shops (the payment object reports it as
// recipient.account_id). Unlike Shop it does not fall back: an unrecognised shop id means the
// notification was not meant for us, and the caller must not guess at credentials.
func (s Shops) ByShopID(shopID string) (channel string, cfg Config, ok bool) {
if shopID == "" {
return "", Config{}, false
}
for ch, c := range s {
if c.Configured() && c.ShopID == shopID {
return ch, c, true
}
}
return "", Config{}, false
}
// Configured reports whether at least one shop has credentials (the YooKassa direct rail is live).
func (s Shops) Configured() bool {
for _, c := range s {
if c.Configured() {
return true
}
}
return false
}
+125
View File
@@ -0,0 +1,125 @@
package yookassa
import (
"strings"
"testing"
)
func testShops() Shops {
return Shops{
ChannelWeb: {ShopID: "100500", SecretKey: "web-secret"},
ChannelAndroid: {ShopID: "100700", SecretKey: "android-secret"},
}
}
func TestShopsShopRouting(t *testing.T) {
s := testShops()
for channel, wantShopID := range map[string]string{
ChannelWeb: "100500",
ChannelAndroid: "100700",
"ios": "100500", // an unconfigured channel falls back to web
"": "100500", // so does an unknown platform subtype
} {
got, ok := s.Shop(channel)
if !ok || got.ShopID != wantShopID {
t.Errorf("Shop(%q) = %q ok=%v, want %q", channel, got.ShopID, ok, wantShopID)
}
}
}
func TestShopsShopWithoutWebFallback(t *testing.T) {
// Only android configured: an unknown channel has nowhere safe to fall back to.
s := Shops{ChannelAndroid: {ShopID: "100700", SecretKey: "android-secret"}}
if got, ok := s.Shop(ChannelAndroid); !ok || got.ShopID != "100700" {
t.Errorf("Shop(android) = %q ok=%v, want the android shop", got.ShopID, ok)
}
if _, ok := s.Shop("ios"); ok {
t.Error("Shop(ios) resolved with no web shop configured")
}
if _, ok := (Shops{}).Shop(ChannelWeb); ok {
t.Error("an empty set resolved a shop")
}
}
func TestShopsByShopIDDoesNotGuess(t *testing.T) {
s := testShops()
channel, cfg, ok := s.ByShopID("100700")
if !ok || channel != ChannelAndroid || cfg.SecretKey != "android-secret" {
t.Errorf("ByShopID(100700) = %q/%q ok=%v, want the android shop", channel, cfg.SecretKey, ok)
}
// An unrecognised shop id means the notification was not meant for us; guessing at credentials
// would verify a foreign payment against our own shop.
for _, id := range []string{"999999", ""} {
if _, _, ok := s.ByShopID(id); ok {
t.Errorf("ByShopID(%q) resolved a shop", id)
}
}
}
func TestShopsIgnoreHalfConfiguredEntries(t *testing.T) {
s := Shops{
ChannelWeb: {ShopID: "100500"}, // no secret key
ChannelAndroid: {SecretKey: "android-secret"},
}
if s.Configured() {
t.Error("Configured() = true with no complete shop")
}
if _, ok := s.Shop(ChannelWeb); ok {
t.Error("Shop(web) resolved a shop with no secret key")
}
if _, _, ok := s.ByShopID("100500"); ok {
t.Error("ByShopID matched a shop with no secret key")
}
}
func TestShopsConfigured(t *testing.T) {
if !testShops().Configured() {
t.Error("Configured() = false with two complete shops")
}
if (Shops{}).Configured() {
t.Error("Configured() = true for an empty set")
}
}
func TestSingleItemReceiptTruncatesLongTitles(t *testing.T) {
long := strings.Repeat("ф", 200) // Cyrillic: truncation must count runes, not bytes
r := SingleItemReceipt("buyer@example.test", long, Amount{Value: "1.00", Currency: "RUB"}, VatCodeNone)
got := []rune(r.Items[0].Description)
if len(got) != maxDescriptionRunes {
t.Errorf("description = %d runes, want %d", len(got), maxDescriptionRunes)
}
for _, c := range got {
if c != 'ф' {
t.Fatalf("truncation cut a multi-byte rune: %q", string(got))
}
}
if r.Items[0].Quantity != 1 {
t.Errorf("quantity = %v, want 1 (a chip pack is bought once)", r.Items[0].Quantity)
}
}
func TestValidVatCode(t *testing.T) {
// VatCodeOff is a legal configuration: it means "send no receipt", the state a merchant outside
// 54-ФЗ runs in.
for _, code := range []int{VatCodeOff, 1, 4, 11, 12} {
if !ValidVatCode(code) {
t.Errorf("vat code %d rejected, want accepted", code)
}
}
for _, code := range []int{-1, 13} {
if ValidVatCode(code) {
t.Errorf("vat code %d accepted, want rejected", code)
}
}
}
func TestReceiptEnabled(t *testing.T) {
if ReceiptEnabled(VatCodeOff) {
t.Error("receipts reported enabled with no VAT rate configured")
}
for _, code := range []int{1, 4, 11} {
if !ReceiptEnabled(code) {
t.Errorf("receipts reported disabled for vat code %d", code)
}
}
}
+280
View File
@@ -0,0 +1,280 @@
// Package yookassa talks to the YooKassa REST API for the direct-rail (RUB) payments: it creates a
// hosted payment the client is redirected to, re-reads a payment to confirm what really happened,
// and issues a refund. It is pure provider glue — no database, no payments-domain coupling — so the
// payments domain stays provider-agnostic and this layer is unit-testable in isolation.
//
// Two properties of the API shape everything here. First, YooKassa notifications carry NO signature:
// authenticity is established by re-reading the object with GetPayment (the notification body is a
// hint, never evidence) and, defensively, by the sender-IP allowlist in AllowedIP. Second, every
// state-changing call is made idempotent by an Idempotence-Key header, so a retried create or refund
// returns the original object instead of charging twice; callers pass the order id as that key.
//
// The order is threaded to the provider through metadata["order_id"] and echoed back on the payment,
// so a notification resolves to an order without trusting anything else in the body.
package yookassa
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// DefaultBaseURL is the production API root. Config.BaseURL overrides it (tests point it at an
// httptest server).
const DefaultBaseURL = "https://api.yookassa.ru/v3"
// MetadataOrderID is the metadata key carrying our order id through the provider and back on the
// payment object — the only link a notification gives us to an order.
const MetadataOrderID = "order_id"
// Payment statuses (https://yookassa.ru/developers/payment-acceptance/getting-started/payment-process).
// A single-stage payment (Capture true) moves pending -> succeeded or pending -> canceled;
// StatusWaitingForCapture only occurs for two-stage payments, which this integration does not use.
const (
StatusPending = "pending"
StatusWaitingForCapture = "waiting_for_capture"
StatusSucceeded = "succeeded"
StatusCanceled = "canceled"
)
// ConfirmationRedirect is the confirmation scenario this integration uses: YooKassa hosts the
// payment form and returns the customer to the return URL afterwards.
const ConfirmationRedirect = "redirect"
// requestTimeout bounds a single API call. The caller's context still governs cancellation; this is
// the ceiling for a provider that has stopped answering.
const requestTimeout = 20 * time.Second
// maxResponseBytes caps how much of a response body is read, so a misbehaving or spoofed endpoint
// cannot exhaust memory.
const maxResponseBytes = 1 << 20
// apiClient is shared by every shop so connections pool across them. It carries only a ceiling
// timeout; per-request cancellation rides the context.
var apiClient = &http.Client{Timeout: requestTimeout}
// Config is a YooKassa merchant shop's credentials. ShopID and SecretKey authenticate every call as
// HTTP Basic credentials. IsTest records that these are a test shop's credentials, which lets the
// caller refuse to credit a live payment against a test shop and vice versa. BaseURL overrides
// DefaultBaseURL when set.
type Config struct {
ShopID string
SecretKey string
IsTest bool
BaseURL string
}
// Amount is a monetary amount on the wire: a decimal string with the fraction separator "." and an
// ISO-4217 currency code. payments.Money.Major() already renders Value in this form.
type Amount struct {
Value string `json:"value"`
Currency string `json:"currency"`
}
// Confirmation carries the confirmation scenario. ReturnURL is sent on a create (where the customer
// lands after paying, absolute); ConfirmationURL comes back on the created payment and is the page
// the customer must be sent to.
type Confirmation struct {
Type string `json:"type"`
ReturnURL string `json:"return_url,omitempty"`
ConfirmationURL string `json:"confirmation_url,omitempty"`
}
// Recipient identifies the shop that received the payment. AccountID is the shop id, which is how an
// incoming notification is attributed to one of several configured shops.
type Recipient struct {
AccountID string `json:"account_id"`
GatewayID string `json:"gateway_id"`
}
// Cancellation explains a canceled payment: which participant decided (yoo_money, payment_network,
// merchant) and why. It is reported to the operator, not to the payer.
type Cancellation struct {
Party string `json:"party"`
Reason string `json:"reason"`
}
// PaymentRequest is the body of a create-payment call in the redirect confirmation scenario. Capture
// true settles in one stage: a paid payment goes straight to succeeded with no capture call.
type PaymentRequest struct {
Amount Amount `json:"amount"`
Capture bool `json:"capture"`
Confirmation Confirmation `json:"confirmation"`
Description string `json:"description,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Receipt *Receipt `json:"receipt,omitempty"`
}
// Payment is a YooKassa payment object. Paid reports that the money was actually taken; Test is true
// for a payment made against a test shop. Metadata echoes what the create call sent, so
// Metadata[MetadataOrderID] identifies the order.
type Payment struct {
ID string `json:"id"`
Status string `json:"status"`
Paid bool `json:"paid"`
Test bool `json:"test"`
Amount Amount `json:"amount"`
Description string `json:"description"`
Confirmation Confirmation `json:"confirmation"`
Metadata map[string]string `json:"metadata"`
Recipient Recipient `json:"recipient"`
CancellationDetails *Cancellation `json:"cancellation_details,omitempty"`
}
// OrderID returns the order the payment funds, taken from the metadata the create call threaded
// through. It is empty for a payment we did not originate.
func (p Payment) OrderID() string { return p.Metadata[MetadataOrderID] }
// RefundRequest is the body of a create-refund call. Amount may be the whole payment or part of it;
// Receipt carries the refund receipt required when the shop registers receipts through YooKassa.
type RefundRequest struct {
PaymentID string `json:"payment_id"`
Amount Amount `json:"amount"`
Description string `json:"description,omitempty"`
Receipt *Receipt `json:"receipt,omitempty"`
}
// Refund is a YooKassa refund object. Its ID is distinct from the refunded payment's id and is what
// the ledger records as the refund's idempotency key.
type Refund struct {
ID string `json:"id"`
Status string `json:"status"`
PaymentID string `json:"payment_id"`
Amount Amount `json:"amount"`
}
// ErrUnconfigured is returned when a call is attempted on a shop with no credentials.
var ErrUnconfigured = errors.New("yookassa: shop is not configured")
// APIError is a non-2xx answer from the API. Description and Parameter come from YooKassa's error
// object and name the offending field, which is what makes a malformed receipt diagnosable.
type APIError struct {
StatusCode int `json:"-"`
Type string `json:"type"`
ID string `json:"id"`
Code string `json:"code"`
Description string `json:"description"`
Parameter string `json:"parameter"`
RetryAfter int `json:"retry_after"`
}
// Error renders the status together with YooKassa's own description, including the offending
// parameter when the API named one.
func (e *APIError) Error() string {
msg := e.Description
if msg == "" {
msg = e.Code
}
if e.Parameter != "" {
return fmt.Sprintf("yookassa: http %d: %s (parameter %s)", e.StatusCode, msg, e.Parameter)
}
return fmt.Sprintf("yookassa: http %d: %s", e.StatusCode, msg)
}
// Retryable reports whether repeating the call could succeed. A 5xx or a 429 is transient; a 4xx is
// a permanent rejection of this request. Callers use it to decide whether to ask the provider to
// redeliver a notification or to accept it as durably handled.
func (e *APIError) Retryable() bool {
return e.StatusCode >= 500 || e.StatusCode == http.StatusTooManyRequests
}
// Configured reports whether the shop has credentials to call the API with.
func (c Config) Configured() bool { return c.ShopID != "" && c.SecretKey != "" }
// CreatePayment opens a payment for the order and returns the created object, whose
// Confirmation.ConfirmationURL is the page the customer must be sent to. idempotenceKey makes the
// call safe to retry — YooKassa replays the original result for 24 hours — so callers pass the order
// id and never mint a second payment for the same order.
func (c Config) CreatePayment(ctx context.Context, req PaymentRequest, idempotenceKey string) (Payment, error) {
var out Payment
err := c.do(ctx, http.MethodPost, "/payments", req, idempotenceKey, &out)
return out, err
}
// GetPayment re-reads a payment by id. This is the authenticity check for the whole rail: YooKassa
// notifications are unsigned, so only the object returned here may be acted on.
func (c Config) GetPayment(ctx context.Context, paymentID string) (Payment, error) {
var out Payment
err := c.do(ctx, http.MethodGet, "/payments/"+url.PathEscape(paymentID), nil, "", &out)
return out, err
}
// CreateRefund returns money for a succeeded payment and reports the created refund. idempotenceKey
// guards against a double refund on a retry; callers pass the order id. The returned refund is not
// necessarily final — check Status before acting on it.
func (c Config) CreateRefund(ctx context.Context, req RefundRequest, idempotenceKey string) (Refund, error) {
var out Refund
err := c.do(ctx, http.MethodPost, "/refunds", req, idempotenceKey, &out)
return out, err
}
// GetRefund re-reads a refund by id. As with GetPayment this is the authenticity check: a refund
// notification is unsigned, so only the object returned here may be acted on.
func (c Config) GetRefund(ctx context.Context, refundID string) (Refund, error) {
var out Refund
err := c.do(ctx, http.MethodGet, "/refunds/"+url.PathEscape(refundID), nil, "", &out)
return out, err
}
// do performs one authenticated API call and decodes the result into out. A non-2xx answer is
// returned as an *APIError carrying YooKassa's own error object when the body holds one.
func (c Config) do(ctx context.Context, method, path string, body any, idempotenceKey string, out any) error {
if !c.Configured() {
return ErrUnconfigured
}
var payload io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("yookassa: encode %s %s: %w", method, path, err)
}
payload = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, c.endpoint()+path, payload)
if err != nil {
return fmt.Errorf("yookassa: build %s %s: %w", method, path, err)
}
req.SetBasicAuth(c.ShopID, c.SecretKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if idempotenceKey != "" {
req.Header.Set("Idempotence-Key", idempotenceKey)
}
resp, err := apiClient.Do(req)
if err != nil {
return fmt.Errorf("yookassa: %s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return fmt.Errorf("yookassa: read %s %s: %w", method, path, err)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
apiErr := &APIError{StatusCode: resp.StatusCode}
// The body is YooKassa's error object on a handled fault and something else entirely on an
// edge failure; either way the status alone is enough to classify the error.
_ = json.Unmarshal(raw, apiErr)
return apiErr
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("yookassa: decode %s %s: %w", method, path, err)
}
return nil
}
// endpoint returns the API root for this shop, without a trailing slash.
func (c Config) endpoint() string {
if c.BaseURL == "" {
return DefaultBaseURL
}
return strings.TrimSuffix(c.BaseURL, "/")
}
+260
View File
@@ -0,0 +1,260 @@
package yookassa
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// capture records what the fake API received, so a test can assert on the request the client built.
type capture struct {
method string
path string
authUser string
authPass string
idempotenceKey string
body map[string]any
}
// fakeAPI serves one canned response and records the request. It returns a Config already pointed at
// it, which is how every API-shaped test drives the client without touching the network.
func fakeAPI(t *testing.T, status int, response string) (Config, *capture) {
t.Helper()
got := &capture{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.method = r.Method
got.path = r.URL.Path
got.authUser, got.authPass, _ = r.BasicAuth()
got.idempotenceKey = r.Header.Get("Idempotence-Key")
if raw, _ := io.ReadAll(r.Body); len(raw) > 0 {
_ = json.Unmarshal(raw, &got.body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, response)
}))
t.Cleanup(srv.Close)
return Config{ShopID: "100500", SecretKey: "secret", BaseURL: srv.URL}, got
}
func TestCreatePaymentBuildsTheRequest(t *testing.T) {
cfg, got := fakeAPI(t, http.StatusOK, `{
"id":"23d93cac-000f-5000-8000-126628f15141","status":"pending","paid":false,"test":false,
"amount":{"value":"149.00","currency":"RUB"},
"confirmation":{"type":"redirect","confirmation_url":"https://yoomoney.ru/pay/abc"},
"metadata":{"order_id":"0197-order"}}`)
amount := Amount{Value: "149.00", Currency: "RUB"}
p, err := cfg.CreatePayment(context.Background(), PaymentRequest{
Amount: amount,
Capture: true,
Confirmation: Confirmation{Type: ConfirmationRedirect, ReturnURL: "https://example.test/pay/yookassa/return"},
Description: "10 chips",
Metadata: map[string]string{MetadataOrderID: "0197-order"},
Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone),
}, "0197-order")
if err != nil {
t.Fatalf("create payment: %v", err)
}
if got.method != http.MethodPost || got.path != "/payments" {
t.Errorf("request = %s %s, want POST /payments", got.method, got.path)
}
if got.authUser != "100500" || got.authPass != "secret" {
t.Errorf("basic auth = %q:%q, want the shop id and secret key", got.authUser, got.authPass)
}
// The idempotence key is what makes a retried create return the original payment instead of
// minting a second one for the same order.
if got.idempotenceKey != "0197-order" {
t.Errorf("Idempotence-Key = %q, want the order id", got.idempotenceKey)
}
if got.body["capture"] != true {
t.Errorf("capture = %v, want true (single-stage payment)", got.body["capture"])
}
conf, _ := got.body["confirmation"].(map[string]any)
if conf["type"] != ConfirmationRedirect || conf["return_url"] != "https://example.test/pay/yookassa/return" {
t.Errorf("confirmation = %v, want a redirect with the return url", conf)
}
meta, _ := got.body["metadata"].(map[string]any)
if meta[MetadataOrderID] != "0197-order" {
t.Errorf("metadata = %v, want the order id threaded through", meta)
}
receipt, _ := got.body["receipt"].(map[string]any)
customer, _ := receipt["customer"].(map[string]any)
if customer["email"] != "buyer@example.test" {
t.Errorf("receipt customer = %v, want the buyer email", customer)
}
items, _ := receipt["items"].([]any)
if len(items) != 1 {
t.Fatalf("receipt items = %d, want 1", len(items))
}
item, _ := items[0].(map[string]any)
if item["vat_code"] != float64(VatCodeNone) || item["payment_subject"] != PaymentSubjectService || item["payment_mode"] != PaymentModeFullPayment {
t.Errorf("receipt item fiscal attributes = %v", item)
}
if p.Confirmation.ConfirmationURL != "https://yoomoney.ru/pay/abc" {
t.Errorf("confirmation url = %q, want the hosted payment page", p.Confirmation.ConfirmationURL)
}
if p.OrderID() != "0197-order" {
t.Errorf("OrderID() = %q, want the order the payment funds", p.OrderID())
}
}
func TestGetPaymentReadsTheObject(t *testing.T) {
cfg, got := fakeAPI(t, http.StatusOK, `{
"id":"pay-1","status":"succeeded","paid":true,"test":true,
"amount":{"value":"149.00","currency":"RUB"},
"recipient":{"account_id":"100500","gateway_id":"100700"},
"metadata":{"order_id":"0197-order"}}`)
p, err := cfg.GetPayment(context.Background(), "pay-1")
if err != nil {
t.Fatalf("get payment: %v", err)
}
if got.method != http.MethodGet || got.path != "/payments/pay-1" {
t.Errorf("request = %s %s, want GET /payments/pay-1", got.method, got.path)
}
// A read is idempotent by nature, so no key is sent.
if got.idempotenceKey != "" {
t.Errorf("Idempotence-Key = %q, want none on a GET", got.idempotenceKey)
}
if p.Status != StatusSucceeded || !p.Paid {
t.Errorf("payment = %s paid=%v, want succeeded and paid", p.Status, p.Paid)
}
if !p.Test {
t.Error("Test = false, want true — a test-shop payment must be distinguishable from a live one")
}
if p.Recipient.AccountID != "100500" {
t.Errorf("recipient.account_id = %q, want the shop id", p.Recipient.AccountID)
}
}
func TestCreateRefundBuildsTheRequest(t *testing.T) {
cfg, got := fakeAPI(t, http.StatusOK, `{
"id":"refund-1","status":"succeeded","payment_id":"pay-1",
"amount":{"value":"149.00","currency":"RUB"}}`)
amount := Amount{Value: "149.00", Currency: "RUB"}
r, err := cfg.CreateRefund(context.Background(), RefundRequest{
PaymentID: "pay-1",
Amount: amount,
Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone),
}, "0197-order")
if err != nil {
t.Fatalf("create refund: %v", err)
}
if got.method != http.MethodPost || got.path != "/refunds" {
t.Errorf("request = %s %s, want POST /refunds", got.method, got.path)
}
if got.idempotenceKey != "0197-order" {
t.Errorf("Idempotence-Key = %q, want the order id (guards a double refund)", got.idempotenceKey)
}
if got.body["payment_id"] != "pay-1" {
t.Errorf("payment_id = %v, want the refunded payment", got.body["payment_id"])
}
if _, ok := got.body["receipt"]; !ok {
t.Error("refund carries no receipt — a refund receipt is required under «Чеки от ЮKassa»")
}
// The refund id is distinct from the payment id: the ledger keys the refund row on it, so the
// fund and refund rows can coexist under the same partial-unique index.
if r.ID != "refund-1" || r.PaymentID != "pay-1" {
t.Errorf("refund = %+v, want id refund-1 for payment pay-1", r)
}
}
func TestAPIErrorClassifiesRetryability(t *testing.T) {
cfg, _ := fakeAPI(t, http.StatusBadRequest,
`{"type":"error","id":"err-1","code":"invalid_request","description":"Receipt item is invalid","parameter":"receipt.items"}`)
_, err := cfg.GetPayment(context.Background(), "pay-1")
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error = %v, want an *APIError", err)
}
if apiErr.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want 400", apiErr.StatusCode)
}
// The offending parameter is what makes a malformed receipt diagnosable rather than a mystery.
if !strings.Contains(apiErr.Error(), "receipt.items") {
t.Errorf("error text %q does not name the offending parameter", apiErr.Error())
}
if apiErr.Retryable() {
t.Error("a 400 reported as retryable; a permanent rejection must not be re-delivered")
}
for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} {
cfg, _ := fakeAPI(t, status, `{"type":"error","description":"try later"}`)
_, err := cfg.GetPayment(context.Background(), "pay-1")
var e *APIError
if !errors.As(err, &e) || !e.Retryable() {
t.Errorf("status %d: error = %v, want a retryable *APIError", status, err)
}
}
}
func TestUnconfiguredShopMakesNoCall(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Error("an unconfigured shop reached the API")
}))
t.Cleanup(srv.Close)
for name, cfg := range map[string]Config{
"no credentials": {BaseURL: srv.URL},
"no secret key": {ShopID: "100500", BaseURL: srv.URL},
"no shop id": {SecretKey: "secret", BaseURL: srv.URL},
} {
if _, err := cfg.GetPayment(context.Background(), "pay-1"); !errors.Is(err, ErrUnconfigured) {
t.Errorf("%s: error = %v, want ErrUnconfigured", name, err)
}
}
}
func TestEndpointDefaultsToTheProductionAPI(t *testing.T) {
if got := (Config{}).endpoint(); got != DefaultBaseURL {
t.Errorf("endpoint = %q, want %q", got, DefaultBaseURL)
}
if got := (Config{BaseURL: "https://api.test/v3/"}).endpoint(); got != "https://api.test/v3" {
t.Errorf("endpoint = %q, want the trailing slash trimmed", got)
}
}
func TestGetRefundReadsTheObject(t *testing.T) {
cfg, got := fakeAPI(t, http.StatusOK, `{
"id":"refund-1","status":"succeeded","payment_id":"pay-1",
"amount":{"value":"149.00","currency":"RUB"}}`)
r, err := cfg.GetRefund(context.Background(), "refund-1")
if err != nil {
t.Fatalf("get refund: %v", err)
}
if got.method != http.MethodGet || got.path != "/refunds/refund-1" {
t.Errorf("request = %s %s, want GET /refunds/refund-1", got.method, got.path)
}
if r.Status != StatusSucceeded || r.PaymentID != "pay-1" {
t.Errorf("refund = %+v, want a succeeded refund of pay-1", r)
}
}
func TestCreateRefundReportsANonFinalStatus(t *testing.T) {
// A refund can be accepted and still be canceled later, so the caller must be able to see that it
// is not settled rather than treat any 200 as done.
cfg, _ := fakeAPI(t, http.StatusOK, `{
"id":"refund-1","status":"pending","payment_id":"pay-1",
"amount":{"value":"149.00","currency":"RUB"}}`)
r, err := cfg.CreateRefund(context.Background(), RefundRequest{
PaymentID: "pay-1", Amount: Amount{Value: "149.00", Currency: "RUB"},
}, "0197-order")
if err != nil {
t.Fatalf("create refund: %v", err)
}
if r.Status != StatusPending {
t.Errorf("status = %q, want pending surfaced to the caller", r.Status)
}
}
+27 -22
View File
@@ -132,28 +132,33 @@ GATEWAY_VK_APP_SECRET=
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
GATEWAY_VK_ID_CLIENT_SECRET=
# --- Payments: Robokassa (direct RUB rail) ----------------------------------
# The shop's merchant login + the two pass phrases (Password1 signs the launch request,
# Password2 signs/verifies the Result callback). An empty login leaves the direct rail off.
# ROBOKASSA_TEST=1 runs test payments against the shop's TEST pass phrases (no real money);
# empty/0 is live. Mapped in compose to BACKEND_ROBOKASSA_*; Gitea TEST_/PROD_ secrets, with
# PROD_BACKEND_ROBOKASSA_TEST a variable so go-live is a flag flip, not a secret redeploy.
ROBOKASSA_MERCHANT_LOGIN=
ROBOKASSA_PASSWORD1=
ROBOKASSA_PASSWORD2=
ROBOKASSA_TEST=
# Multi-shop direct rail (D42): the vars above seed the "web" channel; add per-channel shops here.
# ROBOKASSA_WEB_* overrides the web shop; ROBOKASSA_ANDROID_* adds the android (RuStore) shop. Each
# credits the one direct wallet; an empty login drops that channel (routing falls back to web). The
# Robokassa cabinet points each shop's Result URL at /pay/robokassa/result/web and .../android.
ROBOKASSA_WEB_MERCHANT_LOGIN=
ROBOKASSA_WEB_PASSWORD1=
ROBOKASSA_WEB_PASSWORD2=
ROBOKASSA_WEB_TEST=
ROBOKASSA_ANDROID_MERCHANT_LOGIN=
ROBOKASSA_ANDROID_PASSWORD1=
ROBOKASSA_ANDROID_PASSWORD2=
ROBOKASSA_ANDROID_TEST=
# --- Payments: YooKassa (direct RUB rail) ------------------------------------
# One merchant shop per channel (D42): the shop id (Настройки — Магазин, field shopId) and the
# secret key (Интеграция — Ключи API). Each shop credits the one direct wallet; a shop missing
# either credential drops that channel (order routing falls back to web), and no shop at all
# leaves the direct rail off. YOOKASSA_*_TEST=1 marks a TEST shop, which makes the intake refuse
# to credit a live payment against it (and a test payment against a live shop).
# Mapped in compose to BACKEND_YOOKASSA_*; Gitea TEST_/PROD_ secrets.
# Set each shop's notification URL in its cabinet (Интеграция — HTTP-уведомления) to
# ${PUBLIC_BASE_URL}/pay/yookassa/notify, events payment.succeeded + payment.canceled +
# refund.succeeded (the last one catches a refund issued in the cabinet, which bypasses our API).
YOOKASSA_WEB_SHOP_ID=
YOOKASSA_WEB_SECRET_KEY=
YOOKASSA_WEB_TEST=
# The android (RuStore) shop — add when that channel gets its own merchant account.
YOOKASSA_ANDROID_SHOP_ID=
YOOKASSA_ANDROID_SECRET_KEY=
YOOKASSA_ANDROID_TEST=
# Fiscal receipts (54-ФЗ) — OFF while empty, which is the intended state: the merchant runs on НПД,
# outside 54-ФЗ, so nothing is registered through the provider and each operation is reported to
# «Мой налог» instead. Set a VAT rate code (tag 1199: 1 = «Без НДС», 4 = 20%, 11 = 22%) to turn
# receipts back on — the switch a lost НПД regime would need. A Gitea VARIABLE, not a secret.
YOOKASSA_VAT_CODE=
#
# Robokassa (the retired direct rail) is deliberately absent here: no deployment sets its
# credentials, which is what keeps it dormant. The backend still reads BACKEND_ROBOKASSA_* and
# falls the direct rail back to it if they are ever set again — see
# backend/internal/robokassa/README.md for the full variable set and how to revive the rail.
# --- Gateway anti-abuse ------------------------------------------------------
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
+4 -4
View File
@@ -76,10 +76,10 @@ compose binds from this directory.
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. |
| `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) |
| `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. |
| `BACKEND_ROBOKASSA_{WEB,ANDROID}_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | secret / variable | Multi-shop direct rail (D42): per-channel Robokassa shops. The legacy `BACKEND_ROBOKASSA_*` seeds the **web** shop; `…_WEB_*` overrides it, `…_ANDROID_*` adds the **android** (RuStore) shop. Each credits the one `direct` wallet; the cabinet Result URL per shop is `/pay/robokassa/result/{web,android}`. Empty login ⇒ that channel unconfigured (order routing falls back to the web shop). |
| `BACKEND_YOOKASSA_{WEB,ANDROID}_SHOP_ID` | secret | YooKassa shop id for that channel of the direct RUB rail (cabinet: **Настройки — Магазин**, field `shopId`). Empty ⇒ that channel is unconfigured and order routing falls back to the **web** shop; no channel configured at all leaves the rail off. Each shop's **Интеграция — HTTP-уведомления** must point at `<PUBLIC_BASE_URL>/pay/yookassa/notify` with events `payment.succeeded`, `payment.canceled` and `refund.succeeded`. |
| `BACKEND_YOOKASSA_{WEB,ANDROID}_SECRET_KEY` | secret | That shop's API secret key (cabinet: **Интеграция — Ключи API**). It is the password half of the HTTP Basic credentials on every API call. A shop id without a secret key is ignored. |
| `BACKEND_YOOKASSA_{WEB,ANDROID}_TEST` | **variable** | `1` marks the credentials as a **test shop**: the intake then refuses to credit anything but a test payment (and a live shop refuses a test one). A variable, not a secret, so switching a contour between a test and a live shop is a flag flip + redeploy. |
| `BACKEND_YOOKASSA_VAT_CODE` | **variable** | Fiscal receipts (54-ФЗ). **Empty by default = no receipt is sent at all**, which is the intended state: the merchant runs on НПД, outside 54-ФЗ, so nothing is registered through the provider and each operation is reported to «Мой налог» instead. Setting a VAT rate code (tag 1199: `1` = «Без НДС», `4` = 20%, `11` = 22%) switches receipts back on with no code change — the path a lost НПД regime (annual income ceiling) would take. Confirm the rate with the accountant before switching it on: a wrong rate produces wrong receipts, not a failed payment, so it fails silently. |
**Plus the bot token**`TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
+18 -19
View File
@@ -164,25 +164,24 @@ services:
# recipient(s) (comma-separated allowed). Both empty disables the alert worker.
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-}
# Direct-rail (Robokassa) payment intake: the merchant login + Password1/Password2 and the
# test-mode flag (deploy env: TEST_/PROD_BACKEND_ROBOKASSA_*). An empty login leaves the
# direct order and Result-callback endpoints unregistered (the rail is off).
BACKEND_ROBOKASSA_MERCHANT_LOGIN: ${ROBOKASSA_MERCHANT_LOGIN:-}
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-}
# Per-channel shops for the multi-shop direct rail (D42): the legacy ROBOKASSA_* above seeds
# the "web" channel; ROBOKASSA_WEB_* overrides it and ROBOKASSA_ANDROID_* adds the android
# shop. Each shop credits the one direct wallet; an empty login drops that channel (order
# routing falls back to the web shop). Result URLs: /pay/robokassa/result/{web,android}.
BACKEND_ROBOKASSA_WEB_MERCHANT_LOGIN: ${ROBOKASSA_WEB_MERCHANT_LOGIN:-}
BACKEND_ROBOKASSA_WEB_PASSWORD1: ${ROBOKASSA_WEB_PASSWORD1:-}
BACKEND_ROBOKASSA_WEB_PASSWORD2: ${ROBOKASSA_WEB_PASSWORD2:-}
BACKEND_ROBOKASSA_WEB_TEST: ${ROBOKASSA_WEB_TEST:-}
BACKEND_ROBOKASSA_ANDROID_MERCHANT_LOGIN: ${ROBOKASSA_ANDROID_MERCHANT_LOGIN:-}
BACKEND_ROBOKASSA_ANDROID_PASSWORD1: ${ROBOKASSA_ANDROID_PASSWORD1:-}
BACKEND_ROBOKASSA_ANDROID_PASSWORD2: ${ROBOKASSA_ANDROID_PASSWORD2:-}
BACKEND_ROBOKASSA_ANDROID_TEST: ${ROBOKASSA_ANDROID_TEST:-}
# Direct-rail (YooKassa) payment intake, one merchant shop per channel (D42): the shop id +
# secret key and a flag marking a test shop (deploy env: TEST_/PROD_BACKEND_YOOKASSA_*).
# Each shop credits the one direct wallet; a shop missing either credential drops that
# channel (order routing falls back to the web shop), and no shop at all leaves the direct
# order and notification endpoints unregistered. Notification URL (set per shop in the
# YooKassa cabinet): ${PUBLIC_BASE_URL}/pay/yookassa/notify — events payment.succeeded,
# payment.canceled and refund.succeeded.
BACKEND_YOOKASSA_WEB_SHOP_ID: ${YOOKASSA_WEB_SHOP_ID:-}
BACKEND_YOOKASSA_WEB_SECRET_KEY: ${YOOKASSA_WEB_SECRET_KEY:-}
BACKEND_YOOKASSA_WEB_TEST: ${YOOKASSA_WEB_TEST:-}
BACKEND_YOOKASSA_ANDROID_SHOP_ID: ${YOOKASSA_ANDROID_SHOP_ID:-}
BACKEND_YOOKASSA_ANDROID_SECRET_KEY: ${YOOKASSA_ANDROID_SECRET_KEY:-}
BACKEND_YOOKASSA_ANDROID_TEST: ${YOOKASSA_ANDROID_TEST:-}
# Fiscal receipts (54-ФЗ) are OFF: the merchant runs on НПД, which is outside 54-ФЗ, so no
# receipt is registered through the provider and each operation is reported to «Мой налог».
# Setting a VAT rate code (tag 1199: 1 = «Без НДС», 4 = 20%, 11 = 22%) turns receipts back on
# — the switch a lost НПД regime would need — with no code change.
BACKEND_YOOKASSA_VAT_CODE: ${YOOKASSA_VAT_CODE:-}
# The dictionary lives on a named volume seeded from the image on first boot
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
# inherits). The admin console writes new version subdirectories here, and the
+7 -4
View File
@@ -54,10 +54,13 @@ export GATEWAY_RECOMMENDED_CLIENT_VERSION='$GATEWAY_RECOMMENDED_CLIENT_VERSION'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
export ROBOKASSA_MERCHANT_LOGIN='$ROBOKASSA_MERCHANT_LOGIN'
export ROBOKASSA_PASSWORD1='$ROBOKASSA_PASSWORD1'
export ROBOKASSA_PASSWORD2='$ROBOKASSA_PASSWORD2'
export ROBOKASSA_TEST='$ROBOKASSA_TEST'
export YOOKASSA_WEB_SHOP_ID='$YOOKASSA_WEB_SHOP_ID'
export YOOKASSA_WEB_SECRET_KEY='$YOOKASSA_WEB_SECRET_KEY'
export YOOKASSA_WEB_TEST='$YOOKASSA_WEB_TEST'
export YOOKASSA_ANDROID_SHOP_ID='$YOOKASSA_ANDROID_SHOP_ID'
export YOOKASSA_ANDROID_SECRET_KEY='$YOOKASSA_ANDROID_SECRET_KEY'
export YOOKASSA_ANDROID_TEST='$YOOKASSA_ANDROID_TEST'
export YOOKASSA_VAT_CODE='${YOOKASSA_VAT_CODE:-1}'
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
+128 -26
View File
@@ -23,7 +23,7 @@ The game earns through two orthogonal channels:
The currency is **two-tier**:
```
money (VK Votes / TG Stars / RUB via Robokassa) ─┐
money (VK Votes / TG Stars / RUB via YooKassa) ─┐
├─► Фишки (chips) ──► values
rewarded ad view ─┘ (no-ads, hints,
tournament fee)
@@ -46,7 +46,7 @@ The chip balance is **segmented by `source`** — the platform where the chips w
|------------|----------------------------------|
| `vk` | VK Votes purchase, VK rewarded ad |
| `telegram` | TG Stars purchase |
| `direct` | Robokassa purchase (web / native)|
| `direct` | YooKassa purchase (web / native) |
A single account holds all three segments simultaneously: `balance = (account_id, source)`,
up to three rows — a row is materialised lazily on the segment's first funding, and an absent
@@ -54,17 +54,27 @@ segment reads as zero. Segmentation is **not** per-identity — see §6.
Why segmented, not one pooled balance: store rules forbid activating value that was paid
for outside the store's own cash desk. Chips funded inside VK (Votes) may only be spent in
the VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside
the VK context; Stars only inside Telegram; YooKassa-funded (`direct`) chips only outside
the stores. See §4.
**Multi-shop direct rail (D42).** The `direct` rail routes to one Robokassa **merchant shop per
**Multi-shop direct rail (D42).** The `direct` rail routes to one YooKassa **merchant shop per
channel** — `web` and `android` (RuStore), `ios` later — chosen by the trusted `X-Platform` subtype;
every shop credits the one `direct` wallet (no per-channel wallet). This is merchant-account
separation for accounting / receipts only: the order records its `shop` (shown in the admin report),
and each shop's Result callback is verified by its own Password2 at `/pay/robokassa/result/<channel>`.
separation for accounting / receipts only: the order records its `shop` (shown in the admin report).
All shops share one notification endpoint, `/pay/yookassa/notify`; an incoming notification is
attributed to a shop by the shop id the payment reports (`recipient.account_id`), falling back to the
channel recorded on the order, and that shop's credentials perform the confirming read (§9).
Standalone apps (Android/iOS) sign in by email only, so a direct purchase always has the D36 email
anchor (D43). Fiscalization (54-ФЗ via the Robokassa cabinet under one ИП) is a single source
regardless of the number of shops (D41).
anchor (D43), which is also where the fiscal receipt is delivered. Fiscalization (54-ФЗ under one ИП)
is a single source regardless of the number of shops (D41).
**Robokassa is retired, not deleted (D47).** It settled the `direct` rail before YooKassa. Its code,
tests and wiring stay in the tree and the rail falls back to it when no YooKassa shop is configured,
so reviving it is a credentials change rather than a code change; no deployment sets those
credentials today. `backend/internal/robokassa/README.md` records the retired variables, the cabinet
configuration and the revival steps. Ledger rows written while it was live keep
`provider = 'robokassa'` — that literal is load-bearing for the idempotency index and is never
renamed or reused.
**Payment availability kill switch (D45/D46).** An operator can disable purchases on a rail/channel
(`direct:web` / `direct:android` / `vk` / `telegram`) or for one account, live from `/_gm`, and the
@@ -224,20 +234,57 @@ never the source of truth.
## 9. Payment intake
**Server callback only.** Chips are credited solely on a **verified** (signature/HMAC)
provider callback — Robokassa webhook / TG `successful_payment` / VK callback. A client "I
paid" is ignored.
**Server callback only.** Chips are credited solely on a **verified** provider callback —
YooKassa notification / TG `successful_payment` / VK callback. A client "I paid" is ignored. What
"verified" means differs by rail: VK and Telegram sign their callbacks, and **YooKassa does not**
(D48) — see the confirming read below.
**Single writer.** One payments domain is the only writer of the ledger. Public webhooks
(Robokassa/VK) terminate at the edge (Caddy/gateway) and proxy into payments; TG
(YooKassa/VK) terminate at the edge (Caddy/gateway) and proxy into payments; TG
`successful_payment` reaches the bot, which forwards into payments. One place credits and
dedupes.
**Order-flow.** The server pre-creates an `order(pending)` with account / platform / pack /
expected amount / origin. The `order_id` is threaded to the provider (Robokassa `InvId` / TG
`invoice_payload` / VK `item` — confirm the exact VK field at integration). The callback
matches by `order_id` (never by amount, so equal-amount collisions cannot happen), verifies
the amount, credits, marks `paid`. **Idempotency:** dedup by `(provider, provider_payment_id)`.
expected amount / origin. The `order_id` is threaded to the provider (YooKassa `metadata.order_id` /
TG `invoice_payload` / VK `item`). The callback matches by `order_id` (never by amount, so
equal-amount collisions cannot happen), verifies the amount, credits, marks `paid`.
**Idempotency:** dedup by `(provider, provider_payment_id)`.
**Direct rail (YooKassa).** Opening a purchase is an outbound API call: the server creates the
payment (`POST /v3/payments`, `capture: true`, redirect confirmation, the order id as both the
`Idempotence-Key` and `metadata.order_id`, plus the fiscal receipt of §12) and sends the customer to
the returned `confirmation_url`. The provider's payment id is recorded on the order straight away,
which is what later lets the order be re-checked and refunded. The browser return page
(`/pay/yookassa/return`) is cosmetic: the credit never rides a redirect.
**The notification is a hint, never evidence (D48).** YooKassa does not sign notifications, so
nothing in the body may be acted on. It only names a payment, which the server then re-reads with
`GET /v3/payments/{id}`; only that answer is trusted. Two guards ride on it: the payment's metadata
must name the order being credited, and its `test` flag must match the shop's, so a test-shop payment
can never credit real chips (nor a live payment be credited against test credentials). The reply
tells the provider whether to redeliver: 200 for anything durably decided, including a duplicate and
a permanent rejection, and 5xx only for a transient failure (YooKassa redelivers for 24 hours).
The sender address is **not** checked (D48 rev). It would add no security — the confirming read is
the whole boundary — and the one thing it bought, keeping a forger from turning each fabricated
notification into an outbound call of ours, is already bought earlier and far more tightly: the order
is resolved from the notification's metadata **before** any provider call, so an id matching no order
costs one indexed read and stops there, and guessing a live order id means guessing a uuid. An
address check is also actively harmful wherever the deployment cannot observe real client addresses —
a contour behind a tunnel sees only its own — where it rejects genuine notifications.
**Reconciliation on a short cadence (D49 rev).** No open-ended polling, but the check is **not** tied
to the order lifetime: that governs when an unpaid order is written off, which answers "how long may
a customer take to pay" — a different question from "how soon should we notice a lost callback".
Tying them together would make a customer wait a full lifetime for chips whenever the notification
path fails. The pending-order reaper instead asks the provider about every pending order older than a
minute that carries a payment id, and credits the ones that were in fact paid. That bounds the calls
one order can cause to its lifetime divided by the sweep interval — a handful — while a failure of
the primary path costs minutes rather than half an hour.
**A declined payment is surfaced.** A YooKassa `payment.canceled` records a `failed` payment event,
so the customer is told the attempt did not go through instead of watching a balance that never
moves. An order simply abandoned records nothing — it just expires, invisibly.
**Pending is invisible** to the user; it auto-expires on a timeout (~30 min, DB hygiene). A
valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation —
@@ -266,10 +313,33 @@ an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (em
message).
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. Refunds are
**admin-triggered** (the E7 console), since no rail pushes an unsolicited refund: Robokassa
refunds run through its refund API / merchant cabinet (auto-polling a rail's refund status is a
deferred worker, not worth it at low chargeback volume), VK refunds are handled by support, and
Telegram Stars refunds are issued with `refundStarPayment`. All of them converge on one engine —
**admin-triggered** (the E7 console). On the direct rail the console does the whole job in one click
(D50): it calls YooKassa's refund API first (`POST /v3/refunds`, the order id as the
`Idempotence-Key`, carrying the refund receipt of §12) and records the reversal only once the money
has actually moved — a failed call records **nothing**, so the ledger can never claim a refund that
did not happen. A refund is recorded **only in status `succeeded`**: one still `pending` can yet be
canceled, and the ledger is append-only, so recording early could revoke a customer's chips for money
that stayed with us. Pressing again is the way out — the idempotency key returns the same refund
rather than paying twice. The recorded refund id is the provider's own, which keeps the ledger
reconcilable against YooKassa's records.
**The cabinet is a second entry point (D52).** Unlike the earlier rails, YooKassa lets an operator
refund from the merchant cabinet, and such a refund never passes through our API — so the money would
go back while the chips stayed credited. The `refund.succeeded` notification closes that: the refund
is re-read from the API (the body is no more evidence than a payment notification's), bound back to
the order through the payment id we recorded, and reversed through the same engine — idempotent on
`(provider, refund id)`, so the notification for a refund the console already recorded reverses
nothing twice. The 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.
The two recording paths race, benignly: YooKassa fires `refund.succeeded` the moment the refund is
created, so the notification often writes the reversal before the console's own write lands. Both
name the same refund id, so the second is caught by the idempotency index and nothing is revoked
twice; the console reports the refund as successful rather than as a repeated click.
VK refunds are still handled by support and Telegram Stars refunds issued with `refundStarPayment`,
both recorded by hand afterwards. All of them converge on one engine —
the `Refund` method (`internal/payments`): it matches the paid order, appends a **refund** ledger
row (idempotent on `(provider, provider_refund_id)` — the refund id is distinct from the fund's
payment id, so the two rows coexist under the same partial-unique index), and **best-effort revokes
@@ -348,15 +418,47 @@ origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume
`origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0
chips (the by-product grant records the source `product_id` + snapshot) — full audit of rewards.
**Per-user financial report** in the admin console `/_gm` — segment balances, payments,
spends, grants, refunds, full history — as an extension of the existing user card
(`UserDetailView`, `handlers_admin_console.go`). Plus a ledger export.
**The ledger section** `/_gm/ledger` is the all-accounts view of the money: every operation, newest
first, filtered by date range (defaulting to the last 30 days), by **wallet** (`vk`/`telegram`/
`direct` — matching the funded segment or the benefit origin), by **rail** (the settling provider, so
chip spends drop out by construction), by kind and by account. Above the table sit the totals for
**everything the filter matches**, not merely the page: money in and refunded per currency (the rails
settle in roubles, Votes and Stars, so one sum across them would mean nothing) and chips credited and
spent. The row amounts come from the ledger snapshot, since the ledger's own columns count chips.
Paging, the CSV export and a refund's way back all carry the same filter query, so none of them can
silently show a different slice than the screen.
The **refund action** lives on the funded rows here — the operator can find a payment by filter
without knowing whose it is first — and returns to the same filtered view afterwards.
**The user card** carries the account's standing rather than its history: segment balances, benefits,
the refund-risk flag, and a short lifetime summary (money paid and refunded per currency, chips
credited and spent), with a link into the ledger section pre-filtered to that account. One place
renders operations, with filters and paging, instead of two.
## 12. Taxes and compliance
Receipts are automatic **through the provider**, and differ by rail:
- **Robokassa** (direct) — self-employed НПД receipt on payment.
- **YooKassa** (direct) — **no receipt is sent, by design (D51 rev)**. The merchant operates on
**НПД**, which is outside 54-ФЗ: there is no online cash register and the provider registers
nothing. Each operation is reported by the merchant to **«Мой налог»**, which issues the чек.
YooKassa does not serve receipts for this regime at all.
The fiscal code is **kept dormant**, not deleted: «Чеки от ЮKassa» (YooKassa owning the cash
register, the fiscal drive and the OFD contract) registers a receipt **only if the request carries
it**, and that request-building lives behind one switch — `BACKEND_YOOKASSA_VAT_CODE`. Unset, no
`receipt` is built or sent; set to a 54-ФЗ rate code (tag 1199) it goes back to sending one line
(pack title, quantity 1, amount) with the settlement subject `service` (tag 1212) and method
`full_payment` (tag 1214), delivered by email to the D36 anchor. That switch exists because the
return is foreseeable: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back
in force.
Everything an automated «Мой налог» submission needs is already recorded — the order id, the
provider payment id, the amount and currency, the credit time, the sold-pack snapshot, and refunds
as their own ledger rows, all exportable as CSV. The one thing it will additionally need is a
per-operation "already reported" marker for its own idempotency; that belongs to the submission
work, not here.
- **VK** — VK processes Votes through the tax authority itself; nothing to do.
- **TG Stars** — no tax side (for a RU self-employed, Stars are not legally withdrawable = not
НПД income; accepted, no receipt issued).
@@ -366,7 +468,7 @@ confirms the exact НПД scheme with a tax advisor.)
## 13. Distribution (native Android)
- **RuStore** — Robokassa/external gate allowed (0%); native = clean `direct` context.
- **RuStore** — an external payment gate is allowed (0%); native = clean `direct` context.
- **Google Play** — direct purchases are **hidden**; the Wallet shows a stub ("install the
RuStore build to make purchases"). Rewarded ads and spending already-earned chips still
work. Confirm Google's current in-app-currency rules before the GP release.
@@ -407,4 +509,4 @@ prod (no purchase flow existed), so legacy values are zeroed.
- **value / benefit** — what chips buy (no-ads, hints, tournament fee).
- **gate** — the one-directional store-compliance rule (§4).
- **ledger** — the append-only record of all money/value operations.
- **rail** — a payment provider (Robokassa / VK Votes / TG Stars).
- **rail** — a payment provider (YooKassa / VK Votes / TG Stars).
+85 -1
View File
@@ -281,6 +281,73 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
(нет строки = default; снять = удалить строку). **`allow` обходит ТОЛЬКО ops-рубильник, НЕ
security-гейты** (D36 email-якорь, VK-iOS-фриз, trusted-платформа, min-client-version) — те
проверяются отдельно в CreateOrder.
- **D47. Direct-рельс переезжает с Robokassa на ЮKassa; Robokassa консервируется, а не удаляется.**
Меняется merchant-отношение, не модель кошельков: сегмент `direct`, стенка трат, origin бенефитов,
мультимагазинность по каналу (D42) и `shop` в заказе (D44) — без изменений. Код Robokassa, её тесты
и проводка остаются в дереве, а direct-рельс **откатывается на неё**, если ни один магазин ЮKassa не
настроен, — значит возврат к Robokassa это смена кредов, а не правка кода. Переменные Robokassa
убраны из CI/compose/деплоя и записаны в `backend/internal/robokassa/README.md` вместе с
настройками кабинета и порядком возврата. Строки журнала с `provider = 'robokassa'` не трогаем:
литерал несущий для индекса идемпотентности. Telegram **не трогаем** — там реальными деньгами за
цифровые товары платить нельзя, рельс остаётся на Stars; префиксы переменных заводим на магазин
сразу, чтобы будущий магазин (например, android/RuStore) добавлялся аддитивно.
- **D48. Уведомления ЮKassa не подписаны → подтверждающее чтение обязательно.** В отличие от
Robokassa (подпись Password2) и VK (подпись), ЮKassa **не подписывает** вебхуки. Поэтому тело
уведомления — только подсказка: оно называет платёж, который сервер перечитывает запросом
`GET /v3/payments/{id}`, и действует **исключительно** по ответу API. Дополнительно: метаданные
платежа должны называть тот самый заказ; флаг `test` платежа должен совпадать с флагом магазина
(платёж тестового магазина не начислит настоящие Фишки, и наоборот). Ответ провайдеру: 200 на всё
окончательно решённое (включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa
повторяет 24 часа).
**Ревизия: адрес отправителя не проверяем.** Изначально сверяли с опубликованными диапазонами
ЮKassa как эшелонированную защиту. Оказалось лишним и вредным: единственное, что она давала —
не дать подделывателю превратить фальшивое уведомление в наш исходящий запрос — уже обеспечено
раньше и жёстче, потому что заказ ищется по метаданным ДО обращения к провайдеру (несуществующий
order_id стоит одного чтения по индексу; угадать живой — значит угадать uuid). А на тестовом
контуре, который за туннелем видит только свой внутренний адрес, проверка отбивала настоящие
уведомления — ровно как IP-баны, сделанные в репозитории prod-only по той же причине.
- **D49. Сверка — без постоянного опроса, но с коротким шагом (ревизия).** «Или» в документации
ЮKassa адресовано тем, кто не хочет вебхуки; у нас вебхуки основные. Но безвозвратно потерянное
уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому существующий
жнец спрашивает провайдера о судьбе незакрытых заказов с идентификатором платежа и начисляет реально
оплаченные; отдельный воркер не заводим.
**Ревизия порога:** сперва проверка была привязана к возрасту истечения заказа (30 минут), и это
была ошибка — время жизни заказа отвечает на вопрос «сколько покупателю позволено думать», а не «как
быстро заметить потерянный колбэк». На практике это дало покупателю 30 минут ожидания Фишек при
первом же сбое доставки. Порог развязан: проверяем заказы старше **минуты**, на каждом тике жнеца.
Ограниченность сохраняется — запросов на заказ не больше, чем время его жизни, делённое на шаг
жнеца.
- **D50. Возвраты на direct-рельсе — через API ЮKassa, одним действием.** Кнопка возврата в `/_gm`
сперва двигает деньги (`POST /v3/refunds`, `Idempotence-Key` = идентификатор заказа, с чеком
возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет **ничего** — журнал не
может заявить о возврате, которого не было. Реверс пишется **только при статусе `succeeded`**:
возврат в `pending` ещё может отмениться, а журнал только на добавление, поэтому ранняя запись
отобрала бы у покупателя Фишки за деньги, оставшиеся у нас; выход — нажать ещё раз, ключ
идемпотентности вернёт тот же возврат. Записывается собственный refund-id провайдера (сверка с
данными ЮKassa). VK и TG Stars — как раньше: деньги руками, запись фактом.
- **D52. Обрабатываем `refund.succeeded`: кабинет — вторая точка входа для возврата.** В отличие от
прежних рельсов, у ЮKassa возврат можно оформить прямо в кабинете магазина, минуя наш API, — тогда
деньги ушли бы назад, а Фишки остались бы начисленными, и молча. Поэтому подписываемся на событие и
проводим возврат тем же движком: возврат перечитывается из API (тело уведомления — не доказательство,
как и у платежа), привязывается к заказу через записанный идентификатор платежа, идемпотентность по
`(провайдер, refund-id)` делает уведомление о возврате, уже записанном консолью, пустой операцией.
**Частичный возврат не записываем вовсе** — движок по устройству работает только с полной суммой
заказа, а непроизвольного способа решить, скольких Фишек стоит часть возврата, нет; вместо записи —
громкий лог для оператора.
- **D51 (ревизия). Чеки не передаём: владелец на НПД, это вне 54-ФЗ.** Уточнено у поддержки ЮKassa:
при НПД провайдер с чеками не работает. Онлайн-кассы нет, `receipt` в запросах не отправляется, о
каждой операции владелец сообщает в **«Мой налог»**, он и формирует чек. Побочный выигрыш: исчезает
целый класс отказов — некорректный `receipt` был ошибкой API прямо при создании платежа, то есть
ломал покупку.
**Код чеков при этом консервируем, а не удаляем** (решение владельца): вся сборка `receipt` живёт за
одним переключателем `BACKEND_YOOKASSA_VAT_CODE` — пусто (дефолт) значит «не отправлять», код ставки
по 54-ФЗ (тег 1199) возвращает прежнее поведение: одна позиция, признак предмета расчёта `service`
(тег 1212), способ расчёта `full_payment` (тег 1214), доставка на email-якорь D36,
`tax_system_code` не шлём. Возврат к чекам предсказуем: у НПД годовой потолок дохода 2,4 млн ₽, и
его превышение возвращает 54-ФЗ — тогда это правка переменной, а не кода.
Связка с «Мой налог» — **отдельная задача**. Всё нужное для неё уже хранится (идентификатор заказа
и платежа провайдера, сумма, валюта, время зачисления, снимок пакета, возвраты); не хватает только
отметки «операция уже отправлена» для идемпотентности выгрузки — её заводит та задача.
## Заметки к оформлению документов
@@ -296,7 +363,7 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
feedback-память `prefer-interview-mode` после plan mode.
## Все развилки закрыты (D1-D46)
## Все развилки закрыты (D1-D52)
Интервью завершено. Дальше — оформление документов и реализация по релизам.
@@ -307,6 +374,23 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация
(B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца).
**Дополнение 2026-07-28 (интервью с владельцем).** Direct-рельс переезжает на **ЮKassa**; добавлены
D47-D51 — консервация Robokassa с откатом по кредам, подтверждающее чтение вместо подписи уведомления,
сверка на истечении заказа, возвраты через API и фискализация через «Чеки от ЮKassa» с отправкой
`receipt` из кода (ревизия D41, этап E12 в `PLAN.md`). Telegram из объёма исключён: реальными
деньгами за цифровые товары в Mini App платить нельзя, рельс остаётся на Stars. Отдельно уточнено:
у ЮKassa **есть** тестовый режим (отдельный тестовый магазин со своими креды и тестовыми картами),
поэтому весь путь проверяется на тестовом контуре без реальных денег.
**Уточнение по возвратам (владелец, 2026-07-28).** D50 дополнена требованием статуса `succeeded`;
добавлена D52 — обработка `refund.succeeded`, потому что кабинет ЮKassa позволяет вернуть деньги
мимо нашего API. Обе правки вошли в тот же PR, что и переход на рельс.
**Уточнение по фискализации (владелец, 2026-07-28).** D51 ревизована: владелец принимает платежи как
ИП на **НПД**, при котором ЮKassa с чеками не работает (подтверждено поддержкой), поэтому `receipt`
не передаётся вовсе, а отчётность идёт через «Мой налог». Код чеков законсервирован за переменной
`BACKEND_YOOKASSA_VAT_CODE` на случай потери режима. Правка вошла в тот же PR.
## План внедрения (черновик PLAN.md — «слоями»)
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
+126 -26
View File
@@ -23,7 +23,7 @@
Валюта **двухуровневая**:
```
деньги (Голоса VK / Stars TG / рубли через Robokassa) ─┐
деньги (Голоса VK / Stars TG / рубли через ЮKassa) ─┐
├─► Фишки ──► ценности
просмотр ролика за награду ─┘ (без рекламы,
подсказки,
@@ -46,7 +46,7 @@
|------------|------------------------------------|
| `vk` | Покупка за Голоса VK, ролик за награду в VK |
| `telegram` | Покупка за Stars TG |
| `direct` | Покупка через Robokassa (web / native) |
| `direct` | Покупка через ЮKassa (web / native) |
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, до трёх
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
@@ -54,17 +54,27 @@
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
в контексте VK; Stars — только внутри Telegram; Фишки от ЮKassa (`direct`) — только вне
сторов. См. §4.
**Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин
Robokassa **на канал**`web` и `android` (RuStore), позже `ios` — по трастовому подтипу
ЮKassa **на канал**`web` и `android` (RuStore), позже `ios` — по трастовому подтипу
`X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет).
Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в
админ-отчёте), а Result-колбэк каждого магазина проверяется своим Password2 по
`/pay/robokassa/result/<channel>`. В standalone-приложениях (Android/iOS) вход только по email, поэтому
у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под
одним ИП) — один источник независимо от числа магазинов (D41).
админ-отчёте). Точка приёма уведомлений у всех магазинов одна — `/pay/yookassa/notify`; входящее
уведомление привязывается к магазину по идентификатору магазина, который сообщает сам платёж
(`recipient.account_id`), с откатом на канал, записанный в заказе, и подтверждающее чтение (§9)
выполняется кредами именно этого магазина. В standalone-приложениях (Android/iOS) вход только по
email, поэтому у direct-покупки всегда есть email-якорь D36 (D43) — он же адрес доставки чека.
Фискализация (54-ФЗ под одним ИП) — один источник независимо от числа магазинов (D41).
**Robokassa законсервирована, но не удалена (D47).** До ЮKassa она обслуживала рельс `direct`. Код,
тесты и проводка остаются в дереве, и рельс откатывается на неё, если ни один магазин ЮKassa не
настроен, — поэтому возврат к ней это смена кредов, а не правка кода; сегодня эти креды не задаёт ни
один контур. `backend/internal/robokassa/README.md` хранит список выведенных переменных, настройки
кабинета и порядок возврата рельса. Строки журнала, записанные при её жизни, сохраняют
`provider = 'robokassa'` — этот литерал несущий для индекса идемпотентности, его не переименовывают и
не переиспользуют.
**Рубильник платежей (D45/D46).** Оператор выключает покупки на рельсе/канале (`direct:web` /
`direct:android` / `vk` / `telegram`) или для одного аккаунта, живьём из `/_gm`, и юзер на следующей
@@ -217,21 +227,56 @@ durable).
## 9. Приём платежей
**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному**
(подпись/HMAC) серверному колбэку — Robokassa webhook / TG `successful_payment` / VK
callback. Клиентское «я оплатил» игнорируется.
**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному** серверному
колбэку — уведомление ЮKassa / TG `successful_payment` / VK callback. Клиентское «я оплатил»
игнорируется. Что значит «проверенный», зависит от рельса: VK и Telegram подписывают свои колбэки,
а **ЮKassa — нет** (D48), см. подтверждающее чтение ниже.
**Единственный писатель.** Один платёжный домен — единственный, кто пишет в журнал операций.
Публичные вебхуки (Robokassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
Публичные вебхуки (ЮKassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
платёжный домен; TG `successful_payment` приходит боту, тот форвардит в платёжный домен.
Одно место начисляет и защищает от повторов.
**Флоу заказа.** Сервер заранее создаёт `order(pending)` с account / платформой / пакетом /
ожидаемой суммой / origin. `order_id` прокидывается провайдеру (Robokassa `InvId` / TG
`invoice_payload` / VK `item` — точную форму VK уточнить при интеграции). Колбэк матчится по
`order_id` (никогда по сумме, поэтому коллизии одинаковых сумм невозможны), сверяет сумму,
начисляет, помечает `paid`. **Защита от повторов:** дедуп по `(провайдер,
provider_payment_id)`.
ожидаемой суммой / origin. `order_id` прокидывается провайдеру (ЮKassa `metadata.order_id` / TG
`invoice_payload` / VK `item`). Колбэк матчится по `order_id` (никогда по сумме, поэтому коллизии
одинаковых сумм невозможны), сверяет сумму, начисляет, помечает `paid`. **Защита от повторов:**
дедуп по `(провайдер, provider_payment_id)`.
**Direct-рельс (ЮKassa).** Открытие покупки — исходящий вызов API: сервер создаёт платёж
(`POST /v3/payments`, `capture: true`, подтверждение через redirect, идентификатор заказа и как
`Idempotence-Key`, и как `metadata.order_id`, плюс фискальный чек из §12) и отправляет покупателя на
полученный `confirmation_url`. Идентификатор платежа сразу записывается в заказ — именно он позволяет
потом перепроверить заказ и оформить возврат. Страница возврата браузера (`/pay/yookassa/return`)
косметическая: начисление никогда не едет на редиректе.
**Уведомление — подсказка, а не доказательство (D48).** ЮKassa не подписывает уведомления, поэтому
действовать по содержимому тела нельзя. Оно лишь называет платёж, который сервер затем перечитывает
запросом `GET /v3/payments/{id}`; доверяем только этому ответу. На нём же держатся две проверки:
метаданные платежа должны называть тот самый заказ, а его флаг `test` — совпадать с флагом магазина,
поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). Ответ сообщает
провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и неустранимый
отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа).
Адрес отправителя **не проверяем** (ревизия D48). Безопасности это не добавляет — вся граница доверия
в подтверждающем чтении, — а единственное, что такая проверка давала (чтобы подделыватель не
превращал каждое фальшивое уведомление в наш исходящий запрос), уже обеспечено раньше и жёстче: заказ
находится по метаданным **до** любого обращения к провайдеру, поэтому идентификатор, которому не
соответствует ни один заказ, стоит одного чтения по индексу и на этом всё, а угадать живой order_id —
значит угадать uuid. Вдобавок проверка адреса вредна везде, где развёртывание не видит настоящих
адресов клиентов (контур за туннелем видит только свой), — там она отбивает настоящие уведомления.
**Сверка с коротким шагом (ревизия D49).** Бесконечного опроса нет, но проверка **не привязана** к
времени жизни заказа: оно отвечает на вопрос «сколько покупателю позволено думать», а не «как быстро
мы должны заметить потерянный колбэк». Связав их, мы заставили бы покупателя ждать Фишки всё время
жизни заказа всякий раз, когда ломается доставка уведомлений. Вместо этого жнец спрашивает провайдера
о каждом незакрытом заказе старше минуты, у которого есть идентификатор платежа, и начисляет реально
оплаченные. Число запросов на один заказ при этом ограничено его временем жизни, делённым на шаг
жнеца, — единицы, — а сбой основного пути стоит минут, а не получаса.
**Об отклонённой оплате сообщаем.** `payment.canceled` от ЮKassa пишет событие `failed`, поэтому
покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто
брошенный заказ не пишет ничего — он молча истекает.
**Pending невидим** пользователю; авто-истекает по таймауту (~30 мин, гигиена базы).
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
@@ -261,9 +306,33 @@ at-least-once + идемпотентный приём (дедуп по `telegram
бота).
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует
админ** (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК
(авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через
поддержку, TG Stars — вызовом `refundStarPayment`. Все сходятся на одном движке — метод `Refund`
админ** (консоль E7). На direct-рельсе консоль делает всю работу одним нажатием (D50): сперва
вызывает refund-API ЮKassa (`POST /v3/refunds`, `Idempotence-Key` — идентификатор заказа, с чеком
возврата из §12) и записывает реверс только после того, как деньги действительно ушли, — неудачный
вызов не пишет **ничего**, поэтому журнал не может заявить о возврате, которого не было. Возврат
записывается **только в статусе `succeeded`**: ещё не завершённый (`pending`) может отмениться, а
журнал только на добавление, поэтому ранняя запись отобрала бы у покупателя Фишки за деньги, оставшиеся у
нас. Выход — нажать ещё раз: ключ идемпотентности вернёт тот же возврат, а не заплатит дважды.
Записывается собственный refund-id провайдера: по нему журнал сверяется с данными ЮKassa.
**Кабинет — вторая точка входа (D52).** В отличие от прежних рельсов, ЮKassa позволяет оформить
возврат прямо в кабинете магазина, и такой возврат не проходит через наш API — деньги ушли бы назад,
а Фишки остались бы начисленными. Это закрывает уведомление `refund.succeeded`: возврат
перечитывается из API (тело уведомления — такое же не-доказательство, как и у платежа), привязывается
к заказу через идентификатор платежа, который мы записали, и проводится тем же движком —
идемпотентно по `(провайдер, refund-id)`, поэтому уведомление о возврате, уже записанном консолью,
ничего не отзывает повторно. Движок по устройству работает **только с полным возвратом** (отзывает
ровно то, что профондировал пакет, и отвергает любую другую сумму), поэтому **частичный** возврат не
записывается вовсе и громко логируется для оператора: непроизвольного способа решить, скольких Фишек
стоит часть возврата, нет.
Два пути записи при этом безобидно гоняются: ЮKassa шлёт `refund.succeeded` сразу после создания
возврата, поэтому уведомление часто записывает реверс раньше, чем это успевает сделать сама консоль.
Оба называют один и тот же refund-id, поэтому вторая запись отсекается индексом идемпотентности и
ничего не отзывается дважды; консоль при этом сообщает об успешном возврате, а не о повторном нажатии.
Возвраты VK по-прежнему через поддержку, TG Stars — вызовом `refundStarPayment`, оба фиксируются
руками после факта. Все сходятся на одном движке — метод `Refund`
(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по
`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки
сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с
@@ -341,15 +410,46 @@ in-process кэш сегментов и бенефитов по ключу-ак
типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) —
полный аудит наград.
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
`handlers_admin_console.go`). Плюс экспорт журнала.
**Раздел журнала** `/_gm/ledger` — общий вид на деньги по всем аккаунтам: все операции, новые
сверху, с фильтрами по диапазону дат (по умолчанию последние 30 дней), по **кошельку**
(`vk`/`telegram`/`direct` — совпадение по пополненному сегменту или по origin бенефита), по
**рельсу** (провайдер, который провёл платёж, поэтому траты Фишек в такой фильтр не попадают вовсе),
по виду операции и по аккаунту. Над таблицей — итоги по **всему, что попало под фильтр**, а не по
странице: пришло и возвращено денег по каждой валюте отдельно (рельсы считают в рублях, Голосах и
Stars, общая сумма по ним не значила бы ничего) и начислено/списано Фишек. Суммы в строках берутся из
снимка операции, потому что собственные колонки журнала считают Фишки, а не деньги. Пагинация,
выгрузка в CSV и возврат оператора после refund несут одну и ту же строку фильтров, поэтому ни один
из них не может незаметно показать срез, отличный от экрана.
**Кнопка возврата** живёт на строках пополнения здесь же — оператор может найти платёж фильтрами, не
зная заранее, чей он, — и после возврата возвращает на тот же отфильтрованный вид.
**Карточка пользователя** показывает положение дел, а не историю: балансы сегментов, бенефиты, флаг
риска и краткую сводку за всё время (сколько заплачено и возвращено по валютам, сколько Фишек
начислено и потрачено), плюс ссылку в раздел журнала, уже отфильтрованный по этому аккаунту.
Операции рисуются в одном месте, с фильтрами и страницами, а не в двух.
## 12. Налоги и комплаенс
Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу:
- **Robokassa** (direct) — чек НПД самозанятого при оплате.
- **ЮKassa** (direct) — **чек не передаём, так задумано (ревизия D51)**. Владелец работает на
**НПД**, а это вне 54-ФЗ: онлайн-кассы нет, провайдер ничего не регистрирует. О каждой операции
владелец сообщает в **«Мой налог»**, он и формирует чек. ЮKassa при таком режиме налогообложения с
чеками не работает вовсе.
Фискальный код **законсервирован, а не удалён**: «Чеки от ЮKassa» (касса, фискальный накопитель и
договор с ОФД на стороне ЮKassa) регистрируют чек **только если запрос его несёт**, и вся сборка
такого запроса спрятана за одним переключателем — `BACKEND_YOOKASSA_VAT_CODE`. Пусто — `receipt` не
собирается и не отправляется; задан код ставки по 54-ФЗ (тег 1199) — снова уходит одна позиция
(название пакета, количество 1, сумма) с признаком предмета расчёта `service` (тег 1212) и способа
расчёта `full_payment` (тег 1214), доставка на email-якорь D36. Переключатель нужен потому, что
возврат к чекам предсказуем: у НПД есть годовой потолок дохода, и потеря режима возвращает 54-ФЗ.
Всё, что потребуется автоматической отправке в «Мой налог», уже записано — идентификатор заказа,
идентификатор платежа провайдера, сумма и валюта, время зачисления, снимок проданного пакета и
возвраты отдельными строками журнала, всё выгружается в CSV. Не хватать будет только отметки «эта
операция уже отправлена» для идемпотентности самой выгрузки; ей место в той задаче, а не здесь.
- **VK** — VK сам процессит Голоса через налоговую; делать нечего.
- **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не
доход НПД; принимаем, чек не формируем).
@@ -359,7 +459,7 @@ in-process кэш сегментов и бенефитов по ключу-ак
## 13. Дистрибуция (native Android)
- **RuStore** — Robokassa/внешний гейт разрешён (0%); native = чистый контекст `direct`.
- **RuStore** — внешний платёжный гейт разрешён (0%); native = чистый контекст `direct`.
- **Google Play** — direct-покупки **скрыты**; «Кошелёк» показывает заглушку («установите
версию из RuStore для покупок»). Ролик за награду и трата уже накопленных Фишек работают.
Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте.
@@ -403,4 +503,4 @@ legacy-значения обнуляются.
взнос).
- **гейт** — одностороннее правило комплаенса сторов (§4).
- **журнал операций (ledger)** — неизменяемая запись всех операций с деньгами/ценностями.
- **платёжный канал / рельса** — платёжный провайдер (Robokassa / Голоса VK / Stars TG).
- **платёжный канал / рельса** — платёжный провайдер (ЮKassa / Голоса VK / Stars TG).
+10
View File
@@ -514,6 +514,16 @@ func (c *Client) RobokassaResult(ctx context.Context, channel string, params map
return out.Response, err
}
// YooKassaNotify forwards a YooKassa webhook body verbatim to the backend intake (the single writer,
// which re-reads the payment from the provider before acting on it — YooKassa does not sign
// notifications). The backend answers 200 for anything durably decided and 5xx only for a transient
// failure, so a nil error means the provider may stop redelivering.
// clientIP is the true sender address, which only the gateway sees; the backend checks it against
// YooKassa's published sender ranges.
func (c *Client) YooKassaNotify(ctx context.Context, clientIP string, body json.RawMessage) error {
return c.do(ctx, http.MethodPost, "/api/v1/internal/payments/yookassa/notify", "", clientIP, body, nil)
}
// VKCallback forwards a gateway-verified VK payment callback's parameters to the backend intake and
// returns the backend's raw VK response envelope (`{"response":…}` or `{"error":…}`) for the gateway
// to relay to VK verbatim. The backend answers 200 in every case (VK's protocol), so a transport
+54 -10
View File
@@ -273,15 +273,20 @@ func (s *Server) HTTPHandler() http.Handler {
// through this session-gated route (not public); see dictBytesHandler.
mux.Handle("/dict/", s.dictBytesHandler())
mux.Handle("/dl/", s.exportDownloadHandler())
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. The
// per-shop callback rides a channel suffix (/pay/robokassa/result/<channel>); the bare path is
// the legacy web shop. Caddy's /pay/* matcher already forwards both, so no Caddyfile change.
// Direct-rail return + callback routes: the server notification (the single crediting signal,
// proxied to the backend intake) and the browser return page. Caddy's /pay/* matcher already
// forwards every path here, so a new route needs no Caddyfile change.
mux.Handle("/pay/yookassa/notify", s.yookassaNotifyHandler())
mux.Handle("/pay/yookassa/return", s.paymentReturnHandler("Оплата принята."))
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
// The retired Robokassa rail. Its routes stay registered — the backend simply has no shop to
// verify against — so reviving the rail is a credentials change, not a code change, and the
// edge probe keeps proving /pay/* reaches the gateway. The per-shop callback rides a channel
// suffix (/pay/robokassa/result/<channel>); the bare path is the legacy web shop.
mux.Handle("/pay/robokassa/result", s.robokassaResultHandler())
mux.Handle("/pay/robokassa/result/", s.robokassaResultHandler())
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена."))
mux.Handle("/pay/robokassa/success", s.paymentReturnHandler("Оплата принята."))
mux.Handle("/pay/robokassa/fail", s.paymentReturnHandler("Оплата не завершена."))
// The client posts its local-move-preview adoption telemetry here (session-gated).
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
@@ -691,6 +696,45 @@ func (s *Server) robokassaResultHandler() http.Handler {
})
}
// maxNotifyBytes caps the provider notification body the gateway will read and forward.
const maxNotifyBytes = 1 << 20
// yookassaNotifyHandler proxies a YooKassa webhook to the backend intake (the single writer). It
// rate-limits per IP and forwards the raw JSON body together with the true sender address, which
// only this hop sees; the backend checks that address against YooKassa's published sender ranges and
// then re-reads the payment from the provider, because YooKassa does not sign notifications.
//
// The backend answers 200 for anything durably decided and 5xx for a transient failure, and that is
// relayed as-is: YooKassa retries a notification for 24 hours until it gets a 200.
func (s *Server) yookassaNotifyHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "yookassa-notify")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxNotifyBytes))
if err != nil || !json.Valid(body) {
// Rejected here rather than proxied: a body that is not JSON cannot become valid on a
// redelivery, and forwarding it would only make the backend answer 5xx forever.
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if err := s.backend.YooKassaNotify(r.Context(), ip, body); err != nil {
s.log.Warn("yookassa notify proxy failed", zap.Error(err))
http.Error(w, "not accepted", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
})
}
// vkCallbackHandler proxies a VK Mini Apps payment callback to the backend intake. It rate-limits
// per IP, verifies the VK signature with the app's protected key (the backend holds no VK secret),
// forwards the provider's parameters, and relays the backend's VK response envelope. A missing or
@@ -731,12 +775,12 @@ func (s *Server) vkCallbackHandler() http.Handler {
})
}
// robokassaReturnHandler serves a minimal self-closing page for Robokassa's Success/Fail browser
// paymentReturnHandler serves a minimal self-closing page for a direct-rail provider's browser
// return. The payment opens in a separate window, so on return this closes that window and drops the
// customer back into the live app (never a cold app start); a link is the fallback if the window
// cannot self-close. The credit rides the server Result callback, never this redirect — the wallet
// cannot self-close. The credit rides the server notification, never this redirect — the wallet
// updates in place from the payment push, with a return-focus refetch as the fallback.
func (s *Server) robokassaReturnHandler(message string) http.Handler {
func (s *Server) paymentReturnHandler(message string) http.Handler {
page := []byte(`<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Оплата</title></head>
<body style="font-family: system-ui, sans-serif; text-align: center; padding: 48px 20px; color: #1a1c20">
+1 -1
View File
@@ -67,7 +67,7 @@ test('wallet: buying a chip pack opens the provider payment page', async ({ page
await page.locator('[data-kind="pack"]').getByTestId('buy-pack').click();
await expect
.poll(() => page.evaluate(() => (window as { __opened?: string }).__opened))
.toContain('robokassa');
.toContain('yookassa');
});
test('wallet: the tab sits between Friends and About', async ({ page }) => {
+8 -1
View File
@@ -46,7 +46,14 @@ const DIST = 'dist';
// rejected, since a repeated word is a real word and the board alone cannot say why the play is
// refused. The rule's own logic — the played-word set and the rescoring — sits with the evaluator in
// the lazy dict chunk. Scoped CSS lands in the CSS chunk, not this JS budget.
const BUDGET = { app: 131, shared: 31, landing: 5 };
//
// The shared chunk (svelte runtime + i18n) was raised from 31 to 32 for the direct rail's email
// notice: the wallet has to explain, before the buy tap, that a money purchase needs a confirmed
// email — the server refuses the order otherwise, and a player signed in through VK or Telegram in a
// browser would otherwise meet a bare error. Every user-visible string lands here, so the chunk had
// been sitting 40 bytes under its cap; the raise restores a working margin rather than paying for
// this one string.
const BUDGET = { app: 131, shared: 32, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+1
View File
@@ -265,6 +265,7 @@ export const en = {
'wallet.iosBlockedPost': ' of the game.',
'wallet.gpStub': 'To buy chips, install the RuStore build.',
'wallet.purchasesSoon': 'Purchases will be available in a future update.',
'wallet.emailToBuy': 'To buy chips, add an email in your profile.',
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'votes',
'wallet.cur.XTR': '⭐',
+1
View File
@@ -260,6 +260,7 @@ export const ru: Record<MessageKey, string> = {
'wallet.iosBlockedPost': ' игры.',
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
'wallet.purchasesSoon': 'Покупки появятся в одном из следующих обновлений.',
'wallet.emailToBuy': 'Чтобы покупать фишки, привяжите почту в профиле.',
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'голосов',
'wallet.cur.XTR': '⭐',
+1 -1
View File
@@ -239,7 +239,7 @@ export class MockGateway implements GatewayClient {
if (!p || p.kind !== 'pack') throw new GatewayError('not_a_pack');
// No real provider in mock: return a stub launch URL so the e2e can assert the purchase reaches
// the provider hand-off without leaving the app.
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId };
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/yookassa?order=' + productId };
}
async walletReward(_nonce: string): Promise<Wallet> {
+16 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import type { Wallet, WalletSegment } from './model';
import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning } from './wallet';
import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning, directPurchaseNeedsEmail } from './wallet';
function seg(source: string, chips: number, spendable = true): WalletSegment {
return { source, chips, spendable };
@@ -61,3 +61,18 @@ describe('needsWebSpendWarning', () => {
expect(needsWebSpendWarning('direct', [seg('vk', 400, false)], 100)).toBe(false);
});
});
describe('directPurchaseNeedsEmail', () => {
it('asks for an email on the direct rail when the account has none', () => {
expect(directPurchaseNeedsEmail('direct', '')).toBe(true);
});
it('stays quiet once the account has a confirmed email', () => {
expect(directPurchaseNeedsEmail('direct', 'player@example.com')).toBe(false);
});
it('never asks inside VK or Telegram, where the store rail carries its own identity', () => {
expect(directPurchaseNeedsEmail('vk', '')).toBe(false);
expect(directPurchaseNeedsEmail('telegram', '')).toBe(false);
});
});
+12
View File
@@ -50,6 +50,18 @@ export function spendableChips(w: Wallet): number {
// segment first, then the store-funded segments.
const drawPriority: readonly string[] = ['direct', 'vk', 'telegram'];
/**
* directPurchaseNeedsEmail reports whether the money storefront must ask for an email before it can
* sell anything. The direct rail funds only an account with a confirmed email — the recovery anchor,
* without which a paying customer who loses the account loses the chips with it — and the server
* refuses the order without one. Inside VK or Telegram the purchase settles on that store's own rail,
* which carries its own identity, so nothing is asked for there and spending already-earned chips is
* never affected.
*/
export function directPurchaseNeedsEmail(context: SpendContext, email: string): boolean {
return context === 'direct' && email === '';
}
/**
* needsWebSpendWarning reports whether buying a chip-priced value in the current context would draw
* store-funded (vk / telegram) chips — the case the UI must warn about, because the benefit it buys
+20 -1
View File
@@ -2,10 +2,11 @@
import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
import { gateway } from '../lib/gateway';
import { normalizeSubtype } from '../lib/platform';
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet';
import { directPurchaseNeedsEmail, executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet';
import { isGooglePlayBuild, purchasesHidden } from '../lib/distribution';
import { onExternalLinkClick, onInAppPageLinkClick, openExternalUrl } from '../lib/links';
import { gatewayOrigin } from '../lib/origin';
@@ -47,6 +48,9 @@
// muted and taps explain why, rather than hiding the storefront. VK's device family is not on the
// client platformSubtype (server-derived) — read it from the signed vk_platform launch param.
const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios';
// The server enforces the direct rail's email anchor; showing the rule here means a player signed
// in through VK or Telegram in a browser reads it instead of tapping Buy and getting a bare error.
const needsEmail = $derived(directPurchaseNeedsEmail(context, app.profile?.email ?? ''));
// The "other version" link points at this deployment's own site root (the landing lists every
// platform), so it follows prod vs the contour without a hardcoded host.
const siteRoot = `${gatewayOrigin()}/`;
@@ -254,6 +258,10 @@
{:else}
<p class="stub" data-testid="purchases-hidden">{t('wallet.purchasesSoon')}</p>
{/if}
{:else if needsEmail}
<p class="stub" data-testid="email-required">
<button class="hintlink" onclick={() => navigate('/profile')}>{t('wallet.emailToBuy')}</button>
</p>
{:else}
{#each packs as p (p.productId)}
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
@@ -429,6 +437,17 @@
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
}
/* The whole explanation is the call to action, so the line itself routes to the profile. */
.hintlink {
background: none;
border: none;
padding: 0;
color: var(--accent);
font: inherit;
text-align: left;
text-decoration: underline;
cursor: pointer;
}
.offer {
margin: 2px 0 0;
text-align: center;