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 @@ Reasons Banners Catalog + Ledger Dictionary Broadcast Grafana ↗ 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}} +
+ + + + + + + +Export CSV ↓ +
+

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

+ +
+

Totals for the filtered range

+

+Money in: {{range .Totals.MoneyIn}}{{.}} {{else}}none{{end}} +· Refunded: {{range .Totals.MoneyRefunded}}{{.}} {{else}}none{{end}} +· Chips credited: {{.Totals.ChipsIn}} +· Chips spent: {{.Totals.ChipsOut}} +

+
+ + + + +{{range .Rows}} + + + + + + + + + + + + +{{else}} + +{{end}} + +
TimeAccountKindMoneyChipsWhatWalletRailShop
{{.At}}card{{.Kind}}{{.Money}}{{.ChipsDelta}}{{.Title}}{{if .Order}} {{.Order}}{{end}}{{.Source}}{{if and .Origin (ne .Origin .Source)}} → {{.Origin}}{{end}}{{.Provider}}{{.Shop}}{{if .Refundable}}
{{end}}
no operations in this range
+ +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 30bc5b3..531d913 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -82,20 +82,14 @@
-

Ledger

-{{$uid := .ID}} -{{if .Finance.Ledger}} - - - -{{range .Finance.Ledger}} - - -{{end}} - -
TimeKindSourceOriginChipsOrderProviderShopDetail
{{.At}}{{.Kind}}{{.Source}}{{.Origin}}{{.ChipsDelta}}{{if .Order}}{{.Order}}{{end}}{{.Provider}}{{.Shop}}{{if .Snapshot}}{{.Snapshot}}{{end}}{{if and (eq .Kind "fund") .Order}}
{{end}}
-

Export the full ledger (CSV) — all accounts, for tax + reconciliation.

-{{else}}

no ledger entries

{{end}} +

Totals

+

+Paid: {{range .Finance.Paid}}{{.}} {{else}}nothing{{end}} +· Refunded: {{range .Finance.Refunded}}{{.}} {{else}}nothing{{end}} +· Chips credited: {{.Finance.ChipsBought}} +· Chips spent: {{.Finance.ChipsSpent}} +

+

Open this account's operations in the ledger → — every entry, with filters, paging and the refund action.

{{else}}

payments not enabled

{{end}}

