Files
scrabble-game/ui/src/screens/Wallet.svelte
T
Ilia Denisov 1507ceb793
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
feat(payments): live payment-availability kill switch + per-account override
Let an operator disable purchases live from the admin — a whole rail/channel or
one account — and show the user a localized reason on their next attempt, so a
provider outage or a misconfig is explained instead of a silent dead button.

- rail kill switch (payments.rail_status, per rail direct:web / direct:android /
  vk / telegram): enabled + a per-language message, edited on the catalog page.
  Fail-open — a rail with no row stays enabled, so payments are never accidentally
  killed. The intake gate (CanPurchase in handleWalletOrder, before the order)
  returns payment_unavailable + the localized message, orthogonal to the security
  gates.
- per-account override (payments.account_payment_override, a row only for
  non-default): allow / deny / default, edited on the user card. "allow" bypasses
  ONLY the ops rail switch, never the security gates (trusted platform, the email
  anchor, the VK-iOS freeze, the min client version).
- wire: an additive ExecuteResponse.message envelope field (frozen-contract-safe);
  the gateway forwards a backend domain-error message; the client shows it on a
  payment_unavailable buy attempt.
- admin: rail toggles on the catalog page, the override control on the user card.
- tests: the pure gate (unit, TDD), the store + gate + override end-to-end
  (integration, migration 00016), the client (svelte-check / vitest).
- docs: PAYMENTS (+ru), the decisions log (D45/D46). Fiscalization stays
  cabinet-side (owner decision) — no itemized-receipt code.

Contour-safe: additive migration (two new tables, no wipe), the wire add is
additive, and fail-open so nothing is disabled until an operator acts.
2026-07-14 16:29:41 +02:00

462 lines
17 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError, showToast } from '../lib/app.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 { isGooglePlayBuild, purchasesHidden } from '../lib/distribution';
import { onExternalLinkClick, 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 "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}
{#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">{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);
}
.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>