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

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

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

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

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

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

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

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