feat(payments): reverse refunds issued outside the console
CI / changes (pull_request) Successful in 11s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 29s
CI / ui (pull_request) Successful in 1m27s
CI / conformance (pull_request) Successful in 19s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s

Two holes on the refund path, both found by asking what happens when a refund
does not come from our own `/_gm` button.

The merchant cabinet is a second entry point. An operator can refund there, and
such a refund never passes through our API — so the money went back while the
chips stayed credited, silently. Handle `refund.succeeded`: the refund is
re-read from the API (the notification body is no more evidence here than it is
for a payment), bound back to its order through the payment id recorded when
the payment was minted, and reversed through the same engine. It is idempotent
on (provider, refund id), so the event for a refund the console already
recorded reverses nothing twice.

The reversal 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, and guessing would be worse than
asking a human.

Second hole: a refund can still be canceled while pending, and the ledger is
append-only. Recording on any non-empty refund id therefore risked revoking a
customer's chips for money that stayed with us, with no way to take the row
back. The console now records only a `succeeded` refund and tells the operator
to press again otherwise — the idempotency key returns the same refund rather
than paying twice.

Tests: unit (GetRefund, the refund notification envelope, a non-final status
surfaced to the caller); integration (a cabinet refund is reversed once and a
redelivery is a no-op, the event after a console refund changes nothing, a
partial refund records nothing, an unconfirmed refund reverses nothing, a
pending refund records nothing until it settles and then does).

The suite shares one database and the ledger dedupes refunds globally, so the
fake provider now mints a refund id per payment — a constant id made one test's
refund look like another's duplicate.

Decisions D50 (amended) and D52; the notification subscription list in the
deploy docs gains refund.succeeded.
This commit is contained in:
Ilia Denisov
2026-07-28 09:18:19 +02:00
parent 92ba527575
commit 395a307eca
17 changed files with 529 additions and 34 deletions
+2 -1
View File
@@ -140,7 +140,8 @@ GATEWAY_VK_ID_CLIENT_SECRET=
# to credit a live payment against it (and a test payment against a live shop).
# Mapped in compose to BACKEND_YOOKASSA_*; Gitea TEST_/PROD_ secrets.
# Set each shop's notification URL in its cabinet (Интеграция — HTTP-уведомления) to
# ${PUBLIC_BASE_URL}/pay/yookassa/notify, events payment.succeeded + payment.canceled.
# ${PUBLIC_BASE_URL}/pay/yookassa/notify, events payment.succeeded + payment.canceled +
# refund.succeeded (the last one catches a refund issued in the cabinet, which bypasses our API).
YOOKASSA_WEB_SHOP_ID=
YOOKASSA_WEB_SECRET_KEY=
YOOKASSA_WEB_TEST=
+1 -1
View File
@@ -76,7 +76,7 @@ compose binds from this directory.
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
| `BACKEND_YOOKASSA_{WEB,ANDROID}_SHOP_ID` | secret | YooKassa shop id for that channel of the direct RUB rail (cabinet: **Настройки — Магазин**, field `shopId`). Empty ⇒ that channel is unconfigured and order routing falls back to the **web** shop; no channel configured at all leaves the rail off. |
| `BACKEND_YOOKASSA_{WEB,ANDROID}_SHOP_ID` | secret | YooKassa shop id for that channel of the direct RUB rail (cabinet: **Настройки — Магазин**, field `shopId`). Empty ⇒ that channel is unconfigured and order routing falls back to the **web** shop; no channel configured at all leaves the rail off. Each shop's **Интеграция — HTTP-уведомления** must point at `<PUBLIC_BASE_URL>/pay/yookassa/notify` with events `payment.succeeded`, `payment.canceled` and `refund.succeeded`. |
| `BACKEND_YOOKASSA_{WEB,ANDROID}_SECRET_KEY` | secret | That shop's API secret key (cabinet: **Интеграция — Ключи API**). It is the password half of the HTTP Basic credentials on every API call. A shop id without a secret key is ignored. |
| `BACKEND_YOOKASSA_{WEB,ANDROID}_TEST` | **variable** | `1` marks the credentials as a **test shop**: the intake then refuses to credit anything but a test payment (and a live shop refuses a test one). A variable, not a secret, so switching a contour between a test and a live shop is a flag flip + redeploy. |
| `BACKEND_YOOKASSA_VAT_CODE` | **variable** | VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199): `1` = «Без НДС» (УСН/ПСН), `4` = 20%, `11` = 22%. Defaults to `1`. A variable so a rate change needs no code change. **Confirm it with the accountant before the first real payment** — a wrong rate produces wrong receipts, not a failed payment, so it fails silently. |
+2 -1
View File
@@ -169,7 +169,8 @@ services:
# Each shop credits the one direct wallet; a shop missing either credential drops that
# channel (order routing falls back to the web shop), and no shop at all leaves the direct
# order and notification endpoints unregistered. Notification URL (set per shop in the
# YooKassa cabinet): ${PUBLIC_BASE_URL}/pay/yookassa/notify.
# YooKassa cabinet): ${PUBLIC_BASE_URL}/pay/yookassa/notify — events payment.succeeded,
# payment.canceled and refund.succeeded.
BACKEND_YOOKASSA_WEB_SHOP_ID: ${YOOKASSA_WEB_SHOP_ID:-}
BACKEND_YOOKASSA_WEB_SECRET_KEY: ${YOOKASSA_WEB_SECRET_KEY:-}
BACKEND_YOOKASSA_WEB_TEST: ${YOOKASSA_WEB_TEST:-}