Grant benefits

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 ".": the +// object whose status changed and the status it entered. +const ( + // EventPaymentSucceeded means the money was taken and the order may be credited. + EventPaymentSucceeded = "payment.succeeded" + // EventPaymentCanceled means the payment was actively declined or abandoned; nothing is credited + // and the payer is told the attempt failed. + EventPaymentCanceled = "payment.canceled" + // EventRefundSucceeded reports a completed refund. It matters because the merchant cabinet is a + // second way to issue one: a refund made there never passes through our API, so without this + // event the money would go back while the chips stayed credited. + EventRefundSucceeded = "refund.succeeded" +) + +// notificationType is the fixed value of a notification envelope's type field. +const notificationType = "notification" + +// Notification is an incoming webhook envelope. Object is left raw because its shape depends on the +// event — a payment for payment.*, a refund for refund.* — and because nothing in it may be acted on +// before GetPayment confirms it: YooKassa does not sign notifications. +type Notification struct { + Type string `json:"type"` + Event string `json:"event"` + Object json.RawMessage `json:"object"` +} + +// ParseNotification decodes a webhook body and checks the envelope is a notification with an event. +// It deliberately validates nothing else: the body is untrusted input whose only job is to name an +// object to re-read from the API. +func ParseNotification(body []byte) (Notification, error) { + var n Notification + if err := json.Unmarshal(body, &n); err != nil { + return Notification{}, fmt.Errorf("yookassa: decode notification: %w", err) + } + if n.Type != notificationType || n.Event == "" { + return Notification{}, fmt.Errorf("yookassa: not a notification envelope (type %q, event %q)", n.Type, n.Event) + } + return n, nil +} + +// Payment decodes the notification's object as a payment. Use it only for payment.* events; it +// yields the payment id to re-read, never the payment state to act on. +func (n Notification) Payment() (Payment, error) { + var p Payment + if err := json.Unmarshal(n.Object, &p); err != nil { + return Payment{}, fmt.Errorf("yookassa: decode notification payment: %w", err) + } + if p.ID == "" { + return Payment{}, fmt.Errorf("yookassa: notification payment has no id") + } + return p, nil +} + +// Refund decodes the notification's object as a refund. Use it only for refund.* events; it yields +// the refund id to re-read, never the refund state to act on. +func (n Notification) Refund() (Refund, error) { + var r Refund + if err := json.Unmarshal(n.Object, &r); err != nil { + return Refund{}, fmt.Errorf("yookassa: decode notification refund: %w", err) + } + if r.ID == "" { + return Refund{}, fmt.Errorf("yookassa: notification refund has no id") + } + return r, nil +} diff --git a/backend/internal/yookassa/notify_test.go b/backend/internal/yookassa/notify_test.go new file mode 100644 index 0000000..bbf0c9b --- /dev/null +++ b/backend/internal/yookassa/notify_test.go @@ -0,0 +1,80 @@ +package yookassa + +import ( + "testing" +) + +func TestParseNotification(t *testing.T) { + n, err := ParseNotification([]byte(`{"type":"notification","event":"payment.succeeded", + "object":{"id":"pay-1","status":"succeeded","paid":true,"metadata":{"order_id":"0197-order"}}}`)) + if err != nil { + t.Fatalf("parse notification: %v", err) + } + if n.Event != EventPaymentSucceeded { + t.Errorf("event = %q, want %q", n.Event, EventPaymentSucceeded) + } + p, err := n.Payment() + if err != nil { + t.Fatalf("decode notification payment: %v", err) + } + if p.ID != "pay-1" { + t.Errorf("payment id = %q, want pay-1", p.ID) + } + if p.OrderID() != "0197-order" { + t.Errorf("order id = %q, want the order carried in metadata", p.OrderID()) + } +} + +func TestParseNotificationRejectsNonEnvelopes(t *testing.T) { + for name, body := range map[string]string{ + "not json": `nonsense`, + "wrong type": `{"type":"payment","event":"payment.succeeded","object":{"id":"pay-1"}}`, + "no event": `{"type":"notification","object":{"id":"pay-1"}}`, + "empty object": `{}`, + } { + if _, err := ParseNotification([]byte(body)); err == nil { + t.Errorf("%s: accepted as a notification envelope", name) + } + } +} + +func TestNotificationPaymentNeedsAnID(t *testing.T) { + // Without an id there is nothing to re-read from the API, and the body itself is never evidence. + n, err := ParseNotification([]byte(`{"type":"notification","event":"payment.succeeded","object":{"status":"succeeded"}}`)) + if err != nil { + t.Fatalf("parse notification: %v", err) + } + if _, err := n.Payment(); err == nil { + t.Error("a payment object with no id was accepted") + } +} + +func TestParseRefundNotification(t *testing.T) { + n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded", + "object":{"id":"refund-1","status":"succeeded","payment_id":"pay-1", + "amount":{"value":"149.00","currency":"RUB"}}}`)) + if err != nil { + t.Fatalf("parse notification: %v", err) + } + if n.Event != EventRefundSucceeded { + t.Errorf("event = %q, want %q", n.Event, EventRefundSucceeded) + } + r, err := n.Refund() + if err != nil { + t.Fatalf("decode notification refund: %v", err) + } + // The refund names the payment, not our order — that is how it is resolved back to one. + if r.ID != "refund-1" || r.PaymentID != "pay-1" { + t.Errorf("refund = %+v, want refund-1 for pay-1", r) + } +} + +func TestNotificationRefundNeedsAnID(t *testing.T) { + n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded","object":{"status":"succeeded"}}`)) + if err != nil { + t.Fatalf("parse notification: %v", err) + } + if _, err := n.Refund(); err == nil { + t.Error("a refund object with no id was accepted") + } +} diff --git a/backend/internal/yookassa/receipt.go b/backend/internal/yookassa/receipt.go new file mode 100644 index 0000000..b8f8994 --- /dev/null +++ b/backend/internal/yookassa/receipt.go @@ -0,0 +1,95 @@ +package yookassa + +// Fiscal receipt support for «Чеки от ЮKassa» (54-ФЗ). YooKassa registers a receipt itself — no cash +// register to rent, no OFD contract — but only if the payment and refund requests carry a receipt +// object. +// +// It is DORMANT: the merchant runs on НПД, which is outside 54-ФЗ, so no receipt is sent and the +// merchant reports each operation to «Мой налог» instead. The code stays because the switch back is +// foreseeable — НПД has an annual income ceiling, and losing the regime puts 54-ФЗ back in force — +// and turning it on is then setting one deploy variable rather than writing this again. Everything +// here is inert while BACKEND_YOOKASSA_VAT_CODE is unset (see ReceiptEnabled). Reference: +// https://yookassa.ru/developers/payment-acceptance/receipts/54fz/yoomoney/parameters-values +const ( + // PaymentSubjectService is the settlement subject (54-ФЗ tag 1212) — what the money is taken + // for. Chips are sold as a service ("Услуга"). + PaymentSubjectService = "service" + // PaymentModeFullPayment is the settlement method (54-ФЗ tag 1214) — the payer pays and receives + // the goods at once ("Полный расчет"). «Чеки от ЮKassa» accept only this and full_prepayment; + // partial prepayment, advance and credit are not supported. + PaymentModeFullPayment = "full_payment" + + // VatCodeNone is the VAT rate code (54-ФЗ tag 1199) for «Без НДС» — the rate a sole proprietor on + // УСН or ПСН charges. The deployed value is configurable because the rate is the one receipt + // attribute that genuinely changes (Russia raised the main rate on 1 Jan 2026, adding codes 11 + // and 12). + VatCodeNone = 1 + // VatCodeOff is the configured value that means "send no receipt at all" — the state a merchant + // outside 54-ФЗ runs in. It is the default, so receipts stay off until a rate is deliberately set. + VatCodeOff = 0 + // VatCodeMax is the highest VAT rate code the API accepts; codes run 1..12. + VatCodeMax = 12 +) + +// Customer is the payer's contact data for delivering the receipt. «Чеки от ЮKassa» deliver only by +// email (no SMS), so Email is required — the direct rail's confirmed email anchor supplies it. +type Customer struct { + Email string `json:"email,omitempty"` +} + +// ReceiptItem is one line of a fiscal receipt: what was sold, how much of it, for how much, and the +// three fiscal attributes that classify it. +type ReceiptItem struct { + Description string `json:"description"` + Quantity float64 `json:"quantity"` + Amount Amount `json:"amount"` + VatCode int `json:"vat_code"` + PaymentSubject string `json:"payment_subject"` + PaymentMode string `json:"payment_mode"` +} + +// Receipt is the fiscal receipt data sent alongside a payment or a refund. tax_system_code is +// deliberately absent: YooKassa ignores it for «Чеки от ЮKassa». +type Receipt struct { + Customer Customer `json:"customer"` + Items []ReceiptItem `json:"items"` +} + +// maxDescriptionRunes is the API's limit for both a payment description and a receipt line +// description (54-ФЗ tag 1030). A longer title is truncated rather than rejected, so an over-long +// product name cannot block a purchase. +const maxDescriptionRunes = 128 + +// SingleItemReceipt builds a one-line receipt for selling title at amount to email. Chip packs are +// always a single line bought once, so quantity is 1 and the line amount is the whole payment. +func SingleItemReceipt(email, title string, amount Amount, vatCode int) *Receipt { + return &Receipt{ + Customer: Customer{Email: email}, + Items: []ReceiptItem{{ + Description: TruncateDescription(title), + Quantity: 1, + Amount: amount, + VatCode: vatCode, + PaymentSubject: PaymentSubjectService, + PaymentMode: PaymentModeFullPayment, + }}, + } +} + +// TruncateDescription shortens s to the API's description limit, counting runes rather than bytes so +// a Cyrillic title is not cut mid-character. +func TruncateDescription(s string) string { + r := []rune(s) + if len(r) <= maxDescriptionRunes { + return s + } + return string(r[:maxDescriptionRunes]) +} + +// ValidVatCode reports whether code is a rate the API accepts (1..12) or VatCodeOff, which disables +// receipts altogether. +func ValidVatCode(code int) bool { return code == VatCodeOff || (code >= 1 && code <= VatCodeMax) } + +// ReceiptEnabled reports whether a configured VAT rate code asks for fiscal receipts. A merchant +// outside 54-ФЗ leaves the code unset and no receipt is built or sent. +func ReceiptEnabled(vatCode int) bool { return vatCode != VatCodeOff } diff --git a/backend/internal/yookassa/shops.go b/backend/internal/yookassa/shops.go new file mode 100644 index 0000000..d182684 --- /dev/null +++ b/backend/internal/yookassa/shops.go @@ -0,0 +1,58 @@ +package yookassa + +// Shops is a set of YooKassa merchant shops keyed by channel — the subtype of the trusted X-Platform +// signal for the direct rail ("web", "android"; "ios" later). The direct rail routes a payment to the +// per-channel shop for separate merchant accounts, accounting and receipts, while every shop still +// credits the one direct wallet (docs/PAYMENTS_DECISIONS_ru.md D42). An empty set leaves the direct +// order and notification endpoints unregistered. +type Shops map[string]Config + +// Channel constants name the direct-rail X-Platform subtypes a shop is keyed by. ChannelWeb is the +// default: an unknown or empty channel routes here on the order side. +const ( + ChannelWeb = "web" + ChannelAndroid = "android" +) + +// Shop returns the shop that issues an order for channel, falling back to the web shop when channel +// is unknown, empty or not configured. The fallback is safe: routing only chooses the merchant +// account and receipt, never the credited wallet (always direct, D42), so a mis-attributed channel +// costs at most accounting accuracy, not money. The second result is false when neither the channel +// nor the web shop is configured. +func (s Shops) Shop(channel string) (Config, bool) { + if channel != "" { + if c, ok := s[channel]; ok && c.Configured() { + return c, true + } + } + if c, ok := s[ChannelWeb]; ok && c.Configured() { + return c, true + } + return Config{}, false +} + +// ByShopID returns the shop with the given YooKassa shop id, which is how an incoming notification is +// attributed to one of several configured shops (the payment object reports it as +// recipient.account_id). Unlike Shop it does not fall back: an unrecognised shop id means the +// notification was not meant for us, and the caller must not guess at credentials. +func (s Shops) ByShopID(shopID string) (channel string, cfg Config, ok bool) { + if shopID == "" { + return "", Config{}, false + } + for ch, c := range s { + if c.Configured() && c.ShopID == shopID { + return ch, c, true + } + } + return "", Config{}, false +} + +// Configured reports whether at least one shop has credentials (the YooKassa direct rail is live). +func (s Shops) Configured() bool { + for _, c := range s { + if c.Configured() { + return true + } + } + return false +} diff --git a/backend/internal/yookassa/shops_test.go b/backend/internal/yookassa/shops_test.go new file mode 100644 index 0000000..1c35730 --- /dev/null +++ b/backend/internal/yookassa/shops_test.go @@ -0,0 +1,125 @@ +package yookassa + +import ( + "strings" + "testing" +) + +func testShops() Shops { + return Shops{ + ChannelWeb: {ShopID: "100500", SecretKey: "web-secret"}, + ChannelAndroid: {ShopID: "100700", SecretKey: "android-secret"}, + } +} + +func TestShopsShopRouting(t *testing.T) { + s := testShops() + for channel, wantShopID := range map[string]string{ + ChannelWeb: "100500", + ChannelAndroid: "100700", + "ios": "100500", // an unconfigured channel falls back to web + "": "100500", // so does an unknown platform subtype + } { + got, ok := s.Shop(channel) + if !ok || got.ShopID != wantShopID { + t.Errorf("Shop(%q) = %q ok=%v, want %q", channel, got.ShopID, ok, wantShopID) + } + } +} + +func TestShopsShopWithoutWebFallback(t *testing.T) { + // Only android configured: an unknown channel has nowhere safe to fall back to. + s := Shops{ChannelAndroid: {ShopID: "100700", SecretKey: "android-secret"}} + if got, ok := s.Shop(ChannelAndroid); !ok || got.ShopID != "100700" { + t.Errorf("Shop(android) = %q ok=%v, want the android shop", got.ShopID, ok) + } + if _, ok := s.Shop("ios"); ok { + t.Error("Shop(ios) resolved with no web shop configured") + } + if _, ok := (Shops{}).Shop(ChannelWeb); ok { + t.Error("an empty set resolved a shop") + } +} + +func TestShopsByShopIDDoesNotGuess(t *testing.T) { + s := testShops() + channel, cfg, ok := s.ByShopID("100700") + if !ok || channel != ChannelAndroid || cfg.SecretKey != "android-secret" { + t.Errorf("ByShopID(100700) = %q/%q ok=%v, want the android shop", channel, cfg.SecretKey, ok) + } + // An unrecognised shop id means the notification was not meant for us; guessing at credentials + // would verify a foreign payment against our own shop. + for _, id := range []string{"999999", ""} { + if _, _, ok := s.ByShopID(id); ok { + t.Errorf("ByShopID(%q) resolved a shop", id) + } + } +} + +func TestShopsIgnoreHalfConfiguredEntries(t *testing.T) { + s := Shops{ + ChannelWeb: {ShopID: "100500"}, // no secret key + ChannelAndroid: {SecretKey: "android-secret"}, + } + if s.Configured() { + t.Error("Configured() = true with no complete shop") + } + if _, ok := s.Shop(ChannelWeb); ok { + t.Error("Shop(web) resolved a shop with no secret key") + } + if _, _, ok := s.ByShopID("100500"); ok { + t.Error("ByShopID matched a shop with no secret key") + } +} + +func TestShopsConfigured(t *testing.T) { + if !testShops().Configured() { + t.Error("Configured() = false with two complete shops") + } + if (Shops{}).Configured() { + t.Error("Configured() = true for an empty set") + } +} + +func TestSingleItemReceiptTruncatesLongTitles(t *testing.T) { + long := strings.Repeat("ф", 200) // Cyrillic: truncation must count runes, not bytes + r := SingleItemReceipt("buyer@example.test", long, Amount{Value: "1.00", Currency: "RUB"}, VatCodeNone) + got := []rune(r.Items[0].Description) + if len(got) != maxDescriptionRunes { + t.Errorf("description = %d runes, want %d", len(got), maxDescriptionRunes) + } + for _, c := range got { + if c != 'ф' { + t.Fatalf("truncation cut a multi-byte rune: %q", string(got)) + } + } + if r.Items[0].Quantity != 1 { + t.Errorf("quantity = %v, want 1 (a chip pack is bought once)", r.Items[0].Quantity) + } +} + +func TestValidVatCode(t *testing.T) { + // VatCodeOff is a legal configuration: it means "send no receipt", the state a merchant outside + // 54-ФЗ runs in. + for _, code := range []int{VatCodeOff, 1, 4, 11, 12} { + if !ValidVatCode(code) { + t.Errorf("vat code %d rejected, want accepted", code) + } + } + for _, code := range []int{-1, 13} { + if ValidVatCode(code) { + t.Errorf("vat code %d accepted, want rejected", code) + } + } +} + +func TestReceiptEnabled(t *testing.T) { + if ReceiptEnabled(VatCodeOff) { + t.Error("receipts reported enabled with no VAT rate configured") + } + for _, code := range []int{1, 4, 11} { + if !ReceiptEnabled(code) { + t.Errorf("receipts reported disabled for vat code %d", code) + } + } +} diff --git a/backend/internal/yookassa/yookassa.go b/backend/internal/yookassa/yookassa.go new file mode 100644 index 0000000..f8e8b33 --- /dev/null +++ b/backend/internal/yookassa/yookassa.go @@ -0,0 +1,280 @@ +// Package yookassa talks to the YooKassa REST API for the direct-rail (RUB) payments: it creates a +// hosted payment the client is redirected to, re-reads a payment to confirm what really happened, +// and issues a refund. It is pure provider glue — no database, no payments-domain coupling — so the +// payments domain stays provider-agnostic and this layer is unit-testable in isolation. +// +// Two properties of the API shape everything here. First, YooKassa notifications carry NO signature: +// authenticity is established by re-reading the object with GetPayment (the notification body is a +// hint, never evidence) and, defensively, by the sender-IP allowlist in AllowedIP. Second, every +// state-changing call is made idempotent by an Idempotence-Key header, so a retried create or refund +// returns the original object instead of charging twice; callers pass the order id as that key. +// +// The order is threaded to the provider through metadata["order_id"] and echoed back on the payment, +// so a notification resolves to an order without trusting anything else in the body. +package yookassa + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// DefaultBaseURL is the production API root. Config.BaseURL overrides it (tests point it at an +// httptest server). +const DefaultBaseURL = "https://api.yookassa.ru/v3" + +// MetadataOrderID is the metadata key carrying our order id through the provider and back on the +// payment object — the only link a notification gives us to an order. +const MetadataOrderID = "order_id" + +// Payment statuses (https://yookassa.ru/developers/payment-acceptance/getting-started/payment-process). +// A single-stage payment (Capture true) moves pending -> succeeded or pending -> canceled; +// StatusWaitingForCapture only occurs for two-stage payments, which this integration does not use. +const ( + StatusPending = "pending" + StatusWaitingForCapture = "waiting_for_capture" + StatusSucceeded = "succeeded" + StatusCanceled = "canceled" +) + +// ConfirmationRedirect is the confirmation scenario this integration uses: YooKassa hosts the +// payment form and returns the customer to the return URL afterwards. +const ConfirmationRedirect = "redirect" + +// requestTimeout bounds a single API call. The caller's context still governs cancellation; this is +// the ceiling for a provider that has stopped answering. +const requestTimeout = 20 * time.Second + +// maxResponseBytes caps how much of a response body is read, so a misbehaving or spoofed endpoint +// cannot exhaust memory. +const maxResponseBytes = 1 << 20 + +// apiClient is shared by every shop so connections pool across them. It carries only a ceiling +// timeout; per-request cancellation rides the context. +var apiClient = &http.Client{Timeout: requestTimeout} + +// Config is a YooKassa merchant shop's credentials. ShopID and SecretKey authenticate every call as +// HTTP Basic credentials. IsTest records that these are a test shop's credentials, which lets the +// caller refuse to credit a live payment against a test shop and vice versa. BaseURL overrides +// DefaultBaseURL when set. +type Config struct { + ShopID string + SecretKey string + IsTest bool + BaseURL string +} + +// Amount is a monetary amount on the wire: a decimal string with the fraction separator "." and an +// ISO-4217 currency code. payments.Money.Major() already renders Value in this form. +type Amount struct { + Value string `json:"value"` + Currency string `json:"currency"` +} + +// Confirmation carries the confirmation scenario. ReturnURL is sent on a create (where the customer +// lands after paying, absolute); ConfirmationURL comes back on the created payment and is the page +// the customer must be sent to. +type Confirmation struct { + Type string `json:"type"` + ReturnURL string `json:"return_url,omitempty"` + ConfirmationURL string `json:"confirmation_url,omitempty"` +} + +// Recipient identifies the shop that received the payment. AccountID is the shop id, which is how an +// incoming notification is attributed to one of several configured shops. +type Recipient struct { + AccountID string `json:"account_id"` + GatewayID string `json:"gateway_id"` +} + +// Cancellation explains a canceled payment: which participant decided (yoo_money, payment_network, +// merchant) and why. It is reported to the operator, not to the payer. +type Cancellation struct { + Party string `json:"party"` + Reason string `json:"reason"` +} + +// PaymentRequest is the body of a create-payment call in the redirect confirmation scenario. Capture +// true settles in one stage: a paid payment goes straight to succeeded with no capture call. +type PaymentRequest struct { + Amount Amount `json:"amount"` + Capture bool `json:"capture"` + Confirmation Confirmation `json:"confirmation"` + Description string `json:"description,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Receipt *Receipt `json:"receipt,omitempty"` +} + +// Payment is a YooKassa payment object. Paid reports that the money was actually taken; Test is true +// for a payment made against a test shop. Metadata echoes what the create call sent, so +// Metadata[MetadataOrderID] identifies the order. +type Payment struct { + ID string `json:"id"` + Status string `json:"status"` + Paid bool `json:"paid"` + Test bool `json:"test"` + Amount Amount `json:"amount"` + Description string `json:"description"` + Confirmation Confirmation `json:"confirmation"` + Metadata map[string]string `json:"metadata"` + Recipient Recipient `json:"recipient"` + CancellationDetails *Cancellation `json:"cancellation_details,omitempty"` +} + +// OrderID returns the order the payment funds, taken from the metadata the create call threaded +// through. It is empty for a payment we did not originate. +func (p Payment) OrderID() string { return p.Metadata[MetadataOrderID] } + +// RefundRequest is the body of a create-refund call. Amount may be the whole payment or part of it; +// Receipt carries the refund receipt required when the shop registers receipts through YooKassa. +type RefundRequest struct { + PaymentID string `json:"payment_id"` + Amount Amount `json:"amount"` + Description string `json:"description,omitempty"` + Receipt *Receipt `json:"receipt,omitempty"` +} + +// Refund is a YooKassa refund object. Its ID is distinct from the refunded payment's id and is what +// the ledger records as the refund's idempotency key. +type Refund struct { + ID string `json:"id"` + Status string `json:"status"` + PaymentID string `json:"payment_id"` + Amount Amount `json:"amount"` +} + +// ErrUnconfigured is returned when a call is attempted on a shop with no credentials. +var ErrUnconfigured = errors.New("yookassa: shop is not configured") + +// APIError is a non-2xx answer from the API. Description and Parameter come from YooKassa's error +// object and name the offending field, which is what makes a malformed receipt diagnosable. +type APIError struct { + StatusCode int `json:"-"` + Type string `json:"type"` + ID string `json:"id"` + Code string `json:"code"` + Description string `json:"description"` + Parameter string `json:"parameter"` + RetryAfter int `json:"retry_after"` +} + +// Error renders the status together with YooKassa's own description, including the offending +// parameter when the API named one. +func (e *APIError) Error() string { + msg := e.Description + if msg == "" { + msg = e.Code + } + if e.Parameter != "" { + return fmt.Sprintf("yookassa: http %d: %s (parameter %s)", e.StatusCode, msg, e.Parameter) + } + return fmt.Sprintf("yookassa: http %d: %s", e.StatusCode, msg) +} + +// Retryable reports whether repeating the call could succeed. A 5xx or a 429 is transient; a 4xx is +// a permanent rejection of this request. Callers use it to decide whether to ask the provider to +// redeliver a notification or to accept it as durably handled. +func (e *APIError) Retryable() bool { + return e.StatusCode >= 500 || e.StatusCode == http.StatusTooManyRequests +} + +// Configured reports whether the shop has credentials to call the API with. +func (c Config) Configured() bool { return c.ShopID != "" && c.SecretKey != "" } + +// CreatePayment opens a payment for the order and returns the created object, whose +// Confirmation.ConfirmationURL is the page the customer must be sent to. idempotenceKey makes the +// call safe to retry — YooKassa replays the original result for 24 hours — so callers pass the order +// id and never mint a second payment for the same order. +func (c Config) CreatePayment(ctx context.Context, req PaymentRequest, idempotenceKey string) (Payment, error) { + var out Payment + err := c.do(ctx, http.MethodPost, "/payments", req, idempotenceKey, &out) + return out, err +} + +// GetPayment re-reads a payment by id. This is the authenticity check for the whole rail: YooKassa +// notifications are unsigned, so only the object returned here may be acted on. +func (c Config) GetPayment(ctx context.Context, paymentID string) (Payment, error) { + var out Payment + err := c.do(ctx, http.MethodGet, "/payments/"+url.PathEscape(paymentID), nil, "", &out) + return out, err +} + +// CreateRefund returns money for a succeeded payment and reports the created refund. idempotenceKey +// guards against a double refund on a retry; callers pass the order id. The returned refund is not +// necessarily final — check Status before acting on it. +func (c Config) CreateRefund(ctx context.Context, req RefundRequest, idempotenceKey string) (Refund, error) { + var out Refund + err := c.do(ctx, http.MethodPost, "/refunds", req, idempotenceKey, &out) + return out, err +} + +// GetRefund re-reads a refund by id. As with GetPayment this is the authenticity check: a refund +// notification is unsigned, so only the object returned here may be acted on. +func (c Config) GetRefund(ctx context.Context, refundID string) (Refund, error) { + var out Refund + err := c.do(ctx, http.MethodGet, "/refunds/"+url.PathEscape(refundID), nil, "", &out) + return out, err +} + +// do performs one authenticated API call and decodes the result into out. A non-2xx answer is +// returned as an *APIError carrying YooKassa's own error object when the body holds one. +func (c Config) do(ctx context.Context, method, path string, body any, idempotenceKey string, out any) error { + if !c.Configured() { + return ErrUnconfigured + } + var payload io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("yookassa: encode %s %s: %w", method, path, err) + } + payload = bytes.NewReader(buf) + } + req, err := http.NewRequestWithContext(ctx, method, c.endpoint()+path, payload) + if err != nil { + return fmt.Errorf("yookassa: build %s %s: %w", method, path, err) + } + req.SetBasicAuth(c.ShopID, c.SecretKey) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if idempotenceKey != "" { + req.Header.Set("Idempotence-Key", idempotenceKey) + } + resp, err := apiClient.Do(req) + if err != nil { + return fmt.Errorf("yookassa: %s %s: %w", method, path, err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return fmt.Errorf("yookassa: read %s %s: %w", method, path, err) + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + apiErr := &APIError{StatusCode: resp.StatusCode} + // The body is YooKassa's error object on a handled fault and something else entirely on an + // edge failure; either way the status alone is enough to classify the error. + _ = json.Unmarshal(raw, apiErr) + return apiErr + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("yookassa: decode %s %s: %w", method, path, err) + } + return nil +} + +// endpoint returns the API root for this shop, without a trailing slash. +func (c Config) endpoint() string { + if c.BaseURL == "" { + return DefaultBaseURL + } + return strings.TrimSuffix(c.BaseURL, "/") +} diff --git a/backend/internal/yookassa/yookassa_test.go b/backend/internal/yookassa/yookassa_test.go new file mode 100644 index 0000000..e556b0d --- /dev/null +++ b/backend/internal/yookassa/yookassa_test.go @@ -0,0 +1,260 @@ +package yookassa + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// capture records what the fake API received, so a test can assert on the request the client built. +type capture struct { + method string + path string + authUser string + authPass string + idempotenceKey string + body map[string]any +} + +// fakeAPI serves one canned response and records the request. It returns a Config already pointed at +// it, which is how every API-shaped test drives the client without touching the network. +func fakeAPI(t *testing.T, status int, response string) (Config, *capture) { + t.Helper() + got := &capture{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.method = r.Method + got.path = r.URL.Path + got.authUser, got.authPass, _ = r.BasicAuth() + got.idempotenceKey = r.Header.Get("Idempotence-Key") + if raw, _ := io.ReadAll(r.Body); len(raw) > 0 { + _ = json.Unmarshal(raw, &got.body) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, response) + })) + t.Cleanup(srv.Close) + return Config{ShopID: "100500", SecretKey: "secret", BaseURL: srv.URL}, got +} + +func TestCreatePaymentBuildsTheRequest(t *testing.T) { + cfg, got := fakeAPI(t, http.StatusOK, `{ + "id":"23d93cac-000f-5000-8000-126628f15141","status":"pending","paid":false,"test":false, + "amount":{"value":"149.00","currency":"RUB"}, + "confirmation":{"type":"redirect","confirmation_url":"https://yoomoney.ru/pay/abc"}, + "metadata":{"order_id":"0197-order"}}`) + + amount := Amount{Value: "149.00", Currency: "RUB"} + p, err := cfg.CreatePayment(context.Background(), PaymentRequest{ + Amount: amount, + Capture: true, + Confirmation: Confirmation{Type: ConfirmationRedirect, ReturnURL: "https://example.test/pay/yookassa/return"}, + Description: "10 chips", + Metadata: map[string]string{MetadataOrderID: "0197-order"}, + Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone), + }, "0197-order") + if err != nil { + t.Fatalf("create payment: %v", err) + } + + if got.method != http.MethodPost || got.path != "/payments" { + t.Errorf("request = %s %s, want POST /payments", got.method, got.path) + } + if got.authUser != "100500" || got.authPass != "secret" { + t.Errorf("basic auth = %q:%q, want the shop id and secret key", got.authUser, got.authPass) + } + // The idempotence key is what makes a retried create return the original payment instead of + // minting a second one for the same order. + if got.idempotenceKey != "0197-order" { + t.Errorf("Idempotence-Key = %q, want the order id", got.idempotenceKey) + } + if got.body["capture"] != true { + t.Errorf("capture = %v, want true (single-stage payment)", got.body["capture"]) + } + conf, _ := got.body["confirmation"].(map[string]any) + if conf["type"] != ConfirmationRedirect || conf["return_url"] != "https://example.test/pay/yookassa/return" { + t.Errorf("confirmation = %v, want a redirect with the return url", conf) + } + meta, _ := got.body["metadata"].(map[string]any) + if meta[MetadataOrderID] != "0197-order" { + t.Errorf("metadata = %v, want the order id threaded through", meta) + } + receipt, _ := got.body["receipt"].(map[string]any) + customer, _ := receipt["customer"].(map[string]any) + if customer["email"] != "buyer@example.test" { + t.Errorf("receipt customer = %v, want the buyer email", customer) + } + items, _ := receipt["items"].([]any) + if len(items) != 1 { + t.Fatalf("receipt items = %d, want 1", len(items)) + } + item, _ := items[0].(map[string]any) + if item["vat_code"] != float64(VatCodeNone) || item["payment_subject"] != PaymentSubjectService || item["payment_mode"] != PaymentModeFullPayment { + t.Errorf("receipt item fiscal attributes = %v", item) + } + + if p.Confirmation.ConfirmationURL != "https://yoomoney.ru/pay/abc" { + t.Errorf("confirmation url = %q, want the hosted payment page", p.Confirmation.ConfirmationURL) + } + if p.OrderID() != "0197-order" { + t.Errorf("OrderID() = %q, want the order the payment funds", p.OrderID()) + } +} + +func TestGetPaymentReadsTheObject(t *testing.T) { + cfg, got := fakeAPI(t, http.StatusOK, `{ + "id":"pay-1","status":"succeeded","paid":true,"test":true, + "amount":{"value":"149.00","currency":"RUB"}, + "recipient":{"account_id":"100500","gateway_id":"100700"}, + "metadata":{"order_id":"0197-order"}}`) + + p, err := cfg.GetPayment(context.Background(), "pay-1") + if err != nil { + t.Fatalf("get payment: %v", err) + } + if got.method != http.MethodGet || got.path != "/payments/pay-1" { + t.Errorf("request = %s %s, want GET /payments/pay-1", got.method, got.path) + } + // A read is idempotent by nature, so no key is sent. + if got.idempotenceKey != "" { + t.Errorf("Idempotence-Key = %q, want none on a GET", got.idempotenceKey) + } + if p.Status != StatusSucceeded || !p.Paid { + t.Errorf("payment = %s paid=%v, want succeeded and paid", p.Status, p.Paid) + } + if !p.Test { + t.Error("Test = false, want true — a test-shop payment must be distinguishable from a live one") + } + if p.Recipient.AccountID != "100500" { + t.Errorf("recipient.account_id = %q, want the shop id", p.Recipient.AccountID) + } +} + +func TestCreateRefundBuildsTheRequest(t *testing.T) { + cfg, got := fakeAPI(t, http.StatusOK, `{ + "id":"refund-1","status":"succeeded","payment_id":"pay-1", + "amount":{"value":"149.00","currency":"RUB"}}`) + + amount := Amount{Value: "149.00", Currency: "RUB"} + r, err := cfg.CreateRefund(context.Background(), RefundRequest{ + PaymentID: "pay-1", + Amount: amount, + Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone), + }, "0197-order") + if err != nil { + t.Fatalf("create refund: %v", err) + } + if got.method != http.MethodPost || got.path != "/refunds" { + t.Errorf("request = %s %s, want POST /refunds", got.method, got.path) + } + if got.idempotenceKey != "0197-order" { + t.Errorf("Idempotence-Key = %q, want the order id (guards a double refund)", got.idempotenceKey) + } + if got.body["payment_id"] != "pay-1" { + t.Errorf("payment_id = %v, want the refunded payment", got.body["payment_id"]) + } + if _, ok := got.body["receipt"]; !ok { + t.Error("refund carries no receipt — a refund receipt is required under «Чеки от ЮKassa»") + } + // The refund id is distinct from the payment id: the ledger keys the refund row on it, so the + // fund and refund rows can coexist under the same partial-unique index. + if r.ID != "refund-1" || r.PaymentID != "pay-1" { + t.Errorf("refund = %+v, want id refund-1 for payment pay-1", r) + } +} + +func TestAPIErrorClassifiesRetryability(t *testing.T) { + cfg, _ := fakeAPI(t, http.StatusBadRequest, + `{"type":"error","id":"err-1","code":"invalid_request","description":"Receipt item is invalid","parameter":"receipt.items"}`) + + _, err := cfg.GetPayment(context.Background(), "pay-1") + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error = %v, want an *APIError", err) + } + if apiErr.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want 400", apiErr.StatusCode) + } + // The offending parameter is what makes a malformed receipt diagnosable rather than a mystery. + if !strings.Contains(apiErr.Error(), "receipt.items") { + t.Errorf("error text %q does not name the offending parameter", apiErr.Error()) + } + if apiErr.Retryable() { + t.Error("a 400 reported as retryable; a permanent rejection must not be re-delivered") + } + + for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} { + cfg, _ := fakeAPI(t, status, `{"type":"error","description":"try later"}`) + _, err := cfg.GetPayment(context.Background(), "pay-1") + var e *APIError + if !errors.As(err, &e) || !e.Retryable() { + t.Errorf("status %d: error = %v, want a retryable *APIError", status, err) + } + } +} + +func TestUnconfiguredShopMakesNoCall(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Error("an unconfigured shop reached the API") + })) + t.Cleanup(srv.Close) + + for name, cfg := range map[string]Config{ + "no credentials": {BaseURL: srv.URL}, + "no secret key": {ShopID: "100500", BaseURL: srv.URL}, + "no shop id": {SecretKey: "secret", BaseURL: srv.URL}, + } { + if _, err := cfg.GetPayment(context.Background(), "pay-1"); !errors.Is(err, ErrUnconfigured) { + t.Errorf("%s: error = %v, want ErrUnconfigured", name, err) + } + } +} + +func TestEndpointDefaultsToTheProductionAPI(t *testing.T) { + if got := (Config{}).endpoint(); got != DefaultBaseURL { + t.Errorf("endpoint = %q, want %q", got, DefaultBaseURL) + } + if got := (Config{BaseURL: "https://api.test/v3/"}).endpoint(); got != "https://api.test/v3" { + t.Errorf("endpoint = %q, want the trailing slash trimmed", got) + } +} + +func TestGetRefundReadsTheObject(t *testing.T) { + cfg, got := fakeAPI(t, http.StatusOK, `{ + "id":"refund-1","status":"succeeded","payment_id":"pay-1", + "amount":{"value":"149.00","currency":"RUB"}}`) + + r, err := cfg.GetRefund(context.Background(), "refund-1") + if err != nil { + t.Fatalf("get refund: %v", err) + } + if got.method != http.MethodGet || got.path != "/refunds/refund-1" { + t.Errorf("request = %s %s, want GET /refunds/refund-1", got.method, got.path) + } + if r.Status != StatusSucceeded || r.PaymentID != "pay-1" { + t.Errorf("refund = %+v, want a succeeded refund of pay-1", r) + } +} + +func TestCreateRefundReportsANonFinalStatus(t *testing.T) { + // A refund can be accepted and still be canceled later, so the caller must be able to see that it + // is not settled rather than treat any 200 as done. + cfg, _ := fakeAPI(t, http.StatusOK, `{ + "id":"refund-1","status":"pending","payment_id":"pay-1", + "amount":{"value":"149.00","currency":"RUB"}}`) + + r, err := cfg.CreateRefund(context.Background(), RefundRequest{ + PaymentID: "pay-1", Amount: Amount{Value: "149.00", Currency: "RUB"}, + }, "0197-order") + if err != nil { + t.Fatalf("create refund: %v", err) + } + if r.Status != StatusPending { + t.Errorf("status = %q, want pending surfaced to the caller", r.Status) + } +} diff --git a/deploy/.env.example b/deploy/.env.example index c559a6c..33df396 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -132,28 +132,33 @@ GATEWAY_VK_APP_SECRET= # come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*. GATEWAY_VK_ID_CLIENT_SECRET= -# --- Payments: Robokassa (direct RUB rail) ---------------------------------- -# The shop's merchant login + the two pass phrases (Password1 signs the launch request, -# Password2 signs/verifies the Result callback). An empty login leaves the direct rail off. -# ROBOKASSA_TEST=1 runs test payments against the shop's TEST pass phrases (no real money); -# empty/0 is live. Mapped in compose to BACKEND_ROBOKASSA_*; Gitea TEST_/PROD_ secrets, with -# PROD_BACKEND_ROBOKASSA_TEST a variable so go-live is a flag flip, not a secret redeploy. -ROBOKASSA_MERCHANT_LOGIN= -ROBOKASSA_PASSWORD1= -ROBOKASSA_PASSWORD2= -ROBOKASSA_TEST= -# Multi-shop direct rail (D42): the vars above seed the "web" channel; add per-channel shops here. -# ROBOKASSA_WEB_* overrides the web shop; ROBOKASSA_ANDROID_* adds the android (RuStore) shop. Each -# credits the one direct wallet; an empty login drops that channel (routing falls back to web). The -# Robokassa cabinet points each shop's Result URL at /pay/robokassa/result/web and .../android. -ROBOKASSA_WEB_MERCHANT_LOGIN= -ROBOKASSA_WEB_PASSWORD1= -ROBOKASSA_WEB_PASSWORD2= -ROBOKASSA_WEB_TEST= -ROBOKASSA_ANDROID_MERCHANT_LOGIN= -ROBOKASSA_ANDROID_PASSWORD1= -ROBOKASSA_ANDROID_PASSWORD2= -ROBOKASSA_ANDROID_TEST= +# --- Payments: YooKassa (direct RUB rail) ------------------------------------ +# One merchant shop per channel (D42): the shop id (Настройки — Магазин, field shopId) and the +# secret key (Интеграция — Ключи API). Each shop credits the one direct wallet; a shop missing +# either credential drops that channel (order routing falls back to web), and no shop at all +# leaves the direct rail off. YOOKASSA_*_TEST=1 marks a TEST shop, which makes the intake refuse +# to credit a live payment against it (and a test payment against a live shop). +# Mapped in compose to BACKEND_YOOKASSA_*; Gitea TEST_/PROD_ secrets. +# Set each shop's notification URL in its cabinet (Интеграция — HTTP-уведомления) to +# ${PUBLIC_BASE_URL}/pay/yookassa/notify, events payment.succeeded + payment.canceled + +# refund.succeeded (the last one catches a refund issued in the cabinet, which bypasses our API). +YOOKASSA_WEB_SHOP_ID= +YOOKASSA_WEB_SECRET_KEY= +YOOKASSA_WEB_TEST= +# The android (RuStore) shop — add when that channel gets its own merchant account. +YOOKASSA_ANDROID_SHOP_ID= +YOOKASSA_ANDROID_SECRET_KEY= +YOOKASSA_ANDROID_TEST= +# Fiscal receipts (54-ФЗ) — OFF while empty, which is the intended state: the merchant runs on НПД, +# outside 54-ФЗ, so nothing is registered through the provider and each operation is reported to +# «Мой налог» instead. Set a VAT rate code (tag 1199: 1 = «Без НДС», 4 = 20%, 11 = 22%) to turn +# receipts back on — the switch a lost НПД regime would need. A Gitea VARIABLE, not a secret. +YOOKASSA_VAT_CODE= +# +# Robokassa (the retired direct rail) is deliberately absent here: no deployment sets its +# credentials, which is what keeps it dormant. The backend still reads BACKEND_ROBOKASSA_* and +# falls the direct rail back to it if they are ever set again — see +# backend/internal/robokassa/README.md for the full variable set and how to revive the rail. # --- Gateway anti-abuse ------------------------------------------------------ # Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban diff --git a/deploy/README.md b/deploy/README.md index a9d683e..2f10f13 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -76,10 +76,10 @@ compose binds from this directory. | `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext ''`. | | `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). | | `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. | -| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. | -| `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) | -| `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. | -| `BACKEND_ROBOKASSA_{WEB,ANDROID}_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | secret / variable | Multi-shop direct rail (D42): per-channel Robokassa shops. The legacy `BACKEND_ROBOKASSA_*` seeds the **web** shop; `…_WEB_*` overrides it, `…_ANDROID_*` adds the **android** (RuStore) shop. Each credits the one `direct` wallet; the cabinet Result URL per shop is `/pay/robokassa/result/{web,android}`. Empty login ⇒ that channel unconfigured (order routing falls back to the web shop). | +| `BACKEND_YOOKASSA_{WEB,ANDROID}_SHOP_ID` | secret | YooKassa shop id for that channel of the direct RUB rail (cabinet: **Настройки — Магазин**, field `shopId`). Empty ⇒ that channel is unconfigured and order routing falls back to the **web** shop; no channel configured at all leaves the rail off. Each shop's **Интеграция — HTTP-уведомления** must point at `/pay/yookassa/notify` with events `payment.succeeded`, `payment.canceled` and `refund.succeeded`. | +| `BACKEND_YOOKASSA_{WEB,ANDROID}_SECRET_KEY` | secret | That shop's API secret key (cabinet: **Интеграция — Ключи API**). It is the password half of the HTTP Basic credentials on every API call. A shop id without a secret key is ignored. | +| `BACKEND_YOOKASSA_{WEB,ANDROID}_TEST` | **variable** | `1` marks the credentials as a **test shop**: the intake then refuses to credit anything but a test payment (and a live shop refuses a test one). A variable, not a secret, so switching a contour between a test and a live shop is a flag flip + redeploy. | +| `BACKEND_YOOKASSA_VAT_CODE` | **variable** | Fiscal receipts (54-ФЗ). **Empty by default = no receipt is sent at all**, which is the intended state: the merchant runs on НПД, outside 54-ФЗ, so nothing is registered through the provider and each operation is reported to «Мой налог» instead. Setting a VAT rate code (tag 1199: `1` = «Без НДС», `4` = 20%, `11` = 22%) switches receipts back on with no code change — the path a lost НПД regime (annual income ceiling) would take. Confirm the rate with the accountant before switching it on: a wrong rate produces wrong receipts, not a failed payment, so it fails silently. | **Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index c200392..86aba69 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -164,25 +164,24 @@ services: # recipient(s) (comma-separated allowed). Both empty disables the alert worker. BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-} BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-} - # Direct-rail (Robokassa) payment intake: the merchant login + Password1/Password2 and the - # test-mode flag (deploy env: TEST_/PROD_BACKEND_ROBOKASSA_*). An empty login leaves the - # direct order and Result-callback endpoints unregistered (the rail is off). - BACKEND_ROBOKASSA_MERCHANT_LOGIN: ${ROBOKASSA_MERCHANT_LOGIN:-} - BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-} - BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-} - BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-} - # Per-channel shops for the multi-shop direct rail (D42): the legacy ROBOKASSA_* above seeds - # the "web" channel; ROBOKASSA_WEB_* overrides it and ROBOKASSA_ANDROID_* adds the android - # shop. Each shop credits the one direct wallet; an empty login drops that channel (order - # routing falls back to the web shop). Result URLs: /pay/robokassa/result/{web,android}. - BACKEND_ROBOKASSA_WEB_MERCHANT_LOGIN: ${ROBOKASSA_WEB_MERCHANT_LOGIN:-} - BACKEND_ROBOKASSA_WEB_PASSWORD1: ${ROBOKASSA_WEB_PASSWORD1:-} - BACKEND_ROBOKASSA_WEB_PASSWORD2: ${ROBOKASSA_WEB_PASSWORD2:-} - BACKEND_ROBOKASSA_WEB_TEST: ${ROBOKASSA_WEB_TEST:-} - BACKEND_ROBOKASSA_ANDROID_MERCHANT_LOGIN: ${ROBOKASSA_ANDROID_MERCHANT_LOGIN:-} - BACKEND_ROBOKASSA_ANDROID_PASSWORD1: ${ROBOKASSA_ANDROID_PASSWORD1:-} - BACKEND_ROBOKASSA_ANDROID_PASSWORD2: ${ROBOKASSA_ANDROID_PASSWORD2:-} - BACKEND_ROBOKASSA_ANDROID_TEST: ${ROBOKASSA_ANDROID_TEST:-} + # Direct-rail (YooKassa) payment intake, one merchant shop per channel (D42): the shop id + + # secret key and a flag marking a test shop (deploy env: TEST_/PROD_BACKEND_YOOKASSA_*). + # Each shop credits the one direct wallet; a shop missing either credential drops that + # channel (order routing falls back to the web shop), and no shop at all leaves the direct + # order and notification endpoints unregistered. Notification URL (set per shop in the + # YooKassa cabinet): ${PUBLIC_BASE_URL}/pay/yookassa/notify — events payment.succeeded, + # payment.canceled and refund.succeeded. + BACKEND_YOOKASSA_WEB_SHOP_ID: ${YOOKASSA_WEB_SHOP_ID:-} + BACKEND_YOOKASSA_WEB_SECRET_KEY: ${YOOKASSA_WEB_SECRET_KEY:-} + BACKEND_YOOKASSA_WEB_TEST: ${YOOKASSA_WEB_TEST:-} + BACKEND_YOOKASSA_ANDROID_SHOP_ID: ${YOOKASSA_ANDROID_SHOP_ID:-} + BACKEND_YOOKASSA_ANDROID_SECRET_KEY: ${YOOKASSA_ANDROID_SECRET_KEY:-} + BACKEND_YOOKASSA_ANDROID_TEST: ${YOOKASSA_ANDROID_TEST:-} + # Fiscal receipts (54-ФЗ) are OFF: the merchant runs on НПД, which is outside 54-ФЗ, so no + # receipt is registered through the provider and each operation is reported to «Мой налог». + # Setting a VAT rate code (tag 1199: 1 = «Без НДС», 4 = 20%, 11 = 22%) turns receipts back on + # — the switch a lost НПД regime would need — with no code change. + BACKEND_YOOKASSA_VAT_CODE: ${YOOKASSA_VAT_CODE:-} # The dictionary lives on a named volume seeded from the image on first boot # (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume # inherits). The admin console writes new version subdirectories here, and the diff --git a/deploy/write-prod-env.sh b/deploy/write-prod-env.sh index 70f8d5f..4ea558f 100755 --- a/deploy/write-prod-env.sh +++ b/deploy/write-prod-env.sh @@ -54,10 +54,13 @@ export GATEWAY_RECOMMENDED_CLIENT_VERSION='$GATEWAY_RECOMMENDED_CLIENT_VERSION' export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN' export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL' export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET' -export ROBOKASSA_MERCHANT_LOGIN='$ROBOKASSA_MERCHANT_LOGIN' -export ROBOKASSA_PASSWORD1='$ROBOKASSA_PASSWORD1' -export ROBOKASSA_PASSWORD2='$ROBOKASSA_PASSWORD2' -export ROBOKASSA_TEST='$ROBOKASSA_TEST' +export YOOKASSA_WEB_SHOP_ID='$YOOKASSA_WEB_SHOP_ID' +export YOOKASSA_WEB_SECRET_KEY='$YOOKASSA_WEB_SECRET_KEY' +export YOOKASSA_WEB_TEST='$YOOKASSA_WEB_TEST' +export YOOKASSA_ANDROID_SHOP_ID='$YOOKASSA_ANDROID_SHOP_ID' +export YOOKASSA_ANDROID_SECRET_KEY='$YOOKASSA_ANDROID_SECRET_KEY' +export YOOKASSA_ANDROID_TEST='$YOOKASSA_ANDROID_TEST' +export YOOKASSA_VAT_CODE='${YOOKASSA_VAT_CODE:-1}' export VITE_VK_APP_ID='$VITE_VK_APP_ID' export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL' export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET' diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index ef49a0e..a64dc52 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -23,7 +23,7 @@ The game earns through two orthogonal channels: The currency is **two-tier**: ``` -money (VK Votes / TG Stars / RUB via Robokassa) ─┐ +money (VK Votes / TG Stars / RUB via YooKassa) ─┐ ├─► Фишки (chips) ──► values rewarded ad view ─┘ (no-ads, hints, tournament fee) @@ -46,7 +46,7 @@ The chip balance is **segmented by `source`** — the platform where the chips w |------------|----------------------------------| | `vk` | VK Votes purchase, VK rewarded ad | | `telegram` | TG Stars purchase | -| `direct` | Robokassa purchase (web / native)| +| `direct` | YooKassa purchase (web / native) | A single account holds all three segments simultaneously: `balance = (account_id, source)`, up to three rows — a row is materialised lazily on the segment's first funding, and an absent @@ -54,17 +54,27 @@ segment reads as zero. Segmentation is **not** per-identity — see §6. Why segmented, not one pooled balance: store rules forbid activating value that was paid for outside the store's own cash desk. Chips funded inside VK (Votes) may only be spent in -the VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside +the VK context; Stars only inside Telegram; YooKassa-funded (`direct`) chips only outside the stores. See §4. -**Multi-shop direct rail (D42).** The `direct` rail routes to one Robokassa **merchant shop per +**Multi-shop direct rail (D42).** The `direct` rail routes to one YooKassa **merchant shop per channel** — `web` and `android` (RuStore), `ios` later — chosen by the trusted `X-Platform` subtype; every shop credits the one `direct` wallet (no per-channel wallet). This is merchant-account -separation for accounting / receipts only: the order records its `shop` (shown in the admin report), -and each shop's Result callback is verified by its own Password2 at `/pay/robokassa/result/`. +separation for accounting / receipts only: the order records its `shop` (shown in the admin report). +All shops share one notification endpoint, `/pay/yookassa/notify`; an incoming notification is +attributed to a shop by the shop id the payment reports (`recipient.account_id`), falling back to the +channel recorded on the order, and that shop's credentials perform the confirming read (§9). Standalone apps (Android/iOS) sign in by email only, so a direct purchase always has the D36 email -anchor (D43). Fiscalization (54-ФЗ via the Robokassa cabinet under one ИП) is a single source -regardless of the number of shops (D41). +anchor (D43), which is also where the fiscal receipt is delivered. Fiscalization (54-ФЗ under one ИП) +is a single source regardless of the number of shops (D41). + +**Robokassa is retired, not deleted (D47).** It settled the `direct` rail before YooKassa. Its code, +tests and wiring stay in the tree and the rail falls back to it when no YooKassa shop is configured, +so reviving it is a credentials change rather than a code change; no deployment sets those +credentials today. `backend/internal/robokassa/README.md` records the retired variables, the cabinet +configuration and the revival steps. Ledger rows written while it was live keep +`provider = 'robokassa'` — that literal is load-bearing for the idempotency index and is never +renamed or reused. **Payment availability kill switch (D45/D46).** An operator can disable purchases on a rail/channel (`direct:web` / `direct:android` / `vk` / `telegram`) or for one account, live from `/_gm`, and the @@ -224,20 +234,57 @@ never the source of truth. ## 9. Payment intake -**Server callback only.** Chips are credited solely on a **verified** (signature/HMAC) -provider callback — Robokassa webhook / TG `successful_payment` / VK callback. A client "I -paid" is ignored. +**Server callback only.** Chips are credited solely on a **verified** provider callback — +YooKassa notification / TG `successful_payment` / VK callback. A client "I paid" is ignored. What +"verified" means differs by rail: VK and Telegram sign their callbacks, and **YooKassa does not** +(D48) — see the confirming read below. **Single writer.** One payments domain is the only writer of the ledger. Public webhooks -(Robokassa/VK) terminate at the edge (Caddy/gateway) and proxy into payments; TG +(YooKassa/VK) terminate at the edge (Caddy/gateway) and proxy into payments; TG `successful_payment` reaches the bot, which forwards into payments. One place credits and dedupes. **Order-flow.** The server pre-creates an `order(pending)` with account / platform / pack / -expected amount / origin. The `order_id` is threaded to the provider (Robokassa `InvId` / TG -`invoice_payload` / VK `item` — confirm the exact VK field at integration). The callback -matches by `order_id` (never by amount, so equal-amount collisions cannot happen), verifies -the amount, credits, marks `paid`. **Idempotency:** dedup by `(provider, provider_payment_id)`. +expected amount / origin. The `order_id` is threaded to the provider (YooKassa `metadata.order_id` / +TG `invoice_payload` / VK `item`). The callback matches by `order_id` (never by amount, so +equal-amount collisions cannot happen), verifies the amount, credits, marks `paid`. +**Idempotency:** dedup by `(provider, provider_payment_id)`. + +**Direct rail (YooKassa).** Opening a purchase is an outbound API call: the server creates the +payment (`POST /v3/payments`, `capture: true`, redirect confirmation, the order id as both the +`Idempotence-Key` and `metadata.order_id`, plus the fiscal receipt of §12) and sends the customer to +the returned `confirmation_url`. The provider's payment id is recorded on the order straight away, +which is what later lets the order be re-checked and refunded. The browser return page +(`/pay/yookassa/return`) is cosmetic: the credit never rides a redirect. + +**The notification is a hint, never evidence (D48).** YooKassa does not sign notifications, so +nothing in the body may be acted on. It only names a payment, which the server then re-reads with +`GET /v3/payments/{id}`; only that answer is trusted. Two guards ride on it: the payment's metadata +must name the order being credited, and its `test` flag must match the shop's, so a test-shop payment +can never credit real chips (nor a live payment be credited against test credentials). The reply +tells the provider whether to redeliver: 200 for anything durably decided, including a duplicate and +a permanent rejection, and 5xx only for a transient failure (YooKassa redelivers for 24 hours). + +The sender address is **not** checked (D48 rev). It would add no security — the confirming read is +the whole boundary — and the one thing it bought, keeping a forger from turning each fabricated +notification into an outbound call of ours, is already bought earlier and far more tightly: the order +is resolved from the notification's metadata **before** any provider call, so an id matching no order +costs one indexed read and stops there, and guessing a live order id means guessing a uuid. An +address check is also actively harmful wherever the deployment cannot observe real client addresses — +a contour behind a tunnel sees only its own — where it rejects genuine notifications. + +**Reconciliation on a short cadence (D49 rev).** No open-ended polling, but the check is **not** tied +to the order lifetime: that governs when an unpaid order is written off, which answers "how long may +a customer take to pay" — a different question from "how soon should we notice a lost callback". +Tying them together would make a customer wait a full lifetime for chips whenever the notification +path fails. The pending-order reaper instead asks the provider about every pending order older than a +minute that carries a payment id, and credits the ones that were in fact paid. That bounds the calls +one order can cause to its lifetime divided by the sweep interval — a handful — while a failure of +the primary path costs minutes rather than half an hour. + +**A declined payment is surfaced.** A YooKassa `payment.canceled` records a `failed` payment event, +so the customer is told the attempt did not go through instead of watching a balance that never +moves. An order simply abandoned records nothing — it just expires, invisibly. **Pending is invisible** to the user; it auto-expires on a timeout (~30 min, DB hygiene). A valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation — @@ -266,10 +313,33 @@ an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (em message). **Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. Refunds are -**admin-triggered** (the E7 console), since no rail pushes an unsolicited refund: Robokassa -refunds run through its refund API / merchant cabinet (auto-polling a rail's refund status is a -deferred worker, not worth it at low chargeback volume), VK refunds are handled by support, and -Telegram Stars refunds are issued with `refundStarPayment`. All of them converge on one engine — +**admin-triggered** (the E7 console). On the direct rail the console does the whole job in one click +(D50): it calls YooKassa's refund API first (`POST /v3/refunds`, the order id as the +`Idempotence-Key`, carrying the refund receipt of §12) and records the reversal only once the money +has actually moved — a failed call records **nothing**, so the ledger can never claim a refund that +did not happen. A refund is recorded **only in status `succeeded`**: one still `pending` can yet be +canceled, and the ledger is append-only, so recording early could revoke a customer's chips for money +that stayed with us. Pressing again is the way out — the idempotency key returns the same refund +rather than paying twice. The recorded refund id is the provider's own, which keeps the ledger +reconcilable against YooKassa's records. + +**The cabinet is a second entry point (D52).** Unlike the earlier rails, YooKassa lets an operator +refund from the merchant cabinet, and such a refund never passes through our API — so the money would +go back while the chips stayed credited. The `refund.succeeded` notification closes that: the refund +is re-read from the API (the body is no more evidence than a payment notification's), bound back to +the order through the payment id we recorded, and reversed through the same engine — idempotent on +`(provider, refund id)`, so the notification for a refund the console already recorded reverses +nothing twice. The engine is **full-refund-only** by design (it revokes exactly what the pack funded +and rejects any other amount), so a **partial** refund is recorded as nothing at all and logged +loudly for an operator: there is no non-arbitrary way to decide how many chips a part-refund costs. + +The two recording paths race, benignly: YooKassa fires `refund.succeeded` the moment the refund is +created, so the notification often writes the reversal before the console's own write lands. Both +name the same refund id, so the second is caught by the idempotency index and nothing is revoked +twice; the console reports the refund as successful rather than as a repeated click. + +VK refunds are still handled by support and Telegram Stars refunds issued with `refundStarPayment`, +both recorded by hand afterwards. All of them converge on one engine — the `Refund` method (`internal/payments`): it matches the paid order, appends a **refund** ledger row (idempotent on `(provider, provider_refund_id)` — the refund id is distinct from the fund's payment id, so the two rows coexist under the same partial-unique index), and **best-effort revokes @@ -348,15 +418,47 @@ origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume `origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0 chips (the by-product grant records the source `product_id` + snapshot) — full audit of rewards. -**Per-user financial report** in the admin console `/_gm` — segment balances, payments, -spends, grants, refunds, full history — as an extension of the existing user card -(`UserDetailView`, `handlers_admin_console.go`). Plus a ledger export. +**The ledger section** `/_gm/ledger` is the all-accounts view of the money: every operation, newest +first, filtered by date range (defaulting to the last 30 days), by **wallet** (`vk`/`telegram`/ +`direct` — matching the funded segment or the benefit origin), by **rail** (the settling provider, so +chip spends drop out by construction), by kind and by account. Above the table sit the totals for +**everything the filter matches**, not merely the page: money in and refunded per currency (the rails +settle in roubles, Votes and Stars, so one sum across them would mean nothing) and chips credited and +spent. The row amounts come from the ledger snapshot, since the ledger's own columns count chips. +Paging, the CSV export and a refund's way back all carry the same filter query, so none of them can +silently show a different slice than the screen. + +The **refund action** lives on the funded rows here — the operator can find a payment by filter +without knowing whose it is first — and returns to the same filtered view afterwards. + +**The user card** carries the account's standing rather than its history: segment balances, benefits, +the refund-risk flag, and a short lifetime summary (money paid and refunded per currency, chips +credited and spent), with a link into the ledger section pre-filtered to that account. One place +renders operations, with filters and paging, instead of two. ## 12. Taxes and compliance Receipts are automatic **through the provider**, and differ by rail: -- **Robokassa** (direct) — self-employed НПД receipt on payment. +- **YooKassa** (direct) — **no receipt is sent, by design (D51 rev)**. The merchant operates on + **НПД**, which is outside 54-ФЗ: there is no online cash register and the provider registers + nothing. Each operation is reported by the merchant to **«Мой налог»**, which issues the чек. + YooKassa does not serve receipts for this regime at all. + + The fiscal code is **kept dormant**, not deleted: «Чеки от ЮKassa» (YooKassa owning the cash + register, the fiscal drive and the OFD contract) registers a receipt **only if the request carries + it**, and that request-building lives behind one switch — `BACKEND_YOOKASSA_VAT_CODE`. Unset, no + `receipt` is built or sent; set to a 54-ФЗ rate code (tag 1199) it goes back to sending one line + (pack title, quantity 1, amount) with the settlement subject `service` (tag 1212) and method + `full_payment` (tag 1214), delivered by email to the D36 anchor. That switch exists because the + return is foreseeable: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back + in force. + + Everything an automated «Мой налог» submission needs is already recorded — the order id, the + provider payment id, the amount and currency, the credit time, the sold-pack snapshot, and refunds + as their own ledger rows, all exportable as CSV. The one thing it will additionally need is a + per-operation "already reported" marker for its own idempotency; that belongs to the submission + work, not here. - **VK** — VK processes Votes through the tax authority itself; nothing to do. - **TG Stars** — no tax side (for a RU self-employed, Stars are not legally withdrawable = not НПД income; accepted, no receipt issued). @@ -366,7 +468,7 @@ confirms the exact НПД scheme with a tax advisor.) ## 13. Distribution (native Android) -- **RuStore** — Robokassa/external gate allowed (0%); native = clean `direct` context. +- **RuStore** — an external payment gate is allowed (0%); native = clean `direct` context. - **Google Play** — direct purchases are **hidden**; the Wallet shows a stub ("install the RuStore build to make purchases"). Rewarded ads and spending already-earned chips still work. Confirm Google's current in-app-currency rules before the GP release. @@ -407,4 +509,4 @@ prod (no purchase flow existed), so legacy values are zeroed. - **value / benefit** — what chips buy (no-ads, hints, tournament fee). - **gate** — the one-directional store-compliance rule (§4). - **ledger** — the append-only record of all money/value operations. -- **rail** — a payment provider (Robokassa / VK Votes / TG Stars). +- **rail** — a payment provider (YooKassa / VK Votes / TG Stars). diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md index f8bf5ba..2671780 100644 --- a/docs/PAYMENTS_DECISIONS_ru.md +++ b/docs/PAYMENTS_DECISIONS_ru.md @@ -281,6 +281,73 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз (нет строки = default; снять = удалить строку). **`allow` обходит ТОЛЬКО ops-рубильник, НЕ security-гейты** (D36 email-якорь, VK-iOS-фриз, trusted-платформа, min-client-version) — те проверяются отдельно в CreateOrder. +- **D47. Direct-рельс переезжает с Robokassa на ЮKassa; Robokassa консервируется, а не удаляется.** + Меняется merchant-отношение, не модель кошельков: сегмент `direct`, стенка трат, origin бенефитов, + мультимагазинность по каналу (D42) и `shop` в заказе (D44) — без изменений. Код Robokassa, её тесты + и проводка остаются в дереве, а direct-рельс **откатывается на неё**, если ни один магазин ЮKassa не + настроен, — значит возврат к Robokassa это смена кредов, а не правка кода. Переменные Robokassa + убраны из CI/compose/деплоя и записаны в `backend/internal/robokassa/README.md` вместе с + настройками кабинета и порядком возврата. Строки журнала с `provider = 'robokassa'` не трогаем: + литерал несущий для индекса идемпотентности. Telegram **не трогаем** — там реальными деньгами за + цифровые товары платить нельзя, рельс остаётся на Stars; префиксы переменных заводим на магазин + сразу, чтобы будущий магазин (например, android/RuStore) добавлялся аддитивно. +- **D48. Уведомления ЮKassa не подписаны → подтверждающее чтение обязательно.** В отличие от + Robokassa (подпись Password2) и VK (подпись), ЮKassa **не подписывает** вебхуки. Поэтому тело + уведомления — только подсказка: оно называет платёж, который сервер перечитывает запросом + `GET /v3/payments/{id}`, и действует **исключительно** по ответу API. Дополнительно: метаданные + платежа должны называть тот самый заказ; флаг `test` платежа должен совпадать с флагом магазина + (платёж тестового магазина не начислит настоящие Фишки, и наоборот). Ответ провайдеру: 200 на всё + окончательно решённое (включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa + повторяет 24 часа). + **Ревизия: адрес отправителя не проверяем.** Изначально сверяли с опубликованными диапазонами + ЮKassa как эшелонированную защиту. Оказалось лишним и вредным: единственное, что она давала — + не дать подделывателю превратить фальшивое уведомление в наш исходящий запрос — уже обеспечено + раньше и жёстче, потому что заказ ищется по метаданным ДО обращения к провайдеру (несуществующий + order_id стоит одного чтения по индексу; угадать живой — значит угадать uuid). А на тестовом + контуре, который за туннелем видит только свой внутренний адрес, проверка отбивала настоящие + уведомления — ровно как IP-баны, сделанные в репозитории prod-only по той же причине. +- **D49. Сверка — без постоянного опроса, но с коротким шагом (ревизия).** «Или» в документации + ЮKassa адресовано тем, кто не хочет вебхуки; у нас вебхуки основные. Но безвозвратно потерянное + уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому существующий + жнец спрашивает провайдера о судьбе незакрытых заказов с идентификатором платежа и начисляет реально + оплаченные; отдельный воркер не заводим. + **Ревизия порога:** сперва проверка была привязана к возрасту истечения заказа (30 минут), и это + была ошибка — время жизни заказа отвечает на вопрос «сколько покупателю позволено думать», а не «как + быстро заметить потерянный колбэк». На практике это дало покупателю 30 минут ожидания Фишек при + первом же сбое доставки. Порог развязан: проверяем заказы старше **минуты**, на каждом тике жнеца. + Ограниченность сохраняется — запросов на заказ не больше, чем время его жизни, делённое на шаг + жнеца. +- **D50. Возвраты на direct-рельсе — через API ЮKassa, одним действием.** Кнопка возврата в `/_gm` + сперва двигает деньги (`POST /v3/refunds`, `Idempotence-Key` = идентификатор заказа, с чеком + возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет **ничего** — журнал не + может заявить о возврате, которого не было. Реверс пишется **только при статусе `succeeded`**: + возврат в `pending` ещё может отмениться, а журнал только на добавление, поэтому ранняя запись + отобрала бы у покупателя Фишки за деньги, оставшиеся у нас; выход — нажать ещё раз, ключ + идемпотентности вернёт тот же возврат. Записывается собственный refund-id провайдера (сверка с + данными ЮKassa). VK и TG Stars — как раньше: деньги руками, запись фактом. +- **D52. Обрабатываем `refund.succeeded`: кабинет — вторая точка входа для возврата.** В отличие от + прежних рельсов, у ЮKassa возврат можно оформить прямо в кабинете магазина, минуя наш API, — тогда + деньги ушли бы назад, а Фишки остались бы начисленными, и молча. Поэтому подписываемся на событие и + проводим возврат тем же движком: возврат перечитывается из API (тело уведомления — не доказательство, + как и у платежа), привязывается к заказу через записанный идентификатор платежа, идемпотентность по + `(провайдер, refund-id)` делает уведомление о возврате, уже записанном консолью, пустой операцией. + **Частичный возврат не записываем вовсе** — движок по устройству работает только с полной суммой + заказа, а непроизвольного способа решить, скольких Фишек стоит часть возврата, нет; вместо записи — + громкий лог для оператора. +- **D51 (ревизия). Чеки не передаём: владелец на НПД, это вне 54-ФЗ.** Уточнено у поддержки ЮKassa: + при НПД провайдер с чеками не работает. Онлайн-кассы нет, `receipt` в запросах не отправляется, о + каждой операции владелец сообщает в **«Мой налог»**, он и формирует чек. Побочный выигрыш: исчезает + целый класс отказов — некорректный `receipt` был ошибкой API прямо при создании платежа, то есть + ломал покупку. + **Код чеков при этом консервируем, а не удаляем** (решение владельца): вся сборка `receipt` живёт за + одним переключателем `BACKEND_YOOKASSA_VAT_CODE` — пусто (дефолт) значит «не отправлять», код ставки + по 54-ФЗ (тег 1199) возвращает прежнее поведение: одна позиция, признак предмета расчёта `service` + (тег 1212), способ расчёта `full_payment` (тег 1214), доставка на email-якорь D36, + `tax_system_code` не шлём. Возврат к чекам предсказуем: у НПД годовой потолок дохода 2,4 млн ₽, и + его превышение возвращает 54-ФЗ — тогда это правка переменной, а не кода. + Связка с «Мой налог» — **отдельная задача**. Всё нужное для неё уже хранится (идентификатор заказа + и платежа провайдера, сумма, валюта, время зачисления, снимок пакета, возвраты); не хватает только + отметки «операция уже отправлена» для идемпотентности выгрузки — её заводит та задача. ## Заметки к оформлению документов @@ -296,7 +363,7 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить feedback-память `prefer-interview-mode` после plan mode. -## Все развилки закрыты (D1-D46) +## Все развилки закрыты (D1-D52) Интервью завершено. Дальше — оформление документов и реализация по релизам. @@ -307,6 +374,23 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация (B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца). +**Дополнение 2026-07-28 (интервью с владельцем).** Direct-рельс переезжает на **ЮKassa**; добавлены +D47-D51 — консервация Robokassa с откатом по кредам, подтверждающее чтение вместо подписи уведомления, +сверка на истечении заказа, возвраты через API и фискализация через «Чеки от ЮKassa» с отправкой +`receipt` из кода (ревизия D41, этап E12 в `PLAN.md`). Telegram из объёма исключён: реальными +деньгами за цифровые товары в Mini App платить нельзя, рельс остаётся на Stars. Отдельно уточнено: +у ЮKassa **есть** тестовый режим (отдельный тестовый магазин со своими креды и тестовыми картами), +поэтому весь путь проверяется на тестовом контуре без реальных денег. + +**Уточнение по возвратам (владелец, 2026-07-28).** D50 дополнена требованием статуса `succeeded`; +добавлена D52 — обработка `refund.succeeded`, потому что кабинет ЮKassa позволяет вернуть деньги +мимо нашего API. Обе правки вошли в тот же PR, что и переход на рельс. + +**Уточнение по фискализации (владелец, 2026-07-28).** D51 ревизована: владелец принимает платежи как +ИП на **НПД**, при котором ЮKassa с чеками не работает (подтверждено поддержкой), поэтому `receipt` +не передаётся вовсе, а отчётность идёт через «Мой налог». Код чеков законсервирован за переменной +`BACKEND_YOOKASSA_VAT_CODE` на случай потери режима. Правка вошла в тот же PR. + ## План внедрения (черновик PLAN.md — «слоями») Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index cc1bdb2..5d3e2cf 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -23,7 +23,7 @@ Валюта **двухуровневая**: ``` -деньги (Голоса VK / Stars TG / рубли через Robokassa) ─┐ +деньги (Голоса VK / Stars TG / рубли через ЮKassa) ─┐ ├─► Фишки ──► ценности просмотр ролика за награду ─┘ (без рекламы, подсказки, @@ -46,7 +46,7 @@ |------------|------------------------------------| | `vk` | Покупка за Голоса VK, ролик за награду в VK | | `telegram` | Покупка за Stars TG | -| `direct` | Покупка через Robokassa (web / native) | +| `direct` | Покупка через ЮKassa (web / native) | Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, до трёх записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент @@ -54,17 +54,27 @@ Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только -в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне +в контексте VK; Stars — только внутри Telegram; Фишки от ЮKassa (`direct`) — только вне сторов. См. §4. **Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин -Robokassa **на канал** — `web` и `android` (RuStore), позже `ios` — по трастовому подтипу +ЮKassa **на канал** — `web` и `android` (RuStore), позже `ios` — по трастовому подтипу `X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет). Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в -админ-отчёте), а Result-колбэк каждого магазина проверяется своим Password2 по -`/pay/robokassa/result/`. В standalone-приложениях (Android/iOS) вход только по email, поэтому -у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под -одним ИП) — один источник независимо от числа магазинов (D41). +админ-отчёте). Точка приёма уведомлений у всех магазинов одна — `/pay/yookassa/notify`; входящее +уведомление привязывается к магазину по идентификатору магазина, который сообщает сам платёж +(`recipient.account_id`), с откатом на канал, записанный в заказе, и подтверждающее чтение (§9) +выполняется кредами именно этого магазина. В standalone-приложениях (Android/iOS) вход только по +email, поэтому у direct-покупки всегда есть email-якорь D36 (D43) — он же адрес доставки чека. +Фискализация (54-ФЗ под одним ИП) — один источник независимо от числа магазинов (D41). + +**Robokassa законсервирована, но не удалена (D47).** До ЮKassa она обслуживала рельс `direct`. Код, +тесты и проводка остаются в дереве, и рельс откатывается на неё, если ни один магазин ЮKassa не +настроен, — поэтому возврат к ней это смена кредов, а не правка кода; сегодня эти креды не задаёт ни +один контур. `backend/internal/robokassa/README.md` хранит список выведенных переменных, настройки +кабинета и порядок возврата рельса. Строки журнала, записанные при её жизни, сохраняют +`provider = 'robokassa'` — этот литерал несущий для индекса идемпотентности, его не переименовывают и +не переиспользуют. **Рубильник платежей (D45/D46).** Оператор выключает покупки на рельсе/канале (`direct:web` / `direct:android` / `vk` / `telegram`) или для одного аккаунта, живьём из `/_gm`, и юзер на следующей @@ -217,21 +227,56 @@ durable). ## 9. Приём платежей -**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному** -(подпись/HMAC) серверному колбэку — Robokassa webhook / TG `successful_payment` / VK -callback. Клиентское «я оплатил» игнорируется. +**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному** серверному +колбэку — уведомление ЮKassa / TG `successful_payment` / VK callback. Клиентское «я оплатил» +игнорируется. Что значит «проверенный», зависит от рельса: VK и Telegram подписывают свои колбэки, +а **ЮKassa — нет** (D48), см. подтверждающее чтение ниже. **Единственный писатель.** Один платёжный домен — единственный, кто пишет в журнал операций. -Публичные вебхуки (Robokassa/VK) терминируются на краю (Caddy/gateway) и проксируются в +Публичные вебхуки (ЮKassa/VK) терминируются на краю (Caddy/gateway) и проксируются в платёжный домен; TG `successful_payment` приходит боту, тот форвардит в платёжный домен. Одно место начисляет и защищает от повторов. **Флоу заказа.** Сервер заранее создаёт `order(pending)` с account / платформой / пакетом / -ожидаемой суммой / origin. `order_id` прокидывается провайдеру (Robokassa `InvId` / TG -`invoice_payload` / VK `item` — точную форму VK уточнить при интеграции). Колбэк матчится по -`order_id` (никогда по сумме, поэтому коллизии одинаковых сумм невозможны), сверяет сумму, -начисляет, помечает `paid`. **Защита от повторов:** дедуп по `(провайдер, -provider_payment_id)`. +ожидаемой суммой / origin. `order_id` прокидывается провайдеру (ЮKassa `metadata.order_id` / TG +`invoice_payload` / VK `item`). Колбэк матчится по `order_id` (никогда по сумме, поэтому коллизии +одинаковых сумм невозможны), сверяет сумму, начисляет, помечает `paid`. **Защита от повторов:** +дедуп по `(провайдер, provider_payment_id)`. + +**Direct-рельс (ЮKassa).** Открытие покупки — исходящий вызов API: сервер создаёт платёж +(`POST /v3/payments`, `capture: true`, подтверждение через redirect, идентификатор заказа и как +`Idempotence-Key`, и как `metadata.order_id`, плюс фискальный чек из §12) и отправляет покупателя на +полученный `confirmation_url`. Идентификатор платежа сразу записывается в заказ — именно он позволяет +потом перепроверить заказ и оформить возврат. Страница возврата браузера (`/pay/yookassa/return`) +косметическая: начисление никогда не едет на редиректе. + +**Уведомление — подсказка, а не доказательство (D48).** ЮKassa не подписывает уведомления, поэтому +действовать по содержимому тела нельзя. Оно лишь называет платёж, который сервер затем перечитывает +запросом `GET /v3/payments/{id}`; доверяем только этому ответу. На нём же держатся две проверки: +метаданные платежа должны называть тот самый заказ, а его флаг `test` — совпадать с флагом магазина, +поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). Ответ сообщает +провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и неустранимый +отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа). + +Адрес отправителя **не проверяем** (ревизия D48). Безопасности это не добавляет — вся граница доверия +в подтверждающем чтении, — а единственное, что такая проверка давала (чтобы подделыватель не +превращал каждое фальшивое уведомление в наш исходящий запрос), уже обеспечено раньше и жёстче: заказ +находится по метаданным **до** любого обращения к провайдеру, поэтому идентификатор, которому не +соответствует ни один заказ, стоит одного чтения по индексу и на этом всё, а угадать живой order_id — +значит угадать uuid. Вдобавок проверка адреса вредна везде, где развёртывание не видит настоящих +адресов клиентов (контур за туннелем видит только свой), — там она отбивает настоящие уведомления. + +**Сверка с коротким шагом (ревизия D49).** Бесконечного опроса нет, но проверка **не привязана** к +времени жизни заказа: оно отвечает на вопрос «сколько покупателю позволено думать», а не «как быстро +мы должны заметить потерянный колбэк». Связав их, мы заставили бы покупателя ждать Фишки всё время +жизни заказа всякий раз, когда ломается доставка уведомлений. Вместо этого жнец спрашивает провайдера +о каждом незакрытом заказе старше минуты, у которого есть идентификатор платежа, и начисляет реально +оплаченные. Число запросов на один заказ при этом ограничено его временем жизни, делённым на шаг +жнеца, — единицы, — а сбой основного пути стоит минут, а не получаса. + +**Об отклонённой оплате сообщаем.** `payment.canceled` от ЮKassa пишет событие `failed`, поэтому +покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто +брошенный заказ не пишет ничего — он молча истекает. **Pending невидим** пользователю; авто-истекает по таймауту (~30 мин, гигиена базы). Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена — @@ -261,9 +306,33 @@ at-least-once + идемпотентный приём (дедуп по `telegram бота). **Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует -админ** (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК -(авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через -поддержку, TG Stars — вызовом `refundStarPayment`. Все сходятся на одном движке — метод `Refund` +админ** (консоль E7). На direct-рельсе консоль делает всю работу одним нажатием (D50): сперва +вызывает refund-API ЮKassa (`POST /v3/refunds`, `Idempotence-Key` — идентификатор заказа, с чеком +возврата из §12) и записывает реверс только после того, как деньги действительно ушли, — неудачный +вызов не пишет **ничего**, поэтому журнал не может заявить о возврате, которого не было. Возврат +записывается **только в статусе `succeeded`**: ещё не завершённый (`pending`) может отмениться, а +журнал только на добавление, поэтому ранняя запись отобрала бы у покупателя Фишки за деньги, оставшиеся у +нас. Выход — нажать ещё раз: ключ идемпотентности вернёт тот же возврат, а не заплатит дважды. +Записывается собственный refund-id провайдера: по нему журнал сверяется с данными ЮKassa. + +**Кабинет — вторая точка входа (D52).** В отличие от прежних рельсов, ЮKassa позволяет оформить +возврат прямо в кабинете магазина, и такой возврат не проходит через наш API — деньги ушли бы назад, +а Фишки остались бы начисленными. Это закрывает уведомление `refund.succeeded`: возврат +перечитывается из API (тело уведомления — такое же не-доказательство, как и у платежа), привязывается +к заказу через идентификатор платежа, который мы записали, и проводится тем же движком — +идемпотентно по `(провайдер, refund-id)`, поэтому уведомление о возврате, уже записанном консолью, +ничего не отзывает повторно. Движок по устройству работает **только с полным возвратом** (отзывает +ровно то, что профондировал пакет, и отвергает любую другую сумму), поэтому **частичный** возврат не +записывается вовсе и громко логируется для оператора: непроизвольного способа решить, скольких Фишек +стоит часть возврата, нет. + +Два пути записи при этом безобидно гоняются: ЮKassa шлёт `refund.succeeded` сразу после создания +возврата, поэтому уведомление часто записывает реверс раньше, чем это успевает сделать сама консоль. +Оба называют один и тот же refund-id, поэтому вторая запись отсекается индексом идемпотентности и +ничего не отзывается дважды; консоль при этом сообщает об успешном возврате, а не о повторном нажатии. + +Возвраты VK по-прежнему через поддержку, TG Stars — вызовом `refundStarPayment`, оба фиксируются +руками после факта. Все сходятся на одном движке — метод `Refund` (`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по `(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с @@ -341,15 +410,46 @@ in-process кэш сегментов и бенефитов по ключу-ак типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) — полный аудит наград. -**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты, -гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`, -`handlers_admin_console.go`). Плюс экспорт журнала. +**Раздел журнала** `/_gm/ledger` — общий вид на деньги по всем аккаунтам: все операции, новые +сверху, с фильтрами по диапазону дат (по умолчанию последние 30 дней), по **кошельку** +(`vk`/`telegram`/`direct` — совпадение по пополненному сегменту или по origin бенефита), по +**рельсу** (провайдер, который провёл платёж, поэтому траты Фишек в такой фильтр не попадают вовсе), +по виду операции и по аккаунту. Над таблицей — итоги по **всему, что попало под фильтр**, а не по +странице: пришло и возвращено денег по каждой валюте отдельно (рельсы считают в рублях, Голосах и +Stars, общая сумма по ним не значила бы ничего) и начислено/списано Фишек. Суммы в строках берутся из +снимка операции, потому что собственные колонки журнала считают Фишки, а не деньги. Пагинация, +выгрузка в CSV и возврат оператора после refund несут одну и ту же строку фильтров, поэтому ни один +из них не может незаметно показать срез, отличный от экрана. + +**Кнопка возврата** живёт на строках пополнения здесь же — оператор может найти платёж фильтрами, не +зная заранее, чей он, — и после возврата возвращает на тот же отфильтрованный вид. + +**Карточка пользователя** показывает положение дел, а не историю: балансы сегментов, бенефиты, флаг +риска и краткую сводку за всё время (сколько заплачено и возвращено по валютам, сколько Фишек +начислено и потрачено), плюс ссылку в раздел журнала, уже отфильтрованный по этому аккаунту. +Операции рисуются в одном месте, с фильтрами и страницами, а не в двух. ## 12. Налоги и комплаенс Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу: -- **Robokassa** (direct) — чек НПД самозанятого при оплате. +- **ЮKassa** (direct) — **чек не передаём, так задумано (ревизия D51)**. Владелец работает на + **НПД**, а это вне 54-ФЗ: онлайн-кассы нет, провайдер ничего не регистрирует. О каждой операции + владелец сообщает в **«Мой налог»**, он и формирует чек. ЮKassa при таком режиме налогообложения с + чеками не работает вовсе. + + Фискальный код **законсервирован, а не удалён**: «Чеки от ЮKassa» (касса, фискальный накопитель и + договор с ОФД на стороне ЮKassa) регистрируют чек **только если запрос его несёт**, и вся сборка + такого запроса спрятана за одним переключателем — `BACKEND_YOOKASSA_VAT_CODE`. Пусто — `receipt` не + собирается и не отправляется; задан код ставки по 54-ФЗ (тег 1199) — снова уходит одна позиция + (название пакета, количество 1, сумма) с признаком предмета расчёта `service` (тег 1212) и способа + расчёта `full_payment` (тег 1214), доставка на email-якорь D36. Переключатель нужен потому, что + возврат к чекам предсказуем: у НПД есть годовой потолок дохода, и потеря режима возвращает 54-ФЗ. + + Всё, что потребуется автоматической отправке в «Мой налог», уже записано — идентификатор заказа, + идентификатор платежа провайдера, сумма и валюта, время зачисления, снимок проданного пакета и + возвраты отдельными строками журнала, всё выгружается в CSV. Не хватать будет только отметки «эта + операция уже отправлена» для идемпотентности самой выгрузки; ей место в той задаче, а не здесь. - **VK** — VK сам процессит Голоса через налоговую; делать нечего. - **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не доход НПД; принимаем, чек не формируем). @@ -359,7 +459,7 @@ in-process кэш сегментов и бенефитов по ключу-ак ## 13. Дистрибуция (native Android) -- **RuStore** — Robokassa/внешний гейт разрешён (0%); native = чистый контекст `direct`. +- **RuStore** — внешний платёжный гейт разрешён (0%); native = чистый контекст `direct`. - **Google Play** — direct-покупки **скрыты**; «Кошелёк» показывает заглушку («установите версию из RuStore для покупок»). Ролик за награду и трата уже накопленных Фишек работают. Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте. @@ -403,4 +503,4 @@ legacy-значения обнуляются. взнос). - **гейт** — одностороннее правило комплаенса сторов (§4). - **журнал операций (ledger)** — неизменяемая запись всех операций с деньгами/ценностями. -- **платёжный канал / рельса** — платёжный провайдер (Robokassa / Голоса VK / Stars TG). +- **платёжный канал / рельса** — платёжный провайдер (ЮKassa / Голоса VK / Stars TG). diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 9bbb2c9..ed25706 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -514,6 +514,16 @@ func (c *Client) RobokassaResult(ctx context.Context, channel string, params map return out.Response, err } +// YooKassaNotify forwards a YooKassa webhook body verbatim to the backend intake (the single writer, +// which re-reads the payment from the provider before acting on it — YooKassa does not sign +// notifications). The backend answers 200 for anything durably decided and 5xx only for a transient +// failure, so a nil error means the provider may stop redelivering. +// clientIP is the true sender address, which only the gateway sees; the backend checks it against +// YooKassa's published sender ranges. +func (c *Client) YooKassaNotify(ctx context.Context, clientIP string, body json.RawMessage) error { + return c.do(ctx, http.MethodPost, "/api/v1/internal/payments/yookassa/notify", "", clientIP, body, nil) +} + // VKCallback forwards a gateway-verified VK payment callback's parameters to the backend intake and // returns the backend's raw VK response envelope (`{"response":…}` or `{"error":…}`) for the gateway // to relay to VK verbatim. The backend answers 200 in every case (VK's protocol), so a transport diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index c789790..d4e09b2 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -273,15 +273,20 @@ func (s *Server) HTTPHandler() http.Handler { // through this session-gated route (not public); see dictBytesHandler. mux.Handle("/dict/", s.dictBytesHandler()) mux.Handle("/dl/", s.exportDownloadHandler()) - // Direct-rail (Robokassa) return + callback routes: the server Result callback (the single - // crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. The - // per-shop callback rides a channel suffix (/pay/robokassa/result/); the bare path is - // the legacy web shop. Caddy's /pay/* matcher already forwards both, so no Caddyfile change. + // Direct-rail return + callback routes: the server notification (the single crediting signal, + // proxied to the backend intake) and the browser return page. Caddy's /pay/* matcher already + // forwards every path here, so a new route needs no Caddyfile change. + mux.Handle("/pay/yookassa/notify", s.yookassaNotifyHandler()) + mux.Handle("/pay/yookassa/return", s.paymentReturnHandler("Оплата принята.")) + mux.Handle("/pay/vk/callback", s.vkCallbackHandler()) + // The retired Robokassa rail. Its routes stay registered — the backend simply has no shop to + // verify against — so reviving the rail is a credentials change, not a code change, and the + // edge probe keeps proving /pay/* reaches the gateway. The per-shop callback rides a channel + // suffix (/pay/robokassa/result/); the bare path is the legacy web shop. mux.Handle("/pay/robokassa/result", s.robokassaResultHandler()) mux.Handle("/pay/robokassa/result/", s.robokassaResultHandler()) - mux.Handle("/pay/vk/callback", s.vkCallbackHandler()) - mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята.")) - mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена.")) + mux.Handle("/pay/robokassa/success", s.paymentReturnHandler("Оплата принята.")) + mux.Handle("/pay/robokassa/fail", s.paymentReturnHandler("Оплата не завершена.")) // The client posts its local-move-preview adoption telemetry here (session-gated). mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) // The index.html boot guard beacons here when it turns a client away on the unsupported-engine @@ -691,6 +696,45 @@ func (s *Server) robokassaResultHandler() http.Handler { }) } +// maxNotifyBytes caps the provider notification body the gateway will read and forward. +const maxNotifyBytes = 1 << 20 + +// yookassaNotifyHandler proxies a YooKassa webhook to the backend intake (the single writer). It +// rate-limits per IP and forwards the raw JSON body together with the true sender address, which +// only this hop sees; the backend checks that address against YooKassa's published sender ranges and +// then re-reads the payment from the provider, because YooKassa does not sign notifications. +// +// The backend answers 200 for anything durably decided and 5xx for a transient failure, and that is +// relayed as-is: YooKassa retries a notification for 24 hours until it gets a 200. +func (s *Server) yookassaNotifyHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.backend == nil { + http.NotFound(w, r) + return + } + ip := peerIP(r.RemoteAddr, r.Header) + if !s.limiter.Allow("public:"+ip, s.publicPolicy) { + s.noteRateLimited(r.Context(), classPublic, ip, "yookassa-notify") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxNotifyBytes)) + if err != nil || !json.Valid(body) { + // Rejected here rather than proxied: a body that is not JSON cannot become valid on a + // redelivery, and forwarding it would only make the backend answer 5xx forever. + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if err := s.backend.YooKassaNotify(r.Context(), ip, body); err != nil { + s.log.Warn("yookassa notify proxy failed", zap.Error(err)) + http.Error(w, "not accepted", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("ok")) + }) +} + // vkCallbackHandler proxies a VK Mini Apps payment callback to the backend intake. It rate-limits // per IP, verifies the VK signature with the app's protected key (the backend holds no VK secret), // forwards the provider's parameters, and relays the backend's VK response envelope. A missing or @@ -731,12 +775,12 @@ func (s *Server) vkCallbackHandler() http.Handler { }) } -// robokassaReturnHandler serves a minimal self-closing page for Robokassa's Success/Fail browser +// paymentReturnHandler serves a minimal self-closing page for a direct-rail provider's browser // return. The payment opens in a separate window, so on return this closes that window and drops the // customer back into the live app (never a cold app start); a link is the fallback if the window -// cannot self-close. The credit rides the server Result callback, never this redirect — the wallet +// cannot self-close. The credit rides the server notification, never this redirect — the wallet // updates in place from the payment push, with a return-focus refetch as the fallback. -func (s *Server) robokassaReturnHandler(message string) http.Handler { +func (s *Server) paymentReturnHandler(message string) http.Handler { page := []byte(` Оплата diff --git a/ui/e2e/wallet.spec.ts b/ui/e2e/wallet.spec.ts index c10935e..45c799e 100644 --- a/ui/e2e/wallet.spec.ts +++ b/ui/e2e/wallet.spec.ts @@ -67,7 +67,7 @@ test('wallet: buying a chip pack opens the provider payment page', async ({ page await page.locator('[data-kind="pack"]').getByTestId('buy-pack').click(); await expect .poll(() => page.evaluate(() => (window as { __opened?: string }).__opened)) - .toContain('robokassa'); + .toContain('yookassa'); }); test('wallet: the tab sits between Friends and About', async ({ page }) => { diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 170da9a..d97632b 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -46,7 +46,14 @@ const DIST = 'dist'; // rejected, since a repeated word is a real word and the board alone cannot say why the play is // refused. The rule's own logic — the played-word set and the rescoring — sits with the evaluator in // the lazy dict chunk. Scoped CSS lands in the CSS chunk, not this JS budget. -const BUDGET = { app: 131, shared: 31, landing: 5 }; +// +// The shared chunk (svelte runtime + i18n) was raised from 31 to 32 for the direct rail's email +// notice: the wallet has to explain, before the buy tap, that a money purchase needs a confirmed +// email — the server refuses the order otherwise, and a player signed in through VK or Telegram in a +// browser would otherwise meet a bare error. Every user-visible string lands here, so the chunk had +// been sitting 40 bytes under its cap; the raise restores a working margin rather than paying for +// this one string. +const BUDGET = { app: 131, shared: 32, landing: 5 }; // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // local file (e.g. the Telegram SDK loaded from a CDN) or is missing. diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 2b37159..9a00000 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -265,6 +265,7 @@ export const en = { 'wallet.iosBlockedPost': ' of the game.', 'wallet.gpStub': 'To buy chips, install the RuStore build.', 'wallet.purchasesSoon': 'Purchases will be available in a future update.', + 'wallet.emailToBuy': 'To buy chips, add an email in your profile.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'votes', 'wallet.cur.XTR': '⭐', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index c698eee..1f985bc 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -260,6 +260,7 @@ export const ru: Record = { 'wallet.iosBlockedPost': ' игры.', 'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.', 'wallet.purchasesSoon': 'Покупки появятся в одном из следующих обновлений.', + 'wallet.emailToBuy': 'Чтобы покупать фишки, привяжите почту в профиле.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'голосов', 'wallet.cur.XTR': '⭐', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 1d763c8..7a4f9d1 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -239,7 +239,7 @@ export class MockGateway implements GatewayClient { if (!p || p.kind !== 'pack') throw new GatewayError('not_a_pack'); // No real provider in mock: return a stub launch URL so the e2e can assert the purchase reaches // the provider hand-off without leaving the app. - return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId }; + return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/yookassa?order=' + productId }; } async walletReward(_nonce: string): Promise { diff --git a/ui/src/lib/wallet.test.ts b/ui/src/lib/wallet.test.ts index c958be6..1881ed4 100644 --- a/ui/src/lib/wallet.test.ts +++ b/ui/src/lib/wallet.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { Wallet, WalletSegment } from './model'; -import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning } from './wallet'; +import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning, directPurchaseNeedsEmail } from './wallet'; function seg(source: string, chips: number, spendable = true): WalletSegment { return { source, chips, spendable }; @@ -61,3 +61,18 @@ describe('needsWebSpendWarning', () => { expect(needsWebSpendWarning('direct', [seg('vk', 400, false)], 100)).toBe(false); }); }); + +describe('directPurchaseNeedsEmail', () => { + it('asks for an email on the direct rail when the account has none', () => { + expect(directPurchaseNeedsEmail('direct', '')).toBe(true); + }); + + it('stays quiet once the account has a confirmed email', () => { + expect(directPurchaseNeedsEmail('direct', 'player@example.com')).toBe(false); + }); + + it('never asks inside VK or Telegram, where the store rail carries its own identity', () => { + expect(directPurchaseNeedsEmail('vk', '')).toBe(false); + expect(directPurchaseNeedsEmail('telegram', '')).toBe(false); + }); +}); diff --git a/ui/src/lib/wallet.ts b/ui/src/lib/wallet.ts index 8b079bf..71fd5bb 100644 --- a/ui/src/lib/wallet.ts +++ b/ui/src/lib/wallet.ts @@ -50,6 +50,18 @@ export function spendableChips(w: Wallet): number { // segment first, then the store-funded segments. const drawPriority: readonly string[] = ['direct', 'vk', 'telegram']; +/** + * directPurchaseNeedsEmail reports whether the money storefront must ask for an email before it can + * sell anything. The direct rail funds only an account with a confirmed email — the recovery anchor, + * without which a paying customer who loses the account loses the chips with it — and the server + * refuses the order without one. Inside VK or Telegram the purchase settles on that store's own rail, + * which carries its own identity, so nothing is asked for there and spending already-earned chips is + * never affected. + */ +export function directPurchaseNeedsEmail(context: SpendContext, email: string): boolean { + return context === 'direct' && email === ''; +} + /** * needsWebSpendWarning reports whether buying a chip-priced value in the current context would draw * store-funded (vk / telegram) chips — the case the UI must warn about, because the benefit it buys diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 2b5c2c8..a5e705e 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -2,10 +2,11 @@ import { onMount } from 'svelte'; import Modal from '../components/Modal.svelte'; import { app, handleError, showToast } from '../lib/app.svelte'; + import { navigate } from '../lib/router.svelte'; import { gateway } from '../lib/gateway'; import { normalizeSubtype } from '../lib/platform'; import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte'; - import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet'; + import { directPurchaseNeedsEmail, executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet'; import { isGooglePlayBuild, purchasesHidden } from '../lib/distribution'; import { onExternalLinkClick, onInAppPageLinkClick, openExternalUrl } from '../lib/links'; import { gatewayOrigin } from '../lib/origin'; @@ -47,6 +48,9 @@ // muted and taps explain why, rather than hiding the storefront. VK's device family is not on the // client platformSubtype (server-derived) — read it from the signed vk_platform launch param. const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios'; + // The server enforces the direct rail's email anchor; showing the rule here means a player signed + // in through VK or Telegram in a browser reads it instead of tapping Buy and getting a bare error. + const needsEmail = $derived(directPurchaseNeedsEmail(context, app.profile?.email ?? '')); // The "other version" link points at this deployment's own site root (the landing lists every // platform), so it follows prod vs the contour without a hardcoded host. const siteRoot = `${gatewayOrigin()}/`; @@ -254,6 +258,10 @@ {:else}

{t('wallet.purchasesSoon')}

{/if} + {:else if needsEmail} +

+ +

{:else} {#each packs as p (p.productId)}
@@ -429,6 +437,17 @@ border: 1px dashed var(--border); border-radius: var(--radius-sm); } + /* The whole explanation is the call to action, so the line itself routes to the profile. */ + .hintlink { + background: none; + border: none; + padding: 0; + color: var(--accent); + font: inherit; + text-align: left; + text-decoration: underline; + cursor: pointer; + } .offer { margin: 2px 0 0; text-align: center;