feat(payments): settle the direct rail through YooKassa #293
+8
-6
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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 легально невыводимы = не
|
||||
доход НПД; принимаем, чек не формируем).
|
||||
|
||||
@@ -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': '⭐',
|
||||
|
||||
@@ -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': '⭐',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user