diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 351436a..7de110e 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -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 (E0–E9). go-jet money rule above applies.
+ - **YooKassa (the direct rail since v1.x) does NOT sign its webhooks.** Authenticity is the
+ confirming `GET /v3/payments/{id}` — never the notification body. Two extra guards ride on the
+ confirmed object: its `metadata.order_id` must name the order, and its `test` flag must match the
+ shop's `IsTest` (a test-shop payment must never credit real chips). Answer 200 for anything
+ durably decided, 5xx only to be redelivered (YooKassa retries 24h).
+ - **Do NOT gate the notification on the sender IP** — tried, reverted. The contour sits behind a
+ tunnel and sees every caller as an internal address (`10.77.0.1`), so the allowlist rejected every
+ genuine notification and the credit fell back to the reconcile sweep. Same root cause as the
+ IP bans being prod-only. It bought nothing anyway: the order is resolved from the notification
+ metadata **before** any provider call, so a forged id costs one indexed read, and guessing a live
+ order id means guessing a uuid.
+ - **The console refund and the `refund.succeeded` notification race, benignly.** YooKassa fires the
+ event the moment `POST /v3/refunds` returns, so the notification often records the reversal before
+ the console's own write lands. Both name the same refund id, so the ledger's idempotency index
+ catches the second and nothing is revoked twice — but the console must not then report "already
+ refunded", which reads as "you clicked twice" for a refund that just succeeded.
+ - **Never key a re-check interval off the order TTL.** The order lifetime answers "how long may a
+ customer take to pay"; the re-check answers "how soon do we notice a lost callback". Tying them
+ together made a failed notification cost the customer the full 30-minute TTL before the chips
+ landed. `payments.ReconcileAfter` is its own constant for that reason.
+ - **Subscribe to `refund.succeeded`, not just the payment events.** The YooKassa cabinet lets an
+ operator refund without touching our API, so without that event the money goes back while the
+ chips stay credited. The reversal engine is **full-refund-only** (it rejects any amount other than
+ the order's), so a partial refund must record nothing and be logged for a human — there is no
+ non-arbitrary chip cost for a part-refund. Also: a refund is only recorded in status `succeeded`;
+ a `pending` one can still be canceled and the ledger is append-only.
+ - **YooKassa DOES have a test mode** — a separate *test shop* (own shopId/secret, test cards, up to
+ 20 of them), and webhooks/receipts/refunds all work there. The whole rail is verifiable on the
+ contour without real money; don't plan around "no test payments".
+ - **We send NO receipt: the merchant is on НПД, which is outside 54-ФЗ**, and YooKassa does not
+ serve receipts for that regime (checked with their support). Reporting goes to «Мой налог».
+ The fiscal code is kept dormant behind `BACKEND_YOOKASSA_VAT_CODE` — unset = send nothing, a
+ 54-ФЗ rate code turns «Чеки от ЮKassa» back on (the НПД income ceiling makes that foreseeable).
+ Gotcha if you ever switch it on: `payment_subject`/`payment_mode`/`vat_code` are fields *inside*
+ `receipt.items[]` (54-ФЗ tags 1212/1214/1199), documented under the receipts section, not the
+ payment API — easy to miss; and a malformed receipt is an API error at payment creation, so it
+ breaks purchases rather than silently skipping the fiscal document.
+ - **The gateway cannot import `backend/internal/*`** (separate module + Go's internal rule), so a
+ security list shared by both hops either lives in `pkg/` or is enforced backend-side. The YooKassa
+ sender allowlist is enforced in the backend, with the gateway forwarding the real peer IP as
+ `X-Forwarded-For` — do not duplicate the CIDR list into the gateway.
+ - **Robokassa is retired but wired.** The direct rail resolves YooKassa → else Robokassa, and no
+ deployment sets the Robokassa credentials. Reviving it is a credentials change, not a code change;
+ `backend/internal/robokassa/README.md` holds the retired vars, cabinet URLs and the known gap
+ (the per-channel `…_WEB_*`/`…_ANDROID_*` vars never reached any workflow).
- **VK payment title trap:** a product title with an HTML-special char (`& < > " '`) silently kills
VK purchases. VK HTML-escapes it in the `order_status_change` callback echo (`"`→`"`) but
signs a different form → the gateway logs `vk callback: bad signature` and rejects it (get_item
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index a045d17..4da0e68 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -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
diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml
index b45b4bb..2dc88b0 100644
--- a/.gitea/workflows/prod-deploy.yaml
+++ b/.gitea/workflows/prod-deploy.yaml
@@ -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.
diff --git a/.gitea/workflows/prod-rollback.yaml b/.gitea/workflows/prod-rollback.yaml
index c4f4134..c122cde 100644
--- a/.gitea/workflows/prod-rollback.yaml
+++ b/.gitea/workflows/prod-rollback.yaml
@@ -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 }}
diff --git a/PLAN.md b/PLAN.md
index 987302c..5c39576 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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 (E0–E9) is fixed.** Do not split or merge stages. Plan each stage
+- **Stage granularity (E0–E12) 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 D47–D51 (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
diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go
index c55fa6f..0382ee9 100644
--- a/backend/cmd/backend/main.go
+++ b/backend/cmd/backend/main.go
@@ -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 {
diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go
index 63f8cd7..9dd7aeb 100644
--- a/backend/internal/account/account.go
+++ b/backend/internal/account/account.go
@@ -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
diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml
index 6a21d6d..a6fb598 100644
--- a/backend/internal/adminconsole/templates/layout.gohtml
+++ b/backend/internal/adminconsole/templates/layout.gohtml
@@ -23,6 +23,7 @@
ReasonsBannersCatalog
+ LedgerDictionaryBroadcastGrafana ↗
diff --git a/backend/internal/adminconsole/templates/pages/ledger.gohtml b/backend/internal/adminconsole/templates/pages/ledger.gohtml
new file mode 100644
index 0000000..5dcb79b
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/ledger.gohtml
@@ -0,0 +1,64 @@
+{{define "content" -}}
+
Ledger
+{{with .Data}}
+
+
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. clear filters
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index 4099425..617a2dc 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -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
+}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index 665cb9f..dc37edb 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -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.
diff --git a/backend/internal/inttest/ledger_console_test.go b/backend/internal/inttest/ledger_console_test.go
new file mode 100644
index 0000000..4d4e758
--- /dev/null
+++ b/backend/internal/inttest/ledger_console_test.go
@@ -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()+"&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")
+ }
+}
diff --git a/backend/internal/inttest/payments_statement_test.go b/backend/internal/inttest/payments_statement_test.go
index 1cc8250..888cad7 100644
--- a/backend/internal/inttest/payments_statement_test.go
+++ b/backend/internal/inttest/payments_statement_test.go
@@ -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")
+ }
}
diff --git a/backend/internal/inttest/payments_yookassa_test.go b/backend/internal/inttest/payments_yookassa_test.go
new file mode 100644
index 0000000..0c098a0
--- /dev/null
+++ b/backend/internal/inttest/payments_yookassa_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/payments/refund_admin.go b/backend/internal/payments/refund_admin.go
index 56a0829..8e3068b 100644
--- a/backend/internal/payments/refund_admin.go
+++ b/backend/internal/payments/refund_admin.go
@@ -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
diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go
index 9605cf8..06d44c8 100644
--- a/backend/internal/payments/service_intake.go
+++ b/backend/internal/payments/service_intake.go
@@ -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).
diff --git a/backend/internal/payments/statement.go b/backend/internal/payments/statement.go
index 41045cf..a3a078f 100644
--- a/backend/internal/payments/statement.go
+++ b/backend/internal/payments/statement.go
@@ -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
+}
diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go
index c4c51ac..fe1f2c4 100644
--- a/backend/internal/payments/store_intake.go
+++ b/backend/internal/payments/store_intake.go
@@ -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
diff --git a/backend/internal/payments/store_ledger.go b/backend/internal/payments/store_ledger.go
new file mode 100644
index 0000000..3b5f85d
--- /dev/null
+++ b/backend/internal/payments/store_ledger.go
@@ -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, ¤cy, &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
+}
diff --git a/backend/internal/robokassa/README.md b/backend/internal/robokassa/README.md
new file mode 100644
index 0000000..36ac620
--- /dev/null
+++ b/backend/internal/robokassa/README.md
@@ -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:///pay/robokassa/result/` (`…/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:///pay/robokassa/success`.
+- **Fail URL** — `https:///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`, 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.
diff --git a/backend/internal/robokassa/robokassa.go b/backend/internal/robokassa/robokassa.go
index d680255..0e34641 100644
--- a/backend/internal/robokassa/robokassa.go
+++ b/backend/internal/robokassa/robokassa.go
@@ -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= (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.
diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go
index f8dece0..44e473b 100644
--- a/backend/internal/server/handlers.go
+++ b/backend/internal/server/handlers.go
@@ -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)
}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index abe2297..449baec 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -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 {
diff --git a/backend/internal/server/handlers_admin_ledger.go b/backend/internal/server/handlers_admin_ledger.go
new file mode 100644
index 0000000..de43173
--- /dev/null
+++ b/backend/internal/server/handlers_admin_ledger.go
@@ -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 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()
+}
diff --git a/backend/internal/server/handlers_admin_refund.go b/backend/internal/server/handlers_admin_refund.go
index b804f88..ac493d6 100644
--- a/backend/internal/server/handlers_admin_refund.go
+++ b/backend/internal/server/handlers_admin_refund.go
@@ -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)
}
diff --git a/backend/internal/server/handlers_intake.go b/backend/internal/server/handlers_intake.go
index 813b751..d486d75 100644
--- a/backend/internal/server/handlers_intake.go
+++ b/backend/internal/server/handlers_intake.go
@@ -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:
diff --git a/backend/internal/server/handlers_yookassa.go b/backend/internal/server/handlers_yookassa.go
new file mode 100644
index 0000000..969f5dd
--- /dev/null
+++ b/backend/internal/server/handlers_yookassa.go
@@ -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
+}
diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go
index 096255f..361a36d 100644
--- a/backend/internal/server/server.go
+++ b/backend/internal/server/server.go
@@ -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,
diff --git a/backend/internal/yookassa/notify.go b/backend/internal/yookassa/notify.go
new file mode 100644
index 0000000..aee343c
--- /dev/null
+++ b/backend/internal/yookassa/notify.go
@@ -0,0 +1,72 @@
+package yookassa
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+// Notification events this integration subscribes to. An event name is "