diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 248e939..2559c52 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 diff --git a/PLAN.md b/PLAN.md index 22a3953..66397df 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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`. diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 89ed38c..dc37edb 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 diff --git a/backend/internal/inttest/payments_yookassa_test.go b/backend/internal/inttest/payments_yookassa_test.go index 34c00bf..a7ef926 100644 --- a/backend/internal/inttest/payments_yookassa_test.go +++ b/backend/internal/inttest/payments_yookassa_test.go @@ -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 { diff --git a/backend/internal/server/handlers_yookassa.go b/backend/internal/server/handlers_yookassa.go index 1e0ef1a..7df35e9 100644 --- a/backend/internal/server/handlers_yookassa.go +++ b/backend/internal/server/handlers_yookassa.go @@ -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 diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 0ec29a2..361a36d 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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. diff --git a/backend/internal/yookassa/receipt.go b/backend/internal/yookassa/receipt.go index 81a2d11..b8f8994 100644 --- a/backend/internal/yookassa/receipt.go +++ b/backend/internal/yookassa/receipt.go @@ -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 } diff --git a/backend/internal/yookassa/shops_test.go b/backend/internal/yookassa/shops_test.go index 98b1691..1c35730 100644 --- a/backend/internal/yookassa/shops_test.go +++ b/backend/internal/yookassa/shops_test.go @@ -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) + } + } +} diff --git a/deploy/.env.example b/deploy/.env.example index 0dfddb5..33df396 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -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 diff --git a/deploy/README.md b/deploy/README.md index 8f3946d..2f10f13 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 `/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 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 24379d7..86aba69 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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 diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index 295eabc..c1d17c0 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -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). diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md index 63283d9..9b69b18 100644 --- a/docs/PAYMENTS_DECISIONS_ru.md +++ b/docs/PAYMENTS_DECISIONS_ru.md @@ -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 — «слоями») Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index b7603da..2f2b387 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -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 легально невыводимы = не доход НПД; принимаем, чек не формируем). diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 2b37159..9a00000 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -265,6 +265,7 @@ export const en = { 'wallet.iosBlockedPost': ' of the game.', 'wallet.gpStub': 'To buy chips, install the RuStore build.', 'wallet.purchasesSoon': 'Purchases will be available in a future update.', + 'wallet.emailToBuy': 'To buy chips, add an email in your profile.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'votes', 'wallet.cur.XTR': '⭐', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index c698eee..1f985bc 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -260,6 +260,7 @@ export const ru: Record = { 'wallet.iosBlockedPost': ' игры.', 'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.', 'wallet.purchasesSoon': 'Покупки появятся в одном из следующих обновлений.', + 'wallet.emailToBuy': 'Чтобы покупать фишки, привяжите почту в профиле.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'голосов', 'wallet.cur.XTR': '⭐', diff --git a/ui/src/lib/wallet.test.ts b/ui/src/lib/wallet.test.ts index c958be6..1881ed4 100644 --- a/ui/src/lib/wallet.test.ts +++ b/ui/src/lib/wallet.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { Wallet, WalletSegment } from './model'; -import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning } from './wallet'; +import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning, directPurchaseNeedsEmail } from './wallet'; function seg(source: string, chips: number, spendable = true): WalletSegment { return { source, chips, spendable }; @@ -61,3 +61,18 @@ describe('needsWebSpendWarning', () => { expect(needsWebSpendWarning('direct', [seg('vk', 400, false)], 100)).toBe(false); }); }); + +describe('directPurchaseNeedsEmail', () => { + it('asks for an email on the direct rail when the account has none', () => { + expect(directPurchaseNeedsEmail('direct', '')).toBe(true); + }); + + it('stays quiet once the account has a confirmed email', () => { + expect(directPurchaseNeedsEmail('direct', 'player@example.com')).toBe(false); + }); + + it('never asks inside VK or Telegram, where the store rail carries its own identity', () => { + expect(directPurchaseNeedsEmail('vk', '')).toBe(false); + expect(directPurchaseNeedsEmail('telegram', '')).toBe(false); + }); +}); diff --git a/ui/src/lib/wallet.ts b/ui/src/lib/wallet.ts index 8b079bf..71fd5bb 100644 --- a/ui/src/lib/wallet.ts +++ b/ui/src/lib/wallet.ts @@ -50,6 +50,18 @@ export function spendableChips(w: Wallet): number { // segment first, then the store-funded segments. const drawPriority: readonly string[] = ['direct', 'vk', 'telegram']; +/** + * directPurchaseNeedsEmail reports whether the money storefront must ask for an email before it can + * sell anything. The direct rail funds only an account with a confirmed email — the recovery anchor, + * without which a paying customer who loses the account loses the chips with it — and the server + * refuses the order without one. Inside VK or Telegram the purchase settles on that store's own rail, + * which carries its own identity, so nothing is asked for there and spending already-earned chips is + * never affected. + */ +export function directPurchaseNeedsEmail(context: SpendContext, email: string): boolean { + return context === 'direct' && email === ''; +} + /** * needsWebSpendWarning reports whether buying a chip-priced value in the current context would draw * store-funded (vk / telegram) chips — the case the UI must warn about, because the benefit it buys diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 2b5c2c8..a5e705e 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -2,10 +2,11 @@ import { onMount } from 'svelte'; import Modal from '../components/Modal.svelte'; import { app, handleError, showToast } from '../lib/app.svelte'; + import { navigate } from '../lib/router.svelte'; import { gateway } from '../lib/gateway'; import { normalizeSubtype } from '../lib/platform'; import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte'; - import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet'; + import { directPurchaseNeedsEmail, executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet'; import { isGooglePlayBuild, purchasesHidden } from '../lib/distribution'; import { onExternalLinkClick, onInAppPageLinkClick, openExternalUrl } from '../lib/links'; import { gatewayOrigin } from '../lib/origin'; @@ -47,6 +48,9 @@ // muted and taps explain why, rather than hiding the storefront. VK's device family is not on the // client platformSubtype (server-derived) — read it from the signed vk_platform launch param. const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios'; + // The server enforces the direct rail's email anchor; showing the rule here means a player signed + // in through VK or Telegram in a browser reads it instead of tapping Buy and getting a bare error. + const needsEmail = $derived(directPurchaseNeedsEmail(context, app.profile?.email ?? '')); // The "other version" link points at this deployment's own site root (the landing lists every // platform), so it follows prod vs the contour without a hardcoded host. const siteRoot = `${gatewayOrigin()}/`; @@ -254,6 +258,10 @@ {:else}

{t('wallet.purchasesSoon')}

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

+ +

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