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

Merged
developer merged 6 commits from feature/yookassa-direct-rail into development 2026-07-28 10:49:47 +00:00
19 changed files with 288 additions and 90 deletions
Showing only changes of commit 12e616ceae - Show all commits
+8 -6
View File
@@ -256,12 +256,14 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde
- **YooKassa DOES have a test mode** — a separate *test shop* (own shopId/secret, test cards, up to - **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 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". contour without real money; don't plan around "no test payments".
- **YooKassa has no cabinet-side generic чек.** Unlike Robokassa's cloud kassa, «Чеки от ЮKassa» - **We send NO receipt: the merchant is on НПД, which is outside 54-ФЗ**, and YooKassa does not
registers a receipt **only if the request carries `receipt`** — so the itemized receipt is serve receipts for that regime (checked with their support). Reporting goes to «Мой налог».
mandatory code on both payment and refund. `payment_subject`/`payment_mode`/`vat_code` are fields The fiscal code is kept dormant behind `BACKEND_YOOKASSA_VAT_CODE` — unset = send nothing, a
*inside* `receipt.items[]` (54-ФЗ tags 1212/1214/1199), documented under the receipts section, not 54-ФЗ rate code turns «Чеки от ЮKassa» back on (the НПД income ceiling makes that foreseeable).
the payment API — easy to miss. A malformed receipt is an API error at payment creation, so it Gotcha if you ever switch it on: `payment_subject`/`payment_mode`/`vat_code` are fields *inside*
breaks purchases loudly rather than silently skipping the fiscal document. `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 - **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 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 sender allowlist is enforced in the backend, with the gateway forwarding the real peer IP as
+8 -3
View File
@@ -1043,9 +1043,14 @@ when no YooKassa shop is configured, so reviving it is a credentials change (D47
nothing, and pressing again is safe. `refund.succeeded` is handled too (D52), because the merchant 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 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). 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: - **Fiscalization** — **no receipt is sent** (D51 rev): the merchant is on НПД, outside 54-ФЗ, and
YooKassa has no cabinet-side generic receipt. `BACKEND_YOOKASSA_VAT_CODE` is a deploy variable; YooKassa does not serve receipts for that regime; reporting goes to «Мой налог» instead (its own
the settlement subject/method are constants. 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}` + - **Config/deploy** — `BACKEND_YOOKASSA_{WEB,ANDROID}_{SHOP_ID,SECRET_KEY,TEST}` +
`BACKEND_YOOKASSA_VAT_CODE`; every `ROBOKASSA_*` mapping removed from compose, `.env.example`, `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`. `write-prod-env.sh` and the three workflows, and recorded in `backend/internal/robokassa/README.md`.
+4 -3
View File
@@ -71,7 +71,8 @@ type Config struct {
// the direct rail back to Robokassa. // the direct rail back to Robokassa.
YooKassa yookassa.Shops YooKassa yookassa.Shops
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199). // 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 YooKassaVatCode int
// Robokassa configures the retired direct-rail payment provider — one merchant shop per channel // 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 // (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 ykShops[channel] = shop
} }
} }
vatCode, err := envInt("BACKEND_YOOKASSA_VAT_CODE", yookassa.VatCodeNone) vatCode, err := envInt("BACKEND_YOOKASSA_VAT_CODE", yookassa.VatCodeOff)
if err != nil { if err != nil {
return Config{}, err return Config{}, err
} }
@@ -278,7 +279,7 @@ func (c Config) validate() error {
} }
} }
if !yookassa.ValidVatCode(c.YooKassaVatCode) { 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() { if c.YooKassa.Configured() {
// The YooKassa rail sends the customer to a hosted payment page and needs an absolute return // 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 // 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. // fail-open means an absent row is an enabled rail.
func yookassaServer(t *testing.T, shop yookassa.Config) (*server.Server, *payments.Service) { 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() t.Helper()
if _, err := testDB.ExecContext(context.Background(), if _, err := testDB.ExecContext(context.Background(),
`DELETE FROM payments.rail_status WHERE rail LIKE 'direct%'`); err != nil { `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(), DictDir: dictDir(),
Payments: paySvc, Payments: paySvc,
YooKassa: yookassa.Shops{yookassa.ChannelWeb: shop}, YooKassa: yookassa.Shops{yookassa.ChannelWeb: shop},
YooKassaVatCode: yookassa.VatCodeNone, YooKassaVatCode: vatCode,
PublicBaseURL: "https://scrabble.test", PublicBaseURL: "https://scrabble.test",
}) })
return srv, paySvc return srv, paySvc
@@ -441,10 +448,84 @@ func TestYooKassaRefundRecordsNothingWhenTheProviderFails(t *testing.T) {
} }
} }
// TestYooKassaOrderMintsAPaymentWithAReceipt drives the purchase path over HTTP: the order endpoint // seedBuyerWithEmail provisions an account with a confirmed email — the D36 anchor a direct purchase
// calls the provider, threads the order id through, sends the fiscal receipt to the buyer's confirmed // requires — and returns the account and the address.
// address, and hands the client the hosted payment page. func seedBuyerWithEmail(t *testing.T) (uuid.UUID, string) {
func TestYooKassaOrderMintsAPaymentWithAReceipt(t *testing.T) { 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) f, shop := newFakeYooKassa(t)
srv, _ := yookassaServer(t, shop) srv, _ := yookassaServer(t, shop)
acc := provisionAccount(t) acc := provisionAccount(t)
@@ -482,18 +563,6 @@ func TestYooKassaOrderMintsAPaymentWithAReceipt(t *testing.T) {
if f.createdIdempotenceKey != out.OrderID { if f.createdIdempotenceKey != out.OrderID {
t.Errorf("Idempotence-Key = %q, want the order id %q", 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. // The payment id is recorded on the order, which is what the safety net and a refund need.
orderID, err := uuid.Parse(out.OrderID) orderID, err := uuid.Parse(out.OrderID)
if err != nil { if err != nil {
+27 -12
View File
@@ -21,11 +21,22 @@ import (
// what it read from the provider; this is the backend's own ceiling on an untrusted body. // what it read from the provider; this is the backend's own ceiling on an untrusted body.
const maxNotifyBytes = 1 << 20 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 // 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 // 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 // email is the account's confirmed address (D36) the recovery anchor a direct purchase requires,
// receipt this rail must send with every payment. No chips are credited here — // and the delivery address of a fiscal receipt when receipts are switched on. No chips are credited
// only later, by the verified notification (or the reconcile sweep). // 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) { 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() ctx := c.Request.Context()
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerYooKassa) 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), Description: yookassa.TruncateDescription(res.Title),
Metadata: map[string]string{yookassa.MetadataOrderID: res.OrderID.String()}, 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 // Off unless a VAT rate is configured: the merchant is outside 54-ФЗ and reports each
// API error here rather than a silently missing fiscal document. // operation itself, so no fiscal receipt is registered through the provider.
Receipt: yookassa.SingleItemReceipt(email, res.Title, amount, s.vatCode), Receipt: s.yooKassaReceipt(email, res.Title, amount),
}, res.OrderID.String()) }, res.OrderID.String())
if err != nil { if err != nil {
// The order stays pending and unpaid; it expires on its own. The customer sees the generic // 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 // 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 // provider's own refund id, which the ledger then records. It sends the order id as the idempotency
// rail registers a refund receipt the same way it registers a payment one, and the order id as the // key, so a repeated attempt returns the original refund instead of paying twice, and a refund
// idempotency key, so a repeated attempt returns the original refund instead of paying twice. // 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 // 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. // 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 { if err != nil {
return "", err return "", err
} }
email, _, err := s.accounts.ConfirmedEmail(ctx, ref.AccountID) // The buyer's address is read only to deliver a refund receipt; with receipts off there is
if err != nil { // 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 return "", err
} }
}
amount := yookassa.Amount{Value: ref.Amount.Major(), Currency: string(ref.Amount.Currency())} amount := yookassa.Amount{Value: ref.Amount.Major(), Currency: string(ref.Amount.Currency())}
refund, err := shop.CreateRefund(ctx, yookassa.RefundRequest{ refund, err := shop.CreateRefund(ctx, yookassa.RefundRequest{
PaymentID: ref.PaymentID, PaymentID: ref.PaymentID,
Amount: amount, Amount: amount,
Receipt: yookassa.SingleItemReceipt(email, title, amount, s.vatCode), Receipt: s.yooKassaReceipt(email, title, amount),
}, ref.OrderID.String()) }, ref.OrderID.String())
if err != nil { if err != nil {
return "", err return "", err
+1
View File
@@ -121,6 +121,7 @@ type Deps struct {
// Robokassa. // Robokassa.
YooKassa yookassa.Shops YooKassa yookassa.Shops
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199). // 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 YooKassaVatCode int
// PublicBaseURL is the canonical https origin the payment return URL is built from. It is // PublicBaseURL is the canonical https origin the payment return URL is built from. It is
// required whenever YooKassa is configured. // required whenever YooKassa is configured.
+22 -9
View File
@@ -1,9 +1,14 @@
package yookassa package yookassa
// Fiscal receipt attributes for «Чеки от ЮKassa» (54-ФЗ). YooKassa registers the receipt itself — // Fiscal receipt support for «Чеки от ЮKassa» (54-ФЗ). YooKassa registers a receipt itself — no cash
// no cash register to rent, no OFD contract — but only if the payment and refund requests carry a // register to rent, no OFD contract — but only if the payment and refund requests carry a receipt
// receipt object, so unlike the previous rail's cabinet-side receipts these fields are load-bearing // object.
// code. Reference: //
// 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 // https://yookassa.ru/developers/payment-acceptance/receipts/54fz/yoomoney/parameters-values
const ( const (
// PaymentSubjectService is the settlement subject (54-ФЗ tag 1212) — what the money is taken // PaymentSubjectService is the settlement subject (54-ФЗ tag 1212) — what the money is taken
@@ -15,10 +20,13 @@ const (
PaymentModeFullPayment = "full_payment" PaymentModeFullPayment = "full_payment"
// VatCodeNone is the VAT rate code (54-ФЗ tag 1199) for «Без НДС» — the rate a sole proprietor on // 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 // УСН or ПСН charges. The deployed value is configurable because the rate is the one receipt
// the one receipt attribute that genuinely changes (Russia raised the main rate on 1 Jan 2026, // attribute that genuinely changes (Russia raised the main rate on 1 Jan 2026, adding codes 11
// adding codes 11 and 12). // and 12).
VatCodeNone = 1 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 is the highest VAT rate code the API accepts; codes run 1..12.
VatCodeMax = 12 VatCodeMax = 12
) )
@@ -78,5 +86,10 @@ func TruncateDescription(s string) string {
return string(r[:maxDescriptionRunes]) return string(r[:maxDescriptionRunes])
} }
// ValidVatCode reports whether code is within the range the API accepts (1..12). // ValidVatCode reports whether code is a rate the API accepts (1..12) or VatCodeOff, which disables
func ValidVatCode(code int) bool { return code >= 1 && code <= VatCodeMax } // 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) { 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) { if !ValidVatCode(code) {
t.Errorf("vat code %d rejected, want accepted", 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) { if ValidVatCode(code) {
t.Errorf("vat code %d accepted, want rejected", code) t.Errorf("vat code %d accepted, want rejected", code)
} }
} }
} }
func TestReceiptEnabled(t *testing.T) {
if ReceiptEnabled(VatCodeOff) {
t.Error("receipts reported enabled with no VAT rate configured")
}
for _, code := range []int{1, 4, 11} {
if !ReceiptEnabled(code) {
t.Errorf("receipts reported disabled for vat code %d", code)
}
}
}
+5 -3
View File
@@ -149,9 +149,11 @@ YOOKASSA_WEB_TEST=
YOOKASSA_ANDROID_SHOP_ID= YOOKASSA_ANDROID_SHOP_ID=
YOOKASSA_ANDROID_SECRET_KEY= YOOKASSA_ANDROID_SECRET_KEY=
YOOKASSA_ANDROID_TEST= YOOKASSA_ANDROID_TEST=
# VAT rate code on every fiscal receipt line (54-ФЗ tag 1199): 1 = «Без НДС» (УСН/ПСН), 4 = 20%, # Fiscal receipts (54-ФЗ) — OFF while empty, which is the intended state: the merchant runs on НПД,
# 11 = 22%. A Gitea VARIABLE, not a secret, so a rate change is a variable edit + redeploy. # outside 54-ФЗ, so nothing is registered through the provider and each operation is reported to
YOOKASSA_VAT_CODE=1 # «Мой налог» 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 # 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 # credentials, which is what keeps it dormant. The backend still reads BACKEND_ROBOKASSA_* and
+1 -1
View File
@@ -79,7 +79,7 @@ compose binds from this directory.
| `BACKEND_YOOKASSA_{WEB,ANDROID}_SHOP_ID` | secret | YooKassa shop id for that channel of the direct RUB rail (cabinet: **Настройки — Магазин**, field `shopId`). Empty ⇒ that channel is unconfigured and order routing falls back to the **web** shop; no channel configured at all leaves the rail off. Each shop's **Интеграция — HTTP-уведомления** must point at `<PUBLIC_BASE_URL>/pay/yookassa/notify` with events `payment.succeeded`, `payment.canceled` and `refund.succeeded`. | | `BACKEND_YOOKASSA_{WEB,ANDROID}_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}_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_{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 **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 secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
+5 -3
View File
@@ -177,9 +177,11 @@ services:
BACKEND_YOOKASSA_ANDROID_SHOP_ID: ${YOOKASSA_ANDROID_SHOP_ID:-} BACKEND_YOOKASSA_ANDROID_SHOP_ID: ${YOOKASSA_ANDROID_SHOP_ID:-}
BACKEND_YOOKASSA_ANDROID_SECRET_KEY: ${YOOKASSA_ANDROID_SECRET_KEY:-} BACKEND_YOOKASSA_ANDROID_SECRET_KEY: ${YOOKASSA_ANDROID_SECRET_KEY:-}
BACKEND_YOOKASSA_ANDROID_TEST: ${YOOKASSA_ANDROID_TEST:-} BACKEND_YOOKASSA_ANDROID_TEST: ${YOOKASSA_ANDROID_TEST:-}
# VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199). 1 = «Без НДС» (УСН/ПСН). # Fiscal receipts (54-ФЗ) are OFF: the merchant runs on НПД, which is outside 54-ФЗ, so no
# A deploy variable, not a secret, so a rate change is a variable edit + redeploy. # receipt is registered through the provider and each operation is reported to «Мой налог».
BACKEND_YOOKASSA_VAT_CODE: ${YOOKASSA_VAT_CODE:-1} # 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 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 # (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 # inherits). The admin console writes new version subdirectories here, and the
+19 -9
View File
@@ -411,15 +411,25 @@ spends, grants, refunds, full history — as an extension of the existing user c
Receipts are automatic **through the provider**, and differ by rail: Receipts are automatic **through the provider**, and differ by rail:
- **YooKassa** (direct) — a 54-ФЗ fiscal receipt through **«Чеки от ЮKassa»**: YooKassa owns the - **YooKassa** (direct) — **no receipt is sent, by design (D51 rev)**. The merchant operates on
cash register, the fiscal drive and the OFD contract, and files with the tax authority. Unlike the **НПД**, which is outside 54-ФЗ: there is no online cash register and the provider registers
retired rail's cabinet-side receipts, it registers one **only if the request carries it** (D51), so nothing. Each operation is reported by the merchant to **«Мой налог»**, which issues the чек.
every payment and every refund sends a `receipt`: one line (the pack title, quantity 1, the amount) YooKassa does not serve receipts for this regime at all.
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). The fiscal code is **kept dormant**, not deleted: «Чеки от ЮKassa» (YooKassa owning the cash
Delivery is by email only, to the D36 confirmed anchor. `tax_system_code` is not sent — YooKassa register, the fiscal drive and the OFD contract) registers a receipt **only if the request carries
ignores it for this solution. A malformed receipt is an API error at payment creation, so it blocks it**, and that request-building lives behind one switch — `BACKEND_YOOKASSA_VAT_CODE`. Unset, no
the purchase loudly rather than silently skipping the fiscal document. `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. - **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 - **TG Stars** — no tax side (for a RU self-employed, Stars are not legally withdrawable = not
НПД income; accepted, no receipt issued). НПД income; accepted, no receipt issued).
+19 -10
View File
@@ -323,16 +323,20 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
**Частичный возврат не записываем вовсе** — движок по устройству работает только с полной суммой **Частичный возврат не записываем вовсе** — движок по устройству работает только с полной суммой
заказа, а непроизвольного способа решить, скольких Фишек стоит часть возврата, нет; вместо записи — заказа, а непроизвольного способа решить, скольких Фишек стоит часть возврата, нет; вместо записи —
громкий лог для оператора. громкий лог для оператора.
- **D51. Фискализация — «Чеки от ЮKassa», чек отправляем из кода.** Ревизия D41: у ЮKassa нет - **D51 (ревизия). Чеки не передаём: владелец на НПД, это вне 54-ФЗ.** Уточнено у поддержки ЮKassa:
кабинетного «обобщённого чека», как у Robokassa, — чек регистрируется **только если запрос его при НПД провайдер с чеками не работает. Онлайн-кассы нет, `receipt` в запросах не отправляется, о
несёт**, поэтому itemized-код, от которого отказались в D41, возвращается в объём. Каждый платёж и каждой операции владелец сообщает в **«Мой налог»**, он и формирует чек. Побочный выигрыш: исчезает
возврат шлют `receipt`: одна позиция (название пакета, количество 1, сумма), код ставки НДС целый класс отказов — некорректный `receipt` был ошибкой API прямо при создании платежа, то есть
(тег 1199), признак предмета расчёта `service` (тег 1212), признак способа расчёта `full_payment` ломал покупку.
(тег 1214); доставка только на email — на подтверждённый якорь D36. Код ставки НДС — **переменная **Код чеков при этом консервируем, а не удаляем** (решение владельца): вся сборка `receipt` живёт за
деплоя** (дефолт `1` = «Без НДС» для УСН/ПСН): это единственный реквизит, который реально меняется одним переключателем `BACKEND_YOOKASSA_VAT_CODE` — пусто (дефолт) значит «не отправлять», код ставки
(с 1 января 2026 ставки выросли, появились коды 11/12), и менять его без релиза нужно; признаки по 54-ФЗ (тег 1199) возвращает прежнее поведение: одна позиция, признак предмета расчёта `service`
предмета и способа расчёта — константы в коде. `tax_system_code` не шлём: для «Чеков от ЮKassa» (тег 1212), способ расчёта `full_payment` (тег 1214), доставка на email-якорь D36,
провайдер его игнорирует. `tax_system_code` не шлём. Возврат к чекам предсказуем: у НПД годовой потолок дохода 2,4 млн ₽, и
его превышение возвращает 54-ФЗ — тогда это правка переменной, а не кода.
Связка с «Мой налог» — **отдельная задача**. Всё нужное для неё уже хранится (идентификатор заказа
и платежа провайдера, сумма, валюта, время зачисления, снимок пакета, возвраты); не хватает только
отметки «операция уже отправлена» для идемпотентности выгрузки — её заводит та задача.
## Заметки к оформлению документов ## Заметки к оформлению документов
@@ -371,6 +375,11 @@ D47-D51 — консервация Robokassa с откатом по кредам
добавлена D52 — обработка `refund.succeeded`, потому что кабинет ЮKassa позволяет вернуть деньги добавлена D52 — обработка `refund.succeeded`, потому что кабинет ЮKassa позволяет вернуть деньги
мимо нашего API. Обе правки вошли в тот же PR, что и переход на рельс. мимо нашего API. Обе правки вошли в тот же PR, что и переход на рельс.
**Уточнение по фискализации (владелец, 2026-07-28).** D51 ревизована: владелец принимает платежи как
ИП на **НПД**, при котором ЮKassa с чеками не работает (подтверждено поддержкой), поэтому `receipt`
не передаётся вовсе, а отчётность идёт через «Мой налог». Код чеков законсервирован за переменной
`BACKEND_YOOKASSA_VAT_CODE` на случай потери режима. Правка вошла в тот же PR.
## План внедрения (черновик PLAN.md — «слоями») ## План внедрения (черновик PLAN.md — «слоями»)
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
+17 -9
View File
@@ -405,15 +405,23 @@ in-process кэш сегментов и бенефитов по ключу-ак
Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу: Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу:
- **ЮKassa** (direct) — фискальный чек по 54-ФЗ через **«Чеки от ЮKassa»**: касса, фискальный - **ЮKassa** (direct) — **чек не передаём, так задумано (ревизия D51)**. Владелец работает на
накопитель, договор с ОФД и отправка в налоговую — на стороне ЮKassa. В отличие от кабинетных чеков **НПД**, а это вне 54-ФЗ: онлайн-кассы нет, провайдер ничего не регистрирует. О каждой операции
прежнего рельса, чек регистрируется **только если запрос его несёт** (D51), поэтому каждый платёж и владелец сообщает в **«Мой налог»**, он и формирует чек. ЮKassa при таком режиме налогообложения с
каждый возврат отправляют `receipt`: одна позиция (название пакета, количество 1, сумма) с кодом чеками не работает вовсе.
ставки НДС (тег 54-ФЗ 1199, переменная деплоя — `1` = «Без НДС» для УСН/ПСН), признаком предмета
расчёта `service` (тег 1212) и признаком способа расчёта `full_payment` (тег 1214). Доставка только Фискальный код **законсервирован, а не удалён**: «Чеки от ЮKassa» (касса, фискальный накопитель и
на email — на подтверждённый якорь D36. `tax_system_code` не передаём: для этого решения ЮKassa его договор с ОФД на стороне ЮKassa) регистрируют чек **только если запрос его несёт**, и вся сборка
игнорирует. Некорректный чек — ошибка API при создании платежа, то есть покупка ломается громко, а такого запроса спрятана за одним переключателем — `BACKEND_YOOKASSA_VAT_CODE`. Пусто — `receipt` не
не тихо остаётся без фискального документа. собирается и не отправляется; задан код ставки по 54-ФЗ (тег 1199) — снова уходит одна позиция
(название пакета, количество 1, сумма) с признаком предмета расчёта `service` (тег 1212) и способа
расчёта `full_payment` (тег 1214), доставка на email-якорь D36. Переключатель нужен потому, что
возврат к чекам предсказуем: у НПД есть годовой потолок дохода, и потеря режима возвращает 54-ФЗ.
Всё, что потребуется автоматической отправке в «Мой налог», уже записано — идентификатор заказа,
идентификатор платежа провайдера, сумма и валюта, время зачисления, снимок проданного пакета и
возвраты отдельными строками журнала, всё выгружается в CSV. Не хватать будет только отметки «эта
операция уже отправлена» для идемпотентности самой выгрузки; ей место в той задаче, а не здесь.
- **VK** — VK сам процессит Голоса через налоговую; делать нечего. - **VK** — VK сам процессит Голоса через налоговую; делать нечего.
- **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не - **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не
доход НПД; принимаем, чек не формируем). доход НПД; принимаем, чек не формируем).
+1
View File
@@ -265,6 +265,7 @@ export const en = {
'wallet.iosBlockedPost': ' of the game.', 'wallet.iosBlockedPost': ' of the game.',
'wallet.gpStub': 'To buy chips, install the RuStore build.', 'wallet.gpStub': 'To buy chips, install the RuStore build.',
'wallet.purchasesSoon': 'Purchases will be available in a future update.', '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.RUB': '₽',
'wallet.cur.VOTE': 'votes', 'wallet.cur.VOTE': 'votes',
'wallet.cur.XTR': '⭐', 'wallet.cur.XTR': '⭐',
+1
View File
@@ -260,6 +260,7 @@ export const ru: Record<MessageKey, string> = {
'wallet.iosBlockedPost': ' игры.', 'wallet.iosBlockedPost': ' игры.',
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.', 'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
'wallet.purchasesSoon': 'Покупки появятся в одном из следующих обновлений.', 'wallet.purchasesSoon': 'Покупки появятся в одном из следующих обновлений.',
'wallet.emailToBuy': 'Чтобы покупать фишки, привяжите почту в профиле.',
'wallet.cur.RUB': '₽', 'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'голосов', 'wallet.cur.VOTE': 'голосов',
'wallet.cur.XTR': '⭐', 'wallet.cur.XTR': '⭐',
+16 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { Wallet, WalletSegment } from './model'; 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 { function seg(source: string, chips: number, spendable = true): WalletSegment {
return { source, chips, spendable }; return { source, chips, spendable };
@@ -61,3 +61,18 @@ describe('needsWebSpendWarning', () => {
expect(needsWebSpendWarning('direct', [seg('vk', 400, false)], 100)).toBe(false); expect(needsWebSpendWarning('direct', [seg('vk', 400, false)], 100)).toBe(false);
}); });
}); });
describe('directPurchaseNeedsEmail', () => {
it('asks for an email on the direct rail when the account has none', () => {
expect(directPurchaseNeedsEmail('direct', '')).toBe(true);
});
it('stays quiet once the account has a confirmed email', () => {
expect(directPurchaseNeedsEmail('direct', 'player@example.com')).toBe(false);
});
it('never asks inside VK or Telegram, where the store rail carries its own identity', () => {
expect(directPurchaseNeedsEmail('vk', '')).toBe(false);
expect(directPurchaseNeedsEmail('telegram', '')).toBe(false);
});
});
+12
View File
@@ -50,6 +50,18 @@ export function spendableChips(w: Wallet): number {
// segment first, then the store-funded segments. // segment first, then the store-funded segments.
const drawPriority: readonly string[] = ['direct', 'vk', 'telegram']; 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 * 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 * store-funded (vk / telegram) chips — the case the UI must warn about, because the benefit it buys
+20 -1
View File
@@ -2,10 +2,11 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte'; import Modal from '../components/Modal.svelte';
import { app, handleError, showToast } from '../lib/app.svelte'; import { app, handleError, showToast } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { normalizeSubtype } from '../lib/platform'; import { normalizeSubtype } from '../lib/platform';
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte'; 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 { isGooglePlayBuild, purchasesHidden } from '../lib/distribution';
import { onExternalLinkClick, onInAppPageLinkClick, openExternalUrl } from '../lib/links'; import { onExternalLinkClick, onInAppPageLinkClick, openExternalUrl } from '../lib/links';
import { gatewayOrigin } from '../lib/origin'; 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 // 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. // client platformSubtype (server-derived) — read it from the signed vk_platform launch param.
const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios'; 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 // 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. // platform), so it follows prod vs the contour without a hardcoded host.
const siteRoot = `${gatewayOrigin()}/`; const siteRoot = `${gatewayOrigin()}/`;
@@ -254,6 +258,10 @@
{:else} {:else}
<p class="stub" data-testid="purchases-hidden">{t('wallet.purchasesSoon')}</p> <p class="stub" data-testid="purchases-hidden">{t('wallet.purchasesSoon')}</p>
{/if} {/if}
{:else if needsEmail}
<p class="stub" data-testid="email-required">
<button class="hintlink" onclick={() => navigate('/profile')}>{t('wallet.emailToBuy')}</button>
</p>
{:else} {:else}
{#each packs as p (p.productId)} {#each packs as p (p.productId)}
<div class="row product" data-testid="product" data-kind="pack" data-pid={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: 1px dashed var(--border);
border-radius: var(--radius-sm); 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 { .offer {
margin: 2px 0 0; margin: 2px 0 0;
text-align: center; text-align: center;