feat(payments): settle the direct rail through YooKassa #293

Merged
developer merged 6 commits from feature/yookassa-direct-rail into development 2026-07-28 10:49:47 +00:00
19 changed files with 288 additions and 90 deletions
Showing only changes of commit 12e616ceae - Show all commits
+8 -6
View File
@@ -256,12 +256,14 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde
- **YooKassa DOES have a test mode** — a separate *test shop* (own shopId/secret, test cards, up to
20 of them), and webhooks/receipts/refunds all work there. The whole rail is verifiable on the
contour without real money; don't plan around "no test payments".
- **YooKassa has no cabinet-side generic чек.** Unlike Robokassa's cloud kassa, «Чеки от ЮKassa»
registers a receipt **only if the request carries `receipt`** — so the itemized receipt is
mandatory code on both payment and refund. `payment_subject`/`payment_mode`/`vat_code` are fields
*inside* `receipt.items[]` (54-ФЗ tags 1212/1214/1199), documented under the receipts section, not
the payment API — easy to miss. A malformed receipt is an API error at payment creation, so it
breaks purchases loudly rather than silently skipping the fiscal document.
- **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
+8 -3
View File
@@ -1043,9 +1043,14 @@ when no YooKassa shop is configured, so reviving it is a credentials change (D47
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** — «Чеки от ЮKassa» with the `receipt` sent from code (D51), reversing E10's B4:
YooKassa has no cabinet-side generic receipt. `BACKEND_YOOKASSA_VAT_CODE` is a deploy variable;
the settlement subject/method are constants.
- **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`.
+4 -3
View File
@@ -71,7 +71,8 @@ type Config struct {
// the direct rail back to Robokassa.
YooKassa yookassa.Shops
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
// It is configurable because the rate is the one receipt attribute that genuinely changes.
// 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
@@ -179,7 +180,7 @@ func Load() (Config, error) {
ykShops[channel] = shop
}
}
vatCode, err := envInt("BACKEND_YOOKASSA_VAT_CODE", yookassa.VatCodeNone)
vatCode, err := envInt("BACKEND_YOOKASSA_VAT_CODE", yookassa.VatCodeOff)
if err != nil {
return Config{}, err
}
@@ -278,7 +279,7 @@ func (c Config) validate() error {
}
}
if !yookassa.ValidVatCode(c.YooKassaVatCode) {
return fmt.Errorf("config: BACKEND_YOOKASSA_VAT_CODE %d is not a 54-ФЗ VAT rate code (1..%d)", c.YooKassaVatCode, yookassa.VatCodeMax)
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
@@ -146,6 +146,13 @@ func (f *fakeYooKassa) setPayment(id string, mutate func(p *yookassa.Payment)) {
// 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 {
@@ -160,7 +167,7 @@ func yookassaServer(t *testing.T, shop yookassa.Config) (*server.Server, *paymen
DictDir: dictDir(),
Payments: paySvc,
YooKassa: yookassa.Shops{yookassa.ChannelWeb: shop},
YooKassaVatCode: yookassa.VatCodeNone,
YooKassaVatCode: vatCode,
PublicBaseURL: "https://scrabble.test",
})
return srv, paySvc
@@ -441,10 +448,84 @@ func TestYooKassaRefundRecordsNothingWhenTheProviderFails(t *testing.T) {
}
}
// TestYooKassaOrderMintsAPaymentWithAReceipt drives the purchase path over HTTP: the order endpoint
// calls the provider, threads the order id through, sends the fiscal receipt to the buyer's confirmed
// address, and hands the client the hosted payment page.
func TestYooKassaOrderMintsAPaymentWithAReceipt(t *testing.T) {
// 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)
@@ -482,18 +563,6 @@ func TestYooKassaOrderMintsAPaymentWithAReceipt(t *testing.T) {
if f.createdIdempotenceKey != out.OrderID {
t.Errorf("Idempotence-Key = %q, want the order id %q", f.createdIdempotenceKey, out.OrderID)
}
customer, _ := f.lastReceipt["customer"].(map[string]any)
if customer["email"] != email {
t.Errorf("receipt customer = %v, want the confirmed email %q", customer, email)
}
items, _ := f.lastReceipt["items"].([]any)
if len(items) != 1 {
t.Fatalf("receipt items = %d, want 1", len(items))
}
item, _ := items[0].(map[string]any)
if item["vat_code"] != float64(yookassa.VatCodeNone) || item["payment_subject"] != yookassa.PaymentSubjectService {
t.Errorf("receipt fiscal attributes = %v", item)
}
// The payment id is recorded on the order, which is what the safety net and a refund need.
orderID, err := uuid.Parse(out.OrderID)
if err != nil {
+28 -13
View File
@@ -21,11 +21,22 @@ import (
// 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), which doubles as the delivery address of the fiscal
// receipt this rail must send with every payment. No chips are credited here —
// only later, by the verified notification (or the reconcile sweep).
// 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)
@@ -43,9 +54,9 @@ func (s *Server) orderYooKassa(c *gin.Context, uid uuid.UUID, cxt payments.Conte
},
Description: yookassa.TruncateDescription(res.Title),
Metadata: map[string]string{yookassa.MetadataOrderID: res.OrderID.String()},
// «Чеки от ЮKassa»: the receipt is registered only if we send it, so a malformed one is an
// API error here rather than a silently missing fiscal document.
Receipt: yookassa.SingleItemReceipt(email, res.Title, amount, s.vatCode),
// 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
@@ -363,9 +374,9 @@ func isPermanentFundError(err error) bool {
}
// refundYooKassa returns the money for a paid order through the YooKassa refund API and reports the
// provider's own refund id, which the ledger then records. The refund carries a receipt because the
// rail registers a refund receipt the same way it registers a payment one, and the order id as the
// idempotency key, so a repeated attempt returns the original refund instead of paying twice.
// 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.
@@ -381,15 +392,19 @@ func (s *Server) refundYooKassa(ctx context.Context, ref payments.OrderRef) (str
if err != nil {
return "", err
}
email, _, err := s.accounts.ConfirmedEmail(ctx, ref.AccountID)
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: yookassa.SingleItemReceipt(email, title, amount, s.vatCode),
Receipt: s.yooKassaReceipt(email, title, amount),
}, ref.OrderID.String())
if err != nil {
return "", err
+1
View File
@@ -121,6 +121,7 @@ type Deps struct {
// 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.
+22 -9
View File
@@ -1,9 +1,14 @@
package yookassa
// Fiscal receipt attributes for «Чеки от ЮKassa» (54-ФЗ). YooKassa registers the receipt itself —
// no cash register to rent, no OFD contract — but only if the payment and refund requests carry a
// receipt object, so unlike the previous rail's cabinet-side receipts these fields are load-bearing
// code. Reference:
// 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
@@ -15,10 +20,13 @@ const (
PaymentModeFullPayment = "full_payment"
// VatCodeNone is the VAT rate code (54-ФЗ tag 1199) for «Без НДС» — the rate a sole proprietor on
// УСН or ПСН charges. It is the default; the deployed value is configurable because the rate is
// the one receipt attribute that genuinely changes (Russia raised the main rate on 1 Jan 2026,
// adding codes 11 and 12).
// УСН 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
)
@@ -78,5 +86,10 @@ func TruncateDescription(s string) string {
return string(r[:maxDescriptionRunes])
}
// ValidVatCode reports whether code is within the range the API accepts (1..12).
func ValidVatCode(code int) bool { return code >= 1 && code <= VatCodeMax }
// 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 }
+15 -2
View File
@@ -99,14 +99,27 @@ func TestSingleItemReceiptTruncatesLongTitles(t *testing.T) {
}
func TestValidVatCode(t *testing.T) {
for _, code := range []int{1, 4, 11, 12} {
// 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{0, -1, 13} {
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)
}
}
}
+5 -3
View File
@@ -149,9 +149,11 @@ YOOKASSA_WEB_TEST=
YOOKASSA_ANDROID_SHOP_ID=
YOOKASSA_ANDROID_SECRET_KEY=
YOOKASSA_ANDROID_TEST=
# VAT rate code on every fiscal receipt line (54-ФЗ tag 1199): 1 = «Без НДС» (УСН/ПСН), 4 = 20%,
# 11 = 22%. A Gitea VARIABLE, not a secret, so a rate change is a variable edit + redeploy.
YOOKASSA_VAT_CODE=1
# Fiscal receipts (54-ФЗ) — OFF while empty, which is the intended state: the merchant runs on НПД,
# outside 54-ФЗ, so nothing is registered through the provider and each operation is reported to
# «Мой налог» instead. Set a VAT rate code (tag 1199: 1 = «Без НДС», 4 = 20%, 11 = 22%) to turn
# receipts back on — the switch a lost НПД regime would need. A Gitea VARIABLE, not a secret.
YOOKASSA_VAT_CODE=
#
# Robokassa (the retired direct rail) is deliberately absent here: no deployment sets its
# credentials, which is what keeps it dormant. The backend still reads BACKEND_ROBOKASSA_* and
+1 -1
View File
@@ -79,7 +79,7 @@ compose binds from this directory.
| `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. |
| `BACKEND_YOOKASSA_VAT_CODE` | **variable** | Fiscal receipts (54-ФЗ). **Empty by default = no receipt is sent at all**, which is the intended state: the merchant runs on НПД, outside 54-ФЗ, so nothing is registered through the provider and each operation is reported to «Мой налог» instead. Setting a VAT rate code (tag 1199: `1` = «Без НДС», `4` = 20%, `11` = 22%) switches receipts back on with no code change — the path a lost НПД regime (annual income ceiling) would take. Confirm the rate with the accountant before switching it on: a wrong rate produces wrong receipts, not a failed payment, so it fails silently. |
**Plus the bot token**`TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
+5 -3
View File
@@ -177,9 +177,11 @@ services:
BACKEND_YOOKASSA_ANDROID_SHOP_ID: ${YOOKASSA_ANDROID_SHOP_ID:-}
BACKEND_YOOKASSA_ANDROID_SECRET_KEY: ${YOOKASSA_ANDROID_SECRET_KEY:-}
BACKEND_YOOKASSA_ANDROID_TEST: ${YOOKASSA_ANDROID_TEST:-}
# VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199). 1 = «Без НДС» (УСН/ПСН).
# A deploy variable, not a secret, so a rate change is a variable edit + redeploy.
BACKEND_YOOKASSA_VAT_CODE: ${YOOKASSA_VAT_CODE:-1}
# Fiscal receipts (54-ФЗ) are OFF: the merchant runs on НПД, which is outside 54-ФЗ, so no
# receipt is registered through the provider and each operation is reported to «Мой налог».
# Setting a VAT rate code (tag 1199: 1 = «Без НДС», 4 = 20%, 11 = 22%) turns receipts back on
# — the switch a lost НПД regime would need — with no code change.
BACKEND_YOOKASSA_VAT_CODE: ${YOOKASSA_VAT_CODE:-}
# The dictionary lives on a named volume seeded from the image on first boot
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
# inherits). The admin console writes new version subdirectories here, and the
+19 -9
View File
@@ -411,15 +411,25 @@ spends, grants, refunds, full history — as an extension of the existing user c
Receipts are automatic **through the provider**, and differ by rail:
- **YooKassa** (direct) — a 54-ФЗ fiscal receipt through **«Чеки от ЮKassa»**: YooKassa owns the
cash register, the fiscal drive and the OFD contract, and files with the tax authority. Unlike the
retired rail's cabinet-side receipts, it registers one **only if the request carries it** (D51), so
every payment and every refund sends a `receipt`: one line (the pack title, quantity 1, the amount)
with the VAT rate code (54-ФЗ tag 1199, a deploy variable — `1` = «Без НДС» for УСН/ПСН), the
settlement subject `service` (tag 1212) and the settlement method `full_payment` (tag 1214).
Delivery is by email only, to the D36 confirmed anchor. `tax_system_code` is not sent — YooKassa
ignores it for this solution. A malformed receipt is an API error at payment creation, so it blocks
the purchase loudly rather than silently skipping the fiscal document.
- **YooKassa** (direct) — **no receipt is sent, by design (D51 rev)**. The merchant operates on
**НПД**, which is outside 54-ФЗ: there is no online cash register and the provider registers
nothing. Each operation is reported by the merchant to **«Мой налог»**, which issues the чек.
YooKassa does not serve receipts for this regime at all.
The fiscal code is **kept dormant**, not deleted: «Чеки от ЮKassa» (YooKassa owning the cash
register, the fiscal drive and the OFD contract) registers a receipt **only if the request carries
it**, and that request-building lives behind one switch — `BACKEND_YOOKASSA_VAT_CODE`. Unset, no
`receipt` is built or sent; set to a 54-ФЗ rate code (tag 1199) it goes back to sending one line
(pack title, quantity 1, amount) with the settlement subject `service` (tag 1212) and method
`full_payment` (tag 1214), delivered by email to the D36 anchor. That switch exists because the
return is foreseeable: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back
in force.
Everything an automated «Мой налог» submission needs is already recorded — the order id, the
provider payment id, the amount and currency, the credit time, the sold-pack snapshot, and refunds
as their own ledger rows, all exportable as CSV. The one thing it will additionally need is a
per-operation "already reported" marker for its own idempotency; that belongs to the submission
work, not here.
- **VK** — VK processes Votes through the tax authority itself; nothing to do.
- **TG Stars** — no tax side (for a RU self-employed, Stars are not legally withdrawable = not
НПД income; accepted, no receipt issued).
+19 -10
View File
@@ -323,16 +323,20 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
**Частичный возврат не записываем вовсе** — движок по устройству работает только с полной суммой
заказа, а непроизвольного способа решить, скольких Фишек стоит часть возврата, нет; вместо записи —
громкий лог для оператора.
- **D51. Фискализация — «Чеки от ЮKassa», чек отправляем из кода.** Ревизия D41: у ЮKassa нет
кабинетного «обобщённого чека», как у Robokassa, — чек регистрируется **только если запрос его
несёт**, поэтому itemized-код, от которого отказались в D41, возвращается в объём. Каждый платёж и
возврат шлют `receipt`: одна позиция (название пакета, количество 1, сумма), код ставки НДС
(тег 1199), признак предмета расчёта `service` (тег 1212), признак способа расчёта `full_payment`
(тег 1214); доставка только на email — на подтверждённый якорь D36. Код ставки НДС — **переменная
деплоя** (дефолт `1` = «Без НДС» для УСН/ПСН): это единственный реквизит, который реально меняется
(с 1 января 2026 ставки выросли, появились коды 11/12), и менять его без релиза нужно; признаки
предмета и способа расчёта — константы в коде. `tax_system_code` не шлём: для «Чеков от ЮKassa»
провайдер его игнорирует.
- **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-ФЗ — тогда это правка переменной, а не кода.
Связка с «Мой налог» — **отдельная задача**. Всё нужное для неё уже хранится (идентификатор заказа
и платежа провайдера, сумма, валюта, время зачисления, снимок пакета, возвраты); не хватает только
отметки «операция уже отправлена» для идемпотентности выгрузки — её заводит та задача.
## Заметки к оформлению документов
@@ -371,6 +375,11 @@ D47-D51 — консервация Robokassa с откатом по кредам
добавлена D52 — обработка `refund.succeeded`, потому что кабинет ЮKassa позволяет вернуть деньги
мимо нашего API. Обе правки вошли в тот же PR, что и переход на рельс.
**Уточнение по фискализации (владелец, 2026-07-28).** D51 ревизована: владелец принимает платежи как
ИП на **НПД**, при котором ЮKassa с чеками не работает (подтверждено поддержкой), поэтому `receipt`
не передаётся вовсе, а отчётность идёт через «Мой налог». Код чеков законсервирован за переменной
`BACKEND_YOOKASSA_VAT_CODE` на случай потери режима. Правка вошла в тот же PR.
## План внедрения (черновик PLAN.md — «слоями»)
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
+17 -9
View File
@@ -405,15 +405,23 @@ in-process кэш сегментов и бенефитов по ключу-ак
Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу:
- **ЮKassa** (direct) — фискальный чек по 54-ФЗ через **«Чеки от ЮKassa»**: касса, фискальный
накопитель, договор с ОФД и отправка в налоговую — на стороне ЮKassa. В отличие от кабинетных чеков
прежнего рельса, чек регистрируется **только если запрос его несёт** (D51), поэтому каждый платёж и
каждый возврат отправляют `receipt`: одна позиция (название пакета, количество 1, сумма) с кодом
ставки НДС (тег 54-ФЗ 1199, переменная деплоя — `1` = «Без НДС» для УСН/ПСН), признаком предмета
расчёта `service` (тег 1212) и признаком способа расчёта `full_payment` (тег 1214). Доставка только
на email — на подтверждённый якорь D36. `tax_system_code` не передаём: для этого решения ЮKassa его
игнорирует. Некорректный чек — ошибка API при создании платежа, то есть покупка ломается громко, а
не тихо остаётся без фискального документа.
- **ЮKassa** (direct) — **чек не передаём, так задумано (ревизия D51)**. Владелец работает на
**НПД**, а это вне 54-ФЗ: онлайн-кассы нет, провайдер ничего не регистрирует. О каждой операции
владелец сообщает в **«Мой налог»**, он и формирует чек. ЮKassa при таком режиме налогообложения с
чеками не работает вовсе.
Фискальный код **законсервирован, а не удалён**: «Чеки от ЮKassa» (касса, фискальный накопитель и
договор с ОФД на стороне ЮKassa) регистрируют чек **только если запрос его несёт**, и вся сборка
такого запроса спрятана за одним переключателем — `BACKEND_YOOKASSA_VAT_CODE`. Пусто — `receipt` не
собирается и не отправляется; задан код ставки по 54-ФЗ (тег 1199) — снова уходит одна позиция
(название пакета, количество 1, сумма) с признаком предмета расчёта `service` (тег 1212) и способа
расчёта `full_payment` (тег 1214), доставка на email-якорь D36. Переключатель нужен потому, что
возврат к чекам предсказуем: у НПД есть годовой потолок дохода, и потеря режима возвращает 54-ФЗ.
Всё, что потребуется автоматической отправке в «Мой налог», уже записано — идентификатор заказа,
идентификатор платежа провайдера, сумма и валюта, время зачисления, снимок проданного пакета и
возвраты отдельными строками журнала, всё выгружается в CSV. Не хватать будет только отметки «эта
операция уже отправлена» для идемпотентности самой выгрузки; ей место в той задаче, а не здесь.
- **VK** — VK сам процессит Голоса через налоговую; делать нечего.
- **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не
доход НПД; принимаем, чек не формируем).
+1
View File
@@ -265,6 +265,7 @@ export const en = {
'wallet.iosBlockedPost': ' of the game.',
'wallet.gpStub': 'To buy chips, install the RuStore build.',
'wallet.purchasesSoon': 'Purchases will be available in a future update.',
'wallet.emailToBuy': 'To buy chips, add an email in your profile.',
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'votes',
'wallet.cur.XTR': '⭐',
+1
View File
@@ -260,6 +260,7 @@ export const ru: Record<MessageKey, string> = {
'wallet.iosBlockedPost': ' игры.',
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
'wallet.purchasesSoon': 'Покупки появятся в одном из следующих обновлений.',
'wallet.emailToBuy': 'Чтобы покупать фишки, привяжите почту в профиле.',
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'голосов',
'wallet.cur.XTR': '⭐',
+16 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import type { Wallet, WalletSegment } from './model';
import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning } from './wallet';
import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning, directPurchaseNeedsEmail } from './wallet';
function seg(source: string, chips: number, spendable = true): WalletSegment {
return { source, chips, spendable };
@@ -61,3 +61,18 @@ describe('needsWebSpendWarning', () => {
expect(needsWebSpendWarning('direct', [seg('vk', 400, false)], 100)).toBe(false);
});
});
describe('directPurchaseNeedsEmail', () => {
it('asks for an email on the direct rail when the account has none', () => {
expect(directPurchaseNeedsEmail('direct', '')).toBe(true);
});
it('stays quiet once the account has a confirmed email', () => {
expect(directPurchaseNeedsEmail('direct', 'player@example.com')).toBe(false);
});
it('never asks inside VK or Telegram, where the store rail carries its own identity', () => {
expect(directPurchaseNeedsEmail('vk', '')).toBe(false);
expect(directPurchaseNeedsEmail('telegram', '')).toBe(false);
});
});
+12
View File
@@ -50,6 +50,18 @@ export function spendableChips(w: Wallet): number {
// segment first, then the store-funded segments.
const drawPriority: readonly string[] = ['direct', 'vk', 'telegram'];
/**
* directPurchaseNeedsEmail reports whether the money storefront must ask for an email before it can
* sell anything. The direct rail funds only an account with a confirmed email — the recovery anchor,
* without which a paying customer who loses the account loses the chips with it — and the server
* refuses the order without one. Inside VK or Telegram the purchase settles on that store's own rail,
* which carries its own identity, so nothing is asked for there and spending already-earned chips is
* never affected.
*/
export function directPurchaseNeedsEmail(context: SpendContext, email: string): boolean {
return context === 'direct' && email === '';
}
/**
* needsWebSpendWarning reports whether buying a chip-priced value in the current context would draw
* store-funded (vk / telegram) chips — the case the UI must warn about, because the benefit it buys
+20 -1
View File
@@ -2,10 +2,11 @@
import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
import { gateway } from '../lib/gateway';
import { normalizeSubtype } from '../lib/platform';
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet';
import { directPurchaseNeedsEmail, executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet';
import { isGooglePlayBuild, purchasesHidden } from '../lib/distribution';
import { onExternalLinkClick, onInAppPageLinkClick, openExternalUrl } from '../lib/links';
import { gatewayOrigin } from '../lib/origin';
@@ -47,6 +48,9 @@
// muted and taps explain why, rather than hiding the storefront. VK's device family is not on the
// client platformSubtype (server-derived) — read it from the signed vk_platform launch param.
const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios';
// The server enforces the direct rail's email anchor; showing the rule here means a player signed
// in through VK or Telegram in a browser reads it instead of tapping Buy and getting a bare error.
const needsEmail = $derived(directPurchaseNeedsEmail(context, app.profile?.email ?? ''));
// The "other version" link points at this deployment's own site root (the landing lists every
// platform), so it follows prod vs the contour without a hardcoded host.
const siteRoot = `${gatewayOrigin()}/`;
@@ -254,6 +258,10 @@
{:else}
<p class="stub" data-testid="purchases-hidden">{t('wallet.purchasesSoon')}</p>
{/if}
{:else if needsEmail}
<p class="stub" data-testid="email-required">
<button class="hintlink" onclick={() => navigate('/profile')}>{t('wallet.emailToBuy')}</button>
</p>
{:else}
{#each packs as p (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-radius: var(--radius-sm);
}
/* The whole explanation is the call to action, so the line itself routes to the profile. */
.hintlink {
background: none;
border: none;
padding: 0;
color: var(--accent);
font: inherit;
text-align: left;
text-decoration: underline;
cursor: pointer;
}
.offer {
margin: 2px 0 0;
text-align: center;