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:
+21
-2
@@ -37,8 +37,27 @@ test('wallet: balances, benefits and the storefront render for a durable account
|
|||||||
await expect(page.getByTestId('product')).toHaveCount(3);
|
await expect(page.getByTestId('product')).toHaveCount(3);
|
||||||
const pack = page.locator('[data-kind="pack"]');
|
const pack = page.locator('[data-kind="pack"]');
|
||||||
await expect(pack).toContainText('₽');
|
await expect(pack).toContainText('₽');
|
||||||
// The pack purchase (money intake) is not wired yet, so its action is the disabled 'Soon'.
|
// The pack purchase is wired to money intake: an enabled Buy action, with the public-offer link.
|
||||||
await expect(pack.getByRole('button', { name: 'Soon' })).toBeDisabled();
|
await expect(pack.getByTestId('buy-pack')).toBeEnabled();
|
||||||
|
await expect(page.getByTestId('offer')).toContainText('Public offer');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => {
|
||||||
|
await loginLobby(page);
|
||||||
|
await openWallet(page);
|
||||||
|
|
||||||
|
// The purchase opens the provider's hosted-payment page in a new tab (window.open); capture it.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as { __opened?: string }).__opened = '';
|
||||||
|
window.open = ((u: string) => {
|
||||||
|
(window as { __opened?: string }).__opened = u;
|
||||||
|
return null;
|
||||||
|
}) as typeof window.open;
|
||||||
|
});
|
||||||
|
await page.locator('[data-kind="pack"]').getByTestId('buy-pack').click();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => (window as { __opened?: string }).__opened))
|
||||||
|
.toContain('robokassa');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wallet: the tab sits between Friends and About', async ({ page }) => {
|
test('wallet: the tab sits between Friends and About', async ({ page }) => {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import type {
|
|||||||
Variant,
|
Variant,
|
||||||
WordCheckResult,
|
WordCheckResult,
|
||||||
Wallet,
|
Wallet,
|
||||||
|
WalletOrder,
|
||||||
Catalog,
|
Catalog,
|
||||||
} from './model';
|
} from './model';
|
||||||
|
|
||||||
@@ -147,6 +148,9 @@ export interface GatewayClient {
|
|||||||
/** walletBuy spends chips on a chip-priced value and returns the updated wallet. Gate-checked
|
/** 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. */
|
* server-side: an untrusted/frozen context or an insufficient balance is refused. */
|
||||||
walletBuy(productId: string): Promise<Wallet>;
|
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 ---
|
// --- friends ---
|
||||||
friendsList(): Promise<AccountRef[]>;
|
friendsList(): Promise<AccountRef[]>;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
decodeWallet,
|
decodeWallet,
|
||||||
decodeCatalog,
|
decodeCatalog,
|
||||||
encodeWalletBuy,
|
encodeWalletBuy,
|
||||||
|
encodeWalletOrder,
|
||||||
|
decodeWalletOrder,
|
||||||
decodeSession,
|
decodeSession,
|
||||||
decodeFeedbackState,
|
decodeFeedbackState,
|
||||||
decodeFeedbackUnread,
|
decodeFeedbackUnread,
|
||||||
@@ -73,6 +75,25 @@ describe('codec', () => {
|
|||||||
expect(w.hints).toBe(5);
|
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)', () => {
|
it('round-trips the catalog view (value + pack)', () => {
|
||||||
const b = new Builder(256);
|
const b = new Builder(256);
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import type {
|
|||||||
MoveResult,
|
MoveResult,
|
||||||
Profile,
|
Profile,
|
||||||
Wallet,
|
Wallet,
|
||||||
|
WalletOrder,
|
||||||
WalletSegment,
|
WalletSegment,
|
||||||
Catalog,
|
Catalog,
|
||||||
CatalogProduct,
|
CatalogProduct,
|
||||||
@@ -373,6 +374,15 @@ export function encodeWalletBuy(productId: string): Uint8Array {
|
|||||||
return finish(b, fb.WalletBuyRequest.endWalletBuyRequest(b));
|
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
|
// decodeWallet reads the wallet payload: the context-visible chip segments and the
|
||||||
// context-applicable benefits.
|
// context-applicable benefits.
|
||||||
export function decodeWallet(buf: Uint8Array): Wallet {
|
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
|
// 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.
|
// value or a chip pack priced in the context's payment method, with its atom composition.
|
||||||
export function decodeCatalog(buf: Uint8Array): Catalog {
|
export function decodeCatalog(buf: Uint8Array): Catalog {
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ export const en = {
|
|||||||
'wallet.store': 'Store',
|
'wallet.store': 'Store',
|
||||||
'wallet.empty': 'The store is empty for now',
|
'wallet.empty': 'The store is empty for now',
|
||||||
'wallet.buy': 'Buy',
|
'wallet.buy': 'Buy',
|
||||||
'wallet.soon': 'Soon',
|
'wallet.offer': 'Public offer',
|
||||||
'wallet.gpStub': 'To buy chips, install the RuStore build.',
|
'wallet.gpStub': 'To buy chips, install the RuStore build.',
|
||||||
'wallet.cur.RUB': '₽',
|
'wallet.cur.RUB': '₽',
|
||||||
'wallet.cur.VOTE': 'votes',
|
'wallet.cur.VOTE': 'votes',
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'wallet.store': 'Магазин',
|
'wallet.store': 'Магазин',
|
||||||
'wallet.empty': 'Магазин пока пуст',
|
'wallet.empty': 'Магазин пока пуст',
|
||||||
'wallet.buy': 'Купить',
|
'wallet.buy': 'Купить',
|
||||||
'wallet.soon': 'Скоро',
|
'wallet.offer': 'Публичная оферта',
|
||||||
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
|
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
|
||||||
'wallet.cur.RUB': '₽',
|
'wallet.cur.RUB': '₽',
|
||||||
'wallet.cur.VOTE': 'голосов',
|
'wallet.cur.VOTE': 'голосов',
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import type {
|
|||||||
Variant,
|
Variant,
|
||||||
WordCheckResult,
|
WordCheckResult,
|
||||||
Wallet,
|
Wallet,
|
||||||
|
WalletOrder,
|
||||||
Catalog,
|
Catalog,
|
||||||
} from '../model';
|
} from '../model';
|
||||||
import { valueForLetter } from '../alphabet';
|
import { valueForLetter } from '../alphabet';
|
||||||
@@ -225,6 +226,14 @@ export class MockGateway implements GatewayClient {
|
|||||||
}
|
}
|
||||||
return this.cloneWallet();
|
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 {
|
private cloneWallet(): Wallet {
|
||||||
return {
|
return {
|
||||||
segments: this.mockWallet.segments.map((s) => ({ ...s })),
|
segments: this.mockWallet.segments.map((s) => ({ ...s })),
|
||||||
|
|||||||
@@ -172,6 +172,14 @@ export interface Wallet {
|
|||||||
|
|
||||||
/** One atom line of a storefront product: the base value type it grants ("chips"/"hints"/
|
/** 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. */
|
* "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 {
|
export interface CatalogAtom {
|
||||||
atomType: string;
|
atomType: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
|||||||
@@ -237,6 +237,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async walletBuy(productId: string) {
|
async walletBuy(productId: string) {
|
||||||
return codec.decodeWallet(await exec('wallet.buy', codec.encodeWalletBuy(productId)));
|
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() {
|
async friendsList() {
|
||||||
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
|
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet';
|
import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet';
|
||||||
import { isGooglePlayBuild } from '../lib/distribution';
|
import { isGooglePlayBuild } from '../lib/distribution';
|
||||||
|
import { openExternalUrl } from '../lib/links';
|
||||||
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
||||||
|
|
||||||
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
|
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
|
||||||
@@ -78,6 +79,22 @@
|
|||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const order = await gateway.walletOrder(p.productId);
|
||||||
|
openExternalUrl(order.redirectUrl);
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wallet" data-testid="wallet">
|
<div class="wallet" data-testid="wallet">
|
||||||
@@ -120,9 +137,14 @@
|
|||||||
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
|
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
|
||||||
<span class="name">{p.title}</span>
|
<span class="name">{p.title}</span>
|
||||||
<span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)}</span>
|
<span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)}</span>
|
||||||
<button class="buy" disabled>{t('wallet.soon')}</button>
|
<button class="buy" data-testid="buy-pack" disabled={busy} onclick={() => onOrder(p)}>{t('wallet.buy')}</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if packs.length > 0}
|
||||||
|
<p class="offer" data-testid="offer">
|
||||||
|
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !loading && values.length === 0 && (gpBuild || packs.length === 0)}
|
{#if !loading && values.length === 0 && (gpBuild || packs.length === 0)}
|
||||||
@@ -207,6 +229,14 @@
|
|||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
.offer {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.offer a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
.warn-body {
|
.warn-body {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user