feat(payments): send no fiscal receipt; keep the code switchable
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s

The merchant accepts payments as a sole proprietor on НПД, which is outside
54-ФЗ: there is no online cash register, YooKassa does not serve receipts for
that regime at all (checked with their support), and each operation is reported
by the merchant to «Мой налог», which issues the чек. So no `receipt` is sent
with a payment or a refund.

This also removes a failure class rather than just code: a malformed receipt
was an API error at payment creation, which broke the purchase outright.

The fiscal code is kept dormant rather than deleted. All of it now sits behind
one switch, `BACKEND_YOOKASSA_VAT_CODE`: unset — the default — builds and sends
nothing; a 54-ФЗ rate code turns «Чеки от ЮKassa» back on unchanged. The return
is foreseeable, which is why the switch exists: НПД carries an annual income
ceiling, and losing the regime puts 54-ФЗ back in force, at which point this is
a deploy-variable edit instead of writing the integration again.

The D36 email anchor still gates a direct purchase. It had two justifications —
a recovery anchor and the receipt address — and only the second is gone; without
an email a paying customer who loses the account loses the chips with it. What
was missing is that the rule was enforced but never communicated: the wallet
showed the packs to a player signed in through VK or Telegram in a browser, and
tapping Buy produced a bare "something went wrong". It now says "add an email in
your profile" in the buy tab instead, linking to the profile; spending
already-earned chips is untouched. The predicate is a pure function so it is
covered by the node-env unit tests rather than needing a browser.

Tests: the receipt-off default is pinned by an integration test asserting a
purchase carries no receipt, and the dormant path by one that switches a VAT
code on and checks the receipt reappears with the right fiscal attributes;
plus unit coverage for the enable predicate and the wallet's email rule.

A note for whoever runs the numbers next: the shared (svelte + i18n) chunk is
now 40 bytes under its 31 KB gzip budget.

Decision D51 revised.
This commit is contained in:
Ilia Denisov
2026-07-28 11:40:59 +02:00
parent 395a307eca
commit 12e616ceae
19 changed files with 288 additions and 90 deletions
+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)
}
}
}