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:
@@ -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