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
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user