export interface TelegramInitResponse { success: boolean; token: string; botLink: string; expiresIn: number; } export interface AuthStatusResponse { status: "pending" | "verified" | "authenticated" | "expired" | "used"; user?: User; } /** * Payload returned by Telegram Login Widget when the user completes * authentication. Same shape as Telegram's official docs — you can hand * this object directly to auth.verifyTelegramWidget(). * https://core.telegram.org/widgets/login */ export interface TelegramWidgetUser { id: number; first_name: string; last_name?: string; username?: string; photo_url?: string; auth_date: number; hash: string; } /** * Narrow user DTO returned by /auth/telegram/miniapp and /auth/telegram/widget. * This is intentionally a subset of the full {@link User} type — the auth * endpoints only echo identity + role, not balance/level/email fields, so * typing it as `User` would lie to SDK consumers. Call auth.getMe() after * login if the storefront needs the full profile. */ export interface TelegramAuthUser { id: number; telegramId: string | null; firstName: string | null; lastName: string | null; username: string | null; photoUrl: string | null; role: string; } /** * Response from the Mini App / Widget POST verification endpoints. * Body includes success, status and (on success) the authenticated user. */ export interface TelegramAuthResponse { success: boolean; status?: "authenticated"; user?: TelegramAuthUser; error?: string; } export interface User { id: number; firstName: string; lastName?: string; username?: string; photoUrl?: string; email?: string; role: string; level: number; telegramId?: string; vkId?: string; /** * `true` when the backend has NO evidence that this user has a chat with the * site's Telegram bot — delivery of the shop's DMs (order ready, payment * received) is unconfirmed, so offer the "connect the bot" prompt. It is * evidence of absence, not proof: a user may have a chat we have not * observed yet. * * Returned by `auth.getMe()` in EVERY session, not just right after a * Telegram login, so the prompt can be raised at any time. Always `false` * for users with no linked Telegram account. If the backend's chat probe * itself fails it answers `false` too — the prompt fails silent rather than * nagging someone who is already connected. * * Optional because older API deployments don't send it; treat `undefined` * as "unknown, don't prompt". */ botChatMissing?: boolean; /** * Where to open that chat: `https://t.me/`, using the site's own bot * and falling back to the platform-wide one. `null` when neither is * configured, and also when the backend failed to read the credential — * either way, render no link rather than a broken one. */ botDeepLink?: string | null; } /** * External trust rating shown in the storefront footer (Task #23). The admin * pastes the score/review-count per platform; the storefront renders an icon + * score row. An empty `externalRatings` array means the trust row is hidden. * Available since gamecore-api 2026-05 (migration 0104). */ export interface ExternalRating { platform: "tbank" | "yandex_maps" | "yandex_market" | "google_maps" | "trustpilot" | "otzovik"; rating: number; reviewCount?: number; url: string; /** ISO timestamp the admin last refreshed this rating. */ updatedAt: string; } export interface SiteConfig { apiVersion: string; site: { slug: string; name: string; /** * Whether the tenant published a legal-entity block. When true, mount * the anti-indexable PNG at `legalInfoImageUrl` (ОГРН/ИНН/address are * served as an image, never JSON, to keep them out of search indexes). * Available since gamecore-api 2026-05 (Task #222). */ legalEntityPublished?: boolean; /** * Path to the legal-entity PNG (e.g. `/legal-info.png`), or `null` when * nothing is published. Mount it as `` — pass `?lang=ru|en` for * the localized render. Companion to `legalEntityPublished`. */ legalInfoImageUrl?: string | null; /** External trust ratings for the footer (Task #23); empty = hidden. */ externalRatings?: ExternalRating[]; }; modules: Record; auth: string[]; /** * Structured per-method auth config. Companion to the flat `auth` array * — `auth` tells you WHICH methods are available, `authConfig` gives * the details you need to render each method on the client (bot * username for the Telegram Login Widget, etc). */ authConfig?: { telegram: { botUsername: string; } | null; }; payments: string[]; displayCurrency: string; rateMode: "auto" | "manual"; currentRate: number; supportedLocales?: string[]; maintenance?: { enabled: boolean; message: string | null; }; analytics?: { yandex: { counterId: string; } | null; google: { measurementId: string; } | null; }; } /** * Options for the high-level {@link GameCoreClient.auth.renderTelegramWidget} * helper. Values map 1:1 to Telegram Login Widget `data-*` attributes * (see https://core.telegram.org/widgets/login). */ export interface TelegramWidgetRenderOptions { /** DOM element the widget renders into. Previous children are cleared. */ container: HTMLElement; /** Button size. Default: "large". */ size?: "small" | "medium" | "large"; /** Corner radius in pixels. Default: Telegram's own (20). */ cornerRadius?: number; /** * Show user photo next to the button. Default: true (matches Telegram's * own default of `data-userpic="true"`). */ showUserPic?: boolean; /** * Request permission to send messages to the user from the bot. Sets * `data-request-access="write"`. Default: true. */ requestWriteAccess?: boolean; /** * Called after the widget reports an authenticated user AND the * payload has been verified by GameCore server-side (HMAC-SHA256). * The `user` object is GameCore's {@link TelegramAuthUser} — * the same shape the underlying `POST /auth/telegram/widget` * endpoint returns. */ onAuth: (user: TelegramAuthUser) => void | Promise; /** * Called if verification fails (expired hash, wrong signature, or * network error). When omitted the error is thrown to the browser's * unhandled-promise handler. */ onError?: (err: Error) => void; /** Optional referral code to thread through to the signup flow. */ ref?: string; /** Optional marketing visit refs to thread through to the signup flow. */ mvFirst?: number; mvLast?: number; /** * Bot username override. Defaults to the value from `gc.site.getConfig()` * — only pass this when you have not called getConfig() yet and want * to skip that round trip. */ botUsername?: string; } /** * Options for the high-level {@link GameCoreClient.auth.loginViaTelegramBot} * helper. Orchestrates the init → poll flow for users whose Telegram * client can't render the Login Widget (third-party mobile clients with * no active Telegram Web session, desktop browsers without TG Web * logged in, etc). */ export interface TelegramBotLoginOptions { /** * Callback that receives the generated bot deep-link. Use it to open * a popup, render a QR code, or redirect the user. This is called * exactly once per login attempt, as soon as the server returns the * link. */ onBotLinkReady: (botLink: string, token: string) => void; /** Poll interval in milliseconds. Default: 2000. */ pollMs?: number; /** * Give up after this many milliseconds. Default: 120000 (2 minutes, * matches the server-side token TTL). */ timeoutMs?: number; /** Optional referral code to thread through to the signup flow. */ ref?: string; /** * Optional marketing visit refs — appended to the polling query (they * ride exactly where `ref` rides) and consumed on first registration. */ mvFirst?: number; mvLast?: number; } /** * Marketing visit refs (marketing attribution, Task 224). `mvFirst` is the * id of the visitor's FIRST tracked visit, `mvLast` the latest one — both * come from earlier {@link GameCoreClient.marketing.trackVisit} responses * that the storefront persisted (e.g. localStorage). Optional everywhere; * invalid/stale ids are silently ignored server-side and can never fail an * auth call or a checkout. */ export interface MarketingVisitRefs { mvFirst?: number; mvLast?: number; } /** * Body for {@link GameCoreClient.marketing.trackVisit} — one first-page * landing beacon. Mirrors POST /marketing/visit. */ export interface MarketingTrackVisitRequest { /** Traffic classification the storefront derived from the landing URL. */ sourceType: "utm" | "ref" | "organic" | "direct"; utm?: { source: string; medium?: string; campaign?: string; content?: string; term?: string; }; /** Referral code when sourceType is "ref". */ refCode?: string; /** Hostname of document.referrer (no scheme), when present. */ referrerHost?: string; /** Landing pathname WITHOUT query string. Empty string is sent as "/". */ landingPath: string; device?: "mobile" | "desktop" | "other"; } /** * Result of {@link GameCoreClient.marketing.trackVisit}. `visitId` is absent * when the visit was filtered (bot UA / unattributable) or the beacon failed * — both are non-events for the storefront (fire-and-forget contract). */ export interface MarketingTrackVisitResult { visitId?: number; } export interface ExchangeRates { usdToRub: number; base: string; updatedAt: string; rates: Record; } export interface LegalDocument { title: string; content: string; version?: string; locale?: string; updatedAt: string; } export interface Game { id: number; slug: string; /** * MGD per-site slug: the URL THIS tenant publishes the game under, when it * publishes one of its own (`site_catalog_pages`, `intent: "main"`). * * Three invariants, all load-bearing: * * 1. **ABSENT, never `null`.** A tenant with no per-site pages — i.e. every * tenant today — gets a byte-identical pre-0.71 body. Read it with * `siteSlug ?? slug`, not with `'siteSlug' in game`. * 2. **ABSENT when it would equal `slug`.** A per-site page whose slug is * the platform slug renames nothing, so nothing is emitted. `siteSlug` * present therefore always means "a DIFFERENT url". * 3. 🔴 **Never valid as INPUT.** No endpoint accepts it: checkout, cart, * analytics, coupons, reviews and favourites all resolve games by the * platform `slug`, and a per-site slug posted back is an identifier no * service can resolve (the write paths reject or silently miss). The * rule for a storefront is one line: **href = `siteSlug ?? slug`, * echo/write = `slug`.** * * ⚠ NOT related to `sites.slug` (the tenant's own identifier, e.g. * `"ashop"`). The name collides; the meaning does not. This is a slug OF A * GAME, chosen BY a site. */ siteSlug?: string; name: string; icon: string | null; localIcon?: string | null; productCount: number; inStock: boolean; soldCount?: number; tags?: string[]; /** * Availability lifecycle. `out_of_stock` is **derived** by the * backend when a game has 0 visible products under the requesting * site's filters (gateway allow-list, per-site overrides, allowed * regions). Editorial states (`coming_soon`, `maintenance`, * `discontinued`) are admin overrides on canonical_games and * always win. Storefronts should branch on this field to render * the right banner / Schema.org `availability` value. */ availabilityStatus?: "available" | "out_of_stock" | "coming_soon" | "maintenance" | "discontinued"; availabilityMessage?: string | null; minPrice?: number | null; type?: string; deliveryTypes?: string[]; categories?: Category[]; /** * Distribution platforms (`steam`, `mobile_game`, `xbox`, `playstation`, * `nintendo`, `epic`). Same slugs that `?platform=` filter accepts and * that `getPlatforms()` enumerates — single source of truth. * * Always present in list endpoints (`getGames`, `getHomepageGames`, * `getGamesFull`, `getRecentGames`, `getRecommendations`, `search`) * and `getGame` since gamecore-api 2026-04-30 — empty array if no * platform metadata. Type stays optional for backward-compat with * pre-0.17 backends. Treat as `string[]` in modern code. */ platforms?: string[]; regions?: string[]; /** * Real game rating for the catalog-card star badge (gamecore-api * 2026-07-14, migration 0214). `rating` is 0.0–5.0 with one decimal; * null/absent means "no trustworthy rating" and the badge should stay * hidden — never substitute a placeholder (honest-data policy). * `ratingSource` says where the number comes from and is picked * per-site via the `rating_source` site setting: 'steam' (public * Steam review summary: positive share × 5), 'rawg' (RAWG community), * 'metacritic' (critic score /20), 'reviews' (the shop's own REAL * customer reviews — orders + published telegram imports, same math * as the game-page AggregateRating, emitted from 3 reviews); * 'google_play' / 'internal' reserved. Returned by getGames, * getHomepageGames and getGame. */ rating?: number | null; ratingCount?: number | null; ratingSource?: "steam" | "google_play" | "internal" | "rawg" | "metacritic" | "reviews" | null; } export interface GameDetail { id: number; slug: string; name: string; icon: string | null; localIcon?: string | null; /** Landscape hero image (1200x630). Null if no gallery is available. */ coverImage?: string | null; /** Gallery of product screenshots (resized to 800x600). */ images?: string[]; description: string | null; /** * Short teaser (<= 200 chars) suitable for list cards and meta * descriptions. Distinct from `description` which may be multi-paragraph. */ shortDescription?: string | null; /** Editorial badges like "hit", "new", "popular", "sale". */ tags?: string[]; /** Game developers (studio names). Empty when supplier doesn't send it. */ developers?: string[]; /** Game publishers. Empty when supplier doesn't send it. */ publishers?: string[]; /** * Genre labels ("Strategy", "RPG", ...). Empty for mobile-donation games. * * Since 2026-05-20: when the request locale is `en`, known Cyrillic * Steam genres are mapped to canonical English labels (Открытый мир * → Open World, etc.) and unknown Cyrillic values are dropped rather * than rendered as-is. For `ru` (and any other locale) the raw * upstream mix passes through unchanged. Callers that need the raw, * un-normalized list should request the entity via locale=ru. */ genres?: string[]; /** * Upstream release date as the supplier sends it — may be an ISO date * "YYYY-MM-DD", a localized natural-language string ("6 июн. 2016 г."), * or null when unknown. Storefronts should render verbatim; do not * parse as a reliable Date. */ releaseDate?: string | null; /** Steam app id — null for non-Steam titles and mobile games. */ steamAppid?: number | null; /** * Real game rating — same triple and honesty rules as {@link Game} * (null = no trustworthy rating, keep the badge hidden). */ rating?: number | null; ratingCount?: number | null; ratingSource?: "steam" | "google_play" | "internal" | "rawg" | "metacritic" | "reviews" | null; /** Availability lifecycle. Shares the union from {@link Game}. */ /** * Availability lifecycle. `out_of_stock` is **derived** by the * backend when a game has 0 visible products under the requesting * site's filters (gateway allow-list, per-site overrides, allowed * regions). Editorial states (`coming_soon`, `maintenance`, * `discontinued`) are admin overrides on canonical_games and * always win. Storefronts should branch on this field to render * the right banner / Schema.org `availability` value. */ availabilityStatus?: "available" | "out_of_stock" | "coming_soon" | "maintenance" | "discontinued"; /** Human-readable message for non-available games. */ availabilityMessage?: string | null; inStock?: boolean; productCount?: number; categories: Category[]; seo?: SeoContent | null; /** * The address to move to when the request used some OTHER url for this * game. When set, the storefront should 308 to `/games/${canonicalSlug}` * instead of rendering. Absent when the requested slug is ALREADY the right * one for this site. * * 🔴 **Changed in 0.71.0 — read this before shipping a redirect.** * * - It is never the slug you requested any more. Before 0.71 a per-site * page echoed its own slug back here, so `if (canonicalSlug) redirect()` * looped forever on exactly the urls per-site pages exist to serve. * - It may now be a PER-SITE slug rather than the platform one — a tenant * that publishes a game under its own url gets sent there in ONE hop, * including from an alias. * - The value may therefore differ from {@link GameDetail.slug}, which * stays the platform identity. Route on this field; identify on `slug`. */ canonicalSlug?: string; /** * ISO-8601 timestamp of the last meaningful edit on this canonical * game — bumped on catalog changes (products, pricing, metadata). * Storefronts should use this as `lastModified` in sitemap entries * so Google doesn't refetch pages that haven't changed. */ updatedAt?: string; /** * One-game-one-page grouping. Present only when this game belongs to a * variant group (region / language / edition / denomination variants of * the same logical product). The `primary` member owns the canonical page * and SEO; other members are `variant`. Use `variants` to render an * in-page selector — each option is an independently-buyable SKU with its * own slug. Absent for standalone games. */ group?: GameGroup | null; /** * The sibling variants of this game's group (including this one), ordered * primary-first. Present only alongside `group`. Each entry links to its * own game-detail page by `slug`; `label` is the short variant tag * ("Deluxe", "EU", "Standard"). Empty/absent for standalone games. */ variants?: GameVariant[]; /** * Per-site page identity. The key is ABSENT (not null) unless a per-site * catalog page answered the request — a tenant without `site_catalog_pages` * rows gets exactly the pre-0.71 response body. */ page?: { slug: string; intent: string; }; /** * MGD per-site slug for this game — same three invariants as * {@link Game.siteSlug}: absent (not `null`) for tenants without per-site * pages, absent when it would equal `slug`, and 🔴 never valid as INPUT to * any endpoint (href = `siteSlug ?? slug`, echo/write = `slug`). * * Note it is independent of {@link GameDetail.page}: `page` says WHICH * per-site page answered THIS request (including a non-main intent page), * while `siteSlug` is the game's one canonical per-site ADDRESS. On an * intent-page request both are present and they differ on purpose. * * ⚠ Not `sites.slug` — see {@link Game.siteSlug}. */ siteSlug?: string; } /** * Metadata about a game's variant group (one-game-one-page). */ export interface GameGroup { /** The shared group slug / logical-product key (e.g. "fifa-24"). */ key: string; /** This game's role within the group: "primary" owns the page, else "variant". */ role: string; /** Slug of the group's primary game — the canonical page that owns the SEO. */ primarySlug: string; /** * MGD per-site slug of that primary game. Absent (not `null`) when the * tenant has no per-site page for it, and absent when it would equal * `primarySlug`. Link with `primarySiteSlug ?? primarySlug`; 🔴 never send * it back to an endpoint — see {@link Game.siteSlug}. */ primarySiteSlug?: string; /** Number of visible variants in the group (for this site). */ variantCount: number; } /** * A single buyable variant within a game group (one-game-one-page selector). */ export interface GameVariant { id: number; slug: string; /** * MGD per-site slug of THIS variant. Absent (not `null`) when the tenant * publishes it under the platform slug, and absent when the two are equal. * The selector must link with `siteSlug ?? slug` — otherwise switching * denomination walks the buyer out of the tenant's own url space. 🔴 Never * valid as input; see {@link Game.siteSlug}. */ siteSlug?: string; name: string; /** Short variant tag — edition/region/language ("Deluxe", "EU", "Standard"). */ label: string | null; /** "primary" | "variant". */ role: string; /** Availability lifecycle for this specific variant. */ availabilityStatus: string; } /** * Per-site SEO content for a game page. * Stored in GameCore admin per site and returned with the game detail response. * All fields are nullable — filled in by content managers via Claude Code workflow. */ export interface SeoContent { title: string | null; h1: string | null; metaDescription: string | null; metaKeywords: string | null; ogTitle: string | null; ogDescription: string | null; ogImage: string | null; canonicalUrl: string | null; intro: string | null; content: string | null; /** Structured content blocks for H2/H3 sections */ contentSections: Array<{ heading: string; level: number; body: string; }>; faq: Array<{ question: string; answer: string; }>; keywords: string[]; /** JSON-LD schema.org object (Product, FAQPage, BreadcrumbList) */ schemaJsonLd: Record | null; footerText: string | null; /** Key cluster identifying the store's SEO theme (e.g. "рублями", "kz-tenge") */ cluster: string | null; /** * When true the page should emit `` and be * excluded from the sitemap. The page stays live + buyable but is kept out * of the search index to protect the domain's quality signal. * * The value is the STORED editorial flag OR a COMPUTED one: the API also * returns `true` when the game has no structurally visible product for this * site (same emptiness predicate the sitemap uses), so a thin, zero-product * doorway is never advertised as indexable. The computed half is * response-only — it flips back by itself as soon as a product returns, and * it never demotes a stored `true`. * * Because of that, a game page can carry a fully NULL SeoContent object * whose only meaningful field is `noindex: true` — render the directive, * fall back to your own defaults for everything else. * * Optional for back-compat with API responses predating the field. */ noindex?: boolean; } export interface Category { /** * When the site's regional fold is enabled, foreign-regional products/tab * carry the synthetic id -1 (slug "regional-other") — a display bucket, * not a DB row; never use -1 as a database key. */ id: number; slug: string; name: string; productCount: number; deliveryTypes?: string[]; platforms?: string[]; /** * Canonical category slug (e.g. "currency", "battle_pass"). Present * on responses from servers running task 119 Phase 2+. Stable across * suppliers — Nexus "Алмазы" and Vendoria "Diamonds" both carry * canonicalSlug="currency". Use this (not `name`) for storefront * dedup and routing. */ canonicalSlug?: string | null; /** * Every slug this category historically answered to (stored slug, * canonicalSlug, transliterated name, category-; for merged * operator tabs — the union across all merged rows). Match stale * category URL segments against these and 308 onto `slug`. Present on * responses from servers running the 2026-07 recategorization program. */ aliasSlugs?: string[]; } /** * Distribution platform descriptor from GET /catalog/platforms. * Use `slug` as the key for ?platform= filter and as a stable identifier * across locales. `labelRu` / `labelEn` are storefront-ready display * text in each supported UI locale; pick whichever matches the current * site locale. Introduced by task 118 Phase 3, bilingualized 2026-05-20. */ export interface PlatformInfo { slug: string; /** * @deprecated Alias for `labelRu` kept for backward-compat with SDK * ≤ 0.17.x. New code should pick `labelRu` or `labelEn` based on * the active site locale. Will be removed in 1.0. */ label: string; labelRu: string; labelEn: string; group: string; gameCount: number; } /** * Canonical category descriptor from GET /catalog/categories. Use * `slug` as the key for ?category= filter. `labelRu`/`labelEn` are * storefront-ready display text in each supported UI locale; pick * whichever matches the current site locale. Introduced by task 119 * Phase 3. */ export interface CategoryInfo { slug: string; labelRu: string; labelEn: string; group: string; gameCount: number; } /** * Product-type shelf descriptor from GET /catalog/categories * (`productTypes` key). Groups the catalog by the *kind* of thing * being sold — `game`, `topup`, `gift_card`, `subscription`, etc. * `labelRu`/`labelEn` are storefront-ready display text; pick * whichever matches the current site locale. Additive second axis * alongside the `category_slugs` axis (see `CategoryInfo`). */ export interface ProductTypeInfo { type: string; labelRu: string; labelEn: string; gameCount: number; } /** * Genre chip descriptor from GET /catalog/categories (`genres` * key). Use `slug` as the `genre` argument to `getGames()` / * `getLetterCounts()`. Only present when the GENRE_AXIS feature is * enabled server-side; may be an empty array. `labelRu`/`labelEn` * are storefront-ready display text per supported UI locale. */ export interface GenreInfo { slug: string; labelRu: string; labelEn: string; gameCount: number; } /** * Aggregate response of GET /catalog/categories exposing all three * catalog navigation axes in one call: `categories` (the existing * `category_slugs` axis, mapped from the response `data` envelope), * `productTypes` (product-kind shelves), and `genres` (genre chips, * empty unless GENRE_AXIS is enabled server-side). Returned by * `getCatalogFacets()`. The legacy `getCatalogCategories()` still * returns just the `categories` array for backward-compat. */ export interface CatalogFacets { categories: CategoryInfo[]; productTypes: ProductTypeInfo[]; genres: GenreInfo[]; } export interface FulfillmentMeta { needsPlayerId: boolean; needsLogin: boolean; needsEmail: boolean; isGiftCode: boolean; isAutoDelivery: boolean; inputFields: Array<{ id: string; label: string; type: string; required: boolean; }>; /** * Auto-derived pre-purchase requirement codes (order-rescue Phase 3, since * SDK 0.57.0). The storefront renders these as a checklist + blocking * checkbox BEFORE payment so the buyer acknowledges what the order needs. * * Current auto codes: * - `confirm_readiness` — after payment the order waits for the buyer to * confirm readiness before the code/top-up is delivered. * - `google_prompt` — delivery involves a Google login prompt the buyer * must approve. * * The vocabulary is OPEN-ENDED — new codes may appear without an SDK bump. * Render human copy for codes you recognize and SKIP unknown codes * gracefully (never hard-fail or show the raw code). Absent entirely when * the product has no requirements (never an empty array). Codes are our own * vocabulary — never a supplier name. * * MERGE RULE: when combining with `DeliveryHelp.requirements` into one * checklist, dedupe by `code` — the auto-derived entry wins (drop the * operator duplicate, keep rendering one checkbox per code). */ requirements?: Array<{ code: string; }>; } export interface Product { id: number; name: string; description?: string | null; estimatedDelivery?: string; icon: string | null; price: number | null; /** * ISO-4217 code (`RUB`, `USD`, `EUR`, `KZT`, …) for `price` and * `priceWithoutDiscount`. Set by the API on every product response * since gamecore-api 2026-05-11 / SDK 0.27 — the storefront should * use this directly rather than tracking the requested currency * separately. Falls through as `undefined` from older API builds. */ currency?: string; priceWithoutDiscount?: number; discountPercent?: number; /** * Supplier-reported original ("was") price in `currency`, for * strike-through display. Emitted since gamecore-api 2026-07-16 and ONLY * when the supplier reports a pre-discount price meaningfully (>1%) above * the current one; computed with the same markup/FX math as `price`, so * when present it is always greater than `price`. Informational — never * enters checkout math. Distinct from `priceWithoutDiscount` (the * signed-in user's own pre-discount gross): render struck-through as * `max(priceWithoutDiscount ?? 0, originalPrice ?? 0)` when `> price`. * Also carried on every variant of `getProductsGrouped()` * (`regions[].variants[].originalPrice`). Absent from older API builds. */ originalPrice?: number | null; /** * A game SLUG despite the name — but WHICH slug depends on the endpoint * that produced this object. `Product` serves THREE responses and they do * not agree: * * - `getProduct(id)` (`/catalog/products/:id`) resolves the supplier game * through `game_mappings` first, so this is the CANONICAL slug — the one * a storefront may build a URL from. {@link gameSlug} carries the same * value there. This is the ONLY response that sends both. * - `getProducts(gameSlug)` / `getProductsGrouped(gameSlug)` * (`/catalog/games/:slug/products…`) fill it from the SUPPLIER game row, * so it is the supplier slug, which need not equal the canonical one — * a supplier slug can carry a dedup suffix (`-2`) when another * supplier's game already took the clean one. * - `search()` (`SearchResult.products`) and the SEO product view do not * send this field at all; those objects carry {@link gameSlug} only. * * Do not treat the three as interchangeable and do not route on this field * without knowing which call produced the object. */ gameId: string; /** * The game slug the storefront routes on — but, like {@link gameId}, its * provenance is per-endpoint, so read that field's note too: * * - `getProduct(id)` and the SEO product view send the CANONICAL slug * (resolved via `game_mappings`). This is the case you can route on — * and on the SEO view it is the ONLY slug field present, since that * response omits {@link gameId} entirely. * - `search()` (`SearchResult.products`) sends the SUPPLIER game slug, * remapped only through an explicit hardcoded alias list and only while * the catalog-dedup flag is on — so it is NOT canonical in general and * may 404 if used to build a game URL directly. * - The list endpoints omit it entirely, which is why it is optional. * * Declared since SDK 0.60.0; the API has sent it for longer, so it was a * field the wire carried and the contract denied. */ gameSlug?: string; /** * MGD per-site slug of the parent game, on the two responses that carry a * canonical `gameSlug`: `getProduct(id)` and the SEO product view. * * Absent (not `null`) for tenants without per-site pages and absent when it * would equal `gameSlug`. 🔴 **Input rule, and it bites hardest here:** * `gameId` / `gameSlug` are what checkout stamps on an order and what every * write path resolves — this twin is for LINKS only * (href = `gameSiteSlug ?? gameSlug`, echo/write = `gameSlug`). * * `canonicalUrl` on the same response is already built with the per-site * slug, so prefer it when you just need the product's URL. */ gameSiteSlug?: string; /** * Ready-made path to this product's page: * `///-`. Sent by `getProduct(id)` and * the SEO product view; absent from the list/search shapes. * * Declared in SDK 0.71.0; the API has sent it for longer — a field the wire * carried and the contract denied (same story as {@link gameSlug}). * * 🔴 An ADDRESS. Since 0.71.0 its game segment is the tenant's own slug on * a site with per-site catalog pages, so emit it verbatim and do NOT parse * a game identifier back out of it — read {@link gameSlug} (or * {@link gameId}) for that. */ canonicalUrl?: string; gameName: string; gameIcon: string | null; /** * When the site's regional fold is enabled, foreign-regional products * carry the synthetic category id -1 (slug "regional-other") — a display * bucket, not a DB row; never use -1 as a database key. */ categoryId: number; categoryName: string; categorySlug?: string; productType: string; deliveryType?: string; fulfillment?: FulfillmentMeta; deliveryDataSchema: unknown[]; region?: string; /** * Supplier platform bucket ("steam" | "mobile_game" | …) — the CATALOG * axis. Not the game-page server/OS axis (Android/iOS), which is parsed out * of the region label. Also carried on every variant of * `getProductsGrouped()` (`regions[].variants[].platform`) since * gamecore-api 2026-07-26. */ platform?: string; /** * Per-SKU variant metadata for the multi-SKU product picker. * Available since gamecore-api 2026-05-01 (Wave 5 #52). All four * fields are nullable — products without an operator-configured * variant grouping leave them at `null` and the storefront falls * back to single-SKU rendering. * * `riskTier` drives the badge ("Безопасно" / "Нужен логин" / * "Полный доступ"); `warningMessage` is short copy under the * variant tabs; `variantGroup` is an opaque key shared by SKUs * that should be folded into one radio-group; `variantLabel` is * the short tab title within that group. * * `warningMessage` is localized server-side and derived from the SAME * region token as `serverRegion`, and (since gamecore-api 2026-07-26) is * also returned on every variant of `getProductsGrouped()` * (`regions[].variants[].warningMessage`). */ riskTier?: "safe" | "moderate" | "requires_credentials" | null; warningMessage?: string | null; variantGroup?: string | null; variantLabel?: string | null; /** * Buyer-facing supplier purchase instructions (e.g. "Выйдите из аккаунта * перед оплатой", "Сделайте профиль публичным"). Null when the supplier * provides none. Returned by getProducts() and getProduct(), and (since * gamecore-api 2026-07-16) on every variant of getProductsGrouped() * (`regions[].variants[].instruction`). May contain markdown (**bold**, * numbered lists) across multiple lines. */ instruction?: string | null; /** * Public URLs of the images embedded in the supplier's raw instruction * (Vendoria form-instruction screenshots — the pictures that used to be * stripped out of `instruction`, mirrored by gamecore-api since * 2026-08-14 / ITQ-58). Render them alongside `instruction` — for ~half * of the affected SKUs the picture IS the whole instruction, so * `instruction` may be null/empty while this field carries content. * `null` = the instruction embeds no images; `undefined` = older API * deployment. Returned by getProducts() and getProduct(), and on every * variant of getProductsGrouped() * (`regions[].variants[].instructionImages`). */ instructionImages?: string[] | null; /** * Non-filtering server-region tag: the sync-stored SKU code first, the * supplier external-id suffix as fallback (e.g. "ru", "idn", "sea", "br", * "cis"); null = region-agnostic. For storefront labels / sort / region tabs * only — distinct from the `region` allowed-regions bucket (a constant * "global" on most tenants). * * Returned by getProducts() and getProduct(), and (since gamecore-api * 2026-07-26) on every variant of getProductsGrouped() * (`regions[].variants[].serverRegion`). It is per-VARIANT on purpose: one * grouped card routinely mixes servers (an "ru" and an "idn" SKU share the * «По умолчанию» region bucket whenever neither the category nor the form * names a cluster), so the display `regions[].regionName` cannot be used to * rank or price a card. Note the grouped return type is not yet updated to * declare these fields — read them off the variant at runtime until the * (breaking) type change lands. */ serverRegion?: string | null; } export interface ProductFilters { deliveryType?: string; region?: string; platform?: string; categoryId?: number; minPrice?: number; maxPrice?: number; /** * Display locale for product NAME + category overlays (sends `?locale=`). * The backend localizes SKU names off the query param (NOT the * Accept-Language header the client may also send), so a per-call locale * is required to get es/pt-br/en names on the grid. Omit → base RU names. */ locale?: string; } export interface SearchResult { games: Game[]; products: Product[]; /** * Roblox SuperPass hits, merged in alongside games/products. On the current * API (0.47+), this field is always present — sites with SuperPass search * disabled receive an empty array. The field is typed optional only to * tolerate older API deployments (pre-0.47) that predate SuperPass search * entirely, where the key is absent and should be treated as an empty list. */ superpasses?: SuperpassSearchHit[]; } export interface CartItem { id?: number; productId: number; gameId: string; gameName: string; productName: string; price: number; deliveryData: Record; /** * Quantity for multi-amount products (e.g. N × 500 Robux). * Optional when writing (server defaults to `1`); always * present on server responses. */ quantity?: number; /** * ISO-8601 timestamp of when the row was inserted. Absent when * the item was freshly constructed client-side and hasn't been * sent to the server yet. */ addedAt?: string; /** * Canonical game icon (localIcon → icon fallback). May be `null` * if the underlying game has no artwork. Never present on * client-authored items — only populated on server responses. */ gameIcon?: string | null; /** * Supplier product icon (localIcon → icon fallback). Same * semantics as `gameIcon`. */ productIcon?: string | null; } /** * Per-cart cashback preview surfaced by `gc.cart.getCashbackPreview()`. * `totalAmount` is the credit (in site balance units) the * authenticated user would receive after the cart is paid for at the * current state. * * The endpoint requires authentication — `gc.cart.getCashbackPreview()` * throws `GameCoreError(401)` for anonymous callers. Storefronts * should skip the request when the user is not signed in instead of * relying on a graceful empty response. For tenants whose cashback * rates are not configured the API returns `totalAmount: 0` with a * UX hint in `rateExplanation`. * * Available since gamecore-api 2026-05-01 (Wave 5 #51). */ export interface CashbackPreview { totalAmount: number; currency: "balance"; breakdown: Array<{ source: "level_base"; amount: number; label: string; }>; rateExplanation: string; } /** * One block of Steam-sourced PC requirements (minimum or recommended). * Available since gamecore-api 2026-05-01 (Wave 5 #50). */ export interface SystemRequirementsBlock { os?: string; processor?: string; memory?: string; graphics?: string; directX?: string; storage?: string; soundCard?: string; additional?: string; } /** * Response from `gc.catalog.getGameSystemRequirements()`. `hasData` * is false for non-Steam games and Steam games where Steam couldn't * supply requirements — frontends should branch on this rather than * treating an empty `minimum`/`recommended` as an error. */ export interface SystemRequirementsResponse { hasData: boolean; minimum: SystemRequirementsBlock; recommended: SystemRequirementsBlock; source: "steam"; fetchedAt: string; } export interface SteamScreenshot { thumbnailUrl: string; fullUrl: string; order: number; } export interface SteamMovie { thumbnailUrl: string; webmUrl?: string; mp4Url?: string; title: string; } /** * Response from `gc.catalog.getGameScreenshots()`. `hasData` mirrors * the system-requirements semantics: false when no screenshots OR * movies are available. */ export interface ScreenshotsResponse { hasData: boolean; screenshots: SteamScreenshot[]; movies: SteamMovie[]; source: "steam"; fetchedAt: string; } /** * One operator-authored pre-purchase requirement (order-rescue Phase 3, since * SDK 0.57.0). Operators attach these to a game's `DeliveryHelp` to join the * auto-derived `FulfillmentMeta.requirements` in the pre-payment checklist. */ export interface DeliveryHelpRequirement { /** * Machine code — our own vocabulary (kebab/snake, `[a-z0-9_-]`, never a * supplier name). Standard codes (`confirm_readiness`, `google_prompt`, * `wait_world_link`) get storefront i18n; for an UNKNOWN code render * `label` and skip if it's absent — never show the raw code. */ code: string; /** * Optional operator-facing RU display string. Present for custom codes the * storefront has no i18n for. Plain text — the storefront MUST escape it * when rendering (never `dangerouslySetInnerHTML`). */ label?: string; } /** * Pre-checkout delivery help block for a game page. Operators * configure copy/screenshots so the storefront can show "how to find * your player ID" / "where to look up your login" instructions * before the user fills the checkout form. All fields except `label` * are optional — the storefront should render whatever combination * the operator provided. */ export interface DeliveryHelp { label: string; helpText?: string; screenshots?: string[]; warnings?: string[]; externalUrl?: string; /** * Operator-authored requirement checklist (order-rescue Phase 3, since SDK * 0.57.0). Rendered BEFORE payment alongside `FulfillmentMeta.requirements` * so the buyer acknowledges what the order needs (e.g. «привяжите аккаунт к * Supercell ID заранее»). Absent entirely when the operator set none (never * an empty array). A per-site override REPLACES the whole default block — * including these requirements. * * MERGE RULE: dedupe against `FulfillmentMeta.requirements` by `code` — * auto-derived wins; an operator row with the same code (e.g. a manual * `confirm_readiness`) must not produce a second checkbox. */ requirements?: DeliveryHelpRequirement[]; } /** * Response from `gc.catalog.getDeliveryHelp()`. `deliveryHelp` is * `null` when the game has no help content configured — the * storefront should hide the help panel in that case. The endpoint * returns the field flat (no `success`/`data` envelope). */ export interface DeliveryHelpResponse { deliveryHelp: DeliveryHelp | null; } /** * One cart line, exactly as `POST /checkout` and `POST /checkout/preview` take * it. Extracted verbatim from the inline shape `CheckoutRequest.items` has * always carried — same fields, same optionality — so this is a rename, not a * reshape: every existing call-site literal still type-checks. * * It earns a name because `checkout.preview()` quotes the SAME lines the buy * charges. A preview typed against a DIFFERENT item shape than the payment is a * preview that can quote a cart nobody can buy. * * ⚠ The optional markers are LOOSER than the wire. `POST /checkout/preview` * declares `gameId`, `gameName`, `productName` and `deliveryData` as REQUIRED * (Plan 1's body schema), so a line built as `{ productId }` type-checks here * and comes back 422 from the server. The optionality is inherited from the * legacy `CheckoutRequest.items` and is kept only so existing literals still * compile — tightening it is a breaking SDK change and its own task. Build cart * lines from the cart, never by hand. */ export interface CheckoutItemInput { productId: number; gameId?: string; gameName?: string; productName?: string; amount?: number; deliveryData?: Record; } export interface CheckoutRequest { email?: string; /** Cart lines. The same shape `checkout.preview()` quotes — see {@link CheckoutItemInput}. */ items: CheckoutItemInput[]; paymentMethod?: string; /** * Optional referral code or slug. When this checkout provisions a NEW * guest account (email checkout without login), the account is attributed * to the referrer who owns this code — commissions then accrue on the * buyer's delivered orders. Ignored for authenticated buyers and existing * accounts. Storefronts should pass the persisted ref (cookie) here. */ ref?: string; /** * Optional marketing visit refs (see {@link MarketingVisitRefs}) — the * checkout creates a PENDING order attribution that is finalized with the * order amount when the payment completes. The SDK drops non-positive/ * non-integer values client-side so a tampered persisted value can never * 422 the checkout. */ mvFirst?: number; mvLast?: number; /** * Coin price the storefront SHOWED the buyer, on the coins rail * (`paymentMethod: "coins"`). The server re-prices the cart and refuses * with 409 `coin_price_changed` (`details.coinAmount` / `details.coinCurrency` * carry the CURRENT price) when the two disagree, instead of silently * charging a different number of coins. Ignored on every other rail. * Available since gamecore-api 2026-07-27. */ expectedCoinAmount?: number; /** * @deprecated Coupons are applied via `gc.coupons.apply()` BEFORE * checkout — the backend reads the active coupon from the * `user_active_coupons` row and ignores any `couponCode` sent in * the checkout body. Leaving this in the type would let storefront * code believe it's wiring coupons correctly when in fact the * field is silently dropped. Field will be removed in 0.29.0. */ couponCode?: string; } /** * Payment-fee mode (Block B, 3-mode fee). Mirrors the server `FeeMode` * (apps/api site-payment-fees.ts) byte-for-byte. * - "included" — the customer pays the goods total (the fee is NOT added to * their charge); the merchant bears the fee. Balance payments * are always "included" with amount 0. * - "absorb" — the merchant eats the fee; the customer still pays goods. * (Same customer billing as "included" — both are merchant- * borne; the label records intent.) * - "surcharge" — the fee is added ON TOP; the gateway charge * (payment.total) becomes goodsTotal + fee.amount (GROSS). * Only "surcharge" changes what the customer is billed. */ export type FeeMode = "included" | "absorb" | "surcharge"; /** * Fee breakdown attached to a checkout payment (Block B). Present on EVERY * checkout path — balance payments carry `{ mode: "included", amount: 0 }` * so storefronts can render `payment.fee` without a special case. * * ⚠ `payment.total` is the GROSS charge. Under `mode: "surcharge"`, * `total === goodsTotal + amount`. Under every other mode * `total === goodsTotal`. Never re-add `amount` to `total` yourself. */ export interface CheckoutFee { /** Which fee mode applied to this payment. */ mode: FeeMode; /** * Fee in RUB. The server guarantees a finite, kopeck-rounded value; added * on top only under surcharge. (Defensive UIs may treat a non-finite value * as 0 to avoid NaN-poisoning a `total − amount` calculation.) */ amount: number; /** Goods subtotal BEFORE any surcharge. For surcharge, payment.total = goodsTotal + amount. */ goodsTotal: number; } export interface CheckoutResponse { payment?: { code: string; /** GROSS charge (goods + surcharge). See {@link CheckoutFee}. */ total: number; /** Gateway path only — redirect target. Absent on balance/direct payments. */ paymentUrl?: string; status?: string; /** Coupon consumed at checkout, or null when none applied. */ couponId?: number | null; /** Bundle-discount breakdown when a bundle rule matched, else null. */ bundleDiscount?: { percent: number; ruleName: string; saved: number; } | null; /** * Fee breakdown (Block B). Present on every path from API ≥ 0.43 (balance * = { mode:"included", amount:0 }); optional so pre-B17 cached responses * still type-check. */ fee?: CheckoutFee; /** * What the buyer pays in COINS, present ONLY on the coins rail (GC Coins * purchase spec, task 5.5). `total` above stays the RUB charge (platform * invariant) — render these two verbatim rather than re-deriving a coin * price from it. * Available since gamecore-api 2026-07-27. */ coinAmount?: number; coinCurrency?: string; /** * «Сдача» (integer-charge spec 2026-07-30): the gateway charge is ceiled * to a WHOLE ruble, and this is the ceil remainder — a PROMISE of rubles * credited to the buyer's balance at fulfillment. `0` on the balance * rail (exact deduction), on the coins rail (paid in coins, no ruble * credit follows), and on any charge that landed whole. Render * «Сдача N ₽ будет зачислена на баланс» only when > 0. Optional so * pre-spec API responses still type-check; treat absence as 0. */ change?: number; }; orders?: Array<{ code: string; gameId: string; gameName: string; total: number; itemCount: number; }>; } /** * What `POST /checkout/preview` answers: what THIS cart costs on the BALANCE * rail, and how much of the buyer's wallet may actually pay for it. * * Bonus rubles are spend-capped per order (the shop keeps a floor of its * markup), which in practice lets bonuses cover ~11–14% of a cart. Until this * endpoint existed, the cap-aware number reached the BUYER only AFTER a * refusal — inside the 402 ({@link InsufficientBalanceDetails}) — while the * checkout page rendered the raw wallet total in green. This is that same * arithmetic, before the button. * * Invariant the server guarantees on both branches: * `bonusApplied + permanentApplied === min(total, spendable)`, all fields ≥ 0. * * ⚠ This is NOT {@link InsufficientBalanceDetails} under different names, and * the two must never be unified. `total` is the charge (the 402 calls it * `requiredAmount`); `spendable` is the cap-aware wallet (the 402 calls it * `spendableTotal`); and `shortfallAmount` — the one name they SHARE — has a * different floor: 0 here on an affordable cart, never below 1 there (a refusal * always needs a real top-up). * * The seven MONEY/echo fields are required: an API deployment that predates the * endpoint answers 404 (the SDK throws), never a partial object. The eighth, * `bonusExpiringSoon`, is the one exception and is OPTIONAL for a reason that * does not weaken the rule — it postdates the route, so an API deployed between * the two omits it while answering 200 with everything else. Treat its absence * exactly like `null`: render no expiry line. */ export interface CheckoutPreview { /** * Kopeck-exact charge on the BALANCE rail. No whole-ruble ceil — that * belongs to the gateway and top-up rails, and re-ceiling it client-side * would disagree with what the rail actually deducts. */ total: number; /** Cap-aware maximum this wallet may pay toward THIS cart. */ spendable: number; /** * Bonus rubles that WILL be drawn for this cart — render it as «бонусами * N ₽», a fact about this order, not a «до X ₽» ceiling. */ bonusApplied: number; /** Permanent balance drawn after the bonus part. */ permanentApplied: number; /** Kopeck-precision gap `max(0, total − spendable)`; `0` when affordable. */ shortfall: number; /** * Whole-ruble top-up that clears the gap, or `0` when `shortfall === 0`. * Feed a «Доплатить N ₽» button with it VERBATIM — never re-round * `shortfall` client-side; the server owns this rounding so the quote and * the invoice cannot disagree. */ shortfallAmount: number; /** * Echo of the flag the numbers were computed under. Pass THIS back to * `checkout.create` rather than the checkbox you last rendered — the echo is * what the quoted numbers mean. */ useBonus: boolean; /** * The NEAREST expiry date among the bonus rubles this cart spends — within 7 * days — and how much of `bonusApplied` dies ON THAT DATE. Render the pair as * one sentence: «из них N ₽ сгорят {дата}». `null` when nothing being spent * here dies that soon, and `null` whenever `bonusApplied` is 0. * * ⚠ `amount` is NOT the week's total. If 10 ₽ dies Friday and 15 ₽ dies * Sunday, this is `{amount: 10, expiresAt: Friday}` — the 15 ₽ belongs to a * different deadline and is deliberately left out, because a number attached * to a date has to be true of that date. Do not add up several previews or * present this as "expiring this week". * * ⚠ `amount` is also a SLICE of `bonusApplied`, never an addition to it: the * two describe the same rubles, so rendering them as a sum double-counts the * buyer's money. And it is not the wallet's expiring balance — bonus lots * outside this cart's spend are excluded, because the line sits next to the * cart's own number. * * `expiresAt` is an ISO-8601 instant; format it in the buyer's timezone. * The same 7-day window the coins wallet uses for its `expiringSoon`. * * Optional because an API deployed before 2026-08-20 omits the key entirely * — `undefined` and `null` mean the same thing here: draw nothing. * Display-only; no money decision may be taken from it. */ bonusExpiringSoon?: { amount: number; expiresAt: string; } | null; } /** Options for `checkout.preview()`. */ export interface CheckoutPreviewOptions { /** * Compute the numbers with bonus spend OFF (the buyer unticked * «Использовать бонусы»). OMIT it for the default: the server reads an * absent flag as `true`, and the SDK sends no key when you omit it, so * "not said" and "said true" stay distinguishable on the wire. */ useBonus?: boolean; } /** * Options for `checkout.create()`, passed as the SECOND argument in place of * the legacy bare idempotency-key string. Both forms are live: * `create(data, "key")` ≡ `create(data, { idempotencyKey: "key" })`. */ export interface CheckoutCreateOptions { /** * Spend bonus rubles on this payment (absent = `true`). The server persists * the flag WITH the payment, so `checkout.completeWithBalance()` deducts * under the same value — do not send it again there, and do not assume a * second call can change it. * * After a SUCCESSFUL preview, pass the `useBonus` it ECHOED rather than the * checkbox state you last rendered: only the echo is the flag the quoted * numbers were computed under. * * When there is no successful preview to echo — it failed, or you never * called it — send the buyer's CURRENT selection EXPLICITLY. The two rules * are one rule ("send the flag the buyer's money will actually move under"), * not a contradiction: what you must never do is omit it. An unticked box * has to reach the wire as `false`, because the server reads an absent flag * as `true` and would spend the bonuses he just declined. */ useBonus?: boolean; /** * Replace the SDK's per-call random `X-Idempotency-Key` with a stable one. * * ⚠ Read this before using it. The server prefers the client header and * gives it the FULL 24h replay window; only when NO header arrives does it * derive a short-lived (120s) key from the cart. Because this SDK has always * sent a random UUID, server-side dedup is effectively off for SDK callers — * every call is a new key, so every call can mint a new payment. * * Pass a key derived from the thing that must happen exactly ONCE (the * top-up chain uses `"chain:" + topupCode`), and the repeat becomes a replay * of the FIRST payment instead of a second one. Do NOT pass a per-render or * per-mount random value — that is the default you already have. * * ⚠ A client-sent key also FREEZES `useBonus`. The flag is folded into the * server's DERIVED key only; a header key is matched on (key, site) alone * and replays the stored response WITHOUT ever reading the new body. So * reusing one key after the buyer unticks «Использовать бонусы» replays the * FIRST payment, and `completeWithBalance()` then deducts under the flag * stored on THAT payment — spending bonuses the buyer just withheld. A * CHANGED flag therefore needs a NEW key; never reuse one across a change. * (Without a header key this cannot happen: `useBonus` is part of the * derived key, so flipping it derives a different key by construction.) * * An empty string is treated as "not given" (a random key is minted) — the * pre-existing semantics, kept so the legacy positional form is unchanged. */ idempotencyKey?: string; } /** * Result of `checkout.uploadDeliveryImage()` — POST /checkout/delivery-image. * * `key` is the Vendoria storage reference (`images/{uuid}.webp` — the * supplier always converts to webp). Put it VERBATIM into the cart item's * `deliveryData` under the image field's id; the server validates the exact * format at checkout, so never construct or edit a key manually. * * `grant` is a signed server-issued proof binding (site, product, key, * expiry ~2h). Checkout REQUIRES it: store it VERBATIM in the same * `deliveryData` under `"__imageGrant_" + fieldId` (the `__` prefix is * stripped before the supplier call). Missing/expired/foreign grant → * checkout 400 asking the buyer to re-upload. */ export interface DeliveryImageUpload { key: string; grant: string; } /** * Body for `checkout.beginGuestSession()` — POST /checkout/guest-session. * * Task #24 (54-ФЗ ст.1.2): persist contact channel + cart snapshot * BEFORE the user clicks "pay" so fiscal receipts and post-payment * notifications survive the user closing the tab. * * Discriminated by `preferredChannel`: when "email" the API requires * `email`, when "sms" it requires `phone` (validator mirrors the DB * CHECK constraints on sites.guest_checkout_sessions). */ export interface GuestCheckoutSessionRequest { preferredChannel: "email" | "sms"; email?: string; phone?: string; cartItems?: Array<{ productId: number; quantity: number; }>; deliveryData?: Record; } /** * Response from `checkout.beginGuestSession()`. The `sessionId` is * the durable row id the storefront persists locally so it can drop * the cached copy when the server-side row expires (`expiresAt` — 7 * days from creation by default). */ export interface GuestCheckoutSessionResponse { sessionId: number; expiresAt: string; } export type OrderStatus = "pending" | "paid" | "processing" | "completed" | "cancelled" | "refunded" | "failed"; /** * Payment record returned by `checkout.getByPayment()`. * * A payment groups one or more orders created in the same checkout * call. The `code` is the external identifier exposed to the * payment gateway and returned via the success URL query string. * * `paymentMethod` and `gatewayType` are `null` when the payment is * fully settled from the user's balance (no external gateway is * involved). `completedAt` is set when the payment transitions to * `completed` via webhook confirmation or balance settlement; it * remains `null` for `pending`, `failed`, and `cancelled` payments * (the failure/cancellation timestamp is tracked separately by * the stale-payment scanner and not surfaced on this shape). */ export interface PaymentInfo { code: string; totalAmount: number; status: "pending" | "completed" | "failed" | "cancelled"; paymentMethod: string | null; gatewayType: string | null; createdAt: string; completedAt: string | null; /** * Block-B fee snapshot (SDK ≥ 0.44 / API migration 0183). Absent for legacy * payments made before the snapshot columns existed — treat absence as * "no surcharge, totalAmount == goods". Under `mode: "surcharge"` * `totalAmount === fee.goodsTotal + fee.amount` (GROSS); every other mode * `totalAmount === fee.goodsTotal`. See {@link CheckoutFee}. */ fee?: CheckoutFee; /** * «Сдача» (integer-charge spec 2026-07-30) — the whole-ruble ceil * remainder credited to the buyer's balance at fulfillment. `0` on the * coins rail and for legacy pre-spec payments (the server coalesces NULL * to 0). This is the surface the success page renders * «Сдача N ₽ зачислена на баланс» from. Treat absence as 0. */ change?: number; } /** * Normalised delivery hint attached to an order. Used by the * storefront "My Orders" page to decide which CTA to show * (robux-via-pass → "Check Roblox transactions"; gift card → * "Copy code"; login delivery → "Waiting for credentials"; etc). * * `helpTextKey` is an i18n key — the storefront owns the * translation layer, so copy changes do not require a backend * deploy. Unknown `kind` values should render a generic * "processing" message as a fallback. */ export interface OrderDeliveryMeta { kind: "cdkey" | "login" | "pass_based" | "service" | "manual" | (string & {}); helpTextKey: string; checkTransactionsUrl?: string; } export interface Order { id: number; code: string; status: OrderStatus; totalAmount: number; gameName: string; gameId: string; paymentCode?: string; items: OrderItem[]; /** * Payment summary for this order. `fee` is the Block-B fee snapshot * (SDK ≥ 0.44 / API migration 0183) — absent for legacy payments, in which * case `totalAmount == goods`. Under surcharge, `totalAmount` is GROSS and * `fee.goodsTotal` is the goods subtotal; render a * goods / fee / charged breakdown from `fee`. See {@link CheckoutFee}. */ payment?: { code: string; status: string; totalAmount: number; fee?: CheckoutFee; }; createdAt: string; completedAt?: string; /** * Delivery hint for the storefront UI — present on responses * from `/profile/orders` and `/orders/:code` starting with * SDK 0.13. Absent on legacy responses and on endpoints that * do not currently surface it. */ deliveryMeta?: OrderDeliveryMeta; /** * Set only on `/profile/orders`, and only when the viewer has confirmed a * mailbox that belongs to ANOTHER account on this site (API migration 0275, * `EMAIL_VISIBILITY`): * * - field ABSENT — nothing is folded in; every order is the viewer's own. * - `null` — this order is the viewer's own (full detail). * - an address — this order belongs to the account behind that confirmed * mailbox. Label the group, e.g. «заказы аккаунта a@b.com». * * ⚠ A linked order is a REDACTED projection, not a full {@link Order}. Only * `code`, `status`, `totalAmount`, `gameId`, `gameName`, `createdAt`, * `completedAt` and a trimmed `items[]` (`productName`, `amount`, `price`, * `status`) are present. `deliveryData`, CD keys, bearer/redeem codes, * `paymentCode` and `deliveryMeta` are DELIBERATELY absent — a mailbox proof * is not a login, so it never yields redeemable payloads. Render the linked * group as history; to see full detail, sign in to that account. * * READ-ONLY, and {@link Order.readOnly} says so explicitly. Confirming a * mailbox never exposes the other account's balance and every order mutation * (cancel, reorder, review) still refuses anything that is not the viewer's * own order. Do not build write affordances on it. * * Claims EXPIRE (180 days by default) and can be revoked by their owner via * `auth.revokeEmailConfirm()`, so a linked group can disappear between calls. */ linkedAccountEmail?: string | null; /** * `true` on a linked (claim-visible) order, `false` on the viewer's own, * absent when no claim is in play. Gate action buttons on this rather than * comparing emails. */ readOnly?: boolean; } /** * SuperPass in-game handoff: the employee assigned to hand an order item * over in-game. Public Roblox identity only; `profileUrl` is always a * string (possibly `""`). ONE shared shape for both wire surfaces: * {@link OrderItem.superpassEmployee} (REST order item, object or `null`) * and the SSE {@link OrderUpdateEvent} item (object or absent). */ export interface SuperpassEmployee { username: string; profileUrl: string; } export interface OrderItem { id: number; /** Per-item order number in the format `{orderCode}-{seq}` (e.g. `ash-A7X9K2-2`). Customer-facing item id and per-item supplier reference. */ itemCode?: string; /** * Buyer-safe cancellation reason for FAILED items (Russian sentence, * e.g. «Заказ оформлен для другой платформы…»). `null` when the item * is not failed or the supplier gave no reason safe to show a buyer. * Present on customer order GETs starting with API 2026-07-18. */ cancelReason?: string | null; /** * Machine code for the «Исправить и повторить» (fix-and-retry) CTA on a * FAILED item (order-rescue Phase 4, API 2026-07-20 / SDK 0.57). * * Emitted from a strict ALLOWLIST of buyer-FIXABLE codes only — one of: * `"wrong_field"`, `"wrong_form_data"`, `"invalid_codes"`, * `"game_not_linked"`, `"two_factor_required"`, `"wrong_platform"`, * `"confirmation_not_received"`, `"ROBLOX_NOT_IN_GAME"`. Match the exact * string (Vendoria codes are lowercase, Nexus UPPERCASE) to key per-code * copy on the storefront. * * `null` means "NOT buyer-fixable": the item is not failed, or the failure * is an internal/by-design-excluded reason (out-of-balance, price change, * item unavailable, customer-requested, unknown) — raw internal codes are * NEVER serialized. Present on the customer order GETs (`/orders`, * `/orders/:code`, `/orders/payment/:code`, `/profile/orders`). */ cancelReasonCode?: string | null; /** * The offending field name when `cancelReasonCode === "wrong_field"` * (e.g. `"login"`, `"password"`) — parsed from the supplier's * `wrong_field:` code. `null` for every other `cancelReasonCode` * value and whenever `cancelReasonCode` is `null`. Use it to point the * buyer at the exact input to correct before retrying. * * Validated server-side to keep the wire store-neutral: the value is ALWAYS * one of the item's delivery-schema field ids (matching a * `deliveryFields[].key` you can highlight), else `null`. Membership in the * item's schema is REQUIRED — a raw/opaque supplier string, a field not in * the schema, OR a schema-less item (e.g. a custom pack / SuperPass with no * buy-form) all drop to `null` while `cancelReasonCode` stays `"wrong_field"`, * so the fix-and-retry CTA still renders — just without per-field targeting. */ wrongField?: string | null; /** * One-click reorder (API 2026-08-15 / SDK 0.66) — may this FAILED item be * bought again right now, in one tap, from balance? SERVER-decided: the * `POST /orders/:code/items/:itemId/reorder` endpoint runs the SAME * eligibility core, so a rendered button and the endpoint cannot disagree. * Never re-derive it client-side. * * `false` is the all-off default and covers every "no": the item is not * failed, the failure is not fixable, the refund has not settled, it was * already reordered, the product is no longer sellable — or the feature is * simply off for this deployment. Render NO button on `false`. * * Deliberately NOT a synonym for {@link cancelReasonCode}: that one says the * failure is of a buyer-fixable KIND, this one says the buyer may act on it * NOW (money back, product still sellable, not already retried). An item can * carry a fixable code and still be ineligible. * * `undefined` on an API deployment that predates the field — treat exactly * like `false`. */ reorderEligible?: boolean; /** * The code of the order that ALREADY reordered this item, or `null` when it * has not been reordered. Present regardless of {@link reorderEligible} — * indeed an already-reordered item is ineligible BECAUSE of this value, so * this is the field that lets you replace the button with a link to the * replacement order («Повторный заказ: ash-B4K2M9») instead of showing * nothing. The 409 `already_reordered` refusal carries the same code as * `reorder_order_code`. */ reorderedAsOrderCode?: string | null; /** * The product's CURRENT per-unit price in RUB — what the reorder will * actually charge, NOT the {@link price} frozen on this item. The two differ * whenever the USD rate moved, the markup was edited or a promo started * since the original purchase, and quoting the old number would promise a * price the endpoint will not honour. * * RAW, not ceiled: ceil it for display the same way the rest of the * storefront does. `null` whenever the item is not eligible or the price is * unavailable — a price is only meaningful next to a live button, and an * unknown one is never asserted as `0`. */ reorderCurrentPrice?: number | null; /** * The delivery estimate THE BUYER WAS SHOWN AT CHECKOUT, frozen on the item * (API 2026-08-21). A finished, already-localized string in the locale the * checkout request carried — e.g. `"обычно ~53 мин, до 12 ч"`, * `"usually ~1 min, up to 31 min"`, or a legacy constant like `"1-5 мин"`. * Render it as-is; do NOT parse it, and do NOT re-localize it. * * It is a SNAPSHOT, not a live lookup: the measured * supplier×delivery-type stats behind it are recomputed daily over a * trailing window, so the order page would otherwise keep rewriting the * promise the buyer actually received. It therefore may differ from the * `estimatedDelivery` the CATALOG serves for the same SKU today — that is * the intended behaviour, not drift. * * `null` means there is nothing to show: an order placed before the field * existed, or a line with no supplier product to resolve from (SuperPass, * robux-via-pass). `undefined` means the API deployment predates the field * — treat it exactly like `null` and render no ETA row. * * Present on the customer order GETs (`/orders`, `/orders/:code`, * `/orders/payment/:code`, `/profile/orders`). NOT present on the redacted * projection `/profile/orders` uses for LINKED (mailbox-claimed) orders. */ estimatedDelivery?: string | null; productId: number; productName: string; gameName: string; price: number; amount: number; status?: string; supplierStatus?: string; /** * @deprecated NEVER populated on an order REST response. The order * endpoints (`/orders`, `/orders/:code`, `/orders/payment/:code`, * `/profile/orders`) send the delivered keys as {@link cdKeys}; this * singular name is real only on the SSE `order_status` item delta * ({@link OrderUpdateEvent}), which carries the FIRST key as a * convenience. Reading it off a freshly fetched order item yields * `undefined` and the buyer never sees the key they paid for. * * It is still DECLARED, rather than removed, for one concrete reason: * the storefronts build their render type as `OrderItem & {…}` and then * fold SSE deltas onto it, so on that merged object `cdKey` IS populated * while the page stays open during fulfillment — removing it here breaks * their build for a value they legitimately hold. Removal is therefore * gated on giving those clients a home for the merged shape (either a * dedicated type in this package or a local widening on their side); * until then the deprecation is the warning at the point of use. */ cdKey?: string; /** * Delivered key codes, exactly as the API sends them: the RAW `cd_keys` * TEXT column, i.e. a JSON **string** such as `[{"code":"XXXXX-YYYYY"}]` * — several entries when qty > 1, and extra keys per entry (`expireAt`, * …) depending on the supplier. `null` or absent before delivery, and on * items that deliver by account top-up rather than by key. * * It is NOT parsed server-side, and legacy rows may hold a bare key * string instead of JSON, so parse defensively: try `JSON.parse`, accept * both `[{code}]` and `["CODE"]`, and fall back to treating the raw * string as a single code. */ cdKeys?: string | null; /** * The AUTHORITATIVE per-key list for this item: every delivered code, its * stable reference, and the buyer's mark. Present on the customer order * GETs (`/orders`, `/orders/:code`, `/orders/payment/:code`, * `/profile/orders`) since API 2026-08-03 / SDK 0.61 — an EMPTY array on * items that delivered nothing by key. * * Render from THIS, never by parsing {@link cdKeys} yourself. The API owns * the parse precisely so a client cannot disagree with it about how many * keys an item has or what order they are in, and a disagreement there does * not fail loudly — it puts a mark on the WRONG code, which is the one * thing the buyer trusts the mark not to do. Two concrete ways your own * parser will disagree: the live storefront render type is * `OrderItem & {…}` with SSE deltas folded on, so its extractor PREPENDS * the singular {@link cdKey} and shifts every position by one; and it drops * JSON-scalar rows that this list keeps. Do not diff the two lists either — * `code` here is TRIMMED while the raw column carries the untrimmed text. * * Write a mark back with the entry's `keyRef` (never its index, never the * code) through `orders.setKeyState(code, itemId, keyRef, state)`, which * wraps `PUT /orders/:code/items/:itemId/key-state`. `state` is required * BUT nullable: pass an explicit `null` to clear a mark — omitting it on * the wire is a 422, not a clear, so a client that serialises `undefined` * away cannot silently wipe what the buyer set. * * `state` is `null` when the buyer has not marked that key. Optional here * only because an older deployment omits the field entirely; treat * `undefined` as "this API cannot mark keys" and hide the control, which is * a different thing from `[]` ("nothing was delivered by key"). */ keys?: Array<{ /** The delivered code, TRIMMED. Same value the buyer sees rendered. */ code: string; /** * Opaque 64-char identity for this code inside this item — the handle * the write endpoint takes. Not reversible to the code, and not stable * across items: the same code delivered twice gets two refs. */ keyRef: string; /** * The buyer's mark, or `null` when he has not marked this key. Same * {@link OrderKeyState} the write takes, deliberately: one alias for * both directions is what keeps the read and the write from drifting * into two different ideas of what a mark is. */ state: OrderKeyState | null; }>; /** * SuperPass in-game handoff: the employee assigned to hand the item * over in-game (order-page banner). Always present (object or `null`) * on the customer order endpoints (`/orders`, `/orders/:code`, * `/orders/payment/:code`, `/profile/orders`) since API PR #71 / * SDK 0.53 — an object when an employee is assigned, `null` otherwise. * Public Roblox identity only; `profileUrl` is always a string * (possibly `""`). See {@link SuperpassEmployee}. */ superpassEmployee?: SuperpassEmployee | null; /** * Raw delivery values keyed by the supplier's field id (e.g. * `Nickname`, `First-Code`). Prefer `deliveryFields` for rendering — * `deliveryData` is kept for legacy clients that already read it. */ deliveryData: Record; /** * Labelled delivery fields ready for direct rendering on an order * page. The backend resolves labels from the supplier's * `deliveryDataSchema` first and falls back to a canonical * dictionary for common keys (Nickname, Login, Password, First-Code, * Second-Code, …). When a field is sensitive (passwords, * gift-card codes, PINs) `sensitive` is `true` and the storefront * should mask the value behind a "Reveal" toggle. * * Ordering follows the supplier schema first, then any extra keys * that arrived only at fulfillment time. */ deliveryFields?: Array<{ key: string; label: string; value: string; sensitive?: boolean; }>; /** * Server-computed code-action button state (order-rescue program, API * 2026-07-20 / SDK 0.56). The three fields below are always present * TOGETHER on the customer order GETs (`/orders`, `/orders/:code`, * `/orders/payment/:code`, `/profile/orders`) — optional here only * because older API versions omit them. Which action applies; see * {@link CodeAffordance} for the per-value rendering rules. */ codeAffordance?: CodeAffordance; /** * TRUE only when the PRIMARY action button (`client_ready` / * `request_retry`) is pressable right now — FALSE while its cooldown * runs, and always FALSE for `enter_code` (primary action is the input * field, not a button) and `none`. Render the button disabled when * FALSE; the countdown itself comes from the action's `resetIn`. */ affordanceActive?: boolean; /** * TRUE only for the `enter_code` "code didn't work / request a new one" * escape (`orders.requestRetry`) while a code request is open. */ retryAvailable?: boolean; /** * When the buyer last pressed «Я готов» (client-ready) on this item, ISO * timestamp; null if never. Lets the order page render a persistent * "sent — waiting for the seller" state instead of the bare button: * the affordance re-arms after its cooldown, so the button alone cannot * distinguish "never pressed" from "pressed and waiting". * `undefined` = API deployment predates this field (added API 2026-08-12). */ clientReadyAt?: string | null; /** Same, for the request-retry action. Added API 2026-08-12. */ retryRequestedAt?: string | null; /** * The two identifiers needed to submit a code straight from the order * page: pass BOTH to * `profile.submitCode(conversationId, requestId, code)`. * * PRESENT ONLY when `codeAffordance === "enter_code"` — i.e. exactly when * the buyer is being shown the code-input field. In every other state the * server sends `null`, deliberately: an open-but-stale request on a * finished or cancelled order must not be submittable. Do NOT cache the * ids across a refresh and do NOT reuse them once the affordance changes — * re-read them from the item each time. * * `undefined` (key absent) means the API deployment predates this field — * treat it exactly like `null` and fall back to the conversation view. * * Added API 2026-08-05 / SDK 0.63. */ codeRequest?: { requestId: number; conversationId: number; } | null; } /** * Outcome discriminator returned by the supplier-aware cancel endpoint * (`POST /orders/:code/request-cancel`). Mirrors B-CORE's * `processCustomerCancellation` outcomes 1:1. These are the WIRE values * (kebab-case) — the server returns them store-neutrally with no supplier * detail. Renderers should branch on this discriminator. * * - `cancelled` — terminal cancelled, balance already credited. * - `refund-pending` — cancellation requested (contested), refund follows. * - `not-cancellable` — already fulfilled (409), no refund. * - `retry` — authoritative status read failed / 502, await + retry. * - `unsupported` — multi-item order, routed to support (no self-cancel). * - `idempotent` — a cancel for this order is already in progress (no-op). */ export type CustomerCancelOutcome = "cancelled" | "refund-pending" | "not-cancellable" | "retry" | "unsupported" | "idempotent"; /** * Result of `orders.requestCancel`. This is the RAW endpoint body — the * supplier-aware cancel endpoint does NOT use the `{ success, data }` * envelope, so the SDK's auto-unwrap is a no-op and the caller reads * `outcome`/`message` directly. Brand-neutral by contract: `message` never * names the fulfilling party and the body carries no supplier or refund * detail (leak rule G — refund amounts are deliberately not surfaced here). */ export interface CustomerCancelResult { success: boolean; outcome: CustomerCancelOutcome; message?: string; } /** * Which code-action applies to an order item right now — the server-computed * button-state enum on customer order items (see {@link OrderItem.codeAffordance}). * Mirrors the backend's code-affordance matrix (`code-affordance.ts`) exactly: * * - `client_ready` — show the «Я готов принять код» primary button * (`orders.clientReady`). * - `enter_code` — a code request is OPEN: show the code-input field * (submit via `profile.submitCode`, using the ids in * {@link OrderItem.codeRequest} — they are populated on * this value ONLY); `retryAvailable` gates the "code * didn't work / request new" escape * (`orders.requestRetry`). * - `request_retry` — show the request-a-new-code primary button * (`orders.requestRetry`). * - `none` — no code action; render nothing. */ export type CodeAffordance = "client_ready" | "enter_code" | "request_retry" | "none"; /** * Failure discriminator for the item-scoped code-readiness actions * (`orders.clientReady` / `orders.requestRetry`). Wire values map onto the * HTTP statuses the server classifies with — render by `outcome`, NOT by * HTTP code: * * - `stale` (409) — the item's affordance changed since the page render; * refetch the order and re-render the buttons. * - `final` (400) — order/item is in a final state; no action applies. * - `not_found` (404) — no such order item for this user/site. * - `cooldown` (429) — the action's cooldown is still running; `resetIn` * carries the countdown. * - `error` (502) — upstream delivery signal failed; retry later. */ export type CodeReadinessOutcome = "stale" | "final" | "not_found" | "cooldown" | "error"; /** * Result of `orders.clientReady` / `orders.requestRetry`. RAW endpoint body * (no `{ success, data }` envelope — the SDK auto-unwrap is a no-op), a * discriminated union on `success`: * * - success — bare `{ success: true }`; the item's affordance flips * server-side, refetch the order to re-render the buttons. * - failure — `{ success: false, outcome, resetIn? }`; branch on * {@link CodeReadinessOutcome}. `resetIn` = SECONDS until the active * cooldown clears — present on `cooldown` only, and is the ONLY cooldown * signal (raw cooldown timestamps are never serialized on items, by * design) — drive the UI countdown from it. * * Store-neutral by contract: no supplier detail ever appears in the body. */ export type CodeReadinessResult = { success: true; outcome?: never; resetIn?: never; } | { success: false; outcome: CodeReadinessOutcome; resetIn?: number; }; /** * A buyer's mark on ONE delivered code — what he reports back about a key he * already has. `null` is not a member: absence of a mark is the absence of * this value, spelled `| null` at every point of use, so "unmarked" can never * be confused with a third state. * * The one source of truth for both directions: read on * {@link OrderItem.keys}`[].state`, write through `orders.setKeyState`. */ export type OrderKeyState = "activated" | "not_working"; /** * Failure discriminator for `orders.setKeyState`. BOTH values arrive at HTTP * 404 — the status alone tells you nothing, so branch on `outcome`: * * - `not_found` — no such order/item for this user and site. Deliberately * collapsed server-side: "does not exist", "not yours" and * "wrong tenant" are one answer, so this value is NOT * evidence about which. Do not word a message as if it were. * - `unknown_key` — the order and item are yours, but the `keyRef` matches no * code currently delivered on that item. Reachable when the * page is holding a stale key list (the column was rewritten * by a re-delivery). Refetch the order and re-render before * letting the buyer try again. */ export type OrderKeyStateOutcome = "not_found" | "unknown_key"; /** * Result of `orders.setKeyState`. RAW endpoint body (no `{ success, data }` * envelope — the SDK auto-unwrap is a no-op), a discriminated union on * `success`: * * - success — bare `{ success: true }`. Also the answer to a no-op write * (marking what is already marked), so it is safe to send on every tap. * - failure — `{ success: false, outcome }`; branch on * {@link OrderKeyStateOutcome}. * * A malformed request is NOT in this union: it throws. See `orders.setKeyState`. */ export type OrderKeyStateResult = { success: true; outcome?: never; } | { success: false; outcome: OrderKeyStateOutcome; }; /** * Why a one-click reorder was refused (HTTP 409). A FROZEN machine dictionary — * the server never re-words these, so key your i18n on the exact strings: * * - `item_not_failed` — the item is not in a failed state; there is nothing * to retry. Refetch the order: your page is stale. * - `not_fixable` — it failed for a reason a repeat purchase cannot * fix (or one deliberately excluded from this rail). * - `refund_not_settled` — the money for the failed item has not come back * yet. This is a WAIT, not a no: the item becomes * eligible on its own once the refund settles. * - `already_reordered` — it was already bought again; the winner's code * rides along as `reorder_order_code`. Link to that * order rather than offering the button again. * - `product_unavailable` — the product is no longer sellable (deactivated, or * not priceable right now). * * Note `product_unavailable` is a real, durable "gone" — a transient pricing / * settings outage answers 503 `reorder_unavailable` instead, precisely so a * buyer is never told a product is gone because a lookup was down. */ export type ReorderIneligibleReason = "item_not_failed" | "not_fixable" | "refund_not_settled" | "already_reordered" | "product_unavailable"; /** * The new order minted by a successful one-click reorder (HTTP 201). * * `snake_case` on purpose — this is the wire verbatim. The reorder endpoint * speaks snake_case where most of the API speaks camelCase, and the SDK does * not rename fields: a client that renders `order_code` from a type spelled * `orderCode` renders `undefined`, and nothing fails loudly. * * The nested `balance_after` keys are camelCase — also verbatim, also not a * typo: the route passes the balance object through unchanged. */ export interface ReorderCreated { /** Code of the NEW order — link the buyer straight to it. */ order_code: string; order_id: number; /** * What was actually taken from the balance, at TODAY's price — this is the * charge, not a quote. May differ from the `reorderCurrentPrice` you * rendered if the price moved between render and tap. */ charged_amount: number; /** The buyer's balance AFTER the charge — render it without a refetch. */ balance_after: { total: number; permanent: number; bonus: number; }; /** The item this order was reordered FROM. */ source: { order_code: string; item_id: number; }; } /** * Result of `orders.reorderItem`. A discriminated union over the endpoint's * classified outcomes — 201 success plus the three refusals a BUYER can be in * (409 / 422 / 503), which resolve instead of throwing so the caller branches * on the body rather than on an HTTP code it never sees. * * Unlike the other order actions this success arm keeps its `{ success, data }` * envelope (the SDK asks for the raw response), because the failure bodies are * unenveloped and `success` is what tells them apart. * * Branch in THIS order — it is the order the union narrows in: * * ```ts * const res = await gc.orders.reorderItem(code, itemId); * if (res.success) return goToOrder(res.data.order_code); // 201 * if (res.code === "validation_error") return markField(res.field); // 422 * if (res.error === "not_eligible") return explain(res.reason); // 409 * return retryLater(); // 503 * ``` * * NOT in this union, on purpose: * * - **402 insufficient balance THROWS** and carries the platform-wide refusal * body — read it with {@link getInsufficientBalanceDetails}, the same call * a storefront already makes on checkout's 402. The body is built by the one * shared builder on all three rails; giving this method a second way to read * it is exactly the drift that builder exists to prevent. It also fails LOUD: * a forgotten branch on a resolved refusal renders nothing, while a * forgotten `catch` on a money path is impossible to miss. * - **404** — flag-off and "no such item" are ONE answer by design (an * un-launched feature must be indistinguishable from a typo'd path), so * there is nothing to branch on. It throws. * - **400** (bad item id) and **429** (5/min anti-abuse) throw as well: a * caller bug and a throttle are not buyer states. */ export type ReorderItemResult = { success: true; data: ReorderCreated; error?: never; code?: never; reason?: never; field?: never; message?: never; } | { success: false; error: "not_eligible"; reason: ReorderIneligibleReason; /** * The order that already bought this item again — present on * `already_reordered` ONLY, and the same value as the item's * {@link OrderItem.reorderedAsOrderCode}. */ reorder_order_code?: string; data?: never; code?: never; field?: never; message?: never; } | { /** * The corrected delivery data was rejected (422), or the rule could * not be enforced because of an operator misconfiguration (503) — * checkout's own split, and the reason this arm is NOT status-shaped: * both statuses carry this identical body. `field` names the input to * highlight; `error` and `message` hold the same buyer-facing sentence. */ success: false; error: string; code: "validation_error"; field: string; message: string; data?: never; reason?: never; } | { /** * The reorder could not be attempted (503) — our pricing / settings * lookup failed, or the purchase itself blew up. Nothing was charged. * A RETRYABLE state: say "попробуйте позже", never "товар недоступен". */ success: false; error: "reorder_unavailable"; data?: never; code?: never; reason?: never; field?: never; message?: never; }; export interface UserBalance { permanent: number; bonus: number; total: number; level: number; levelDiscount: number; totalSpent: number; bonusDetails: Array<{ id: number; remaining: number; expiresAt: string; }>; } /** * One rung of the loyalty level ladder — used by the "how levels * work" explainer on the profile page. Shape matches what the * server computes from `loyalty_levels` (per-site custom) or the * hardcoded default ladder. */ export interface LevelSystemLevel { level: number; name: string; discountPercent: number; minSpendingUsd: number; minReviews: number; minReferrals: number; } export interface LevelStatus { currentLevel: number; currentDiscount: number; nextLevel: number | null; nextDiscount: number | null; isMaxLevel: boolean; requirements?: { spending?: { current: number; required: number; met: boolean; }; reviews?: { current: number; required: number; met: boolean; }; referrals?: { current: number; required: number; met: boolean; }; }; /** * Highest level in the ladder. Storefronts should use this * instead of hardcoding "15" so per-site custom ladders render * correctly. Always present on responses from SDK 0.13+. */ maxLevel?: number; /** Highest discount percent in the ladder. */ maxDiscount?: number; /** * Full ordered ladder. Use this to render the explainer table * or a progress timeline. Omitted on very old responses. */ allLevels?: LevelSystemLevel[]; } export interface Transaction { id: number; type: string; amount: number; description: string; createdAt: string; } /** * An in-app notification, as returned by `gc.profile.getNotifications()` and * pushed on the user SSE channel as `new_notification`. * * Wire history — read this before touching the field names. Until * gamecore-api 2026-08-01 the endpoint serialized the database row verbatim * (`readAt`, `payload`) while this interface declared `isRead`/`data`, and * nothing translated. Clients reading the typed fields saw `undefined` * forever: bells rendered read notifications as unread and deep links never * fired. Whether a given storefront actually misrendered came down to whether * it happened to carry a local tolerance shim — one live shop did and was * fine, another did not and was not. The API now emits BOTH spellings — the * canonical ones below plus the two deprecated aliases — so a client works * whichever release it meets. */ export interface Notification { id: number; type: string; title: string; body: string; /** * Read-state. Server-derived from the `readAt` timestamp. Absent on the * wire before gamecore-api 2026-08-01, so a client that must also work * against an older API should read `isRead === true || readAt != null`. */ isRead: boolean; /** * Deep-link body — `{ orderId?, orderCode?, conversationId?, threadId?, … }` * depending on `type`. Absent when the notification carries none. A * `conversationId` may be nulled by the server's self-heal when the * conversation no longer belongs to the user; fall through to `orderId`. */ data?: Record; createdAt: string; /** * @deprecated The raw column name behind {@link isRead} — an ISO timestamp * when read, `null` when unread. Emitted alongside the canonical field for * clients written against the pre-2026-08-01 wire; prefer `isRead`. Do not * assume presence: a future release removes it. */ readAt?: string | null; /** @deprecated The raw column name behind {@link data}. Prefer `data`. */ payload?: Record | null; } export interface Favorite { id: number; productId: number; productName: string; productSlug?: string; productIcon?: string | null; price: number; gameId: string; gameSlug?: string; gameName: string; gameIcon?: string | null; categorySlug?: string; createdAt: string; } /** * Review record. Fields are endpoint-specific — this interface is a * union of everything any review endpoint may return, and real-world * shape depends on which route you call: * * - `reviews.listPublic()` → `id`, `rating`, `text`, `authorName`, * `gameName`, `adminReply`, `adminReplyAt`, * `createdAt` * - `reviews.mine()` → `id`, `rating`, `text`, `adminReply`, * `adminReplyAt`, `createdAt`, `order` * - `reviews.create()` → the freshly-created review + `bonus` * * Fields that are not relevant to a given endpoint are `undefined`. Do * not assume any single field is always present. */ export interface Review { id: number; rating: number; /** * The written review, or `null` when the buyer rated without writing * anything (0.65.0, UNRELEASED — package.json is still 0.64.0; the bump and * the publish are a separate, deliberate step). * * Such rating-only reviews USED to be hidden from the public listing while * still counting toward the rating, which is why a game page could show * "5.0 — 1 отзыв" above an empty list; they are now returned and are meant * to render as a compact card — stars, author, date, what was purchased — * with no text block at all. * * Test the VALUE, not the key: `null` is sent explicitly, so `'text' in * review` is true for a review that has none. Never render an empty quote * block, and never `review.text.length` without a null check. * * `undefined` means the deployment predates the field on that endpoint. * `""` is not sent — the API collapses blank text to `null`. */ text?: string | null; createdAt: string; /** * Public display name (author). Returned by `listPublic()`. * * Always a name safe to print: the API resolves it server-side from the * buyer's own display fields and, for a buyer who registered by e-mail and * has neither, from a MASKED form of his address ("ko***@mail.ru"). The raw * address is never sent on any field. `"Покупатель"` is the neutral * fallback when the row carries nothing at all. */ authorName?: string; /** * Public author avatar URL. Always HTTPS or `null` — the backend * normalizes anything else to `null` so it is safe to drop into an * without re-validating on the client. Returned by * `listPublic()` and `getRandom()`. Telegram-imported reviews do * not carry an avatar and will be `null`. */ authorAvatarUrl?: string | null; /** Legacy alias for `authorName`. Prefer `authorName` on new code. */ userName?: string; /** Owner of the review. Returned only to authenticated admins. */ userId?: number; /** Link back to the order the review was left for. */ orderId?: number; orderCode?: string; gameName?: string; /** * Public reply from site support. Rendered under the review body * as "Ответ поддержки". `null` if support has not replied or the * reply was removed. Included in both the public listing and the * authenticated profile reviews list. */ adminReply?: string | null; /** * ISO-8601 timestamp of when the support reply was created or last * updated. `null` when no reply is present. */ adminReplyAt?: string | null; /** * Nested order summary, returned only by `reviews.mine()` so the * profile "My reviews" page can show game + order code without a * second fetch. `null` when the underlying order was deleted. */ order?: { code: string; gameName: string; createdAt: string; } | null; /** * Optional per-dimension ratings (since 0.62.0): how fast delivery was and * how support did, 1–5 each. Both are OPTIONAL for the buyer, so: * * - `null` → no answer for this row. Either the buyer left the * question blank (the column is NULL), or the row is a * Telegram-imported review in the merged public feed, * which has no such column and is serialized with an * explicit `null` rather than an absent key. * - `undefined` → the API predates 0.62.0 and does not send the field at * all. On 0.62.0+ every review-shaped response maps * these columns through, so this case is about API * VERSION, not about which endpoint you called. * * Both mean "no answer" and both must render NOTHING — do not fall back to * 0, and do not feed the value into star arithmetic without a presence * check first (`null <= 5` is `true`). Note that `'deliveryRating' in * review` is NOT that check on the public feed: the key is present and * null on every Telegram-imported row. Test the value, not the key. */ deliveryRating?: number | null; supportRating?: number | null; /** * Purchased order lines behind the review (since 0.64.0): what the buyer * actually bought, e.g. "Music Emote Pack 1". Sent on the public surfaces * only — `reviews.listPublic()` (including the per-game and * product-scoped variants) and `reviews.getRandom()`; `reviews.getMine()` * and the admin listing deliberately do not carry it. Privacy: only the * product name and its amount leave the API — never prices, suppliers or * anything about the buyer. * * - `[]` → items unknown. Either a legacy order whose item rows * never existed, or a Telegram-imported review, which * has no order behind it at all. Render NOTHING — no * empty block, no placeholder. * - `undefined` → the API deployment predates this field and does not * send it. Same rendering rule as `[]`. * * ⚠ `amount: null` means "this amount is MONEY, not a count — render the * name alone". The underlying column is a double that holds a piece count * for most products but a USD sum for wallet top-ups (a real Steam prod * row holds `25.5` — and one holds a WHOLE number, so no client-side * integer heuristic can tell the two apart). The API classifies by the * product's own `amount_type` and nulls the money case server-side. * Even for non-null values, never render `×{amount}` unconditionally: * show a multiplier only when the value is an integer greater than 1 * (`Number.isInteger(amount) && amount > 1`) — a rule a `null` passes * through safely (`Number.isInteger(null)` is `false`), so clients built * against the pre-null shape degrade to the name alone, which is exactly * right. "Pack ×2" is right; "Pack ×25.5" on a customer's screen is the * bug this note exists to prevent. */ items?: { name: string; amount: number | null; }[]; /** Bonus granted for leaving the review, if any. */ bonus?: { amount: number; percent: number; expiresAt: string; }; } export interface ReviewStats { averageRating: number; totalCount: number; distribution?: Record; /** * Per-dimension aggregates (since 0.62.0), over PUBLISHED reviews that * actually carry that dimension — hidden reviews are excluded from every * aggregate, and blank answers are not counted as zeros. Telegram-imported * reviews contribute to `totalCount` but to NEITHER dimension: they have no * delivery/support answers to contribute. * * `deliveryCount` is therefore ≤ `totalCount`, and the two averages are * computed over DIFFERENT populations: never derive one from the other and * never present `deliveryAverage` as "the rating" of the shop. * * READ THE COUNT, NOT THE AVERAGE, to decide whether to render a dimension. * The API coerces an empty `AVG()` to `0`, so "nobody has rated this yet" * arrives as `{ deliveryAverage: 0, deliveryCount: 0 }` — a branch on * `deliveryAverage === null` never fires and renders 0.0 stars, and a * truthiness check cannot tell "unrated" from a genuine average. The * averages are nonetheless declared nullable so a producer that passes the * raw SQL NULL through is a case consumers were already forced to handle. * * `undefined` = this API predates 0.62.0. */ deliveryAverage?: number | null; deliveryCount?: number; supportAverage?: number | null; supportCount?: number; } /** * Response shape returned by `checkout.completeWithBalance()`. * * The post-deduction balance (`newBalance`) is always present on a * successful response — the storefront success page can render * "new balance: X" without a second `profile.getBalance()` fetch * (which is race-prone right after completion). * * The deduction breakdown (`balanceUsed`, `balanceDeduction`) is * present **only on the first successful call** — it reflects what * was actually moved by this request. If the client retries the * endpoint for an already-completed payment (idempotent replay), * those fields are `undefined` because the original breakdown is * not re-derivable from persisted state. The `newBalance` is still * included in that case so the receipt UI has something to render. * * When present, `balanceUsed` equals * `balanceDeduction.fromBonus + balanceDeduction.fromPermanent`. */ export interface CompleteWithBalanceResult { paymentCode: string; status: "completed"; balanceUsed?: number; balanceDeduction?: { fromBonus: number; fromPermanent: number; }; newBalance: { total: number; permanent: number; bonus: number; }; /** * Coins actually SETTLED by this capture, on the coins rail only (GC Coins * purchase spec, task 5.8). Absent on the RUB rails — where `balanceUsed` * is the answer and a `0` here would be a claim, not a number — and absent * on an idempotent replay of an already-completed payment (the settled * total belongs to the call that did the settling). * Available since gamecore-api 2026-07-27. */ coinsUsed?: number; /** * The buyer's coin wallet AFTER the capture, so the receipt renders without * a race-prone second `gc.coins.getMe()`. Present on the coins rail only, * and OMITTED (never zero-filled) when the balance read itself failed — * a missing field means "ask `/coins/me`", a fabricated `0` would say * "your coins are gone". * Available since gamecore-api 2026-07-27. */ newCoinBalance?: { balance: number; reserved: number; }; } /** * Short summary of a conversation as returned by * `profile.getConversations()` — one row per user-owned order * thread. Use the `id` to fetch full messages via * `profile.getConversationMessages(id)`. */ export interface Conversation { id: number; orderId: number; orderCode: string | null; gameName: string | null; orderStatus: string | null; /** Number of unread system / admin messages. User messages don't count. */ unreadCount: number; /** * Preview of the most recent message. `body` is truncated to ~100 * chars. `null` if the conversation has no messages yet. */ lastMessage: { body: string; authorType: "user" | "admin" | "system"; messageType: "text" | "status_change" | "code_requested" | "screenshot_requested" | (string & {}); createdAt: string; } | null; /** Last time either party posted a message, ISO-8601. */ lastMessageAt: string; } /** * Single message inside a conversation. `metadata` is parsed JSON — * system messages may carry status-change payloads, request ids, etc. * Free chat (user-authored `text` messages) is currently disabled — * expect only `admin`, `system`, and request-response messages. */ export interface ConversationMessage { id: number; authorType: "user" | "admin" | "system"; authorId: number | null; messageType: "text" | "status_change" | "code_requested" | "screenshot_requested" | (string & {}); body: string; metadata: Record | null; /** Set when the current viewer marked the message as read. */ readAt: string | null; createdAt: string; } /** * Open supplier-initiated request inside a conversation. The UI * decides which input to show the user based on `requestType`. */ export interface ConversationRequest { id: number; conversationId: number; orderId: number; orderItemId: number; requestType: "code" | "screenshot"; status: "open" | "fulfilled" | "cancelled" | "expired"; requestedAt: string; fulfilledAt: string | null; } /** * Full detail returned by `profile.getConversationMessages(id)`. * The conversation header is included so the UI does not need a * separate fetch to render the page title / order summary. */ export interface ConversationDetail { conversation: { id: number; orderId: number; orderCode: string | null; gameName: string | null; orderStatus: string | null; }; messages: ConversationMessage[]; /** Open supplier requests the user can respond to right now. */ openRequests: ConversationRequest[]; } /** * Web Push subscription payload returned by the browser's * `PushSubscription.toJSON()`. Pass this to * `profile.subscribePush()` to start receiving push notifications * for order status updates and conversation replies. */ export interface WebPushSubscriptionInput { endpoint: string; keys: { p256dh: string; auth: string; }; } /** * Narrow response returned by `reviews.create()`. * * The create endpoint deliberately returns less than a full `Review` * — only the fields the storefront needs to acknowledge the * submission: the new review id, the numeric rating, the text echo, * and the optional bonus grant metadata. * * Callers who need the full review (with `createdAt`, `authorName`, * etc.) should re-fetch via `reviews.getMine()` after the create. */ export interface ReviewCreateResult { id: number; rating: number; /** * Echo of the per-dimension ratings as stored (since 0.62.0) — `null` for * a question the buyer skipped. Optional because this endpoint, unlike its * guest twin, predates 0.62.0 and older deployments omit both keys. */ deliveryRating?: number | null; supportRating?: number | null; text: string | null; bonus: { amount: number; percent: number; expiresAt: string; } | null; } /** * Options form of `reviews.create()` (since 0.62.0) — the only way to send the * optional per-dimension ratings. The positional form * `create(orderId, rating, text?)` is unchanged and still supported. * * An omitted dimension is omitted from the request body, never sent as `null`: * the endpoint validates them as optional integers 1–5, so a literal `null` is * a 422 rather than "no answer". That asymmetry with the RESPONSE (where * `null` is exactly how "no answer" comes back) is why this is its own type * and not a `Partial`. */ export interface ReviewCreateOptions { rating: number; deliveryRating?: number; supportRating?: number; text?: string; } /** * Options for `reviews.createGuest()` (since 0.62.0). Same shape as * {@link ReviewCreateOptions} plus the display name a guest types in — there is * no account to read one from. The server sanitizes it and caps it at 80 * characters; omit it and the storefront renders the neutral fallback * ("Покупатель"), which the API substitutes server-side. */ export interface ReviewGuestCreateOptions extends ReviewCreateOptions { authorName?: string; } /** * Result of `reviews.createGuest()`. Deliberately NOT * {@link ReviewCreateResult}: a guest review earns no bonus (there is no * balance to credit), so `bonus` is optional-and-null-only rather than a * required nullable object. A UI that renders "бонус зачислен" from a truthy * `bonus` therefore cannot be made to lie by this call. * * The two dimensions are REQUIRED here, unlike on every other review shape: * the guest endpoint did not exist before 0.62.0, so there is no older * deployment that could answer without them. `null` still means "skipped". */ export interface ReviewGuestCreateResult { id: number; rating: number; deliveryRating: number | null; supportRating: number | null; text: string | null; bonus?: null; } export interface CouponResult { type: string; value: number; code: string; gameId?: number | null; bonusAmount?: number; } /** * Public-facing coupon entry returned by `gc.coupons.getActiveForGame()`. * Designed for SEO landings (`/promocode/`). `code` is `null` * when the operator chose to advertise the offer without exposing the * code itself (`isPublic === false`); the storefront still renders the * card with `description`, validity window, and discount details. * * Available since gamecore-api 2026-05-01 (Wave 5 #54). */ export interface PublicCoupon { code: string | null; isPublic: boolean; type: "bonus_balance" | "markup_discount"; value: number; gameId: string | null; description: string | null; validFrom: string | null; validUntil: string | null; usagesLeft: number | null; oneTimePerUser: boolean; } /** * Daily-bonus claim status returned by `gc.profile.getDailyBonus()`. * `available` is true when the cooldown has elapsed; `nextRewardAmount` * is a preview of what the user would get on the next claim using * the current streak. Available since gamecore-api 2026-05-01 * (Wave 5 #49). */ export interface DailyBonusStatus { available: boolean; currentStreak: number; longestStreak: number; nextRewardAmount: number; nextAvailableAt: string | null; streakMultiplier: number; lastClaimedAt: string | null; totalClaimedAmount: number; /** * Payout currency. Omitted (undefined) in legacy RUB mode; `"coins"` on * sites with the Coin Rewards module enabled. Available since * gamecore-api 2026-07-22 (Coin Rewards v1). */ currency?: "rub" | "coins"; /** * Display name of the coin (e.g. «жетоны»). Coins mode only. Available * since gamecore-api 2026-07-22 (Coin Rewards v1). */ coinName?: string; } export interface DailyBonusClaimResult { claimedAmount: number; newStreak: number; newBalance: number; nextAvailableAt: string; } /** * Quest catalog entry returned by `gc.profile.getQuests()`. * Status lifecycle: * available → in_progress → completed → claimed * `verificationMode` describes how the quest is checked: * * `auto` — backend computes progress from order/event tables. * * `trust` — storefront sends a click signal (TG/VK subscribe). * * `manual` — admin moderates (e.g. tied to review-proof #56). * * Available since gamecore-api 2026-05-01 (Wave 5 #49). */ export interface Quest { id: number; code: string; type: "daily" | "weekly" | "one_time"; category: string | null; titleRu: string; titleEn: string | null; descriptionRu: string | null; descriptionEn: string | null; /** * Flat ₽ reward. `0` when `rewardType` is `"percent_profit"` — the real * payout is then computed server-side from the triggering order's profit * at claim time, so render `rewardPercent` (e.g. "10% back") instead. */ rewardAmount: number; rewardCurrency: string; /** * Reward model. `"flat"` pays `rewardAmount`; `"percent_profit"` pays * `rewardPercent`% of the order's profit (capped at `rewardCap`). Older * gamecore-api builds omit this — treat a missing value as `"flat"`. * Available since gamecore-api 2026-06-29. */ rewardType?: "flat" | "percent_profit"; /** Percent of order profit paid when `rewardType` is `"percent_profit"`. */ rewardPercent?: number | null; /** Optional ₽ ceiling on a percent reward. */ rewardCap?: number | null; verificationMode: "auto" | "trust" | "manual"; iconUrl: string | null; status: "available" | "in_progress" | "completed" | "claimed"; progressCurrent: number; progressRequired: number; expiresAt: string | null; } export interface QuestCompleteResult { rewardAmount: number; newBalance: number; alreadyClaimed: boolean; } /** * Review-proof submission row returned by `gc.profile.getReviewProofs()` * and surfaced to admins via `gc.admin.reviewProofs.list()`. Available * since gamecore-api 2026-05-01 (Wave 5 #56). * * Status flow: pending → approved (gets `payoutAmount`) or rejected * (gets `moderatorNote`). Already-resolved rows cannot transition again. */ export interface ReviewProof { id: number; siteId: number; userId: number; platform: string; screenshotUrl: string; reviewUrl: string | null; userNote: string | null; status: "pending" | "approved" | "rejected"; payoutAmount: number | null; moderatorNote: string | null; reviewedByUserId: number | null; reviewedAt: string | null; createdAt: string; } export interface ReferralStats { totalReferrals: number; lifetimeEarnings: number; commissionRate: number; commissionBasis: string; links?: ReferralLink[]; } export interface ReferralLink { id: number; code: string; slug?: string | null; label?: string; targetUrl?: string | null; clicks: number; registrations: number; url?: string; createdAt: string; } export interface ReferralCommission { id: number; referredUserId: number; orderId: number; orderAmount: number; commissionAmount: number; commissionPercent: number; createdAt: string; } /** * Date-range aggregated performance metrics for a referrer. * * Commission, transaction and member counts honour the `from` / * `to` range passed to `referrals.getPerformance()`. `totalClicks` * is **lifetime across all of the referrer's links** and is NOT * date-filtered — click events are not individually timestamped in * the current schema. Storefronts should label the clicks metric * accordingly ("all-time clicks" vs. "period earnings"). */ export interface ReferralPerformance { totalCommission: number; totalClicks: number; totalTransactions: number; newMembers: number; } /** * Result of `gc.referrals.transferToBalance()` — the SDK auto-unwraps * the `{success, data}` envelope so callers receive this inner object * directly. `transferredAmount` is the total moved from pending * commissions to permanent balance. */ export interface ReferralTransferResult { transferredAmount: number; transferredCount: number; newPermanentBalance: number; } export interface TopupMethod { type: string; label: string; description?: string; feePercent?: number; /** Fixed part of the processor fee in RUB (API sends `0` by default). */ feeFixed?: number; /** * How the processor fee is applied, surfaced ONLY for display parity with * checkout. Topups are ALWAYS charged net: the user pays exactly the amount * entered and receives that same amount on balance. Do NOT render a * surcharge line on a topup even when `feeMode === "surcharge"` — a * non-included fee is booked as absorbed, not added (see apps/api * topup-routes.ts). */ feeMode?: FeeMode; gatewayType?: string; invoiceCurrency?: string | null; /** * Minimum topup amount in RUB. `null` means the storefront * should use its global minimum (typically 50 RUB). The topup * route enforces this server-side too. */ minAmount?: number | null; /** * Maximum topup amount in RUB. `null` means "use global cap". */ maxAmount?: number | null; /** * Grouping bucket for collapsible UIs. Known values: `"direct"`, * `"p2p"`, `"intl"`. Unknown values should render as a * generic "Other" group. */ group?: "direct" | "p2p" | "intl" | (string & {}) | null; /** * Optional warning text rendered underneath the method * selector (e.g. "Временно нестабильно"). `null` means no note. */ stabilityNote?: string | null; } /** Options for `topup.create()`. */ export interface TopupCreateOptions { /** * `X-Idempotency-Key` for this top-up — FRESH per deliberate attempt, NOT a * stable one (that is `CheckoutCreateOptions.idempotencyKey`'s job, and the * reasoning is inverted here; see below). The route already honours the * header; without one it derives a short-TTL key from user+amount+method, * which collapses a double-click but ALSO collapses a deliberate second * top-up of the same amount made minutes later. * * So a chain that means "invoice this gap now" must pass a FRESH unique key * per deliberate attempt (`"chain:" + crypto.randomUUID()`) — otherwise the * derived-key window can report old money as new. * * "Per ATTEMPT" is the exact granularity, and it is not "per call": generate * the key ONCE when the buyer deliberately asks to top up, RETAIN it, and * reuse that same value for transport retries of THAT attempt — a request * you re-send after a timeout must REPLAY the invoice, not mint a second * one. Rotate only for a separately intended top-up. Never inline a fresh * `crypto.randomUUID()` at the call site: a rerun then silently becomes new * money, which is the very failure the key exists to prevent. * * This is still the mirror image of `CheckoutCreateOptions.idempotencyKey`, * just sharpened: both keys are STABLE across retries of one intent, and the * difference is the size of the intent. Checkout's spans the whole chain * (every re-submit of that purchase replays payment #1); this one dies with * the single «Доплатить» that minted it. * * An EMPTY STRING counts as omitted — the header is sent only for a truthy * key, so `{ idempotencyKey: "" }` sends none and the server derives. That * matters: the route reads the header with `??`, so an empty one would be * kept as the literal key `""` — a single site-wide key every such caller * would collide on. */ idempotencyKey?: string; } export interface TopupResponse { code: string; /** The NET amount the buyer asked to add to the balance. */ amount: number; /** * «Сдача» (integer-charge spec 2026-07-30): the gateway charge is ceiled * to a WHOLE ruble; this remainder is credited to the balance TOGETHER * with `amount` on confirmation, so the balance grows by * `amount + changeAmount`. Always present on new API responses (0 when * the charge landed whole); treat absence as 0. */ changeAmount?: number; paymentUrl?: string; } export interface TopupStatus { code: string; status: string; amount: number; /** * «Сдача» — same field as {@link TopupResponse.changeAmount}, on the * status poll: the credited total is always `amount + changeAmount` * (the server coalesces legacy NULL to 0). Treat absence as 0. */ changeAmount?: number; createdAt?: string; } /** * Gift card voucher. Stored in RUB; the field was historically * called `denomination` in the SDK and `amount_usd` in the DB * column, but both were misnomers — the value has always been RUB * (see Wave 3 #34 audit `tasks/112a-giftcard-currency-audit.md`). * Use `amountRub` on new code; `amountUsd` and `denomination` are * deprecated aliases that echo the same value. */ export interface GiftCard { id: number; code: string; /** Canonical RUB value. Use this on new code. */ amountRub: number; /** * @deprecated Legacy alias echoing `amountRub`. The name was * wrong historically — the value was always RUB despite the * `Usd` suffix. Remove once storefronts upgrade past SDK 0.13.x. */ amountUsd?: number; /** * @deprecated Legacy alias echoing `amountRub`. Kept for * storefronts that were reading this field from typed code; at * runtime the backend never emitted `denomination`, so most * consumers were already getting `undefined`. */ denomination?: number; /** Currency of `amountRub`. Always `"RUB"` for now. */ currency: "RUB"; /** * Remaining balance if the card supports partial redemption. * For active and expired cards this equals `amountRub`. For * redeemed or cancelled cards it is `0`. Partial redemption is * not implemented yet — the field is reserved so storefronts * can start rendering it now without a future breaking change. */ remainingBalance: number; status: "active" | "redeemed" | "expired" | "cancelled" | (string & {}); message?: string | null; /** ISO-8601 expiry timestamp; `null` if the card never expires. */ expiresAt?: string | null; createdAt: string; redeemedAt?: string | null; } export interface Announcement { id: number; title: string; body: string; type?: string; imageUrl?: string | null; createdAt: string; } export interface SiteUIConfig { header: { logoText?: string | null; logoUrl?: string | null; navLinks: Array<{ label: string; href: string; order: number; enabled: boolean; }>; showSearch: boolean; showThemeToggle: boolean; }; footer: { paymentMethods: Array<{ name: string; icon?: string; }>; legalLinks: Array<{ label: string; href: string; }>; socialLinks: Array<{ platform: string; url: string; icon?: string; }>; copyrightText?: string | null; }; trustPills: Array<{ text: string; icon?: string; enabled: boolean; }>; } export interface PaymentMethod { type: string; label: string; description?: string; feePercent?: number; /** * Fixed part of the processor fee in RUB (the API sends `0` when a method * has no fixed component). Combine with `feePercent` for the surcharge * preview: fee = feePercent% of goods + feeFixed ₽. */ feeFixed?: number; /** * How the processor fee is applied. Absent or `"included"` = legacy (no * surcharge line). Preview the surcharge for a method at selection time * with {@link estimateSurcharge}; the EXACT charged amount is in the * checkout response `payment.fee.amount` — the server is the source of * truth. Only `"surcharge"` adds to the total. */ feeMode?: FeeMode; /** * Variant-grouping bucket for collapsible UIs (mirrors * {@link TopupMethod.group}). Known values: `"direct"`, `"p2p"`, `"intl"`. * `null` (the API default) or unknown values render under a generic * "Other" group. */ group?: "direct" | "p2p" | "intl" | (string & {}) | null; /** * Which rail settles the payment. Two values are INTERNAL (no gateway, no * redirect — the checkout completes in-process): `"balance"` (the RUB * wallet) and `"coins"` (the coin wallet; only offered when the site's Coin * Rewards module is on). Everything else is a payment provider * (`"liqpay"`, `"antilopay"`, …) and returns a `paymentUrl` to redirect to. * The `"coins"` rail is available since gamecore-api 2026-07-27. */ gatewayType?: string; /** * Acquirer limits on the CHARGED amount (goods + surcharge), in RUB. * `null`/absent = no limit (older APIs omit both fields entirely). * POST /checkout rejects out-of-range amounts with `code: "min_limit"` / * `"max_limit"` (limit/label/methodId in the error body) — render the * bound next to the method and disable it BEFORE the buyer commits. * Mirrors {@link TopupMethod.minAmount}/{@link TopupMethod.maxAmount}. */ minAmount?: number | null; maxAmount?: number | null; } export interface CatalogSection { id: number; slug: string; label: string; icon: string | null; filterType: string; filterValue: string; sortOrder: number; } export interface SiteStats { totalCustomers: number; totalOrders: number; totalGames: number; avgDeliveryMinutes: number; } export interface AnnouncementBar { enabled: boolean; text: string; link: string | null; } export interface OrderUpdateEvent { orderId: number; orderCode: string; status: string; items?: Array<{ productName: string; status: string; cdKey?: string; /** * SuperPass in-game handoff: assigned employee, pushed when the * fulfillment assigns one mid-tracking. Absent until assigned — * never `null` on the SSE side. See {@link SuperpassEmployee}. */ superpassEmployee?: SuperpassEmployee; }>; } export type WebhookEvent = "order.created" | "order.completed" | "order.failed" | "order.cancelled" | "payment.received" | "payment.failed" | "user.registered" | "catalog.updated"; export interface WebhookPayload { event: WebhookEvent; timestamp: string; siteId: number; sandbox: boolean; data: Record; } /** * Per-site review policy. Storefronts use this to render the review form and * the "leave a review and get +N% cashback" banner on the order success page. * * ⚠ `enabled: false` means the server REFUSES review submissions site-wide — * both `reviews.create` and `reviews.createGuest` answer * `400 "Review submission is currently disabled"`. It does NOT mean "accepted * but unpaid", which is what this docstring used to claim; a storefront that * followed the old wording rendered a working form whose every submit was * guaranteed to fail. Render a short disabled panel instead of the form. * * `percent: 0` — not `enabled: false` — is the state that means "reviews are * welcome, no payout": show the form, omit the bonus copy. */ export interface ReviewPolicy { /** false = the server rejects every submission. Render a disabled panel. */ enabled: boolean; percent: number; expiresInDays: number; requiresText: boolean; /** Server-side ceiling is 2000 — the review body cap on both endpoints. */ minTextLength: number; } /** Single FAQ entry. `gameId: null` means a global (site-wide) entry. */ export interface FaqItem { id: number; question: string; answer: string; gameId: number | null; position: number; } /** Response from `GET /site/faq`. */ export interface FaqListResponse { items: FaqItem[]; } /** A single "please add this game" request submitted by the current user. */ export interface GameRequestItem { id: number; gameName: string; comment: string | null; gameUrl: string | null; status: string; createdAt: string; } /** Inner payload of `GET /profile/game-requests` (envelope auto-unwrapped). */ export interface GameRequestList { items: GameRequestItem[]; } /** Aggregated profile counters for header / sidebar widgets. */ export interface ProfileSummary { cartCount: number; unreadNotifications: number; pendingReviews: number; hasOpenSupportThread: boolean; permanentBalance: number; bonusBalance: number; } /** Status of a checkout payment plus its child orders. Public endpoint. */ export interface CheckoutStatus { payment: { code: string; status: string; totalAmount: number; /** * «Сдача» (integer-charge spec 2026-07-30): the whole-ruble ceil * remainder that lands on the buyer's balance at fulfillment. `0` on * the coins rail and for legacy rows. This poll is where a * «сдача зачислена» line appears first. Treat absence as 0. */ change?: number; gatewayPaymentUrl: string | null; createdAt: string; completedAt: string | null; }; orders: Array<{ code: string; status: string; gameName: string; totalAmount: number; }>; /** * Is this payment's buyer still an auto-provisioned guest shell — a `users` * row created at checkout that has never been claimed (no password, no * social login, e-mail not verified)? Storefronts use it to honestly show * the «мы создали аккаунт» notice on the success page. * * THREE states, and the difference between the last two matters: * • `true` — unclaimed shell. The ONLY state that may render the * notice / offer to claim the account. * • `false` — definitively NOT one: a registered or already-claimed * account, a legacy order from before guest provisioning, * or no single buyer behind the payment. The server * looked and answered. * • key ABSENT — the server could NOT determine it (the lookup failed; * the flag is cosmetic and fails open rather than turning * a payment view into a 500). It is "unknown", not "no". * * For a storefront `false` and absent are the same instruction — claim * nothing — so gate rendering on `=== true` and never on `!== false` * (which would show the notice to a registered buyer on any lookup blip). */ guestAccount?: boolean; } export type CmsArticleType = "news" | "promo" | "footer_block" | "guide"; /** * Locale a CMS article is written in. Widened past ru/en for multilingual * guides. "ru" is the DEFAULT (an omitted `locale` option resolves to it * server-side, as does any value the server doesn't recognise) — but it is * NOT a fallback: `getArticle()` matches the requested locale exactly and * throws 404 when that translation is missing. */ export type CmsArticleLocale = "ru" | "en" | "es" | "pt-br" | "fa"; export type CmsArticleStatus = "draft" | "published" | "archived"; /** * Game linkage kind for CMS `guide` articles (added SDK 0.48.0, W5 SEO * ContentPipe guides). `superpass` = SuperPass (Roblox) game, * `canonical` = regular `canonical_games` catalog entry. Non-guide * article types (news/promo/footer_block) are never entity-linked. */ export type CmsArticleEntityKind = "superpass" | "canonical"; /** * Summary projection returned by `gc.site.getArticles()` — list pages * stay cheap by omitting the article body. Storefronts call * `gc.site.getArticle(type, slug)` to fetch the full body when the * user opens the article. * * Date fields (`publishedAt`) are ISO-8601 strings on the wire. */ export interface CmsArticleSummary { id: number; type: CmsArticleType; locale: CmsArticleLocale; slug: string; title: string; summary: string | null; coverImage: string | null; publishedAt: string | null; sortOrder: number; /** Game linkage kind, present on `type: "guide"` articles. `null` otherwise. */ entityKind: CmsArticleEntityKind | null; /** Linked game's id (superpass or canonical_games, per `entityKind`). `null` when unlinked. */ entityId: number | null; /** * The linked game's catalog slug, resolved server-side from * `entityKind`/`entityId` so storefronts can link a guide straight * to its game page without a second lookup. `null` when the article * has no game linkage, or the linked game could not be resolved * (e.g. deleted/unpublished since the guide was generated). */ entitySlug: string | null; /** * MGD per-site slug of the linked game — the url THIS tenant publishes it * under. Absent (not `null`) when the tenant has no per-site page for it, * when it would equal `entitySlug`, and ALWAYS for `entityKind: * "superpass"` (a different id space, deliberately untouched). * * Link the «к игре» button with `entitySiteSlug ?? entitySlug`; 🔴 never * send it back as an identity or a write value — see {@link Game.siteSlug}. * * Optional, NOT nullable: the server omits the key rather than emitting * `null` (both spreads drop it), and typing a null the wire never carries * would force every caller into a second guard. `entitySlug` beside it IS * nullable — that one really can come back null for an unresolved link. */ entitySiteSlug?: string; } /** * Full CMS article shape returned by `gc.site.getArticle()`. The * `locale` field reflects the article's actual language. Since SDK * 0.69.0 the server resolves the requested locale EXACTLY — there is * no RU fallback any more: requesting `en` when the article exists * only in `ru` yields 404/null (hreflang correctness — translated * URLs must never serve another language's body). * * Date fields are ISO-8601 strings. * * Corrected in SDK 0.48.0: earlier versions of this type wrongly * claimed `siteId`, `status`, `createdAt`, `updatedAt` — the public * detail endpoint (`GET /site/cms/:type/:slug`) never actually returns * those (they're admin-only fields; see `PUBLIC_DETAIL_COLS` in * `apps/api/src/services/cms.ts`). Those fields were always * `undefined` at runtime despite being typed as required — this is a * type-accuracy fix, not a behavior change. */ export interface CmsArticle { id: number; type: CmsArticleType; locale: CmsArticleLocale; slug: string; title: string; summary: string | null; body: string; coverImage: string | null; /** * SEO meta overrides (API migration 0212). `null` → storefront should * fall back to `title` / `summary`. Today populated by legacy-CMS * migrations whose hand-written meta tags differ from the visible copy. */ metaTitle: string | null; metaDescription: string | null; publishedAt: string | null; sortOrder: number; /** Game linkage kind, present on `type: "guide"` articles. `null` otherwise. */ entityKind: CmsArticleEntityKind | null; /** Linked game's id (superpass or canonical_games, per `entityKind`). `null` when unlinked. */ entityId: number | null; /** Linked game's catalog slug, resolved server-side. `null` when unlinked/unresolved. */ entitySlug: string | null; /** * MGD per-site slug of the linked game — same rule as on * {@link CmsArticleSummary.entitySiteSlug}: absent (not `null`) without a * per-site page, absent when equal to `entitySlug`, never emitted for * superpass links, and 🔴 never valid as an identity or a write value. * Optional, not nullable — the declaration matches that sentence. */ entitySiteSlug?: string; } /** Response from `GET /site/cms/:type`. */ export interface CmsArticleListResponse { items: CmsArticleSummary[]; total: number; } /** Response from `GET /site/cms/:type/:slug`. */ export interface CmsArticleResponse { article: CmsArticle; } /** * Letter-bucket histogram returned by `catalog.getLetterCounts()`. * Special key `"0-9"` is the bucket for names that start with a * digit; every other key is a single uppercase letter (Cyrillic or * Latin) reflecting whatever the visible catalog actually contains. */ export type CatalogLetterCounts = Record; /** * One game row in the bulk sitemap feed returned by * `catalog.getSitemapRoutes()`. Bundles the canonical slug, * lastModified signal, plus all approved categories + products * visible to the site so the storefront can build sitemap URLs * without a second per-game request. * * `categories[].slug` is the storefront's ROUTE slug (`slug || * slugify(name)`), so each emitted sitemap category URL resolves to a live * category page. It deliberately does NOT use the dedup CLASSIFIER slug * (currency / other / battle_pass): that slug is non-unique (distinct * categories collide on it) and the storefront routes category pages by * name-slug, so emitting the classifier both merges distinct categories and * 404s. `products[].categorySlug`, by contrast, DOES stay the dedup-remapped * slug — the product page resolves by `productId` and never validates the * category segment, so it is cosmetic there. The two slugs intentionally * diverge. * `products[].price` is RUB-rounded to two decimals via the live * USD rate + site markup; `null` only if pricing context was not * resolvable (the assembly loop does not currently emit `null`, * but the contract leaves room for it). */ export interface CatalogSitemapGame { slug: string; updatedAt: string; productCount: number; categories: Array<{ id: number; name: string; slug: string; productCount: number; }>; products: Array<{ id: number; name: string; categoryId: number; categorySlug: string; price: number | null; updatedAt: string; }>; } /** Paginated wrapper for `catalog.getSitemapRoutes()`. */ export interface CatalogSitemapRoutesResponse { games: CatalogSitemapGame[]; total: number; page: number; limit: number; } /** * Lightweight lastModified entry returned by `seo.getSitemapData()`. * Storefront `app/sitemap.ts` reads `updatedAt` to stamp accurate * `lastModified` on each canonical-game URL — without this signal * sitemaps had to stamp `new Date()` per page and wasted crawl * budget on untouched URLs. */ export interface SeoSitemapEntry { /** * The slug to PUBLISH for this game — an address, not an identity. * * 🔴 Since 0.71.0 this is the tenant's OWN slug on a site with per-site * catalog pages (`site_catalog_pages`), and the platform slug everywhere * else. No new field and no shape change: the same key now answers "which * url does this site publish", which is what a sitemap needs. Emit it * verbatim; do not resolve it back to a game id — the write paths key on * the platform slug, which this feed no longer promises to carry. */ slug: string; updatedAt: string; } /** * Support thread state returned by `support.getThread()` — the * persistent conversation between an authenticated user and the * site operators. One thread per (user × site); calling `getThread` * creates it on first invocation. Requires the `chat` site module * to be enabled; if disabled the API returns 404. * * Shape mirrors `support_threads` row + the computed `unreadCount`. * Date columns are serialised over JSON as ISO-8601 strings. * * `aiMode`: * - `manual` — operator-only, no AI involvement * - `assist` — AI drafts replies for operator review (drafts not * surfaced to user via this endpoint) * - `auto` — AI replies directly; `callAdmin()` flips * `aiHandoffRequired=true` + sets `aiPausedUntil` */ export interface SupportThread { id: number; siteId: number; userId: number; status: "open" | "closed" | (string & {}); lastMessageAt: string; userLastReadAt: string | null; adminLastReadAt: string | null; aiMode: "manual" | "assist" | "auto" | (string & {}); aiEnabled: boolean; aiPausedUntil: string | null; aiLastReplyAt: string | null; aiHandoffRequired: boolean; aiContext: string | null; createdAt: string; unreadCount: number; } /** * Single message in a support thread. `authorType` is "user" when * the storefront submitted it, "admin" when staff replied via * site-admin, "system" for automated transitions (thread closed, * admin handoff, AI takeover, etc). * * `messageType: "order_card"` carries a JSON-encoded `metadata` * field with `{ orderCode, orderStatus, gameName, totalAmount, * orderUrl }` so the storefront can render an inline order * reference inside the chat transcript. */ export interface SupportMessage { id: number; threadId: number; siteId: number; authorType: "user" | "admin" | "system" | (string & {}); authorId: number | null; messageType: "text" | "image" | "order_card" | (string & {}); body: string; imageUrl: string | null; metadata: string | null; createdAt: string; } /** * Result of `support.callAdmin()` — manual handoff that flags the * thread for an operator and pauses any AI auto-reply window. Same * shape returned on idempotent re-calls (already in handoff). */ export interface SupportCallAdminResult { threadId: number; aiHandoffRequired: boolean; aiPausedUntil: string | null; } /** * Topic taxonomy for the public guest support form. Mirrors the DB * CHECK constraint on `public_support_tickets.topic`. */ export type PublicSupportTopic = "payment" | "delivery" | "refund" | "other"; /** * Body for `support.submitPublic()` — the guest support form. Send * EXACTLY ONE of `email` / `telegram`; the validator rejects "both" * and "neither" with HTTP 400. `honeypot` is a hidden form field — * leave undefined / empty. Optional `orderCode` is a free-form * 3-32 char string; the operator decides on review whether it maps * to a real order. */ export interface PublicSupportRequest { topic: PublicSupportTopic; subject: string; body: string; email?: string; telegram?: string; orderCode?: string; honeypot?: string; } /** Response from `support.submitPublic()`. */ export interface PublicSupportResponse { ticketCode: string; status: "received" | (string & {}); id: number | null; } /** * One row of the public "recent purchases" feed returned by * `site.getRecentPurchases()`. Drives the homepage social-proof * ticker. Privacy-shaped server-side: `userName` is either the * buyer's first name, a masked username (`vi***`), or the literal * "Покупатель" — never the full username/email. */ export interface RecentPurchase { userName: string; gameName: string; productName: string; completedAt: string; } /** * SuperPass hit surfaced inside `catalog.search()`'s `superpasses` array — * a separate Meilisearch index from games/products, merged in for sites * that opt into SuperPass search. See `SearchResult.superpasses`. */ export interface SuperpassSearchHit { id: number; slug: string; name: string; nameEn: string | null; icon: string | null; } /** Game row from `superpasses.list()` — Roblox SuperPass catalog. */ export interface SuperpassGameSummary { id: number; slug: string; name: string; nameEn: string | null; image: string | null; rate: number | null; onlinePlayers: number | null; tags: string[]; superpassOrders: number; passCount: number; inGame: boolean; /** * Active search aliases for this game (e.g. alternate spellings, * romanizations), fed into client-side catalog filtering on the * storefront. The current API always sends an array (`[]` when a game * has no aliases); optional here only because older API deployments * that predate this field omit it entirely. */ aliases?: string[]; } /** Single pass row inside `superpasses.get(slug).passes`. */ export interface SuperpassItem { id: number; nexusProductId: number | null; name: string; icon: string | null; basePriceUsd: number; priceRub: number; productType: string | null; amountType: unknown; deliveryDataSchema: unknown; } /** Game detail block inside `superpasses.get(slug)`. */ export interface SuperpassGameDetail { id: number; slug: string; name: string; nameEn: string | null; image: string | null; description: string | null; descriptionEn: string | null; rate: number | null; onlinePlayers: number | null; tags: string[]; robloxUri: string | null; inGame: boolean; superpassOrders: number; isActive: boolean; } /** Related-game card surfaced under `superpasses.get(slug).relatedGames`. */ export interface SuperpassRelatedGame { id: number; slug: string; name: string; image: string | null; passCount: number; } /** * Per-site SEO copy for a SuperPass game page. Reuses the platform's * generic `seo_content` storage under `page_type: "superpass-game"`, * resolved per-locale via `superpasses.get(slug, locale)`. Texts are * written on demand by content managers — `null` on `superpasses.get()` * is the normal state until copy exists for that game/locale. */ export interface SuperpassSeoContent { title: string | null; h1: string | null; metaDescription: string | null; ogTitle: string | null; ogDescription: string | null; intro: string | null; content: string | null; contentSections: Array<{ heading: string; level?: number; body: string; }> | null; faq: Array<{ question: string; answer: string; }> | null; /** * Internal-links block rendered at the bottom of the page (HTML). * Mirrors `SeoContent.footerText` on game pages. Absent on API * deployments predating the superpass mapping fix. */ footerText: string | null; noindex: boolean; } /** Full payload returned by `superpasses.get(slug)`. */ export interface SuperpassGameResponse { game: SuperpassGameDetail; passes: SuperpassItem[]; relatedGames: SuperpassRelatedGame[]; /** * Per-site, per-locale SEO copy for this game's page — `null` when no * copy has been written yet for the resolved locale (the default state * for most games). Absent entirely only on API deployments that predate * this field. */ seo?: SuperpassSeoContent | null; } /** * Roblox username verification result from `superpasses.verifyUser()`. * Rate-limited (10 req/min); the SDK surfaces the API's 404 / 400 as * a thrown `GameCoreError`, so a returned object always means * success. */ export interface SuperpassUserVerification { id: number | string; name: string; displayName: string; } /** * Lifecycle of a custom pack request. Forward flow: * `submitted` → (vendor prices) `priced` → (buyer pays) `awaiting_payment` * → `paid` → `purchasing` → `purchased` → `completed`. Terminal branches: * `failed` (vendor purchase failed → auto-refund), `declined` (vendor * refused to price), `expired` (payment deadline passed), `cancelled` * (buyer cancel). The `(string & {})` arm keeps older SDKs tolerant of * statuses added server-side later. */ export type PackRequestStatus = "submitted" | "priced" | "awaiting_payment" | "paid" | "purchasing" | "purchased" | "completed" | "failed" | "declined" | "expired" | "cancelled" | (string & {}); /** * A game the buyer can request a custom pack for, from * `packRequests.listGames()`. `id` is the SUPPLIER game id — pass it back * verbatim to `uploadImage()` / `create()`; it is NOT a catalog game id. * Name/slug/icon come from the mapped canonical (storefront-facing) game. */ export interface PackRequestGame { id: number; name: string; slug: string; icon: string | null; } /** * One input field of a vendor delivery form. Persisted VERBATIM from the * vendor's pricing payload, so unknown `type` values may appear — render * defensively. `regex` (text), `options` (select) and `min`/`max`/`step` * (number) are enforced server-side on `pay()`; mirroring them client-side * just saves the buyer a round-trip. */ export interface PackRequestFormField { id: string; type: "text" | "select" | "number" | "image" | (string & {}); label: string; required: boolean; /** Allowed values for `type: "select"`. */ options?: string[]; /** Validation pattern for `type: "text"`. */ regex?: string; min?: number; max?: number; step?: number; } /** * A vendor delivery form attached to a priced pack request. The buyer * picks ONE form and fills its fields; the choice goes to `pay()` as * `formId` + `deliveryData` (field id → value). */ export interface PackRequestForm { id: number; name?: string; fields: PackRequestFormField[]; /** Vendor's human instruction for the buyer, when present. */ instruction?: string | null; hasGooglePrompt?: boolean; } /** * Buyer-facing view of a pack request, returned by `packRequests.list()`, * `get()` and `create()`. Money fields appear once the vendor prices the * request: `priceRub` is the FINAL sale price (RUB, markup applied * server-side — never recompute it), `payDeadline` is the ISO timestamp * after which `pay()` answers 410, `forms` are the vendor delivery forms * to choose from. `formId` is set after a successful `pay()`. * * After `pay()`: `paymentUrl` (and its `paymentCode`) let a reloaded * request page resume the checkout without re-POSTing `pay()` — but ONLY * while the invoice is legitimately payable: the server returns them * solely when `status === 'awaiting_payment'`, the payment is still * pending AND `payDeadline` has not elapsed; on terminal requests or past * the deadline both are `null` (render the expired/terminal state, never * a pay button). `orderCode` appears once an invoice was minted and is * the key for post-payment tracking (`orders.get()` / SSE). */ export interface PackRequest { id: number; status: PackRequestStatus; /** Supplier game id — matches `PackRequestGame.id`. */ supplierGameId: number; /** The buyer's own comment from `create()`. */ comment: string | null; priceRub: number | null; payDeadline: string | null; forms: PackRequestForm[] | null; formId: number | null; /** Payment code of the LIVE pending invoice; null otherwise. */ paymentCode: string | null; /** Gateway checkout URL while the invoice is pending; null otherwise. */ paymentUrl: string | null; /** Order code once an invoice was minted — fulfillment tracking key. */ orderCode: string | null; createdAt: string; pricedAt: string | null; } /** * Result of `packRequests.pay()` — the minted invoice. `total` is the * GROSS charge (goods + surcharge where the method's fee mode applies); * `fee` is the authoritative breakdown (same contract as checkout — never * re-add `fee.amount` to `total`). `paymentUrl` is the gateway redirect, * or `null` for balance payments, which complete instantly * (`status: "paid"`). `orderCode` tracks fulfillment via the standard * orders surface (`orders.get()` / SSE). */ export interface PackRequestPayResponse { paymentCode: string; paymentUrl: string | null; total: number; fee: CheckoutFee; orderCode: string; status: "awaiting_payment" | "paid" | (string & {}); } /** Standard API response wrapper. Most endpoints return { success, data } or { success, error }. */ export interface ApiResponse { success: boolean; data?: T; error?: string; } export interface PaginatedResponse { data: T[]; pagination: { limit: number; offset: number; total: number; hasMore: boolean; }; } /** Catalog games use page-based pagination (page 1, 2, ...) */ export interface PagedGamesResponse { data: Game[]; pagination: { page: number; limit: number; total: number; totalPages: number; hasMore: boolean; }; } /** * Coin wallet snapshot returned by `gc.coins.getMe()`. Coin Rewards is a * default-OFF per-site module — endpoints 404 on sites without it. * Available since gamecore-api 2026-07-22 (Coin Rewards v1). */ export interface CoinWallet { balance: number; reserved: number; expiringSoon: { amount: number; expiresAt: string; } | null; coinName: string; icon: string | null; /** * ISO code the site's coin is DENOMINATED in (e.g. `"UAH"`). Coins are 1:1 * with this currency, so it is also the unit of `minPurchase`/`maxPurchase` * and of `gc.coins.purchase({ amount })`. NOT optional: `/coins/me` merges * the site's coin config over platform defaults, so a site that never set * one still answers `"RUB"`. * Available since gamecore-api 2026-07-27. */ coinCurrency: string; /** * Smallest / largest `amount` `gc.coins.purchase()` accepts, in coins. * Render the form's bounds from these — the route answers 400 * `amount_out_of_range` (with `details.min`/`max`/`currency`) otherwise. * Available since gamecore-api 2026-07-27. */ minPurchase: number; maxPurchase: number; claim: DailyBonusStatus; } /** * Result of `gc.coins.purchase()` — an invoice-first coin purchase. The row is * written BEFORE the gateway call, so the `code` is valid to poll with * {@link GameCoreClient.coins.getPurchase} the instant this resolves. Coins are * credited by the payment webhook, NEVER by this call. * Available since gamecore-api 2026-07-27. */ export interface CoinPurchaseResult { /** `C-XXXXXX` — the purchase code, also the gateway's order id. */ code: string; /** Coins bought == units of {@link CoinPurchaseResult.coinCurrency}. */ amount: number; coinCurrency: string; /** The site's brand name for its coin (e.g. "GC Coins"). */ coinName: string; /** * Gateway redirect target. The API sends it on every success today; typed * optional so the storefront keeps a guard instead of navigating to * `undefined` if a future rail ever completes without a redirect (mirrors * {@link TopupResponse.paymentUrl}). */ paymentUrl?: string; } /** * Status of a coin purchase, returned by `gc.coins.getPurchase(code)` — the * gateway-return screen polls this until it leaves `pending`/`processing`. * A foreign or unknown code answers 404 `purchase_not_found` (never 403 — a * prober must not learn that a code exists). * Available since gamecore-api 2026-07-27. */ export interface CoinPurchaseStatus { code: string; /** Coins bought — the ROW's snapshot, not today's config. */ amount: number; /** Denomination of the ROW: a UAH purchase stays UAH after a config switch. */ coinCurrency: string; /** * The site's name for its coin — the gateway-return page has no wallet * loaded and renders `formatCoins(amount, coinName)` from this. Comes from * SITE CONFIG (a brand name is config, not a per-purchase snapshot). */ coinName: string; /** * `credit_failed` means the money WAS taken but the coin credit did not * land — it is an operator alert, not a buyer-fixable state. Never render * it as "payment failed". */ status: "pending" | "processing" | "completed" | "credit_failed" | "failed"; createdAt: string; } /** * One settled movement in the caller's coin journal, from * `gc.coins.getTransactions()`. Newest first, offset-paginated. * Available since gamecore-api 2026-07-27. */ export interface CoinTransaction { id: number; /** * `claim` | `accrual` | `purchase` | `refund` | `spend` | `expire` | * `admin_adjust`. The `reserve` / `release` halves of a checkout HOLD are * deliberately NOT returned — rendering them would show one checkout as two * debits plus a phantom credit. String-typed because the server may add * movement kinds without an SDK bump. */ type: "claim" | "accrual" | "purchase" | "refund" | "spend" | "expire" | "admin_adjust" | (string & {}); /** Signed: credits positive, debits negative. Units are coins. */ amount: number; description: string | null; createdAt: string; } /** * Reward catalog entry returned by `gc.coins.getRewards()`. `available` / * `unavailableReason` are only present when the request is authenticated — * an anonymous catalog listing carries the catalog fields only. * Available since gamecore-api 2026-07-22 (Coin Rewards v1). */ export interface CoinReward { id: number; kind: "balance_credit" | "coupon" | "product_prize"; title: string; titleTranslations: Record | null; icon: string | null; costCoins: number; available?: boolean; unavailableReason?: string; } /** * Result of `gc.coins.redeem()`. Which fields are populated depends on the * reward kind: `couponCode` for `coupon`, `orderId`/`orderCode` for * `product_prize`, `balanceAfter` for `balance_credit`. * Available since gamecore-api 2026-07-22 (Coin Rewards v1). */ export interface CoinRedeemResult { redemptionId: number; couponCode?: string; orderId?: number; orderCode?: string; balanceAfter?: number; } /** * A single entry in the caller's own redemption history, returned by * `gc.coins.getRedemptions()` (latest 50, newest first). * Available since gamecore-api 2026-07-22 (Coin Rewards v1). */ export interface CoinRedemption { id: number; rewardKind: CoinReward["kind"]; rewardTitle: string; costCoins: number; status: "pending" | "completed" | "failed"; createdAt: string; completedAt: string | null; } /** * Pseudonymized entry in the public winners feed returned by * `gc.coins.getWinners()`. `name` is server-pseudonymized (e.g. "Иван П." or * "Покупатель") — never a full name, email, or user id. May be empty/404 when * the site has disabled the winners feed. * Available since gamecore-api 2026-07-22 (Coin Rewards v1). */ export interface CoinWinner { name: string; rewardKind: CoinReward["kind"]; rewardTitle: string; costCoins: number; completedAt: string | null; } export declare class GameCoreError extends Error { status: number; code?: string; /** * Full parsed JSON error body from the API, exactly as received. Machine * fields — `limit`/`currency`/`label`/`methodId` on min/max limit errors * (#62.2/#62.3), `requiredAmount`/`currentBalance` on "insufficient funds" — * live here instead of being regex-scraped out of the human `message`. * 429 bodies ARE parsed too: differentiated codes * (`upload_quota_exceeded` / `rate_limited` / `queue_full`, …) surface * via `code` with the full body here, `RATE_LIMITED` only as the * no-body fallback. `undefined` when the body was not JSON, or when the * SDK synthesized the error (e.g. the 401 short-circuit, which never * reads the body). */ details?: Record; constructor(message: string, status: number, code?: string, details?: Record); } /** * Runtime + type guard for {@link GameCoreError}. Prefer this over a bare * `instanceof` in storefront code: it narrows an `unknown` caught value and * reads clearly at the call site. */ export declare function isGameCoreError(e: unknown): e is GameCoreError; /** * Typed shape of {@link GameCoreError.details} for min/max payment-limit * errors. Mirrors the API's `MethodAmountErrorFields`. `limit` is in RUB — * the storefront converts to its display currency itself; `label` is the RU * config label, re-localizable via `methodId`. NOTE (Block B): min/max are * measured against the GROSS charge (goods + surcharge), not the visible * goods subtotal. */ export interface MethodAmountLimitDetails { code: "min_limit" | "max_limit"; limit: number; currency: "RUB"; label: string; methodId: string; } /** * Narrow an unknown error to a min/max payment-limit {@link GameCoreError} * whose `details` is a populated {@link MethodAmountLimitDetails}. Lets a * storefront render a typed "min N ₽ / max N ₽" branch without regex on prose. */ export declare function isMethodAmountLimitError(e: unknown): e is GameCoreError & { details: MethodAmountLimitDetails; }; /** * Machine fields of the `insufficient_coins` refusal on the coins rail. * * Emitted on BOTH checkout paths, with the SAME three fields — wire one * shortfall handler and reuse it: * - `POST /checkout` → **409**, when the hold is taken at cart creation. * - `POST /checkout/:code/complete` → **402**, defense-in-depth for the same * shortfall at capture time (near-unreachable today, since the hold already * exists by then — but it is typed, so handle it). * * `required` is the coin price of THIS cart; `available` is the buyer's * spendable balance and is OPTIONAL — the server OMITS it when its own balance * read failed, because a fabricated `0` would tell the buyer their coins are * gone. Absent means "unknown → refetch `gc.coins.getMe()`". * Available since gamecore-api 2026-07-27. */ export interface InsufficientCoinsDetails { required: number; available?: number; coinCurrency: string; } /** * Machine fields of the 409 `coin_price_changed` refusal from `POST /checkout` * — the cart's coin price moved away from the `expectedCoinAmount` the * storefront displayed. `coinAmount` is the CURRENT price: re-render it, let * the buyer confirm, then retry. * Available since gamecore-api 2026-07-27. */ export interface CoinPriceChangedDetails { coinAmount: number; coinCurrency: string; } /** * Narrow a caught checkout failure to the `insufficient_coins` refusal and * return its {@link InsufficientCoinsDetails}, else `null`. Lets a storefront * render "not enough coins — buy N more" without regex on prose. * * Status-agnostic: it matches the tag, not the code, so it reads BOTH the 409 * from `POST /checkout` and the 402 twin from `POST /checkout/:code/complete`. * Available since gamecore-api 2026-07-27. */ export declare function getInsufficientCoinsDetails(e: unknown): InsufficientCoinsDetails | null; /** * Narrow a caught checkout failure to the 409 `coin_price_changed` refusal and * return its {@link CoinPriceChangedDetails}, else `null`. * Available since gamecore-api 2026-07-27. */ export declare function getCoinPriceChangedDetails(e: unknown): CoinPriceChangedDetails | null; /** * Machine fields of the 402 «Недостаточно средств на балансе» refusal — the * balance-rail twin of {@link InsufficientCoinsDetails}. Emitted by BOTH ends * of the balance rail (`POST /checkout` fail-fast and * `POST /checkout/:code/complete`) with one shared body, so a storefront * parses ONE shape wherever the buyer is refused. * * `spendableTotal` is the number the refusal was actually DECIDED on: the * buyer's permanent balance plus only the bonus that may pay for THIS order * (owner rule: bonuses cover at most part of the margin). It can be far below * `balanceTotal` — rendering the raw wallet as "available" is exactly the * self-contradiction (доступно 279.59 ₽ … недостаточно) this shape replaces. * * `shortfallAmount` is the topup that clears the gap, in WHOLE rubles and * never below 1 (topups are integer-only; a 0.16 ₽ gap reported as 0 would * offer the buyer a no-op topup). Wire a «Пополнить на N ₽» button to it * verbatim. `shortfall` is the same gap at kopeck precision, for display next * to the two balances. */ export interface InsufficientBalanceDetails { requiredAmount: number; /** Cap-aware spendable the refusal was decided on («доступно к оплате»). */ spendableTotal: number; /** Whole-ruble topup that clears the gap; always ≥ 1. */ shortfallAmount: number; /** The raw wallet total («всего на балансе»), when the server sent it. */ balanceTotal?: number; /** Kopeck-precision gap: requiredAmount − spendableTotal. */ shortfall?: number; } /** * Read the balance-rail 402 machine fields off a caught checkout error, or * `null` when it is a different failure. * * Matched STRUCTURALLY (the three flat numbers must all be present), not by a * latin tag: the body's `error` is deliberately a self-explanatory Russian * sentence — vsenamid-class storefronts print it verbatim and their mappers * swallow anything containing the latin word "insufficient", so the server * never sends a machine tag on this refusal. */ export declare function getInsufficientBalanceDetails(e: unknown): InsufficientBalanceDetails | null;