12e616ceae
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The merchant accepts payments as a sole proprietor on НПД, which is outside 54-ФЗ: there is no online cash register, YooKassa does not serve receipts for that regime at all (checked with their support), and each operation is reported by the merchant to «Мой налог», which issues the чек. So no `receipt` is sent with a payment or a refund. This also removes a failure class rather than just code: a malformed receipt was an API error at payment creation, which broke the purchase outright. The fiscal code is kept dormant rather than deleted. All of it now sits behind one switch, `BACKEND_YOOKASSA_VAT_CODE`: unset — the default — builds and sends nothing; a 54-ФЗ rate code turns «Чеки от ЮKassa» back on unchanged. The return is foreseeable, which is why the switch exists: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back in force, at which point this is a deploy-variable edit instead of writing the integration again. The D36 email anchor still gates a direct purchase. It had two justifications — a recovery anchor and the receipt address — and only the second is gone; without an email a paying customer who loses the account loses the chips with it. What was missing is that the rule was enforced but never communicated: the wallet showed the packs to a player signed in through VK or Telegram in a browser, and tapping Buy produced a bare "something went wrong". It now says "add an email in your profile" in the buy tab instead, linking to the profile; spending already-earned chips is untouched. The predicate is a pure function so it is covered by the node-env unit tests rather than needing a browser. Tests: the receipt-off default is pinned by an integration test asserting a purchase carries no receipt, and the dormant path by one that switches a VAT code on and checks the receipt reappears with the right fiscal attributes; plus unit coverage for the enable predicate and the wallet's email rule. A note for whoever runs the numbers next: the shared (svelte + i18n) chunk is now 40 bytes under its 31 KB gzip budget. Decision D51 revised.
91 lines
4.1 KiB
TypeScript
91 lines
4.1 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'];
|
|
|
|
/**
|
|
* directPurchaseNeedsEmail reports whether the money storefront must ask for an email before it can
|
|
* sell anything. The direct rail funds only an account with a confirmed email — the recovery anchor,
|
|
* without which a paying customer who loses the account loses the chips with it — and the server
|
|
* refuses the order without one. Inside VK or Telegram the purchase settles on that store's own rail,
|
|
* which carries its own identity, so nothing is asked for there and spending already-earned chips is
|
|
* never affected.
|
|
*/
|
|
export function directPurchaseNeedsEmail(context: SpendContext, email: string): boolean {
|
|
return context === 'direct' && email === '';
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|