Merge pull request 'feat(payments): settle the direct rail through YooKassa' (#293) from feature/yookassa-direct-rail into development
CI / changes (push) Successful in 3s
CI / integration (push) Successful in 23s
CI / gate (push) Successful in 0s
CI / unit (push) Successful in 11s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / deploy (push) Successful in 1m47s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
CI / changes (push) Successful in 3s
CI / integration (push) Successful in 23s
CI / gate (push) Successful in 0s
CI / unit (push) Successful in 11s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / deploy (push) Successful in 1m47s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
This commit was merged in pull request #293.
This commit is contained in:
@@ -241,6 +241,51 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde
|
|||||||
|
|
||||||
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
|
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
|
||||||
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies.
|
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies.
|
||||||
|
- **YooKassa (the direct rail since v1.x) does NOT sign its webhooks.** Authenticity is the
|
||||||
|
confirming `GET /v3/payments/{id}` — never the notification body. Two extra guards ride on the
|
||||||
|
confirmed object: its `metadata.order_id` must name the order, and its `test` flag must match the
|
||||||
|
shop's `IsTest` (a test-shop payment must never credit real chips). Answer 200 for anything
|
||||||
|
durably decided, 5xx only to be redelivered (YooKassa retries 24h).
|
||||||
|
- **Do NOT gate the notification on the sender IP** — tried, reverted. The contour sits behind a
|
||||||
|
tunnel and sees every caller as an internal address (`10.77.0.1`), so the allowlist rejected every
|
||||||
|
genuine notification and the credit fell back to the reconcile sweep. Same root cause as the
|
||||||
|
IP bans being prod-only. It bought nothing anyway: the order is resolved from the notification
|
||||||
|
metadata **before** any provider call, so a forged id costs one indexed read, and guessing a live
|
||||||
|
order id means guessing a uuid.
|
||||||
|
- **The console refund and the `refund.succeeded` notification race, benignly.** YooKassa fires the
|
||||||
|
event the moment `POST /v3/refunds` returns, so the notification often records the reversal before
|
||||||
|
the console's own write lands. Both name the same refund id, so the ledger's idempotency index
|
||||||
|
catches the second and nothing is revoked twice — but the console must not then report "already
|
||||||
|
refunded", which reads as "you clicked twice" for a refund that just succeeded.
|
||||||
|
- **Never key a re-check interval off the order TTL.** The order lifetime answers "how long may a
|
||||||
|
customer take to pay"; the re-check answers "how soon do we notice a lost callback". Tying them
|
||||||
|
together made a failed notification cost the customer the full 30-minute TTL before the chips
|
||||||
|
landed. `payments.ReconcileAfter` is its own constant for that reason.
|
||||||
|
- **Subscribe to `refund.succeeded`, not just the payment events.** The YooKassa cabinet lets an
|
||||||
|
operator refund without touching our API, so without that event the money goes back while the
|
||||||
|
chips stay credited. The reversal engine is **full-refund-only** (it rejects any amount other than
|
||||||
|
the order's), so a partial refund must record nothing and be logged for a human — there is no
|
||||||
|
non-arbitrary chip cost for a part-refund. Also: a refund is only recorded in status `succeeded`;
|
||||||
|
a `pending` one can still be canceled and the ledger is append-only.
|
||||||
|
- **YooKassa DOES have a test mode** — a separate *test shop* (own shopId/secret, test cards, up to
|
||||||
|
20 of them), and webhooks/receipts/refunds all work there. The whole rail is verifiable on the
|
||||||
|
contour without real money; don't plan around "no test payments".
|
||||||
|
- **We send NO receipt: the merchant is on НПД, which is outside 54-ФЗ**, and YooKassa does not
|
||||||
|
serve receipts for that regime (checked with their support). Reporting goes to «Мой налог».
|
||||||
|
The fiscal code is kept dormant behind `BACKEND_YOOKASSA_VAT_CODE` — unset = send nothing, a
|
||||||
|
54-ФЗ rate code turns «Чеки от ЮKassa» back on (the НПД income ceiling makes that foreseeable).
|
||||||
|
Gotcha if you ever switch it on: `payment_subject`/`payment_mode`/`vat_code` are fields *inside*
|
||||||
|
`receipt.items[]` (54-ФЗ tags 1212/1214/1199), documented under the receipts section, not the
|
||||||
|
payment API — easy to miss; and a malformed receipt is an API error at payment creation, so it
|
||||||
|
breaks purchases rather than silently skipping the fiscal document.
|
||||||
|
- **The gateway cannot import `backend/internal/*`** (separate module + Go's internal rule), so a
|
||||||
|
security list shared by both hops either lives in `pkg/` or is enforced backend-side. The YooKassa
|
||||||
|
sender allowlist is enforced in the backend, with the gateway forwarding the real peer IP as
|
||||||
|
`X-Forwarded-For` — do not duplicate the CIDR list into the gateway.
|
||||||
|
- **Robokassa is retired but wired.** The direct rail resolves YooKassa → else Robokassa, and no
|
||||||
|
deployment sets the Robokassa credentials. Reviving it is a credentials change, not a code change;
|
||||||
|
`backend/internal/robokassa/README.md` holds the retired vars, cabinet URLs and the known gap
|
||||||
|
(the per-channel `…_WEB_*`/`…_ANDROID_*` vars never reached any workflow).
|
||||||
- **VK payment title trap:** a product title with an HTML-special char (`& < > " '`) silently kills
|
- **VK 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
|
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
|
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_HOST: ${{ vars.SMTP_RELAY_HOST }}
|
||||||
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
|
||||||
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
|
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
|
||||||
# Direct-rail (Robokassa) sandbox intake on the contour: the test shop's merchant login +
|
# Direct-rail (YooKassa) intake on the contour: the credentials of a YooKassa TEST shop,
|
||||||
# Password1/Password2. IsTest is forced to 1 below so the contour can never take real money
|
# which takes test cards and moves no real money. YOOKASSA_WEB_TEST is forced to 1 so the
|
||||||
# (independent of the shop's own mode). Empty login leaves the direct rail off.
|
# contour refuses to credit anything but a test payment, whatever credentials are set.
|
||||||
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.TEST_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
|
# Empty credentials leave the direct rail off.
|
||||||
ROBOKASSA_PASSWORD1: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD1 }}
|
YOOKASSA_WEB_SHOP_ID: ${{ secrets.TEST_BACKEND_YOOKASSA_WEB_SHOP_ID }}
|
||||||
ROBOKASSA_PASSWORD2: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD2 }}
|
YOOKASSA_WEB_SECRET_KEY: ${{ secrets.TEST_BACKEND_YOOKASSA_WEB_SECRET_KEY }}
|
||||||
ROBOKASSA_TEST: "1"
|
YOOKASSA_WEB_TEST: "1"
|
||||||
|
YOOKASSA_VAT_CODE: ${{ vars.TEST_BACKEND_YOOKASSA_VAT_CODE }}
|
||||||
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
|
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
|
||||||
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
|
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
|
||||||
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
|
# 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
|
- name: Probe the /pay/ callback route reaches the gateway
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
# /pay/robokassa/result must reach the gateway, not fall to the landing catch-all. An
|
# /pay/yookassa/notify must reach the gateway, not fall to the landing catch-all. The probe
|
||||||
# unsigned probe is rejected downstream, so the gateway answers a 4xx/5xx (never a 200 or
|
# is a bare GET from an address that is not one of YooKassa's senders, so it is rejected
|
||||||
# a 404 landing.html), which proves the edge route is wired.
|
# downstream and the gateway answers a 4xx/5xx (never a 200 or a 404 landing.html), which
|
||||||
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/pay/robokassa/result 2>&1 || true)"
|
# 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
|
echo "$out" | grep -E "HTTP/" || true
|
||||||
if echo "$out" | grep -qE "HTTP/1\.1 (4|5)[0-9][0-9]"; then
|
if echo "$out" | grep -qE "HTTP/1\.1 (4|5)[0-9][0-9]"; then
|
||||||
echo "ok: /pay/ reaches the gateway (non-landing response)"
|
echo "ok: /pay/ reaches the gateway (non-landing response)"
|
||||||
else
|
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
|
docker logs --tail 50 scrabble-gateway || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -99,14 +99,18 @@ jobs:
|
|||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
# Robokassa direct-rail (backend BACKEND_ROBOKASSA_*): the prod shop login + the pass phrases
|
# YooKassa direct rail (backend BACKEND_YOOKASSA_*), one merchant shop per channel: the shop
|
||||||
# that sign the launch request / verify the Result callback, and the test-mode flag — a var so
|
# id + secret key, and a test-shop flag — a var, so pointing prod at a test shop is a flag
|
||||||
# go-live is a flag flip, not a secret redeploy ("1" runs test payments against the test
|
# flip rather than a secret rotation ("1" refuses to credit anything but a test payment;
|
||||||
# passwords; empty/"0" is live). An empty login leaves the direct rail disabled.
|
# empty/"0" is live). Empty credentials leave that channel — or the whole rail — disabled.
|
||||||
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
|
# The VAT rate code on every receipt line is a var too, so a rate change needs no code change.
|
||||||
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
|
YOOKASSA_WEB_SHOP_ID: ${{ secrets.PROD_BACKEND_YOOKASSA_WEB_SHOP_ID }}
|
||||||
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
|
YOOKASSA_WEB_SECRET_KEY: ${{ secrets.PROD_BACKEND_YOOKASSA_WEB_SECRET_KEY }}
|
||||||
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
|
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
|
# 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
|
# 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.
|
# 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
|
# 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.
|
# 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 }}
|
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.
|
# it must carry the same credentials or the direct rail goes dark after a rollback.
|
||||||
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
|
YOOKASSA_WEB_SHOP_ID: ${{ secrets.PROD_BACKEND_YOOKASSA_WEB_SHOP_ID }}
|
||||||
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
|
YOOKASSA_WEB_SECRET_KEY: ${{ secrets.PROD_BACKEND_YOOKASSA_WEB_SECRET_KEY }}
|
||||||
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
|
YOOKASSA_WEB_TEST: ${{ vars.PROD_BACKEND_YOOKASSA_WEB_TEST }}
|
||||||
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_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 }}
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Technical, step-by-step implementation of the monetization domain. Business mechanics and
|
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
|
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
|
[`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
|
[`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
|
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
|
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
|
## 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.
|
densely enough to execute whole.
|
||||||
- **Never leak stage ids into the product.** Code, comments, commit messages, PR
|
- **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.
|
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 |
|
| E9 | Tournament fee | future | TODO |
|
||||||
| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | DONE |
|
| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | DONE |
|
||||||
| E11 | Payment availability kill switch + per-account override | 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 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
|
**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
|
||||||
@@ -1012,6 +1013,69 @@ so nothing is disabled until an operator acts.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## E12 — YooKassa direct rail (Robokassa retired)
|
||||||
|
|
||||||
|
**Status:** DONE · **Release 2+ (money-live)** · depends on: E5 (intake/`Fund`, the order flow), E7
|
||||||
|
(the admin refund + report), E10 (per-channel shops), E11 (the kill switch) · mechanics: PAYMENTS §2,
|
||||||
|
§9, §11, §12; decisions D47–D51 (revising D41).
|
||||||
|
|
||||||
|
**Delivered.** The `direct` (RUB) rail settles through **YooKassa** instead of Robokassa. The wallet
|
||||||
|
model is untouched — one `direct` segment, the same spend wall, the same per-channel shops (D42) and
|
||||||
|
`shop` on the order (D44). Robokassa is **dormant, not deleted**: the direct rail falls back to it
|
||||||
|
when no YooKassa shop is configured, so reviving it is a credentials change (D47).
|
||||||
|
|
||||||
|
- **Provider glue** — `backend/internal/yookassa`: an API client (`CreatePayment` / `GetPayment` /
|
||||||
|
`CreateRefund`, HTTP Basic, `Idempotence-Key`), a per-channel `Shops` registry with `ByShopID` for
|
||||||
|
attributing a notification, the notification envelope + the sender-IP allowlist, and the fiscal
|
||||||
|
receipt builder. No DB, no payments-domain coupling — the same shape as the `robokassa` package.
|
||||||
|
- **Order** — `handleWalletOrder` prefers a YooKassa shop and otherwise falls through to Robokassa.
|
||||||
|
It creates the payment (order id as both `Idempotence-Key` and `metadata.order_id`), records the
|
||||||
|
provider payment id on the order (`AttachProviderPayment`; no migration — the column existed) and
|
||||||
|
returns `confirmation_url`. D36's email anchor now also feeds the receipt (`ConfirmedEmail`).
|
||||||
|
- **Notification** — `/pay/yookassa/notify` (gateway: rate limit + raw-body proxy; backend: the
|
||||||
|
confirming `GetPayment`). Nothing in the body is acted on (D48). Guards: the payment's metadata must
|
||||||
|
name the order, and its `test` flag must match the shop's. The sender address is deliberately not
|
||||||
|
checked (D48 rev) — it adds no security, and the order lookup that precedes any provider call is
|
||||||
|
the tighter guard against a forger driving traffic at the provider through us.
|
||||||
|
`payment.canceled` records a `failed` event. 200 = durably decided, 5xx = redeliver.
|
||||||
|
- **Reconcile** — `runOrderReaper` asks the provider about each pending order older than
|
||||||
|
`payments.ReconcileAfter` (a minute) carrying a payment id, and credits the ones really paid
|
||||||
|
(D49 rev — the threshold is deliberately not the order lifetime, which answers a different
|
||||||
|
question and made a failed notification cost the customer half an hour).
|
||||||
|
- **Refund** — the `/_gm` button calls `POST /v3/refunds` first and records only on a **succeeded**
|
||||||
|
refund, under the provider's own refund id (D50); a failure — or a still-`pending` refund — records
|
||||||
|
nothing, and pressing again is safe. `refund.succeeded` is handled too (D52), because the merchant
|
||||||
|
cabinet can issue a refund that never passes through our API; a **partial** refund records nothing
|
||||||
|
and is logged for an operator (the engine is full-refund-only).
|
||||||
|
- **Fiscalization** — **no receipt is sent** (D51 rev): the merchant is on НПД, outside 54-ФЗ, and
|
||||||
|
YooKassa does not serve receipts for that regime; reporting goes to «Мой налог» instead (its own
|
||||||
|
task — the data it needs is already recorded, bar a per-operation "reported" marker). The fiscal
|
||||||
|
code stays dormant behind `BACKEND_YOOKASSA_VAT_CODE`: unset sends nothing, a 54-ФЗ rate code turns
|
||||||
|
«Чеки от ЮKassa» back on — the path a lost НПД regime (annual income ceiling) would take.
|
||||||
|
- **Email anchor** — D36 still gates a direct purchase (the recovery anchor stands on its own now
|
||||||
|
that the receipt address is gone). The wallet now says so **before** the buy tap: a player signed in
|
||||||
|
through VK/Telegram in a browser sees "add an email in your profile" instead of a bare error.
|
||||||
|
- **Config/deploy** — `BACKEND_YOOKASSA_{WEB,ANDROID}_{SHOP_ID,SECRET_KEY,TEST}` +
|
||||||
|
`BACKEND_YOOKASSA_VAT_CODE`; every `ROBOKASSA_*` mapping removed from compose, `.env.example`,
|
||||||
|
`write-prod-env.sh` and the three workflows, and recorded in `backend/internal/robokassa/README.md`.
|
||||||
|
The CI edge probe now targets `/pay/yookassa/notify`.
|
||||||
|
- **Tests** — unit (`yookassa`: request building, receipt, idempotence key, error retryability, shop
|
||||||
|
registry, IP allowlist); integration (`payments_yookassa_test.go`: credit-once + redelivery, a
|
||||||
|
forged notification credits nothing, a foreign sender is refused, a live payment on a test shop is
|
||||||
|
refused, a decline records `failed`, the reconcile sweep credits a lost notification and leaves an
|
||||||
|
unpaid order alone, the refund moves money then records — and records nothing when it fails, the
|
||||||
|
order path mints a payment with a receipt, D36 still gates the rail, a cabinet refund is reversed
|
||||||
|
once, the event after a console refund is a no-op, a partial refund changes nothing, an unconfirmed
|
||||||
|
refund reverses nothing, and a pending refund records nothing until it settles).
|
||||||
|
|
||||||
|
**Contour-safe:** no migration, no wire change; the client is rail-agnostic (only the mock URL and an
|
||||||
|
e2e assertion name a provider).
|
||||||
|
|
||||||
|
**Verified on the contour** against a YooKassa **test shop** (test mode exists — a separate test shop
|
||||||
|
with its own credentials and test cards), then in prod with one real payment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Verification & CI (all stages)
|
## Verification & CI (all stages)
|
||||||
|
|
||||||
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
|
- 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,
|
Notifier: hub,
|
||||||
ExportSignKey: cfg.ExportSignKey,
|
ExportSignKey: cfg.ExportSignKey,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
Robokassa: cfg.Robokassa,
|
YooKassa: cfg.YooKassa,
|
||||||
|
|
||||||
|
YooKassaVatCode: cfg.YooKassaVatCode,
|
||||||
|
PublicBaseURL: cfg.PublicBaseURL,
|
||||||
|
Robokassa: cfg.Robokassa,
|
||||||
})
|
})
|
||||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
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("http_addr", cfg.HTTPAddr),
|
||||||
zap.String("grpc_addr", cfg.GRPCAddr))
|
zap.String("grpc_addr", cfg.GRPCAddr))
|
||||||
// Sweep expired pending payment orders on a cadence (cosmetic hygiene; a late valid callback
|
// Sweep expired pending payment orders on a cadence (cosmetic hygiene; a late valid callback
|
||||||
// still credits). Runs until ctx is cancelled.
|
// still credits), asking the provider about each one first. Runs until ctx is cancelled.
|
||||||
go runOrderReaper(ctx, paymentsSvc, logger)
|
go runOrderReaper(ctx, paymentsSvc, srv, logger)
|
||||||
// Deliver pending payment_events to connected clients as an in-app wallet-refresh push (the
|
// 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).
|
// credit already landed in the ledger; a return-focus poll is the client-side fallback).
|
||||||
go runPaymentDispatcher(ctx, paymentsSvc, hub, logger)
|
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
|
// 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
|
// ctx is cancelled. Expiry is cosmetic: a later valid provider callback still credits an expired
|
||||||
// order.
|
// 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)
|
t := time.NewTicker(5 * time.Minute)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
@@ -371,6 +380,9 @@ func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-t.C:
|
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 {
|
if n, err := p.ExpireOrders(ctx); err != nil {
|
||||||
log.Warn("order reaper: sweep failed", zap.Error(err))
|
log.Warn("order reaper: sweep failed", zap.Error(err))
|
||||||
} else if n > 0 {
|
} 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
|
// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail
|
||||||
// recovery anchor a first purchase requires (D36).
|
// recovery anchor a first purchase requires (D36).
|
||||||
func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
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)
|
ids, err := s.Identities(ctx, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return "", false, err
|
||||||
}
|
}
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if id.Kind == "email" && id.Confirmed {
|
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
|
// ListAccounts returns accounts for the admin user list, newest first, paginated
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
|
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
|
||||||
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
|
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
|
||||||
<a href="/_gm/catalog"{{if eq .ActiveNav "catalog"}} class="active"{{end}}>Catalog</a>
|
<a href="/_gm/catalog"{{if eq .ActiveNav "catalog"}} class="active"{{end}}>Catalog</a>
|
||||||
|
<a href="/_gm/ledger"{{if eq .ActiveNav "ledger"}} class="active"{{end}}>Ledger</a>
|
||||||
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||||
<a href="/_gm/grafana/">Grafana ↗</a>
|
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Ledger</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<form class="form" method="get" action="/_gm/ledger">
|
||||||
|
<label>From <input type="date" name="from" value="{{.From}}"></label>
|
||||||
|
<label>To <input type="date" name="to" value="{{.To}}"></label>
|
||||||
|
<select name="kind">
|
||||||
|
<option value="">any kind</option>
|
||||||
|
{{range .Kinds}}<option value="{{.}}"{{if eq . $.Data.Kind}} selected{{end}}>{{.}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
<select name="wallet">
|
||||||
|
<option value="">any wallet</option>
|
||||||
|
{{range .Wallets}}<option value="{{.}}"{{if eq . $.Data.Wallet}} selected{{end}}>{{.}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
<select name="provider">
|
||||||
|
<option value="">any rail</option>
|
||||||
|
{{range .Providers}}<option value="{{.}}"{{if eq . $.Data.Provider}} selected{{end}}>{{.}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
<input name="user" value="{{.UserID}}" placeholder="user id" size="36">
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
<a class="export" href="/_gm/ledger.csv?{{.FilterQuery}}">Export CSV ↓</a>
|
||||||
|
</form>
|
||||||
|
<p class="note">Defaults to the last 30 days. The wallet filter matches the funded segment or the
|
||||||
|
benefit origin; the rail filter matches the settling provider, so chip spends (which have no rail)
|
||||||
|
drop out of it. <a href="/_gm/ledger">clear filters</a></p>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Totals for the filtered range</h2>
|
||||||
|
<p>
|
||||||
|
Money in: {{range .Totals.MoneyIn}}<strong>{{.}}</strong> {{else}}<span class="note">none</span>{{end}}
|
||||||
|
· Refunded: {{range .Totals.MoneyRefunded}}<strong>{{.}}</strong> {{else}}<span class="note">none</span>{{end}}
|
||||||
|
· Chips credited: <strong>{{.Totals.ChipsIn}}</strong>
|
||||||
|
· Chips spent: <strong>{{.Totals.ChipsOut}}</strong>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Time</th><th>Account</th><th>Kind</th><th>Money</th><th>Chips</th><th>What</th><th>Wallet</th><th>Rail</th><th>Shop</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Rows}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.At}}</td>
|
||||||
|
<td><a href="/_gm/users/{{.AccountID}}">card</a></td>
|
||||||
|
<td>{{.Kind}}</td>
|
||||||
|
<td>{{.Money}}</td>
|
||||||
|
<td>{{.ChipsDelta}}</td>
|
||||||
|
<td>{{.Title}}{{if .Order}} <code>{{.Order}}</code>{{end}}</td>
|
||||||
|
<td>{{.Source}}{{if and .Origin (ne .Origin .Source)}} → {{.Origin}}{{end}}</td>
|
||||||
|
<td>{{.Provider}}</td>
|
||||||
|
<td>{{.Shop}}</td>
|
||||||
|
<td>{{if .Refundable}}<form class="form" method="post" action="/_gm/users/{{.AccountID}}/refund" onsubmit="return confirm('Refund this order in full? On the direct rail this moves the money back through the provider and then revokes the chips (floored at 0); on the other rails refund on the rail first.')"><input type="hidden" name="order_id" value="{{.Order}}"><input type="hidden" name="back" value="/_gm/ledger?{{$.Data.FilterQuery}}"><button type="submit">Refund</button></form>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="10"><span class="note">no operations in this range</span></td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .Pager.HasPrev}}<a href="/_gm/ledger?{{.FilterQuery}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||||
|
{{if .Pager.HasNext}}<a href="/_gm/ledger?{{.FilterQuery}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -82,20 +82,14 @@
|
|||||||
</select></label>
|
</select></label>
|
||||||
<div><button type="submit">Save</button></div>
|
<div><button type="submit">Save</button></div>
|
||||||
</form>
|
</form>
|
||||||
<h3>Ledger</h3>
|
<h3>Totals</h3>
|
||||||
{{$uid := .ID}}
|
<p>
|
||||||
{{if .Finance.Ledger}}
|
Paid: {{range .Finance.Paid}}<strong>{{.}}</strong> {{else}}<span class="note">nothing</span>{{end}}
|
||||||
<table class="list">
|
· Refunded: {{range .Finance.Refunded}}<strong>{{.}}</strong> {{else}}<span class="note">nothing</span>{{end}}
|
||||||
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Shop</th><th>Detail</th><th></th></tr></thead>
|
· Chips credited: <strong>{{.Finance.ChipsBought}}</strong>
|
||||||
<tbody>
|
· Chips spent: <strong>{{.Finance.ChipsSpent}}</strong>
|
||||||
{{range .Finance.Ledger}}
|
</p>
|
||||||
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{.Shop}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
|
<p class="note"><a href="/_gm/ledger?{{.Finance.LedgerQuery}}">Open this account's operations in the ledger →</a> — every entry, with filters, paging and the refund action.</p>
|
||||||
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="note"><a href="/_gm/ledger.csv">Export the full ledger (CSV)</a> — all accounts, for tax + reconciliation.</p>
|
|
||||||
{{else}}<p class="note">no ledger entries</p>{{end}}
|
|
||||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Grant benefits</h2>
|
<section class="panel"><h2>Grant benefits</h2>
|
||||||
|
|||||||
@@ -207,16 +207,26 @@ type UserDetailView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FinanceView is the account's payments picture on the user card: chip balances per funding
|
// FinanceView is the account's payments picture on the user card: chip balances per funding
|
||||||
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
|
// segment, benefits per origin, the recorded refund risk, and a short lifetime summary. The
|
||||||
// (newest first). Present is false when the payments domain is unwired.
|
// operations themselves live in the ledger section, which this links to pre-filtered to the
|
||||||
|
// account — the card answers "where does this account stand", not "what happened when".
|
||||||
|
// Present is false when the payments domain is unwired.
|
||||||
type FinanceView struct {
|
type FinanceView struct {
|
||||||
Present bool
|
Present bool
|
||||||
Segments []SegmentRow
|
Segments []SegmentRow
|
||||||
Benefits []BenefitRow
|
Benefits []BenefitRow
|
||||||
// Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
|
// Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
|
||||||
Abuse bool
|
Abuse bool
|
||||||
Loss int
|
Loss int
|
||||||
Ledger []LedgerRow
|
// Paid is the money the account has spent, per currency, already formatted; Refunded is what
|
||||||
|
// came back. ChipsBought counts every chip ever credited (purchases, rewarded views, grants) and
|
||||||
|
// ChipsSpent every chip spent on a value.
|
||||||
|
Paid []string
|
||||||
|
Refunded []string
|
||||||
|
ChipsBought int
|
||||||
|
ChipsSpent int
|
||||||
|
// LedgerQuery is the ledger-section query string that shows this account's operations.
|
||||||
|
LedgerQuery template.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// SegmentRow is one funding segment's chip balance.
|
// SegmentRow is one funding segment's chip balance.
|
||||||
@@ -234,22 +244,6 @@ type BenefitRow struct {
|
|||||||
Forever bool
|
Forever bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
|
|
||||||
// delta, the product / order / provider / direct-rail shop it references (empty when none), the raw
|
|
||||||
// snapshot JSON and the pre-formatted time.
|
|
||||||
type LedgerRow struct {
|
|
||||||
Kind string
|
|
||||||
Source string
|
|
||||||
Origin string
|
|
||||||
ChipsDelta int
|
|
||||||
Product string
|
|
||||||
Order string
|
|
||||||
Provider string
|
|
||||||
Shop string
|
|
||||||
Snapshot string
|
|
||||||
At string
|
|
||||||
}
|
|
||||||
|
|
||||||
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
|
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
|
||||||
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
|
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
|
||||||
type RelationRow struct {
|
type RelationRow struct {
|
||||||
@@ -764,3 +758,53 @@ type GrantProductOption struct {
|
|||||||
Summary string
|
Summary string
|
||||||
Archived bool
|
Archived bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LedgerView is the all-accounts financial ledger: one page of operations, the totals for
|
||||||
|
// everything the current filter matches (not merely the page), and the filter state itself so the
|
||||||
|
// form renders back what the operator asked for. FilterQuery is those filters URL-encoded, carried
|
||||||
|
// into the pager, the CSV export and the refund's return link so every one of them stays on the
|
||||||
|
// same slice of the ledger (see MessagesView.FilterQuery).
|
||||||
|
type LedgerView struct {
|
||||||
|
Rows []LedgerRow
|
||||||
|
Pager Pager
|
||||||
|
Totals LedgerTotalsRow
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
UserID string
|
||||||
|
Kind string
|
||||||
|
Wallet string
|
||||||
|
Provider string
|
||||||
|
Kinds []string
|
||||||
|
Wallets []string
|
||||||
|
Providers []string
|
||||||
|
FilterQuery template.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerRow is one operation in the ledger list. Money is the amount the customer paid or was
|
||||||
|
// refunded, already formatted with its currency, and empty for a row that moved no money (a chip
|
||||||
|
// spend, an admin grant, a rewarded-video credit). Refundable marks a funded order the operator may
|
||||||
|
// still reverse, which is what puts the Refund button on that row.
|
||||||
|
type LedgerRow struct {
|
||||||
|
At string
|
||||||
|
AccountID string
|
||||||
|
Kind string
|
||||||
|
Source string
|
||||||
|
Origin string
|
||||||
|
ChipsDelta int
|
||||||
|
Money string
|
||||||
|
Title string
|
||||||
|
Order string
|
||||||
|
Provider string
|
||||||
|
Shop string
|
||||||
|
Refundable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerTotalsRow is the summary above the table, over everything the filter matches. Money is
|
||||||
|
// listed per currency because the rails settle in different ones and one sum across them would mean
|
||||||
|
// nothing.
|
||||||
|
type LedgerTotalsRow struct {
|
||||||
|
MoneyIn []string
|
||||||
|
MoneyRefunded []string
|
||||||
|
ChipsIn int
|
||||||
|
ChipsOut int
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"scrabble/backend/internal/robokassa"
|
"scrabble/backend/internal/robokassa"
|
||||||
"scrabble/backend/internal/robot"
|
"scrabble/backend/internal/robot"
|
||||||
"scrabble/backend/internal/telemetry"
|
"scrabble/backend/internal/telemetry"
|
||||||
|
"scrabble/backend/internal/yookassa"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds the backend's runtime configuration.
|
// Config holds the backend's runtime configuration.
|
||||||
@@ -65,8 +66,18 @@ type Config struct {
|
|||||||
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
||||||
// http://renderer:8090). Empty disables the PNG export artifact.
|
// http://renderer:8090). Empty disables the PNG export artifact.
|
||||||
RendererURL string
|
RendererURL string
|
||||||
// Robokassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
|
// YooKassa 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.
|
// (D42). An empty set leaves the direct order and notification endpoints unregistered and falls
|
||||||
|
// the direct rail back to Robokassa.
|
||||||
|
YooKassa yookassa.Shops
|
||||||
|
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
|
||||||
|
// Unset (0) sends no receipt at all — the state a merchant outside 54-ФЗ runs in, which is the
|
||||||
|
// default; setting a rate turns fiscal receipts back on without a code change.
|
||||||
|
YooKassaVatCode int
|
||||||
|
// Robokassa configures the retired direct-rail payment provider — one merchant shop per channel
|
||||||
|
// (D42). It is dormant: no deployment sets its credentials, so the set is empty and the rail
|
||||||
|
// resolves to YooKassa. Kept wired so restoring Robokassa is a credentials change, not a code
|
||||||
|
// change — see backend/internal/robokassa/README.md.
|
||||||
Robokassa robokassa.Shops
|
Robokassa robokassa.Shops
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +169,26 @@ func Load() (Config, error) {
|
|||||||
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed
|
// YooKassa direct rail: one merchant shop per channel (D42). A shop missing either credential is
|
||||||
// the web channel so existing deploys keep working; the per-channel vars add the rest. A shop
|
// dropped, so the rail stays dormant until a channel is fully configured.
|
||||||
// with no MerchantLogin is dropped (the rail stays dormant when none is configured).
|
ykShops := yookassa.Shops{}
|
||||||
|
for channel, prefix := range map[string]string{
|
||||||
|
yookassa.ChannelWeb: "BACKEND_YOOKASSA_WEB",
|
||||||
|
yookassa.ChannelAndroid: "BACKEND_YOOKASSA_ANDROID",
|
||||||
|
} {
|
||||||
|
if shop := yookassaShop(prefix); shop.Configured() {
|
||||||
|
ykShops[channel] = shop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vatCode, err := envInt("BACKEND_YOOKASSA_VAT_CODE", yookassa.VatCodeOff)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Robokassa direct rail: retired but kept wired (see backend/internal/robokassa/README.md). No
|
||||||
|
// deployment sets these, so the set is empty and the direct rail resolves to YooKassa; restoring
|
||||||
|
// the credentials revives it without a code change. The legacy single-shop vars seed the web
|
||||||
|
// channel; the per-channel vars add the rest.
|
||||||
shops := robokassa.Shops{}
|
shops := robokassa.Shops{}
|
||||||
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
|
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
|
||||||
if web.MerchantLogin == "" {
|
if web.MerchantLogin == "" {
|
||||||
@@ -190,6 +218,8 @@ func Load() (Config, error) {
|
|||||||
GuestRetention: guestRetention,
|
GuestRetention: guestRetention,
|
||||||
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||||
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||||
|
YooKassa: ykShops,
|
||||||
|
YooKassaVatCode: vatCode,
|
||||||
Robokassa: shops,
|
Robokassa: shops,
|
||||||
}
|
}
|
||||||
if err := c.validate(); err != nil {
|
if err := c.validate(); err != nil {
|
||||||
@@ -248,6 +278,19 @@ func (c Config) validate() error {
|
|||||||
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
|
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !yookassa.ValidVatCode(c.YooKassaVatCode) {
|
||||||
|
return fmt.Errorf("config: BACKEND_YOOKASSA_VAT_CODE %d is not a 54-ФЗ VAT rate code (1..%d); leave it unset to send no receipts", c.YooKassaVatCode, yookassa.VatCodeMax)
|
||||||
|
}
|
||||||
|
if c.YooKassa.Configured() {
|
||||||
|
// The YooKassa rail sends the customer to a hosted payment page and needs an absolute return
|
||||||
|
// URL to bring them back, which only the public base URL can supply.
|
||||||
|
if c.PublicBaseURL == "" {
|
||||||
|
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when a YooKassa shop is configured")
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +331,18 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) {
|
|||||||
return d, nil
|
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.
|
// 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
|
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
|
||||||
// MerchantLogin yields a zero Config the caller drops.
|
// MerchantLogin yields a zero Config the caller drops.
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedLedgerRow writes one ledger row directly, at a chosen age, so a test can stand up a history
|
||||||
|
// that spans a date range without waiting for one.
|
||||||
|
func seedLedgerRow(t *testing.T, acc uuid.UUID, kind, source, provider string, chips int, snapshot string, age time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
var providerPaymentID any
|
||||||
|
if provider != "" {
|
||||||
|
providerPaymentID = "pp-" + uuid.NewString()
|
||||||
|
}
|
||||||
|
if _, err := testDB.ExecContext(context.Background(), `
|
||||||
|
INSERT INTO payments.ledger (ledger_id, account_id, kind, source, origin, chips_delta,
|
||||||
|
provider, provider_payment_id, snapshot, created_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$4,$5,NULLIF($6,''),$7,$8::jsonb, now() - $9::interval)`,
|
||||||
|
uuid.New(), acc, kind, source, chips, provider, providerPaymentID, snapshot,
|
||||||
|
fmt.Sprintf("%d seconds", int(age.Seconds()))); err != nil {
|
||||||
|
t.Fatalf("seed ledger row: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledgerReport reads a filtered page through the service, the way the console does.
|
||||||
|
func ledgerReport(t *testing.T, pay *payments.Service, f payments.LedgerFilter, limit, offset int) payments.LedgerReport {
|
||||||
|
t.Helper()
|
||||||
|
rep, err := pay.LedgerReportPage(context.Background(), f, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ledger report: %v", err)
|
||||||
|
}
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLedgerReportFilters covers each filter axis over one seeded history, including the property
|
||||||
|
// that matters most for an operator: the totals describe the whole filtered range, not the page.
|
||||||
|
func TestLedgerReportFilters(t *testing.T) {
|
||||||
|
acc, other := provisionAccount(t), provisionAccount(t)
|
||||||
|
// Two recent purchases on different rails, one older purchase outside a narrow range, a spend
|
||||||
|
// (no rail at all) and a refund.
|
||||||
|
seedLedgerRow(t, acc, "fund", "direct", "yookassa", 100, `{"amount_minor":14900,"currency":"RUB","title":"100 chips"}`, time.Hour)
|
||||||
|
seedLedgerRow(t, acc, "fund", "vk", "vk", 50, `{"amount_minor":50,"currency":"VOTE","title":"50 chips"}`, 2*time.Hour)
|
||||||
|
seedLedgerRow(t, other, "fund", "direct", "yookassa", 30, `{"amount_minor":4900,"currency":"RUB","title":"30 chips"}`, 40*24*time.Hour)
|
||||||
|
seedLedgerRow(t, acc, "spend", "direct", "", -20, `{"title":"hints"}`, 30*time.Minute)
|
||||||
|
seedLedgerRow(t, acc, "refund", "direct", "yookassa", -100, `{"amount_minor":14900,"currency":"RUB"}`, 10*time.Minute)
|
||||||
|
|
||||||
|
pay := newPaymentsService()
|
||||||
|
// Scoped to this test's own account: the suite shares one database and other tests seed recent
|
||||||
|
// ledger rows, so an unscoped count would be a coin toss.
|
||||||
|
recent := payments.LedgerFilter{From: time.Now().Add(-24 * time.Hour), AccountID: acc}
|
||||||
|
|
||||||
|
t.Run("date range excludes older rows", func(t *testing.T) {
|
||||||
|
rep := ledgerReport(t, pay, recent, 100, 0)
|
||||||
|
for _, r := range rep.Rows {
|
||||||
|
if r.AccountID == other {
|
||||||
|
t.Error("a row from outside the range was returned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("account", func(t *testing.T) {
|
||||||
|
rep := ledgerReport(t, pay, payments.LedgerFilter{AccountID: other}, 100, 0)
|
||||||
|
if rep.Total != 1 {
|
||||||
|
t.Fatalf("rows for the other account = %d, want 1", rep.Total)
|
||||||
|
}
|
||||||
|
rep = ledgerReport(t, pay, payments.LedgerFilter{AccountID: acc}, 100, 0)
|
||||||
|
if rep.Total != 4 {
|
||||||
|
t.Fatalf("rows for the account = %d, want 4", rep.Total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("kind", func(t *testing.T) {
|
||||||
|
f := recent
|
||||||
|
f.Kind = "spend"
|
||||||
|
rep := ledgerReport(t, pay, f, 100, 0)
|
||||||
|
for _, r := range rep.Rows {
|
||||||
|
if r.Kind != "spend" {
|
||||||
|
t.Errorf("kind filter returned a %q row", r.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rep.Total == 0 {
|
||||||
|
t.Error("kind filter matched nothing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wallet", func(t *testing.T) {
|
||||||
|
f := recent
|
||||||
|
f.Wallet = "vk"
|
||||||
|
rep := ledgerReport(t, pay, f, 100, 0)
|
||||||
|
if rep.Total != 1 {
|
||||||
|
t.Fatalf("vk wallet rows = %d, want 1", rep.Total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rail excludes spends, which have none", func(t *testing.T) {
|
||||||
|
f := recent
|
||||||
|
f.Provider = "yookassa"
|
||||||
|
rep := ledgerReport(t, pay, f, 100, 0)
|
||||||
|
for _, r := range rep.Rows {
|
||||||
|
if r.Kind == "spend" {
|
||||||
|
t.Error("a chip spend matched a rail filter, but a spend has no rail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("totals cover the range, not the page", func(t *testing.T) {
|
||||||
|
f := recent
|
||||||
|
full := ledgerReport(t, pay, f, 100, 0)
|
||||||
|
firstPage := ledgerReport(t, pay, f, 1, 0)
|
||||||
|
if len(firstPage.Rows) != 1 {
|
||||||
|
t.Fatalf("page rows = %d, want 1", len(firstPage.Rows))
|
||||||
|
}
|
||||||
|
if firstPage.Total != full.Total {
|
||||||
|
t.Errorf("page total = %d, want the full match count %d", firstPage.Total, full.Total)
|
||||||
|
}
|
||||||
|
if firstPage.Totals.ChipsIn != full.Totals.ChipsIn || firstPage.Totals.ChipsOut != full.Totals.ChipsOut {
|
||||||
|
t.Errorf("page totals = %+v, want the whole range's %+v", firstPage.Totals, full.Totals)
|
||||||
|
}
|
||||||
|
// 100 + 50 credited, 20 spent + 100 refunded revoked.
|
||||||
|
if full.Totals.ChipsIn != 150 || full.Totals.ChipsOut != 120 {
|
||||||
|
t.Errorf("chip totals = in %d / out %d, want 150 / 120", full.Totals.ChipsIn, full.Totals.ChipsOut)
|
||||||
|
}
|
||||||
|
// Money is kept per currency; summing roubles and Votes together would be meaningless.
|
||||||
|
var rub, vote bool
|
||||||
|
for _, m := range full.Totals.MoneyIn {
|
||||||
|
switch m.Currency() {
|
||||||
|
case payments.CurrencyRUB:
|
||||||
|
rub = true
|
||||||
|
if m.Minor() != 14900 {
|
||||||
|
t.Errorf("RUB in = %d, want 14900", m.Minor())
|
||||||
|
}
|
||||||
|
case payments.CurrencyVote:
|
||||||
|
vote = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !rub || !vote {
|
||||||
|
t.Errorf("money in = %v, want both a RUB and a VOTE total", full.Totals.MoneyIn)
|
||||||
|
}
|
||||||
|
if len(full.Totals.MoneyRefunded) != 1 || full.Totals.MoneyRefunded[0].Minor() != 14900 {
|
||||||
|
t.Errorf("money refunded = %v, want one 149.00 RUB total", full.Totals.MoneyRefunded)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("money is recovered from the snapshot", func(t *testing.T) {
|
||||||
|
f := recent
|
||||||
|
f.Kind = "fund"
|
||||||
|
f.Wallet = "direct"
|
||||||
|
rep := ledgerReport(t, pay, f, 100, 0)
|
||||||
|
if len(rep.Rows) != 1 {
|
||||||
|
t.Fatalf("rows = %d, want 1", len(rep.Rows))
|
||||||
|
}
|
||||||
|
r := rep.Rows[0]
|
||||||
|
if !r.HasMoney() || r.Money.Minor() != 14900 || r.Money.Currency() != payments.CurrencyRUB {
|
||||||
|
t.Errorf("row money = %v, want 149.00 RUB", r.Money)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a spend carries no money", func(t *testing.T) {
|
||||||
|
f := recent
|
||||||
|
f.Kind = "spend"
|
||||||
|
rep := ledgerReport(t, pay, f, 100, 0)
|
||||||
|
if len(rep.Rows) != 1 {
|
||||||
|
t.Fatalf("rows = %d, want 1", len(rep.Rows))
|
||||||
|
}
|
||||||
|
if rep.Rows[0].HasMoney() {
|
||||||
|
t.Errorf("a chip spend reported money %v", rep.Rows[0].Money)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLedgerReportPaging checks paging walks the range without repeating or dropping a row.
|
||||||
|
func TestLedgerReportPaging(t *testing.T) {
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
for i := range 5 {
|
||||||
|
seedLedgerRow(t, acc, "fund", "direct", "yookassa", 10,
|
||||||
|
`{"amount_minor":1000,"currency":"RUB"}`, time.Duration(i+1)*time.Minute)
|
||||||
|
}
|
||||||
|
pay := newPaymentsService()
|
||||||
|
f := payments.LedgerFilter{AccountID: acc}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for offset := 0; offset < 5; offset += 2 {
|
||||||
|
rep := ledgerReport(t, pay, f, 2, offset)
|
||||||
|
if rep.Total != 5 {
|
||||||
|
t.Fatalf("total = %d, want 5 at every offset", rep.Total)
|
||||||
|
}
|
||||||
|
for _, r := range rep.Rows {
|
||||||
|
key := r.ProviderPaymentID
|
||||||
|
if seen[key] {
|
||||||
|
t.Errorf("row %s appeared on two pages", key)
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(seen) != 5 {
|
||||||
|
t.Errorf("walked %d rows across the pages, want 5", len(seen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLedgerConsolePageAndExport drives the console: the page renders under a filter, the CSV export
|
||||||
|
// honours that same filter, and the user card links to the ledger scoped to the account instead of
|
||||||
|
// listing the rows itself.
|
||||||
|
func TestLedgerConsolePageAndExport(t *testing.T) {
|
||||||
|
srv, _, _ := bannerServer(t)
|
||||||
|
acc, other := provisionAccount(t), provisionAccount(t)
|
||||||
|
seedLedgerRow(t, acc, "fund", "direct", "yookassa", 100, `{"amount_minor":14900,"currency":"RUB","title":"Pack A"}`, time.Hour)
|
||||||
|
seedLedgerRow(t, other, "fund", "vk", "vk", 50, `{"amount_minor":50,"currency":"VOTE","title":"Pack B"}`, time.Hour)
|
||||||
|
|
||||||
|
rec := consoleGet(t, srv, "/_gm/ledger")
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("ledger page = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, want := range []string{"Pack A", "Pack B", "149.00 RUB", "Totals for the filtered range"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("ledger page does not show %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtering to one account must drop the other's operation from the page.
|
||||||
|
rec = consoleGet(t, srv, "/_gm/ledger?user="+acc.String())
|
||||||
|
if body := rec.Body.String(); !strings.Contains(body, "Pack A") || strings.Contains(body, "Pack B") {
|
||||||
|
t.Error("the user filter did not scope the ledger page to one account")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pager links must carry the filters, or paging would silently widen the view.
|
||||||
|
if body := rec.Body.String(); strings.Contains(body, "page=") && !strings.Contains(body, "user="+acc.String()+"&page=") {
|
||||||
|
t.Error("a pager link dropped the active filters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The export honours the same filter as the screen it is linked from.
|
||||||
|
rec = consoleGet(t, srv, "/_gm/ledger.csv?user="+acc.String())
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("ledger export = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
csv := rec.Body.String()
|
||||||
|
if !strings.Contains(csv, "Pack A") || strings.Contains(csv, "Pack B") {
|
||||||
|
t.Error("the CSV export ignored the filter")
|
||||||
|
}
|
||||||
|
if !strings.Contains(csv, "amount_minor,currency") {
|
||||||
|
t.Error("the CSV export has no money columns")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user card is a summary now: totals and a link into the ledger, not the rows themselves.
|
||||||
|
rec = consoleGet(t, srv, "/_gm/users/"+acc.String())
|
||||||
|
card := rec.Body.String()
|
||||||
|
if !strings.Contains(card, "/_gm/ledger?user="+acc.String()) {
|
||||||
|
t.Error("the user card does not link to its ledger slice")
|
||||||
|
}
|
||||||
|
if !strings.Contains(card, "Chips credited") {
|
||||||
|
t.Error("the user card shows no totals summary")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,7 +70,8 @@ func TestAccountStatement(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestConsoleFinancePanel checks the user card renders the finance panel: a funded pack + an admin
|
// TestConsoleFinancePanel checks the user card renders the finance panel: a funded pack + an admin
|
||||||
// grant surface as the segment balance, the benefit and the ledger rows.
|
// grant surface as the segment balance, the benefit and the lifetime summary. The operations
|
||||||
|
// themselves belong to the ledger section, which the card links to scoped to this account.
|
||||||
func TestConsoleFinancePanel(t *testing.T) {
|
func TestConsoleFinancePanel(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
srv, _, pay := bannerServer(t)
|
srv, _, pay := bannerServer(t)
|
||||||
@@ -93,9 +94,17 @@ func TestConsoleFinancePanel(t *testing.T) {
|
|||||||
if code != http.StatusOK {
|
if code != http.StatusOK {
|
||||||
t.Fatalf("user card = %d, want 200", code)
|
t.Fatalf("user card = %d, want 200", code)
|
||||||
}
|
}
|
||||||
for _, want := range []string{"Finance", "Chips (direct)", "Benefits (direct)", "5 hints", "admin_grant", "fund"} {
|
for _, want := range []string{
|
||||||
|
"Finance", "Chips (direct)", "Benefits (direct)", "5 hints",
|
||||||
|
"Chips credited", "149.00 RUB", // the lifetime summary: chips in, money paid
|
||||||
|
"/_gm/ledger?user=" + id.String(), // the hand-off to the operations view
|
||||||
|
} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("finance panel missing %q", want)
|
t.Errorf("finance panel missing %q", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The card must not re-render the operations the ledger section owns.
|
||||||
|
if strings.Contains(body, "admin_grant") {
|
||||||
|
t.Error("the user card still lists ledger rows; they belong to /_gm/ledger now")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,866 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
|
"scrabble/backend/internal/server"
|
||||||
|
"scrabble/backend/internal/yookassa"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The test shop the fake API answers for. IsTest is true throughout, matching a YooKassa test shop.
|
||||||
|
const (
|
||||||
|
ykShopID = "100500"
|
||||||
|
ykSecretKey = "test_secret"
|
||||||
|
// ykSenderIP is inside YooKassa's published notification ranges; the intake rejects anything else.
|
||||||
|
ykSenderIP = "185.71.76.1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeYooKassa is a stand-in for the YooKassa API. Tests set the payment it reports and whether a
|
||||||
|
// refund succeeds, then assert on what the intake did with that answer.
|
||||||
|
type fakeYooKassa struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
// payments answers GET /payments/{id}; a missing id is a 404, as it is for a payment we never made.
|
||||||
|
payments map[string]yookassa.Payment
|
||||||
|
// refundFails makes POST /refunds answer 500, standing in for a provider that did not move money.
|
||||||
|
refundFails bool
|
||||||
|
// refundStatus is the status a created refund reports; empty means succeeded.
|
||||||
|
refundStatus string
|
||||||
|
// refundCalls counts the refunds actually requested.
|
||||||
|
refundCalls int
|
||||||
|
// paymentGets counts the confirming reads, so a test can assert a forged notification never
|
||||||
|
// reached the provider at all.
|
||||||
|
paymentGets int
|
||||||
|
// refunds answers GET /refunds/{id}; a missing id is a 404.
|
||||||
|
refunds map[string]yookassa.Refund
|
||||||
|
// createdIdempotenceKey records the key sent on the last create-payment call.
|
||||||
|
createdIdempotenceKey string
|
||||||
|
// lastReceipt records the receipt object of the last create-payment call.
|
||||||
|
lastReceipt map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFakeYooKassa starts the fake API and returns it with a shop config pointed at it.
|
||||||
|
func newFakeYooKassa(t *testing.T) (*fakeYooKassa, yookassa.Config) {
|
||||||
|
t.Helper()
|
||||||
|
f := &fakeYooKassa{payments: map[string]yookassa.Payment{}, refunds: map[string]yookassa.Refund{}}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(f.serve))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return f, yookassa.Config{ShopID: ykShopID, SecretKey: ykSecretKey, IsTest: true, BaseURL: srv.URL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeYooKassa) serve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/payments":
|
||||||
|
var body struct {
|
||||||
|
Amount yookassa.Amount `json:"amount"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
Receipt map[string]any `json:"receipt"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
f.createdIdempotenceKey = r.Header.Get("Idempotence-Key")
|
||||||
|
f.lastReceipt = body.Receipt
|
||||||
|
id := "pay-" + body.Metadata[yookassa.MetadataOrderID]
|
||||||
|
p := yookassa.Payment{
|
||||||
|
ID: id, Status: yookassa.StatusPending, Test: true, Amount: body.Amount,
|
||||||
|
Confirmation: yookassa.Confirmation{Type: yookassa.ConfirmationRedirect, ConfirmationURL: "https://yoomoney.test/pay/" + id},
|
||||||
|
Metadata: body.Metadata,
|
||||||
|
Recipient: yookassa.Recipient{AccountID: ykShopID},
|
||||||
|
}
|
||||||
|
f.payments[id] = p
|
||||||
|
_ = json.NewEncoder(w).Encode(p)
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/payments/"):
|
||||||
|
f.paymentGets++
|
||||||
|
p, ok := f.payments[strings.TrimPrefix(r.URL.Path, "/payments/")]
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"type":"error","description":"not found"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(p)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/refunds":
|
||||||
|
if f.refundFails {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(`{"type":"error","description":"acquirer unavailable"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.refundCalls++
|
||||||
|
var body yookassa.RefundRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
status := f.refundStatus
|
||||||
|
if status == "" {
|
||||||
|
status = yookassa.StatusSucceeded
|
||||||
|
}
|
||||||
|
// The ledger dedupes refunds on (provider, refund id) across the whole database, so the id has
|
||||||
|
// to be unique per payment or one test's refund looks like another's duplicate.
|
||||||
|
refund := yookassa.Refund{ID: "refund-" + body.PaymentID, Status: status, PaymentID: body.PaymentID, Amount: body.Amount}
|
||||||
|
f.refunds[refund.ID] = refund
|
||||||
|
_ = json.NewEncoder(w).Encode(refund)
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/refunds/"):
|
||||||
|
refund, ok := f.refunds[strings.TrimPrefix(r.URL.Path, "/refunds/")]
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"type":"error","description":"not found"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(refund)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setRefund publishes a refund the API will report — how a test stands up a refund that was issued
|
||||||
|
// somewhere other than through our own API, such as in the merchant cabinet.
|
||||||
|
func (f *fakeYooKassa) setRefund(id string, refund yookassa.Refund) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
refund.ID = id
|
||||||
|
f.refunds[id] = refund
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPayment overwrites what the API reports for a payment — how a test says "the money did (or did
|
||||||
|
// not) really move", independently of what a notification claims.
|
||||||
|
func (f *fakeYooKassa) setPayment(id string, mutate func(p *yookassa.Payment)) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
p := f.payments[id]
|
||||||
|
p.ID = id
|
||||||
|
mutate(&p)
|
||||||
|
f.payments[id] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
// yookassaServer builds a backend server whose direct rail is the fake YooKassa shop.
|
||||||
|
//
|
||||||
|
// The suite shares one database, and the kill-switch test leaves the direct rail disabled, so the
|
||||||
|
// rail status is reset first: these tests are about the provider, not about ops availability, and
|
||||||
|
// fail-open means an absent row is an enabled rail.
|
||||||
|
func yookassaServer(t *testing.T, shop yookassa.Config) (*server.Server, *payments.Service) {
|
||||||
|
// Receipts off, as in production: the merchant is outside 54-ФЗ.
|
||||||
|
return yookassaServerWithVat(t, shop, yookassa.VatCodeOff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// yookassaServerWithVat is yookassaServer with an explicit VAT rate code, for exercising the fiscal
|
||||||
|
// receipt path that stays dormant until a rate is configured.
|
||||||
|
func yookassaServerWithVat(t *testing.T, shop yookassa.Config, vatCode int) (*server.Server, *payments.Service) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := testDB.ExecContext(context.Background(),
|
||||||
|
`DELETE FROM payments.rail_status WHERE rail LIKE 'direct%'`); err != nil {
|
||||||
|
t.Fatalf("reset direct rail status: %v", err)
|
||||||
|
}
|
||||||
|
paySvc := newPaymentsService()
|
||||||
|
srv := server.New(":0", server.Deps{
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
Accounts: account.NewStore(testDB),
|
||||||
|
Games: newGameService(),
|
||||||
|
Registry: testRegistry,
|
||||||
|
DictDir: dictDir(),
|
||||||
|
Payments: paySvc,
|
||||||
|
YooKassa: yookassa.Shops{yookassa.ChannelWeb: shop},
|
||||||
|
YooKassaVatCode: vatCode,
|
||||||
|
PublicBaseURL: "https://scrabble.test",
|
||||||
|
})
|
||||||
|
return srv, paySvc
|
||||||
|
}
|
||||||
|
|
||||||
|
// postYooKassaNotify posts a notification body to the intake as the given sender, and reports the status code.
|
||||||
|
func postYooKassaNotify(t *testing.T, srv *server.Server, senderIP, event, paymentID, orderID string) int {
|
||||||
|
t.Helper()
|
||||||
|
body := fmt.Sprintf(`{"type":"notification","event":%q,"object":{"id":%q,"status":"succeeded",
|
||||||
|
"recipient":{"account_id":%q},"metadata":{"order_id":%q}}}`, event, paymentID, ykShopID, orderID)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/payments/yookassa/notify", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Forwarded-For", senderIP)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedYooKassaOrder opens a paid-for-real order: a pending order with the provider payment id
|
||||||
|
// attached, exactly as the order path leaves it once YooKassa has minted the payment.
|
||||||
|
func seedYooKassaOrder(t *testing.T, f *fakeYooKassa, pay *payments.Service, acc uuid.UUID) (uuid.UUID, string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||||
|
res, err := pay.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "yookassa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create order: %v", err)
|
||||||
|
}
|
||||||
|
paymentID := "pay-" + res.OrderID.String()
|
||||||
|
f.setPayment(paymentID, func(p *yookassa.Payment) {
|
||||||
|
p.Status = yookassa.StatusSucceeded
|
||||||
|
p.Paid = true
|
||||||
|
p.Test = true
|
||||||
|
p.Amount = yookassa.Amount{Value: "149.00", Currency: "RUB"}
|
||||||
|
p.Metadata = map[string]string{yookassa.MetadataOrderID: res.OrderID.String()}
|
||||||
|
p.Recipient = yookassa.Recipient{AccountID: ykShopID}
|
||||||
|
})
|
||||||
|
if err := pay.AttachProviderPayment(ctx, res.OrderID, "yookassa", paymentID); err != nil {
|
||||||
|
t.Fatalf("attach provider payment: %v", err)
|
||||||
|
}
|
||||||
|
return res.OrderID, paymentID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaNotifyCreditsOnce drives the crediting path: a succeeded notification credits the
|
||||||
|
// order exactly once, and a redelivery credits nothing more.
|
||||||
|
func TestYooKassaNotifyCreditsOnce(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||||
|
|
||||||
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||||
|
t.Fatalf("notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||||
|
t.Errorf("balance = %d, want 100", got)
|
||||||
|
}
|
||||||
|
if orderStatus(t, orderID) != "paid" {
|
||||||
|
t.Errorf("order status = %s, want paid", orderStatus(t, orderID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// YooKassa redelivers until it gets a 200; a redelivery must credit nothing and still answer 200.
|
||||||
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||||
|
t.Fatalf("redelivered notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||||
|
t.Errorf("balance after redelivery = %d, want 100 (credited once)", got)
|
||||||
|
}
|
||||||
|
if ledgerRows(t, acc, "fund") != 1 {
|
||||||
|
t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaNotifyIsNotEvidence is the security property of the whole rail: notifications are
|
||||||
|
// unsigned, so a fabricated "succeeded" credits nothing when the confirming read says otherwise.
|
||||||
|
func TestYooKassaNotifyIsNotEvidence(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||||
|
|
||||||
|
// The provider says the payment never completed, whatever the notification claims.
|
||||||
|
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusPending; p.Paid = false })
|
||||||
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||||
|
t.Fatalf("notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||||
|
t.Errorf("balance = %d, want 0 — a forged notification credited chips", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A payment we never made: the confirming read 404s, so there is nothing to credit.
|
||||||
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, "pay-invented", orderID.String()); code != http.StatusOK {
|
||||||
|
t.Fatalf("notify for an unknown payment = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||||
|
t.Errorf("balance = %d, want 0 — an invented payment credited chips", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaNotifyForAnUnknownOrderCostsNoProviderCall pins what actually keeps a forger from
|
||||||
|
// using this endpoint to drive traffic at the provider on our behalf. The sender address is not
|
||||||
|
// checked — it cannot be, in a deployment that sees only its own tunnel — so the guard is that the
|
||||||
|
// order is resolved from the notification first: an id matching no order costs one indexed read and
|
||||||
|
// stops there. Guessing a live order id means guessing a uuid.
|
||||||
|
func TestYooKassaNotifyForAnUnknownOrderCostsNoProviderCall(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, _ := yookassaServer(t, shop)
|
||||||
|
|
||||||
|
before := f.paymentGets
|
||||||
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, "pay-forged", uuid.NewString()); code != http.StatusOK {
|
||||||
|
t.Fatalf("notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if f.paymentGets != before {
|
||||||
|
t.Errorf("confirming reads = %d, want %d — a forged notification reached the provider", f.paymentGets, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaNotifyIsAcceptedFromAnyAddress: the contour (and any deployment behind a tunnel) sees
|
||||||
|
// only its own internal address as the sender, so a genuine notification must still be honoured.
|
||||||
|
func TestYooKassaNotifyIsAcceptedFromAnyAddress(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||||
|
|
||||||
|
if code := postYooKassaNotify(t, srv, "10.77.0.1", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||||
|
t.Fatalf("notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||||
|
t.Errorf("balance = %d, want 100 — a genuine notification was refused on its source address", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaNotifyRejectsLiveOnTestShop is the guard that keeps play money out of a live ledger and
|
||||||
|
// real money out of a test one: a payment whose test flag disagrees with the shop's is not credited.
|
||||||
|
func TestYooKassaNotifyRejectsLiveOnTestShop(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop) // the shop is a test shop
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||||
|
|
||||||
|
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Test = false }) // a live payment
|
||||||
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||||
|
t.Fatalf("notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||||
|
t.Errorf("balance = %d, want 0 — a live payment credited against test credentials", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaCanceledRecordsFailure checks an active decline records a failed payment event and
|
||||||
|
// credits nothing, so the customer learns the attempt did not go through.
|
||||||
|
func TestYooKassaCanceledRecordsFailure(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||||
|
|
||||||
|
f.setPayment(paymentID, func(p *yookassa.Payment) {
|
||||||
|
p.Status = yookassa.StatusCanceled
|
||||||
|
p.Paid = false
|
||||||
|
p.CancellationDetails = &yookassa.Cancellation{Party: "payment_network", Reason: "insufficient_funds"}
|
||||||
|
})
|
||||||
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentCanceled, paymentID, orderID.String()); code != http.StatusOK {
|
||||||
|
t.Fatalf("notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||||
|
t.Errorf("balance = %d, want 0 — a canceled payment credited chips", got)
|
||||||
|
}
|
||||||
|
if orderStatus(t, orderID) != "pending" {
|
||||||
|
t.Errorf("order status = %s, want pending (a decline does not settle the order)", orderStatus(t, orderID))
|
||||||
|
}
|
||||||
|
evs, err := pay.UndispatchedEvents(context.Background(), 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read events: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, e := range evs {
|
||||||
|
if e.AccountID == acc && e.Type == "failed" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("no failed payment event recorded for a declined payment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaReconcileCreditsLostNotification is the safety net: when no notification ever arrives,
|
||||||
|
// the expiry-time check asks the provider and credits an order that was in fact paid.
|
||||||
|
func TestYooKassaReconcileCreditsLostNotification(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
orderID, _ := seedYooKassaOrder(t, f, pay, acc)
|
||||||
|
|
||||||
|
// Age the order just past the re-check threshold — minutes, not the order's full lifetime — with
|
||||||
|
// no notification ever delivered.
|
||||||
|
if _, err := testDB.ExecContext(context.Background(),
|
||||||
|
`UPDATE payments.orders SET created_at = now() - interval '2 minutes' WHERE order_id=$1`, orderID); err != nil {
|
||||||
|
t.Fatalf("age order: %v", err)
|
||||||
|
}
|
||||||
|
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 1 {
|
||||||
|
t.Fatalf("reconciled %d orders, want 1", n)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||||
|
t.Errorf("balance = %d, want 100 — the safety net did not credit a paid order", got)
|
||||||
|
}
|
||||||
|
if orderStatus(t, orderID) != "paid" {
|
||||||
|
t.Errorf("order status = %s, want paid", orderStatus(t, orderID))
|
||||||
|
}
|
||||||
|
// A second sweep finds nothing left to do — the order is no longer pending.
|
||||||
|
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
|
||||||
|
t.Errorf("second sweep credited %d orders, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaReconcileSkipsFreshOrders keeps the sweep off an order the customer is still paying
|
||||||
|
// for: it re-checks only orders older than ReconcileAfter, so opening a payment page does not start
|
||||||
|
// polling the provider straight away.
|
||||||
|
func TestYooKassaReconcileSkipsFreshOrders(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
seedYooKassaOrder(t, f, pay, acc) // just created
|
||||||
|
|
||||||
|
before := f.paymentGets
|
||||||
|
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
|
||||||
|
t.Errorf("reconciled %d orders, want 0 (the order is seconds old)", n)
|
||||||
|
}
|
||||||
|
if f.paymentGets != before {
|
||||||
|
t.Errorf("confirming reads = %d, want %d — a fresh order was polled", f.paymentGets, before)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||||
|
t.Errorf("balance = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaReconcileLeavesUnpaidOrders checks the sweep does not invent a credit for an order the
|
||||||
|
// customer abandoned.
|
||||||
|
func TestYooKassaReconcileLeavesUnpaidOrders(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||||
|
f.setPayment(paymentID, func(p *yookassa.Payment) { p.Status = yookassa.StatusCanceled; p.Paid = false })
|
||||||
|
|
||||||
|
if _, err := testDB.ExecContext(context.Background(),
|
||||||
|
`UPDATE payments.orders SET created_at = now() - interval '2 days' WHERE order_id=$1`, orderID); err != nil {
|
||||||
|
t.Fatalf("age order: %v", err)
|
||||||
|
}
|
||||||
|
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
|
||||||
|
t.Errorf("reconciled %d orders, want 0 (the payment was never completed)", n)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||||
|
t.Errorf("balance = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaConsoleRefundMovesMoneyThenRecords drives the operator refund end to end: the provider
|
||||||
|
// refund is requested first and the ledger records the provider's own refund id.
|
||||||
|
func TestYooKassaConsoleRefundMovesMoneyThenRecords(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||||
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||||
|
t.Fatalf("notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := srv.Handler()
|
||||||
|
base := "http://admin.test/_gm/users/" + acc.String()
|
||||||
|
code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
|
||||||
|
if code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
|
||||||
|
t.Fatalf("refund = %d, body has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
|
||||||
|
}
|
||||||
|
if f.refundCalls != 1 {
|
||||||
|
t.Errorf("provider refunds requested = %d, want 1", f.refundCalls)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||||
|
t.Errorf("balance after refund = %d, want 0", got)
|
||||||
|
}
|
||||||
|
// The ledger records the provider's own refund id, which is what keeps it reconcilable against
|
||||||
|
// YooKassa's records — and is distinct from the payment id, so both rows coexist.
|
||||||
|
var provider, refundID string
|
||||||
|
if err := testDB.QueryRowContext(context.Background(),
|
||||||
|
`SELECT provider, provider_payment_id FROM payments.ledger WHERE account_id=$1 AND kind='refund'`,
|
||||||
|
acc).Scan(&provider, &refundID); err != nil {
|
||||||
|
t.Fatalf("read refund ledger row: %v", err)
|
||||||
|
}
|
||||||
|
if provider != "yookassa" || refundID != "refund-"+paymentID {
|
||||||
|
t.Errorf("refund ledger row = (%s, %s), want (yookassa, refund-%s)", provider, refundID, paymentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaRefundRecordsNothingWhenTheProviderFails is the invariant that keeps the ledger honest:
|
||||||
|
// if the money did not move, nothing is written.
|
||||||
|
func TestYooKassaRefundRecordsNothingWhenTheProviderFails(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||||
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||||
|
t.Fatalf("notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.refundFails = true
|
||||||
|
h := srv.Handler()
|
||||||
|
base := "http://admin.test/_gm/users/" + acc.String()
|
||||||
|
_, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
|
||||||
|
if !strings.Contains(body, "nothing was recorded") {
|
||||||
|
t.Errorf("refund failure message = %q, want it to say nothing was recorded", body)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||||
|
t.Errorf("balance = %d, want 100 — chips were revoked without the money moving", got)
|
||||||
|
}
|
||||||
|
if ledgerRows(t, acc, "refund") != 0 {
|
||||||
|
t.Error("a refund ledger row was written although the provider did not refund")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedBuyerWithEmail provisions an account with a confirmed email — the D36 anchor a direct purchase
|
||||||
|
// requires — and returns the account and the address.
|
||||||
|
func seedBuyerWithEmail(t *testing.T) (uuid.UUID, string) {
|
||||||
|
t.Helper()
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
// The full id, not a prefix: account ids are uuid v7, whose leading hex digits are the top bits
|
||||||
|
// of a millisecond timestamp and so are identical for accounts made moments apart.
|
||||||
|
email := "buyer-" + acc.String() + "@example.test"
|
||||||
|
if _, err := testDB.ExecContext(context.Background(),
|
||||||
|
`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
|
||||||
|
uuid.New(), acc, email); err != nil {
|
||||||
|
t.Fatalf("seed email identity: %v", err)
|
||||||
|
}
|
||||||
|
return acc, email
|
||||||
|
}
|
||||||
|
|
||||||
|
// postWalletOrder opens a purchase over HTTP as the account, on the direct/web platform.
|
||||||
|
func postWalletOrder(t *testing.T, srv *server.Server, acc, productID uuid.UUID) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
|
||||||
|
strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, productID)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-User-ID", acc.String())
|
||||||
|
req.Header.Set("X-Platform", "direct/web")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaOrderSendsNoReceipt pins the fiscal decision: the merchant is outside 54-ФЗ, so a
|
||||||
|
// purchase must carry no receipt at all — the provider registers nothing and the merchant reports
|
||||||
|
// each operation itself.
|
||||||
|
func TestYooKassaOrderSendsNoReceipt(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, _ := yookassaServer(t, shop)
|
||||||
|
acc, _ := seedBuyerWithEmail(t)
|
||||||
|
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||||
|
|
||||||
|
rec := postWalletOrder(t, srv, acc, prod)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if f.lastReceipt != nil {
|
||||||
|
t.Errorf("the purchase carried a receipt (%v); receipts are off outside 54-ФЗ", f.lastReceipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaReceiptTurnsOnWithAVatCode proves the dormant fiscal path still works, so a lost НПД
|
||||||
|
// regime is a deploy-variable change rather than a code change.
|
||||||
|
func TestYooKassaReceiptTurnsOnWithAVatCode(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, _ := yookassaServerWithVat(t, shop, yookassa.VatCodeNone)
|
||||||
|
acc, email := seedBuyerWithEmail(t)
|
||||||
|
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||||
|
|
||||||
|
if rec := postWalletOrder(t, srv, acc, prod); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if f.lastReceipt == nil {
|
||||||
|
t.Fatal("no receipt sent although a VAT rate code is configured")
|
||||||
|
}
|
||||||
|
customer, _ := f.lastReceipt["customer"].(map[string]any)
|
||||||
|
if customer["email"] != email {
|
||||||
|
t.Errorf("receipt customer = %v, want the confirmed email %q", customer, email)
|
||||||
|
}
|
||||||
|
items, _ := f.lastReceipt["items"].([]any)
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("receipt items = %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
item, _ := items[0].(map[string]any)
|
||||||
|
if item["vat_code"] != float64(yookassa.VatCodeNone) || item["payment_subject"] != yookassa.PaymentSubjectService {
|
||||||
|
t.Errorf("receipt fiscal attributes = %v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaOrderMintsAPayment drives the purchase path over HTTP: the order endpoint calls the
|
||||||
|
// provider, threads the order id through, and hands the client the hosted payment page.
|
||||||
|
func TestYooKassaOrderMintsAPayment(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, _ := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
const email = "buyer@example.test"
|
||||||
|
if _, err := testDB.ExecContext(context.Background(),
|
||||||
|
`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
|
||||||
|
uuid.New(), acc, email); err != nil {
|
||||||
|
t.Fatalf("seed email identity: %v", err)
|
||||||
|
}
|
||||||
|
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
|
||||||
|
strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-User-ID", acc.String())
|
||||||
|
req.Header.Set("X-Platform", "direct/web")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("order = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
OrderID string `json:"order_id"`
|
||||||
|
RedirectURL string `json:"redirect_url"`
|
||||||
|
Rail string `json:"rail"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode order response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Rail != "yookassa" || !strings.HasPrefix(out.RedirectURL, "https://yoomoney.test/pay/") {
|
||||||
|
t.Errorf("order response = %+v, want the yookassa rail and its hosted payment page", out)
|
||||||
|
}
|
||||||
|
// The order id is both the idempotency key (so a retried create cannot mint a second payment)
|
||||||
|
// and the metadata that later resolves a notification to this order.
|
||||||
|
if f.createdIdempotenceKey != out.OrderID {
|
||||||
|
t.Errorf("Idempotence-Key = %q, want the order id %q", f.createdIdempotenceKey, out.OrderID)
|
||||||
|
}
|
||||||
|
// The payment id is recorded on the order, which is what the safety net and a refund need.
|
||||||
|
orderID, err := uuid.Parse(out.OrderID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse order id: %v", err)
|
||||||
|
}
|
||||||
|
var paymentID string
|
||||||
|
if err := testDB.QueryRowContext(context.Background(),
|
||||||
|
`SELECT provider_payment_id FROM payments.orders WHERE order_id=$1`, orderID).Scan(&paymentID); err != nil {
|
||||||
|
t.Fatalf("read order payment id: %v", err)
|
||||||
|
}
|
||||||
|
if paymentID != "pay-"+out.OrderID {
|
||||||
|
t.Errorf("order provider_payment_id = %q, want the minted payment id", paymentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaOrderRequiresAnEmailAnchor keeps D36 in force on the new rail: without a confirmed
|
||||||
|
// address there is no recovery anchor and nowhere to deliver the fiscal receipt, so no payment is
|
||||||
|
// minted at all.
|
||||||
|
func TestYooKassaOrderRequiresAnEmailAnchor(t *testing.T) {
|
||||||
|
_, shop := newFakeYooKassa(t)
|
||||||
|
srv, _ := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t) // a telegram identity only — no confirmed email
|
||||||
|
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/wallet/order",
|
||||||
|
strings.NewReader(fmt.Sprintf(`{"product_id":%q}`, prod)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-User-ID", acc.String())
|
||||||
|
req.Header.Set("X-Platform", "direct/web")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "email_required") {
|
||||||
|
t.Fatalf("order = %d (%s), want 403 email_required", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// postYooKassaRefundNotify posts a refund notification to the intake and reports the status code.
|
||||||
|
func postYooKassaRefundNotify(t *testing.T, srv *server.Server, refundID, paymentID string) int {
|
||||||
|
t.Helper()
|
||||||
|
body := fmt.Sprintf(`{"type":"notification","event":%q,"object":{"id":%q,"status":"succeeded","payment_id":%q}}`,
|
||||||
|
yookassa.EventRefundSucceeded, refundID, paymentID)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/payments/yookassa/notify", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Forwarded-For", ykSenderIP)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// fundedYooKassaOrder seeds an order and credits it, leaving an account with chips to reverse.
|
||||||
|
func fundedYooKassaOrder(t *testing.T, f *fakeYooKassa, srv *server.Server, pay *payments.Service, acc uuid.UUID) (uuid.UUID, string) {
|
||||||
|
t.Helper()
|
||||||
|
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||||
|
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||||
|
t.Fatalf("notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
return orderID, paymentID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaCabinetRefundIsReversed is why the refund event is handled at all: an operator can
|
||||||
|
// refund in the merchant cabinet, which never passes through our API, so without this the money would
|
||||||
|
// go back while the chips stayed credited.
|
||||||
|
func TestYooKassaCabinetRefundIsReversed(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||||
|
t.Fatalf("balance before the refund = %d, want 100", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The refund exists at the provider, issued outside our API.
|
||||||
|
f.setRefund("cabinet-refund-1", yookassa.Refund{
|
||||||
|
Status: yookassa.StatusSucceeded, PaymentID: paymentID,
|
||||||
|
Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
|
||||||
|
})
|
||||||
|
if code := postYooKassaRefundNotify(t, srv, "cabinet-refund-1", paymentID); code != http.StatusOK {
|
||||||
|
t.Fatalf("refund notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||||
|
t.Errorf("balance = %d, want 0 — a cabinet refund did not revoke the chips", got)
|
||||||
|
}
|
||||||
|
if f.refundCalls != 0 {
|
||||||
|
t.Errorf("provider refunds requested = %d, want 0 — the money had already moved", f.refundCalls)
|
||||||
|
}
|
||||||
|
var provider, refundID string
|
||||||
|
if err := testDB.QueryRowContext(context.Background(),
|
||||||
|
`SELECT provider, provider_payment_id FROM payments.ledger WHERE account_id=$1 AND kind='refund'`,
|
||||||
|
acc).Scan(&provider, &refundID); err != nil {
|
||||||
|
t.Fatalf("read refund ledger row: %v", err)
|
||||||
|
}
|
||||||
|
if provider != "yookassa" || refundID != "cabinet-refund-1" {
|
||||||
|
t.Errorf("refund ledger row = (%s, %s), want (yookassa, cabinet-refund-1)", provider, refundID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A redelivery reverses nothing a second time.
|
||||||
|
if code := postYooKassaRefundNotify(t, srv, "cabinet-refund-1", paymentID); code != http.StatusOK {
|
||||||
|
t.Fatalf("redelivered refund notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if ledgerRows(t, acc, "refund") != 1 {
|
||||||
|
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaRefundNotifyAfterConsoleRefundIsANoOp checks the two refund entry points cannot double
|
||||||
|
// up: the event for a refund the console already recorded reverses nothing again.
|
||||||
|
func TestYooKassaRefundNotifyAfterConsoleRefundIsANoOp(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
orderID, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
|
||||||
|
|
||||||
|
base := "http://admin.test/_gm/users/" + acc.String()
|
||||||
|
if code, _ := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test"); code != http.StatusOK {
|
||||||
|
t.Fatalf("console refund = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||||
|
t.Fatalf("balance after the console refund = %d, want 0", got)
|
||||||
|
}
|
||||||
|
// YooKassa then notifies about that same refund.
|
||||||
|
if code := postYooKassaRefundNotify(t, srv, "refund-"+paymentID, paymentID); code != http.StatusOK {
|
||||||
|
t.Fatalf("refund notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if ledgerRows(t, acc, "refund") != 1 {
|
||||||
|
t.Errorf("refund ledger rows = %d, want 1 (reversed once)", ledgerRows(t, acc, "refund"))
|
||||||
|
}
|
||||||
|
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
|
||||||
|
t.Errorf("risk = (abuse %v, loss %d), want none — the second reversal ran anyway", abuse, loss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaPartialRefundIsLeftToAnOperator: the reversal engine revokes exactly what the pack
|
||||||
|
// funded and rejects any other amount, so a partial refund cannot be recorded without guessing how
|
||||||
|
// many chips it costs. It must change nothing rather than guess.
|
||||||
|
func TestYooKassaPartialRefundIsLeftToAnOperator(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
|
||||||
|
|
||||||
|
f.setRefund("partial-1", yookassa.Refund{
|
||||||
|
Status: yookassa.StatusSucceeded, PaymentID: paymentID,
|
||||||
|
Amount: yookassa.Amount{Value: "50.00", Currency: "RUB"}, // part of the 149.00 order
|
||||||
|
})
|
||||||
|
if code := postYooKassaRefundNotify(t, srv, "partial-1", paymentID); code != http.StatusOK {
|
||||||
|
t.Fatalf("refund notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||||
|
t.Errorf("balance = %d, want 100 — a partial refund revoked chips", got)
|
||||||
|
}
|
||||||
|
if ledgerRows(t, acc, "refund") != 0 {
|
||||||
|
t.Error("a partial refund wrote a refund ledger row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaRefundNotifyIsNotEvidence: as with a payment, the body only names a refund. One the
|
||||||
|
// provider does not confirm reverses nothing.
|
||||||
|
func TestYooKassaRefundNotifyIsNotEvidence(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
_, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
|
||||||
|
|
||||||
|
// A refund the provider has never heard of.
|
||||||
|
if code := postYooKassaRefundNotify(t, srv, "invented-refund", paymentID); code != http.StatusOK {
|
||||||
|
t.Fatalf("refund notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
// One that exists but is not final.
|
||||||
|
f.setRefund("pending-refund", yookassa.Refund{
|
||||||
|
Status: yookassa.StatusPending, PaymentID: paymentID,
|
||||||
|
Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
|
||||||
|
})
|
||||||
|
if code := postYooKassaRefundNotify(t, srv, "pending-refund", paymentID); code != http.StatusOK {
|
||||||
|
t.Fatalf("refund notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||||
|
t.Errorf("balance = %d, want 100 — an unconfirmed refund revoked chips", got)
|
||||||
|
}
|
||||||
|
if ledgerRows(t, acc, "refund") != 0 {
|
||||||
|
t.Error("an unconfirmed refund wrote a refund ledger row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaPendingRefundRecordsNothing: a refund can still be canceled while pending and the
|
||||||
|
// ledger is append-only, so the console records only a completed one. Retrying is the way out.
|
||||||
|
func TestYooKassaPendingRefundRecordsNothing(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
orderID, _ := fundedYooKassaOrder(t, f, srv, pay, acc)
|
||||||
|
|
||||||
|
f.refundStatus = yookassa.StatusPending
|
||||||
|
base := "http://admin.test/_gm/users/" + acc.String()
|
||||||
|
_, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
|
||||||
|
if !strings.Contains(body, "not completed the refund yet") || !strings.Contains(body, "nothing was recorded") {
|
||||||
|
t.Errorf("message = %q, want it to say the refund is not final and nothing was recorded", body)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||||
|
t.Errorf("balance = %d, want 100 — chips were revoked for an unsettled refund", got)
|
||||||
|
}
|
||||||
|
if ledgerRows(t, acc, "refund") != 0 {
|
||||||
|
t.Error("a pending refund wrote a refund ledger row")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the provider settles it, pressing again records the reversal — the idempotency key returns
|
||||||
|
// the same refund rather than paying twice.
|
||||||
|
f.refundStatus = yookassa.StatusSucceeded
|
||||||
|
if code, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
|
||||||
|
t.Fatalf("retried refund = %d, body has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||||
|
t.Errorf("balance = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYooKassaConsoleRefundAfterItsOwnNotification covers the race the two recording paths really
|
||||||
|
// run: the console asks the provider to refund, the provider fires refund.succeeded at once, and the
|
||||||
|
// notification records the reversal before the console gets to. Both name the same refund id, so the
|
||||||
|
// idempotency index catches the second write and nothing is revoked twice — but the operator must be
|
||||||
|
// told the refund succeeded, not that they clicked twice.
|
||||||
|
func TestYooKassaConsoleRefundAfterItsOwnNotification(t *testing.T) {
|
||||||
|
f, shop := newFakeYooKassa(t)
|
||||||
|
srv, pay := yookassaServer(t, shop)
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
orderID, paymentID := fundedYooKassaOrder(t, f, srv, pay, acc)
|
||||||
|
|
||||||
|
// The provider's notification lands first, for the refund the console is about to create.
|
||||||
|
refundID := "refund-" + paymentID
|
||||||
|
f.setRefund(refundID, yookassa.Refund{
|
||||||
|
Status: yookassa.StatusSucceeded, PaymentID: paymentID,
|
||||||
|
Amount: yookassa.Amount{Value: "149.00", Currency: "RUB"},
|
||||||
|
})
|
||||||
|
if code := postYooKassaRefundNotify(t, srv, refundID, paymentID); code != http.StatusOK {
|
||||||
|
t.Fatalf("refund notify = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||||
|
t.Fatalf("balance after the notification = %d, want 0", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := "http://admin.test/_gm/users/" + acc.String()
|
||||||
|
code, body := consoleDo(srv.Handler(), http.MethodPost, base+"/refund", "order_id="+orderID.String(), "http://admin.test")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("console refund = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "Already refunded") {
|
||||||
|
t.Errorf("the console reported a repeat click for a refund it had just made successfully: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Refunded") {
|
||||||
|
t.Errorf("console message = %q, want it to report the refund succeeded", body)
|
||||||
|
}
|
||||||
|
if ledgerRows(t, acc, "refund") != 1 {
|
||||||
|
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
|
||||||
|
}
|
||||||
|
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
|
||||||
|
t.Errorf("risk = (abuse %v, loss %d), want none", abuse, loss)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,24 @@ import (
|
|||||||
// one manual full refund per order.
|
// one manual full refund per order.
|
||||||
const providerAdmin = "admin"
|
const providerAdmin = "admin"
|
||||||
|
|
||||||
// RefundOrderFull refunds a paid order in full at the operator's request: it revokes the funded
|
// RefundOrderFull refunds a paid order in full at the operator's request, recording the reversal
|
||||||
// chips best-effort (floored at 0, never negative — D27), records a refund ledger row, and is
|
// only. It is for the rails that have no refund API of our own to call: the operator performs the
|
||||||
// idempotent (a second call reports AlreadyRefunded). The operator performs the actual money refund
|
// actual money refund there by hand (VK support, Telegram refundStarPayment, the Robokassa cabinet),
|
||||||
// on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment); this records it.
|
// and this records it under the operator's own idempotency key.
|
||||||
func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (RefundOutcome, error) {
|
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)
|
o, err := s.store.orderByID(ctx, orderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RefundOutcome{}, err
|
return RefundOutcome{}, err
|
||||||
@@ -24,7 +37,7 @@ func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (Refun
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return RefundOutcome{}, err
|
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
|
// LedgerExportRow is one append-only ledger row for the tax / reconciliation export, carrying the
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -64,6 +65,101 @@ func directShop(cxt Context) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AttachProviderPayment records the provider's own payment identifier on a pending order, right
|
||||||
|
// after the provider mints it and before the customer has paid. Two later paths depend on it: the
|
||||||
|
// reconcile sweep, which asks the provider what became of an order no callback ever confirmed, and a
|
||||||
|
// refund, which must address the original payment. It does not credit anything and does not change
|
||||||
|
// the order status.
|
||||||
|
func (s *Service) AttachProviderPayment(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string) error {
|
||||||
|
return s.store.attachProviderPayment(ctx, orderID, provider, providerPaymentID, s.clock())
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderRef identifies a stored order to a provider: which rail settles it, that rail's own payment
|
||||||
|
// id, the merchant shop channel it was issued through, and the amount and account it belongs to. It
|
||||||
|
// is what the reconcile sweep and the refund path need without giving them the whole order.
|
||||||
|
type OrderRef struct {
|
||||||
|
OrderID uuid.UUID
|
||||||
|
AccountID uuid.UUID
|
||||||
|
Provider string
|
||||||
|
PaymentID string
|
||||||
|
Shop string
|
||||||
|
Amount Money
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderRef projects a stored order onto an OrderRef.
|
||||||
|
func orderRef(o orderRow) (OrderRef, error) {
|
||||||
|
amount, err := MoneyFromMinor(o.expectedAmount, Currency(o.currency))
|
||||||
|
if err != nil {
|
||||||
|
return OrderRef{}, err
|
||||||
|
}
|
||||||
|
return OrderRef{
|
||||||
|
OrderID: o.orderID,
|
||||||
|
AccountID: o.accountID,
|
||||||
|
Provider: o.provider,
|
||||||
|
PaymentID: o.paymentID,
|
||||||
|
Shop: o.shop,
|
||||||
|
Amount: amount,
|
||||||
|
Status: o.status,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderProviderRef reads how an order reaches its provider — the rail, that rail's payment id and
|
||||||
|
// the shop it was issued through. The refund path uses it to call the right merchant account.
|
||||||
|
func (s *Service) OrderProviderRef(ctx context.Context, orderID uuid.UUID) (OrderRef, error) {
|
||||||
|
o, err := s.store.orderByID(ctx, orderID)
|
||||||
|
if err != nil {
|
||||||
|
return OrderRef{}, err
|
||||||
|
}
|
||||||
|
return orderRef(o)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderByProviderPayment reads the order a provider's payment id belongs to. It resolves a provider
|
||||||
|
// event that names only its own payment — such as a refund notification — back to an order.
|
||||||
|
func (s *Service) OrderByProviderPayment(ctx context.Context, provider, providerPaymentID string) (OrderRef, error) {
|
||||||
|
o, err := s.store.orderByProviderPayment(ctx, provider, providerPaymentID)
|
||||||
|
if err != nil {
|
||||||
|
return OrderRef{}, err
|
||||||
|
}
|
||||||
|
return orderRef(o)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileBatch bounds one reconcile sweep, so a backlog cannot turn a periodic tick into a long
|
||||||
|
// run of provider calls.
|
||||||
|
const reconcileBatch = 50
|
||||||
|
|
||||||
|
// ReconcileAfter is how old a pending order must be before the provider is asked what became of it.
|
||||||
|
// It is deliberately NOT the order lifetime: that governs when an unpaid order is written off, which
|
||||||
|
// answers "how long may a customer take to pay", a different question from "how soon should we
|
||||||
|
// notice a lost callback". Tying the two together would make a customer wait a full lifetime for
|
||||||
|
// chips whenever the notification path fails. A minute is long enough that an order the customer is
|
||||||
|
// still paying for is not polled, and short enough that a failure costs minutes, not half an hour.
|
||||||
|
const ReconcileAfter = time.Minute
|
||||||
|
|
||||||
|
// PendingForReconcile returns the pending orders old enough to re-check that carry a provider payment
|
||||||
|
// id — the ones where the money may well have moved but no callback ever told us. The caller asks the
|
||||||
|
// provider for each one's real outcome. Orders that never reached a payment are not returned: there
|
||||||
|
// is nothing to ask about, and neither are orders younger than ReconcileAfter, so an order the
|
||||||
|
// customer is still paying for is left alone.
|
||||||
|
//
|
||||||
|
// The sweep repeats until the order settles or is written off, which bounds the provider calls one
|
||||||
|
// order can cause to its lifetime divided by the sweep interval — a handful, not an open-ended poll.
|
||||||
|
func (s *Service) PendingForReconcile(ctx context.Context) ([]OrderRef, error) {
|
||||||
|
rows, err := s.store.pendingForReconcile(ctx, int(ReconcileAfter.Seconds()), s.clock(), reconcileBatch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]OrderRef, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
ref, err := orderRef(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, ref)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
|
// 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
|
// 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).
|
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package payments
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -63,3 +64,100 @@ type LedgerEntry struct {
|
|||||||
func (s *Service) AccountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
|
func (s *Service) AccountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
|
||||||
return s.store.accountStatement(ctx, accountID)
|
return s.store.accountStatement(ctx, accountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LedgerFilter narrows the operator's ledger report. A zero value matches everything; each set field
|
||||||
|
// narrows further. From is inclusive and To exclusive, so a day range does not double-count a row on
|
||||||
|
// the boundary.
|
||||||
|
//
|
||||||
|
// Wallet and Provider are deliberately separate axes, because they answer different questions.
|
||||||
|
// Wallet ("what happened on VK") matches the row's funded segment or the origin a benefit was bought
|
||||||
|
// in; Provider ("what came through YooKassa") matches the rail that settled it — and a chip spend has
|
||||||
|
// no rail at all, so a provider filter excludes spends by construction.
|
||||||
|
type LedgerFilter struct {
|
||||||
|
From time.Time
|
||||||
|
To time.Time
|
||||||
|
AccountID uuid.UUID
|
||||||
|
Kind string
|
||||||
|
Wallet string
|
||||||
|
Provider string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerReportRow is one ledger row for the operator report: the entry itself, the account it
|
||||||
|
// belongs to (the per-account report already knows that, the all-accounts one does not) and the
|
||||||
|
// money the row moved, recovered from the snapshot — the ledger's own columns count chips.
|
||||||
|
type LedgerReportRow struct {
|
||||||
|
LedgerEntry
|
||||||
|
AccountID uuid.UUID
|
||||||
|
// Money is what the customer actually paid or was refunded, or the zero Money for a row that
|
||||||
|
// moved no money (a spend, an admin grant, a rewarded-video credit).
|
||||||
|
Money Money
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasMoney reports whether the row moved real money.
|
||||||
|
func (r LedgerReportRow) HasMoney() bool { return r.Money.Currency() != "" }
|
||||||
|
|
||||||
|
// LedgerTotals sums everything a filter matches, not merely the page on screen. Money is listed per
|
||||||
|
// currency because the rails settle in different ones (roubles, Votes, Stars) and summing across
|
||||||
|
// them would be meaningless.
|
||||||
|
type LedgerTotals struct {
|
||||||
|
MoneyIn []Money
|
||||||
|
MoneyRefunded []Money
|
||||||
|
ChipsIn int
|
||||||
|
ChipsOut int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerReport is one page of the filtered ledger plus the totals for the whole filtered range and
|
||||||
|
// how many rows it matches.
|
||||||
|
type LedgerReport struct {
|
||||||
|
Rows []LedgerReportRow
|
||||||
|
Totals LedgerTotals
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportLimit caps the CSV export. The ledger is append-only and grows forever, so an unbounded
|
||||||
|
// export would eventually time out; the filter is the way to narrow a real accounting export.
|
||||||
|
const exportLimit = 100_000
|
||||||
|
|
||||||
|
// LedgerReportPage reads one page of the filtered ledger together with the totals for everything the
|
||||||
|
// filter matches. It is the all-accounts operator view: uncached, admin-only, not a hot path.
|
||||||
|
func (s *Service) LedgerReportPage(ctx context.Context, f LedgerFilter, limit, offset int) (LedgerReport, error) {
|
||||||
|
rows, total, err := s.store.ledgerPage(ctx, f, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return LedgerReport{}, err
|
||||||
|
}
|
||||||
|
totals, err := s.store.ledgerTotals(ctx, f)
|
||||||
|
if err != nil {
|
||||||
|
return LedgerReport{}, err
|
||||||
|
}
|
||||||
|
return LedgerReport{Rows: rows, Totals: totals, Total: total}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilteredLedgerExport reads the whole filtered ledger for the CSV export, capped at exportLimit.
|
||||||
|
func (s *Service) FilteredLedgerExport(ctx context.Context, f LedgerFilter) ([]LedgerReportRow, error) {
|
||||||
|
return s.store.filteredLedger(ctx, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoneyFromLedgerSnapshot recovers the money a ledger row moved from its snapshot, returning the
|
||||||
|
// zero Money when the row carries none (a spend, a grant) or the snapshot predates the fields. It is
|
||||||
|
// exported for callers folding their own totals out of a Statement's history.
|
||||||
|
func MoneyFromLedgerSnapshot(snapshot string) Money { return moneyFromSnapshot(snapshot) }
|
||||||
|
|
||||||
|
// moneyFromSnapshot recovers the money a ledger row moved from its snapshot, returning the zero
|
||||||
|
// Money when the row carries none (a spend, a grant) or the snapshot predates the fields.
|
||||||
|
func moneyFromSnapshot(snapshot string) Money {
|
||||||
|
if snapshot == "" {
|
||||||
|
return Money{}
|
||||||
|
}
|
||||||
|
var snap struct {
|
||||||
|
Amount int64 `json:"amount_minor"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(snapshot), &snap); err != nil || snap.Currency == "" {
|
||||||
|
return Money{}
|
||||||
|
}
|
||||||
|
m, err := MoneyFromMinor(snap.Amount, Currency(snap.Currency))
|
||||||
|
if err != nil {
|
||||||
|
return Money{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|||||||
@@ -182,6 +182,9 @@ type orderRow struct {
|
|||||||
currency string
|
currency string
|
||||||
origin string
|
origin string
|
||||||
status string
|
status string
|
||||||
|
provider string
|
||||||
|
paymentID string
|
||||||
|
shop string
|
||||||
}
|
}
|
||||||
|
|
||||||
// orderByID reads an order, or ErrOrderNotFound.
|
// 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 {
|
if err != nil {
|
||||||
return orderRow{}, fmt.Errorf("payments: load order %s: %w", orderID, err)
|
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{
|
return orderRow{
|
||||||
orderID: o.OrderID,
|
orderID: o.OrderID,
|
||||||
accountID: o.AccountID,
|
accountID: o.AccountID,
|
||||||
@@ -206,7 +215,83 @@ func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, err
|
|||||||
currency: o.Currency,
|
currency: o.Currency,
|
||||||
origin: o.Origin,
|
origin: o.Origin,
|
||||||
status: o.Status,
|
status: o.Status,
|
||||||
}, nil
|
provider: derefString(o.Provider),
|
||||||
|
paymentID: derefString(o.ProviderPaymentID),
|
||||||
|
shop: o.Shop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// derefString reads a nullable text column as a plain string, treating NULL as empty.
|
||||||
|
func derefString(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderByProviderPayment reads the order a provider's payment id belongs to, or ErrOrderNotFound.
|
||||||
|
// It is how a provider event that names only its own payment — a refund notification, say — is
|
||||||
|
// resolved back to an order.
|
||||||
|
func (s *Store) orderByProviderPayment(ctx context.Context, provider, providerPaymentID string) (orderRow, error) {
|
||||||
|
if provider == "" || providerPaymentID == "" {
|
||||||
|
return orderRow{}, ErrOrderNotFound
|
||||||
|
}
|
||||||
|
var o model.Orders
|
||||||
|
err := postgres.SELECT(table.Orders.AllColumns).
|
||||||
|
FROM(table.Orders).
|
||||||
|
WHERE(table.Orders.Provider.EQ(postgres.String(provider)).
|
||||||
|
AND(table.Orders.ProviderPaymentID.EQ(postgres.String(providerPaymentID)))).
|
||||||
|
LIMIT(1).
|
||||||
|
QueryContext(ctx, s.db, &o)
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return orderRow{}, ErrOrderNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return orderRow{}, fmt.Errorf("payments: load order by %s payment %s: %w", provider, providerPaymentID, err)
|
||||||
|
}
|
||||||
|
return newOrderRow(o), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachProviderPayment records the provider's own payment id on a pending order, as soon as the
|
||||||
|
// provider mints it. It is what later lets an unattended order be re-checked against the provider
|
||||||
|
// and a refund address the right payment; fund overwrites it with the same value when the callback
|
||||||
|
// lands. It never changes the order status.
|
||||||
|
func (s *Store) attachProviderPayment(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, now time.Time) error {
|
||||||
|
_, err := table.Orders.
|
||||||
|
UPDATE(table.Orders.Provider, table.Orders.ProviderPaymentID, table.Orders.UpdatedAt).
|
||||||
|
SET(postgres.String(provider), postgres.String(providerPaymentID), postgres.TimestampzT(now)).
|
||||||
|
WHERE(table.Orders.OrderID.EQ(postgres.UUID(orderID))).
|
||||||
|
ExecContext(ctx, s.db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("payments: attach provider payment to order %s: %w", orderID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingForReconcile reads the pending orders older than ageSeconds that carry a provider payment
|
||||||
|
// id — the ones whose real outcome is still unknown to us because no callback ever arrived. The
|
||||||
|
// caller asks the provider what happened. Orders with no provider payment id are skipped: the
|
||||||
|
// customer never got as far as a payment.
|
||||||
|
func (s *Store) pendingForReconcile(ctx context.Context, ageSeconds int, now time.Time, limit int) ([]orderRow, error) {
|
||||||
|
cutoff := now.Add(-time.Duration(ageSeconds) * time.Second)
|
||||||
|
var rows []model.Orders
|
||||||
|
err := postgres.SELECT(table.Orders.AllColumns).
|
||||||
|
FROM(table.Orders).
|
||||||
|
WHERE(table.Orders.Status.EQ(postgres.String("pending")).
|
||||||
|
AND(table.Orders.CreatedAt.LT(postgres.TimestampzT(cutoff))).
|
||||||
|
AND(table.Orders.ProviderPaymentID.IS_NOT_NULL()).
|
||||||
|
AND(table.Orders.ProviderPaymentID.NOT_EQ(postgres.String("")))).
|
||||||
|
ORDER_BY(table.Orders.CreatedAt.ASC()).
|
||||||
|
LIMIT(int64(limit)).
|
||||||
|
QueryContext(ctx, s.db, &rows)
|
||||||
|
if err != nil && !errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("payments: load orders for reconcile: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]orderRow, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, newOrderRow(r))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FundOutcome reports the result of an intake credit: whose balance, which segment and how many
|
// FundOutcome reports the result of an intake credit: whose balance, which segment and how many
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ledgerColumns is the projection every ledger report row is read through, joined to the order so a
|
||||||
|
// row carries the merchant channel its payment used.
|
||||||
|
const ledgerColumns = `l.account_id, l.kind, l.source, l.origin, l.chips_delta, l.product_id,
|
||||||
|
l.order_id, l.provider, l.provider_payment_id, COALESCE(o.shop, ''), l.snapshot, l.created_at`
|
||||||
|
|
||||||
|
// ledgerWhere renders a LedgerFilter as a SQL predicate plus its arguments. Every value is bound as
|
||||||
|
// a parameter; only the fixed fragments are concatenated.
|
||||||
|
//
|
||||||
|
// The wallet filter deliberately matches either side of a row. A row names up to two wallets — the
|
||||||
|
// segment whose chips moved (source) and, for a benefit purchase, where that benefit was bought
|
||||||
|
// (origin) — and an operator asking "what happened on VK" means both.
|
||||||
|
func ledgerWhere(f LedgerFilter) (string, []any) {
|
||||||
|
var clauses []string
|
||||||
|
var args []any
|
||||||
|
add := func(clause string, v any) {
|
||||||
|
args = append(args, v)
|
||||||
|
clauses = append(clauses, fmt.Sprintf(clause, len(args)))
|
||||||
|
}
|
||||||
|
if !f.From.IsZero() {
|
||||||
|
add("l.created_at >= $%d", f.From)
|
||||||
|
}
|
||||||
|
if !f.To.IsZero() {
|
||||||
|
add("l.created_at < $%d", f.To)
|
||||||
|
}
|
||||||
|
if f.AccountID != uuid.Nil {
|
||||||
|
add("l.account_id = $%d", f.AccountID)
|
||||||
|
}
|
||||||
|
if f.Kind != "" {
|
||||||
|
add("l.kind = $%d", f.Kind)
|
||||||
|
}
|
||||||
|
if f.Provider != "" {
|
||||||
|
add("l.provider = $%d", f.Provider)
|
||||||
|
}
|
||||||
|
if f.Wallet != "" {
|
||||||
|
args = append(args, f.Wallet)
|
||||||
|
clauses = append(clauses, fmt.Sprintf("(l.source = $%d OR l.origin = $%d)", len(args), len(args)))
|
||||||
|
}
|
||||||
|
if len(clauses) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledgerPage reads one page of the filtered ledger, newest first, together with how many rows the
|
||||||
|
// filter matches in total (which is what the pager needs — the page itself cannot report it).
|
||||||
|
func (s *Store) ledgerPage(ctx context.Context, f LedgerFilter, limit, offset int) ([]LedgerReportRow, int, error) {
|
||||||
|
where, args := ledgerWhere(f)
|
||||||
|
|
||||||
|
var total int
|
||||||
|
if err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM payments.ledger l`+where, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("payments: count ledger: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `SELECT ` + ledgerColumns + `
|
||||||
|
FROM payments.ledger l
|
||||||
|
LEFT JOIN payments.orders o ON o.order_id = l.order_id` + where + `
|
||||||
|
ORDER BY l.created_at DESC, l.ledger_id DESC
|
||||||
|
LIMIT $%d OFFSET $%d`
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
rows, err := s.db.QueryContext(ctx, fmt.Sprintf(q, len(args)-1, len(args)), args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("payments: read ledger page: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []LedgerReportRow
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
r LedgerReportRow
|
||||||
|
accountID uuid.UUID
|
||||||
|
source, origin, provider, paymentID sql.NullString
|
||||||
|
productID, orderID sql.NullString
|
||||||
|
snapshot sql.NullString
|
||||||
|
shop string
|
||||||
|
chipsDelta int32
|
||||||
|
createdAt time.Time
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&accountID, &r.Kind, &source, &origin, &chipsDelta, &productID,
|
||||||
|
&orderID, &provider, &paymentID, &shop, &snapshot, &createdAt); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("payments: scan ledger row: %w", err)
|
||||||
|
}
|
||||||
|
r.AccountID = accountID
|
||||||
|
r.LedgerEntry = LedgerEntry{
|
||||||
|
Kind: r.Kind,
|
||||||
|
Source: source.String,
|
||||||
|
Origin: origin.String,
|
||||||
|
ChipsDelta: int(chipsDelta),
|
||||||
|
ProductID: productID.String,
|
||||||
|
OrderID: orderID.String,
|
||||||
|
Provider: provider.String,
|
||||||
|
ProviderPaymentID: paymentID.String,
|
||||||
|
Shop: shop,
|
||||||
|
Snapshot: snapshot.String,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
}
|
||||||
|
r.Money = moneyFromSnapshot(snapshot.String)
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("payments: read ledger page: %w", err)
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledgerTotals sums what the filter matches — across every matching row, not just the page on
|
||||||
|
// screen, which is the point of showing them at all. Money is summed per currency from the row
|
||||||
|
// snapshot, since the ledger's own columns count chips, not money.
|
||||||
|
func (s *Store) ledgerTotals(ctx context.Context, f LedgerFilter) (LedgerTotals, error) {
|
||||||
|
where, args := ledgerWhere(f)
|
||||||
|
out := LedgerTotals{}
|
||||||
|
|
||||||
|
if err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT COALESCE(SUM(l.chips_delta) FILTER (WHERE l.chips_delta > 0), 0),
|
||||||
|
COALESCE(-SUM(l.chips_delta) FILTER (WHERE l.chips_delta < 0), 0)
|
||||||
|
FROM payments.ledger l`+where, args...).Scan(&out.ChipsIn, &out.ChipsOut); err != nil {
|
||||||
|
return LedgerTotals{}, fmt.Errorf("payments: sum ledger chips: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The money predicate is folded into the same WHERE, because the filter's own clause list may be
|
||||||
|
// empty and an "AND ..." tacked onto a missing WHERE is a syntax error.
|
||||||
|
moneyWhere := where
|
||||||
|
const moneyOnly = `l.kind IN ('fund','refund') AND l.snapshot->>'amount_minor' IS NOT NULL`
|
||||||
|
if moneyWhere == "" {
|
||||||
|
moneyWhere = " WHERE " + moneyOnly
|
||||||
|
} else {
|
||||||
|
moneyWhere += " AND " + moneyOnly
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT l.kind, l.snapshot->>'currency',
|
||||||
|
COALESCE(SUM((l.snapshot->>'amount_minor')::bigint), 0)
|
||||||
|
FROM payments.ledger l`+moneyWhere+`
|
||||||
|
GROUP BY 1, 2`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return LedgerTotals{}, fmt.Errorf("payments: sum ledger money: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var kind string
|
||||||
|
var currency sql.NullString
|
||||||
|
var minor int64
|
||||||
|
if err := rows.Scan(&kind, ¤cy, &minor); err != nil {
|
||||||
|
return LedgerTotals{}, fmt.Errorf("payments: scan ledger money: %w", err)
|
||||||
|
}
|
||||||
|
m, err := MoneyFromMinor(minor, Currency(currency.String))
|
||||||
|
if err != nil {
|
||||||
|
continue // an unknown currency in an old snapshot must not break the report
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case "fund":
|
||||||
|
out.MoneyIn = append(out.MoneyIn, m)
|
||||||
|
case "refund":
|
||||||
|
out.MoneyRefunded = append(out.MoneyRefunded, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return LedgerTotals{}, fmt.Errorf("payments: sum ledger money: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// filteredLedger reads the whole filtered ledger for the CSV export — unpaginated by design, since
|
||||||
|
// an export of the page on screen would be useless for accounting.
|
||||||
|
func (s *Store) filteredLedger(ctx context.Context, f LedgerFilter) ([]LedgerReportRow, error) {
|
||||||
|
rows, _, err := s.ledgerPage(ctx, f, exportLimit, 0)
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
@@ -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
|
// 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.
|
// 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
|
// 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
|
// 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.
|
// 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)
|
s.internal.GET("/offer/pricing", s.handleOfferPricing)
|
||||||
}
|
}
|
||||||
if s.payments != nil {
|
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
|
// 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
|
// gateway-only (the single writer): the VK payment callback (both phases handled here), the
|
||||||
// the Robokassa Result callback when a merchant is configured.
|
// YooKassa notification, and the retired Robokassa Result callback if a merchant is ever
|
||||||
|
// configured again.
|
||||||
u.POST("/wallet/order", s.handleWalletOrder)
|
u.POST("/wallet/order", s.handleWalletOrder)
|
||||||
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
|
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
|
||||||
// The Telegram Stars rail: the bot forwards a pre_checkout validation and a completed
|
// 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.
|
// 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/precheckout", s.handleTelegramPreCheckout)
|
||||||
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
|
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
|
||||||
|
if s.yookassa.Configured() {
|
||||||
|
s.internal.POST("/payments/yookassa/notify", s.handleYooKassaNotify)
|
||||||
|
}
|
||||||
if s.robokassa.Configured() {
|
if s.robokassa.Configured() {
|
||||||
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -119,6 +120,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||||
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
|
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
|
||||||
|
gm.GET("/ledger", s.consoleLedger)
|
||||||
gm.GET("/ledger.csv", s.consoleLedgerExport)
|
gm.GET("/ledger.csv", s.consoleLedgerExport)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,7 +460,7 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
if s.payments != nil {
|
if s.payments != nil {
|
||||||
if stmt, err := s.payments.AccountStatement(ctx, id); err == nil {
|
if stmt, err := s.payments.AccountStatement(ctx, id); err == nil {
|
||||||
view.Finance = financeView(stmt)
|
view.Finance = financeView(id, stmt)
|
||||||
} else {
|
} else {
|
||||||
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -470,9 +472,11 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// financeView projects an account's payments statement into the user-card finance panel, with the
|
// financeView projects an account's payments statement into the user-card finance panel: where the
|
||||||
// benefit expiry and ledger times pre-formatted for the logic-free template.
|
// account stands (balances, benefits, lifetime money and chip totals), not what happened when. The
|
||||||
func financeView(stmt payments.Statement) adminconsole.FinanceView {
|
// operations themselves are the ledger section's job, which the panel links to pre-filtered to this
|
||||||
|
// account — one place renders them, with filters and paging, instead of two.
|
||||||
|
func financeView(id uuid.UUID, stmt payments.Statement) adminconsole.FinanceView {
|
||||||
fv := adminconsole.FinanceView{Present: true, Abuse: stmt.Risk.Abuse, Loss: stmt.Risk.LossChips}
|
fv := adminconsole.FinanceView{Present: true, Abuse: stmt.Risk.Abuse, Loss: stmt.Risk.LossChips}
|
||||||
for _, sg := range stmt.Segments {
|
for _, sg := range stmt.Segments {
|
||||||
fv.Segments = append(fv.Segments, adminconsole.SegmentRow{Source: string(sg.Source), Chips: sg.Chips})
|
fv.Segments = append(fv.Segments, adminconsole.SegmentRow{Source: string(sg.Source), Chips: sg.Chips})
|
||||||
@@ -484,16 +488,52 @@ func financeView(stmt payments.Statement) adminconsole.FinanceView {
|
|||||||
}
|
}
|
||||||
fv.Benefits = append(fv.Benefits, row)
|
fv.Benefits = append(fv.Benefits, row)
|
||||||
}
|
}
|
||||||
|
// The lifetime totals are folded from the history the statement already carries, so the summary
|
||||||
|
// costs no extra query. Money is kept per currency: the rails settle in roubles, Votes and Stars,
|
||||||
|
// and one sum across them would mean nothing.
|
||||||
|
paid, refunded := map[payments.Currency]int64{}, map[payments.Currency]int64{}
|
||||||
for _, e := range stmt.Ledger {
|
for _, e := range stmt.Ledger {
|
||||||
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
|
if e.ChipsDelta > 0 {
|
||||||
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
|
fv.ChipsBought += e.ChipsDelta
|
||||||
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Shop: e.Shop, Snapshot: e.Snapshot,
|
} else if e.Kind == "spend" {
|
||||||
At: fmtTime(e.CreatedAt),
|
fv.ChipsSpent -= e.ChipsDelta
|
||||||
})
|
}
|
||||||
|
m := payments.MoneyFromLedgerSnapshot(e.Snapshot)
|
||||||
|
if m.Currency() == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch e.Kind {
|
||||||
|
case "fund":
|
||||||
|
paid[m.Currency()] += m.Minor()
|
||||||
|
case "refund":
|
||||||
|
refunded[m.Currency()] += m.Minor()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
fv.Paid = fmtMoneyTotals(paid)
|
||||||
|
fv.Refunded = fmtMoneyTotals(refunded)
|
||||||
|
fv.LedgerQuery = template.URL(url.Values{"user": {id.String()}}.Encode())
|
||||||
return fv
|
return fv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fmtMoneyTotals renders per-currency minor-unit sums as display strings, in a stable order so the
|
||||||
|
// panel does not reshuffle between reloads (map iteration is random).
|
||||||
|
func fmtMoneyTotals(totals map[payments.Currency]int64) []string {
|
||||||
|
currencies := make([]string, 0, len(totals))
|
||||||
|
for c := range totals {
|
||||||
|
currencies = append(currencies, string(c))
|
||||||
|
}
|
||||||
|
sort.Strings(currencies)
|
||||||
|
out := make([]string, 0, len(currencies))
|
||||||
|
for _, c := range currencies {
|
||||||
|
m, err := payments.MoneyFromMinor(totals[payments.Currency(c)], payments.Currency(c))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, fmtMoney(m))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// overrideName renders a purchase override as the form/select value ("default"/"allow"/"deny").
|
// overrideName renders a purchase override as the form/select value ("default"/"allow"/"deny").
|
||||||
func overrideName(ov payments.PurchaseOverride) string {
|
func overrideName(ov payments.PurchaseOverride) string {
|
||||||
switch ov {
|
switch ov {
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/adminconsole"
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ledgerPageSize is how many operations one ledger page shows.
|
||||||
|
const ledgerPageSize = 100
|
||||||
|
|
||||||
|
// ledgerDefaultDays is how far back the ledger looks when the operator has not chosen a range. A
|
||||||
|
// month is the reporting period that matters (it is what a tax filing covers) and it keeps the
|
||||||
|
// default view bounded on a ledger that only ever grows.
|
||||||
|
const ledgerDefaultDays = 30
|
||||||
|
|
||||||
|
// ledgerDateFormat is the date form the filter accepts and renders (an <input type="date"> value).
|
||||||
|
const ledgerDateFormat = "2006-01-02"
|
||||||
|
|
||||||
|
// ledgerKinds, ledgerWallets and ledgerProviders are the filter's fixed option lists. They are
|
||||||
|
// spelled out rather than derived from the data so the form is stable on an empty ledger and an
|
||||||
|
// operator can see what exists at all.
|
||||||
|
var (
|
||||||
|
ledgerKinds = []string{"fund", "spend", "admin_grant", "refund"}
|
||||||
|
ledgerWallets = []string{string(payments.SourceDirect), string(payments.SourceVK), string(payments.SourceTelegram)}
|
||||||
|
ledgerProviders = []string{providerYooKassa, providerRobokassa, providerVK, providerTelegram, "vk_ads", "admin"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ledgerFilterFrom reads the ledger filter out of the query string, defaulting the range to the last
|
||||||
|
// ledgerDefaultDays. An unparseable date or id is treated as unset rather than as an error: a
|
||||||
|
// hand-edited URL should narrow nothing, not break the page.
|
||||||
|
func ledgerFilterFrom(c *gin.Context, now time.Time) (payments.LedgerFilter, adminconsole.LedgerView) {
|
||||||
|
view := adminconsole.LedgerView{
|
||||||
|
Kinds: ledgerKinds,
|
||||||
|
Wallets: ledgerWallets,
|
||||||
|
Providers: ledgerProviders,
|
||||||
|
}
|
||||||
|
f := payments.LedgerFilter{}
|
||||||
|
|
||||||
|
from, to := strings.TrimSpace(c.Query("from")), strings.TrimSpace(c.Query("to"))
|
||||||
|
if from == "" && to == "" {
|
||||||
|
from = now.AddDate(0, 0, -ledgerDefaultDays).Format(ledgerDateFormat)
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(ledgerDateFormat, from); err == nil {
|
||||||
|
f.From, view.From = t, from
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(ledgerDateFormat, to); err == nil {
|
||||||
|
// The range is inclusive of the chosen end date, so it runs to the start of the next day.
|
||||||
|
f.To, view.To = t.AddDate(0, 0, 1), to
|
||||||
|
}
|
||||||
|
if id, err := uuid.Parse(strings.TrimSpace(c.Query("user"))); err == nil {
|
||||||
|
f.AccountID, view.UserID = id, id.String()
|
||||||
|
}
|
||||||
|
if v := c.Query("kind"); slicesHas(ledgerKinds, v) {
|
||||||
|
f.Kind, view.Kind = v, v
|
||||||
|
}
|
||||||
|
if v := c.Query("wallet"); slicesHas(ledgerWallets, v) {
|
||||||
|
f.Wallet, view.Wallet = v, v
|
||||||
|
}
|
||||||
|
if v := c.Query("provider"); slicesHas(ledgerProviders, v) {
|
||||||
|
f.Provider, view.Provider = v, v
|
||||||
|
}
|
||||||
|
view.FilterQuery = template.URL(ledgerFilterQuery(view))
|
||||||
|
return f, view
|
||||||
|
}
|
||||||
|
|
||||||
|
// slicesHas reports whether v is one of the allowed options. The filter only ever accepts a value
|
||||||
|
// from its own list, so a crafted query string cannot reach the store with something unexpected.
|
||||||
|
func slicesHas(allowed []string, v string) bool {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if a == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledgerFilterQuery renders the active filters as an escaped query fragment. Every link that must
|
||||||
|
// stay on the same slice of the ledger — the pager, the CSV export, a refund's way back — is built
|
||||||
|
// from it, which is what keeps them in step with the form.
|
||||||
|
func ledgerFilterQuery(v adminconsole.LedgerView) string {
|
||||||
|
q := url.Values{}
|
||||||
|
for key, val := range map[string]string{
|
||||||
|
"from": v.From, "to": v.To, "user": v.UserID,
|
||||||
|
"kind": v.Kind, "wallet": v.Wallet, "provider": v.Provider,
|
||||||
|
} {
|
||||||
|
if val != "" {
|
||||||
|
q.Set(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleLedger renders the all-accounts financial ledger: one page of operations under the current
|
||||||
|
// filter, and the totals for everything that filter matches — the picture no per-account card can
|
||||||
|
// give. The page number rides the same query string as the filters, so paging never silently widens
|
||||||
|
// the view.
|
||||||
|
func (s *Server) consoleLedger(c *gin.Context) {
|
||||||
|
f, view := ledgerFilterFrom(c, time.Now())
|
||||||
|
page, _ := strconv.Atoi(c.Query("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
report, err := s.payments.LedgerReportPage(c.Request.Context(), f, ledgerPageSize, (page-1)*ledgerPageSize)
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view.Pager = adminconsole.NewPager(page, ledgerPageSize, report.Total)
|
||||||
|
view.Totals = ledgerTotalsRow(report.Totals)
|
||||||
|
for _, r := range report.Rows {
|
||||||
|
view.Rows = append(view.Rows, ledgerRow(r))
|
||||||
|
}
|
||||||
|
s.renderConsole(c, "ledger", "ledger", "Ledger", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledgerRow projects one report row for the logic-free template: times and money pre-formatted, the
|
||||||
|
// product title lifted out of the snapshot, and the refund affordance decided here rather than in
|
||||||
|
// the template — only a funded order can be reversed.
|
||||||
|
func ledgerRow(r payments.LedgerReportRow) adminconsole.LedgerRow {
|
||||||
|
row := adminconsole.LedgerRow{
|
||||||
|
At: fmtTime(r.CreatedAt),
|
||||||
|
AccountID: r.AccountID.String(),
|
||||||
|
Kind: r.Kind,
|
||||||
|
Source: r.Source,
|
||||||
|
Origin: r.Origin,
|
||||||
|
ChipsDelta: r.ChipsDelta,
|
||||||
|
Order: r.OrderID,
|
||||||
|
Provider: r.Provider,
|
||||||
|
Shop: r.Shop,
|
||||||
|
Refundable: r.Kind == "fund" && r.OrderID != "",
|
||||||
|
Title: snapshotTitle(r.Snapshot),
|
||||||
|
}
|
||||||
|
if r.HasMoney() {
|
||||||
|
row.Money = fmtMoney(r.Money)
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledgerTotalsRow pre-formats the period totals for the template.
|
||||||
|
func ledgerTotalsRow(t payments.LedgerTotals) adminconsole.LedgerTotalsRow {
|
||||||
|
out := adminconsole.LedgerTotalsRow{ChipsIn: t.ChipsIn, ChipsOut: t.ChipsOut}
|
||||||
|
for _, m := range t.MoneyIn {
|
||||||
|
out.MoneyIn = append(out.MoneyIn, fmtMoney(m))
|
||||||
|
}
|
||||||
|
for _, m := range t.MoneyRefunded {
|
||||||
|
out.MoneyRefunded = append(out.MoneyRefunded, fmtMoney(m))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtMoney renders an amount with its currency, e.g. "149.00 RUB".
|
||||||
|
func fmtMoney(m payments.Money) string {
|
||||||
|
return fmt.Sprintf("%s %s", m.Major(), m.Currency())
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotTitle lifts the sold product's title out of a ledger row snapshot, so the table names what
|
||||||
|
// was bought instead of only its id. It returns "" when the snapshot carries no title.
|
||||||
|
func snapshotTitle(snapshot string) string {
|
||||||
|
if snapshot == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var snap struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(snapshot), &snap); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return snap.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleLedgerExport streams the filtered ledger as a CSV attachment for tax reporting and rail
|
||||||
|
// reconciliation. It honours the same filters as the page it is linked from — an export that
|
||||||
|
// silently covered a different range than the screen would be worse than no export.
|
||||||
|
func (s *Server) consoleLedgerExport(c *gin.Context) {
|
||||||
|
f, _ := ledgerFilterFrom(c, time.Now())
|
||||||
|
rows, err := s.payments.FilteredLedgerExport(c.Request.Context(), f)
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
|
||||||
|
w := csv.NewWriter(c.Writer)
|
||||||
|
_ = w.Write([]string{
|
||||||
|
"created_at", "account_id", "kind", "source", "origin", "chips_delta",
|
||||||
|
"amount_minor", "currency", "product_id", "order_id", "provider", "provider_payment_id", "shop", "snapshot",
|
||||||
|
})
|
||||||
|
for _, r := range rows {
|
||||||
|
amount, currency := "", ""
|
||||||
|
if r.HasMoney() {
|
||||||
|
amount, currency = strconv.FormatInt(r.Money.Minor(), 10), string(r.Money.Currency())
|
||||||
|
}
|
||||||
|
_ = w.Write([]string{
|
||||||
|
r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID.String(), r.Kind, r.Source, r.Origin,
|
||||||
|
strconv.Itoa(r.ChipsDelta), amount, currency,
|
||||||
|
r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Shop, r.Snapshot,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
@@ -1,25 +1,30 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/csv"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"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
|
// consoleRefund refunds a paid order in full at the operator's request: it records the reversal — a
|
||||||
// actual money refund on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment);
|
// refund ledger row and a floor-0 chip revoke (never negative, D27) — and, on the direct rail, moves
|
||||||
// this records it — a refund ledger row and a floor-0 chip revoke (never negative, D27). Idempotent.
|
// 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) {
|
func (s *Server) consoleRefund(c *gin.Context) {
|
||||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
back := "/_gm/users/" + id.String()
|
back := consoleReturnTo(c.PostForm("back"), "/_gm/users/"+id.String())
|
||||||
if s.payments == nil {
|
if s.payments == nil {
|
||||||
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
|
||||||
return
|
return
|
||||||
@@ -29,12 +34,27 @@ func (s *Server) consoleRefund(c *gin.Context) {
|
|||||||
s.renderConsoleMessage(c, "Refund failed", "no order to refund", back)
|
s.renderConsoleMessage(c, "Refund failed", "no order to refund", back)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
|
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if out.AlreadyRefunded {
|
if out.AlreadyRefunded {
|
||||||
|
if ref.Provider == providerYooKassa {
|
||||||
|
// The provider refunded the money on this very click, so this is a success, not a repeat.
|
||||||
|
// The reversal was already in the ledger because the provider's own refund notification
|
||||||
|
// reached us first — it names the same refund id, which is exactly why the idempotency
|
||||||
|
// index caught it and nothing was revoked twice. Saying "already refunded" here would read
|
||||||
|
// as "you clicked twice".
|
||||||
|
s.renderConsoleMessage(c, "Refunded", "the money is back with the customer; the reversal was already recorded, so nothing changed just now", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
s.renderConsoleMessage(c, "Already refunded", "this order was already refunded", back)
|
s.renderConsoleMessage(c, "Already refunded", "this order was already refunded", back)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -46,26 +66,39 @@ func (s *Server) consoleRefund(c *gin.Context) {
|
|||||||
s.renderConsoleMessage(c, "Refunded", msg, back)
|
s.renderConsoleMessage(c, "Refunded", msg, back)
|
||||||
}
|
}
|
||||||
|
|
||||||
// consoleLedgerExport streams the entire append-only ledger as a CSV attachment for tax reporting
|
// consoleReturnTo picks where the result page links back to: the caller's requested destination when
|
||||||
// and rail reconciliation. The snapshot column carries the raw purchase/refund JSON.
|
// it is a console page, else the fallback. A refund can be started from the account card or from the
|
||||||
func (s *Server) consoleLedgerExport(c *gin.Context) {
|
// ledger (where the operator wants to land back on the same filtered slice), so the destination
|
||||||
rows, err := s.payments.LedgerExport(c.Request.Context())
|
// travels with the form — but only ever as a console-relative path, never as an arbitrary URL a
|
||||||
if err != nil {
|
// crafted form could point elsewhere.
|
||||||
s.consoleError(c, err)
|
func consoleReturnTo(requested, fallback string) string {
|
||||||
return
|
if strings.HasPrefix(requested, "/_gm/") && !strings.HasPrefix(requested, "/_gm//") {
|
||||||
|
return requested
|
||||||
}
|
}
|
||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
return fallback
|
||||||
c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
|
}
|
||||||
w := csv.NewWriter(c.Writer)
|
|
||||||
_ = w.Write([]string{
|
// refundOrder reverses a paid order, moving the money back through the rail's own refund API when
|
||||||
"created_at", "account_id", "kind", "source", "origin", "chips_delta",
|
// there is one. The provider call comes first and a failure aborts with nothing recorded: the ledger
|
||||||
"product_id", "order_id", "provider", "provider_payment_id", "snapshot",
|
// 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.
|
||||||
for _, r := range rows {
|
//
|
||||||
_ = w.Write([]string{
|
// A rail with no refund API of ours records under the operator's key instead, and the operator moves
|
||||||
r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID, r.Kind, r.Source, r.Origin,
|
// the money by hand (VK support, Telegram refundStarPayment, the Robokassa cabinet).
|
||||||
strconv.Itoa(r.ChipsDelta), r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Snapshot,
|
func (s *Server) refundOrder(ctx context.Context, ref payments.OrderRef) (payments.RefundOutcome, error) {
|
||||||
})
|
if ref.Provider != providerYooKassa {
|
||||||
}
|
return s.payments.RefundOrderFull(ctx, ref.OrderID)
|
||||||
w.Flush()
|
}
|
||||||
|
refundID, err := s.refundYooKassa(ctx, ref)
|
||||||
|
if errors.Is(err, errRefundNotFinal) {
|
||||||
|
// The money is on its way but not settled. Nothing is recorded yet; pressing again is safe —
|
||||||
|
// the idempotency key returns the same refund rather than paying a second time.
|
||||||
|
s.log.Warn("yookassa refund not final", zap.String("order", ref.OrderID.String()), zap.Error(err))
|
||||||
|
return payments.RefundOutcome{}, fmt.Errorf("%w — nothing was recorded; try again in a minute", err)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("yookassa refund failed", zap.String("order", ref.OrderID.String()), zap.Error(err))
|
||||||
|
return payments.RefundOutcome{}, fmt.Errorf("the provider did not refund the payment, nothing was recorded: %w", err)
|
||||||
|
}
|
||||||
|
return s.payments.RefundOrderFullAs(ctx, ref.OrderID, providerYooKassa, refundID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,20 +15,28 @@ import (
|
|||||||
"scrabble/backend/internal/robokassa"
|
"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 (
|
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
|
providerVK = "vk" // the VK Votes rail
|
||||||
providerTelegram = "telegram" // the Telegram Stars (XTR) 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.
|
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
|
||||||
type walletOrderRequest struct {
|
type walletOrderRequest struct {
|
||||||
ProductID string `json:"product_id"`
|
ProductID string `json:"product_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
|
// 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
|
// 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)
|
// Stars rail the gateway mints the invoice link from InvoiceTitle and InvoiceAmount (whole stars)
|
||||||
// via the bot and returns it in RedirectURL.
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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 {
|
switch cxt.Kind {
|
||||||
case payments.SourceDirect:
|
case payments.SourceDirect:
|
||||||
shop, ok := s.robokassa.Shop(cxt.Subtype)
|
// YooKassa settles the direct rail. Robokassa is retired and normally unconfigured, so it is
|
||||||
if !ok {
|
// 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"}})
|
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// D36: a direct purchase requires a confirmed email anchor.
|
// D36: a direct purchase requires a confirmed email anchor, which is also where the fiscal
|
||||||
hasEmail, err := s.accounts.HasConfirmedEmail(ctx, uid)
|
// receipt is delivered.
|
||||||
|
email, hasEmail, err := s.accounts.ConfirmedEmail(ctx, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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"}})
|
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if onYooKassa {
|
||||||
|
s.orderYooKassa(c, uid, cxt, present, productID, ykShop, email)
|
||||||
|
return
|
||||||
|
}
|
||||||
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
|
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
@@ -106,7 +125,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, walletOrderResponse{
|
c.JSON(http.StatusOK, walletOrderResponse{
|
||||||
OrderID: res.OrderID.String(),
|
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,
|
Rail: providerRobokassa,
|
||||||
})
|
})
|
||||||
case payments.SourceVK:
|
case payments.SourceVK:
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
|
"scrabble/backend/internal/yookassa"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxNotifyBytes caps the notification body the backend will read. The gateway already forwards only
|
||||||
|
// what it read from the provider; this is the backend's own ceiling on an untrusted body.
|
||||||
|
const maxNotifyBytes = 1 << 20
|
||||||
|
|
||||||
|
// yooKassaReceipt builds the fiscal receipt for a sale, or nil when receipts are off. They are off
|
||||||
|
// by default: the merchant runs on НПД, which is outside 54-ФЗ, so nothing is registered through the
|
||||||
|
// provider and each operation is reported to «Мой налог» instead. Setting a VAT rate code turns them
|
||||||
|
// back on — the switch a lost НПД regime would need — without touching this code.
|
||||||
|
func (s *Server) yooKassaReceipt(email, title string, amount yookassa.Amount) *yookassa.Receipt {
|
||||||
|
if !yookassa.ReceiptEnabled(s.vatCode) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return yookassa.SingleItemReceipt(email, title, amount, s.vatCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderYooKassa opens a pending order on the direct rail and mints the YooKassa payment the customer
|
||||||
|
// is redirected to. shop is the merchant shop the caller resolved for this platform channel, and
|
||||||
|
// email is the account's confirmed address (D36) — the recovery anchor a direct purchase requires,
|
||||||
|
// and the delivery address of a fiscal receipt when receipts are switched on. No chips are credited
|
||||||
|
// here — only later, by the verified notification (or the reconcile sweep).
|
||||||
|
func (s *Server) orderYooKassa(c *gin.Context, uid uuid.UUID, cxt payments.Context, present []payments.Source, productID uuid.UUID, shop yookassa.Config, email string) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerYooKassa)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
amount := yookassa.Amount{Value: res.Amount.Major(), Currency: string(res.Amount.Currency())}
|
||||||
|
payment, err := shop.CreatePayment(ctx, yookassa.PaymentRequest{
|
||||||
|
Amount: amount,
|
||||||
|
Capture: true,
|
||||||
|
Confirmation: yookassa.Confirmation{
|
||||||
|
Type: yookassa.ConfirmationRedirect,
|
||||||
|
ReturnURL: strings.TrimSuffix(s.publicURL, "/") + yookassaReturnPath,
|
||||||
|
},
|
||||||
|
Description: yookassa.TruncateDescription(res.Title),
|
||||||
|
Metadata: map[string]string{yookassa.MetadataOrderID: res.OrderID.String()},
|
||||||
|
// Off unless a VAT rate is configured: the merchant is outside 54-ФЗ and reports each
|
||||||
|
// operation itself, so no fiscal receipt is registered through the provider.
|
||||||
|
Receipt: s.yooKassaReceipt(email, res.Title, amount),
|
||||||
|
}, res.OrderID.String())
|
||||||
|
if err != nil {
|
||||||
|
// The order stays pending and unpaid; it expires on its own. The customer sees the generic
|
||||||
|
// error, and the log carries the provider's own description (which names the offending
|
||||||
|
// parameter when a receipt is at fault).
|
||||||
|
s.log.Error("yookassa create payment failed", zap.String("order", res.OrderID.String()), zap.Error(err))
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadGateway, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available right now"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payment.Confirmation.ConfirmationURL == "" {
|
||||||
|
s.log.Error("yookassa payment has no confirmation url",
|
||||||
|
zap.String("order", res.OrderID.String()), zap.String("payment", payment.ID))
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadGateway, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available right now"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Record the provider's payment id so the reconcile sweep and any refund can address it. A
|
||||||
|
// failure here is logged and tolerated: the credit path matches on the order id carried in the
|
||||||
|
// payment metadata, so the purchase still completes — only the safety net and refunds lose their
|
||||||
|
// handle on this one order.
|
||||||
|
if err := s.payments.AttachProviderPayment(ctx, res.OrderID, providerYooKassa, payment.ID); err != nil {
|
||||||
|
s.log.Error("yookassa attach payment id failed",
|
||||||
|
zap.String("order", res.OrderID.String()), zap.String("payment", payment.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, walletOrderResponse{
|
||||||
|
OrderID: res.OrderID.String(),
|
||||||
|
RedirectURL: payment.Confirmation.ConfirmationURL,
|
||||||
|
Rail: providerYooKassa,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleYooKassaNotify is the YooKassa webhook, reached only through the gateway (which checks the
|
||||||
|
// sender address and forwards the raw JSON body on the internal, gateway-only route).
|
||||||
|
//
|
||||||
|
// YooKassa does not sign notifications, so the body is never evidence: it only names a payment to
|
||||||
|
// re-read. Everything acted on comes from the confirming GetPayment. A succeeded payment credits its
|
||||||
|
// order exactly once (idempotent, and honoured even if the order expired); a canceled one records a
|
||||||
|
// failed event so the customer is told the attempt did not go through.
|
||||||
|
//
|
||||||
|
// The sender address is deliberately NOT checked. It would not add security — the confirming read is
|
||||||
|
// the whole boundary — and the one thing it did buy, keeping a forger from turning each fabricated
|
||||||
|
// notification into an outbound call of ours, is already bought earlier and far more tightly: the
|
||||||
|
// order is resolved from the notification's metadata first, and an id that matches no order costs a
|
||||||
|
// single indexed read and nothing else. Guessing a live order id means guessing a uuid. Meanwhile an
|
||||||
|
// address check is actively harmful in a deployment that cannot observe real client addresses (a
|
||||||
|
// contour behind a tunnel sees only its own), where it rejects genuine notifications.
|
||||||
|
//
|
||||||
|
// The reply tells the provider whether to redeliver: 200 for anything durably decided — including a
|
||||||
|
// duplicate and a permanent rejection, which no retry could fix — and 5xx only for a transient
|
||||||
|
// failure we want redelivered (YooKassa retries for 24 hours).
|
||||||
|
func (s *Server) handleYooKassaNotify(c *gin.Context) {
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, maxNotifyBytes))
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
note, err := yookassa.ParseNotification(raw)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("yookassa notify: malformed body", zap.Error(err))
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch note.Event {
|
||||||
|
case yookassa.EventPaymentSucceeded, yookassa.EventPaymentCanceled:
|
||||||
|
case yookassa.EventRefundSucceeded:
|
||||||
|
s.handleYooKassaRefundNotification(c, note)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// A subscription we do not act on.
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claimed, err := note.Payment()
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("yookassa notify: unusable payment object", zap.String("event", note.Event), zap.Error(err))
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderID, err := uuid.Parse(claimed.OrderID())
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("yookassa notify: no order in payment metadata", zap.String("payment", claimed.ID))
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
ref, err := s.payments.OrderProviderRef(ctx, orderID)
|
||||||
|
if errors.Is(err, payments.ErrOrderNotFound) {
|
||||||
|
s.log.Warn("yookassa notify: unknown order", zap.String("order", orderID.String()), zap.String("payment", claimed.ID))
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("yookassa notify: order lookup failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||||
|
c.String(http.StatusInternalServerError, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payment, shop, err := s.confirmYooKassaPayment(ctx, claimed.ID, claimed.Recipient.AccountID, ref)
|
||||||
|
if err != nil {
|
||||||
|
var apiErr *yookassa.APIError
|
||||||
|
if errors.As(err, &apiErr) && !apiErr.Retryable() {
|
||||||
|
// The provider says this payment is not ours or not readable — a forged or stale
|
||||||
|
// notification. Nothing to redeliver.
|
||||||
|
s.log.Warn("yookassa notify: payment not confirmed",
|
||||||
|
zap.String("order", orderID.String()), zap.String("payment", claimed.ID), zap.Error(err))
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Error("yookassa notify: confirm failed",
|
||||||
|
zap.String("order", orderID.String()), zap.String("payment", claimed.ID), zap.Error(err))
|
||||||
|
c.String(http.StatusInternalServerError, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.yooKassaPaymentMatchesOrder(payment, shop, orderID) {
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch payment.Status {
|
||||||
|
case yookassa.StatusSucceeded:
|
||||||
|
if err := s.creditYooKassaPayment(ctx, orderID, payment); err != nil {
|
||||||
|
if isPermanentFundError(err) {
|
||||||
|
s.log.Warn("yookassa notify rejected", zap.String("order", orderID.String()), zap.Error(err))
|
||||||
|
c.String(http.StatusOK, "rejected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Error("yookassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||||
|
c.String(http.StatusInternalServerError, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case yookassa.StatusCanceled:
|
||||||
|
// An active decline (§9): the customer is told the attempt failed. An order abandoned without
|
||||||
|
// a decline never reaches here — it just expires, invisibly.
|
||||||
|
s.recordYooKassaFailure(ctx, ref.AccountID, orderID, payment)
|
||||||
|
default:
|
||||||
|
// Still pending: the notification raced ahead of the payment, or was forged. Either way the
|
||||||
|
// reconcile sweep will settle the order, so there is nothing to redeliver.
|
||||||
|
s.log.Info("yookassa notify: payment not final",
|
||||||
|
zap.String("order", orderID.String()), zap.String("status", payment.Status))
|
||||||
|
}
|
||||||
|
c.String(http.StatusOK, "ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleYooKassaRefundNotification reverses a refund the provider reports as completed. Its reason
|
||||||
|
// for existing is the merchant cabinet: an operator can refund there, and such a refund never passes
|
||||||
|
// through our API, so without this the money would go back while the chips stayed credited.
|
||||||
|
//
|
||||||
|
// As with a payment, the body is only a hint — the refund is re-read from the API before anything is
|
||||||
|
// recorded. The reversal is idempotent on (yookassa, refund id), so the notification for a refund the
|
||||||
|
// console already recorded reverses nothing a second time.
|
||||||
|
func (s *Server) handleYooKassaRefundNotification(c *gin.Context, note yookassa.Notification) {
|
||||||
|
claimed, err := note.Refund()
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("yookassa notify: unusable refund object", zap.Error(err))
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
// The refund object names the payment, not our order, so the order is found by the payment id we
|
||||||
|
// recorded when the payment was minted. The claim is untrusted, but it only selects which shop's
|
||||||
|
// credentials perform the confirming read; the confirmed refund is then bound back to this order.
|
||||||
|
ref, err := s.payments.OrderByProviderPayment(ctx, providerYooKassa, claimed.PaymentID)
|
||||||
|
if errors.Is(err, payments.ErrOrderNotFound) {
|
||||||
|
s.log.Warn("yookassa refund notify: no order for the payment",
|
||||||
|
zap.String("payment", claimed.PaymentID), zap.String("refund", claimed.ID))
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("yookassa refund notify: order lookup failed", zap.String("payment", claimed.PaymentID), zap.Error(err))
|
||||||
|
c.String(http.StatusInternalServerError, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shop, ok := s.yookassa.Shop(ref.Shop)
|
||||||
|
if !ok {
|
||||||
|
s.log.Warn("yookassa refund notify: no shop for the order's channel", zap.String("order", ref.OrderID.String()))
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refund, err := shop.GetRefund(ctx, claimed.ID)
|
||||||
|
if err != nil {
|
||||||
|
var apiErr *yookassa.APIError
|
||||||
|
if errors.As(err, &apiErr) && !apiErr.Retryable() {
|
||||||
|
s.log.Warn("yookassa refund notify: refund not confirmed",
|
||||||
|
zap.String("order", ref.OrderID.String()), zap.String("refund", claimed.ID), zap.Error(err))
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Error("yookassa refund notify: confirm failed",
|
||||||
|
zap.String("order", ref.OrderID.String()), zap.String("refund", claimed.ID), zap.Error(err))
|
||||||
|
c.String(http.StatusInternalServerError, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if refund.Status != yookassa.StatusSucceeded || refund.PaymentID != ref.PaymentID {
|
||||||
|
s.log.Warn("yookassa refund notify: confirmed refund does not settle this order",
|
||||||
|
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
|
||||||
|
zap.String("status", refund.Status), zap.String("refund_payment", refund.PaymentID))
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The reversal engine is full-refund-only by design: it revokes exactly what the pack funded and
|
||||||
|
// rejects any other amount. A partial refund therefore cannot be recorded without guessing how
|
||||||
|
// many chips it should cost, so it is left to an operator — loudly, because the money has moved.
|
||||||
|
paid, err := payments.ParseMoney(refund.Amount.Value, payments.Currency(refund.Amount.Currency))
|
||||||
|
if err != nil || paid.Currency() != ref.Amount.Currency() || paid.Minor() != ref.Amount.Minor() {
|
||||||
|
s.log.Error("yookassa refund notify: partial refund needs an operator; nothing was reversed",
|
||||||
|
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
|
||||||
|
zap.String("refunded", refund.Amount.Value), zap.String("order_amount", ref.Amount.Major()))
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := s.payments.RefundOrderFullAs(ctx, ref.OrderID, providerYooKassa, refund.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, payments.ErrOrderNotPaid) {
|
||||||
|
s.log.Warn("yookassa refund notify: the order was never credited",
|
||||||
|
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID))
|
||||||
|
c.String(http.StatusOK, "ignored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Error("yookassa refund notify: reversal failed",
|
||||||
|
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID), zap.Error(err))
|
||||||
|
c.String(http.StatusInternalServerError, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !out.AlreadyRefunded {
|
||||||
|
// This is the first record of the refund. It is usually one an operator made in the merchant
|
||||||
|
// cabinet, but it can equally be a console refund whose notification outran the console's own
|
||||||
|
// write — both name the same refund id, so either way it is recorded exactly once.
|
||||||
|
s.log.Info("yookassa refund notify: reversed a refund",
|
||||||
|
zap.String("order", ref.OrderID.String()), zap.String("refund", refund.ID),
|
||||||
|
zap.Int("revoked", out.Revoked), zap.Int("loss", out.Loss))
|
||||||
|
}
|
||||||
|
c.String(http.StatusOK, "ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmYooKassaPayment re-reads a payment from the API — the authenticity check for the whole rail,
|
||||||
|
// since notifications are unsigned. The shop is chosen by the shop id the payment claims to have been
|
||||||
|
// paid to, falling back to the merchant channel recorded on the order; a claim naming a shop we do
|
||||||
|
// not run is not honoured with our own credentials.
|
||||||
|
func (s *Server) confirmYooKassaPayment(ctx context.Context, paymentID, claimedShopID string, ref payments.OrderRef) (yookassa.Payment, yookassa.Config, error) {
|
||||||
|
_, shop, ok := s.yookassa.ByShopID(claimedShopID)
|
||||||
|
if !ok {
|
||||||
|
shop, ok = s.yookassa.Shop(ref.Shop)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return yookassa.Payment{}, yookassa.Config{}, yookassa.ErrUnconfigured
|
||||||
|
}
|
||||||
|
payment, err := shop.GetPayment(ctx, paymentID)
|
||||||
|
return payment, shop, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// yooKassaPaymentMatchesOrder checks the confirmed payment really belongs to the order being
|
||||||
|
// credited and to the kind of shop we think it is. It rejects a payment whose metadata names a
|
||||||
|
// different order, and — the guard that keeps play money out of the live ledger — a live payment
|
||||||
|
// arriving on test credentials or a test payment on live ones.
|
||||||
|
func (s *Server) yooKassaPaymentMatchesOrder(payment yookassa.Payment, shop yookassa.Config, orderID uuid.UUID) bool {
|
||||||
|
if payment.OrderID() != orderID.String() {
|
||||||
|
s.log.Warn("yookassa: confirmed payment belongs to another order",
|
||||||
|
zap.String("order", orderID.String()), zap.String("payment", payment.ID),
|
||||||
|
zap.String("payment_order", payment.OrderID()))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if payment.Test != shop.IsTest {
|
||||||
|
s.log.Error("yookassa: payment test flag does not match the shop; refusing to credit",
|
||||||
|
zap.String("order", orderID.String()), zap.String("payment", payment.ID),
|
||||||
|
zap.Bool("payment_test", payment.Test), zap.Bool("shop_test", shop.IsTest))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// creditYooKassaPayment credits a confirmed succeeded payment to its order exactly once and records
|
||||||
|
// the succeeded event a duplicate must not re-emit.
|
||||||
|
func (s *Server) creditYooKassaPayment(ctx context.Context, orderID uuid.UUID, payment yookassa.Payment) error {
|
||||||
|
paid, err := payments.ParseMoney(payment.Amount.Value, payments.Currency(payment.Amount.Currency))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
outcome, err := s.payments.Fund(ctx, orderID, providerYooKassa, payment.ID, paid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if outcome.AlreadyCredited {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
|
||||||
|
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
|
||||||
|
// The credit is already committed; only the in-app notification is lost, and the wallet still
|
||||||
|
// refreshes on the client's return.
|
||||||
|
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordYooKassaFailure records the failed payment event for an actively declined payment, so the
|
||||||
|
// customer learns the attempt did not go through instead of watching a balance that never moves.
|
||||||
|
func (s *Server) recordYooKassaFailure(ctx context.Context, accountID uuid.UUID, orderID uuid.UUID, payment yookassa.Payment) {
|
||||||
|
reason := ""
|
||||||
|
if payment.CancellationDetails != nil {
|
||||||
|
reason = payment.CancellationDetails.Reason
|
||||||
|
}
|
||||||
|
s.log.Info("yookassa payment canceled",
|
||||||
|
zap.String("order", orderID.String()), zap.String("payment", payment.ID), zap.String("reason", reason))
|
||||||
|
payload, _ := json.Marshal(map[string]any{"reason": reason})
|
||||||
|
if err := s.payments.RecordPaymentEvent(ctx, accountID, &orderID, "failed", payload); err != nil {
|
||||||
|
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPermanentFundError reports whether a credit failed for a reason no retry can fix — an order that
|
||||||
|
// does not exist, an amount that does not match, or a product that is no longer a chip pack.
|
||||||
|
func isPermanentFundError(err error) bool {
|
||||||
|
return errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
|
||||||
|
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refundYooKassa returns the money for a paid order through the YooKassa refund API and reports the
|
||||||
|
// provider's own refund id, which the ledger then records. It sends the order id as the idempotency
|
||||||
|
// key, so a repeated attempt returns the original refund instead of paying twice, and a refund
|
||||||
|
// receipt when receipts are switched on (the rail registers one the same way it does for a payment).
|
||||||
|
//
|
||||||
|
// It is called before anything is recorded: if the money does not move, nothing is written, and the
|
||||||
|
// ledger never claims a refund that did not happen.
|
||||||
|
func (s *Server) refundYooKassa(ctx context.Context, ref payments.OrderRef) (string, error) {
|
||||||
|
shop, ok := s.yookassa.Shop(ref.Shop)
|
||||||
|
if !ok {
|
||||||
|
return "", yookassa.ErrUnconfigured
|
||||||
|
}
|
||||||
|
if ref.PaymentID == "" {
|
||||||
|
return "", errors.New("the order carries no provider payment id to refund")
|
||||||
|
}
|
||||||
|
title, _, err := s.payments.OrderItem(ctx, ref.OrderID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// The buyer's address is read only to deliver a refund receipt; with receipts off there is
|
||||||
|
// nothing to deliver and nothing to read.
|
||||||
|
email := ""
|
||||||
|
if yookassa.ReceiptEnabled(s.vatCode) {
|
||||||
|
if email, _, err = s.accounts.ConfirmedEmail(ctx, ref.AccountID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
amount := yookassa.Amount{Value: ref.Amount.Major(), Currency: string(ref.Amount.Currency())}
|
||||||
|
refund, err := shop.CreateRefund(ctx, yookassa.RefundRequest{
|
||||||
|
PaymentID: ref.PaymentID,
|
||||||
|
Amount: amount,
|
||||||
|
Receipt: s.yooKassaReceipt(email, title, amount),
|
||||||
|
}, ref.OrderID.String())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if refund.ID == "" {
|
||||||
|
return "", errors.New("the provider returned a refund with no id")
|
||||||
|
}
|
||||||
|
// Only a completed refund is recorded. A refund can still be canceled while pending, and the
|
||||||
|
// ledger is append-only, so recording early would mean revoking a customer's chips for money that
|
||||||
|
// then stayed with us — with no way to take the row back.
|
||||||
|
if refund.Status != yookassa.StatusSucceeded {
|
||||||
|
return "", fmt.Errorf("%w (status %s)", errRefundNotFinal, refund.Status)
|
||||||
|
}
|
||||||
|
return refund.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errRefundNotFinal reports that the provider accepted the refund but has not completed it. Retrying
|
||||||
|
// is safe and is the way out: the idempotency key returns the same refund rather than paying twice,
|
||||||
|
// so the operator can press again until it settles.
|
||||||
|
var errRefundNotFinal = errors.New("the provider has not completed the refund yet")
|
||||||
|
|
||||||
|
// ReconcileYooKassaOrders asks YooKassa what became of every pending order that reached its expiry
|
||||||
|
// age while carrying a payment id, and credits the ones that were in fact paid. It is the safety net
|
||||||
|
// for a notification that was lost for good: YooKassa redelivers for 24 hours, so a short outage
|
||||||
|
// heals itself, but a notification that never arrived at all would otherwise leave the money taken
|
||||||
|
// and the chips unowed. It runs once per order, just before the order is written off as expired.
|
||||||
|
//
|
||||||
|
// It returns how many orders it credited. Failures are logged and skipped: the next sweep retries.
|
||||||
|
func (s *Server) ReconcileYooKassaOrders(ctx context.Context) int {
|
||||||
|
if !s.yookassa.Configured() || s.payments == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
refs, err := s.payments.PendingForReconcile(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("yookassa reconcile: read pending orders failed", zap.Error(err))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
credited := 0
|
||||||
|
for _, ref := range refs {
|
||||||
|
if ref.Provider != providerYooKassa || ref.PaymentID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shop, ok := s.yookassa.Shop(ref.Shop)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payment, err := shop.GetPayment(ctx, ref.PaymentID)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("yookassa reconcile: payment lookup failed",
|
||||||
|
zap.String("order", ref.OrderID.String()), zap.String("payment", ref.PaymentID), zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if payment.Status != yookassa.StatusSucceeded || !s.yooKassaPaymentMatchesOrder(payment, shop, ref.OrderID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.creditYooKassaPayment(ctx, ref.OrderID, payment); err != nil {
|
||||||
|
s.log.Error("yookassa reconcile: credit failed",
|
||||||
|
zap.String("order", ref.OrderID.String()), zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.log.Info("yookassa reconcile: credited an order no notification confirmed",
|
||||||
|
zap.String("order", ref.OrderID.String()), zap.String("payment", payment.ID))
|
||||||
|
credited++
|
||||||
|
}
|
||||||
|
return credited
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import (
|
|||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
"scrabble/backend/internal/social"
|
"scrabble/backend/internal/social"
|
||||||
"scrabble/backend/internal/telemetry"
|
"scrabble/backend/internal/telemetry"
|
||||||
|
"scrabble/backend/internal/yookassa"
|
||||||
)
|
)
|
||||||
|
|
||||||
// shutdownTimeout bounds how long Run waits for in-flight requests to finish
|
// shutdownTimeout bounds how long Run waits for in-flight requests to finish
|
||||||
@@ -115,8 +116,19 @@ type Deps struct {
|
|||||||
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
// 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).
|
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
||||||
Renderer *render.Client
|
Renderer *render.Client
|
||||||
// Robokassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
|
// YooKassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
|
||||||
// set leaves the order and Result-callback endpoints unregistered.
|
// set leaves the order and notification endpoints unregistered and falls the direct rail back to
|
||||||
|
// Robokassa.
|
||||||
|
YooKassa yookassa.Shops
|
||||||
|
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
|
||||||
|
// Unset sends no receipt at all — the state a merchant outside 54-ФЗ runs in.
|
||||||
|
YooKassaVatCode int
|
||||||
|
// PublicBaseURL is the canonical https origin the payment return URL is built from. It is
|
||||||
|
// required whenever YooKassa is configured.
|
||||||
|
PublicBaseURL string
|
||||||
|
// Robokassa configures the retired direct-rail provider — one merchant shop per channel. No
|
||||||
|
// deployment sets it, so the set is empty and the direct rail resolves to YooKassa; it stays
|
||||||
|
// wired so restoring the rail is a credentials change (backend/internal/robokassa/README.md).
|
||||||
Robokassa robokassa.Shops
|
Robokassa robokassa.Shops
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +158,9 @@ type Server struct {
|
|||||||
ads *ads.Service
|
ads *ads.Service
|
||||||
payments *payments.Service
|
payments *payments.Service
|
||||||
gamelimits *gamelimits.Service
|
gamelimits *gamelimits.Service
|
||||||
|
yookassa yookassa.Shops
|
||||||
|
vatCode int
|
||||||
|
publicURL string
|
||||||
robokassa robokassa.Shops
|
robokassa robokassa.Shops
|
||||||
notifier notify.Publisher
|
notifier notify.Publisher
|
||||||
console *adminconsole.Renderer
|
console *adminconsole.Renderer
|
||||||
@@ -201,6 +216,9 @@ func New(addr string, deps Deps) *Server {
|
|||||||
ads: deps.Ads,
|
ads: deps.Ads,
|
||||||
payments: deps.Payments,
|
payments: deps.Payments,
|
||||||
gamelimits: deps.GameLimits,
|
gamelimits: deps.GameLimits,
|
||||||
|
yookassa: deps.YooKassa,
|
||||||
|
vatCode: deps.YooKassaVatCode,
|
||||||
|
publicURL: deps.PublicBaseURL,
|
||||||
robokassa: deps.Robokassa,
|
robokassa: deps.Robokassa,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
renderer: deps.Renderer,
|
renderer: deps.Renderer,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package yookassa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notification events this integration subscribes to. An event name is "<object>.<status>": the
|
||||||
|
// object whose status changed and the status it entered.
|
||||||
|
const (
|
||||||
|
// EventPaymentSucceeded means the money was taken and the order may be credited.
|
||||||
|
EventPaymentSucceeded = "payment.succeeded"
|
||||||
|
// EventPaymentCanceled means the payment was actively declined or abandoned; nothing is credited
|
||||||
|
// and the payer is told the attempt failed.
|
||||||
|
EventPaymentCanceled = "payment.canceled"
|
||||||
|
// EventRefundSucceeded reports a completed refund. It matters because the merchant cabinet is a
|
||||||
|
// second way to issue one: a refund made there never passes through our API, so without this
|
||||||
|
// event the money would go back while the chips stayed credited.
|
||||||
|
EventRefundSucceeded = "refund.succeeded"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notificationType is the fixed value of a notification envelope's type field.
|
||||||
|
const notificationType = "notification"
|
||||||
|
|
||||||
|
// Notification is an incoming webhook envelope. Object is left raw because its shape depends on the
|
||||||
|
// event — a payment for payment.*, a refund for refund.* — and because nothing in it may be acted on
|
||||||
|
// before GetPayment confirms it: YooKassa does not sign notifications.
|
||||||
|
type Notification struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Object json.RawMessage `json:"object"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseNotification decodes a webhook body and checks the envelope is a notification with an event.
|
||||||
|
// It deliberately validates nothing else: the body is untrusted input whose only job is to name an
|
||||||
|
// object to re-read from the API.
|
||||||
|
func ParseNotification(body []byte) (Notification, error) {
|
||||||
|
var n Notification
|
||||||
|
if err := json.Unmarshal(body, &n); err != nil {
|
||||||
|
return Notification{}, fmt.Errorf("yookassa: decode notification: %w", err)
|
||||||
|
}
|
||||||
|
if n.Type != notificationType || n.Event == "" {
|
||||||
|
return Notification{}, fmt.Errorf("yookassa: not a notification envelope (type %q, event %q)", n.Type, n.Event)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment decodes the notification's object as a payment. Use it only for payment.* events; it
|
||||||
|
// yields the payment id to re-read, never the payment state to act on.
|
||||||
|
func (n Notification) Payment() (Payment, error) {
|
||||||
|
var p Payment
|
||||||
|
if err := json.Unmarshal(n.Object, &p); err != nil {
|
||||||
|
return Payment{}, fmt.Errorf("yookassa: decode notification payment: %w", err)
|
||||||
|
}
|
||||||
|
if p.ID == "" {
|
||||||
|
return Payment{}, fmt.Errorf("yookassa: notification payment has no id")
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refund decodes the notification's object as a refund. Use it only for refund.* events; it yields
|
||||||
|
// the refund id to re-read, never the refund state to act on.
|
||||||
|
func (n Notification) Refund() (Refund, error) {
|
||||||
|
var r Refund
|
||||||
|
if err := json.Unmarshal(n.Object, &r); err != nil {
|
||||||
|
return Refund{}, fmt.Errorf("yookassa: decode notification refund: %w", err)
|
||||||
|
}
|
||||||
|
if r.ID == "" {
|
||||||
|
return Refund{}, fmt.Errorf("yookassa: notification refund has no id")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package yookassa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseNotification(t *testing.T) {
|
||||||
|
n, err := ParseNotification([]byte(`{"type":"notification","event":"payment.succeeded",
|
||||||
|
"object":{"id":"pay-1","status":"succeeded","paid":true,"metadata":{"order_id":"0197-order"}}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse notification: %v", err)
|
||||||
|
}
|
||||||
|
if n.Event != EventPaymentSucceeded {
|
||||||
|
t.Errorf("event = %q, want %q", n.Event, EventPaymentSucceeded)
|
||||||
|
}
|
||||||
|
p, err := n.Payment()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode notification payment: %v", err)
|
||||||
|
}
|
||||||
|
if p.ID != "pay-1" {
|
||||||
|
t.Errorf("payment id = %q, want pay-1", p.ID)
|
||||||
|
}
|
||||||
|
if p.OrderID() != "0197-order" {
|
||||||
|
t.Errorf("order id = %q, want the order carried in metadata", p.OrderID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNotificationRejectsNonEnvelopes(t *testing.T) {
|
||||||
|
for name, body := range map[string]string{
|
||||||
|
"not json": `nonsense`,
|
||||||
|
"wrong type": `{"type":"payment","event":"payment.succeeded","object":{"id":"pay-1"}}`,
|
||||||
|
"no event": `{"type":"notification","object":{"id":"pay-1"}}`,
|
||||||
|
"empty object": `{}`,
|
||||||
|
} {
|
||||||
|
if _, err := ParseNotification([]byte(body)); err == nil {
|
||||||
|
t.Errorf("%s: accepted as a notification envelope", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationPaymentNeedsAnID(t *testing.T) {
|
||||||
|
// Without an id there is nothing to re-read from the API, and the body itself is never evidence.
|
||||||
|
n, err := ParseNotification([]byte(`{"type":"notification","event":"payment.succeeded","object":{"status":"succeeded"}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse notification: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := n.Payment(); err == nil {
|
||||||
|
t.Error("a payment object with no id was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRefundNotification(t *testing.T) {
|
||||||
|
n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded",
|
||||||
|
"object":{"id":"refund-1","status":"succeeded","payment_id":"pay-1",
|
||||||
|
"amount":{"value":"149.00","currency":"RUB"}}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse notification: %v", err)
|
||||||
|
}
|
||||||
|
if n.Event != EventRefundSucceeded {
|
||||||
|
t.Errorf("event = %q, want %q", n.Event, EventRefundSucceeded)
|
||||||
|
}
|
||||||
|
r, err := n.Refund()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode notification refund: %v", err)
|
||||||
|
}
|
||||||
|
// The refund names the payment, not our order — that is how it is resolved back to one.
|
||||||
|
if r.ID != "refund-1" || r.PaymentID != "pay-1" {
|
||||||
|
t.Errorf("refund = %+v, want refund-1 for pay-1", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationRefundNeedsAnID(t *testing.T) {
|
||||||
|
n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded","object":{"status":"succeeded"}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse notification: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := n.Refund(); err == nil {
|
||||||
|
t.Error("a refund object with no id was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package yookassa
|
||||||
|
|
||||||
|
// Fiscal receipt support for «Чеки от ЮKassa» (54-ФЗ). YooKassa registers a receipt itself — no cash
|
||||||
|
// register to rent, no OFD contract — but only if the payment and refund requests carry a receipt
|
||||||
|
// object.
|
||||||
|
//
|
||||||
|
// It is DORMANT: the merchant runs on НПД, which is outside 54-ФЗ, so no receipt is sent and the
|
||||||
|
// merchant reports each operation to «Мой налог» instead. The code stays because the switch back is
|
||||||
|
// foreseeable — НПД has an annual income ceiling, and losing the regime puts 54-ФЗ back in force —
|
||||||
|
// and turning it on is then setting one deploy variable rather than writing this again. Everything
|
||||||
|
// here is inert while BACKEND_YOOKASSA_VAT_CODE is unset (see ReceiptEnabled). Reference:
|
||||||
|
// https://yookassa.ru/developers/payment-acceptance/receipts/54fz/yoomoney/parameters-values
|
||||||
|
const (
|
||||||
|
// PaymentSubjectService is the settlement subject (54-ФЗ tag 1212) — what the money is taken
|
||||||
|
// for. Chips are sold as a service ("Услуга").
|
||||||
|
PaymentSubjectService = "service"
|
||||||
|
// PaymentModeFullPayment is the settlement method (54-ФЗ tag 1214) — the payer pays and receives
|
||||||
|
// the goods at once ("Полный расчет"). «Чеки от ЮKassa» accept only this and full_prepayment;
|
||||||
|
// partial prepayment, advance and credit are not supported.
|
||||||
|
PaymentModeFullPayment = "full_payment"
|
||||||
|
|
||||||
|
// VatCodeNone is the VAT rate code (54-ФЗ tag 1199) for «Без НДС» — the rate a sole proprietor on
|
||||||
|
// УСН or ПСН charges. The deployed value is configurable because the rate is the one receipt
|
||||||
|
// attribute that genuinely changes (Russia raised the main rate on 1 Jan 2026, adding codes 11
|
||||||
|
// and 12).
|
||||||
|
VatCodeNone = 1
|
||||||
|
// VatCodeOff is the configured value that means "send no receipt at all" — the state a merchant
|
||||||
|
// outside 54-ФЗ runs in. It is the default, so receipts stay off until a rate is deliberately set.
|
||||||
|
VatCodeOff = 0
|
||||||
|
// VatCodeMax is the highest VAT rate code the API accepts; codes run 1..12.
|
||||||
|
VatCodeMax = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
// Customer is the payer's contact data for delivering the receipt. «Чеки от ЮKassa» deliver only by
|
||||||
|
// email (no SMS), so Email is required — the direct rail's confirmed email anchor supplies it.
|
||||||
|
type Customer struct {
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiptItem is one line of a fiscal receipt: what was sold, how much of it, for how much, and the
|
||||||
|
// three fiscal attributes that classify it.
|
||||||
|
type ReceiptItem struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Amount Amount `json:"amount"`
|
||||||
|
VatCode int `json:"vat_code"`
|
||||||
|
PaymentSubject string `json:"payment_subject"`
|
||||||
|
PaymentMode string `json:"payment_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receipt is the fiscal receipt data sent alongside a payment or a refund. tax_system_code is
|
||||||
|
// deliberately absent: YooKassa ignores it for «Чеки от ЮKassa».
|
||||||
|
type Receipt struct {
|
||||||
|
Customer Customer `json:"customer"`
|
||||||
|
Items []ReceiptItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxDescriptionRunes is the API's limit for both a payment description and a receipt line
|
||||||
|
// description (54-ФЗ tag 1030). A longer title is truncated rather than rejected, so an over-long
|
||||||
|
// product name cannot block a purchase.
|
||||||
|
const maxDescriptionRunes = 128
|
||||||
|
|
||||||
|
// SingleItemReceipt builds a one-line receipt for selling title at amount to email. Chip packs are
|
||||||
|
// always a single line bought once, so quantity is 1 and the line amount is the whole payment.
|
||||||
|
func SingleItemReceipt(email, title string, amount Amount, vatCode int) *Receipt {
|
||||||
|
return &Receipt{
|
||||||
|
Customer: Customer{Email: email},
|
||||||
|
Items: []ReceiptItem{{
|
||||||
|
Description: TruncateDescription(title),
|
||||||
|
Quantity: 1,
|
||||||
|
Amount: amount,
|
||||||
|
VatCode: vatCode,
|
||||||
|
PaymentSubject: PaymentSubjectService,
|
||||||
|
PaymentMode: PaymentModeFullPayment,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TruncateDescription shortens s to the API's description limit, counting runes rather than bytes so
|
||||||
|
// a Cyrillic title is not cut mid-character.
|
||||||
|
func TruncateDescription(s string) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= maxDescriptionRunes {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:maxDescriptionRunes])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidVatCode reports whether code is a rate the API accepts (1..12) or VatCodeOff, which disables
|
||||||
|
// receipts altogether.
|
||||||
|
func ValidVatCode(code int) bool { return code == VatCodeOff || (code >= 1 && code <= VatCodeMax) }
|
||||||
|
|
||||||
|
// ReceiptEnabled reports whether a configured VAT rate code asks for fiscal receipts. A merchant
|
||||||
|
// outside 54-ФЗ leaves the code unset and no receipt is built or sent.
|
||||||
|
func ReceiptEnabled(vatCode int) bool { return vatCode != VatCodeOff }
|
||||||
@@ -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,125 @@
|
|||||||
|
package yookassa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testShops() Shops {
|
||||||
|
return Shops{
|
||||||
|
ChannelWeb: {ShopID: "100500", SecretKey: "web-secret"},
|
||||||
|
ChannelAndroid: {ShopID: "100700", SecretKey: "android-secret"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShopsShopRouting(t *testing.T) {
|
||||||
|
s := testShops()
|
||||||
|
for channel, wantShopID := range map[string]string{
|
||||||
|
ChannelWeb: "100500",
|
||||||
|
ChannelAndroid: "100700",
|
||||||
|
"ios": "100500", // an unconfigured channel falls back to web
|
||||||
|
"": "100500", // so does an unknown platform subtype
|
||||||
|
} {
|
||||||
|
got, ok := s.Shop(channel)
|
||||||
|
if !ok || got.ShopID != wantShopID {
|
||||||
|
t.Errorf("Shop(%q) = %q ok=%v, want %q", channel, got.ShopID, ok, wantShopID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShopsShopWithoutWebFallback(t *testing.T) {
|
||||||
|
// Only android configured: an unknown channel has nowhere safe to fall back to.
|
||||||
|
s := Shops{ChannelAndroid: {ShopID: "100700", SecretKey: "android-secret"}}
|
||||||
|
if got, ok := s.Shop(ChannelAndroid); !ok || got.ShopID != "100700" {
|
||||||
|
t.Errorf("Shop(android) = %q ok=%v, want the android shop", got.ShopID, ok)
|
||||||
|
}
|
||||||
|
if _, ok := s.Shop("ios"); ok {
|
||||||
|
t.Error("Shop(ios) resolved with no web shop configured")
|
||||||
|
}
|
||||||
|
if _, ok := (Shops{}).Shop(ChannelWeb); ok {
|
||||||
|
t.Error("an empty set resolved a shop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShopsByShopIDDoesNotGuess(t *testing.T) {
|
||||||
|
s := testShops()
|
||||||
|
channel, cfg, ok := s.ByShopID("100700")
|
||||||
|
if !ok || channel != ChannelAndroid || cfg.SecretKey != "android-secret" {
|
||||||
|
t.Errorf("ByShopID(100700) = %q/%q ok=%v, want the android shop", channel, cfg.SecretKey, ok)
|
||||||
|
}
|
||||||
|
// An unrecognised shop id means the notification was not meant for us; guessing at credentials
|
||||||
|
// would verify a foreign payment against our own shop.
|
||||||
|
for _, id := range []string{"999999", ""} {
|
||||||
|
if _, _, ok := s.ByShopID(id); ok {
|
||||||
|
t.Errorf("ByShopID(%q) resolved a shop", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShopsIgnoreHalfConfiguredEntries(t *testing.T) {
|
||||||
|
s := Shops{
|
||||||
|
ChannelWeb: {ShopID: "100500"}, // no secret key
|
||||||
|
ChannelAndroid: {SecretKey: "android-secret"},
|
||||||
|
}
|
||||||
|
if s.Configured() {
|
||||||
|
t.Error("Configured() = true with no complete shop")
|
||||||
|
}
|
||||||
|
if _, ok := s.Shop(ChannelWeb); ok {
|
||||||
|
t.Error("Shop(web) resolved a shop with no secret key")
|
||||||
|
}
|
||||||
|
if _, _, ok := s.ByShopID("100500"); ok {
|
||||||
|
t.Error("ByShopID matched a shop with no secret key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShopsConfigured(t *testing.T) {
|
||||||
|
if !testShops().Configured() {
|
||||||
|
t.Error("Configured() = false with two complete shops")
|
||||||
|
}
|
||||||
|
if (Shops{}).Configured() {
|
||||||
|
t.Error("Configured() = true for an empty set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSingleItemReceiptTruncatesLongTitles(t *testing.T) {
|
||||||
|
long := strings.Repeat("ф", 200) // Cyrillic: truncation must count runes, not bytes
|
||||||
|
r := SingleItemReceipt("buyer@example.test", long, Amount{Value: "1.00", Currency: "RUB"}, VatCodeNone)
|
||||||
|
got := []rune(r.Items[0].Description)
|
||||||
|
if len(got) != maxDescriptionRunes {
|
||||||
|
t.Errorf("description = %d runes, want %d", len(got), maxDescriptionRunes)
|
||||||
|
}
|
||||||
|
for _, c := range got {
|
||||||
|
if c != 'ф' {
|
||||||
|
t.Fatalf("truncation cut a multi-byte rune: %q", string(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Items[0].Quantity != 1 {
|
||||||
|
t.Errorf("quantity = %v, want 1 (a chip pack is bought once)", r.Items[0].Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidVatCode(t *testing.T) {
|
||||||
|
// VatCodeOff is a legal configuration: it means "send no receipt", the state a merchant outside
|
||||||
|
// 54-ФЗ runs in.
|
||||||
|
for _, code := range []int{VatCodeOff, 1, 4, 11, 12} {
|
||||||
|
if !ValidVatCode(code) {
|
||||||
|
t.Errorf("vat code %d rejected, want accepted", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, code := range []int{-1, 13} {
|
||||||
|
if ValidVatCode(code) {
|
||||||
|
t.Errorf("vat code %d accepted, want rejected", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReceiptEnabled(t *testing.T) {
|
||||||
|
if ReceiptEnabled(VatCodeOff) {
|
||||||
|
t.Error("receipts reported enabled with no VAT rate configured")
|
||||||
|
}
|
||||||
|
for _, code := range []int{1, 4, 11} {
|
||||||
|
if !ReceiptEnabled(code) {
|
||||||
|
t.Errorf("receipts reported disabled for vat code %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
// Package yookassa talks to the YooKassa REST API for the direct-rail (RUB) payments: it creates a
|
||||||
|
// hosted payment the client is redirected to, re-reads a payment to confirm what really happened,
|
||||||
|
// and issues a refund. It is pure provider glue — no database, no payments-domain coupling — so the
|
||||||
|
// payments domain stays provider-agnostic and this layer is unit-testable in isolation.
|
||||||
|
//
|
||||||
|
// Two properties of the API shape everything here. First, YooKassa notifications carry NO signature:
|
||||||
|
// authenticity is established by re-reading the object with GetPayment (the notification body is a
|
||||||
|
// hint, never evidence) and, defensively, by the sender-IP allowlist in AllowedIP. Second, every
|
||||||
|
// state-changing call is made idempotent by an Idempotence-Key header, so a retried create or refund
|
||||||
|
// returns the original object instead of charging twice; callers pass the order id as that key.
|
||||||
|
//
|
||||||
|
// The order is threaded to the provider through metadata["order_id"] and echoed back on the payment,
|
||||||
|
// so a notification resolves to an order without trusting anything else in the body.
|
||||||
|
package yookassa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultBaseURL is the production API root. Config.BaseURL overrides it (tests point it at an
|
||||||
|
// httptest server).
|
||||||
|
const DefaultBaseURL = "https://api.yookassa.ru/v3"
|
||||||
|
|
||||||
|
// MetadataOrderID is the metadata key carrying our order id through the provider and back on the
|
||||||
|
// payment object — the only link a notification gives us to an order.
|
||||||
|
const MetadataOrderID = "order_id"
|
||||||
|
|
||||||
|
// Payment statuses (https://yookassa.ru/developers/payment-acceptance/getting-started/payment-process).
|
||||||
|
// A single-stage payment (Capture true) moves pending -> succeeded or pending -> canceled;
|
||||||
|
// StatusWaitingForCapture only occurs for two-stage payments, which this integration does not use.
|
||||||
|
const (
|
||||||
|
StatusPending = "pending"
|
||||||
|
StatusWaitingForCapture = "waiting_for_capture"
|
||||||
|
StatusSucceeded = "succeeded"
|
||||||
|
StatusCanceled = "canceled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfirmationRedirect is the confirmation scenario this integration uses: YooKassa hosts the
|
||||||
|
// payment form and returns the customer to the return URL afterwards.
|
||||||
|
const ConfirmationRedirect = "redirect"
|
||||||
|
|
||||||
|
// requestTimeout bounds a single API call. The caller's context still governs cancellation; this is
|
||||||
|
// the ceiling for a provider that has stopped answering.
|
||||||
|
const requestTimeout = 20 * time.Second
|
||||||
|
|
||||||
|
// maxResponseBytes caps how much of a response body is read, so a misbehaving or spoofed endpoint
|
||||||
|
// cannot exhaust memory.
|
||||||
|
const maxResponseBytes = 1 << 20
|
||||||
|
|
||||||
|
// apiClient is shared by every shop so connections pool across them. It carries only a ceiling
|
||||||
|
// timeout; per-request cancellation rides the context.
|
||||||
|
var apiClient = &http.Client{Timeout: requestTimeout}
|
||||||
|
|
||||||
|
// Config is a YooKassa merchant shop's credentials. ShopID and SecretKey authenticate every call as
|
||||||
|
// HTTP Basic credentials. IsTest records that these are a test shop's credentials, which lets the
|
||||||
|
// caller refuse to credit a live payment against a test shop and vice versa. BaseURL overrides
|
||||||
|
// DefaultBaseURL when set.
|
||||||
|
type Config struct {
|
||||||
|
ShopID string
|
||||||
|
SecretKey string
|
||||||
|
IsTest bool
|
||||||
|
BaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amount is a monetary amount on the wire: a decimal string with the fraction separator "." and an
|
||||||
|
// ISO-4217 currency code. payments.Money.Major() already renders Value in this form.
|
||||||
|
type Amount struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmation carries the confirmation scenario. ReturnURL is sent on a create (where the customer
|
||||||
|
// lands after paying, absolute); ConfirmationURL comes back on the created payment and is the page
|
||||||
|
// the customer must be sent to.
|
||||||
|
type Confirmation struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ReturnURL string `json:"return_url,omitempty"`
|
||||||
|
ConfirmationURL string `json:"confirmation_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient identifies the shop that received the payment. AccountID is the shop id, which is how an
|
||||||
|
// incoming notification is attributed to one of several configured shops.
|
||||||
|
type Recipient struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
GatewayID string `json:"gateway_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancellation explains a canceled payment: which participant decided (yoo_money, payment_network,
|
||||||
|
// merchant) and why. It is reported to the operator, not to the payer.
|
||||||
|
type Cancellation struct {
|
||||||
|
Party string `json:"party"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentRequest is the body of a create-payment call in the redirect confirmation scenario. Capture
|
||||||
|
// true settles in one stage: a paid payment goes straight to succeeded with no capture call.
|
||||||
|
type PaymentRequest struct {
|
||||||
|
Amount Amount `json:"amount"`
|
||||||
|
Capture bool `json:"capture"`
|
||||||
|
Confirmation Confirmation `json:"confirmation"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
Receipt *Receipt `json:"receipt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment is a YooKassa payment object. Paid reports that the money was actually taken; Test is true
|
||||||
|
// for a payment made against a test shop. Metadata echoes what the create call sent, so
|
||||||
|
// Metadata[MetadataOrderID] identifies the order.
|
||||||
|
type Payment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Paid bool `json:"paid"`
|
||||||
|
Test bool `json:"test"`
|
||||||
|
Amount Amount `json:"amount"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Confirmation Confirmation `json:"confirmation"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
Recipient Recipient `json:"recipient"`
|
||||||
|
CancellationDetails *Cancellation `json:"cancellation_details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderID returns the order the payment funds, taken from the metadata the create call threaded
|
||||||
|
// through. It is empty for a payment we did not originate.
|
||||||
|
func (p Payment) OrderID() string { return p.Metadata[MetadataOrderID] }
|
||||||
|
|
||||||
|
// RefundRequest is the body of a create-refund call. Amount may be the whole payment or part of it;
|
||||||
|
// Receipt carries the refund receipt required when the shop registers receipts through YooKassa.
|
||||||
|
type RefundRequest struct {
|
||||||
|
PaymentID string `json:"payment_id"`
|
||||||
|
Amount Amount `json:"amount"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Receipt *Receipt `json:"receipt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refund is a YooKassa refund object. Its ID is distinct from the refunded payment's id and is what
|
||||||
|
// the ledger records as the refund's idempotency key.
|
||||||
|
type Refund struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
PaymentID string `json:"payment_id"`
|
||||||
|
Amount Amount `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrUnconfigured is returned when a call is attempted on a shop with no credentials.
|
||||||
|
var ErrUnconfigured = errors.New("yookassa: shop is not configured")
|
||||||
|
|
||||||
|
// APIError is a non-2xx answer from the API. Description and Parameter come from YooKassa's error
|
||||||
|
// object and name the offending field, which is what makes a malformed receipt diagnosable.
|
||||||
|
type APIError struct {
|
||||||
|
StatusCode int `json:"-"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Parameter string `json:"parameter"`
|
||||||
|
RetryAfter int `json:"retry_after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error renders the status together with YooKassa's own description, including the offending
|
||||||
|
// parameter when the API named one.
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
msg := e.Description
|
||||||
|
if msg == "" {
|
||||||
|
msg = e.Code
|
||||||
|
}
|
||||||
|
if e.Parameter != "" {
|
||||||
|
return fmt.Sprintf("yookassa: http %d: %s (parameter %s)", e.StatusCode, msg, e.Parameter)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("yookassa: http %d: %s", e.StatusCode, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retryable reports whether repeating the call could succeed. A 5xx or a 429 is transient; a 4xx is
|
||||||
|
// a permanent rejection of this request. Callers use it to decide whether to ask the provider to
|
||||||
|
// redeliver a notification or to accept it as durably handled.
|
||||||
|
func (e *APIError) Retryable() bool {
|
||||||
|
return e.StatusCode >= 500 || e.StatusCode == http.StatusTooManyRequests
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured reports whether the shop has credentials to call the API with.
|
||||||
|
func (c Config) Configured() bool { return c.ShopID != "" && c.SecretKey != "" }
|
||||||
|
|
||||||
|
// CreatePayment opens a payment for the order and returns the created object, whose
|
||||||
|
// Confirmation.ConfirmationURL is the page the customer must be sent to. idempotenceKey makes the
|
||||||
|
// call safe to retry — YooKassa replays the original result for 24 hours — so callers pass the order
|
||||||
|
// id and never mint a second payment for the same order.
|
||||||
|
func (c Config) CreatePayment(ctx context.Context, req PaymentRequest, idempotenceKey string) (Payment, error) {
|
||||||
|
var out Payment
|
||||||
|
err := c.do(ctx, http.MethodPost, "/payments", req, idempotenceKey, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPayment re-reads a payment by id. This is the authenticity check for the whole rail: YooKassa
|
||||||
|
// notifications are unsigned, so only the object returned here may be acted on.
|
||||||
|
func (c Config) GetPayment(ctx context.Context, paymentID string) (Payment, error) {
|
||||||
|
var out Payment
|
||||||
|
err := c.do(ctx, http.MethodGet, "/payments/"+url.PathEscape(paymentID), nil, "", &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRefund returns money for a succeeded payment and reports the created refund. idempotenceKey
|
||||||
|
// guards against a double refund on a retry; callers pass the order id. The returned refund is not
|
||||||
|
// necessarily final — check Status before acting on it.
|
||||||
|
func (c Config) CreateRefund(ctx context.Context, req RefundRequest, idempotenceKey string) (Refund, error) {
|
||||||
|
var out Refund
|
||||||
|
err := c.do(ctx, http.MethodPost, "/refunds", req, idempotenceKey, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRefund re-reads a refund by id. As with GetPayment this is the authenticity check: a refund
|
||||||
|
// notification is unsigned, so only the object returned here may be acted on.
|
||||||
|
func (c Config) GetRefund(ctx context.Context, refundID string) (Refund, error) {
|
||||||
|
var out Refund
|
||||||
|
err := c.do(ctx, http.MethodGet, "/refunds/"+url.PathEscape(refundID), nil, "", &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// do performs one authenticated API call and decodes the result into out. A non-2xx answer is
|
||||||
|
// returned as an *APIError carrying YooKassa's own error object when the body holds one.
|
||||||
|
func (c Config) do(ctx context.Context, method, path string, body any, idempotenceKey string, out any) error {
|
||||||
|
if !c.Configured() {
|
||||||
|
return ErrUnconfigured
|
||||||
|
}
|
||||||
|
var payload io.Reader
|
||||||
|
if body != nil {
|
||||||
|
buf, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("yookassa: encode %s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
payload = bytes.NewReader(buf)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.endpoint()+path, payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("yookassa: build %s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(c.ShopID, c.SecretKey)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if idempotenceKey != "" {
|
||||||
|
req.Header.Set("Idempotence-Key", idempotenceKey)
|
||||||
|
}
|
||||||
|
resp, err := apiClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("yookassa: %s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("yookassa: read %s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||||
|
apiErr := &APIError{StatusCode: resp.StatusCode}
|
||||||
|
// The body is YooKassa's error object on a handled fault and something else entirely on an
|
||||||
|
// edge failure; either way the status alone is enough to classify the error.
|
||||||
|
_ = json.Unmarshal(raw, apiErr)
|
||||||
|
return apiErr
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, out); err != nil {
|
||||||
|
return fmt.Errorf("yookassa: decode %s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpoint returns the API root for this shop, without a trailing slash.
|
||||||
|
func (c Config) endpoint() string {
|
||||||
|
if c.BaseURL == "" {
|
||||||
|
return DefaultBaseURL
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(c.BaseURL, "/")
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package yookassa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// capture records what the fake API received, so a test can assert on the request the client built.
|
||||||
|
type capture struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
authUser string
|
||||||
|
authPass string
|
||||||
|
idempotenceKey string
|
||||||
|
body map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeAPI serves one canned response and records the request. It returns a Config already pointed at
|
||||||
|
// it, which is how every API-shaped test drives the client without touching the network.
|
||||||
|
func fakeAPI(t *testing.T, status int, response string) (Config, *capture) {
|
||||||
|
t.Helper()
|
||||||
|
got := &capture{}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got.method = r.Method
|
||||||
|
got.path = r.URL.Path
|
||||||
|
got.authUser, got.authPass, _ = r.BasicAuth()
|
||||||
|
got.idempotenceKey = r.Header.Get("Idempotence-Key")
|
||||||
|
if raw, _ := io.ReadAll(r.Body); len(raw) > 0 {
|
||||||
|
_ = json.Unmarshal(raw, &got.body)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = io.WriteString(w, response)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return Config{ShopID: "100500", SecretKey: "secret", BaseURL: srv.URL}, got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePaymentBuildsTheRequest(t *testing.T) {
|
||||||
|
cfg, got := fakeAPI(t, http.StatusOK, `{
|
||||||
|
"id":"23d93cac-000f-5000-8000-126628f15141","status":"pending","paid":false,"test":false,
|
||||||
|
"amount":{"value":"149.00","currency":"RUB"},
|
||||||
|
"confirmation":{"type":"redirect","confirmation_url":"https://yoomoney.ru/pay/abc"},
|
||||||
|
"metadata":{"order_id":"0197-order"}}`)
|
||||||
|
|
||||||
|
amount := Amount{Value: "149.00", Currency: "RUB"}
|
||||||
|
p, err := cfg.CreatePayment(context.Background(), PaymentRequest{
|
||||||
|
Amount: amount,
|
||||||
|
Capture: true,
|
||||||
|
Confirmation: Confirmation{Type: ConfirmationRedirect, ReturnURL: "https://example.test/pay/yookassa/return"},
|
||||||
|
Description: "10 chips",
|
||||||
|
Metadata: map[string]string{MetadataOrderID: "0197-order"},
|
||||||
|
Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone),
|
||||||
|
}, "0197-order")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create payment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.method != http.MethodPost || got.path != "/payments" {
|
||||||
|
t.Errorf("request = %s %s, want POST /payments", got.method, got.path)
|
||||||
|
}
|
||||||
|
if got.authUser != "100500" || got.authPass != "secret" {
|
||||||
|
t.Errorf("basic auth = %q:%q, want the shop id and secret key", got.authUser, got.authPass)
|
||||||
|
}
|
||||||
|
// The idempotence key is what makes a retried create return the original payment instead of
|
||||||
|
// minting a second one for the same order.
|
||||||
|
if got.idempotenceKey != "0197-order" {
|
||||||
|
t.Errorf("Idempotence-Key = %q, want the order id", got.idempotenceKey)
|
||||||
|
}
|
||||||
|
if got.body["capture"] != true {
|
||||||
|
t.Errorf("capture = %v, want true (single-stage payment)", got.body["capture"])
|
||||||
|
}
|
||||||
|
conf, _ := got.body["confirmation"].(map[string]any)
|
||||||
|
if conf["type"] != ConfirmationRedirect || conf["return_url"] != "https://example.test/pay/yookassa/return" {
|
||||||
|
t.Errorf("confirmation = %v, want a redirect with the return url", conf)
|
||||||
|
}
|
||||||
|
meta, _ := got.body["metadata"].(map[string]any)
|
||||||
|
if meta[MetadataOrderID] != "0197-order" {
|
||||||
|
t.Errorf("metadata = %v, want the order id threaded through", meta)
|
||||||
|
}
|
||||||
|
receipt, _ := got.body["receipt"].(map[string]any)
|
||||||
|
customer, _ := receipt["customer"].(map[string]any)
|
||||||
|
if customer["email"] != "buyer@example.test" {
|
||||||
|
t.Errorf("receipt customer = %v, want the buyer email", customer)
|
||||||
|
}
|
||||||
|
items, _ := receipt["items"].([]any)
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("receipt items = %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
item, _ := items[0].(map[string]any)
|
||||||
|
if item["vat_code"] != float64(VatCodeNone) || item["payment_subject"] != PaymentSubjectService || item["payment_mode"] != PaymentModeFullPayment {
|
||||||
|
t.Errorf("receipt item fiscal attributes = %v", item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Confirmation.ConfirmationURL != "https://yoomoney.ru/pay/abc" {
|
||||||
|
t.Errorf("confirmation url = %q, want the hosted payment page", p.Confirmation.ConfirmationURL)
|
||||||
|
}
|
||||||
|
if p.OrderID() != "0197-order" {
|
||||||
|
t.Errorf("OrderID() = %q, want the order the payment funds", p.OrderID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPaymentReadsTheObject(t *testing.T) {
|
||||||
|
cfg, got := fakeAPI(t, http.StatusOK, `{
|
||||||
|
"id":"pay-1","status":"succeeded","paid":true,"test":true,
|
||||||
|
"amount":{"value":"149.00","currency":"RUB"},
|
||||||
|
"recipient":{"account_id":"100500","gateway_id":"100700"},
|
||||||
|
"metadata":{"order_id":"0197-order"}}`)
|
||||||
|
|
||||||
|
p, err := cfg.GetPayment(context.Background(), "pay-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get payment: %v", err)
|
||||||
|
}
|
||||||
|
if got.method != http.MethodGet || got.path != "/payments/pay-1" {
|
||||||
|
t.Errorf("request = %s %s, want GET /payments/pay-1", got.method, got.path)
|
||||||
|
}
|
||||||
|
// A read is idempotent by nature, so no key is sent.
|
||||||
|
if got.idempotenceKey != "" {
|
||||||
|
t.Errorf("Idempotence-Key = %q, want none on a GET", got.idempotenceKey)
|
||||||
|
}
|
||||||
|
if p.Status != StatusSucceeded || !p.Paid {
|
||||||
|
t.Errorf("payment = %s paid=%v, want succeeded and paid", p.Status, p.Paid)
|
||||||
|
}
|
||||||
|
if !p.Test {
|
||||||
|
t.Error("Test = false, want true — a test-shop payment must be distinguishable from a live one")
|
||||||
|
}
|
||||||
|
if p.Recipient.AccountID != "100500" {
|
||||||
|
t.Errorf("recipient.account_id = %q, want the shop id", p.Recipient.AccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRefundBuildsTheRequest(t *testing.T) {
|
||||||
|
cfg, got := fakeAPI(t, http.StatusOK, `{
|
||||||
|
"id":"refund-1","status":"succeeded","payment_id":"pay-1",
|
||||||
|
"amount":{"value":"149.00","currency":"RUB"}}`)
|
||||||
|
|
||||||
|
amount := Amount{Value: "149.00", Currency: "RUB"}
|
||||||
|
r, err := cfg.CreateRefund(context.Background(), RefundRequest{
|
||||||
|
PaymentID: "pay-1",
|
||||||
|
Amount: amount,
|
||||||
|
Receipt: SingleItemReceipt("buyer@example.test", "10 chips", amount, VatCodeNone),
|
||||||
|
}, "0197-order")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create refund: %v", err)
|
||||||
|
}
|
||||||
|
if got.method != http.MethodPost || got.path != "/refunds" {
|
||||||
|
t.Errorf("request = %s %s, want POST /refunds", got.method, got.path)
|
||||||
|
}
|
||||||
|
if got.idempotenceKey != "0197-order" {
|
||||||
|
t.Errorf("Idempotence-Key = %q, want the order id (guards a double refund)", got.idempotenceKey)
|
||||||
|
}
|
||||||
|
if got.body["payment_id"] != "pay-1" {
|
||||||
|
t.Errorf("payment_id = %v, want the refunded payment", got.body["payment_id"])
|
||||||
|
}
|
||||||
|
if _, ok := got.body["receipt"]; !ok {
|
||||||
|
t.Error("refund carries no receipt — a refund receipt is required under «Чеки от ЮKassa»")
|
||||||
|
}
|
||||||
|
// The refund id is distinct from the payment id: the ledger keys the refund row on it, so the
|
||||||
|
// fund and refund rows can coexist under the same partial-unique index.
|
||||||
|
if r.ID != "refund-1" || r.PaymentID != "pay-1" {
|
||||||
|
t.Errorf("refund = %+v, want id refund-1 for payment pay-1", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIErrorClassifiesRetryability(t *testing.T) {
|
||||||
|
cfg, _ := fakeAPI(t, http.StatusBadRequest,
|
||||||
|
`{"type":"error","id":"err-1","code":"invalid_request","description":"Receipt item is invalid","parameter":"receipt.items"}`)
|
||||||
|
|
||||||
|
_, err := cfg.GetPayment(context.Background(), "pay-1")
|
||||||
|
var apiErr *APIError
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
t.Fatalf("error = %v, want an *APIError", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
// The offending parameter is what makes a malformed receipt diagnosable rather than a mystery.
|
||||||
|
if !strings.Contains(apiErr.Error(), "receipt.items") {
|
||||||
|
t.Errorf("error text %q does not name the offending parameter", apiErr.Error())
|
||||||
|
}
|
||||||
|
if apiErr.Retryable() {
|
||||||
|
t.Error("a 400 reported as retryable; a permanent rejection must not be re-delivered")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} {
|
||||||
|
cfg, _ := fakeAPI(t, status, `{"type":"error","description":"try later"}`)
|
||||||
|
_, err := cfg.GetPayment(context.Background(), "pay-1")
|
||||||
|
var e *APIError
|
||||||
|
if !errors.As(err, &e) || !e.Retryable() {
|
||||||
|
t.Errorf("status %d: error = %v, want a retryable *APIError", status, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnconfiguredShopMakesNoCall(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Error("an unconfigured shop reached the API")
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
for name, cfg := range map[string]Config{
|
||||||
|
"no credentials": {BaseURL: srv.URL},
|
||||||
|
"no secret key": {ShopID: "100500", BaseURL: srv.URL},
|
||||||
|
"no shop id": {SecretKey: "secret", BaseURL: srv.URL},
|
||||||
|
} {
|
||||||
|
if _, err := cfg.GetPayment(context.Background(), "pay-1"); !errors.Is(err, ErrUnconfigured) {
|
||||||
|
t.Errorf("%s: error = %v, want ErrUnconfigured", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndpointDefaultsToTheProductionAPI(t *testing.T) {
|
||||||
|
if got := (Config{}).endpoint(); got != DefaultBaseURL {
|
||||||
|
t.Errorf("endpoint = %q, want %q", got, DefaultBaseURL)
|
||||||
|
}
|
||||||
|
if got := (Config{BaseURL: "https://api.test/v3/"}).endpoint(); got != "https://api.test/v3" {
|
||||||
|
t.Errorf("endpoint = %q, want the trailing slash trimmed", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRefundReadsTheObject(t *testing.T) {
|
||||||
|
cfg, got := fakeAPI(t, http.StatusOK, `{
|
||||||
|
"id":"refund-1","status":"succeeded","payment_id":"pay-1",
|
||||||
|
"amount":{"value":"149.00","currency":"RUB"}}`)
|
||||||
|
|
||||||
|
r, err := cfg.GetRefund(context.Background(), "refund-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get refund: %v", err)
|
||||||
|
}
|
||||||
|
if got.method != http.MethodGet || got.path != "/refunds/refund-1" {
|
||||||
|
t.Errorf("request = %s %s, want GET /refunds/refund-1", got.method, got.path)
|
||||||
|
}
|
||||||
|
if r.Status != StatusSucceeded || r.PaymentID != "pay-1" {
|
||||||
|
t.Errorf("refund = %+v, want a succeeded refund of pay-1", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateRefundReportsANonFinalStatus(t *testing.T) {
|
||||||
|
// A refund can be accepted and still be canceled later, so the caller must be able to see that it
|
||||||
|
// is not settled rather than treat any 200 as done.
|
||||||
|
cfg, _ := fakeAPI(t, http.StatusOK, `{
|
||||||
|
"id":"refund-1","status":"pending","payment_id":"pay-1",
|
||||||
|
"amount":{"value":"149.00","currency":"RUB"}}`)
|
||||||
|
|
||||||
|
r, err := cfg.CreateRefund(context.Background(), RefundRequest{
|
||||||
|
PaymentID: "pay-1", Amount: Amount{Value: "149.00", Currency: "RUB"},
|
||||||
|
}, "0197-order")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create refund: %v", err)
|
||||||
|
}
|
||||||
|
if r.Status != StatusPending {
|
||||||
|
t.Errorf("status = %q, want pending surfaced to the caller", r.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-22
@@ -132,28 +132,33 @@ GATEWAY_VK_APP_SECRET=
|
|||||||
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET=
|
GATEWAY_VK_ID_CLIENT_SECRET=
|
||||||
|
|
||||||
# --- Payments: Robokassa (direct RUB rail) ----------------------------------
|
# --- Payments: YooKassa (direct RUB rail) ------------------------------------
|
||||||
# The shop's merchant login + the two pass phrases (Password1 signs the launch request,
|
# One merchant shop per channel (D42): the shop id (Настройки — Магазин, field shopId) and the
|
||||||
# Password2 signs/verifies the Result callback). An empty login leaves the direct rail off.
|
# secret key (Интеграция — Ключи API). Each shop credits the one direct wallet; a shop missing
|
||||||
# ROBOKASSA_TEST=1 runs test payments against the shop's TEST pass phrases (no real money);
|
# either credential drops that channel (order routing falls back to web), and no shop at all
|
||||||
# empty/0 is live. Mapped in compose to BACKEND_ROBOKASSA_*; Gitea TEST_/PROD_ secrets, with
|
# leaves the direct rail off. YOOKASSA_*_TEST=1 marks a TEST shop, which makes the intake refuse
|
||||||
# PROD_BACKEND_ROBOKASSA_TEST a variable so go-live is a flag flip, not a secret redeploy.
|
# to credit a live payment against it (and a test payment against a live shop).
|
||||||
ROBOKASSA_MERCHANT_LOGIN=
|
# Mapped in compose to BACKEND_YOOKASSA_*; Gitea TEST_/PROD_ secrets.
|
||||||
ROBOKASSA_PASSWORD1=
|
# Set each shop's notification URL in its cabinet (Интеграция — HTTP-уведомления) to
|
||||||
ROBOKASSA_PASSWORD2=
|
# ${PUBLIC_BASE_URL}/pay/yookassa/notify, events payment.succeeded + payment.canceled +
|
||||||
ROBOKASSA_TEST=
|
# refund.succeeded (the last one catches a refund issued in the cabinet, which bypasses our API).
|
||||||
# Multi-shop direct rail (D42): the vars above seed the "web" channel; add per-channel shops here.
|
YOOKASSA_WEB_SHOP_ID=
|
||||||
# ROBOKASSA_WEB_* overrides the web shop; ROBOKASSA_ANDROID_* adds the android (RuStore) shop. Each
|
YOOKASSA_WEB_SECRET_KEY=
|
||||||
# credits the one direct wallet; an empty login drops that channel (routing falls back to web). The
|
YOOKASSA_WEB_TEST=
|
||||||
# Robokassa cabinet points each shop's Result URL at /pay/robokassa/result/web and .../android.
|
# The android (RuStore) shop — add when that channel gets its own merchant account.
|
||||||
ROBOKASSA_WEB_MERCHANT_LOGIN=
|
YOOKASSA_ANDROID_SHOP_ID=
|
||||||
ROBOKASSA_WEB_PASSWORD1=
|
YOOKASSA_ANDROID_SECRET_KEY=
|
||||||
ROBOKASSA_WEB_PASSWORD2=
|
YOOKASSA_ANDROID_TEST=
|
||||||
ROBOKASSA_WEB_TEST=
|
# Fiscal receipts (54-ФЗ) — OFF while empty, which is the intended state: the merchant runs on НПД,
|
||||||
ROBOKASSA_ANDROID_MERCHANT_LOGIN=
|
# outside 54-ФЗ, so nothing is registered through the provider and each operation is reported to
|
||||||
ROBOKASSA_ANDROID_PASSWORD1=
|
# «Мой налог» instead. Set a VAT rate code (tag 1199: 1 = «Без НДС», 4 = 20%, 11 = 22%) to turn
|
||||||
ROBOKASSA_ANDROID_PASSWORD2=
|
# receipts back on — the switch a lost НПД regime would need. A Gitea VARIABLE, not a secret.
|
||||||
ROBOKASSA_ANDROID_TEST=
|
YOOKASSA_VAT_CODE=
|
||||||
|
#
|
||||||
|
# Robokassa (the retired direct rail) is deliberately absent here: no deployment sets its
|
||||||
|
# credentials, which is what keeps it dormant. The backend still reads BACKEND_ROBOKASSA_* and
|
||||||
|
# falls the direct rail back to it if they are ever set again — see
|
||||||
|
# backend/internal/robokassa/README.md for the full variable set and how to revive the rail.
|
||||||
|
|
||||||
# --- Gateway anti-abuse ------------------------------------------------------
|
# --- Gateway anti-abuse ------------------------------------------------------
|
||||||
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
# 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>'`. |
|
| `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). |
|
| `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`. |
|
| `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_YOOKASSA_{WEB,ANDROID}_SHOP_ID` | secret | YooKassa shop id for that channel of the direct RUB rail (cabinet: **Настройки — Магазин**, field `shopId`). Empty ⇒ that channel is unconfigured and order routing falls back to the **web** shop; no channel configured at all leaves the rail off. Each shop's **Интеграция — HTTP-уведомления** must point at `<PUBLIC_BASE_URL>/pay/yookassa/notify` with events `payment.succeeded`, `payment.canceled` and `refund.succeeded`. |
|
||||||
| `BACKEND_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_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_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_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_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_VAT_CODE` | **variable** | Fiscal receipts (54-ФЗ). **Empty by default = no receipt is sent at all**, which is the intended state: the merchant runs on НПД, outside 54-ФЗ, so nothing is registered through the provider and each operation is reported to «Мой налог» instead. Setting a VAT rate code (tag 1199: `1` = «Без НДС», `4` = 20%, `11` = 22%) switches receipts back on with no code change — the path a lost НПД regime (annual income ceiling) would take. Confirm the rate with the accountant before switching it on: a wrong rate produces wrong receipts, not a failed payment, so it fails silently. |
|
||||||
|
|
||||||
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
**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
|
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
||||||
|
|||||||
+18
-19
@@ -164,25 +164,24 @@ services:
|
|||||||
# recipient(s) (comma-separated allowed). Both empty disables the alert worker.
|
# recipient(s) (comma-separated allowed). Both empty disables the alert worker.
|
||||||
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
|
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
|
||||||
BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||||
# Direct-rail (Robokassa) payment intake: the merchant login + Password1/Password2 and the
|
# Direct-rail (YooKassa) payment intake, one merchant shop per channel (D42): the shop id +
|
||||||
# test-mode flag (deploy env: TEST_/PROD_BACKEND_ROBOKASSA_*). An empty login leaves the
|
# secret key and a flag marking a test shop (deploy env: TEST_/PROD_BACKEND_YOOKASSA_*).
|
||||||
# direct order and Result-callback endpoints unregistered (the rail is off).
|
# Each shop credits the one direct wallet; a shop missing either credential drops that
|
||||||
BACKEND_ROBOKASSA_MERCHANT_LOGIN: ${ROBOKASSA_MERCHANT_LOGIN:-}
|
# channel (order routing falls back to the web shop), and no shop at all leaves the direct
|
||||||
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
|
# order and notification endpoints unregistered. Notification URL (set per shop in the
|
||||||
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
|
# YooKassa cabinet): ${PUBLIC_BASE_URL}/pay/yookassa/notify — events payment.succeeded,
|
||||||
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-}
|
# payment.canceled and refund.succeeded.
|
||||||
# Per-channel shops for the multi-shop direct rail (D42): the legacy ROBOKASSA_* above seeds
|
BACKEND_YOOKASSA_WEB_SHOP_ID: ${YOOKASSA_WEB_SHOP_ID:-}
|
||||||
# the "web" channel; ROBOKASSA_WEB_* overrides it and ROBOKASSA_ANDROID_* adds the android
|
BACKEND_YOOKASSA_WEB_SECRET_KEY: ${YOOKASSA_WEB_SECRET_KEY:-}
|
||||||
# shop. Each shop credits the one direct wallet; an empty login drops that channel (order
|
BACKEND_YOOKASSA_WEB_TEST: ${YOOKASSA_WEB_TEST:-}
|
||||||
# routing falls back to the web shop). Result URLs: /pay/robokassa/result/{web,android}.
|
BACKEND_YOOKASSA_ANDROID_SHOP_ID: ${YOOKASSA_ANDROID_SHOP_ID:-}
|
||||||
BACKEND_ROBOKASSA_WEB_MERCHANT_LOGIN: ${ROBOKASSA_WEB_MERCHANT_LOGIN:-}
|
BACKEND_YOOKASSA_ANDROID_SECRET_KEY: ${YOOKASSA_ANDROID_SECRET_KEY:-}
|
||||||
BACKEND_ROBOKASSA_WEB_PASSWORD1: ${ROBOKASSA_WEB_PASSWORD1:-}
|
BACKEND_YOOKASSA_ANDROID_TEST: ${YOOKASSA_ANDROID_TEST:-}
|
||||||
BACKEND_ROBOKASSA_WEB_PASSWORD2: ${ROBOKASSA_WEB_PASSWORD2:-}
|
# Fiscal receipts (54-ФЗ) are OFF: the merchant runs on НПД, which is outside 54-ФЗ, so no
|
||||||
BACKEND_ROBOKASSA_WEB_TEST: ${ROBOKASSA_WEB_TEST:-}
|
# receipt is registered through the provider and each operation is reported to «Мой налог».
|
||||||
BACKEND_ROBOKASSA_ANDROID_MERCHANT_LOGIN: ${ROBOKASSA_ANDROID_MERCHANT_LOGIN:-}
|
# Setting a VAT rate code (tag 1199: 1 = «Без НДС», 4 = 20%, 11 = 22%) turns receipts back on
|
||||||
BACKEND_ROBOKASSA_ANDROID_PASSWORD1: ${ROBOKASSA_ANDROID_PASSWORD1:-}
|
# — the switch a lost НПД regime would need — with no code change.
|
||||||
BACKEND_ROBOKASSA_ANDROID_PASSWORD2: ${ROBOKASSA_ANDROID_PASSWORD2:-}
|
BACKEND_YOOKASSA_VAT_CODE: ${YOOKASSA_VAT_CODE:-}
|
||||||
BACKEND_ROBOKASSA_ANDROID_TEST: ${ROBOKASSA_ANDROID_TEST:-}
|
|
||||||
# The dictionary lives on a named volume seeded from the image on first boot
|
# 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
|
# (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
|
# 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_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||||
export ROBOKASSA_MERCHANT_LOGIN='$ROBOKASSA_MERCHANT_LOGIN'
|
export YOOKASSA_WEB_SHOP_ID='$YOOKASSA_WEB_SHOP_ID'
|
||||||
export ROBOKASSA_PASSWORD1='$ROBOKASSA_PASSWORD1'
|
export YOOKASSA_WEB_SECRET_KEY='$YOOKASSA_WEB_SECRET_KEY'
|
||||||
export ROBOKASSA_PASSWORD2='$ROBOKASSA_PASSWORD2'
|
export YOOKASSA_WEB_TEST='$YOOKASSA_WEB_TEST'
|
||||||
export ROBOKASSA_TEST='$ROBOKASSA_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_APP_ID='$VITE_VK_APP_ID'
|
||||||
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
|
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
|
||||||
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
|
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
|
||||||
|
|||||||
+128
-26
@@ -23,7 +23,7 @@ The game earns through two orthogonal channels:
|
|||||||
The currency is **two-tier**:
|
The currency is **two-tier**:
|
||||||
|
|
||||||
```
|
```
|
||||||
money (VK Votes / TG Stars / RUB via Robokassa) ─┐
|
money (VK Votes / TG Stars / RUB via YooKassa) ─┐
|
||||||
├─► Фишки (chips) ──► values
|
├─► Фишки (chips) ──► values
|
||||||
rewarded ad view ─┘ (no-ads, hints,
|
rewarded ad view ─┘ (no-ads, hints,
|
||||||
tournament fee)
|
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 |
|
| `vk` | VK Votes purchase, VK rewarded ad |
|
||||||
| `telegram` | TG Stars purchase |
|
| `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)`,
|
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
|
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
|
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
|
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.
|
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;
|
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
|
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),
|
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>`.
|
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
|
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
|
anchor (D43), which is also where the fiscal receipt is delivered. Fiscalization (54-ФЗ under one ИП)
|
||||||
regardless of the number of shops (D41).
|
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
|
**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
|
(`direct:web` / `direct:android` / `vk` / `telegram`) or for one account, live from `/_gm`, and the
|
||||||
@@ -224,20 +234,57 @@ never the source of truth.
|
|||||||
|
|
||||||
## 9. Payment intake
|
## 9. Payment intake
|
||||||
|
|
||||||
**Server callback only.** Chips are credited solely on a **verified** (signature/HMAC)
|
**Server callback only.** Chips are credited solely on a **verified** provider callback —
|
||||||
provider callback — Robokassa webhook / TG `successful_payment` / VK callback. A client "I
|
YooKassa notification / TG `successful_payment` / VK callback. A client "I paid" is ignored. What
|
||||||
paid" is ignored.
|
"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
|
**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
|
`successful_payment` reaches the bot, which forwards into payments. One place credits and
|
||||||
dedupes.
|
dedupes.
|
||||||
|
|
||||||
**Order-flow.** The server pre-creates an `order(pending)` with account / platform / pack /
|
**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
|
expected amount / origin. The `order_id` is threaded to the provider (YooKassa `metadata.order_id` /
|
||||||
`invoice_payload` / VK `item` — confirm the exact VK field at integration). The callback
|
TG `invoice_payload` / VK `item`). The callback matches by `order_id` (never by amount, so
|
||||||
matches by `order_id` (never by amount, so equal-amount collisions cannot happen), verifies
|
equal-amount collisions cannot happen), verifies the amount, credits, marks `paid`.
|
||||||
the amount, credits, marks `paid`. **Idempotency:** dedup by `(provider, provider_payment_id)`.
|
**Idempotency:** dedup by `(provider, provider_payment_id)`.
|
||||||
|
|
||||||
|
**Direct rail (YooKassa).** Opening a purchase is an outbound API call: the server creates the
|
||||||
|
payment (`POST /v3/payments`, `capture: true`, redirect confirmation, the order id as both the
|
||||||
|
`Idempotence-Key` and `metadata.order_id`, plus the fiscal receipt of §12) and sends the customer to
|
||||||
|
the returned `confirmation_url`. The provider's payment id is recorded on the order straight away,
|
||||||
|
which is what later lets the order be re-checked and refunded. The browser return page
|
||||||
|
(`/pay/yookassa/return`) is cosmetic: the credit never rides a redirect.
|
||||||
|
|
||||||
|
**The notification is a hint, never evidence (D48).** YooKassa does not sign notifications, so
|
||||||
|
nothing in the body may be acted on. It only names a payment, which the server then re-reads with
|
||||||
|
`GET /v3/payments/{id}`; only that answer is trusted. Two guards ride on it: the payment's metadata
|
||||||
|
must name the order being credited, and its `test` flag must match the shop's, so a test-shop payment
|
||||||
|
can never credit real chips (nor a live payment be credited against test credentials). The reply
|
||||||
|
tells the provider whether to redeliver: 200 for anything durably decided, including a duplicate and
|
||||||
|
a permanent rejection, and 5xx only for a transient failure (YooKassa redelivers for 24 hours).
|
||||||
|
|
||||||
|
The sender address is **not** checked (D48 rev). It would add no security — the confirming read is
|
||||||
|
the whole boundary — and the one thing it bought, keeping a forger from turning each fabricated
|
||||||
|
notification into an outbound call of ours, is already bought earlier and far more tightly: the order
|
||||||
|
is resolved from the notification's metadata **before** any provider call, so an id matching no order
|
||||||
|
costs one indexed read and stops there, and guessing a live order id means guessing a uuid. An
|
||||||
|
address check is also actively harmful wherever the deployment cannot observe real client addresses —
|
||||||
|
a contour behind a tunnel sees only its own — where it rejects genuine notifications.
|
||||||
|
|
||||||
|
**Reconciliation on a short cadence (D49 rev).** No open-ended polling, but the check is **not** tied
|
||||||
|
to the order lifetime: that governs when an unpaid order is written off, which answers "how long may
|
||||||
|
a customer take to pay" — a different question from "how soon should we notice a lost callback".
|
||||||
|
Tying them together would make a customer wait a full lifetime for chips whenever the notification
|
||||||
|
path fails. The pending-order reaper instead asks the provider about every pending order older than a
|
||||||
|
minute that carries a payment id, and credits the ones that were in fact paid. That bounds the calls
|
||||||
|
one order can cause to its lifetime divided by the sweep interval — a handful — while a failure of
|
||||||
|
the primary path costs minutes rather than half an hour.
|
||||||
|
|
||||||
|
**A declined payment is surfaced.** A YooKassa `payment.canceled` records a `failed` payment event,
|
||||||
|
so the customer is told the attempt did not go through instead of watching a balance that never
|
||||||
|
moves. An order simply abandoned records nothing — it just expires, invisibly.
|
||||||
|
|
||||||
**Pending is invisible** to the user; it auto-expires on a timeout (~30 min, DB hygiene). A
|
**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 —
|
valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation —
|
||||||
@@ -266,10 +313,33 @@ an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (em
|
|||||||
message).
|
message).
|
||||||
|
|
||||||
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. Refunds are
|
**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
|
**admin-triggered** (the E7 console). On the direct rail the console does the whole job in one click
|
||||||
refunds run through its refund API / merchant cabinet (auto-polling a rail's refund status is a
|
(D50): it calls YooKassa's refund API first (`POST /v3/refunds`, the order id as the
|
||||||
deferred worker, not worth it at low chargeback volume), VK refunds are handled by support, and
|
`Idempotence-Key`, carrying the refund receipt of §12) and records the reversal only once the money
|
||||||
Telegram Stars refunds are issued with `refundStarPayment`. All of them converge on one engine —
|
has actually moved — a failed call records **nothing**, so the ledger can never claim a refund that
|
||||||
|
did not happen. A refund is recorded **only in status `succeeded`**: one still `pending` can yet be
|
||||||
|
canceled, and the ledger is append-only, so recording early could revoke a customer's chips for money
|
||||||
|
that stayed with us. Pressing again is the way out — the idempotency key returns the same refund
|
||||||
|
rather than paying twice. The recorded refund id is the provider's own, which keeps the ledger
|
||||||
|
reconcilable against YooKassa's records.
|
||||||
|
|
||||||
|
**The cabinet is a second entry point (D52).** Unlike the earlier rails, YooKassa lets an operator
|
||||||
|
refund from the merchant cabinet, and such a refund never passes through our API — so the money would
|
||||||
|
go back while the chips stayed credited. The `refund.succeeded` notification closes that: the refund
|
||||||
|
is re-read from the API (the body is no more evidence than a payment notification's), bound back to
|
||||||
|
the order through the payment id we recorded, and reversed through the same engine — idempotent on
|
||||||
|
`(provider, refund id)`, so the notification for a refund the console already recorded reverses
|
||||||
|
nothing twice. The engine is **full-refund-only** by design (it revokes exactly what the pack funded
|
||||||
|
and rejects any other amount), so a **partial** refund is recorded as nothing at all and logged
|
||||||
|
loudly for an operator: there is no non-arbitrary way to decide how many chips a part-refund costs.
|
||||||
|
|
||||||
|
The two recording paths race, benignly: YooKassa fires `refund.succeeded` the moment the refund is
|
||||||
|
created, so the notification often writes the reversal before the console's own write lands. Both
|
||||||
|
name the same refund id, so the second is caught by the idempotency index and nothing is revoked
|
||||||
|
twice; the console reports the refund as successful rather than as a repeated click.
|
||||||
|
|
||||||
|
VK refunds are still handled by support and Telegram Stars refunds issued with `refundStarPayment`,
|
||||||
|
both recorded by hand afterwards. All of them converge on one engine —
|
||||||
the `Refund` method (`internal/payments`): it matches the paid order, appends a **refund** ledger
|
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
|
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
|
payment id, so the two rows coexist under the same partial-unique index), and **best-effort revokes
|
||||||
@@ -348,15 +418,47 @@ origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume
|
|||||||
`origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0
|
`origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0
|
||||||
chips (the by-product grant records the source `product_id` + snapshot) — full audit of rewards.
|
chips (the by-product grant records the source `product_id` + snapshot) — full audit of rewards.
|
||||||
|
|
||||||
**Per-user financial report** in the admin console `/_gm` — segment balances, payments,
|
**The ledger section** `/_gm/ledger` is the all-accounts view of the money: every operation, newest
|
||||||
spends, grants, refunds, full history — as an extension of the existing user card
|
first, filtered by date range (defaulting to the last 30 days), by **wallet** (`vk`/`telegram`/
|
||||||
(`UserDetailView`, `handlers_admin_console.go`). Plus a ledger export.
|
`direct` — matching the funded segment or the benefit origin), by **rail** (the settling provider, so
|
||||||
|
chip spends drop out by construction), by kind and by account. Above the table sit the totals for
|
||||||
|
**everything the filter matches**, not merely the page: money in and refunded per currency (the rails
|
||||||
|
settle in roubles, Votes and Stars, so one sum across them would mean nothing) and chips credited and
|
||||||
|
spent. The row amounts come from the ledger snapshot, since the ledger's own columns count chips.
|
||||||
|
Paging, the CSV export and a refund's way back all carry the same filter query, so none of them can
|
||||||
|
silently show a different slice than the screen.
|
||||||
|
|
||||||
|
The **refund action** lives on the funded rows here — the operator can find a payment by filter
|
||||||
|
without knowing whose it is first — and returns to the same filtered view afterwards.
|
||||||
|
|
||||||
|
**The user card** carries the account's standing rather than its history: segment balances, benefits,
|
||||||
|
the refund-risk flag, and a short lifetime summary (money paid and refunded per currency, chips
|
||||||
|
credited and spent), with a link into the ledger section pre-filtered to that account. One place
|
||||||
|
renders operations, with filters and paging, instead of two.
|
||||||
|
|
||||||
## 12. Taxes and compliance
|
## 12. Taxes and compliance
|
||||||
|
|
||||||
Receipts are automatic **through the provider**, and differ by rail:
|
Receipts are automatic **through the provider**, and differ by rail:
|
||||||
|
|
||||||
- **Robokassa** (direct) — self-employed НПД receipt on payment.
|
- **YooKassa** (direct) — **no receipt is sent, by design (D51 rev)**. The merchant operates on
|
||||||
|
**НПД**, which is outside 54-ФЗ: there is no online cash register and the provider registers
|
||||||
|
nothing. Each operation is reported by the merchant to **«Мой налог»**, which issues the чек.
|
||||||
|
YooKassa does not serve receipts for this regime at all.
|
||||||
|
|
||||||
|
The fiscal code is **kept dormant**, not deleted: «Чеки от ЮKassa» (YooKassa owning the cash
|
||||||
|
register, the fiscal drive and the OFD contract) registers a receipt **only if the request carries
|
||||||
|
it**, and that request-building lives behind one switch — `BACKEND_YOOKASSA_VAT_CODE`. Unset, no
|
||||||
|
`receipt` is built or sent; set to a 54-ФЗ rate code (tag 1199) it goes back to sending one line
|
||||||
|
(pack title, quantity 1, amount) with the settlement subject `service` (tag 1212) and method
|
||||||
|
`full_payment` (tag 1214), delivered by email to the D36 anchor. That switch exists because the
|
||||||
|
return is foreseeable: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back
|
||||||
|
in force.
|
||||||
|
|
||||||
|
Everything an automated «Мой налог» submission needs is already recorded — the order id, the
|
||||||
|
provider payment id, the amount and currency, the credit time, the sold-pack snapshot, and refunds
|
||||||
|
as their own ledger rows, all exportable as CSV. The one thing it will additionally need is a
|
||||||
|
per-operation "already reported" marker for its own idempotency; that belongs to the submission
|
||||||
|
work, not here.
|
||||||
- **VK** — VK processes Votes through the tax authority itself; nothing to do.
|
- **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
|
- **TG Stars** — no tax side (for a RU self-employed, Stars are not legally withdrawable = not
|
||||||
НПД income; accepted, no receipt issued).
|
НПД income; accepted, no receipt issued).
|
||||||
@@ -366,7 +468,7 @@ confirms the exact НПД scheme with a tax advisor.)
|
|||||||
|
|
||||||
## 13. Distribution (native Android)
|
## 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
|
- **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
|
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.
|
work. Confirm Google's current in-app-currency rules before the GP release.
|
||||||
@@ -407,4 +509,4 @@ prod (no purchase flow existed), so legacy values are zeroed.
|
|||||||
- **value / benefit** — what chips buy (no-ads, hints, tournament fee).
|
- **value / benefit** — what chips buy (no-ads, hints, tournament fee).
|
||||||
- **gate** — the one-directional store-compliance rule (§4).
|
- **gate** — the one-directional store-compliance rule (§4).
|
||||||
- **ledger** — the append-only record of all money/value operations.
|
- **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,73 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
|||||||
(нет строки = default; снять = удалить строку). **`allow` обходит ТОЛЬКО ops-рубильник, НЕ
|
(нет строки = default; снять = удалить строку). **`allow` обходит ТОЛЬКО ops-рубильник, НЕ
|
||||||
security-гейты** (D36 email-якорь, VK-iOS-фриз, trusted-платформа, min-client-version) — те
|
security-гейты** (D36 email-якорь, VK-iOS-фриз, trusted-платформа, min-client-version) — те
|
||||||
проверяются отдельно в CreateOrder.
|
проверяются отдельно в CreateOrder.
|
||||||
|
- **D47. Direct-рельс переезжает с Robokassa на ЮKassa; Robokassa консервируется, а не удаляется.**
|
||||||
|
Меняется merchant-отношение, не модель кошельков: сегмент `direct`, стенка трат, origin бенефитов,
|
||||||
|
мультимагазинность по каналу (D42) и `shop` в заказе (D44) — без изменений. Код Robokassa, её тесты
|
||||||
|
и проводка остаются в дереве, а direct-рельс **откатывается на неё**, если ни один магазин ЮKassa не
|
||||||
|
настроен, — значит возврат к Robokassa это смена кредов, а не правка кода. Переменные Robokassa
|
||||||
|
убраны из CI/compose/деплоя и записаны в `backend/internal/robokassa/README.md` вместе с
|
||||||
|
настройками кабинета и порядком возврата. Строки журнала с `provider = 'robokassa'` не трогаем:
|
||||||
|
литерал несущий для индекса идемпотентности. Telegram **не трогаем** — там реальными деньгами за
|
||||||
|
цифровые товары платить нельзя, рельс остаётся на Stars; префиксы переменных заводим на магазин
|
||||||
|
сразу, чтобы будущий магазин (например, android/RuStore) добавлялся аддитивно.
|
||||||
|
- **D48. Уведомления ЮKassa не подписаны → подтверждающее чтение обязательно.** В отличие от
|
||||||
|
Robokassa (подпись Password2) и VK (подпись), ЮKassa **не подписывает** вебхуки. Поэтому тело
|
||||||
|
уведомления — только подсказка: оно называет платёж, который сервер перечитывает запросом
|
||||||
|
`GET /v3/payments/{id}`, и действует **исключительно** по ответу API. Дополнительно: метаданные
|
||||||
|
платежа должны называть тот самый заказ; флаг `test` платежа должен совпадать с флагом магазина
|
||||||
|
(платёж тестового магазина не начислит настоящие Фишки, и наоборот). Ответ провайдеру: 200 на всё
|
||||||
|
окончательно решённое (включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa
|
||||||
|
повторяет 24 часа).
|
||||||
|
**Ревизия: адрес отправителя не проверяем.** Изначально сверяли с опубликованными диапазонами
|
||||||
|
ЮKassa как эшелонированную защиту. Оказалось лишним и вредным: единственное, что она давала —
|
||||||
|
не дать подделывателю превратить фальшивое уведомление в наш исходящий запрос — уже обеспечено
|
||||||
|
раньше и жёстче, потому что заказ ищется по метаданным ДО обращения к провайдеру (несуществующий
|
||||||
|
order_id стоит одного чтения по индексу; угадать живой — значит угадать uuid). А на тестовом
|
||||||
|
контуре, который за туннелем видит только свой внутренний адрес, проверка отбивала настоящие
|
||||||
|
уведомления — ровно как IP-баны, сделанные в репозитории prod-only по той же причине.
|
||||||
|
- **D49. Сверка — без постоянного опроса, но с коротким шагом (ревизия).** «Или» в документации
|
||||||
|
ЮKassa адресовано тем, кто не хочет вебхуки; у нас вебхуки основные. Но безвозвратно потерянное
|
||||||
|
уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому существующий
|
||||||
|
жнец спрашивает провайдера о судьбе незакрытых заказов с идентификатором платежа и начисляет реально
|
||||||
|
оплаченные; отдельный воркер не заводим.
|
||||||
|
**Ревизия порога:** сперва проверка была привязана к возрасту истечения заказа (30 минут), и это
|
||||||
|
была ошибка — время жизни заказа отвечает на вопрос «сколько покупателю позволено думать», а не «как
|
||||||
|
быстро заметить потерянный колбэк». На практике это дало покупателю 30 минут ожидания Фишек при
|
||||||
|
первом же сбое доставки. Порог развязан: проверяем заказы старше **минуты**, на каждом тике жнеца.
|
||||||
|
Ограниченность сохраняется — запросов на заказ не больше, чем время его жизни, делённое на шаг
|
||||||
|
жнеца.
|
||||||
|
- **D50. Возвраты на direct-рельсе — через API ЮKassa, одним действием.** Кнопка возврата в `/_gm`
|
||||||
|
сперва двигает деньги (`POST /v3/refunds`, `Idempotence-Key` = идентификатор заказа, с чеком
|
||||||
|
возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет **ничего** — журнал не
|
||||||
|
может заявить о возврате, которого не было. Реверс пишется **только при статусе `succeeded`**:
|
||||||
|
возврат в `pending` ещё может отмениться, а журнал только на добавление, поэтому ранняя запись
|
||||||
|
отобрала бы у покупателя Фишки за деньги, оставшиеся у нас; выход — нажать ещё раз, ключ
|
||||||
|
идемпотентности вернёт тот же возврат. Записывается собственный refund-id провайдера (сверка с
|
||||||
|
данными ЮKassa). VK и TG Stars — как раньше: деньги руками, запись фактом.
|
||||||
|
- **D52. Обрабатываем `refund.succeeded`: кабинет — вторая точка входа для возврата.** В отличие от
|
||||||
|
прежних рельсов, у ЮKassa возврат можно оформить прямо в кабинете магазина, минуя наш API, — тогда
|
||||||
|
деньги ушли бы назад, а Фишки остались бы начисленными, и молча. Поэтому подписываемся на событие и
|
||||||
|
проводим возврат тем же движком: возврат перечитывается из API (тело уведомления — не доказательство,
|
||||||
|
как и у платежа), привязывается к заказу через записанный идентификатор платежа, идемпотентность по
|
||||||
|
`(провайдер, refund-id)` делает уведомление о возврате, уже записанном консолью, пустой операцией.
|
||||||
|
**Частичный возврат не записываем вовсе** — движок по устройству работает только с полной суммой
|
||||||
|
заказа, а непроизвольного способа решить, скольких Фишек стоит часть возврата, нет; вместо записи —
|
||||||
|
громкий лог для оператора.
|
||||||
|
- **D51 (ревизия). Чеки не передаём: владелец на НПД, это вне 54-ФЗ.** Уточнено у поддержки ЮKassa:
|
||||||
|
при НПД провайдер с чеками не работает. Онлайн-кассы нет, `receipt` в запросах не отправляется, о
|
||||||
|
каждой операции владелец сообщает в **«Мой налог»**, он и формирует чек. Побочный выигрыш: исчезает
|
||||||
|
целый класс отказов — некорректный `receipt` был ошибкой API прямо при создании платежа, то есть
|
||||||
|
ломал покупку.
|
||||||
|
**Код чеков при этом консервируем, а не удаляем** (решение владельца): вся сборка `receipt` живёт за
|
||||||
|
одним переключателем `BACKEND_YOOKASSA_VAT_CODE` — пусто (дефолт) значит «не отправлять», код ставки
|
||||||
|
по 54-ФЗ (тег 1199) возвращает прежнее поведение: одна позиция, признак предмета расчёта `service`
|
||||||
|
(тег 1212), способ расчёта `full_payment` (тег 1214), доставка на email-якорь D36,
|
||||||
|
`tax_system_code` не шлём. Возврат к чекам предсказуем: у НПД годовой потолок дохода 2,4 млн ₽, и
|
||||||
|
его превышение возвращает 54-ФЗ — тогда это правка переменной, а не кода.
|
||||||
|
Связка с «Мой налог» — **отдельная задача**. Всё нужное для неё уже хранится (идентификатор заказа
|
||||||
|
и платежа провайдера, сумма, валюта, время зачисления, снимок пакета, возвраты); не хватает только
|
||||||
|
отметки «операция уже отправлена» для идемпотентности выгрузки — её заводит та задача.
|
||||||
|
|
||||||
## Заметки к оформлению документов
|
## Заметки к оформлению документов
|
||||||
|
|
||||||
@@ -296,7 +363,7 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
|||||||
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
|
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
|
||||||
feedback-память `prefer-interview-mode` после plan mode.
|
feedback-память `prefer-interview-mode` после plan mode.
|
||||||
|
|
||||||
## Все развилки закрыты (D1-D46)
|
## Все развилки закрыты (D1-D52)
|
||||||
|
|
||||||
Интервью завершено. Дальше — оформление документов и реализация по релизам.
|
Интервью завершено. Дальше — оформление документов и реализация по релизам.
|
||||||
|
|
||||||
@@ -307,6 +374,23 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
|||||||
рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация
|
рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация
|
||||||
(B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца).
|
(B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца).
|
||||||
|
|
||||||
|
**Дополнение 2026-07-28 (интервью с владельцем).** Direct-рельс переезжает на **ЮKassa**; добавлены
|
||||||
|
D47-D51 — консервация Robokassa с откатом по кредам, подтверждающее чтение вместо подписи уведомления,
|
||||||
|
сверка на истечении заказа, возвраты через API и фискализация через «Чеки от ЮKassa» с отправкой
|
||||||
|
`receipt` из кода (ревизия D41, этап E12 в `PLAN.md`). Telegram из объёма исключён: реальными
|
||||||
|
деньгами за цифровые товары в Mini App платить нельзя, рельс остаётся на Stars. Отдельно уточнено:
|
||||||
|
у ЮKassa **есть** тестовый режим (отдельный тестовый магазин со своими креды и тестовыми картами),
|
||||||
|
поэтому весь путь проверяется на тестовом контуре без реальных денег.
|
||||||
|
|
||||||
|
**Уточнение по возвратам (владелец, 2026-07-28).** D50 дополнена требованием статуса `succeeded`;
|
||||||
|
добавлена D52 — обработка `refund.succeeded`, потому что кабинет ЮKassa позволяет вернуть деньги
|
||||||
|
мимо нашего API. Обе правки вошли в тот же PR, что и переход на рельс.
|
||||||
|
|
||||||
|
**Уточнение по фискализации (владелец, 2026-07-28).** D51 ревизована: владелец принимает платежи как
|
||||||
|
ИП на **НПД**, при котором ЮKassa с чеками не работает (подтверждено поддержкой), поэтому `receipt`
|
||||||
|
не передаётся вовсе, а отчётность идёт через «Мой налог». Код чеков законсервирован за переменной
|
||||||
|
`BACKEND_YOOKASSA_VAT_CODE` на случай потери режима. Правка вошла в тот же PR.
|
||||||
|
|
||||||
## План внедрения (черновик PLAN.md — «слоями»)
|
## План внедрения (черновик PLAN.md — «слоями»)
|
||||||
|
|
||||||
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
|
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
|
||||||
|
|||||||
+126
-26
@@ -23,7 +23,7 @@
|
|||||||
Валюта **двухуровневая**:
|
Валюта **двухуровневая**:
|
||||||
|
|
||||||
```
|
```
|
||||||
деньги (Голоса VK / Stars TG / рубли через Robokassa) ─┐
|
деньги (Голоса VK / Stars TG / рубли через ЮKassa) ─┐
|
||||||
├─► Фишки ──► ценности
|
├─► Фишки ──► ценности
|
||||||
просмотр ролика за награду ─┘ (без рекламы,
|
просмотр ролика за награду ─┘ (без рекламы,
|
||||||
подсказки,
|
подсказки,
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
|------------|------------------------------------|
|
|------------|------------------------------------|
|
||||||
| `vk` | Покупка за Голоса VK, ролик за награду в VK |
|
| `vk` | Покупка за Голоса VK, ролик за награду в VK |
|
||||||
| `telegram` | Покупка за Stars TG |
|
| `telegram` | Покупка за Stars TG |
|
||||||
| `direct` | Покупка через Robokassa (web / native) |
|
| `direct` | Покупка через ЮKassa (web / native) |
|
||||||
|
|
||||||
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, до трёх
|
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, до трёх
|
||||||
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
|
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
|
||||||
@@ -54,17 +54,27 @@
|
|||||||
|
|
||||||
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
|
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
|
||||||
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
|
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
|
||||||
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
|
в контексте VK; Stars — только внутри Telegram; Фишки от ЮKassa (`direct`) — только вне
|
||||||
сторов. См. §4.
|
сторов. См. §4.
|
||||||
|
|
||||||
**Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин
|
**Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин
|
||||||
Robokassa **на канал** — `web` и `android` (RuStore), позже `ios` — по трастовому подтипу
|
ЮKassa **на канал** — `web` и `android` (RuStore), позже `ios` — по трастовому подтипу
|
||||||
`X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет).
|
`X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет).
|
||||||
Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в
|
Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в
|
||||||
админ-отчёте), а Result-колбэк каждого магазина проверяется своим Password2 по
|
админ-отчёте). Точка приёма уведомлений у всех магазинов одна — `/pay/yookassa/notify`; входящее
|
||||||
`/pay/robokassa/result/<channel>`. В standalone-приложениях (Android/iOS) вход только по email, поэтому
|
уведомление привязывается к магазину по идентификатору магазина, который сообщает сам платёж
|
||||||
у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под
|
(`recipient.account_id`), с откатом на канал, записанный в заказе, и подтверждающее чтение (§9)
|
||||||
одним ИП) — один источник независимо от числа магазинов (D41).
|
выполняется кредами именно этого магазина. В 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` /
|
**Рубильник платежей (D45/D46).** Оператор выключает покупки на рельсе/канале (`direct:web` /
|
||||||
`direct:android` / `vk` / `telegram`) или для одного аккаунта, живьём из `/_gm`, и юзер на следующей
|
`direct:android` / `vk` / `telegram`) или для одного аккаунта, живьём из `/_gm`, и юзер на следующей
|
||||||
@@ -217,21 +227,56 @@ durable).
|
|||||||
|
|
||||||
## 9. Приём платежей
|
## 9. Приём платежей
|
||||||
|
|
||||||
**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному**
|
**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному** серверному
|
||||||
(подпись/HMAC) серверному колбэку — Robokassa webhook / TG `successful_payment` / VK
|
колбэку — уведомление ЮKassa / TG `successful_payment` / VK callback. Клиентское «я оплатил»
|
||||||
callback. Клиентское «я оплатил» игнорируется.
|
игнорируется. Что значит «проверенный», зависит от рельса: VK и Telegram подписывают свои колбэки,
|
||||||
|
а **ЮKassa — нет** (D48), см. подтверждающее чтение ниже.
|
||||||
|
|
||||||
**Единственный писатель.** Один платёжный домен — единственный, кто пишет в журнал операций.
|
**Единственный писатель.** Один платёжный домен — единственный, кто пишет в журнал операций.
|
||||||
Публичные вебхуки (Robokassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
|
Публичные вебхуки (ЮKassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
|
||||||
платёжный домен; TG `successful_payment` приходит боту, тот форвардит в платёжный домен.
|
платёжный домен; TG `successful_payment` приходит боту, тот форвардит в платёжный домен.
|
||||||
Одно место начисляет и защищает от повторов.
|
Одно место начисляет и защищает от повторов.
|
||||||
|
|
||||||
**Флоу заказа.** Сервер заранее создаёт `order(pending)` с account / платформой / пакетом /
|
**Флоу заказа.** Сервер заранее создаёт `order(pending)` с account / платформой / пакетом /
|
||||||
ожидаемой суммой / origin. `order_id` прокидывается провайдеру (Robokassa `InvId` / TG
|
ожидаемой суммой / origin. `order_id` прокидывается провайдеру (ЮKassa `metadata.order_id` / TG
|
||||||
`invoice_payload` / VK `item` — точную форму VK уточнить при интеграции). Колбэк матчится по
|
`invoice_payload` / VK `item`). Колбэк матчится по `order_id` (никогда по сумме, поэтому коллизии
|
||||||
`order_id` (никогда по сумме, поэтому коллизии одинаковых сумм невозможны), сверяет сумму,
|
одинаковых сумм невозможны), сверяет сумму, начисляет, помечает `paid`. **Защита от повторов:**
|
||||||
начисляет, помечает `paid`. **Защита от повторов:** дедуп по `(провайдер,
|
дедуп по `(провайдер, provider_payment_id)`.
|
||||||
provider_payment_id)`.
|
|
||||||
|
**Direct-рельс (ЮKassa).** Открытие покупки — исходящий вызов API: сервер создаёт платёж
|
||||||
|
(`POST /v3/payments`, `capture: true`, подтверждение через redirect, идентификатор заказа и как
|
||||||
|
`Idempotence-Key`, и как `metadata.order_id`, плюс фискальный чек из §12) и отправляет покупателя на
|
||||||
|
полученный `confirmation_url`. Идентификатор платежа сразу записывается в заказ — именно он позволяет
|
||||||
|
потом перепроверить заказ и оформить возврат. Страница возврата браузера (`/pay/yookassa/return`)
|
||||||
|
косметическая: начисление никогда не едет на редиректе.
|
||||||
|
|
||||||
|
**Уведомление — подсказка, а не доказательство (D48).** ЮKassa не подписывает уведомления, поэтому
|
||||||
|
действовать по содержимому тела нельзя. Оно лишь называет платёж, который сервер затем перечитывает
|
||||||
|
запросом `GET /v3/payments/{id}`; доверяем только этому ответу. На нём же держатся две проверки:
|
||||||
|
метаданные платежа должны называть тот самый заказ, а его флаг `test` — совпадать с флагом магазина,
|
||||||
|
поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). Ответ сообщает
|
||||||
|
провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и неустранимый
|
||||||
|
отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа).
|
||||||
|
|
||||||
|
Адрес отправителя **не проверяем** (ревизия D48). Безопасности это не добавляет — вся граница доверия
|
||||||
|
в подтверждающем чтении, — а единственное, что такая проверка давала (чтобы подделыватель не
|
||||||
|
превращал каждое фальшивое уведомление в наш исходящий запрос), уже обеспечено раньше и жёстче: заказ
|
||||||
|
находится по метаданным **до** любого обращения к провайдеру, поэтому идентификатор, которому не
|
||||||
|
соответствует ни один заказ, стоит одного чтения по индексу и на этом всё, а угадать живой order_id —
|
||||||
|
значит угадать uuid. Вдобавок проверка адреса вредна везде, где развёртывание не видит настоящих
|
||||||
|
адресов клиентов (контур за туннелем видит только свой), — там она отбивает настоящие уведомления.
|
||||||
|
|
||||||
|
**Сверка с коротким шагом (ревизия D49).** Бесконечного опроса нет, но проверка **не привязана** к
|
||||||
|
времени жизни заказа: оно отвечает на вопрос «сколько покупателю позволено думать», а не «как быстро
|
||||||
|
мы должны заметить потерянный колбэк». Связав их, мы заставили бы покупателя ждать Фишки всё время
|
||||||
|
жизни заказа всякий раз, когда ломается доставка уведомлений. Вместо этого жнец спрашивает провайдера
|
||||||
|
о каждом незакрытом заказе старше минуты, у которого есть идентификатор платежа, и начисляет реально
|
||||||
|
оплаченные. Число запросов на один заказ при этом ограничено его временем жизни, делённым на шаг
|
||||||
|
жнеца, — единицы, — а сбой основного пути стоит минут, а не получаса.
|
||||||
|
|
||||||
|
**Об отклонённой оплате сообщаем.** `payment.canceled` от ЮKassa пишет событие `failed`, поэтому
|
||||||
|
покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто
|
||||||
|
брошенный заказ не пишет ничего — он молча истекает.
|
||||||
|
|
||||||
**Pending невидим** пользователю; авто-истекает по таймауту (~30 мин, гигиена базы).
|
**Pending невидим** пользователю; авто-истекает по таймауту (~30 мин, гигиена базы).
|
||||||
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
|
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
|
||||||
@@ -261,9 +306,33 @@ at-least-once + идемпотентный приём (дедуп по `telegram
|
|||||||
бота).
|
бота).
|
||||||
|
|
||||||
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует
|
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует
|
||||||
админ** (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК
|
админ** (консоль E7). На direct-рельсе консоль делает всю работу одним нажатием (D50): сперва
|
||||||
(авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через
|
вызывает refund-API ЮKassa (`POST /v3/refunds`, `Idempotence-Key` — идентификатор заказа, с чеком
|
||||||
поддержку, TG Stars — вызовом `refundStarPayment`. Все сходятся на одном движке — метод `Refund`
|
возврата из §12) и записывает реверс только после того, как деньги действительно ушли, — неудачный
|
||||||
|
вызов не пишет **ничего**, поэтому журнал не может заявить о возврате, которого не было. Возврат
|
||||||
|
записывается **только в статусе `succeeded`**: ещё не завершённый (`pending`) может отмениться, а
|
||||||
|
журнал только на добавление, поэтому ранняя запись отобрала бы у покупателя Фишки за деньги, оставшиеся у
|
||||||
|
нас. Выход — нажать ещё раз: ключ идемпотентности вернёт тот же возврат, а не заплатит дважды.
|
||||||
|
Записывается собственный refund-id провайдера: по нему журнал сверяется с данными ЮKassa.
|
||||||
|
|
||||||
|
**Кабинет — вторая точка входа (D52).** В отличие от прежних рельсов, ЮKassa позволяет оформить
|
||||||
|
возврат прямо в кабинете магазина, и такой возврат не проходит через наш API — деньги ушли бы назад,
|
||||||
|
а Фишки остались бы начисленными. Это закрывает уведомление `refund.succeeded`: возврат
|
||||||
|
перечитывается из API (тело уведомления — такое же не-доказательство, как и у платежа), привязывается
|
||||||
|
к заказу через идентификатор платежа, который мы записали, и проводится тем же движком —
|
||||||
|
идемпотентно по `(провайдер, refund-id)`, поэтому уведомление о возврате, уже записанном консолью,
|
||||||
|
ничего не отзывает повторно. Движок по устройству работает **только с полным возвратом** (отзывает
|
||||||
|
ровно то, что профондировал пакет, и отвергает любую другую сумму), поэтому **частичный** возврат не
|
||||||
|
записывается вовсе и громко логируется для оператора: непроизвольного способа решить, скольких Фишек
|
||||||
|
стоит часть возврата, нет.
|
||||||
|
|
||||||
|
Два пути записи при этом безобидно гоняются: ЮKassa шлёт `refund.succeeded` сразу после создания
|
||||||
|
возврата, поэтому уведомление часто записывает реверс раньше, чем это успевает сделать сама консоль.
|
||||||
|
Оба называют один и тот же refund-id, поэтому вторая запись отсекается индексом идемпотентности и
|
||||||
|
ничего не отзывается дважды; консоль при этом сообщает об успешном возврате, а не о повторном нажатии.
|
||||||
|
|
||||||
|
Возвраты VK по-прежнему через поддержку, TG Stars — вызовом `refundStarPayment`, оба фиксируются
|
||||||
|
руками после факта. Все сходятся на одном движке — метод `Refund`
|
||||||
(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по
|
(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по
|
||||||
`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки
|
`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки
|
||||||
сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с
|
сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с
|
||||||
@@ -341,15 +410,46 @@ in-process кэш сегментов и бенефитов по ключу-ак
|
|||||||
типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) —
|
типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) —
|
||||||
полный аудит наград.
|
полный аудит наград.
|
||||||
|
|
||||||
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
|
**Раздел журнала** `/_gm/ledger` — общий вид на деньги по всем аккаунтам: все операции, новые
|
||||||
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
|
сверху, с фильтрами по диапазону дат (по умолчанию последние 30 дней), по **кошельку**
|
||||||
`handlers_admin_console.go`). Плюс экспорт журнала.
|
(`vk`/`telegram`/`direct` — совпадение по пополненному сегменту или по origin бенефита), по
|
||||||
|
**рельсу** (провайдер, который провёл платёж, поэтому траты Фишек в такой фильтр не попадают вовсе),
|
||||||
|
по виду операции и по аккаунту. Над таблицей — итоги по **всему, что попало под фильтр**, а не по
|
||||||
|
странице: пришло и возвращено денег по каждой валюте отдельно (рельсы считают в рублях, Голосах и
|
||||||
|
Stars, общая сумма по ним не значила бы ничего) и начислено/списано Фишек. Суммы в строках берутся из
|
||||||
|
снимка операции, потому что собственные колонки журнала считают Фишки, а не деньги. Пагинация,
|
||||||
|
выгрузка в CSV и возврат оператора после refund несут одну и ту же строку фильтров, поэтому ни один
|
||||||
|
из них не может незаметно показать срез, отличный от экрана.
|
||||||
|
|
||||||
|
**Кнопка возврата** живёт на строках пополнения здесь же — оператор может найти платёж фильтрами, не
|
||||||
|
зная заранее, чей он, — и после возврата возвращает на тот же отфильтрованный вид.
|
||||||
|
|
||||||
|
**Карточка пользователя** показывает положение дел, а не историю: балансы сегментов, бенефиты, флаг
|
||||||
|
риска и краткую сводку за всё время (сколько заплачено и возвращено по валютам, сколько Фишек
|
||||||
|
начислено и потрачено), плюс ссылку в раздел журнала, уже отфильтрованный по этому аккаунту.
|
||||||
|
Операции рисуются в одном месте, с фильтрами и страницами, а не в двух.
|
||||||
|
|
||||||
## 12. Налоги и комплаенс
|
## 12. Налоги и комплаенс
|
||||||
|
|
||||||
Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу:
|
Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу:
|
||||||
|
|
||||||
- **Robokassa** (direct) — чек НПД самозанятого при оплате.
|
- **ЮKassa** (direct) — **чек не передаём, так задумано (ревизия D51)**. Владелец работает на
|
||||||
|
**НПД**, а это вне 54-ФЗ: онлайн-кассы нет, провайдер ничего не регистрирует. О каждой операции
|
||||||
|
владелец сообщает в **«Мой налог»**, он и формирует чек. ЮKassa при таком режиме налогообложения с
|
||||||
|
чеками не работает вовсе.
|
||||||
|
|
||||||
|
Фискальный код **законсервирован, а не удалён**: «Чеки от ЮKassa» (касса, фискальный накопитель и
|
||||||
|
договор с ОФД на стороне ЮKassa) регистрируют чек **только если запрос его несёт**, и вся сборка
|
||||||
|
такого запроса спрятана за одним переключателем — `BACKEND_YOOKASSA_VAT_CODE`. Пусто — `receipt` не
|
||||||
|
собирается и не отправляется; задан код ставки по 54-ФЗ (тег 1199) — снова уходит одна позиция
|
||||||
|
(название пакета, количество 1, сумма) с признаком предмета расчёта `service` (тег 1212) и способа
|
||||||
|
расчёта `full_payment` (тег 1214), доставка на email-якорь D36. Переключатель нужен потому, что
|
||||||
|
возврат к чекам предсказуем: у НПД есть годовой потолок дохода, и потеря режима возвращает 54-ФЗ.
|
||||||
|
|
||||||
|
Всё, что потребуется автоматической отправке в «Мой налог», уже записано — идентификатор заказа,
|
||||||
|
идентификатор платежа провайдера, сумма и валюта, время зачисления, снимок проданного пакета и
|
||||||
|
возвраты отдельными строками журнала, всё выгружается в CSV. Не хватать будет только отметки «эта
|
||||||
|
операция уже отправлена» для идемпотентности самой выгрузки; ей место в той задаче, а не здесь.
|
||||||
- **VK** — VK сам процессит Голоса через налоговую; делать нечего.
|
- **VK** — VK сам процессит Голоса через налоговую; делать нечего.
|
||||||
- **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не
|
- **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не
|
||||||
доход НПД; принимаем, чек не формируем).
|
доход НПД; принимаем, чек не формируем).
|
||||||
@@ -359,7 +459,7 @@ in-process кэш сегментов и бенефитов по ключу-ак
|
|||||||
|
|
||||||
## 13. Дистрибуция (native Android)
|
## 13. Дистрибуция (native Android)
|
||||||
|
|
||||||
- **RuStore** — Robokassa/внешний гейт разрешён (0%); native = чистый контекст `direct`.
|
- **RuStore** — внешний платёжный гейт разрешён (0%); native = чистый контекст `direct`.
|
||||||
- **Google Play** — direct-покупки **скрыты**; «Кошелёк» показывает заглушку («установите
|
- **Google Play** — direct-покупки **скрыты**; «Кошелёк» показывает заглушку («установите
|
||||||
версию из RuStore для покупок»). Ролик за награду и трата уже накопленных Фишек работают.
|
версию из RuStore для покупок»). Ролик за награду и трата уже накопленных Фишек работают.
|
||||||
Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте.
|
Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте.
|
||||||
@@ -403,4 +503,4 @@ legacy-значения обнуляются.
|
|||||||
взнос).
|
взнос).
|
||||||
- **гейт** — одностороннее правило комплаенса сторов (§4).
|
- **гейт** — одностороннее правило комплаенса сторов (§4).
|
||||||
- **журнал операций (ledger)** — неизменяемая запись всех операций с деньгами/ценностями.
|
- **журнал операций (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
|
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
|
// 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
|
// 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
|
// 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.
|
// through this session-gated route (not public); see dictBytesHandler.
|
||||||
mux.Handle("/dict/", s.dictBytesHandler())
|
mux.Handle("/dict/", s.dictBytesHandler())
|
||||||
mux.Handle("/dl/", s.exportDownloadHandler())
|
mux.Handle("/dl/", s.exportDownloadHandler())
|
||||||
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single
|
// Direct-rail return + callback routes: the server notification (the single crediting signal,
|
||||||
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. The
|
// proxied to the backend intake) and the browser return page. Caddy's /pay/* matcher already
|
||||||
// per-shop callback rides a channel suffix (/pay/robokassa/result/<channel>); the bare path is
|
// forwards every path here, so a new route needs no Caddyfile change.
|
||||||
// the legacy web shop. Caddy's /pay/* matcher already forwards both, so 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/robokassa/result/", s.robokassaResultHandler())
|
mux.Handle("/pay/robokassa/result/", s.robokassaResultHandler())
|
||||||
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
|
mux.Handle("/pay/robokassa/success", s.paymentReturnHandler("Оплата принята."))
|
||||||
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
|
mux.Handle("/pay/robokassa/fail", s.paymentReturnHandler("Оплата не завершена."))
|
||||||
mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена."))
|
|
||||||
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
||||||
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
||||||
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
|
// 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
|
// 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),
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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>
|
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>
|
<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">
|
<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 page.locator('[data-kind="pack"]').getByTestId('buy-pack').click();
|
||||||
await expect
|
await expect
|
||||||
.poll(() => page.evaluate(() => (window as { __opened?: string }).__opened))
|
.poll(() => page.evaluate(() => (window as { __opened?: string }).__opened))
|
||||||
.toContain('robokassa');
|
.toContain('yookassa');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wallet: the tab sits between Friends and About', async ({ page }) => {
|
test('wallet: the tab sits between Friends and About', async ({ page }) => {
|
||||||
|
|||||||
@@ -46,7 +46,14 @@ const DIST = 'dist';
|
|||||||
// rejected, since a repeated word is a real word and the board alone cannot say why the play is
|
// rejected, since a repeated word is a real word and the board alone cannot say why the play is
|
||||||
// refused. The rule's own logic — the played-word set and the rescoring — sits with the evaluator in
|
// refused. The rule's own logic — the played-word set and the rescoring — sits with the evaluator in
|
||||||
// the lazy dict chunk. Scoped CSS lands in the CSS chunk, not this JS budget.
|
// the lazy dict chunk. Scoped CSS lands in the CSS chunk, not this JS budget.
|
||||||
const BUDGET = { app: 131, shared: 31, landing: 5 };
|
//
|
||||||
|
// The shared chunk (svelte runtime + i18n) was raised from 31 to 32 for the direct rail's email
|
||||||
|
// notice: the wallet has to explain, before the buy tap, that a money purchase needs a confirmed
|
||||||
|
// email — the server refuses the order otherwise, and a player signed in through VK or Telegram in a
|
||||||
|
// browser would otherwise meet a bare error. Every user-visible string lands here, so the chunk had
|
||||||
|
// been sitting 40 bytes under its cap; the raise restores a working margin rather than paying for
|
||||||
|
// this one string.
|
||||||
|
const BUDGET = { app: 131, shared: 32, landing: 5 };
|
||||||
|
|
||||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export const en = {
|
|||||||
'wallet.iosBlockedPost': ' of the game.',
|
'wallet.iosBlockedPost': ' of the game.',
|
||||||
'wallet.gpStub': 'To buy chips, install the RuStore build.',
|
'wallet.gpStub': 'To buy chips, install the RuStore build.',
|
||||||
'wallet.purchasesSoon': 'Purchases will be available in a future update.',
|
'wallet.purchasesSoon': 'Purchases will be available in a future update.',
|
||||||
|
'wallet.emailToBuy': 'To buy chips, add an email in your profile.',
|
||||||
'wallet.cur.RUB': '₽',
|
'wallet.cur.RUB': '₽',
|
||||||
'wallet.cur.VOTE': 'votes',
|
'wallet.cur.VOTE': 'votes',
|
||||||
'wallet.cur.XTR': '⭐',
|
'wallet.cur.XTR': '⭐',
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'wallet.iosBlockedPost': ' игры.',
|
'wallet.iosBlockedPost': ' игры.',
|
||||||
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
|
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
|
||||||
'wallet.purchasesSoon': 'Покупки появятся в одном из следующих обновлений.',
|
'wallet.purchasesSoon': 'Покупки появятся в одном из следующих обновлений.',
|
||||||
|
'wallet.emailToBuy': 'Чтобы покупать фишки, привяжите почту в профиле.',
|
||||||
'wallet.cur.RUB': '₽',
|
'wallet.cur.RUB': '₽',
|
||||||
'wallet.cur.VOTE': 'голосов',
|
'wallet.cur.VOTE': 'голосов',
|
||||||
'wallet.cur.XTR': '⭐',
|
'wallet.cur.XTR': '⭐',
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
if (!p || p.kind !== 'pack') throw new GatewayError('not_a_pack');
|
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
|
// 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.
|
// 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> {
|
async walletReward(_nonce: string): Promise<Wallet> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import type { Wallet, WalletSegment } from './model';
|
import type { Wallet, WalletSegment } from './model';
|
||||||
import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning } from './wallet';
|
import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning, directPurchaseNeedsEmail } from './wallet';
|
||||||
|
|
||||||
function seg(source: string, chips: number, spendable = true): WalletSegment {
|
function seg(source: string, chips: number, spendable = true): WalletSegment {
|
||||||
return { source, chips, spendable };
|
return { source, chips, spendable };
|
||||||
@@ -61,3 +61,18 @@ describe('needsWebSpendWarning', () => {
|
|||||||
expect(needsWebSpendWarning('direct', [seg('vk', 400, false)], 100)).toBe(false);
|
expect(needsWebSpendWarning('direct', [seg('vk', 400, false)], 100)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('directPurchaseNeedsEmail', () => {
|
||||||
|
it('asks for an email on the direct rail when the account has none', () => {
|
||||||
|
expect(directPurchaseNeedsEmail('direct', '')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays quiet once the account has a confirmed email', () => {
|
||||||
|
expect(directPurchaseNeedsEmail('direct', 'player@example.com')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never asks inside VK or Telegram, where the store rail carries its own identity', () => {
|
||||||
|
expect(directPurchaseNeedsEmail('vk', '')).toBe(false);
|
||||||
|
expect(directPurchaseNeedsEmail('telegram', '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ export function spendableChips(w: Wallet): number {
|
|||||||
// segment first, then the store-funded segments.
|
// segment first, then the store-funded segments.
|
||||||
const drawPriority: readonly string[] = ['direct', 'vk', 'telegram'];
|
const drawPriority: readonly string[] = ['direct', 'vk', 'telegram'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* directPurchaseNeedsEmail reports whether the money storefront must ask for an email before it can
|
||||||
|
* sell anything. The direct rail funds only an account with a confirmed email — the recovery anchor,
|
||||||
|
* without which a paying customer who loses the account loses the chips with it — and the server
|
||||||
|
* refuses the order without one. Inside VK or Telegram the purchase settles on that store's own rail,
|
||||||
|
* which carries its own identity, so nothing is asked for there and spending already-earned chips is
|
||||||
|
* never affected.
|
||||||
|
*/
|
||||||
|
export function directPurchaseNeedsEmail(context: SpendContext, email: string): boolean {
|
||||||
|
return context === 'direct' && email === '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* needsWebSpendWarning reports whether buying a chip-priced value in the current context would draw
|
* needsWebSpendWarning reports whether buying a chip-priced value in the current context would draw
|
||||||
* store-funded (vk / telegram) chips — the case the UI must warn about, because the benefit it buys
|
* store-funded (vk / telegram) chips — the case the UI must warn about, because the benefit it buys
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||||
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { normalizeSubtype } from '../lib/platform';
|
import { normalizeSubtype } from '../lib/platform';
|
||||||
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet';
|
import { directPurchaseNeedsEmail, executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet';
|
||||||
import { isGooglePlayBuild, purchasesHidden } from '../lib/distribution';
|
import { isGooglePlayBuild, purchasesHidden } from '../lib/distribution';
|
||||||
import { onExternalLinkClick, onInAppPageLinkClick, openExternalUrl } from '../lib/links';
|
import { onExternalLinkClick, onInAppPageLinkClick, openExternalUrl } from '../lib/links';
|
||||||
import { gatewayOrigin } from '../lib/origin';
|
import { gatewayOrigin } from '../lib/origin';
|
||||||
@@ -47,6 +48,9 @@
|
|||||||
// muted and taps explain why, rather than hiding the storefront. VK's device family is not on the
|
// muted and taps explain why, rather than hiding the storefront. VK's device family is not on the
|
||||||
// client platformSubtype (server-derived) — read it from the signed vk_platform launch param.
|
// client platformSubtype (server-derived) — read it from the signed vk_platform launch param.
|
||||||
const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios';
|
const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios';
|
||||||
|
// The server enforces the direct rail's email anchor; showing the rule here means a player signed
|
||||||
|
// in through VK or Telegram in a browser reads it instead of tapping Buy and getting a bare error.
|
||||||
|
const needsEmail = $derived(directPurchaseNeedsEmail(context, app.profile?.email ?? ''));
|
||||||
// The "other version" link points at this deployment's own site root (the landing lists every
|
// The "other version" link points at this deployment's own site root (the landing lists every
|
||||||
// platform), so it follows prod vs the contour without a hardcoded host.
|
// platform), so it follows prod vs the contour without a hardcoded host.
|
||||||
const siteRoot = `${gatewayOrigin()}/`;
|
const siteRoot = `${gatewayOrigin()}/`;
|
||||||
@@ -254,6 +258,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<p class="stub" data-testid="purchases-hidden">{t('wallet.purchasesSoon')}</p>
|
<p class="stub" data-testid="purchases-hidden">{t('wallet.purchasesSoon')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if needsEmail}
|
||||||
|
<p class="stub" data-testid="email-required">
|
||||||
|
<button class="hintlink" onclick={() => navigate('/profile')}>{t('wallet.emailToBuy')}</button>
|
||||||
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each packs as p (p.productId)}
|
{#each packs as p (p.productId)}
|
||||||
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
|
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
|
||||||
@@ -429,6 +437,17 @@
|
|||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
/* The whole explanation is the call to action, so the line itself routes to the profile. */
|
||||||
|
.hintlink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.offer {
|
.offer {
|
||||||
margin: 2px 0 0;
|
margin: 2px 0 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user