Files
scrabble-game/ui/src/lib/wallet.ts
T
Ilia Denisov 4aa6174968
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
feat(payments): wallet screen with balances, benefits and storefront
Add the "Кошелёк" section to the settings hub: context-visible chip
balances, active benefits (no-ads term/forever, hints) and a storefront of
chip-priced values and money-priced chip packs. Guests have no wallet; the
Google Play build hides the money purchases behind a RuStore stub; a web
purchase that would draw VK/Telegram chips warns first.

Add the catalog read path the storefront needs — a context-projected
GET /api/v1/user/wallet/catalog (payments service + store, gateway op
wallet.catalog, FBS Catalog/CatalogProduct/CatalogAtom, client decode) —
plus the client leg for the existing wallet.get/buy ops. Value spends reuse
the existing spend path; the chip-pack purchase (money order flow) arrives
with payment intake, so its action is a disabled placeholder for now.

Covered by Go unit (catalog projection) + integration (/wallet/catalog over
Postgres), vitest (formatting, spendable selection, web-spend warning, GP
flag, codec + gateway encode round-trips) and Playwright mock e2e (render,
guest-hidden, GP stub, warning) on Chromium + WebKit.
2026-07-08 12:37:32 +02:00

79 lines
3.4 KiB
TypeScript

// Wallet storefront logic, kept pure and out of the .svelte screen so it unit-tests in the node
// environment: money/price formatting, the spendable-segment selection, and the web-spend warning
// trigger. The server owns the real store-compliance gate; these helpers drive display only.
import type { Wallet, WalletSegment } from './model';
import { insideVK } from './vk';
import { insideTelegram } from './telegram';
/** The client's execution context for the wallet UI, mirroring the server's platform kind. */
export type SpendContext = 'vk' | 'telegram' | 'direct';
/**
* executionContext reports the client's best-effort context: a VK Mini App, a Telegram Mini App,
* else a web/native (direct) session. It only drives the display-only web-spend warning; the
* server enforces the real gate on every spend (fail-closed), never trusting this.
*/
export function executionContext(): SpendContext {
if (insideVK()) return 'vk';
if (insideTelegram()) return 'telegram';
return 'direct';
}
/**
* majorAmount converts a money amount in a currency's minor units to its major unit — the rouble
* has 100 kopecks; Votes and Stars are whole units (scale 1).
*/
export function majorAmount(minor: number, currency: string): number {
return minor / (currency === 'RUB' ? 100 : 1);
}
/**
* formatAmount renders a money price in major units: two fractional digits for the rouble, a whole
* number for the whole-unit currencies (Votes / Stars). The currency label is added by the caller
* (it is localized), so this stays a pure number formatter.
*/
export function formatAmount(minor: number, currency: string): string {
return currency === 'RUB' ? (minor / 100).toFixed(2) : String(minor);
}
/**
* spendableChips totals the chips the player can actually spend in the current context — the sum of
* the segments the server marked spendable (zero for a VK-iOS-frozen or untrusted wallet, where no
* segment is spendable).
*/
export function spendableChips(w: Wallet): number {
return w.segments.reduce((sum, s) => sum + (s.spendable ? s.chips : 0), 0);
}
// drawPriority is the segment draw order on the web, mirroring the backend spend: the home direct
// segment first, then the store-funded segments.
const drawPriority: readonly string[] = ['direct', 'vk', 'telegram'];
/**
* 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
* (origin=direct) is then usable on the web/native only, by the store-compliance rule. It fires only
* in a direct context and only when the priority draw (direct→vk→tg) actually reaches a store
* segment to cover the price; a spend that the direct segment covers alone, or a spend in a VK/
* Telegram context (which stays within the same store segment), never warns.
*/
export function needsWebSpendWarning(
context: SpendContext,
segments: WalletSegment[],
chipPrice: number,
): boolean {
if (context !== 'direct') return false;
let remaining = chipPrice;
let reachesStore = false;
for (const src of drawPriority) {
if (remaining <= 0) break;
const seg = segments.find((s) => s.source === src && s.spendable);
if (!seg || seg.chips <= 0) continue;
const take = Math.min(seg.chips, remaining);
if (src !== 'direct' && take > 0) reachesStore = true;
remaining -= take;
}
return remaining <= 0 && reachesStore;
}