feat(ui): wire the chip-pack purchase to Robokassa

Replace the disabled "Soon" pack action with a real purchase: the Wallet opens a
money order (wallet.order) and sends the player to the provider's hosted-payment
page (window.open via openExternalUrl); the chips are credited later by the
verified server callback. Add a public-offer link under the packs (paying
accepts the offer). Codec order round-trip unit test + a mock-e2e purchase test;
the Google Play stub and the chip-spend paths are unchanged.
This commit is contained in:
Ilia Denisov
2026-07-09 17:43:02 +02:00
parent 4f6c22d669
commit 936a70ab94
10 changed files with 115 additions and 5 deletions
+4
View File
@@ -35,6 +35,7 @@ import type {
Variant,
WordCheckResult,
Wallet,
WalletOrder,
Catalog,
} from './model';
@@ -147,6 +148,9 @@ export interface GatewayClient {
/** 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>;
/** walletOrder opens a money order to fund a chip pack and returns the provider launch URL the
* client opens; chips are credited later, by the verified server callback. Direct rail only. */
walletOrder(productId: string): Promise<WalletOrder>;
// --- friends ---
friendsList(): Promise<AccountRef[]>;
+21
View File
@@ -17,6 +17,8 @@ import {
decodeWallet,
decodeCatalog,
encodeWalletBuy,
encodeWalletOrder,
decodeWalletOrder,
decodeSession,
decodeFeedbackState,
decodeFeedbackUnread,
@@ -73,6 +75,25 @@ describe('codec', () => {
expect(w.hints).toBe(5);
});
it('round-trips the wallet order request and response', () => {
// order request: pack id survives the wire
const req = fb.WalletOrderRequest.getRootAsWalletOrderRequest(new ByteBuffer(encodeWalletOrder('pack-7')));
expect(req.productId()).toBe('pack-7');
// order response: the created id and the provider launch URL decode back
const b = new Builder(64);
const oid = b.createString('order-123');
const url = b.createString('https://pay.example/abc');
fb.WalletOrderResponse.startWalletOrderResponse(b);
fb.WalletOrderResponse.addOrderId(b, oid);
fb.WalletOrderResponse.addRedirectUrl(b, url);
b.finish(fb.WalletOrderResponse.endWalletOrderResponse(b));
const order = decodeWalletOrder(b.asUint8Array());
expect(order.orderId).toBe('order-123');
expect(order.redirectUrl).toBe('https://pay.example/abc');
});
it('round-trips the catalog view (value + pack)', () => {
const b = new Builder(256);
+16
View File
@@ -39,6 +39,7 @@ import type {
MoveResult,
Profile,
Wallet,
WalletOrder,
WalletSegment,
Catalog,
CatalogProduct,
@@ -373,6 +374,15 @@ export function encodeWalletBuy(productId: string): Uint8Array {
return finish(b, fb.WalletBuyRequest.endWalletBuyRequest(b));
}
// encodeWalletOrder wraps the pack id for a money order (POST /user/wallet/order).
export function encodeWalletOrder(productId: string): Uint8Array {
const b = new Builder(64);
const pid = b.createString(productId);
fb.WalletOrderRequest.startWalletOrderRequest(b);
fb.WalletOrderRequest.addProductId(b, pid);
return finish(b, fb.WalletOrderRequest.endWalletOrderRequest(b));
}
// decodeWallet reads the wallet payload: the context-visible chip segments and the
// context-applicable benefits.
export function decodeWallet(buf: Uint8Array): Wallet {
@@ -391,6 +401,12 @@ export function decodeWallet(buf: Uint8Array): Wallet {
};
}
// decodeWalletOrder reads the created order: its id and the provider launch URL the client opens.
export function decodeWalletOrder(buf: Uint8Array): WalletOrder {
const o = fb.WalletOrderResponse.getRootAsWalletOrderResponse(new ByteBuffer(buf));
return { orderId: s(o.orderId()), redirectUrl: s(o.redirectUrl()) };
}
// 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 {
+1 -1
View File
@@ -242,7 +242,7 @@ export const en = {
'wallet.store': 'Store',
'wallet.empty': 'The store is empty for now',
'wallet.buy': 'Buy',
'wallet.soon': 'Soon',
'wallet.offer': 'Public offer',
'wallet.gpStub': 'To buy chips, install the RuStore build.',
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'votes',
+1 -1
View File
@@ -242,7 +242,7 @@ export const ru: Record<MessageKey, string> = {
'wallet.store': 'Магазин',
'wallet.empty': 'Магазин пока пуст',
'wallet.buy': 'Купить',
'wallet.soon': 'Скоро',
'wallet.offer': 'Публичная оферта',
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'голосов',
+9
View File
@@ -41,6 +41,7 @@ import type {
Variant,
WordCheckResult,
Wallet,
WalletOrder,
Catalog,
} from '../model';
import { valueForLetter } from '../alphabet';
@@ -225,6 +226,14 @@ export class MockGateway implements GatewayClient {
}
return this.cloneWallet();
}
async walletOrder(productId: string): Promise<WalletOrder> {
const p = this.mockCatalog.products.find((x) => x.productId === productId);
if (!p || p.kind !== 'pack') throw new GatewayError('not_a_pack');
// No real provider in mock: return a stub launch URL so the e2e can assert the purchase reaches
// the provider hand-off without leaving the app.
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId };
}
private cloneWallet(): Wallet {
return {
segments: this.mockWallet.segments.map((s) => ({ ...s })),
+8
View File
@@ -172,6 +172,14 @@ export interface Wallet {
/** 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. */
// WalletOrder is a created money order to fund a chip pack: its id and the provider launch URL the
// client opens (the Robokassa hosted-payment page). Chips arrive later, by the verified server
// callback.
export interface WalletOrder {
orderId: string;
redirectUrl: string;
}
export interface CatalogAtom {
atomType: string;
quantity: number;
+3
View File
@@ -237,6 +237,9 @@ export function createTransport(baseUrl: string): GatewayClient {
async walletBuy(productId: string) {
return codec.decodeWallet(await exec('wallet.buy', codec.encodeWalletBuy(productId)));
},
async walletOrder(productId: string) {
return codec.decodeWalletOrder(await exec('wallet.order', codec.encodeWalletOrder(productId)));
},
async friendsList() {
return codec.decodeFriendList(await exec('friends.list', codec.empty()));