feat(payments): wallet screen with balances, benefits and storefront
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
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
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.
This commit is contained in:
@@ -34,6 +34,8 @@ import type {
|
||||
Tile,
|
||||
Variant,
|
||||
WordCheckResult,
|
||||
Wallet,
|
||||
Catalog,
|
||||
} from './model';
|
||||
|
||||
/** GatewayError carries a stable code (the gateway result_code, or an edge code). */
|
||||
@@ -135,6 +137,17 @@ export interface GatewayClient {
|
||||
/** feedbackUnread reports whether an operator reply awaits delivery (the badge). */
|
||||
feedbackUnread(): Promise<boolean>;
|
||||
|
||||
// --- wallet ---
|
||||
/** wallet returns the caller's chip segments and active benefits, visible in their current
|
||||
* trusted execution context (view-only when the platform is untrusted or VK-iOS frozen). */
|
||||
wallet(): Promise<Wallet>;
|
||||
/** catalog returns the storefront for the caller's context: chip-priced values and the chip
|
||||
* packs priced in the context's payment method. */
|
||||
catalog(): Promise<Catalog>;
|
||||
/** walletBuy spends chips on a chip-priced value and returns the updated wallet. Gate-checked
|
||||
* server-side: an untrusted/frozen context or an insufficient balance is refused. */
|
||||
walletBuy(productId: string): Promise<Wallet>;
|
||||
|
||||
// --- friends ---
|
||||
friendsList(): Promise<AccountRef[]>;
|
||||
friendsIncoming(): Promise<AccountRef[]>;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
decodeOutgoingList,
|
||||
decodeProfile,
|
||||
decodeWallet,
|
||||
decodeCatalog,
|
||||
encodeWalletBuy,
|
||||
decodeSession,
|
||||
decodeFeedbackState,
|
||||
@@ -72,6 +73,54 @@ describe('codec', () => {
|
||||
expect(w.hints).toBe(5);
|
||||
});
|
||||
|
||||
it('round-trips the catalog view (value + pack)', () => {
|
||||
const b = new Builder(256);
|
||||
|
||||
// a chip-priced value with one atom
|
||||
const vKind = b.createString('value');
|
||||
const vId = b.createString('val-1');
|
||||
const vTitle = b.createString('50 hints');
|
||||
const aType = b.createString('hints');
|
||||
fb.CatalogAtom.startCatalogAtom(b);
|
||||
fb.CatalogAtom.addAtomType(b, aType);
|
||||
fb.CatalogAtom.addQuantity(b, 50);
|
||||
const atom = fb.CatalogAtom.endCatalogAtom(b);
|
||||
const vAtoms = fb.CatalogProduct.createAtomsVector(b, [atom]);
|
||||
fb.CatalogProduct.startCatalogProduct(b);
|
||||
fb.CatalogProduct.addKind(b, vKind);
|
||||
fb.CatalogProduct.addProductId(b, vId);
|
||||
fb.CatalogProduct.addTitle(b, vTitle);
|
||||
fb.CatalogProduct.addChips(b, 100);
|
||||
fb.CatalogProduct.addMoneyAmount(b, BigInt(0));
|
||||
fb.CatalogProduct.addAtoms(b, vAtoms);
|
||||
const value = fb.CatalogProduct.endCatalogProduct(b);
|
||||
|
||||
// a RUB-priced chip pack (no atoms exercised)
|
||||
const pKind = b.createString('pack');
|
||||
const pId = b.createString('pack-1');
|
||||
const pTitle = b.createString('100 chips');
|
||||
const pCur = b.createString('RUB');
|
||||
fb.CatalogProduct.startCatalogProduct(b);
|
||||
fb.CatalogProduct.addKind(b, pKind);
|
||||
fb.CatalogProduct.addProductId(b, pId);
|
||||
fb.CatalogProduct.addTitle(b, pTitle);
|
||||
fb.CatalogProduct.addChips(b, 0);
|
||||
fb.CatalogProduct.addMoneyAmount(b, BigInt(14900));
|
||||
fb.CatalogProduct.addMoneyCurrency(b, pCur);
|
||||
const pack = fb.CatalogProduct.endCatalogProduct(b);
|
||||
|
||||
const prods = fb.Catalog.createProductsVector(b, [value, pack]);
|
||||
fb.Catalog.startCatalog(b);
|
||||
fb.Catalog.addProducts(b, prods);
|
||||
b.finish(fb.Catalog.endCatalog(b));
|
||||
|
||||
const c = decodeCatalog(b.asUint8Array());
|
||||
expect(c.products).toEqual([
|
||||
{ kind: 'value', productId: 'val-1', title: '50 hints', chips: 100, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'hints', quantity: 50 }] },
|
||||
{ kind: 'pack', productId: 'pack-1', title: '100 chips', chips: 0, moneyAmount: 14900, moneyCurrency: 'RUB', atoms: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('round-trips the export-url request and response', () => {
|
||||
const req = fb.ExportUrlRequest.getRootAsExportUrlRequest(
|
||||
new ByteBuffer(
|
||||
|
||||
@@ -40,6 +40,9 @@ import type {
|
||||
Profile,
|
||||
Wallet,
|
||||
WalletSegment,
|
||||
Catalog,
|
||||
CatalogProduct,
|
||||
CatalogAtom,
|
||||
ProfileUpdate,
|
||||
PushEvent,
|
||||
Seat,
|
||||
@@ -388,6 +391,33 @@ export function decodeWallet(buf: Uint8Array): Wallet {
|
||||
};
|
||||
}
|
||||
|
||||
// decodeCatalog reads the storefront payload: the context-visible products, each a chip-priced
|
||||
// value or a chip pack priced in the context's payment method, with its atom composition.
|
||||
export function decodeCatalog(buf: Uint8Array): Catalog {
|
||||
const c = fb.Catalog.getRootAsCatalog(new ByteBuffer(buf));
|
||||
const products: CatalogProduct[] = [];
|
||||
for (let i = 0; i < c.productsLength(); i++) {
|
||||
const p = c.products(i);
|
||||
if (!p) continue;
|
||||
const atoms: CatalogAtom[] = [];
|
||||
for (let j = 0; j < p.atomsLength(); j++) {
|
||||
const a = p.atoms(j);
|
||||
if (!a) continue;
|
||||
atoms.push({ atomType: s(a.atomType()), quantity: a.quantity() });
|
||||
}
|
||||
products.push({
|
||||
kind: s(p.kind()) === 'pack' ? 'pack' : 'value',
|
||||
productId: s(p.productId()),
|
||||
title: s(p.title()),
|
||||
chips: p.chips(),
|
||||
moneyAmount: Number(p.moneyAmount()),
|
||||
moneyCurrency: s(p.moneyCurrency()),
|
||||
atoms,
|
||||
});
|
||||
}
|
||||
return { products };
|
||||
}
|
||||
|
||||
export function decodeProfile(buf: Uint8Array): Profile {
|
||||
const p = fb.Profile.getRootAsProfile(new ByteBuffer(buf));
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isGooglePlayBuild } from './distribution';
|
||||
|
||||
describe('isGooglePlayBuild', () => {
|
||||
it('is false by default (no VITE_GP_BUILD flag, not the mock ?gp force)', () => {
|
||||
// The unit env is neither the Google Play build nor the mock ?gp force, so the normal purchase
|
||||
// actions show. The Google Play stub and the mock force are covered by the Playwright e2e.
|
||||
expect(isGooglePlayBuild()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
// Distribution channel of the native build. Google Play forbids selling in-app currency through
|
||||
// an external gate, so the Google Play build hides the purchase actions and shows a stub pointing
|
||||
// the user to the RuStore build; every other build (web, VK, Telegram, RuStore native) sells
|
||||
// normally. The channel is a build-time flag the Google Play build sets, not a runtime guess.
|
||||
|
||||
/**
|
||||
* isGooglePlayBuild reports whether this is the Google Play native build, where in-app-currency
|
||||
* purchases are hidden. It reads the build-time flag VITE_GP_BUILD (set to "1" only by the Google
|
||||
* Play build); every other build reads false. Under the mock e2e it can be forced on with a `?gp`
|
||||
* query parameter so the stub path is exercisable without a separate build.
|
||||
*/
|
||||
export function isGooglePlayBuild(): boolean {
|
||||
if (
|
||||
import.meta.env.MODE === 'mock' &&
|
||||
typeof window !== 'undefined' &&
|
||||
new URLSearchParams(window.location.search).has('gp')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (import.meta.env as Record<string, string | undefined>).VITE_GP_BUILD === '1';
|
||||
}
|
||||
@@ -225,6 +225,33 @@ export const en = {
|
||||
'offline.promptYes': 'Enable',
|
||||
'offline.promptNo': 'Keep trying',
|
||||
|
||||
// --- wallet ---
|
||||
'wallet.title': 'Wallet',
|
||||
'wallet.tab': 'Wallet',
|
||||
'wallet.balance': 'Balance',
|
||||
'wallet.noChips': 'No chips yet',
|
||||
'wallet.source.vk': 'VK',
|
||||
'wallet.source.telegram': 'Telegram',
|
||||
'wallet.source.direct': 'Web',
|
||||
'wallet.viewOnly': 'view only',
|
||||
'wallet.benefits': 'Benefits',
|
||||
'wallet.noAdsUntil': 'No ads until {date}',
|
||||
'wallet.noAdsForever': 'No ads forever',
|
||||
'wallet.adsOn': 'Ads are on',
|
||||
'wallet.hints': 'Hints {n}',
|
||||
'wallet.store': 'Store',
|
||||
'wallet.empty': 'The store is empty for now',
|
||||
'wallet.buy': 'Buy',
|
||||
'wallet.soon': 'Soon',
|
||||
'wallet.gpStub': 'To buy chips, install the RuStore build.',
|
||||
'wallet.cur.RUB': '₽',
|
||||
'wallet.cur.VOTE': 'votes',
|
||||
'wallet.cur.XTR': '⭐',
|
||||
'wallet.warnTitle': 'Web-only purchase',
|
||||
'wallet.warnBody':
|
||||
'This spends your VK/Telegram chips, and the benefit will work on the web and in the app only — because of store rules.',
|
||||
'wallet.warnConfirm': 'Continue',
|
||||
'wallet.warnCancel': 'Cancel',
|
||||
'about.title': 'About',
|
||||
'about.tab': 'Info',
|
||||
'about.description': 'A multiplatform Scrabble game.',
|
||||
|
||||
@@ -225,6 +225,33 @@ export const ru: Record<MessageKey, string> = {
|
||||
'offline.promptYes': 'Включить',
|
||||
'offline.promptNo': 'Ждать сеть',
|
||||
|
||||
// --- wallet ---
|
||||
'wallet.title': 'Кошелёк',
|
||||
'wallet.tab': 'Кошелёк',
|
||||
'wallet.balance': 'Баланс',
|
||||
'wallet.noChips': 'Пока нет фишек',
|
||||
'wallet.source.vk': 'VK',
|
||||
'wallet.source.telegram': 'Telegram',
|
||||
'wallet.source.direct': 'Веб',
|
||||
'wallet.viewOnly': 'просмотр',
|
||||
'wallet.benefits': 'Блага',
|
||||
'wallet.noAdsUntil': 'Без рекламы до {date}',
|
||||
'wallet.noAdsForever': 'Без рекламы навсегда',
|
||||
'wallet.adsOn': 'Реклама включена',
|
||||
'wallet.hints': 'Подсказки {n}',
|
||||
'wallet.store': 'Магазин',
|
||||
'wallet.empty': 'Магазин пока пуст',
|
||||
'wallet.buy': 'Купить',
|
||||
'wallet.soon': 'Скоро',
|
||||
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
|
||||
'wallet.cur.RUB': '₽',
|
||||
'wallet.cur.VOTE': 'голосов',
|
||||
'wallet.cur.XTR': '⭐',
|
||||
'wallet.warnTitle': 'Покупка только для веба',
|
||||
'wallet.warnBody':
|
||||
'Оплата спишет фишки VK/Telegram, и благо будет работать только в вебе и приложении — из-за правил магазинов.',
|
||||
'wallet.warnConfirm': 'Продолжить',
|
||||
'wallet.warnCancel': 'Отмена',
|
||||
'about.title': 'О программе',
|
||||
'about.tab': 'Инфо',
|
||||
'about.description': 'Мультиплатформенная игра в «Эрудит».',
|
||||
|
||||
@@ -40,6 +40,8 @@ import type {
|
||||
Stats,
|
||||
Variant,
|
||||
WordCheckResult,
|
||||
Wallet,
|
||||
Catalog,
|
||||
} from '../model';
|
||||
import { valueForLetter } from '../alphabet';
|
||||
import { seedMockAlphabets } from './alphabet';
|
||||
@@ -112,10 +114,37 @@ export class MockGateway implements GatewayClient {
|
||||
private readonly stats: Stats = { ...MOCK_STATS };
|
||||
private readonly drafts = new Map<string, string>();
|
||||
|
||||
// The mock chip wallet: a web/native (direct) context holding a direct and a vk segment, so the
|
||||
// storefront, the priority draw (direct→vk→tg) and the web-spend warning (a value whose price
|
||||
// reaches into the vk segment) are all exercisable without a backend.
|
||||
private readonly mockWallet: Wallet = {
|
||||
segments: [
|
||||
{ source: 'direct', chips: 120, spendable: true },
|
||||
{ source: 'vk', chips: 400, spendable: true },
|
||||
],
|
||||
adsForever: false,
|
||||
adsPaidUntilMs: 0,
|
||||
hints: 5,
|
||||
};
|
||||
// The mock storefront: two chip-priced values (one cheap enough to draw direct only, one that
|
||||
// reaches into vk → the warning) and one RUB-priced chip pack (the direct-context method).
|
||||
private readonly mockCatalog: Catalog = {
|
||||
products: [
|
||||
{ kind: 'value', productId: 'val-hints-50', title: '50 подсказок', chips: 100, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'hints', quantity: 50 }] },
|
||||
{ kind: 'value', productId: 'val-noads-30', title: 'Без рекламы: 30 дней', chips: 300, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'noads_days', quantity: 30 }] },
|
||||
{ kind: 'pack', productId: 'pack-chips-100', title: '100 фишек', chips: 0, moneyAmount: 14900, moneyCurrency: 'RUB', atoms: [{ atomType: 'chips', quantity: 100 }] },
|
||||
],
|
||||
};
|
||||
|
||||
constructor() {
|
||||
// Seed the per-variant alphabet cache the rack, blank chooser and scoring read, so the
|
||||
// mock-driven UI is alphabet-agnostic without a backend.
|
||||
seedMockAlphabets();
|
||||
// e2e seam: `?guest` seeds a guest profile so the durable-only surfaces (Friends, Wallet) can
|
||||
// be asserted hidden. The mock profile is otherwise a durable account.
|
||||
if (typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('guest')) {
|
||||
this.profile.isGuest = true;
|
||||
}
|
||||
}
|
||||
|
||||
setToken(_token: string | null): void {
|
||||
@@ -166,6 +195,44 @@ export class MockGateway implements GatewayClient {
|
||||
// The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
|
||||
return { blocked: false, permanent: false, until: '', reason: '' };
|
||||
}
|
||||
|
||||
// --- wallet ---
|
||||
async wallet(): Promise<Wallet> {
|
||||
return this.cloneWallet();
|
||||
}
|
||||
async catalog(): Promise<Catalog> {
|
||||
return { products: this.mockCatalog.products.map((p) => ({ ...p, atoms: p.atoms.map((a) => ({ ...a })) })) };
|
||||
}
|
||||
async walletBuy(productId: string): Promise<Wallet> {
|
||||
const p = this.mockCatalog.products.find((x) => x.productId === productId);
|
||||
if (!p || p.kind !== 'value') throw new GatewayError('product_not_found');
|
||||
// Draw the chip price across the segments by priority direct→vk→tg, mirroring the backend.
|
||||
let remaining = p.chips;
|
||||
for (const src of ['direct', 'vk', 'telegram'] as const) {
|
||||
if (remaining <= 0) break;
|
||||
const seg = this.mockWallet.segments.find((s) => s.source === src);
|
||||
if (!seg) continue;
|
||||
const take = Math.min(seg.chips, remaining);
|
||||
seg.chips -= take;
|
||||
remaining -= take;
|
||||
}
|
||||
if (remaining > 0) throw new GatewayError('insufficient_chips');
|
||||
for (const a of p.atoms) {
|
||||
if (a.atomType === 'hints') this.mockWallet.hints += a.quantity;
|
||||
if (a.atomType === 'noads_days') {
|
||||
this.mockWallet.adsPaidUntilMs = Math.max(Date.now(), this.mockWallet.adsPaidUntilMs) + a.quantity * 86_400_000;
|
||||
}
|
||||
}
|
||||
return this.cloneWallet();
|
||||
}
|
||||
private cloneWallet(): Wallet {
|
||||
return {
|
||||
segments: this.mockWallet.segments.map((s) => ({ ...s })),
|
||||
adsForever: this.mockWallet.adsForever,
|
||||
adsPaidUntilMs: this.mockWallet.adsPaidUntilMs,
|
||||
hints: this.mockWallet.hints,
|
||||
};
|
||||
}
|
||||
async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
|
||||
// The offline e2e serves the real per-variant dawgs from the preview build's /e2edict/ (copied
|
||||
// in by scripts/e2e-dict.mjs from the scrabble-dictionary release, never committed), so a local
|
||||
|
||||
@@ -170,6 +170,32 @@ export interface Wallet {
|
||||
hints: number;
|
||||
}
|
||||
|
||||
/** One atom line of a storefront product: the base value type it grants ("chips"/"hints"/
|
||||
* "noads_days"/"tournament") and how many of it the product carries. */
|
||||
export interface CatalogAtom {
|
||||
atomType: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
/** One storefront product for the caller's context. A "value" is bought with chips (chips is its
|
||||
* uniform price, money fields zero); a "pack" funds chips with money (moneyAmount is its price in
|
||||
* the context currency's minor units under moneyCurrency, chips zero). atoms lists what it grants. */
|
||||
export interface CatalogProduct {
|
||||
kind: 'value' | 'pack';
|
||||
productId: string;
|
||||
title: string;
|
||||
chips: number;
|
||||
moneyAmount: number;
|
||||
moneyCurrency: string;
|
||||
atoms: CatalogAtom[];
|
||||
}
|
||||
|
||||
/** The storefront: the products visible and purchasable in the caller's context — the chip-priced
|
||||
* values and the chip packs priced in the context's payment method. */
|
||||
export interface Catalog {
|
||||
products: CatalogProduct[];
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
|
||||
@@ -13,6 +13,7 @@ export type RouteName =
|
||||
| 'settings'
|
||||
| 'about'
|
||||
| 'friends'
|
||||
| 'wallet'
|
||||
| 'feedback'
|
||||
| 'stats'
|
||||
| 'confirm'
|
||||
@@ -55,6 +56,8 @@ export function parse(hash: string): Route {
|
||||
return { name: 'about', params: {} };
|
||||
case 'friends':
|
||||
return { name: 'friends', params: {} };
|
||||
case 'wallet':
|
||||
return { name: 'wallet', params: {} };
|
||||
case 'feedback':
|
||||
return { name: 'feedback', params: {} };
|
||||
case 'stats':
|
||||
|
||||
@@ -228,6 +228,16 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
return codec.decodeFeedbackUnread(await exec('feedback.unread', codec.empty()));
|
||||
},
|
||||
|
||||
async wallet() {
|
||||
return codec.decodeWallet(await exec('wallet.get', codec.empty()));
|
||||
},
|
||||
async catalog() {
|
||||
return codec.decodeCatalog(await exec('wallet.catalog', codec.empty()));
|
||||
},
|
||||
async walletBuy(productId: string) {
|
||||
return codec.decodeWallet(await exec('wallet.buy', codec.encodeWalletBuy(productId)));
|
||||
},
|
||||
|
||||
async friendsList() {
|
||||
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
|
||||
},
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Wallet, WalletSegment } from './model';
|
||||
import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning } from './wallet';
|
||||
|
||||
function seg(source: string, chips: number, spendable = true): WalletSegment {
|
||||
return { source, chips, spendable };
|
||||
}
|
||||
|
||||
function wallet(segments: WalletSegment[]): Wallet {
|
||||
return { segments, adsForever: false, adsPaidUntilMs: 0, hints: 0 };
|
||||
}
|
||||
|
||||
describe('money formatting', () => {
|
||||
it('scales roubles by 100 and leaves whole-unit currencies as-is', () => {
|
||||
expect(majorAmount(14900, 'RUB')).toBe(149);
|
||||
expect(majorAmount(20, 'VOTE')).toBe(20);
|
||||
expect(majorAmount(25, 'XTR')).toBe(25);
|
||||
});
|
||||
|
||||
it('formats roubles with two decimals and whole-unit currencies as integers', () => {
|
||||
expect(formatAmount(14900, 'RUB')).toBe('149.00');
|
||||
expect(formatAmount(15050, 'RUB')).toBe('150.50');
|
||||
expect(formatAmount(20, 'VOTE')).toBe('20');
|
||||
expect(formatAmount(25, 'XTR')).toBe('25');
|
||||
});
|
||||
});
|
||||
|
||||
describe('spendableChips', () => {
|
||||
it('sums only the spendable segments', () => {
|
||||
expect(spendableChips(wallet([seg('direct', 100), seg('vk', 50)]))).toBe(150);
|
||||
expect(spendableChips(wallet([seg('direct', 100, false), seg('vk', 50, false)]))).toBe(0);
|
||||
expect(spendableChips(wallet([seg('direct', 100), seg('vk', 50, false)]))).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsWebSpendWarning', () => {
|
||||
it('does not warn when the direct segment alone covers the price', () => {
|
||||
expect(needsWebSpendWarning('direct', [seg('direct', 200), seg('vk', 400)], 150)).toBe(false);
|
||||
});
|
||||
|
||||
it('warns when the priority draw reaches into a store (vk/tg) segment', () => {
|
||||
// direct 120 + vk covers the rest of a 300 price → touches vk
|
||||
expect(needsWebSpendWarning('direct', [seg('direct', 120), seg('vk', 400)], 300)).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when only a store segment is spendable and it covers the price', () => {
|
||||
expect(needsWebSpendWarning('direct', [seg('vk', 400)], 100)).toBe(true);
|
||||
expect(needsWebSpendWarning('direct', [seg('telegram', 400)], 100)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not warn when the price exceeds all spendable chips (buy is disabled anyway)', () => {
|
||||
expect(needsWebSpendWarning('direct', [seg('direct', 120), seg('vk', 100)], 500)).toBe(false);
|
||||
});
|
||||
|
||||
it('never warns inside a VK or Telegram context (spend stays in the same store segment)', () => {
|
||||
expect(needsWebSpendWarning('vk', [seg('vk', 400)], 100)).toBe(false);
|
||||
expect(needsWebSpendWarning('telegram', [seg('telegram', 400)], 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores non-spendable segments (a frozen or untrusted wallet never warns)', () => {
|
||||
expect(needsWebSpendWarning('direct', [seg('vk', 400, false)], 100)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user