Files
scrabble-game/ui/src/screens/Wallet.svelte
T
Ilia Denisov 12e616ceae
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
feat(payments): send no fiscal receipt; keep the code switchable
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.
2026-07-28 11:40:59 +02:00

481 lines
18 KiB
Svelte

<script lang="ts">
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 { 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';
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
import { telegramOpenInvoice } from '../lib/telegram';
import { rewardedReady, showRewarded } from '../lib/ads';
import { GatewayError } from '../lib/client';
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
// The Wallet section: a compact chip balance, the active benefits, and the storefront. The store
// splits into "buy chips" (chip packs, funded with money — a provider redirect) and "spend chips"
// (values bought with chips — an instant exchange). When purchases are hidden — the Google Play
// build (behind a RuStore stub, store policy) or the native MVP that defers billing (a neutral
// note) — the money purchases disappear while spending already-earned chips still works.
let wallet = $state<Wallet | null>(null);
let catalog = $state<Catalog | null>(null);
let loading = $state(true);
// The product whose purchase is in flight, or null. It both guards against a concurrent purchase
// (busy) and scopes the pressed/dimmed visual to the tapped button — not every buy button.
let busyId = $state<string | null>(null);
const busy = $derived(busyId !== null);
const context: SpendContext = executionContext();
// The storefront half in view: buying chips for money, or spending chips on values.
let tab = $state<'buy' | 'spend'>('buy');
// The value awaiting an exchange confirmation, or null. Every chip-spend confirms (it is instant —
// no provider redirect to stand in as a confirmation), and the dialog folds in the cross-platform
// warning when the spend would draw vk/tg chips.
let spendProduct = $state<CatalogProduct | null>(null);
const spendWarn = $derived(
!!spendProduct && needsWebSpendWarning(context, wallet?.segments ?? [], spendProduct.chips),
);
const gpBuild = isGooglePlayBuild();
// Money purchases are hidden in the Google Play build (policy) and in the native MVP that defers
// billing; the RuStore-pointer stub shows only for the former (gpBuild).
const noPurchases = purchasesHidden();
// Money purchases are not permitted inside the VK iOS app (Apple ToS); the pack CTA is shown
// 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()}/`;
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
// The chips the player can actually spend in this context — a value costing more is out of reach,
// so its exchange button is disabled (the server stays authoritative and also rejects it).
const spendable = $derived(wallet ? spendableChips(wallet) : 0);
// The balance reads as the context's own segment first (🪙 N), with any linked other-platform
// segments appended behind their logo — on the web all linked segments show and spend together;
// inside a VK/Telegram store only that store's own segment is present, so it stands alone.
const mainSeg = $derived(wallet?.segments.find((s) => s.source === context));
const linkedSegs = $derived((wallet?.segments ?? []).filter((s) => s.source !== context));
// The active benefits, one short phrase each, only for what the player actually holds — nothing
// to show (ad-free lapsed and no hints) hides the whole section.
const activeItems = $derived.by(() => {
const items: string[] = [];
if (!wallet) return items;
if (wallet.hints > 0) items.push(t('wallet.activeHints', { n: wallet.hints }));
if (wallet.adsForever) items.push(t('wallet.noAdsForever'));
else if (wallet.adsPaidUntilMs > Date.now()) {
const date = new Date(wallet.adsPaidUntilMs).toLocaleDateString(i18n.locale === 'ru' ? 'ru-RU' : 'en-US');
items.push(t('wallet.noAdsUntil', { date }));
}
return items;
});
// Rewarded video (VK ads): the "watch for chips" CTA shows only when the server offers a payout in
// this context (wallet.rewardChips > 0 — VK, configured) and an ad is ready to show.
let rewardReady = $state(false);
const canReward = $derived(!!wallet && wallet.rewardChips > 0 && rewardReady);
// onWatchAd shows a rewarded ad and, if watched, credits the payout server-side (client-attested +
// a server daily/hourly cap). A stub view (contour test) shows an "ad fired" marker; a reached cap
// is surfaced as its own toast, distinct from a generic error.
async function onWatchAd() {
if (busy) return;
busyId = 'reward';
try {
const res = await showRewarded();
if (res.stub) showToast('ad fired');
if (!res.watched) {
showToast(t('wallet.rewardFailed'));
return;
}
const nonce = crypto.randomUUID();
const before = wallet?.segments.find((s) => s.source === 'vk')?.chips ?? 0;
const w = await gateway.walletReward(nonce);
wallet = w;
const gained = (w.segments.find((s) => s.source === 'vk')?.chips ?? 0) - before;
showToast(gained > 0 ? t('wallet.rewardCredited', { n: gained }) : t('wallet.rewardCredited', { n: w.rewardChips }));
} catch (e) {
if (e instanceof GatewayError && e.code === 'reward_capped') {
showToast(t('wallet.rewardCapped'));
} else {
handleError(e);
}
} finally {
busyId = null;
}
}
async function load() {
try {
const [w, c] = await Promise.all([gateway.wallet(), gateway.catalog()]);
wallet = w;
catalog = c;
} catch (e) {
handleError(e);
} finally {
loading = false;
}
}
onMount(() => {
void load();
// Probe rewarded-ad readiness once so the "watch for chips" button only shows when a view would
// actually play.
void rewardedReady().then((r) => (rewardReady = r));
// Fallback to the payment push: re-fetch when the tab regains focus, i.e. the user returned
// from the provider's payment window.
const onVisible = () => {
if (!document.hidden) void load();
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
});
// Main path: a payment-intake push bumps app.walletRefresh; re-fetch in place when it changes
// (a credit landed while the screen is open).
let seenRefresh = app.walletRefresh;
$effect(() => {
if (app.walletRefresh !== seenRefresh) {
seenRefresh = app.walletRefresh;
void load();
}
});
function sourceLabel(source: string): string {
return t(`wallet.source.${source}` as MessageKey);
}
function currencyLabel(currency: string): string {
return t(`wallet.cur.${currency}` as MessageKey);
}
function platformLogo(source: string): string {
return source === 'vk' ? 'vk-logo.svg' : 'telegram-logo.svg';
}
// onSpend opens the exchange confirmation for a chip-priced value; doBuy runs the actual spend.
function onSpend(p: CatalogProduct) {
spendProduct = p;
}
async function doBuy(p: CatalogProduct) {
spendProduct = null;
if (busy) return;
busyId = p.productId;
try {
wallet = await gateway.walletBuy(p.productId);
} catch (e) {
handleError(e);
} finally {
busyId = null;
}
}
// onOrder funds a chip pack with money: it opens a pending order and sends the player to the
// provider's hosted-payment page. The chips are credited later, by the verified server callback;
// paying accepts the public offer (linked below the packs).
async function onOrder(p: CatalogProduct) {
if (busy) return;
busyId = p.productId;
try {
const order = await gateway.walletOrder(p.productId);
if (context === 'vk') {
// VK settles the payment in-app via the bridge; the chips arrive on the server callback.
await vkShowOrderBox(order.orderId);
} else if (context === 'telegram') {
// Telegram Stars: the bot minted an invoice link (returned as redirectUrl); open it in-app.
// The chips are credited server-side by the forwarded payment and arrive on the live push;
// refresh on 'paid' as a fallback. Outside a Stars-capable client, fall back to the link.
const opened = telegramOpenInvoice(order.redirectUrl, (status) => {
if (status === 'paid') void load();
});
if (!opened) openExternalUrl(order.redirectUrl);
} else {
openExternalUrl(order.redirectUrl);
}
} catch (e) {
// A rail kill switch (or a per-account block) returns payment_unavailable with an operator's
// localized explanation — show it to the user instead of the generic error.
if (e instanceof GatewayError && e.code === 'payment_unavailable') {
showToast(e.message);
} else {
handleError(e);
}
} finally {
busyId = null;
}
}
</script>
<div class="wallet" data-testid="wallet">
<div class="balance" data-testid="balance">
<span class="coin" aria-hidden="true">🪙</span><span class="num" class:dim={mainSeg && !mainSeg.spendable}>{mainSeg?.chips ?? 0}</span>
{#each linkedSegs as s (s.source)}
<span class="plus" aria-hidden="true">+</span><img class="plogo" src={platformLogo(s.source)} width="16" height="16" alt={sourceLabel(s.source)} /><span class="num" class:dim={!s.spendable}>{s.chips}</span>
{/each}
</div>
{#if activeItems.length > 0}
<section data-testid="active">
<h3>{t('wallet.active')}</h3>
<div class="row"><span class="name">{activeItems.join('. ')}</span></div>
</section>
{/if}
<section>
<h3>{t('wallet.store')}</h3>
<div class="seg">
<button class="opt" class:active={tab === 'buy'} data-testid="tab-buy" onclick={() => (tab = 'buy')}>{t('wallet.tabBuy')}</button>
<button class="opt" class:active={tab === 'spend'} data-testid="tab-spend" onclick={() => (tab = 'spend')}>{t('wallet.tabSpend')}</button>
</div>
{#if tab === 'buy'}
{#if purchaseBlocked}
<!-- prettier-ignore -->
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href={siteRoot} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
{/if}
{#if canReward}
<div class="row reward" data-testid="reward-row">
<span class="name">{t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })}</span>
<button class="buy" class:working={busyId === 'reward'} data-testid="watch-ad" disabled={busy} onclick={onWatchAd}
>{t('wallet.rewardCta')}</button
>
</div>
{/if}
{#if noPurchases}
{#if gpBuild}
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
{: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}>
<span class="name">{p.title}</span>
<span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)}&nbsp;{currencyLabel(p.moneyCurrency)}</span>
<button
class="buy"
class:blocked={purchaseBlocked}
class:working={busyId === p.productId}
data-testid="buy-pack"
disabled={busy}
onclick={() => {
if (purchaseBlocked) showToast(t('wallet.platformNoBuy'));
else void onOrder(p);
}}>{t('wallet.buy')}</button
>
</div>
{/each}
{#if packs.length > 0}
<p class="offer" data-testid="offer">
<a href="/offer/" target="_blank" rel="noopener noreferrer" onclick={onInAppPageLinkClick}>{t('wallet.offer')}</a>
</p>
{:else if !loading}
<p class="empty">{t('wallet.empty')}</p>
{/if}
{/if}
{:else}
{#each values as p (p.productId)}
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
<span class="name">{p.title}</span>
<span class="price">🪙 {p.chips}</span>
<button class="buy" class:working={busyId === p.productId} class:cantafford={spendable < p.chips} data-testid="buy" disabled={busy || spendable < p.chips} onclick={() => onSpend(p)}
>{t('wallet.spend')}</button
>
</div>
{/each}
{#if !loading && values.length === 0}
<p class="empty">{t('wallet.empty')}</p>
{/if}
{/if}
</section>
</div>
{#if spendProduct}
<Modal title={t('wallet.spendTitle')} onclose={() => (spendProduct = null)}>
<p class="spend-body" data-testid="spend-body">{t('wallet.spendBody', { n: spendProduct.chips, title: spendProduct.title })}</p>
{#if spendWarn}<p class="warn-body" data-testid="warn">{t('wallet.warnBody')}</p>{/if}
<div class="warn-actions">
<button class="cancel" onclick={() => (spendProduct = null)}>{t('wallet.warnCancel')}</button>
<button class="buy" data-testid="spend-confirm" onclick={() => spendProduct && doBuy(spendProduct)}
>{t('wallet.spendConfirm')}</button
>
</div>
</Modal>
{/if}
<style>
.wallet {
display: flex;
flex-direction: column;
gap: 18px;
padding: var(--pad);
}
/* The compact balance: the context's own chips (🪙 N) then any linked other-platform segments
behind their logo, digits monospaced so they align; a non-spendable (view-only) segment dims. */
.balance {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
font-weight: 600;
font-size: 1.05rem;
}
.balance .num {
font-family: monospace;
}
.balance .num.dim {
opacity: 0.5;
}
.balance .plus {
margin: 0 3px;
color: var(--text-muted);
}
.balance .plogo {
display: block;
}
section {
display: flex;
flex-direction: column;
gap: 8px;
}
h3 {
margin: 0;
font-size: 0.95rem;
color: var(--text-muted);
}
/* The buy/spend segmented control, matching the New Game type selector. */
.seg {
display: flex;
gap: 8px;
}
.opt {
flex: 1;
min-width: 64px;
padding: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
cursor: pointer;
}
.opt.active {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
}
.name {
flex: 1 1 auto;
min-width: 0;
}
.price {
flex: 0 0 auto;
font-weight: 600;
white-space: nowrap;
}
.buy {
flex: 0 0 auto;
padding: 8px 14px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
font-weight: 600;
cursor: pointer;
}
.buy:disabled {
cursor: default;
}
/* The dimmed look is scoped: the tapped button while its purchase is in flight (working), or a
blocked pack on VK iOS — never every buy button at once when one purchase is busy. */
.buy.blocked,
.buy.working,
.buy.cantafford {
opacity: 0.5;
}
.ios-note {
margin: 0 0 4px;
padding: 8px 12px;
color: var(--text-muted);
font-size: 0.85rem;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
}
.ios-note a {
color: var(--accent);
}
.empty,
.stub {
margin: 0;
padding: 10px 12px;
color: var(--text-muted);
}
.stub {
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;
font-size: 0.85rem;
}
.offer a {
color: var(--text-muted);
}
.spend-body {
margin: 0 0 10px;
}
.warn-body {
margin: 0 0 14px;
color: var(--text-muted);
font-size: 0.9rem;
}
.warn-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.cancel {
padding: 8px 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
cursor: pointer;
}
</style>