feat(payments): settle the direct rail through YooKassa
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Replace Robokassa with YooKassa as the RUB direct-rail provider. The wallet
model is untouched: one `direct` segment, the same spend wall, the same
per-channel merchant shops (D42) and `shop` on the order (D44).
The two providers are not shaped alike, and that drives the change:
- Opening a purchase is now an outbound API call (`POST /v3/payments`,
single-stage capture, redirect confirmation). The order id is both the
`Idempotence-Key` and `metadata.order_id`, so a retried create cannot mint a
second payment and a notification always resolves to its order.
- YooKassa does NOT sign notifications, so the body is never evidence: it only
names a payment, which is re-read with `GET /v3/payments/{id}`, and only that
answer is acted on. Two guards ride on it — the payment's metadata must name
the order, and its `test` flag must match the shop's, so a test-shop payment
can never credit real chips. The sender address is checked against YooKassa's
published ranges first, which stops a forger turning each fabricated
notification into an outbound call of ours.
- A notification lost for good would leave the money taken and the chips unowed,
silently. The existing pending-order reaper now asks the provider about each
order that reached its expiry age carrying a payment id, and credits the ones
really paid — one request per order over its whole life, not polling.
- `payment.canceled` records a `failed` event, so a declined payment is finally
surfaced to the customer as PAYMENTS.md §9 already specified.
- The admin refund moves the money through `POST /v3/refunds` before recording
anything; a failed call records nothing, so the ledger cannot claim a refund
that did not happen, and the recorded id is the provider's own.
- YooKassa has no cabinet-side generic receipt: «Чеки от ЮKassa» registers one
only if the request carries it, so every payment and refund now sends an
itemized `receipt` to the D36 confirmed email. The VAT rate code is a deploy
variable; the settlement subject and method are constants.
Robokassa is retired, not deleted: the direct rail falls back to it when no
YooKassa shop is configured and no deployment sets its credentials, so reviving
it is a credentials change rather than a code change. Its variables are removed
from compose, .env.example, write-prod-env.sh and the three workflows, and
recorded in backend/internal/robokassa/README.md together with the cabinet
configuration and the revival steps. Ledger rows keep `provider = 'robokassa'`;
that literal is load-bearing for the idempotency index.
No migration and no wire change: `orders.provider_payment_id` already existed,
and the client is rail-agnostic.
Decisions D47-D51 (revising D41) and stage E12 are baked into the docs.
This commit is contained in:
@@ -241,6 +241,29 @@ 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 — plus the published sender-IP
|
||||
allowlist. 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).
|
||||
- **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".
|
||||
- **YooKassa has no cabinet-side generic чек.** Unlike Robokassa's cloud kassa, «Чеки от ЮKassa»
|
||||
registers a receipt **only if the request carries `receipt`** — so the itemized receipt is
|
||||
mandatory code on both payment and refund. `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. A malformed receipt is an API error at payment creation, so it
|
||||
breaks purchases loudly 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
|
||||
|
||||
+14
-12
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,55 @@ 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: sender
|
||||
allowlist, then 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. `payment.canceled`
|
||||
records a `failed` event. 200 = durably decided, 5xx = redeliver.
|
||||
- **Reconcile** — `runOrderReaper` asks the provider about each pending order that reached its expiry
|
||||
age carrying a payment id, and credits the ones really paid (D49). One request per order.
|
||||
- **Refund** — the `/_gm` button calls `POST /v3/refunds` first and records only on success, under the
|
||||
provider's own refund id (D50); a failure records nothing.
|
||||
- **Fiscalization** — «Чеки от ЮKassa» with the `receipt` sent from code (D51), reversing E10's B4:
|
||||
YooKassa has no cabinet-side generic receipt. `BACKEND_YOOKASSA_VAT_CODE` is a deploy variable;
|
||||
the settlement subject/method are constants.
|
||||
- **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, and D36 still gates the rail).
|
||||
|
||||
**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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,17 @@ 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).
|
||||
// It is configurable because the rate is the one receipt attribute that genuinely changes.
|
||||
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 +168,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.VatCodeNone)
|
||||
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 +217,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 +277,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)", 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 +330,18 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// yookassaShop reads a YooKassa shop's credentials from the environment under prefix (e.g.
|
||||
// "BACKEND_YOOKASSA_WEB" → _SHOP_ID / _SECRET_KEY / _TEST). _TEST marks a test shop, which lets the
|
||||
// intake refuse to credit a live payment against test credentials and vice versa. A shop missing
|
||||
// either credential yields a Config the caller drops.
|
||||
func yookassaShop(prefix string) yookassa.Config {
|
||||
return yookassa.Config{
|
||||
ShopID: os.Getenv(prefix + "_SHOP_ID"),
|
||||
SecretKey: os.Getenv(prefix + "_SECRET_KEY"),
|
||||
IsTest: os.Getenv(prefix+"_TEST") == "1",
|
||||
}
|
||||
}
|
||||
|
||||
// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g.
|
||||
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
|
||||
// MerchantLogin yields a zero Config the caller drops.
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
//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
|
||||
// refundCalls counts the refunds actually requested.
|
||||
refundCalls int
|
||||
// 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{}}
|
||||
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/"):
|
||||
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)
|
||||
_ = json.NewEncoder(w).Encode(yookassa.Refund{
|
||||
ID: "refund-1", Status: yookassa.StatusSucceeded, PaymentID: body.PaymentID, Amount: body.Amount,
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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: yookassa.VatCodeNone,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaNotifyRejectsForeignSenders checks the sender allowlist: an address outside YooKassa's
|
||||
// published ranges is refused before any provider call, so a forger cannot make us fan out requests.
|
||||
func TestYooKassaNotifyRejectsForeignSenders(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, "8.8.8.8", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusForbidden {
|
||||
t.Fatalf("notify from a foreign address = %d, want 403", code)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance = %d, want 0 — a foreign sender credited chips", 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 past its lifetime without any notification having been delivered.
|
||||
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 != 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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-1" {
|
||||
t.Errorf("refund ledger row = (%s, %s), want (yookassa, refund-1)", provider, refundID)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaOrderMintsAPaymentWithAReceipt drives the purchase path over HTTP: the order endpoint
|
||||
// calls the provider, threads the order id through, sends the fiscal receipt to the buyer's confirmed
|
||||
// address, and hands the client the hosted payment page.
|
||||
func TestYooKassaOrderMintsAPaymentWithAReceipt(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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -64,6 +64,83 @@ 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)
|
||||
}
|
||||
|
||||
// reconcileBatch bounds one reconcile sweep, so a backlog cannot turn a periodic tick into a long
|
||||
// run of provider calls.
|
||||
const reconcileBatch = 50
|
||||
|
||||
// PendingForReconcile returns the pending orders that have reached their expiry age while carrying 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 before ExpireOrders writes them off.
|
||||
// Orders that never reached a payment are not returned: there is nothing to ask about.
|
||||
func (s *Service) PendingForReconcile(ctx context.Context) ([]OrderRef, error) {
|
||||
ttl, err := s.store.orderTTL(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.store.pendingForReconcile(ctx, ttl, 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).
|
||||
|
||||
@@ -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,60 @@ 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
|
||||
}
|
||||
|
||||
// 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 that have reached their expiry age and 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 before the order is written off as expired.
|
||||
// Orders with no provider payment id are skipped: the customer never got as far as a payment.
|
||||
func (s *Store) pendingForReconcile(ctx context.Context, ttlSeconds int, now time.Time, limit int) ([]orderRow, error) {
|
||||
cutoff := now.Add(-time.Duration(ttlSeconds) * 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
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Robokassa — the retired direct rail
|
||||
|
||||
Robokassa used to settle the **`direct`** (RUB) rail. It was replaced by
|
||||
[YooKassa](../yookassa) and is now **dormant**: the code, its tests and its wiring are all still
|
||||
here, but **no deployment sets its credentials**, so `Shops.Configured()` is false, the Result
|
||||
callback route is never registered, and the direct rail resolves to YooKassa.
|
||||
|
||||
The code is kept because the merchant relationship could come back. This file records the operational
|
||||
knowledge that used to live in `deploy/` and `.gitea/workflows/` and was removed from there so the
|
||||
live configuration stays free of dead variables.
|
||||
|
||||
## How the rail is chosen
|
||||
|
||||
`handleWalletOrder` (`backend/internal/server/handlers_intake.go`) resolves the direct rail in this
|
||||
order:
|
||||
|
||||
1. a configured YooKassa shop for the platform channel → YooKassa;
|
||||
2. otherwise a configured Robokassa shop → **this rail**;
|
||||
3. otherwise `501 rail_unavailable`.
|
||||
|
||||
So reviving Robokassa is a **credentials change, not a code change**: restore the environment block
|
||||
below and the rail comes back on the next deploy. If both providers are configured, YooKassa wins.
|
||||
|
||||
## Environment variables (retired)
|
||||
|
||||
The backend still reads all of these. They were removed from `deploy/docker-compose.yml`,
|
||||
`deploy/.env.example`, `deploy/write-prod-env.sh`, `deploy/README.md` and the three workflows.
|
||||
|
||||
| Backend variable (inside the container) | Meaning |
|
||||
| --- | --- |
|
||||
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | Shop login. Empty ⇒ the shop is dropped. Legacy single-shop form: it seeds the **web** channel. |
|
||||
| `BACKEND_ROBOKASSA_PASSWORD1` | Pass phrase #1 — signs the outgoing payment request. |
|
||||
| `BACKEND_ROBOKASSA_PASSWORD2` | Pass phrase #2 — signs and so verifies the incoming Result callback. |
|
||||
| `BACKEND_ROBOKASSA_TEST` | `1` adds `IsTest=1`, so the shop simulates payments against its **test** pass phrases and no money moves. Empty/`0` is live. |
|
||||
| `BACKEND_ROBOKASSA_WEB_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | The per-channel **web** shop; overrides the legacy set above. |
|
||||
| `BACKEND_ROBOKASSA_ANDROID_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | The per-channel **android** (RuStore) shop (D42). |
|
||||
|
||||
Password3 (Robokassa's JWT-invoice API) was never used.
|
||||
|
||||
The three-step naming this repo uses was: a Gitea secret/variable `PROD_BACKEND_ROBOKASSA_PASSWORD1`
|
||||
→ mapped by the workflow to `ROBOKASSA_PASSWORD1` → mapped by compose to
|
||||
`BACKEND_ROBOKASSA_PASSWORD1` inside the container.
|
||||
|
||||
| Contour | Gitea entries that fed the rail |
|
||||
| --- | --- |
|
||||
| test | secrets `TEST_BACKEND_ROBOKASSA_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2}`; `ROBOKASSA_TEST` was hard-coded to `"1"` in `ci.yaml` so the contour could never take real money whatever the shop's own mode. |
|
||||
| prod | secrets `PROD_BACKEND_ROBOKASSA_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2}`; **variable** `PROD_BACKEND_ROBOKASSA_TEST`, deliberately a variable so go-live was a flag flip rather than a secret rotation. Mirrored in `prod-rollback.yaml` so a rollback did not dark the rail. |
|
||||
|
||||
**Known gap, if the rail is ever revived:** the per-channel `…_WEB_*` / `…_ANDROID_*` variables
|
||||
existed in compose and in `config.go` but were **never** carried by any workflow or by
|
||||
`write-prod-env.sh` — only the four legacy variables reached prod. A revival that wants the
|
||||
multi-shop split must add them to the deploy plumbing too.
|
||||
|
||||
## Cabinet configuration
|
||||
|
||||
Set in the Robokassa ЛКК, per shop:
|
||||
|
||||
- **Result URL** — `https://<host>/pay/robokassa/result/<channel>` (`…/web`, `…/android`). The bare
|
||||
`…/pay/robokassa/result` is the legacy path and is verified as the **web** shop. Method: POST.
|
||||
Each shop's callback is verified only by its own Password2 (`Shops.Verifier` does not fall back).
|
||||
- **Success URL** — `https://<host>/pay/robokassa/success`.
|
||||
- **Fail URL** — `https://<host>/pay/robokassa/fail`.
|
||||
- **Signature algorithm** — SHA-256 (the code hard-codes it; a shop set to MD5 will not verify).
|
||||
- **54-ФЗ** — fiscalization was cabinet-side through Robokassa's cloud cash register (kassa + ОФД +
|
||||
СНО configured by the owner), so no receipt data was ever sent from code. YooKassa does **not**
|
||||
work this way: it registers a receipt only if the request carries one.
|
||||
|
||||
The gateway routes (`gateway/internal/connectsrv/server.go`) are still registered, so the cabinet
|
||||
URLs above stay reachable; only the backend has no shop to verify against.
|
||||
|
||||
## Protocol notes
|
||||
|
||||
- The order id is a UUID and Robokassa's `InvId` is numeric, so the order rides the custom-parameter
|
||||
channel as **`Shp_order`** and `InvId` is always sent as `0`. Idempotency at the credit site is
|
||||
therefore keyed on the order id, not on a provider payment id.
|
||||
- Outgoing signature base: `MerchantLogin:OutSum:InvId:Password1[:Shp_key=value…sorted]`.
|
||||
- Incoming (Result) signature base: `OutSum:InvId:Password2[:Shp_key=value…sorted]`, compared
|
||||
case-insensitively.
|
||||
- The Result callback must be answered with the body `OK<InvId>`, which the gateway echoes verbatim.
|
||||
|
||||
## What is *not* reversible by configuration
|
||||
|
||||
Ledger rows written while this rail was live carry `provider = 'robokassa'`. That literal is
|
||||
load-bearing for the `(provider, provider_payment_id)` idempotency index and must never be renamed or
|
||||
reused for another provider.
|
||||
@@ -3,6 +3,12 @@
|
||||
// an order. It is pure provider glue — no database, no payments-domain coupling — so the payments
|
||||
// domain stays provider-agnostic and this layer is unit-testable in isolation.
|
||||
//
|
||||
// This rail is RETIRED: YooKassa settles the direct rail now, and no deployment sets these
|
||||
// credentials, so the shop set is empty and the rail is dormant. It stays wired as the fallback the
|
||||
// direct rail resolves to when YooKassa has no shop, which makes reviving it a credentials change
|
||||
// rather than a code change. README.md in this directory records the retired environment variables,
|
||||
// the cabinet configuration and how to bring the rail back.
|
||||
//
|
||||
// The order is threaded through Robokassa's custom-parameter channel as Shp_order=<order id> (echoed
|
||||
// back in the callback and bound into the signature), not the numeric InvId, because an order id is
|
||||
// a uuid; InvId is sent as 0. Idempotency is therefore keyed on the order id at the credit site.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"strconv"
|
||||
@@ -9,11 +10,17 @@ import (
|
||||
|
||||
"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 {
|
||||
@@ -29,7 +36,13 @@ 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
|
||||
@@ -46,6 +59,25 @@ func (s *Server) consoleRefund(c *gin.Context) {
|
||||
s.renderConsoleMessage(c, "Refunded", msg, back)
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -15,20 +15,28 @@ import (
|
||||
"scrabble/backend/internal/robokassa"
|
||||
)
|
||||
|
||||
// Ledger/order provider tags per rail.
|
||||
// Ledger/order provider tags per rail. providerRobokassa is retired but never renamed or reused:
|
||||
// historical ledger rows carry it, and it is load-bearing for the (provider, provider_payment_id)
|
||||
// idempotency index.
|
||||
const (
|
||||
providerRobokassa = "robokassa" // the direct (RUB) rail
|
||||
providerYooKassa = "yookassa" // the direct (RUB) rail
|
||||
providerRobokassa = "robokassa" // the retired direct (RUB) rail
|
||||
providerVK = "vk" // the VK Votes rail
|
||||
providerTelegram = "telegram" // the Telegram Stars (XTR) rail
|
||||
)
|
||||
|
||||
// yookassaReturnPath is where YooKassa returns the customer's browser after the hosted payment page.
|
||||
// The credit never rides this redirect — it rides the server notification — so the page only closes
|
||||
// the payment window and drops the customer back into the live app.
|
||||
const yookassaReturnPath = "/pay/yookassa/return"
|
||||
|
||||
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
|
||||
type walletOrderRequest struct {
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
|
||||
// the provider's hosted-payment URL for the direct rail (empty for VK/Telegram, which settle
|
||||
// the provider's hosted-payment page for the direct rail (empty for VK/Telegram, which settle
|
||||
// in-app). Rail names the settling rail so the gateway knows how to launch it; for the Telegram
|
||||
// Stars rail the gateway mints the invoice link from InvoiceTitle and InvoiceAmount (whole stars)
|
||||
// via the bot and returns it in RedirectURL.
|
||||
@@ -41,7 +49,7 @@ type walletOrderResponse struct {
|
||||
}
|
||||
|
||||
// handleWalletOrder opens a pending order to fund a chip pack and returns the rail's launch
|
||||
// details for the client: the Robokassa hosted-payment URL (direct), the order id for
|
||||
// details for the client: the provider's hosted-payment URL (direct), the order id for
|
||||
// VKWebAppShowOrderBox (VK), or the pack title and star amount the gateway mints into a Stars
|
||||
// invoice link (Telegram). It enforces the wallet gate and, on the direct rail, D36 (a purchase
|
||||
// requires a confirmed email anchor). No chips are credited here — only later, by the verified
|
||||
@@ -84,13 +92,20 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
}
|
||||
switch cxt.Kind {
|
||||
case payments.SourceDirect:
|
||||
shop, ok := s.robokassa.Shop(cxt.Subtype)
|
||||
if !ok {
|
||||
// YooKassa settles the direct rail. Robokassa is retired and normally unconfigured, so it is
|
||||
// only reached when YooKassa has no shop for this channel — which is what makes reviving the
|
||||
// old rail a credentials change rather than a code change. The rail is resolved before any
|
||||
// account-level gate, so an unconfigured rail still reads as unavailable rather than as
|
||||
// something the customer could fix.
|
||||
ykShop, onYooKassa := s.yookassa.Shop(cxt.Subtype)
|
||||
rkShop, onRobokassa := s.robokassa.Shop(cxt.Subtype)
|
||||
if !onYooKassa && !onRobokassa {
|
||||
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
|
||||
return
|
||||
}
|
||||
// D36: a direct purchase requires a confirmed email anchor.
|
||||
hasEmail, err := s.accounts.HasConfirmedEmail(ctx, uid)
|
||||
// D36: a direct purchase requires a confirmed email anchor, which is also where the fiscal
|
||||
// receipt is delivered.
|
||||
email, hasEmail, err := s.accounts.ConfirmedEmail(ctx, uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
@@ -99,6 +114,10 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
|
||||
return
|
||||
}
|
||||
if onYooKassa {
|
||||
s.orderYooKassa(c, uid, cxt, present, productID, ykShop, email)
|
||||
return
|
||||
}
|
||||
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
@@ -106,7 +125,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, walletOrderResponse{
|
||||
OrderID: res.OrderID.String(),
|
||||
RedirectURL: shop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
||||
RedirectURL: rkShop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
||||
Rail: providerRobokassa,
|
||||
})
|
||||
case payments.SourceVK:
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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
|
||||
|
||||
// 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), which doubles as the delivery address of the fiscal
|
||||
// receipt this rail must send with every payment. 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()},
|
||||
// «Чеки от ЮKassa»: the receipt is registered only if we send it, so a malformed one is an
|
||||
// API error here rather than a silently missing fiscal document.
|
||||
Receipt: yookassa.SingleItemReceipt(email, res.Title, amount, s.vatCode),
|
||||
}, 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 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). An unrecognised sender is a 403,
|
||||
// which is also redelivered: if YooKassa ever changed its published ranges, the retries plus the
|
||||
// reconcile sweep would keep the money owed while the allowlist is corrected.
|
||||
func (s *Server) handleYooKassaNotify(c *gin.Context) {
|
||||
// The sender check runs before anything else. It is defence in depth — the confirming GetPayment
|
||||
// is what actually establishes authenticity — but it is what stops a forger from turning each
|
||||
// fabricated notification into an outbound API call of ours. The address is the true sender,
|
||||
// forwarded by the gateway, which is the only hop that sees it.
|
||||
if ip := clientIP(c); !yookassa.AllowedSender(ip) {
|
||||
s.log.Warn("yookassa notify: sender is not a YooKassa address", zap.String("ip", ip))
|
||||
c.String(http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
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:
|
||||
default:
|
||||
// Refund events are informational: refunds are issued through the API, which records them
|
||||
// synchronously. Anything else is 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")
|
||||
}
|
||||
|
||||
// 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. The refund carries a receipt because the
|
||||
// rail registers a refund receipt the same way it registers a payment one, and the order id as the
|
||||
// idempotency key, so a repeated attempt returns the original refund instead of paying twice.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
email, _, err := s.accounts.ConfirmedEmail(ctx, ref.AccountID)
|
||||
if 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: yookassa.SingleItemReceipt(email, title, amount, s.vatCode),
|
||||
}, ref.OrderID.String())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if refund.ID == "" {
|
||||
return "", errors.New("the provider returned a refund with no id")
|
||||
}
|
||||
return refund.ID, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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,18 @@ 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).
|
||||
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 +157,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 +215,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,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package yookassa
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// Notification events this integration subscribes to. An event name is "<object>.<status>": the
|
||||
// object whose status changed and the status it entered.
|
||||
const (
|
||||
// EventPaymentSucceeded means the money was taken and the order may be credited.
|
||||
EventPaymentSucceeded = "payment.succeeded"
|
||||
// EventPaymentCanceled means the payment was actively declined or abandoned; nothing is credited
|
||||
// and the payer is told the attempt failed.
|
||||
EventPaymentCanceled = "payment.canceled"
|
||||
// EventRefundSucceeded reports a completed refund. Refunds here are always initiated by an
|
||||
// operator through the API, which records them synchronously, so this event is informational.
|
||||
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
|
||||
}
|
||||
|
||||
// senderPrefixes are the address ranges YooKassa delivers notifications from
|
||||
// (https://yookassa.ru/developers/using-api/webhooks). Single addresses are expressed as /32 and
|
||||
// /128 prefixes. The list is defence in depth only — the confirming GetPayment is what actually
|
||||
// establishes authenticity — so it is kept deliberately literal and easy to audit against the docs.
|
||||
var senderPrefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("185.71.76.0/27"),
|
||||
netip.MustParsePrefix("185.71.77.0/27"),
|
||||
netip.MustParsePrefix("77.75.153.0/25"),
|
||||
netip.MustParsePrefix("77.75.156.11/32"),
|
||||
netip.MustParsePrefix("77.75.156.35/32"),
|
||||
netip.MustParsePrefix("77.75.154.128/25"),
|
||||
netip.MustParsePrefix("2a02:5180::/32"),
|
||||
}
|
||||
|
||||
// AllowedIP reports whether addr is one of YooKassa's notification senders. An IPv4-mapped IPv6
|
||||
// address is unmapped first, so a dual-stack listener's view of an IPv4 sender still matches.
|
||||
func AllowedIP(addr netip.Addr) bool {
|
||||
addr = addr.Unmap()
|
||||
if !addr.IsValid() {
|
||||
return false
|
||||
}
|
||||
for _, p := range senderPrefixes {
|
||||
if p.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllowedSender reports whether the textual address ip is one of YooKassa's notification senders. An
|
||||
// unparseable address is not allowed.
|
||||
func AllowedSender(ip string) bool {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return AllowedIP(addr)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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 TestAllowedSender(t *testing.T) {
|
||||
allowed := []string{
|
||||
"185.71.76.0", "185.71.76.31", // /27 bounds
|
||||
"185.71.77.15",
|
||||
"77.75.153.0", "77.75.153.127", // /25 bounds
|
||||
"77.75.156.11", "77.75.156.35", // the two single addresses
|
||||
"77.75.154.128", "77.75.154.255",
|
||||
"2a02:5180::1", "2a02:5180:ffff::abcd",
|
||||
}
|
||||
for _, ip := range allowed {
|
||||
if !AllowedSender(ip) {
|
||||
t.Errorf("%s rejected, want allowed", ip)
|
||||
}
|
||||
}
|
||||
denied := []string{
|
||||
"185.71.76.32", "185.71.75.255", // just outside the /27
|
||||
"77.75.153.128", // just outside the /25
|
||||
"77.75.156.12", // adjacent to a single allowed address
|
||||
"77.75.154.127",
|
||||
"2a02:5181::1",
|
||||
"8.8.8.8", "127.0.0.1", "",
|
||||
"not an address",
|
||||
}
|
||||
for _, ip := range denied {
|
||||
if AllowedSender(ip) {
|
||||
t.Errorf("%s allowed, want rejected", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedSenderUnmapsIPv4(t *testing.T) {
|
||||
// A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d; it must still match the v4 range.
|
||||
if !AllowedSender("::ffff:77.75.156.11") {
|
||||
t.Error("an IPv4-mapped allowed sender was rejected")
|
||||
}
|
||||
if AllowedSender("::ffff:8.8.8.8") {
|
||||
t.Error("an IPv4-mapped foreign sender was allowed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package yookassa
|
||||
|
||||
// Fiscal receipt attributes for «Чеки от ЮKassa» (54-ФЗ). YooKassa registers the receipt itself —
|
||||
// no cash register to rent, no OFD contract — but only if the payment and refund requests carry a
|
||||
// receipt object, so unlike the previous rail's cabinet-side receipts these fields are load-bearing
|
||||
// code. 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. It is the default; 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
|
||||
// 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 within the range the API accepts (1..12).
|
||||
func ValidVatCode(code int) bool { return code >= 1 && code <= VatCodeMax }
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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) {
|
||||
for _, code := range []int{1, 4, 11, 12} {
|
||||
if !ValidVatCode(code) {
|
||||
t.Errorf("vat code %d rejected, want accepted", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []int{0, -1, 13} {
|
||||
if ValidVatCode(code) {
|
||||
t.Errorf("vat code %d accepted, want rejected", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
|
||||
// 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, "/")
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+24
-22
@@ -132,28 +132,30 @@ 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.
|
||||
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=
|
||||
# VAT rate code on every fiscal receipt line (54-ФЗ tag 1199): 1 = «Без НДС» (УСН/ПСН), 4 = 20%,
|
||||
# 11 = 22%. A Gitea VARIABLE, not a secret, so a rate change is a variable edit + redeploy.
|
||||
YOOKASSA_VAT_CODE=1
|
||||
#
|
||||
# Robokassa (the retired direct rail) is deliberately absent here: no deployment sets its
|
||||
# credentials, which is what keeps it dormant. The backend still reads BACKEND_ROBOKASSA_* and
|
||||
# falls the direct rail back to it if they are ever set again — see
|
||||
# backend/internal/robokassa/README.md for the full variable set and how to revive the rail.
|
||||
|
||||
# --- Gateway anti-abuse ------------------------------------------------------
|
||||
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
||||
|
||||
+4
-4
@@ -76,10 +76,10 @@ compose binds from this directory.
|
||||
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
||||
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
|
||||
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
||||
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. |
|
||||
| `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) |
|
||||
| `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. |
|
||||
| `BACKEND_ROBOKASSA_{WEB,ANDROID}_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | secret / variable | Multi-shop direct rail (D42): per-channel Robokassa shops. The legacy `BACKEND_ROBOKASSA_*` seeds the **web** shop; `…_WEB_*` overrides it, `…_ANDROID_*` adds the **android** (RuStore) shop. Each credits the one `direct` wallet; the cabinet Result URL per shop is `/pay/robokassa/result/{web,android}`. Empty login ⇒ that channel unconfigured (order routing falls back to the web shop). |
|
||||
| `BACKEND_YOOKASSA_{WEB,ANDROID}_SHOP_ID` | secret | YooKassa shop id for that channel of the direct RUB rail (cabinet: **Настройки — Магазин**, field `shopId`). Empty ⇒ that channel is unconfigured and order routing falls back to the **web** shop; no channel configured at all leaves the rail off. |
|
||||
| `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** | VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199): `1` = «Без НДС» (УСН/ПСН), `4` = 20%, `11` = 22%. Defaults to `1`. A variable so a rate change needs no code change. **Confirm it with the accountant before the first real payment** — 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
|
||||
|
||||
+15
-19
@@ -164,25 +164,21 @@ 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.
|
||||
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:-}
|
||||
# VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199). 1 = «Без НДС» (УСН/ПСН).
|
||||
# A deploy variable, not a secret, so a rate change is a variable edit + redeploy.
|
||||
BACKEND_YOOKASSA_VAT_CODE: ${YOOKASSA_VAT_CODE:-1}
|
||||
# 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
|
||||
|
||||
@@ -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'
|
||||
|
||||
+72
-23
@@ -23,7 +23,7 @@ The game earns through two orthogonal channels:
|
||||
The currency is **two-tier**:
|
||||
|
||||
```
|
||||
money (VK Votes / TG Stars / RUB via Robokassa) ─┐
|
||||
money (VK Votes / TG Stars / RUB via YooKassa) ─┐
|
||||
├─► Фишки (chips) ──► values
|
||||
rewarded ad view ─┘ (no-ads, hints,
|
||||
tournament fee)
|
||||
@@ -46,7 +46,7 @@ The chip balance is **segmented by `source`** — the platform where the chips w
|
||||
|------------|----------------------------------|
|
||||
| `vk` | VK Votes purchase, VK rewarded ad |
|
||||
| `telegram` | TG Stars purchase |
|
||||
| `direct` | Robokassa purchase (web / native)|
|
||||
| `direct` | YooKassa purchase (web / native) |
|
||||
|
||||
A single account holds all three segments simultaneously: `balance = (account_id, source)`,
|
||||
up to three rows — a row is materialised lazily on the segment's first funding, and an absent
|
||||
@@ -54,17 +54,27 @@ segment reads as zero. Segmentation is **not** per-identity — see §6.
|
||||
|
||||
Why segmented, not one pooled balance: store rules forbid activating value that was paid
|
||||
for outside the store's own cash desk. Chips funded inside VK (Votes) may only be spent in
|
||||
the VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside
|
||||
the VK context; Stars only inside Telegram; YooKassa-funded (`direct`) chips only outside
|
||||
the stores. See §4.
|
||||
|
||||
**Multi-shop direct rail (D42).** The `direct` rail routes to one Robokassa **merchant shop per
|
||||
**Multi-shop direct rail (D42).** The `direct` rail routes to one YooKassa **merchant shop per
|
||||
channel** — `web` and `android` (RuStore), `ios` later — chosen by the trusted `X-Platform` subtype;
|
||||
every shop credits the one `direct` wallet (no per-channel wallet). This is merchant-account
|
||||
separation for accounting / receipts only: the order records its `shop` (shown in the admin report),
|
||||
and each shop's Result callback is verified by its own Password2 at `/pay/robokassa/result/<channel>`.
|
||||
separation for accounting / receipts only: the order records its `shop` (shown in the admin report).
|
||||
All shops share one notification endpoint, `/pay/yookassa/notify`; an incoming notification is
|
||||
attributed to a shop by the shop id the payment reports (`recipient.account_id`), falling back to the
|
||||
channel recorded on the order, and that shop's credentials perform the confirming read (§9).
|
||||
Standalone apps (Android/iOS) sign in by email only, so a direct purchase always has the D36 email
|
||||
anchor (D43). Fiscalization (54-ФЗ via the Robokassa cabinet under one ИП) is a single source
|
||||
regardless of the number of shops (D41).
|
||||
anchor (D43), which is also where the fiscal receipt is delivered. Fiscalization (54-ФЗ under one ИП)
|
||||
is a single source regardless of the number of shops (D41).
|
||||
|
||||
**Robokassa is retired, not deleted (D47).** It settled the `direct` rail before YooKassa. Its code,
|
||||
tests and wiring stay in the tree and the rail falls back to it when no YooKassa shop is configured,
|
||||
so reviving it is a credentials change rather than a code change; no deployment sets those
|
||||
credentials today. `backend/internal/robokassa/README.md` records the retired variables, the cabinet
|
||||
configuration and the revival steps. Ledger rows written while it was live keep
|
||||
`provider = 'robokassa'` — that literal is load-bearing for the idempotency index and is never
|
||||
renamed or reused.
|
||||
|
||||
**Payment availability kill switch (D45/D46).** An operator can disable purchases on a rail/channel
|
||||
(`direct:web` / `direct:android` / `vk` / `telegram`) or for one account, live from `/_gm`, and the
|
||||
@@ -224,20 +234,47 @@ 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). As defence in
|
||||
depth — and to stop a forger turning each fabricated notification into an outbound call of ours — the
|
||||
sender address is first checked against YooKassa's published ranges. 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).
|
||||
|
||||
**Expiry-time reconciliation (D49).** No continuous polling. But a notification that is lost for good
|
||||
would leave the money taken and the chips unowed, silently, so the existing pending-order reaper asks
|
||||
the provider what became of each order that reached its expiry age carrying a payment id, and credits
|
||||
the ones that were in fact paid. One request per order over its whole life.
|
||||
|
||||
**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 +303,14 @@ 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), since no rail pushes an unsolicited refund. 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. The recorded refund id is the provider's
|
||||
own, which keeps the ledger reconcilable against YooKassa's records. 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
|
||||
@@ -356,7 +397,15 @@ spends, grants, refunds, full history — as an extension of the existing user c
|
||||
|
||||
Receipts are automatic **through the provider**, and differ by rail:
|
||||
|
||||
- **Robokassa** (direct) — self-employed НПД receipt on payment.
|
||||
- **YooKassa** (direct) — a 54-ФЗ fiscal receipt through **«Чеки от ЮKassa»**: YooKassa owns the
|
||||
cash register, the fiscal drive and the OFD contract, and files with the tax authority. Unlike the
|
||||
retired rail's cabinet-side receipts, it registers one **only if the request carries it** (D51), so
|
||||
every payment and every refund sends a `receipt`: one line (the pack title, quantity 1, the amount)
|
||||
with the VAT rate code (54-ФЗ tag 1199, a deploy variable — `1` = «Без НДС» for УСН/ПСН), the
|
||||
settlement subject `service` (tag 1212) and the settlement method `full_payment` (tag 1214).
|
||||
Delivery is by email only, to the D36 confirmed anchor. `tax_system_code` is not sent — YooKassa
|
||||
ignores it for this solution. A malformed receipt is an API error at payment creation, so it blocks
|
||||
the purchase loudly rather than silently skipping the fiscal document.
|
||||
- **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 +415,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 +456,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).
|
||||
|
||||
@@ -281,6 +281,46 @@ 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` платежа должен совпадать с флагом магазина
|
||||
(платёж тестового магазина не начислит настоящие Фишки, и наоборот); адрес отправителя сверяется с
|
||||
опубликованными диапазонами ЮKassa — эшелонированная защита, которая не даёт превратить каждое
|
||||
фальшивое уведомление в наш исходящий запрос. Ответ провайдеру: 200 на всё окончательно решённое
|
||||
(включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa повторяет 24 часа).
|
||||
- **D49. Сверка — одна проверка на истечении заказа, без постоянного опроса.** «Или» в документации
|
||||
ЮKassa адресовано тем, кто не хочет вебхуки; у нас вебхуки основные. Но безвозвратно потерянное
|
||||
уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому существующий
|
||||
жнец просроченных заказов перед списанием в `expired` спрашивает провайдера о судьбе каждого заказа,
|
||||
дожившего до срока с идентификатором платежа, и начисляет реально оплаченные. Один запрос на заказ
|
||||
за всю его жизнь; отдельный воркер не заводим.
|
||||
- **D50. Возвраты на direct-рельсе — через API ЮKassa, одним действием.** Кнопка возврата в `/_gm`
|
||||
сперва двигает деньги (`POST /v3/refunds`, `Idempotence-Key` = идентификатор заказа, с чеком
|
||||
возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет **ничего** — журнал не
|
||||
может заявить о возврате, которого не было. Записывается собственный refund-id провайдера (сверка с
|
||||
данными ЮKassa). VK и TG Stars — как раньше: деньги руками, запись фактом.
|
||||
- **D51. Фискализация — «Чеки от ЮKassa», чек отправляем из кода.** Ревизия D41: у ЮKassa нет
|
||||
кабинетного «обобщённого чека», как у Robokassa, — чек регистрируется **только если запрос его
|
||||
несёт**, поэтому itemized-код, от которого отказались в D41, возвращается в объём. Каждый платёж и
|
||||
возврат шлют `receipt`: одна позиция (название пакета, количество 1, сумма), код ставки НДС
|
||||
(тег 1199), признак предмета расчёта `service` (тег 1212), признак способа расчёта `full_payment`
|
||||
(тег 1214); доставка только на email — на подтверждённый якорь D36. Код ставки НДС — **переменная
|
||||
деплоя** (дефолт `1` = «Без НДС» для УСН/ПСН): это единственный реквизит, который реально меняется
|
||||
(с 1 января 2026 ставки выросли, появились коды 11/12), и менять его без релиза нужно; признаки
|
||||
предмета и способа расчёта — константы в коде. `tax_system_code` не шлём: для «Чеков от ЮKassa»
|
||||
провайдер его игнорирует.
|
||||
|
||||
## Заметки к оформлению документов
|
||||
|
||||
@@ -296,7 +336,7 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
|
||||
feedback-память `prefer-interview-mode` после plan mode.
|
||||
|
||||
## Все развилки закрыты (D1-D46)
|
||||
## Все развилки закрыты (D1-D51)
|
||||
|
||||
Интервью завершено. Дальше — оформление документов и реализация по релизам.
|
||||
|
||||
@@ -307,6 +347,14 @@ 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 **есть** тестовый режим (отдельный тестовый магазин со своими креды и тестовыми картами),
|
||||
поэтому весь путь проверяется на тестовом контуре без реальных денег.
|
||||
|
||||
## План внедрения (черновик PLAN.md — «слоями»)
|
||||
|
||||
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
|
||||
|
||||
+72
-23
@@ -23,7 +23,7 @@
|
||||
Валюта **двухуровневая**:
|
||||
|
||||
```
|
||||
деньги (Голоса VK / Stars TG / рубли через Robokassa) ─┐
|
||||
деньги (Голоса VK / Stars TG / рубли через ЮKassa) ─┐
|
||||
├─► Фишки ──► ценности
|
||||
просмотр ролика за награду ─┘ (без рекламы,
|
||||
подсказки,
|
||||
@@ -46,7 +46,7 @@
|
||||
|------------|------------------------------------|
|
||||
| `vk` | Покупка за Голоса VK, ролик за награду в VK |
|
||||
| `telegram` | Покупка за Stars TG |
|
||||
| `direct` | Покупка через Robokassa (web / native) |
|
||||
| `direct` | Покупка через ЮKassa (web / native) |
|
||||
|
||||
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, до трёх
|
||||
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
|
||||
@@ -54,17 +54,27 @@
|
||||
|
||||
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
|
||||
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
|
||||
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
|
||||
в контексте VK; Stars — только внутри Telegram; Фишки от ЮKassa (`direct`) — только вне
|
||||
сторов. См. §4.
|
||||
|
||||
**Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин
|
||||
Robokassa **на канал** — `web` и `android` (RuStore), позже `ios` — по трастовому подтипу
|
||||
ЮKassa **на канал** — `web` и `android` (RuStore), позже `ios` — по трастовому подтипу
|
||||
`X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет).
|
||||
Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в
|
||||
админ-отчёте), а Result-колбэк каждого магазина проверяется своим Password2 по
|
||||
`/pay/robokassa/result/<channel>`. В standalone-приложениях (Android/iOS) вход только по email, поэтому
|
||||
у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под
|
||||
одним ИП) — один источник независимо от числа магазинов (D41).
|
||||
админ-отчёте). Точка приёма уведомлений у всех магазинов одна — `/pay/yookassa/notify`; входящее
|
||||
уведомление привязывается к магазину по идентификатору магазина, который сообщает сам платёж
|
||||
(`recipient.account_id`), с откатом на канал, записанный в заказе, и подтверждающее чтение (§9)
|
||||
выполняется кредами именно этого магазина. В standalone-приложениях (Android/iOS) вход только по
|
||||
email, поэтому у direct-покупки всегда есть email-якорь D36 (D43) — он же адрес доставки чека.
|
||||
Фискализация (54-ФЗ под одним ИП) — один источник независимо от числа магазинов (D41).
|
||||
|
||||
**Robokassa законсервирована, но не удалена (D47).** До ЮKassa она обслуживала рельс `direct`. Код,
|
||||
тесты и проводка остаются в дереве, и рельс откатывается на неё, если ни один магазин ЮKassa не
|
||||
настроен, — поэтому возврат к ней это смена кредов, а не правка кода; сегодня эти креды не задаёт ни
|
||||
один контур. `backend/internal/robokassa/README.md` хранит список выведенных переменных, настройки
|
||||
кабинета и порядок возврата рельса. Строки журнала, записанные при её жизни, сохраняют
|
||||
`provider = 'robokassa'` — этот литерал несущий для индекса идемпотентности, его не переименовывают и
|
||||
не переиспользуют.
|
||||
|
||||
**Рубильник платежей (D45/D46).** Оператор выключает покупки на рельсе/канале (`direct:web` /
|
||||
`direct:android` / `vk` / `telegram`) или для одного аккаунта, живьём из `/_gm`, и юзер на следующей
|
||||
@@ -217,21 +227,48 @@ 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` — совпадать с флагом магазина,
|
||||
поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). В качестве
|
||||
эшелонированной защиты — и чтобы подделыватель не превращал каждое фальшивое уведомление в наш
|
||||
исходящий запрос — адрес отправителя сперва сверяется с опубликованными диапазонами ЮKassa. Ответ
|
||||
сообщает провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и
|
||||
неустранимый отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа).
|
||||
|
||||
**Сверка на истечении заказа (D49).** Постоянного опроса нет. Но безвозвратно потерянное уведомление
|
||||
оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому уже существующий жнец
|
||||
просроченных заказов спрашивает у провайдера судьбу каждого заказа, который дожил до своего срока с
|
||||
идентификатором платежа, и начисляет те, что на самом деле оплачены. Один запрос на заказ за всю его
|
||||
жизнь.
|
||||
|
||||
**Об отклонённой оплате сообщаем.** `payment.canceled` от ЮKassa пишет событие `failed`, поэтому
|
||||
покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто
|
||||
брошенный заказ не пишет ничего — он молча истекает.
|
||||
|
||||
**Pending невидим** пользователю; авто-истекает по таймауту (~30 мин, гигиена базы).
|
||||
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
|
||||
@@ -261,9 +298,13 @@ 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) и записывает реверс только после
|
||||
того, как деньги действительно ушли, — неудачный вызов не пишет **ничего**, поэтому журнал не может
|
||||
заявить о возврате, которого не было. Записывается собственный refund-id провайдера: по нему журнал
|
||||
сверяется с данными ЮKassa. Возвраты VK по-прежнему через поддержку, TG Stars — вызовом
|
||||
`refundStarPayment`, оба фиксируются руками после факта. Все сходятся на одном движке — метод `Refund`
|
||||
(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по
|
||||
`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки
|
||||
сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с
|
||||
@@ -349,7 +390,15 @@ in-process кэш сегментов и бенефитов по ключу-ак
|
||||
|
||||
Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу:
|
||||
|
||||
- **Robokassa** (direct) — чек НПД самозанятого при оплате.
|
||||
- **ЮKassa** (direct) — фискальный чек по 54-ФЗ через **«Чеки от ЮKassa»**: касса, фискальный
|
||||
накопитель, договор с ОФД и отправка в налоговую — на стороне ЮKassa. В отличие от кабинетных чеков
|
||||
прежнего рельса, чек регистрируется **только если запрос его несёт** (D51), поэтому каждый платёж и
|
||||
каждый возврат отправляют `receipt`: одна позиция (название пакета, количество 1, сумма) с кодом
|
||||
ставки НДС (тег 54-ФЗ 1199, переменная деплоя — `1` = «Без НДС» для УСН/ПСН), признаком предмета
|
||||
расчёта `service` (тег 1212) и признаком способа расчёта `full_payment` (тег 1214). Доставка только
|
||||
на email — на подтверждённый якорь D36. `tax_system_code` не передаём: для этого решения ЮKassa его
|
||||
игнорирует. Некорректный чек — ошибка API при создании платежа, то есть покупка ломается громко, а
|
||||
не тихо остаётся без фискального документа.
|
||||
- **VK** — VK сам процессит Голоса через налоговую; делать нечего.
|
||||
- **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не
|
||||
доход НПД; принимаем, чек не формируем).
|
||||
@@ -359,7 +408,7 @@ in-process кэш сегментов и бенефитов по ключу-ак
|
||||
|
||||
## 13. Дистрибуция (native Android)
|
||||
|
||||
- **RuStore** — Robokassa/внешний гейт разрешён (0%); native = чистый контекст `direct`.
|
||||
- **RuStore** — внешний платёжный гейт разрешён (0%); native = чистый контекст `direct`.
|
||||
- **Google Play** — direct-покупки **скрыты**; «Кошелёк» показывает заглушку («установите
|
||||
версию из RuStore для покупок»). Ролик за награду и трата уже накопленных Фишек работают.
|
||||
Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте.
|
||||
@@ -403,4 +452,4 @@ legacy-значения обнуляются.
|
||||
взнос).
|
||||
- **гейт** — одностороннее правило комплаенса сторов (§4).
|
||||
- **журнал операций (ledger)** — неизменяемая запись всех операций с деньгами/ценностями.
|
||||
- **платёжный канал / рельса** — платёжный провайдер (Robokassa / Голоса VK / Stars TG).
|
||||
- **платёжный канал / рельса** — платёжный провайдер (ЮKassa / Голоса VK / Stars TG).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -273,15 +273,20 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// through this session-gated route (not public); see dictBytesHandler.
|
||||
mux.Handle("/dict/", s.dictBytesHandler())
|
||||
mux.Handle("/dl/", s.exportDownloadHandler())
|
||||
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single
|
||||
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. The
|
||||
// per-shop callback rides a channel suffix (/pay/robokassa/result/<channel>); the bare path is
|
||||
// the legacy web shop. Caddy's /pay/* matcher already forwards both, so no Caddyfile change.
|
||||
// Direct-rail return + callback routes: the server notification (the single crediting signal,
|
||||
// proxied to the backend intake) and the browser return page. Caddy's /pay/* matcher already
|
||||
// forwards every path here, so a new route needs no Caddyfile change.
|
||||
mux.Handle("/pay/yookassa/notify", s.yookassaNotifyHandler())
|
||||
mux.Handle("/pay/yookassa/return", s.paymentReturnHandler("Оплата принята."))
|
||||
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
|
||||
// The retired Robokassa rail. Its routes stay registered — the backend simply has no shop to
|
||||
// verify against — so reviving the rail is a credentials change, not a code change, and the
|
||||
// edge probe keeps proving /pay/* reaches the gateway. The per-shop callback rides a channel
|
||||
// suffix (/pay/robokassa/result/<channel>); the bare path is the legacy web shop.
|
||||
mux.Handle("/pay/robokassa/result", s.robokassaResultHandler())
|
||||
mux.Handle("/pay/robokassa/result/", s.robokassaResultHandler())
|
||||
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
|
||||
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
|
||||
mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена."))
|
||||
mux.Handle("/pay/robokassa/success", s.paymentReturnHandler("Оплата принята."))
|
||||
mux.Handle("/pay/robokassa/fail", s.paymentReturnHandler("Оплата не завершена."))
|
||||
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
||||
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
||||
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
|
||||
@@ -691,6 +696,45 @@ func (s *Server) robokassaResultHandler() http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// maxNotifyBytes caps the provider notification body the gateway will read and forward.
|
||||
const maxNotifyBytes = 1 << 20
|
||||
|
||||
// yookassaNotifyHandler proxies a YooKassa webhook to the backend intake (the single writer). It
|
||||
// rate-limits per IP and forwards the raw JSON body together with the true sender address, which
|
||||
// only this hop sees; the backend checks that address against YooKassa's published sender ranges and
|
||||
// then re-reads the payment from the provider, because YooKassa does not sign notifications.
|
||||
//
|
||||
// The backend answers 200 for anything durably decided and 5xx for a transient failure, and that is
|
||||
// relayed as-is: YooKassa retries a notification for 24 hours until it gets a 200.
|
||||
func (s *Server) yookassaNotifyHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backend == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ip := peerIP(r.RemoteAddr, r.Header)
|
||||
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
|
||||
s.noteRateLimited(r.Context(), classPublic, ip, "yookassa-notify")
|
||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxNotifyBytes))
|
||||
if err != nil || !json.Valid(body) {
|
||||
// Rejected here rather than proxied: a body that is not JSON cannot become valid on a
|
||||
// redelivery, and forwarding it would only make the backend answer 5xx forever.
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.backend.YooKassaNotify(r.Context(), ip, body); err != nil {
|
||||
s.log.Warn("yookassa notify proxy failed", zap.Error(err))
|
||||
http.Error(w, "not accepted", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
}
|
||||
|
||||
// vkCallbackHandler proxies a VK Mini Apps payment callback to the backend intake. It rate-limits
|
||||
// per IP, verifies the VK signature with the app's protected key (the backend holds no VK secret),
|
||||
// forwards the provider's parameters, and relays the backend's VK response envelope. A missing or
|
||||
@@ -731,12 +775,12 @@ func (s *Server) vkCallbackHandler() http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// robokassaReturnHandler serves a minimal self-closing page for Robokassa's Success/Fail browser
|
||||
// paymentReturnHandler serves a minimal self-closing page for a direct-rail provider's browser
|
||||
// return. The payment opens in a separate window, so on return this closes that window and drops the
|
||||
// customer back into the live app (never a cold app start); a link is the fallback if the window
|
||||
// cannot self-close. The credit rides the server Result callback, never this redirect — the wallet
|
||||
// cannot self-close. The credit rides the server notification, never this redirect — the wallet
|
||||
// updates in place from the payment push, with a return-focus refetch as the fallback.
|
||||
func (s *Server) robokassaReturnHandler(message string) http.Handler {
|
||||
func (s *Server) paymentReturnHandler(message string) http.Handler {
|
||||
page := []byte(`<!doctype html>
|
||||
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Оплата</title></head>
|
||||
<body style="font-family: system-ui, sans-serif; text-align: center; padding: 48px 20px; color: #1a1c20">
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -239,7 +239,7 @@ export class MockGateway implements GatewayClient {
|
||||
if (!p || p.kind !== 'pack') throw new GatewayError('not_a_pack');
|
||||
// No real provider in mock: return a stub launch URL so the e2e can assert the purchase reaches
|
||||
// the provider hand-off without leaving the app.
|
||||
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId };
|
||||
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/yookassa?order=' + productId };
|
||||
}
|
||||
|
||||
async walletReward(_nonce: string): Promise<Wallet> {
|
||||
|
||||
Reference in New Issue
Block a user