diff --git a/.gitea/workflows/android-build.yaml b/.gitea/workflows/android-build.yaml index d995407..7a5b674 100644 --- a/.gitea/workflows/android-build.yaml +++ b/.gitea/workflows/android-build.yaml @@ -3,7 +3,7 @@ # a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and # assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP). # -# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without +# Signing degrades gracefully: with the ANDROID_RUSTORE_* secrets present the APK is signed; without # them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline). # The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook). # The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the runner host needs @@ -144,7 +144,7 @@ jobs: - name: Decode the release keystore id: keystore env: - ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_BASE64 }} run: | if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks" @@ -152,16 +152,16 @@ jobs: echo "keystore decoded -> signed release build" else echo "file=" >> "$GITHUB_OUTPUT" - echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)" + echo "::warning::ANDROID_RUSTORE_KEYSTORE_BASE64 secret is not set — building an UNSIGNED release APK (not installable/publishable)" fi - name: Assemble the release APK working-directory: ui/android env: ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }} - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} - ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RUSTORE_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEY_PASSWORD }} run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain - name: Upload the APK artifact diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md index 8937aea..d7433b0 100644 --- a/ANDROID_PLAN.md +++ b/ANDROID_PLAN.md @@ -62,19 +62,20 @@ client-only (no server change). Kept current as parts land so a fresh session resumes without re-deriving. Verify against code. -> ### ▶ RESUME HERE — 2026-07-13 (documents track + release-prep) +> ### ▶ RESUME HERE — 2026-07-14 (keystore secrets set; payments landed; gate = RuStore account + merge/tag) > -> **Where we are.** The legal-documents track, the release-prep hygiene, **and the icon rebrand** are -> DONE. The RuStore release is now gated on **owner-side** items only (keystore, RuStore account) plus -> the merge/tag chain. Do not re-do or re-litigate the finished work; do not re-ask the locked decisions. +> **Where we are.** The legal-documents track, the release-prep hygiene, the icon rebrand **and the +> release keystore** are DONE (the keystore + its Gitea secrets set by the owner — see below). The +> RuStore MVP is now gated on **one owner-side item** — the **RuStore developer account** (owner going +> ИП; RuStore updated its requirements) — plus the agent's **promote/tag → signed-build** chain. Also +> landed on `development` since: the payments **E10 multi-shop split** (#266) + **E11 kill switch** +> (#267), both dormant / fail-open (no bearing on the Android build), and the signing-secret rename +> `ANDROID_*` → **`ANDROID_RUSTORE_*`** (#268). Do not re-do the finished work or re-ask the locked +> decisions. > -> **Branches / PRs (current working branch: `feature/android-release-prep`).** -> - `development` — has the **legal-documents track**, merged via **PR #253** (green, deployed to the -> test contour). `/privacy/`, `/eula/`, `/offer/` are live there, bilingual RU/EN. -> - `feature/android-release-prep` — the **release-prep hygiene**, **PR #254 OPEN, green, NOT merged** -> (the owner chose to hold the merge). It is branched off `development`, so it also contains the -> legal-documents track. Merge #254 into `development` when ready — needs **owner approval** (the -> agent cannot self-approve). +> **Branches / PRs.** All merged to `development` (contour-green): legal documents (#253); release-prep +> hygiene + the re-enabled `android-build` workflow (#254); the payments E10/E11 (#266/#267); the +> signing-secret rename (#268). No open Android branch. > > **Done this session.** > - **Legal documents** (satisfies the RuStore *hosted privacy-policy URL* prerequisite): authored @@ -113,12 +114,12 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif > 2. **Merge PR #254** → `development` (owner approval), then **promote `development` → `master`** and > **tag `vX.Y.Z`** for the release (the version stamps the APK `versionName`/`versionCode` and the > `X-Client-Version` header). -> 3. **Release keystore.** OWNER generates it (`keytool -genkeypair -v -keystore erudit-release.jks -> -alias erudit -keyalg RSA -keysize 4096 -validity 10000`), base64-encodes it, sets the four Gitea -> **secrets** `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / -> `ANDROID_KEY_PASSWORD`, and backs the `.jks` up **off-host** (loss ⇒ the app can never be updated). -> The AGENT provides the exact commands on request. Without the secrets the workflow builds an -> UNSIGNED APK (a dry run still proves the pipeline). +> 3. **Release keystore — DONE (owner, 2026-07-14).** `erudit-release.jks` (RSA 4096, alias `erudit`, +> PKCS12) generated + backed up off-host; the four Gitea secrets are set under the RuStore-specific +> names `ANDROID_RUSTORE_KEYSTORE_BASE64` / `ANDROID_RUSTORE_KEYSTORE_PASSWORD` / +> `ANDROID_RUSTORE_KEY_ALIAS` / `ANDROID_RUSTORE_KEY_PASSWORD` (loss ⇒ the app can never be updated — +> the cert is pinned at the first RuStore upload). Google Play, later, would use its own +> `ANDROID_PLAY_*` upload key (Play App Signing) — a separate key. > 4. **RuStore developer account.** OWNER (verified legal entity or self-employed / самозанятый). Gates > publication, not the build. > 5. **Dispatch the signed build.** AGENT: on `master`, dispatch `android-build` (`confirm=build`) via @@ -132,6 +133,16 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif > 8. **Upload to RuStore.** OWNER. After publication, set the `ANDROID_RUSTORE_URL` Gitea variable to > the store deep-link so the update button works on future gated releases (empty until then — the > gate is dormant in the MVP). +> +> **Next tracks (design in a future session — interview the owner first).** +> - **Native notifications (push).** Promote FCM/push from "Out of scope" to a real track: a Capacitor +> push plugin, the Android send path (FCM — but **check RuStore's own push service vs FCM on RuStore +> devices**, since Google Play Services may be absent), token registration, and reusing the existing +> server notification dispatcher. Owner will bring specifics. +> - **A test APK for the emulator** (owner asked): the signed APK comes from `android-build` (needs the +> promote/tag chain, step 2). A quick **debug** APK is buildable without the release chain if the host +> has the Android SDK + JDK 21 (`pnpm build` → `cap sync` → `assembleDebug`). +> - Possibly other native polish (owner to flag). - **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in diff --git a/PLAN.md b/PLAN.md index e3995bb..987302c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -39,10 +39,13 @@ status — without re-deriving decisions. | E7 | Admin, reports & catalog | 2 | DONE | | E8 | Guest limits | — | DONE | | E9 | Tournament fee | future | TODO | +| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | DONE | +| E11 | Payment availability kill switch + per-account override | 2 | DONE | **Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3). **Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in -parallel). E9 is future. +parallel). E9 is future. E10 splits the `direct` rail into per-channel Robokassa shops + ИП +fiscalization (post-launch, backend-first). --- @@ -892,6 +895,123 @@ tournament-entry storage + pricing design. --- +## E10 — Multi-shop direct rail + ИП fiscalization + +**Status:** DONE (merged — the multi-shop PR) · **Release 2+ (post-launch, backend-first)** · depends on: E5 (intake/`Fund`, +the `robokassa` adapter, the Result callback), E7 (the per-user report — channel breakdown) · +mechanics: PAYMENTS §9, §12; decisions D41 (rev), D42–D44. + +**Goal.** Split the single Robokassa `direct` rail into **per-channel merchant shops** (web, +android; ios later) — separate merchant accounts / withdrawal / accounting and a per-channel +breakdown in the E7 report — while keeping **one `direct` wallet** (D42). Land the wallet/identity +rules the native apps need (email-only anchor, D43) and revise fiscalization for the owner's move +to **ИП / 54-ФЗ** (D41 rev). All **additive / contour-safe** and **money-live** → expand-contract +throughout. + +**Locked decisions (owner interview 2026-07-14): D41 (rev), D42, D43, D44.** No wallet-model / +spend-wall change; the split is merchant-account routing under one `direct` segment. Route the shop +by the **trusted** `X-Platform` subtype, never a client field; unknown → `web`. + +**B1 — Config: one shop → a set of shops.** + +- `robokassa.Config` (single) → a **shops registry** keyed by channel (`web`, `android`; `ios` + later), each 4 fields (`MerchantLogin`/`Password1`/`Password2`/`IsTest`). New env + `BACKEND_ROBOKASSA_WEB_*`, `BACKEND_ROBOKASSA_ANDROID_*`. +- **Expand-contract, no flag-day:** the legacy `BACKEND_ROBOKASSA_*` seeds the `web` shop; add the + new vars; retire the legacy set after the prod rollout sets the split vars. `validate()`: a shop + with a login must carry both passwords (mirror the current check), per shop. +- Config/README + `deploy/.env.example` + the deploy ansible vars. + +**B2 — Route the shop by channel (intake).** + +- `handleWalletOrder` `SourceDirect` branch: pick the shop by `cxt.Subtype` (`web`/`android`; + unknown → `web`). Build the payment request from that shop. The subtype rides the trusted + gateway-injected `X-Platform` (`/`, `parsePlatformHeader`) — no spoofable client + field. **Verify the gateway emits `direct/android` for the native build**; if it sends a bare + `direct/`, add the android subtype (small gateway change). +- Record the chosen `shop` on the order (feeds B5). + +**B3 — Per-shop Result callbacks.** + +- The callback must select the right `Password2` → each shop gets its own Result URL: + **per-shop public edge routes** (mirror the existing single Robokassa Result route, e.g. + `…/result/web`, `…/result/android`), each forwarded to the backend and verified with that shop's + config, then the same `Fund` (source stays `direct`). Add the routes to the **Caddyfile + `@gateway` matcher** (else they fall to the landing catch-all) **+ a CI probe per route**. +- Keep the legacy single route alive (shop = `web`) through the expand-contract window until the + cabinet Result URLs are cut over. + +**B4 — ИП fiscalization (D41 rev) — RESOLVED (owner 2026-07-14): cabinet-side only.** The owner keeps +Robokassa's cabinet auto-fiscalization (a generic чек is acceptable for the ИП); the optional +itemized-`Receipt` / `Email` code below is **dropped** — not needed. (Kept for context.) + +- **Owner/cabinet:** enable the 54-ФЗ cloud kassa in the Robokassa ЛКК (kassa + ОФД + СНО). + Required for ИП regardless of code. +- **Code (optional, itemized чеки):** send a one-line `Receipt` (pack name, qty 1, `sum`, `tax` + per СНО, `payment_object`/`payment_method`) + the customer `Email` (the D36 confirmed anchor) in + the payment request; extend the signature to `MerchantLogin:OutSum:InvId:Receipt:Password1` + (URL-encode `Receipt`). One line fits the current GET redirect; POST-form only as a URL-length + fallback. One feature, all shops. **Sequenced last** — the split (B1–B3, B5, B6) needs no + fiscalization. Supersedes the E5 §12 shop-side НПД receipt note. + +**B5 — Channel in the report (D44).** + +- Additive `shop` column on the order (default `web` / backfill from `origin`); a per-channel + breakdown in the E7 per-user report (D40). Expand-contract migration (nullable/defaulted column), + **contour-safe** (a schema touch → note the contour `DROP SCHEMA` step in `PRERELEASE.md`). + +**B6 — Tests + docs.** + +- unit: the `robokassa` shops registry + per-shop `VerifyResult` (right `Password2`); + signature-with-`Receipt` (B4); intake shop routing by subtype (web/android/unknown→web). +- integration: order→per-shop callback→credit (each route), a duplicate credits once, an expired + order still honoured; the `shop` recorded + reported. +- docs: `PAYMENTS.md` (+`_ru`) the multi-shop topology + the ИП/54-ФЗ receipt revision; + `deploy/README` + `.env.example` the new vars + the per-shop Result routes + probes; + `PRERELEASE.md` the `shop` column contour step. + +**Done-criteria.** A `direct/web` order pays through the web shop and `direct/android` through the +android shop, each verified with its own `Password2`, both crediting one `direct` wallet exactly +once; the E7 report breaks payments down by channel; the split stays contour-safe (the legacy shop +still works until the cabinet cutover). B4 (fiscalization) verified when the owner's ИП/ОФД/СНО are +live. + +**Notes/risks.** Edge routes fall through if not in the Caddyfile `@gateway` matcher — add a CI +probe (field note). The prod rolling deploy skips caddy on config-only changes — force-recreate on a +Caddyfile change. Money-live: expand-contract only, image rollback DB-safe. Confirm the gateway +emits `direct/android` for the native build before relying on subtype routing. + +--- + +## E11 — Payment availability kill switch + per-account override + +**Status:** DONE · **Release 2** · depends on: E5 (intake/order), E7 (admin console) · mechanics: +PAYMENTS §9; decisions D45, D46. + +**Delivered.** An operator disables purchases live from `/_gm` — a whole rail/channel or one account +— and the user sees a localized reason on the next purchase attempt. Motivation (owner): real apps +show broken payments with no explanation; this gives ops a live switch + a clear user message. + +- **Rail kill switch** — `payments.rail_status` (per rail `direct:web` / `direct:android` / `vk` / + `telegram`): `enabled` + a per-language message, edited on the catalog page. **Fail-open** (no row + ⇒ enabled, so payments are never accidentally killed). The intake gate — `CanPurchase` in + `handleWalletOrder`, before the order — returns `payment_unavailable` + the localized message; + orthogonal to the security gates. +- **Per-account override** — `payments.account_payment_override` (a row only for non-default; default + = no row, cleared by delete): allow / deny / default, edited on the user card. `allow` bypasses + **only** the ops rail switch, never the security gates (D46). +- **Wire** — the message rides an additive `ExecuteResponse.message` (envelope layer, + frozen-contract-safe; the gateway forwards a backend domain-error message via `DomainMessage`); the + client reads it into `GatewayError.message` and shows it on a `payment_unavailable` buy attempt. +- **Tests** — the pure gate `PurchaseGate` (unit, TDD); the store + gate + override end-to-end + (integration, migration `00016`); the client (svelte-check / vitest). **Docs** — PAYMENTS (+`_ru`), + decisions D45 / D46. + +**Contour-safe:** additive migration (two new tables, no wipe); the wire add is additive; fail-open, +so nothing is disabled until an operator acts. + +--- + ## Verification & CI (all stages) - Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a diff --git a/assets/icons/brand/android-monochrome.png b/assets/icons/brand/android-monochrome.png index fe88567..d551201 100644 Binary files a/assets/icons/brand/android-monochrome.png and b/assets/icons/brand/android-monochrome.png differ diff --git a/assets/icons/brand/build-android-res.mjs b/assets/icons/brand/build-android-res.mjs index b237389..5136b56 100644 --- a/assets/icons/brand/build-android-res.mjs +++ b/assets/icons/brand/build-android-res.mjs @@ -19,6 +19,11 @@ const uri = p => 'data:image/png;base64,' + readFileSync(p).toString('base64'); const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png')); const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png')); const MONO = uri(join(DIR, 'android-monochrome.png')); +// The full-bleed master (square tile + master mark) — the legacy SQUARE launcher (API < 26) +// uses it so old Android shows a large mark rather than the safe-zone foreground shrunk into +// the middle. The round legacy icon keeps the safe-zone composite (a round mask would clip +// the master's corner ✻). +const MASTER = uri(join(ROOT, 'ui', 'assets', 'icon.png')); // density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)] const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] }; @@ -38,8 +43,9 @@ for (const [d, [fg, lg]] of Object.entries(D)) { writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false)); writeFileSync(join(dir, 'ic_launcher_foreground.png'), await shot(im(FG, fg), fg, true)); writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true)); - const comp = `
${im(BG, lg)}
${im(FG, lg)}
`; - writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false)); + // Square launcher: the full-bleed master (large mark). Round launcher: the safe-zone + // composite, circle-clipped (the master's corner ✻ would be clipped by the round mask). + writeFileSync(join(dir, 'ic_launcher.png'), await shot(im(MASTER, lg), lg, false)); const round = `
${im(BG, lg)}
${im(FG, lg)}
`; writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true)); console.log('mipmap-' + d, 'ok'); diff --git a/assets/icons/brand/build-icon.mjs b/assets/icons/brand/build-icon.mjs index 7d5b1ea..bf54c03 100644 --- a/assets/icons/brand/build-icon.mjs +++ b/assets/icons/brand/build-icon.mjs @@ -33,8 +33,12 @@ const SHADE = 0.15; // black, this alpha bottom-left -> transp // The mark, placed for the standalone master (bottom-right biased, full-bleed): const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] }; // The mark, placed for the Android foreground layer (centred-ish inside the 61% safe -// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻): -const FOREGROUND = { lh: 0.5391, lc: [0.5234, 0.4951], sd: 0.1318, sc: [0.7627, 0.7266] }; +// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻). +// Every value is the earlier placement scaled 0.75 about the icon centre (0.5, 0.5): the +// whole mark is 25% smaller, its «Э»↔✻ arrangement unchanged, so the ✻ that used to graze +// the round mask now sits well inside the safe zone. Round-mask launchers (adaptive + the +// legacy round icon) get more breathing room; the full-bleed master is untouched. +const FOREGROUND = { lh: 0.4043, lc: [0.5176, 0.4963], sd: 0.0989, sc: [0.6970, 0.6700] }; const PALETTE = { light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' }, diff --git a/assets/icons/brand/icon-dark-android-foreground.png b/assets/icons/brand/icon-dark-android-foreground.png index a4a22d6..02de1ff 100644 Binary files a/assets/icons/brand/icon-dark-android-foreground.png and b/assets/icons/brand/icon-dark-android-foreground.png differ diff --git a/assets/icons/brand/icon-light-android-foreground.png b/assets/icons/brand/icon-light-android-foreground.png index 12b4a8e..123fd76 100644 Binary files a/assets/icons/brand/icon-light-android-foreground.png and b/assets/icons/brand/icon-light-android-foreground.png differ diff --git a/backend/internal/adminconsole/templates/pages/catalog.gohtml b/backend/internal/adminconsole/templates/pages/catalog.gohtml index 2c94452..f53fe84 100644 --- a/backend/internal/adminconsole/templates/pages/catalog.gohtml +++ b/backend/internal/adminconsole/templates/pages/catalog.gohtml @@ -30,6 +30,18 @@
+

Payment availability (kill switch)

+

Turn purchases off on a rail/channel and show the user a localized reason on their next attempt. Unchecked = off; an empty message shows a built-in “temporarily unavailable”. Fail-open: a rail you never touch stays on. A per-user “allow” override (on a user card) bypasses this switch.

+{{range .Rails}} +
+ + + + +
+
+{{end}} +

Products

Showing {{if .ShowAll}}all products (active + archived) — active only{{else}}active products — show all (incl. archived){{end}}.

diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 6b9bc29..30bc5b3 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -72,14 +72,24 @@ {{if or .Finance.Abuse .Finance.Loss}}
  • Refund risk {{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}
  • {{end}} {{else}}

    no balances or benefits

    {{end}} +

    Payment override

    + + +
    +

    Ledger

    {{$uid := .ID}} {{if .Finance.Ledger}}
    - + {{range .Finance.Ledger}} - + {{end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index c60e6f3..4099425 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -198,6 +198,9 @@ type UserDetailView struct { // Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present // is false when the payments domain is unwired. Finance FinanceView + // PurchaseOverride is the account's per-account purchase override ("default"/"allow"/"deny"), + // shown in and edited from the user card's payment-override control. + PurchaseOverride string // Grant is the admin-grant panel (origin picker + grantable products). Present is false when the // payments domain is unwired. Grant GrantFormView @@ -232,8 +235,8 @@ type BenefitRow struct { } // LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip -// delta, the product / order / provider it references (empty when none), the raw snapshot JSON and -// the pre-formatted time. +// delta, the product / order / provider / direct-rail shop it references (empty when none), the raw +// snapshot JSON and the pre-formatted time. type LedgerRow struct { Kind string Source string @@ -242,6 +245,7 @@ type LedgerRow struct { Product string Order string Provider string + Shop string Snapshot string At string } @@ -686,6 +690,18 @@ type CatalogView struct { RewardPayout int RewardDailyCap int RewardHourlyCap int + // Rails is the per-rail operational kill-switch state (enabled + per-language off-message), shown + // in and edited from the page's payment-availability form. + Rails []RailStatusRow +} + +// RailStatusRow is one payment rail's operational availability in the kill-switch editor: the rail +// key, whether purchases are enabled, and the operator's per-language off-message shown to the user. +type RailStatusRow struct { + Rail string + Enabled bool + MessageRU string + MessageEN string } // ProductRow is one product in the catalog list: its composition, prices, the archived flag diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f3844ee..a533ea2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -65,9 +65,9 @@ type Config struct { // RendererURL is the base URL of the internal image-render sidecar (e.g. // http://renderer:8090). Empty disables the PNG export artifact. RendererURL string - // Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin - // leaves the direct order and Result-callback endpoints unregistered. - Robokassa robokassa.Config + // Robokassa configures the direct-rail (RUB) payment provider — one merchant shop per channel + // (D42). An empty set leaves the direct order and Result-callback endpoints unregistered. + Robokassa robokassa.Shops } // Defaults applied when the corresponding environment variable is unset. @@ -157,11 +157,19 @@ func Load() (Config, error) { AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"), } - robo := robokassa.Config{ - MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"), - Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"), - Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"), - IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1", + // Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed + // the web channel so existing deploys keep working; the per-channel vars add the rest. A shop + // with no MerchantLogin is dropped (the rail stays dormant when none is configured). + shops := robokassa.Shops{} + web := robokassaShop("BACKEND_ROBOKASSA_WEB") + if web.MerchantLogin == "" { + web = robokassaShop("BACKEND_ROBOKASSA") // legacy single-shop credentials seed the web channel + } + if web.MerchantLogin != "" { + shops[robokassa.ChannelWeb] = web + } + if android := robokassaShop("BACKEND_ROBOKASSA_ANDROID"); android.MerchantLogin != "" { + shops[robokassa.ChannelAndroid] = android } c := Config{ @@ -181,7 +189,7 @@ func Load() (Config, error) { GuestRetention: guestRetention, ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"), RendererURL: os.Getenv("BACKEND_RENDERER_URL"), - Robokassa: robo, + Robokassa: shops, } if err := c.validate(); err != nil { return Config{}, err @@ -234,8 +242,10 @@ func (c Config) validate() error { return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL) } } - if c.Robokassa.MerchantLogin != "" && (c.Robokassa.Password1 == "" || c.Robokassa.Password2 == "") { - return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is") + for channel, shop := range c.Robokassa { + if shop.Password1 == "" || shop.Password2 == "" { + return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel) + } } return nil } @@ -276,3 +286,15 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) { } return d, nil } + +// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g. +// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing +// MerchantLogin yields a zero Config the caller drops. +func robokassaShop(prefix string) robokassa.Config { + return robokassa.Config{ + MerchantLogin: os.Getenv(prefix + "_MERCHANT_LOGIN"), + Password1: os.Getenv(prefix + "_PASSWORD1"), + Password2: os.Getenv(prefix + "_PASSWORD2"), + IsTest: os.Getenv(prefix+"_TEST") == "1", + } +} diff --git a/backend/internal/inttest/payment_availability_test.go b/backend/internal/inttest/payment_availability_test.go new file mode 100644 index 0000000..fbce2e6 --- /dev/null +++ b/backend/internal/inttest/payment_availability_test.go @@ -0,0 +1,83 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/payments" +) + +// TestPaymentAvailabilityKillSwitch exercises the per-rail kill switch + the per-account override +// end-to-end against Postgres: fail-open, a disabled rail with a localized message, per-rail +// granularity, and the allow/deny/default overrides. +func TestPaymentAvailabilityKillSwitch(t *testing.T) { + ctx := context.Background() + svc := newPaymentsService() + acc := uuid.New() + + // Fail-open: an untouched rail is enabled. + if ok, _, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); err != nil || !ok { + t.Fatalf("fail-open: CanPurchase = %v/%v, want true", ok, err) + } + + // Disable the web rail with a per-language message → blocked with that message, localized. + if err := svc.SetRailStatus(ctx, payments.RailDirectWeb, payments.RailAvailability{MessageRU: "Чиним", MessageEN: "Fixing"}); err != nil { + t.Fatalf("set rail status: %v", err) + } + if ok, reason, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "ru"); err != nil || ok || reason != "Чиним" { + t.Fatalf("disabled rail (ru): CanPurchase = %v/%q/%v, want false/Чиним", ok, reason, err) + } + if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); ok || reason != "Fixing" { + t.Fatalf("disabled rail (en): = %v/%q, want false/Fixing", ok, reason) + } + // Per-rail granularity: another rail stays enabled. + if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok { + t.Fatalf("android rail should stay enabled") + } + + // A per-account "allow" override bypasses the disabled rail. + if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideAllow); err != nil { + t.Fatalf("set override allow: %v", err) + } + if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); !ok { + t.Fatalf("allow override should bypass the disabled rail") + } + if ov, err := svc.PurchaseOverrideFor(ctx, acc); err != nil || ov != payments.OverrideAllow { + t.Fatalf("PurchaseOverrideFor = %v/%v, want allow", ov, err) + } + + // A "deny" override blocks even an enabled rail. + if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDeny); err != nil { + t.Fatalf("set override deny: %v", err) + } + if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); ok || reason == "" { + t.Fatalf("deny override should block the enabled android rail with a reason, got %v/%q", ok, reason) + } + + // Clearing the override (default) restores rail-driven behaviour. + if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDefault); err != nil { + t.Fatalf("clear override: %v", err) + } + if ov, _ := svc.PurchaseOverrideFor(ctx, acc); ov != payments.OverrideDefault { + t.Fatalf("after clear, override = %v, want default", ov) + } + if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok { + t.Fatalf("after clearing override, android rail should be enabled again") + } + + // RailStatuses reflects the stored web-rail change and fills the rest fail-open. + all, err := svc.RailStatuses(ctx) + if err != nil { + t.Fatalf("rail statuses: %v", err) + } + if all[payments.RailDirectWeb].Enabled { + t.Fatalf("web rail should read disabled") + } + if !all[payments.RailVK].Enabled { + t.Fatalf("untouched vk rail should read enabled") + } +} diff --git a/backend/internal/payments/availability.go b/backend/internal/payments/availability.go new file mode 100644 index 0000000..0314658 --- /dev/null +++ b/backend/internal/payments/availability.go @@ -0,0 +1,107 @@ +package payments + +import "slices" + +// PurchaseOverride is a per-account purchase override that forces purchases allowed or denied for +// one account regardless of the operational rail switch, or is absent (OverrideDefault) when the +// account follows the rail switch. The zero value is OverrideDefault, so a missing override row maps +// to it. OverrideAllow bypasses ONLY the operational rail switch — never the security / compliance +// gates (trusted platform, the D36 email anchor, the VK-iOS spend freeze, the min client version), +// which CreateOrder enforces separately. +type PurchaseOverride int + +const ( + // OverrideDefault follows the rail switch (no override row for the account). + OverrideDefault PurchaseOverride = iota + // OverrideAllow always allows the purchase, bypassing only the operational rail switch. + OverrideAllow + // OverrideDeny always denies the purchase for the account. + OverrideDeny +) + +// RailAvailability is a payment rail's operational availability: whether purchases are enabled and, +// when disabled, the operator's explanation in each language, shown to the user on a purchase +// attempt. A rail with no status row is treated as enabled (fail-open — see the store), so the +// zero value is never used as a live "enabled" default. +type RailAvailability struct { + Enabled bool + MessageRU string + MessageEN string +} + +// PurchaseGate decides whether an account may open a purchase order on a rail, from the per-account +// override and the rail's operational availability. It is the operational layer only — the caller +// still enforces the security gates. On a block it returns a reason localized to lang ("ru", else +// English). Fail-open: OverrideDefault on an enabled rail allows. +func PurchaseGate(override PurchaseOverride, rail RailAvailability, lang string) (ok bool, reason string) { + switch override { + case OverrideAllow: + return true, "" // bypasses only the ops switch; the security gates still apply upstream + case OverrideDeny: + return false, defaultUnavailable(lang) // a per-account block — a neutral reason + default: // OverrideDefault — follow the rail switch + if rail.Enabled { + return true, "" + } + return false, railMessage(rail, lang) + } +} + +// railMessage returns the operator's rail-off message in lang, falling back to the other language +// and then to the built-in default when the operator left both blank. +func railMessage(rail RailAvailability, lang string) string { + if lang == "ru" { + if rail.MessageRU != "" { + return rail.MessageRU + } + if rail.MessageEN != "" { + return rail.MessageEN + } + } else { + if rail.MessageEN != "" { + return rail.MessageEN + } + if rail.MessageRU != "" { + return rail.MessageRU + } + } + return defaultUnavailable(lang) +} + +// defaultUnavailable is the built-in localized "payments unavailable" reason used when the operator +// set no custom message, and for a per-account deny. +func defaultUnavailable(lang string) string { + if lang == "ru" { + return "Оплата временно недоступна. Попробуйте позже." + } + return "Payments are temporarily unavailable. Please try again later." +} + +// Rail keys name the operational payment rails the kill switch and the per-account override key on. +// The direct rail is split per channel (D42); vk and telegram are single-rail. +const ( + RailDirectWeb = "direct:web" + RailDirectAndroid = "direct:android" + RailVK = "vk" + RailTelegram = "telegram" +) + +// KnownRails is the fixed set of rail keys, for the admin editor and for validation. +var KnownRails = []string{RailDirectWeb, RailDirectAndroid, RailVK, RailTelegram} + +// RailKey maps a payment context to its operational rail key: the direct rail by channel subtype +// (android, else web), or the store rail's own name (vk / telegram). +func RailKey(kind Source, subtype string) string { + if kind == SourceDirect { + if subtype == "android" { + return RailDirectAndroid + } + return RailDirectWeb + } + return string(kind) +} + +// isKnownRail reports whether rail is one of the fixed rail keys. +func isKnownRail(rail string) bool { + return slices.Contains(KnownRails, rail) +} diff --git a/backend/internal/payments/availability_test.go b/backend/internal/payments/availability_test.go new file mode 100644 index 0000000..46be677 --- /dev/null +++ b/backend/internal/payments/availability_test.go @@ -0,0 +1,40 @@ +package payments + +import "testing" + +func TestPurchaseGate(t *testing.T) { + on := RailAvailability{Enabled: true} + offMsg := RailAvailability{Enabled: false, MessageRU: "Чиним", MessageEN: "Fixing"} + offNoMsg := RailAvailability{Enabled: false} + + // OverrideDefault follows the rail switch. + if ok, _ := PurchaseGate(OverrideDefault, on, "en"); !ok { + t.Error("default + enabled rail should allow") + } + if ok, r := PurchaseGate(OverrideDefault, offMsg, "ru"); ok || r != "Чиним" { + t.Errorf("default + off rail (ru) = %v/%q, want false/Чиним", ok, r) + } + if ok, r := PurchaseGate(OverrideDefault, offMsg, "en"); ok || r != "Fixing" { + t.Errorf("default + off rail (en) = %v/%q, want false/Fixing", ok, r) + } + + // Off rail with no custom message → the built-in default, localized (not the English default in ru). + if ok, r := PurchaseGate(OverrideDefault, offNoMsg, "ru"); ok || r != defaultUnavailable("ru") { + t.Errorf("default + off no-msg (ru) = %v/%q, want false + the ru default", ok, r) + } + + // OverrideAllow bypasses the ops switch even when the rail is off. + if ok, r := PurchaseGate(OverrideAllow, offMsg, "en"); !ok || r != "" { + t.Errorf("allow + off rail = %v/%q, want true/empty (bypasses the ops switch)", ok, r) + } + + // OverrideDeny blocks even when the rail is on, with a non-empty reason. + if ok, r := PurchaseGate(OverrideDeny, on, "en"); ok || r == "" { + t.Errorf("deny + on rail = %v/%q, want false + a reason", ok, r) + } + + // The message falls back to the other language when only one is set. + if _, r := PurchaseGate(OverrideDefault, RailAvailability{MessageEN: "OnlyEN"}, "ru"); r != "OnlyEN" { + t.Errorf("off rail ru with only EN msg = %q, want OnlyEN (fallback)", r) + } +} diff --git a/backend/internal/payments/service_availability.go b/backend/internal/payments/service_availability.go new file mode 100644 index 0000000..b0253c7 --- /dev/null +++ b/backend/internal/payments/service_availability.go @@ -0,0 +1,63 @@ +package payments + +import ( + "context" + "fmt" + + "github.com/google/uuid" +) + +// CanPurchase reports whether an account may open a purchase order on rail, combining the account's +// per-account override with the rail's operational switch (PurchaseGate). It is the operational +// availability layer only — the caller still enforces the security gates (trusted platform, the D36 +// email anchor, the VK-iOS spend freeze, the min client version). On a block, reason is localized to +// lang for the user; err is only a store failure. +func (s *Service) CanPurchase(ctx context.Context, accountID uuid.UUID, rail, lang string) (ok bool, reason string, err error) { + ov, err := s.store.purchaseOverride(ctx, accountID) + if err != nil { + return false, "", err + } + avail, err := s.store.railAvailability(ctx, rail) + if err != nil { + return false, "", err + } + ok, reason = PurchaseGate(ov, avail, lang) + return ok, reason, nil +} + +// RailStatuses returns every known rail's operational status for the admin editor, filling a rail +// with no stored row with the fail-open enabled default so the editor always shows one row per rail. +func (s *Service) RailStatuses(ctx context.Context) (map[string]RailAvailability, error) { + stored, err := s.store.allRailStatus(ctx) + if err != nil { + return nil, err + } + out := make(map[string]RailAvailability, len(KnownRails)) + for _, rail := range KnownRails { + if a, ok := stored[rail]; ok { + out[rail] = a + } else { + out[rail] = RailAvailability{Enabled: true} + } + } + return out, nil +} + +// SetRailStatus upserts a rail's operational status from the admin editor. It rejects an unknown rail +// key so a typo cannot create a dead row. +func (s *Service) SetRailStatus(ctx context.Context, rail string, a RailAvailability) error { + if !isKnownRail(rail) { + return fmt.Errorf("payments: unknown rail %q", rail) + } + return s.store.setRailStatus(ctx, rail, a, s.clock()) +} + +// PurchaseOverrideFor returns an account's purchase override (OverrideDefault when none is set). +func (s *Service) PurchaseOverrideFor(ctx context.Context, accountID uuid.UUID) (PurchaseOverride, error) { + return s.store.purchaseOverride(ctx, accountID) +} + +// SetPurchaseOverride sets or clears an account's purchase override (OverrideDefault deletes the row). +func (s *Service) SetPurchaseOverride(ctx context.Context, accountID uuid.UUID, ov PurchaseOverride) error { + return s.store.setPurchaseOverride(ctx, accountID, ov, s.clock()) +} diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go index cf65f3e..9605cf8 100644 --- a/backend/internal/payments/service_intake.go +++ b/backend/internal/payments/service_intake.go @@ -46,6 +46,7 @@ func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Cont amount: pack.price, origin: method, provider: provider, + shop: directShop(cxt), } if err := s.store.createOrder(ctx, o, s.clock()); err != nil { return OrderResult{}, err @@ -53,6 +54,16 @@ func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Cont return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil } +// directShop returns the merchant shop (channel) a direct order is issued through — the trusted +// platform subtype for the direct rail ("web"/"android"), or "" for any other rail (the per-shop +// split is direct-only; D42/D44). Recorded on the order for the admin per-channel breakdown. +func directShop(cxt Context) string { + if cxt.Kind == SourceDirect { + return cxt.Subtype + } + return "" +} + // OrderItem returns a pending order's human title and the amount it charges, in the order's own // currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order // and the pack title, honouring the pack even if it was later deactivated (mirrors Fund). diff --git a/backend/internal/payments/statement.go b/backend/internal/payments/statement.go index 1ecc816..41045cf 100644 --- a/backend/internal/payments/statement.go +++ b/backend/internal/payments/statement.go @@ -42,7 +42,8 @@ type RiskInfo struct { } // LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when -// the column is NULL; Snapshot is the raw purchase/refund JSON (empty when none). +// the column is NULL; Shop is the direct-rail merchant channel the referenced order used (empty for +// other rails or order-less rows); Snapshot is the raw purchase/refund JSON (empty when none). type LedgerEntry struct { Kind string Source string @@ -52,6 +53,7 @@ type LedgerEntry struct { OrderID string Provider string ProviderPaymentID string + Shop string Snapshot string CreatedAt time.Time } diff --git a/backend/internal/payments/store_availability.go b/backend/internal/payments/store_availability.go new file mode 100644 index 0000000..41ebcdd --- /dev/null +++ b/backend/internal/payments/store_availability.go @@ -0,0 +1,96 @@ +package payments + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +// railAvailability reads a rail's operational availability. A missing row is fail-open: enabled with +// no message, so payments stay on unless an operator explicitly disabled the rail. +func (s *Store) railAvailability(ctx context.Context, rail string) (RailAvailability, error) { + var a RailAvailability + err := s.db.QueryRowContext(ctx, + `SELECT enabled, message_ru, message_en FROM payments.rail_status WHERE rail = $1`, rail). + Scan(&a.Enabled, &a.MessageRU, &a.MessageEN) + if errors.Is(err, sql.ErrNoRows) { + return RailAvailability{Enabled: true}, nil + } + if err != nil { + return RailAvailability{}, fmt.Errorf("payments: read rail status %q: %w", rail, err) + } + return a, nil +} + +// allRailStatus reads every stored rail-status row for the admin editor, keyed by rail. Rails with +// no row are absent (the caller fills them with the fail-open enabled default). +func (s *Store) allRailStatus(ctx context.Context) (map[string]RailAvailability, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT rail, enabled, message_ru, message_en FROM payments.rail_status`) + if err != nil { + return nil, fmt.Errorf("payments: read rail statuses: %w", err) + } + defer rows.Close() + out := make(map[string]RailAvailability) + for rows.Next() { + var rail string + var a RailAvailability + if err := rows.Scan(&rail, &a.Enabled, &a.MessageRU, &a.MessageEN); err != nil { + return nil, fmt.Errorf("payments: scan rail status: %w", err) + } + out[rail] = a + } + return out, rows.Err() +} + +// setRailStatus upserts a rail's operational status (admin). +func (s *Store) setRailStatus(ctx context.Context, rail string, a RailAvailability, now time.Time) error { + if _, err := s.db.ExecContext(ctx, + `INSERT INTO payments.rail_status (rail, enabled, message_ru, message_en, updated_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (rail) DO UPDATE SET enabled = $2, message_ru = $3, message_en = $4, updated_at = $5`, + rail, a.Enabled, a.MessageRU, a.MessageEN, now); err != nil { + return fmt.Errorf("payments: set rail status %q: %w", rail, err) + } + return nil +} + +// purchaseOverride reads an account's purchase override; a missing row is OverrideDefault. +func (s *Store) purchaseOverride(ctx context.Context, accountID uuid.UUID) (PurchaseOverride, error) { + var allow bool + err := s.db.QueryRowContext(ctx, + `SELECT allow FROM payments.account_payment_override WHERE account_id = $1`, accountID).Scan(&allow) + if errors.Is(err, sql.ErrNoRows) { + return OverrideDefault, nil + } + if err != nil { + return OverrideDefault, fmt.Errorf("payments: read purchase override %s: %w", accountID, err) + } + if allow { + return OverrideAllow, nil + } + return OverrideDeny, nil +} + +// setPurchaseOverride upserts an account's override, or deletes the row for OverrideDefault (the +// default state is the absence of a row). +func (s *Store) setPurchaseOverride(ctx context.Context, accountID uuid.UUID, ov PurchaseOverride, now time.Time) error { + if ov == OverrideDefault { + if _, err := s.db.ExecContext(ctx, + `DELETE FROM payments.account_payment_override WHERE account_id = $1`, accountID); err != nil { + return fmt.Errorf("payments: clear purchase override %s: %w", accountID, err) + } + return nil + } + if _, err := s.db.ExecContext(ctx, + `INSERT INTO payments.account_payment_override (account_id, allow, updated_at) VALUES ($1, $2, $3) + ON CONFLICT (account_id) DO UPDATE SET allow = $2, updated_at = $3`, + accountID, ov == OverrideAllow, now); err != nil { + return fmt.Errorf("payments: set purchase override %s: %w", accountID, err) + } + return nil +} diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go index b435226..c4c51ac 100644 --- a/backend/internal/payments/store_intake.go +++ b/backend/internal/payments/store_intake.go @@ -151,6 +151,7 @@ type newOrder struct { amount Money origin Source provider string + shop string } // createOrder inserts a pending order. @@ -159,12 +160,12 @@ func (s *Store) createOrder(ctx context.Context, o newOrder, now time.Time) erro table.Orders.OrderID, table.Orders.AccountID, table.Orders.Platform, table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency, table.Orders.Origin, table.Orders.Status, table.Orders.Provider, - table.Orders.CreatedAt, table.Orders.UpdatedAt, + table.Orders.CreatedAt, table.Orders.UpdatedAt, table.Orders.Shop, ).VALUES( o.orderID, o.accountID, o.platform, o.productID, o.amount.Minor(), string(o.amount.Currency()), string(o.origin), "pending", o.provider, - now, now, + now, now, o.shop, ) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("payments: create order: %w", err) diff --git a/backend/internal/payments/store_statement.go b/backend/internal/payments/store_statement.go index 33cb623..78b8685 100644 --- a/backend/internal/payments/store_statement.go +++ b/backend/internal/payments/store_statement.go @@ -74,9 +74,39 @@ func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Stat CreatedAt: r.CreatedAt, }) } + + // Annotate direct-rail entries with the merchant shop (channel) their order was issued through + // (E10/D44), keyed by the ledger's order id. Non-direct / order-less entries stay "". + shops, err := s.orderShops(ctx, accountID) + if err != nil { + return Statement{}, err + } + for i := range out.Ledger { + out.Ledger[i].Shop = shops[out.Ledger[i].OrderID] + } return out, nil } +// orderShops maps an account's order ids (as strings) to the merchant shop (channel) each direct +// order was issued through, for annotating the ledger report (E10/D44). Orders with an empty shop +// (non-direct or pre-split) are omitted, so a lookup miss yields "". +func (s *Store) orderShops(ctx context.Context, accountID uuid.UUID) (map[string]string, error) { + var rows []model.Orders + if err := postgres.SELECT(table.Orders.OrderID, table.Orders.Shop). + FROM(table.Orders). + WHERE(table.Orders.AccountID.EQ(postgres.UUID(accountID))). + QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("payments: load order shops %s: %w", accountID, err) + } + m := make(map[string]string, len(rows)) + for _, r := range rows { + if r.Shop != "" { + m[r.OrderID.String()] = r.Shop + } + } + return m, nil +} + // allLedger reads the entire append-only ledger (all accounts, newest first) for the admin export. func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) { var rows []model.Ledger diff --git a/backend/internal/postgres/jet/payments/model/orders.go b/backend/internal/postgres/jet/payments/model/orders.go index 55010ff..3161506 100644 --- a/backend/internal/postgres/jet/payments/model/orders.go +++ b/backend/internal/postgres/jet/payments/model/orders.go @@ -25,4 +25,5 @@ type Orders struct { ProviderPaymentID *string CreatedAt time.Time UpdatedAt time.Time + Shop string } diff --git a/backend/internal/postgres/jet/payments/table/orders.go b/backend/internal/postgres/jet/payments/table/orders.go index 26de702..21a974b 100644 --- a/backend/internal/postgres/jet/payments/table/orders.go +++ b/backend/internal/postgres/jet/payments/table/orders.go @@ -29,6 +29,7 @@ type ordersTable struct { ProviderPaymentID postgres.ColumnString CreatedAt postgres.ColumnTimestampz UpdatedAt postgres.ColumnTimestampz + Shop postgres.ColumnString AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -82,9 +83,10 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable { ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id") CreatedAtColumn = postgres.TimestampzColumn("created_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn} - defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn} + ShopColumn = postgres.StringColumn("shop") + allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn} + defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn} ) return ordersTable{ @@ -103,6 +105,7 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable { ProviderPaymentID: ProviderPaymentIDColumn, CreatedAt: CreatedAtColumn, UpdatedAt: UpdatedAtColumn, + Shop: ShopColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/migrations/00015_orders_shop.sql b/backend/internal/postgres/migrations/00015_orders_shop.sql new file mode 100644 index 0000000..cb9f111 --- /dev/null +++ b/backend/internal/postgres/migrations/00015_orders_shop.sql @@ -0,0 +1,13 @@ +-- Multi-shop direct rail (E10/D44): record which Robokassa merchant shop (channel) issued a direct +-- order — "web" / "android" (ios later) — so the admin financial report can break direct payments +-- down by channel. Only the direct rail is per-channel; other rails leave it "". Additive only (a +-- defaulted column), applies forward via goose with no data rewrite (no contour wipe); an image +-- rollback ignores the column. +-- +goose Up + +ALTER TABLE payments.orders + ADD COLUMN shop text NOT NULL DEFAULT ''; + +-- +goose Down + +ALTER TABLE payments.orders DROP COLUMN shop; diff --git a/backend/internal/postgres/migrations/00016_payment_availability.sql b/backend/internal/postgres/migrations/00016_payment_availability.sql new file mode 100644 index 0000000..85af927 --- /dev/null +++ b/backend/internal/postgres/migrations/00016_payment_availability.sql @@ -0,0 +1,26 @@ +-- Payment availability controls (D45/D46): a per-rail operational kill switch with a localized message, +-- and a per-account purchase override. Both let an operator disable payments live from the admin — +-- a whole rail/channel, or one account — and explain why to the user on a purchase attempt. Additive +-- (new tables) — applies forward via goose with no data rewrite (no contour wipe); an image rollback +-- ignores both tables. Fail-open by design: a rail with no row is enabled; an account with no row +-- follows the rail switch. +-- +goose Up + +CREATE TABLE payments.rail_status ( + rail text PRIMARY KEY, + enabled boolean NOT NULL DEFAULT true, + message_ru text NOT NULL DEFAULT '', + message_en text NOT NULL DEFAULT '', + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE payments.account_payment_override ( + account_id uuid PRIMARY KEY, + allow boolean NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- +goose Down + +DROP TABLE payments.account_payment_override; +DROP TABLE payments.rail_status; diff --git a/backend/internal/robokassa/shops.go b/backend/internal/robokassa/shops.go new file mode 100644 index 0000000..c328c48 --- /dev/null +++ b/backend/internal/robokassa/shops.go @@ -0,0 +1,54 @@ +package robokassa + +// Shops is a set of Robokassa merchant shops keyed by channel — the subtype of the trusted +// X-Platform signal for the direct rail ("web", "android"; "ios" later). The direct rail routes a +// payment to the per-channel shop for separate merchant accounts, accounting and receipts, while +// every shop still credits the one direct wallet (docs/PAYMENTS_DECISIONS_ru.md D42). An empty set +// leaves the direct order and Result-callback endpoints unregistered. +type Shops map[string]Config + +// Channel constants name the direct-rail X-Platform subtypes a shop is keyed by. ChannelWeb is the +// default: an unknown or empty channel routes here on the order side, and the legacy single-shop +// configuration seeds it. +const ( + ChannelWeb = "web" + ChannelAndroid = "android" +) + +// Shop returns the shop that issues an order for channel, falling back to the web shop when channel +// is unknown, empty or not configured. The fallback is safe: routing only chooses the merchant +// account and receipt, never the credited wallet (always direct, D42), so a mis-attributed channel +// costs at most accounting accuracy, not money. The second result is false when neither the channel +// nor the web shop has a merchant login (the rail is unconfigured). +func (s Shops) Shop(channel string) (Config, bool) { + if channel != "" { + if c, ok := s[channel]; ok && c.MerchantLogin != "" { + return c, true + } + } + if c, ok := s[ChannelWeb]; ok && c.MerchantLogin != "" { + return c, true + } + return Config{}, false +} + +// Verifier returns the shop whose Password2 verifies a Result callback delivered to channel's +// dedicated Result route. Unlike Shop it does not fall back to web: each shop's callback is verified +// only by that shop's own credentials, so a route with no configured shop reports false (and its +// handler answers as unregistered). The second result is false when channel has no merchant login. +func (s Shops) Verifier(channel string) (Config, bool) { + if c, ok := s[channel]; ok && c.MerchantLogin != "" { + return c, true + } + return Config{}, false +} + +// Configured reports whether at least one shop has a merchant login (the direct rail is live). +func (s Shops) Configured() bool { + for _, c := range s { + if c.MerchantLogin != "" { + return true + } + } + return false +} diff --git a/backend/internal/robokassa/shops_test.go b/backend/internal/robokassa/shops_test.go new file mode 100644 index 0000000..afdb467 --- /dev/null +++ b/backend/internal/robokassa/shops_test.go @@ -0,0 +1,52 @@ +package robokassa + +import "testing" + +func TestShopsShopRouting(t *testing.T) { + shops := Shops{ + ChannelWeb: {MerchantLogin: "webshop", Password1: "w1", Password2: "w2"}, + ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"}, + } + // Order side: the exact channel wins. + if c, ok := shops.Shop(ChannelAndroid); !ok || c.MerchantLogin != "androidshop" { + t.Errorf("Shop(android) = %q/%v, want androidshop/true", c.MerchantLogin, ok) + } + // Order side: an unknown or empty channel falls back to the web shop (safe — always credits direct). + if c, ok := shops.Shop("ios"); !ok || c.MerchantLogin != "webshop" { + t.Errorf("Shop(ios) = %q/%v, want webshop/true (web fallback)", c.MerchantLogin, ok) + } + if c, ok := shops.Shop(""); !ok || c.MerchantLogin != "webshop" { + t.Errorf("Shop(empty) = %q/%v, want webshop/true (web fallback)", c.MerchantLogin, ok) + } + // No web shop and an unknown channel → unconfigured. + if _, ok := (Shops{ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"}}).Shop("ios"); ok { + t.Error("Shop(ios) with no web shop returned ok, want false") + } +} + +func TestShopsVerifierIsStrict(t *testing.T) { + shops := Shops{ + ChannelWeb: {MerchantLogin: "webshop", Password1: "w1", Password2: "w2"}, + ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"}, + } + // Callback side: the exact channel's own credentials, no web fallback (each callback is verified + // only by its own shop's Password2). + if c, ok := shops.Verifier(ChannelAndroid); !ok || c.MerchantLogin != "androidshop" { + t.Errorf("Verifier(android) = %q/%v, want androidshop/true", c.MerchantLogin, ok) + } + if _, ok := shops.Verifier("ios"); ok { + t.Error("Verifier(ios) returned ok, want false (no fallback)") + } +} + +func TestShopsConfigured(t *testing.T) { + if (Shops{}).Configured() { + t.Error("an empty set reported configured") + } + if (Shops{ChannelWeb: {}}).Configured() { + t.Error("a shop with no merchant login reported configured") + } + if !(Shops{ChannelWeb: {MerchantLogin: "s", Password1: "1", Password2: "2"}}).Configured() { + t.Error("a configured shop reported not configured") + } +} diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 2e3f114..5176daf 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -89,7 +89,7 @@ func (s *Server) registerRoutes() { // payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes. s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout) s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment) - if s.robokassa.MerchantLogin != "" { + if s.robokassa.Configured() { s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult) } } diff --git a/backend/internal/server/handlers_admin_catalog.go b/backend/internal/server/handlers_admin_catalog.go index a0d7686..e43abf1 100644 --- a/backend/internal/server/handlers_admin_catalog.go +++ b/backend/internal/server/handlers_admin_catalog.go @@ -51,7 +51,16 @@ func (s *Server) consoleCatalog(c *gin.Context) { s.consoleError(c, err) return } + rails, err := s.payments.RailStatuses(c.Request.Context()) + if err != nil { + s.consoleError(c, err) + return + } view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly} + for _, rail := range payments.KnownRails { + a := rails[rail] + view.Rails = append(view.Rails, adminconsole.RailStatusRow{Rail: rail, Enabled: a.Enabled, MessageRU: a.MessageRU, MessageEN: a.MessageEN}) + } for _, p := range products { view.Products = append(view.Products, catalogRow(p)) } @@ -72,6 +81,23 @@ func (s *Server) consoleSetReward(c *gin.Context) { s.renderConsoleMessage(c, "Saved", "the rewarded-ads config was updated", catalogBack) } +// consoleSetRailStatus toggles a payment rail's operational kill switch and its user-facing +// off-message (per language) from the catalog page's payment-availability form. An unchecked box +// disables the rail; an unknown rail is refused by the service. +func (s *Server) consoleSetRailStatus(c *gin.Context) { + rail := strings.TrimSpace(c.PostForm("rail")) + a := payments.RailAvailability{ + Enabled: c.PostForm("enabled") == "on", + MessageRU: strings.TrimSpace(c.PostForm("message_ru")), + MessageEN: strings.TrimSpace(c.PostForm("message_en")), + } + if err := s.payments.SetRailStatus(c.Request.Context(), rail, a); err != nil { + s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack) + return + } + s.renderConsoleMessage(c, "Saved", "payment availability was updated", catalogBack) +} + // catalogRow projects a product into its list row. func catalogRow(p payments.AdminProduct) adminconsole.ProductRow { row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index dcf06c6..abe2297 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -61,6 +61,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.POST("/users/:id/grant", s.consoleGrant) gm.POST("/users/:id/grant-product", s.consoleGrantProduct) gm.POST("/users/:id/refund", s.consoleRefund) + gm.POST("/users/:id/purchase-override", s.consoleSetPurchaseOverride) gm.POST("/users/:id/delete", s.consoleDeleteUser) gm.GET("/reasons", s.consoleReasons) gm.POST("/reasons", s.consoleCreateReason) @@ -113,6 +114,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/catalog", s.consoleCatalog) gm.POST("/catalog", s.consoleCreateProduct) gm.POST("/catalog/reward", s.consoleSetReward) + gm.POST("/catalog/rail-status", s.consoleSetRailStatus) gm.GET("/catalog/:id", s.consoleCatalogDetail) gm.POST("/catalog/:id", s.consoleUpdateProduct) gm.POST("/catalog/:id/archive", s.consoleArchiveProduct) @@ -461,6 +463,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) { s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err)) } view.Grant = s.grantForm(ctx) + if ov, oerr := s.payments.PurchaseOverrideFor(ctx, id); oerr == nil { + view.PurchaseOverride = overrideName(ov) + } } s.renderConsole(c, "user_detail", "users", acc.DisplayName, view) } @@ -482,13 +487,54 @@ func financeView(stmt payments.Statement) adminconsole.FinanceView { for _, e := range stmt.Ledger { fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{ Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta, - Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Snapshot: e.Snapshot, + Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Shop: e.Shop, Snapshot: e.Snapshot, At: fmtTime(e.CreatedAt), }) } return fv } +// overrideName renders a purchase override as the form/select value ("default"/"allow"/"deny"). +func overrideName(ov payments.PurchaseOverride) string { + switch ov { + case payments.OverrideAllow: + return "allow" + case payments.OverrideDeny: + return "deny" + default: + return "default" + } +} + +// consoleSetPurchaseOverride sets or clears an account's per-account purchase override (allow / deny +// / default) from the user card. "allow" bypasses only the operational rail switch, never the +// security gates; "default" clears the override (deletes the row). +func (s *Server) consoleSetPurchaseOverride(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + if s.payments == nil { + s.renderConsoleMessage(c, "Unavailable", "payments are not configured", back) + return + } + var ov payments.PurchaseOverride + switch c.PostForm("override") { + case "allow": + ov = payments.OverrideAllow + case "deny": + ov = payments.OverrideDeny + default: + ov = payments.OverrideDefault + } + if err := s.payments.SetPurchaseOverride(c.Request.Context(), id, ov); err != nil { + s.renderConsoleMessage(c, "Update failed", err.Error(), back) + return + } + s.renderConsoleMessage(c, "Saved", "the purchase override was updated", back) +} + // relationRows maps the social graph entries to the cross-linked, date-formatted rows the // user card renders. func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow { diff --git a/backend/internal/server/handlers_intake.go b/backend/internal/server/handlers_intake.go index 10f6491..813b751 100644 --- a/backend/internal/server/handlers_intake.go +++ b/backend/internal/server/handlers_intake.go @@ -12,6 +12,7 @@ import ( "go.uber.org/zap" "scrabble/backend/internal/payments" + "scrabble/backend/internal/robokassa" ) // Ledger/order provider tags per rail. @@ -66,9 +67,25 @@ func (s *Server) handleWalletOrder(c *gin.Context) { s.abortErr(c, err) return } + // Operational availability gate (D45/D46): the per-rail kill switch + the per-account override, + // before any order is opened. Orthogonal to the security gates in the rail branches below — a + // per-account "allow" override bypasses only this switch, never those. The reason is localized to + // the account's language for the user. + lang := "" + if acc, aerr := s.accounts.GetByID(ctx, uid); aerr == nil { + lang = acc.PreferredLanguage + } + if ok, reason, aerr := s.payments.CanPurchase(ctx, uid, payments.RailKey(cxt.Kind, cxt.Subtype), lang); aerr != nil { + s.abortErr(c, aerr) + return + } else if !ok { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, errorResponse{Error: errorBody{Code: "payment_unavailable", Message: reason}}) + return + } switch cxt.Kind { case payments.SourceDirect: - if s.robokassa.MerchantLogin == "" { + shop, ok := s.robokassa.Shop(cxt.Subtype) + if !ok { c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}}) return } @@ -89,7 +106,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) { } c.JSON(http.StatusOK, walletOrderResponse{ OrderID: res.OrderID.String(), - RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title), + RedirectURL: shop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title), Rail: providerRobokassa, }) case payments.SourceVK: @@ -136,7 +153,16 @@ func (s *Server) handleRobokassaResult(c *gin.Context) { for k, val := range params { v.Set(k, val) } - orderID, outSum, ok := s.robokassa.VerifyResult(v) + // The per-shop Result route carries the channel as ?channel=; the legacy bare route defaults to + // the web shop. Each channel is verified only by its own shop's Password2 (Verifier is strict). + channel := c.DefaultQuery("channel", robokassa.ChannelWeb) + shop, ok := s.robokassa.Verifier(channel) + if !ok { + s.log.Warn("robokassa result: unknown shop channel", zap.String("channel", channel)) + c.String(http.StatusBadRequest, "bad sign") + return + } + orderID, outSum, ok := shop.VerifyResult(v) if !ok { s.log.Warn("robokassa result: bad signature") c.String(http.StatusBadRequest, "bad sign") diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 4656c8c..096255f 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -115,9 +115,9 @@ type Deps struct { // Renderer is the image-render sidecar client for the PNG export artifact. A // nil Renderer makes the PNG download answer 404 (the GCG artifact still works). Renderer *render.Client - // Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the - // order and Result-callback endpoints unregistered. - Robokassa robokassa.Config + // Robokassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty + // set leaves the order and Result-callback endpoints unregistered. + Robokassa robokassa.Shops } // Server owns the gin engine, the underlying HTTP server and the readiness @@ -146,7 +146,7 @@ type Server struct { ads *ads.Service payments *payments.Service gamelimits *gamelimits.Service - robokassa robokassa.Config + robokassa robokassa.Shops notifier notify.Publisher console *adminconsole.Renderer exportKey []byte diff --git a/deploy/.env.example b/deploy/.env.example index 7846f75..c559a6c 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -142,6 +142,18 @@ ROBOKASSA_MERCHANT_LOGIN= ROBOKASSA_PASSWORD1= ROBOKASSA_PASSWORD2= ROBOKASSA_TEST= +# Multi-shop direct rail (D42): the vars above seed the "web" channel; add per-channel shops here. +# ROBOKASSA_WEB_* overrides the web shop; ROBOKASSA_ANDROID_* adds the android (RuStore) shop. Each +# credits the one direct wallet; an empty login drops that channel (routing falls back to web). The +# Robokassa cabinet points each shop's Result URL at /pay/robokassa/result/web and .../android. +ROBOKASSA_WEB_MERCHANT_LOGIN= +ROBOKASSA_WEB_PASSWORD1= +ROBOKASSA_WEB_PASSWORD2= +ROBOKASSA_WEB_TEST= +ROBOKASSA_ANDROID_MERCHANT_LOGIN= +ROBOKASSA_ANDROID_PASSWORD1= +ROBOKASSA_ANDROID_PASSWORD2= +ROBOKASSA_ANDROID_TEST= # --- Gateway anti-abuse ------------------------------------------------------ # Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban diff --git a/deploy/README.md b/deploy/README.md index 06e5d06..0b72f4a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -79,6 +79,7 @@ compose binds from this directory. | `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. | | `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) | | `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. | +| `BACKEND_ROBOKASSA_{WEB,ANDROID}_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | secret / variable | Multi-shop direct rail (D42): per-channel Robokassa shops. The legacy `BACKEND_ROBOKASSA_*` seeds the **web** shop; `…_WEB_*` overrides it, `…_ANDROID_*` adds the **android** (RuStore) shop. Each credits the one `direct` wallet; the cabinet Result URL per shop is `/pay/robokassa/result/{web,android}`. Empty login ⇒ that channel unconfigured (order routing falls back to the web shop). | **Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at @@ -256,8 +257,8 @@ web/prod rollout — a signed APK uploaded to RuStore by hand. The build/release the SDK is missing or unreadable. - **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated): `keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`, - then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`), - `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL` + then set the Gitea **secrets** `ANDROID_RUSTORE_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`), + `ANDROID_RUSTORE_KEYSTORE_PASSWORD`, `ANDROID_RUSTORE_KEY_ALIAS`, `ANDROID_RUSTORE_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL` variable to the store listing once published (empty until then — the in-app update button no-ops). - **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 04a81fd..c200392 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -171,6 +171,18 @@ services: BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-} BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-} BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-} + # Per-channel shops for the multi-shop direct rail (D42): the legacy ROBOKASSA_* above seeds + # the "web" channel; ROBOKASSA_WEB_* overrides it and ROBOKASSA_ANDROID_* adds the android + # shop. Each shop credits the one direct wallet; an empty login drops that channel (order + # routing falls back to the web shop). Result URLs: /pay/robokassa/result/{web,android}. + BACKEND_ROBOKASSA_WEB_MERCHANT_LOGIN: ${ROBOKASSA_WEB_MERCHANT_LOGIN:-} + BACKEND_ROBOKASSA_WEB_PASSWORD1: ${ROBOKASSA_WEB_PASSWORD1:-} + BACKEND_ROBOKASSA_WEB_PASSWORD2: ${ROBOKASSA_WEB_PASSWORD2:-} + BACKEND_ROBOKASSA_WEB_TEST: ${ROBOKASSA_WEB_TEST:-} + BACKEND_ROBOKASSA_ANDROID_MERCHANT_LOGIN: ${ROBOKASSA_ANDROID_MERCHANT_LOGIN:-} + BACKEND_ROBOKASSA_ANDROID_PASSWORD1: ${ROBOKASSA_ANDROID_PASSWORD1:-} + BACKEND_ROBOKASSA_ANDROID_PASSWORD2: ${ROBOKASSA_ANDROID_PASSWORD2:-} + BACKEND_ROBOKASSA_ANDROID_TEST: ${ROBOKASSA_ANDROID_TEST:-} # The dictionary lives on a named volume seeded from the image on first boot # (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume # inherits). The admin console writes new version subdirectories here, and the diff --git a/docs/ICONS.md b/docs/ICONS.md index 12ff0d0..17a787d 100644 --- a/docs/ICONS.md +++ b/docs/ICONS.md @@ -54,7 +54,10 @@ already fill the 108 dp canvas (foreground inside the 61 % safe zone, background full-bleed), so they must be placed with **no inset**, and we add the **monochrome** themed layer, neither of which `capacitor-assets` does. Per density mdpi→xxxhdpi (+ldpi): -- `mipmap-*/ic_launcher.png` / `ic_launcher_round.png` — legacy square / round (composite) +- `mipmap-*/ic_launcher.png` — legacy square (< API 26): the **full-bleed master** (large + mark), so old square launchers do not shrink it into the safe zone +- `mipmap-*/ic_launcher_round.png` — legacy round (< API 26): the safe-zone **composite**, + circle-clipped (a round mask would clip the master's corner ✻) - `mipmap-*/ic_launcher_foreground.png` + `ic_launcher_background.png` — adaptive layers - `mipmap-*/ic_launcher_monochrome.png` — themed layer - `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` — **no ``**, with diff --git a/docs/ICON_BRANDBOOK.md b/docs/ICON_BRANDBOOK.md index 0a0e713..4bb0a04 100644 --- a/docs/ICON_BRANDBOOK.md +++ b/docs/ICON_BRANDBOOK.md @@ -123,12 +123,13 @@ So Android is **not** the full-bleed master — it is built as two layers: and square** (no rounded corners; the OS supplies the shape). It fills the whole 108 dp layer, so the sacrificial outer ring is just more wood. - **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside - the mask: - - «Э»: box height **53.9 %**, box center **52.3 % / 49.5 %**. - - ✻: outer diameter **13.2 %** (smaller than the master's), center **76.3 % / 72.7 %**, + the mask. The whole mark is the master's optical placement (nudged right 8 % / down 3 %) + scaled **0.75 about the icon centre** — **25 % smaller**, its «Э» ↔ ✻ arrangement + unchanged — so the ✻ that used to graze the round mask now sits well inside the **61 % + safe zone** with margin to spare: + - «Э»: box height **40.4 %**, box center **51.8 % / 49.6 %**. + - ✻: outer diameter **9.9 %** (smaller than the master's), center **69.7 % / 67.0 %**, tucked closer to the letter. - - The mark is centred, then nudged **right 8 % / down 3 %** so it reads optically - centred under the round mask, and the whole group fits within the **61 % safe zone**. Everything else (palette, grain, gradients, star construction) is identical to the master. diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index eadad8f..f2d9a10 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -57,6 +57,21 @@ for outside the store's own cash desk. Chips funded inside VK (Votes) may only b the VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside the stores. See §4. +**Multi-shop direct rail (D42).** The `direct` rail routes to one Robokassa **merchant shop per +channel** — `web` and `android` (RuStore), `ios` later — chosen by the trusted `X-Platform` subtype; +every shop credits the one `direct` wallet (no per-channel wallet). This is merchant-account +separation for accounting / receipts only: the order records its `shop` (shown in the admin report), +and each shop's Result callback is verified by its own Password2 at `/pay/robokassa/result/`. +Standalone apps (Android/iOS) sign in by email only, so a direct purchase always has the D36 email +anchor (D43). Fiscalization (54-ФЗ via the Robokassa cabinet under one ИП) is a single source +regardless of the number of shops (D41). + +**Payment availability kill switch (D45/D46).** An operator can disable purchases on a rail/channel +(`direct:web` / `direct:android` / `vk` / `telegram`) or for one account, live from `/_gm`, and the +user sees a localized reason on their next attempt (`payment_unavailable`, carried on the additive +`ExecuteResponse.message`). **Fail-open:** a rail with no status row is enabled. A per-account +`allow` override bypasses only this operational switch, never the security gates (D46). + ## 3. Three operations, kept distinct Do not conflate these — they use different keys: diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md index 089774e..f8bf5ba 100644 --- a/docs/PAYMENTS_DECISIONS_ru.md +++ b/docs/PAYMENTS_DECISIONS_ru.md @@ -234,11 +234,53 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз - **D40. Финансовый отчёт per-user в `/_gm`** — балансы сегментов, платежи, траты, гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`, `handlers_admin_console.go:343`). Плюс экспорт журнала (D27). -- **D41. Налоги/чеки — авто через провайдера.** **Robokassa** (direct) — авточек НПД - (режим самозанятого). **VK** — сам процессит Голоса через налоговую (владельцу делать - нечего). **TG Stars** — налоговой стороны нет (для РФ-самозанятого невыводимы легально - = не доход по НПД; принимаем, чек не формируем). ОРД по VK-рекламе — на стороне VK. - *(Не юридическая консультация — точную схему НПД владелец сверяет с налоговой стороной.)* +- **D41. Налоги/чеки — авто через провайдера (ревизия: владелец переходит на ИП).** + **Robokassa** (direct) — фискальный чек по **54-ФЗ** через облачную кассу Robokassa + (провайдер как фискальный агент): чек формирует касса, не банк (банковский слип об оплате — + отдельный документ, не фискальный чек). Настраивается владельцем в ЛКК Robokassa + (касса + ОФД + СНО). Код — опционально: слать детализированный `Receipt` + `Email` + покупателя (берём подтверждённый email-якорь D36) → чек с точным названием пакета и ставкой + по СНО; иначе — обобщённый дефолт-чек из кабинета. `Receipt` входит в подпись + (`MerchantLogin:OutSum:InvId:Receipt:Пароль#1`, URL-кодируется), одна позиция влезает в + текущий GET-redirect (POST-форма — фолбэк на длину URL). Источник чеков один — ИП/касса, + независимо от числа магазинов Robokassa. **VK** — процессит Голоса через налоговую сам. + **TG Stars** — вне рублёвого фискального контура (принимаем, чек не формируем). ОРД по + VK-рекламе — на стороне VK. *(Не юрконсультация — схему по 54-ФЗ/СНО владелец сверяет с + бухгалтером.)* +- **D42. Direct-рельс — несколько магазинов Robokassa = маршрутизация merchant-аккаунтов по + каналу при едином кошельке.** Кошелёк `direct` остаётся один. Отдельные магазины Robokassa + (web, android; ios — позже) различаются только кредами / Return-URL / учётом и **все + зачисляют в сегмент `direct`**. Модель кошельков, стенка трат и origin бенефитов не меняются. + Магазин выбирается по трастовому сигналу канала (`X-Platform` вида `/`: + `direct/web`, `direct/android`), а не по подделываемому клиентскому полю; неизвестный подтип + → магазин `web` (безопасный дефолт). Зачисление от выбора магазина не зависит (всегда + `direct`), поэтому ошибка маршрутизации влияет максимум на учёт, не на деньги. +- **D43. Standalone-приложения (Android, iOS) — вход только по email; покупка требует + email-якорь.** В нативной сборке доступны только guest + email: VK ID-логин (full-page + redirect на `id.vk.com`) не возвращается в Capacitor, TG Login Widget в WebView ненадёжен — + это уже действующая реальность сборки, не новое ограничение. Direct-покупка требует + подтверждённого email-якоря (D36) — он же адрес фискального чека (D41); гость не покупает. + Отдельный сегмент `apple` НЕ заводим: iOS-standalone — тот же `direct`-контекст, внешний гейт + (Robokassa) фондирует единый `direct`. Сегмент `apple` со стенкой (по образцу vk/tg) + понадобился бы только при Apple IAP (StoreKit) — отложено до решения по iOS; и identity-kind + `apple→direct`, и отдельный сегмент — аддитивны, без риска, делаются на месте. +- **D44. Канал платежа хранится на заказе для раздельного учёта/отчёта.** Заказ получает поле + `shop` (web/android/…) — аддитивная колонка. Используется в финансовом отчёте (D40) для + разбивки «из какого магазина/канала платёж». На зачисление и на сегмент кошелька не влияет + (всегда `direct`, D42). +- **D45. Рубильник платежей per-rail + локализованное сообщение.** Оператор выключает покупки на + рельсе/канале (`direct:web`/`direct:android`/`vk`/`telegram`) живьём из `/_gm` (таблица + `payments.rail_status`, редактируется на странице каталога). **Fail-open:** нет строки → рельс + включён (случайно не убить платежи). Выключенный рельс на попытке покупки возвращает + `payment_unavailable` + сообщение админа на языке юзера (RU/EN; пусто → встроенное «временно + недоступно»). Сообщение едет клиенту через аддитивное `ExecuteResponse.message` (envelope-слой, + frozen-contract-safe). Гейт ортогонален security-гейтам. +- **D46. Per-user override покупок (allow/deny/default).** На карточке юзера (`/_gm`) — + переопределение на аккаунт: `allow` (всегда разрешить), `deny` (всегда запретить), `default` (по + рельс-рубильнику). Хранится в `payments.account_payment_override` строкой только для не-default + (нет строки = default; снять = удалить строку). **`allow` обходит ТОЛЬКО ops-рубильник, НЕ + security-гейты** (D36 email-якорь, VK-iOS-фриз, trusted-платформа, min-client-version) — те + проверяются отдельно в CreateOrder. ## Заметки к оформлению документов @@ -254,10 +296,17 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить feedback-память `prefer-interview-mode` после plan mode. -## Все развилки закрыты (D1-D41) +## Все развилки закрыты (D1-D46) Интервью завершено. Дальше — оформление документов и реализация по релизам. +**Дополнение 2026-07-14 (интервью с владельцем).** D41 ревизована (владелец переходит на +ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит +`direct`-рельса Robokassa на магазины по каналу (web/android; ios позже) при едином кошельке +`direct` и входе email-only в standalone-приложениях (этап E10 в `PLAN.md`). Плюс D45-D46 — +рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация +(B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца). + ## План внедрения (черновик PLAN.md — «слоями») Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index 66d6363..cc1bdb2 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -57,6 +57,21 @@ в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне сторов. См. §4. +**Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин +Robokassa **на канал** — `web` и `android` (RuStore), позже `ios` — по трастовому подтипу +`X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет). +Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в +админ-отчёте), а Result-колбэк каждого магазина проверяется своим Password2 по +`/pay/robokassa/result/`. В standalone-приложениях (Android/iOS) вход только по email, поэтому +у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под +одним ИП) — один источник независимо от числа магазинов (D41). + +**Рубильник платежей (D45/D46).** Оператор выключает покупки на рельсе/канале (`direct:web` / +`direct:android` / `vk` / `telegram`) или для одного аккаунта, живьём из `/_gm`, и юзер на следующей +попытке видит локализованную причину (`payment_unavailable`, едет на аддитивном +`ExecuteResponse.message`). **Fail-open:** рельс без строки статуса — включён. Per-account `allow` +обходит только этот ops-рубильник, не security-гейты (D46). + ## 3. Три операции, которые нельзя путать Не смешивать — у них разные ключи: diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 29d0dbe..7610e07 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -498,10 +498,15 @@ type robokassaResultResp struct { // RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the // single writer, which verifies the signature and credits) and returns the body to echo to -// Robokassa ("OK"). params carries the provider's raw form fields. -func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) { +// Robokassa ("OK"). params carries the provider's raw form fields; channel selects the +// per-shop signature verifier (empty → the web shop). +func (c *Client) RobokassaResult(ctx context.Context, channel string, params map[string]string) (string, error) { var out robokassaResultResp - err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/robokassa/result", "", "", params, &out) + path := "/api/v1/internal/payments/robokassa/result" + if channel != "" { + path += "?channel=" + url.QueryEscape(channel) + } + err := c.do(ctx, http.MethodPost, path, "", "", params, &out) return out.Response, err } diff --git a/gateway/internal/connectsrv/cors.go b/gateway/internal/connectsrv/cors.go new file mode 100644 index 0000000..faf59e0 --- /dev/null +++ b/gateway/internal/connectsrv/cors.go @@ -0,0 +1,47 @@ +package connectsrv + +import "net/http" + +// nativeWebViewOrigins are the browser Origins of the packaged native apps. Capacitor serves the +// bundled SPA from a localhost scheme, so its Connect calls to the gateway are cross-origin; the web +// app is same-origin and needs no entry. Requests carry Authorization, so the allowlist reflects the +// exact origin rather than "*". +var nativeWebViewOrigins = map[string]bool{ + "https://localhost": true, // Capacitor Android (default https scheme) + "http://localhost": true, // Capacitor with the http scheme + "capacitor://localhost": true, // Capacitor iOS default scheme +} + +// withNativeCORS answers the CORS preflight and adds the CORS response headers for the packaged +// native apps' cross-origin calls to the Connect edge. Without it the WebView blocks every RPC on the +// preflight (the gateway otherwise returns 405 with no Access-Control-Allow-Origin), so a native +// build can never reach the gateway and stays stuck offline. Same-origin (web) and any non-native +// origin are untouched. +func withNativeCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if nativeWebViewOrigins[origin] { + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Add("Vary", "Origin") + h.Set("Access-Control-Allow-Credentials", "true") + // The client interceptor reads the soft-tier update signal off the response. + h.Set("Access-Control-Expose-Headers", "X-Update-Recommended") + if r.Method == http.MethodOptions { + h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + // Reflect the requested headers (the origin is already allowlisted): the Connect client + // sends Connect-Protocol-Version / Connect-Timeout-Ms plus X-Client-Version and + // Authorization, and the exact set varies by call. + if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" { + h.Set("Access-Control-Allow-Headers", reqHeaders) + } else { + h.Set("Access-Control-Allow-Headers", "Content-Type, Connect-Protocol-Version, X-Client-Version, Authorization") + } + h.Set("Access-Control-Max-Age", "86400") + w.WriteHeader(http.StatusNoContent) + return + } + } + next.ServeHTTP(w, r) + }) +} diff --git a/gateway/internal/connectsrv/cors_test.go b/gateway/internal/connectsrv/cors_test.go new file mode 100644 index 0000000..f191a6a --- /dev/null +++ b/gateway/internal/connectsrv/cors_test.go @@ -0,0 +1,54 @@ +package connectsrv + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestWithNativeCORS(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + h := withNativeCORS(next) + + // Native-origin preflight → 204 with the CORS headers, reflecting the requested headers, without + // reaching the inner handler. + req := httptest.NewRequest(http.MethodOptions, "/scrabble.edge.v1.Gateway/Execute", nil) + req.Header.Set("Origin", "https://localhost") + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Headers", "content-type,connect-protocol-version,x-client-version") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("preflight status = %d, want 204", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://localhost" { + t.Errorf("Allow-Origin = %q, want https://localhost", got) + } + if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "content-type,connect-protocol-version,x-client-version" { + t.Errorf("Allow-Headers = %q, want the reflected set", got) + } + + // Native-origin POST → the CORS headers are set and the inner handler runs. + req = httptest.NewRequest(http.MethodPost, "/scrabble.edge.v1.Gateway/Execute", nil) + req.Header.Set("Origin", "capacitor://localhost") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("POST status = %d, want 200 (passed through)", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" { + t.Errorf("POST Allow-Origin = %q, want capacitor://localhost", got) + } + + // A non-native origin gets no CORS headers and still passes through (web is same-origin anyway). + req = httptest.NewRequest(http.MethodPost, "/scrabble.edge.v1.Gateway/Execute", nil) + req.Header.Set("Origin", "https://evil.example") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("non-native POST status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("non-native Allow-Origin = %q, want empty", got) + } +} diff --git a/gateway/internal/connectsrv/robokassa_channel_test.go b/gateway/internal/connectsrv/robokassa_channel_test.go new file mode 100644 index 0000000..551a3f7 --- /dev/null +++ b/gateway/internal/connectsrv/robokassa_channel_test.go @@ -0,0 +1,17 @@ +package connectsrv + +import "testing" + +func TestRobokassaChannel(t *testing.T) { + cases := map[string]string{ + "/pay/robokassa/result": "", // legacy bare path → the backend defaults to the web shop + "/pay/robokassa/result/": "", // trailing slash, no channel → the web default + "/pay/robokassa/result/web": "web", + "/pay/robokassa/result/android": "android", + } + for path, want := range cases { + if got := robokassaChannel(path); got != want { + t.Errorf("robokassaChannel(%q) = %q, want %q", path, got, want) + } + } +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index d5b2cfd..c789790 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -274,8 +274,11 @@ func (s *Server) HTTPHandler() http.Handler { mux.Handle("/dict/", s.dictBytesHandler()) mux.Handle("/dl/", s.exportDownloadHandler()) // Direct-rail (Robokassa) return + callback routes: the server Result callback (the single - // crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. + // crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. The + // per-shop callback rides a channel suffix (/pay/robokassa/result/); the bare path is + // the legacy web shop. Caddy's /pay/* matcher already forwards both, so no Caddyfile change. mux.Handle("/pay/robokassa/result", s.robokassaResultHandler()) + mux.Handle("/pay/robokassa/result/", s.robokassaResultHandler()) mux.Handle("/pay/vk/callback", s.vkCallbackHandler()) mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята.")) mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена.")) @@ -299,7 +302,7 @@ func (s *Server) HTTPHandler() http.Handler { // honeypot hit is turned away before the body cap and the mux. Every request // body on the public listener is then capped (the admin proxy POSTs included); // the h2c server carries explicit stream/idle sizing. - return h2c.NewHandler(s.abuseGuard(maxBodyHandler(s.maxBodyBytes, mux)), &http2.Server{ + return h2c.NewHandler(s.abuseGuard(withNativeCORS(maxBodyHandler(s.maxBodyBytes, mux))), &http2.Server{ MaxConcurrentStreams: h2cMaxConcurrentStreams, IdleTimeout: h2cIdleTimeout, }) @@ -469,6 +472,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut return connect.NewResponse(&edgev1.ExecuteResponse{ RequestId: req.Msg.GetRequestId(), ResultCode: code, + Message: transcode.DomainMessage(err), }), nil } s.log.Error("execute failed", zap.String("message_type", msgType), zap.Error(err)) @@ -637,6 +641,21 @@ func (s *Server) exportDownloadHandler() http.Handler { }) } +// robokassaResultPrefix is the channel-suffixed Result path; the segment after it names the +// per-shop channel (matches the direct-rail X-Platform subtype: "web", "android"). +const robokassaResultPrefix = "/pay/robokassa/result/" + +// robokassaChannel extracts the shop channel from a Result callback path: the segment after +// robokassaResultPrefix, or "" for the legacy bare /pay/robokassa/result (the backend then defaults +// to the web shop). The backend selects the per-shop signature verifier from it. +func robokassaChannel(path string) string { + rest := strings.TrimPrefix(path, robokassaResultPrefix) + if rest == path { + return "" + } + return rest +} + // robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single // writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's // "OK" to Robokassa on success; any error tells Robokassa the notification was not accepted, @@ -661,7 +680,7 @@ func (s *Server) robokassaResultHandler() http.Handler { for k := range r.Form { params[k] = r.Form.Get(k) } - resp, err := s.backend.RobokassaResult(r.Context(), params) + resp, err := s.backend.RobokassaResult(r.Context(), robokassaChannel(r.URL.Path), params) if err != nil { s.log.Warn("robokassa result proxy failed", zap.Error(err)) http.Error(w, "not accepted", http.StatusBadGateway) diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index e3c60e4..6fc60b5 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -189,6 +189,17 @@ func (r *Registry) Lookup(messageType string) (Op, bool) { return op, ok } +// DomainMessage returns the human-readable, already-localized reason a backend domain error carries +// for the user (e.g. an operator's payment-unavailable explanation), or "" when it carries none. It +// rides the Execute envelope's message field alongside the result code. +func DomainMessage(err error) string { + var apiErr *backendclient.APIError + if errors.As(err, &apiErr) { + return apiErr.Message + } + return "" +} + // DomainCode maps an error to a stable result code to surface in the Execute // envelope, reporting false for an unexpected error the caller should treat as a // transport-level internal failure. diff --git a/gateway/proto/edge/v1/edge.pb.go b/gateway/proto/edge/v1/edge.pb.go index 95c7ba2..992def3 100644 --- a/gateway/proto/edge/v1/edge.pb.go +++ b/gateway/proto/edge/v1/edge.pb.go @@ -94,10 +94,14 @@ func (x *ExecuteRequest) GetRequestId() string { // ExecuteResponse is the unary reply. result_code is "ok" on success or a stable // error code; payload is the FlatBuffers-encoded response body (empty on error). type ExecuteResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - ResultCode string `protobuf:"bytes,2,opt,name=result_code,json=resultCode,proto3" json:"result_code,omitempty"` - Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + ResultCode string `protobuf:"bytes,2,opt,name=result_code,json=resultCode,proto3" json:"result_code,omitempty"` + Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` + // message is an optional, already-localized human-readable reason a domain outcome may carry for + // the user (e.g. a payment-unavailable explanation set by an operator). Additive and + // frozen-contract-safe; empty for the common case where result_code alone suffices. + Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -153,6 +157,13 @@ func (x *ExecuteResponse) GetPayload() []byte { return nil } +func (x *ExecuteResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + // SubscribeRequest opens the live stream. It is empty: the session is taken from // the Authorization header. type SubscribeRequest struct { @@ -262,13 +273,14 @@ const file_edge_v1_edge_proto_rawDesc = "" + "\fmessage_type\x18\x01 \x01(\tR\vmessageType\x12\x18\n" + "\apayload\x18\x02 \x01(\fR\apayload\x12\x1d\n" + "\n" + - "request_id\x18\x03 \x01(\tR\trequestId\"k\n" + + "request_id\x18\x03 \x01(\tR\trequestId\"\x85\x01\n" + "\x0fExecuteResponse\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n" + "\vresult_code\x18\x02 \x01(\tR\n" + "resultCode\x12\x18\n" + - "\apayload\x18\x03 \x01(\fR\apayload\"\x12\n" + + "\apayload\x18\x03 \x01(\fR\apayload\x12\x18\n" + + "\amessage\x18\x04 \x01(\tR\amessage\"\x12\n" + "\x10SubscribeRequest\"P\n" + "\x05Event\x12\x12\n" + "\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n" + diff --git a/gateway/proto/edge/v1/edge.proto b/gateway/proto/edge/v1/edge.proto index 447c63f..e2ab4b3 100644 --- a/gateway/proto/edge/v1/edge.proto +++ b/gateway/proto/edge/v1/edge.proto @@ -35,6 +35,10 @@ message ExecuteResponse { string request_id = 1; string result_code = 2; bytes payload = 3; + // message is an optional, already-localized human-readable reason a domain outcome may carry for + // the user (e.g. a payment-unavailable explanation set by an operator). Additive and + // frozen-contract-safe; empty for the common case where result_code alone suffices. + string message = 4; } // SubscribeRequest opens the live stream. It is empty: the session is taken from diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 48fdd77..8fb8bd2 100644 Binary files a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 9024f34..63228c3 100644 Binary files a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png index f83e989..ddaafc8 100644 Binary files a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 8909874..16c1180 100644 Binary files a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png index a4ff54b..c546019 100644 Binary files a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png differ diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png index eb08478..4fe946d 100644 Binary files a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png differ diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_monochrome.png index 1d171d7..5f3ae18 100644 Binary files a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_monochrome.png and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_monochrome.png differ diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png index 1be6faa..168bd44 100644 Binary files a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png differ diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index ee098ab..9a2a994 100644 Binary files a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index e2a7460..a648a5a 100644 Binary files a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png index 497be0f..1adbd1a 100644 Binary files a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 05d4e1c..1ed63a7 100644 Binary files a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 8e448c3..94b3c96 100644 Binary files a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index 69a8967..7257423 100644 Binary files a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png index b0e40ea..5b6f910 100644 Binary files a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index b517260..97bb510 100644 Binary files a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 88ece49..2218429 100644 Binary files a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index 15a62cf..032d0f3 100644 Binary files a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png index 1eae863..57d6eb4 100644 Binary files a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 38ba881..9c168d0 100644 Binary files a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index b705035..6dffad5 100644 Binary files a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 717737b..7c13b89 100644 Binary files a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png index c22f4e4..426fadd 100644 Binary files a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 6133338..abac6c3 100644 Binary files a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/ui/android/build.gradle b/ui/android/build.gradle index f8f0e43..471660d 100644 --- a/ui/android/build.gradle +++ b/ui/android/build.gradle @@ -24,6 +24,17 @@ allprojects { } } +// Pin build-tools to the compileSdk-matching version for every Android module (the app + the +// Capacitor modules regenerated by `cap sync`), so AGP does not fall back to its default (35.0.0), +// which the provisioned / read-only CI SDK does not have installed. +subprojects { + afterEvaluate { + if (project.hasProperty('android')) { + android.buildToolsVersion = "36.0.0" + } + } +} + task clean(type: Delete) { delete rootProject.buildDir } diff --git a/ui/assets/icon-foreground.png b/ui/assets/icon-foreground.png index 12b4a8e..123fd76 100644 Binary files a/ui/assets/icon-foreground.png and b/ui/assets/icon-foreground.png differ diff --git a/ui/e2e/native.spec.ts b/ui/e2e/native.spec.ts index d3d8666..4fc1f5b 100644 --- a/ui/e2e/native.spec.ts +++ b/ui/e2e/native.spec.ts @@ -43,20 +43,22 @@ test.describe('native offline-first', () => { await page.goto('/'); await expectOfflineGuestLobby(page); - // Play a local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). The dictionary - // comes from the APK's bundled tier (./dict/scrabble_en@dev.dawg served from dist-e2e), so no - // network is needed — this is the device-local guest playing with zero connectivity. + // Play a local Erudit vs_ai game with a pinned bag seed (deals the rack ОЖЬЯНАО). Erudit is the + // only variant the profileless offline guest is offered (the new-account default), so the single + // `.variant` click both selects it and asserts nothing else is on offer. The dictionary comes from + // the APK's bundled tier (./dict/ru_erudit@dev.dawg served from dist-e2e), so no network is needed + // — this is the device-local guest playing with zero connectivity. await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1')); await page.locator('button.tab').nth(0).click(); - await page.locator('.variant', { hasText: 'Scrabble' }).click(); + await page.locator('.variant').click(); await page.locator('button.invite').click(); await expect(page.locator('[data-cell]').first()).toBeVisible(); - // The human plays WAY horizontally across the centre (7,5)-(7,7); the local robot replies with a - // real move (which needs the bundled dictionary), so the board gains tiles beyond WAY's three. - await placeTile(page, 'W', 7, 5); - await placeTile(page, 'A', 7, 6); - await placeTile(page, 'Y', 7, 7); + // The human plays НОЖ horizontally across the centre (7,5)-(7,7); the local robot replies with a + // real move (which needs the bundled dictionary), so the board gains tiles beyond НОЖ's three. + await placeTile(page, 'Н', 7, 5); + await placeTile(page, 'О', 7, 6); + await placeTile(page, 'Ж', 7, 7); await expect(page.locator('[data-cell].pending')).toHaveCount(3); await page.locator('.make').click(); await expect(async () => { @@ -95,14 +97,15 @@ test.describe('native offline-first', () => { await expectOfflineGuestLobby(page); // New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest. - // A minimal create: set the mandatory host (master) PIN, decline a seat, English, two open seats. + // A minimal create: set the mandatory host (master) PIN, decline a seat, the sole offered variant + // (Erudit — the profileless guest's default), two open seats. await page.locator('button.tab').nth(0).click(); await page.getByRole('button', { name: /With friends|друзьями/i }).click(); await page.locator('.hostpin .plink').click(); await typePin(page, '9999'); await typePin(page, '9999'); await page.getByRole('button', { name: /^(No|Нет)$/ }).click(); - await page.locator('.variant', { hasText: 'Scrabble' }).click(); + await page.locator('.variant').click(); await page.locator('.pname').nth(0).fill('Ann'); await page.locator('.pname').nth(1).fill('Bob'); await page.locator('button.invite').click(); diff --git a/ui/public/icon-maskable-512-dark.png b/ui/public/icon-maskable-512-dark.png index 9b6d045..24389fa 100644 Binary files a/ui/public/icon-maskable-512-dark.png and b/ui/public/icon-maskable-512-dark.png differ diff --git a/ui/public/icon-maskable-512.png b/ui/public/icon-maskable-512.png index ee6e488..a6aad67 100644 Binary files a/ui/public/icon-maskable-512.png and b/ui/public/icon-maskable-512.png differ diff --git a/ui/src/gen/edge/v1/edge_pb.ts b/ui/src/gen/edge/v1/edge_pb.ts index db58ee8..cbf74d0 100644 --- a/ui/src/gen/edge/v1/edge_pb.ts +++ b/ui/src/gen/edge/v1/edge_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file edge/v1/edge.proto. */ export const file_edge_v1_edge: GenFile = /*@__PURE__*/ - fileDesc("ChJlZGdlL3YxL2VkZ2UucHJvdG8SEHNjcmFiYmxlLmVkZ2UudjEiSwoORXhlY3V0ZVJlcXVlc3QSFAoMbWVzc2FnZV90eXBlGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEgoKcmVxdWVzdF9pZBgDIAEoCSJLCg9FeGVjdXRlUmVzcG9uc2USEgoKcmVxdWVzdF9pZBgBIAEoCRITCgtyZXN1bHRfY29kZRgCIAEoCRIPCgdwYXlsb2FkGAMgASgMIhIKEFN1YnNjcmliZVJlcXVlc3QiOAoFRXZlbnQSDAoEa2luZBgBIAEoCRIPCgdwYXlsb2FkGAIgASgMEhAKCGV2ZW50X2lkGAMgASgJMqUBCgdHYXRld2F5Ek4KB0V4ZWN1dGUSIC5zY3JhYmJsZS5lZGdlLnYxLkV4ZWN1dGVSZXF1ZXN0GiEuc2NyYWJibGUuZWRnZS52MS5FeGVjdXRlUmVzcG9uc2USSgoJU3Vic2NyaWJlEiIuc2NyYWJibGUuZWRnZS52MS5TdWJzY3JpYmVSZXF1ZXN0Ghcuc2NyYWJibGUuZWRnZS52MS5FdmVudDABQidaJXNjcmFiYmxlL2dhdGV3YXkvcHJvdG8vZWRnZS92MTtlZGdldjFiBnByb3RvMw"); + fileDesc("ChJlZGdlL3YxL2VkZ2UucHJvdG8SEHNjcmFiYmxlLmVkZ2UudjEiSwoORXhlY3V0ZVJlcXVlc3QSFAoMbWVzc2FnZV90eXBlGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEgoKcmVxdWVzdF9pZBgDIAEoCSJcCg9FeGVjdXRlUmVzcG9uc2USEgoKcmVxdWVzdF9pZBgBIAEoCRITCgtyZXN1bHRfY29kZRgCIAEoCRIPCgdwYXlsb2FkGAMgASgMEg8KB21lc3NhZ2UYBCABKAkiEgoQU3Vic2NyaWJlUmVxdWVzdCI4CgVFdmVudBIMCgRraW5kGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEAoIZXZlbnRfaWQYAyABKAkypQEKB0dhdGV3YXkSTgoHRXhlY3V0ZRIgLnNjcmFiYmxlLmVkZ2UudjEuRXhlY3V0ZVJlcXVlc3QaIS5zY3JhYmJsZS5lZGdlLnYxLkV4ZWN1dGVSZXNwb25zZRJKCglTdWJzY3JpYmUSIi5zY3JhYmJsZS5lZGdlLnYxLlN1YnNjcmliZVJlcXVlc3QaFy5zY3JhYmJsZS5lZGdlLnYxLkV2ZW50MAFCJ1olc2NyYWJibGUvZ2F0ZXdheS9wcm90by9lZGdlL3YxO2VkZ2V2MWIGcHJvdG8z"); /** * ExecuteRequest is the unary envelope. message_type selects the operation; @@ -71,6 +71,15 @@ export type ExecuteResponse = Message<"scrabble.edge.v1.ExecuteResponse"> & { * @generated from field: bytes payload = 3; */ payload: Uint8Array; + + /** + * message is an optional, already-localized human-readable reason a domain outcome may carry for + * the user (e.g. a payment-unavailable explanation set by an operator). Additive and + * frozen-contract-safe; empty for the common case where result_code alone suffices. + * + * @generated from field: string message = 4; + */ + message: string; }; /** diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 216bc78..82b5233 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -118,7 +118,9 @@ export function createTransport(baseUrl: string): GatewayClient { maintenanceRecovered(); if (res.resultCode && res.resultCode !== 'ok') { if (res.resultCode === UPDATE_REQUIRED && !opts?.silent) reportUpdateRequired(); - throw new GatewayError(res.resultCode); + // Carry the envelope's optional human-readable message (e.g. an operator's payment-unavailable + // explanation) so a caller that shows it to the user has the localized text. + throw new GatewayError(res.resultCode, res.message || undefined); } return res.payload; } diff --git a/ui/src/lib/variants.test.ts b/ui/src/lib/variants.test.ts index e8a208d..9558a62 100644 --- a/ui/src/lib/variants.test.ts +++ b/ui/src/lib/variants.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { ALL_VARIANTS, + DEFAULT_VARIANTS, availableVariants, supportsMultipleWordsToggle, multipleWordsForRequest, @@ -16,9 +17,11 @@ describe('ALL_VARIANTS', () => { }); describe('availableVariants', () => { - it('is ungated (all variants) for an empty or absent preference set', () => { - expect(availableVariants([])).toEqual(ALL_VARIANTS); - expect(availableVariants(undefined)).toEqual(ALL_VARIANTS); + it('falls back to the Erudit-only default for an empty or absent preference set', () => { + const fallback = ALL_VARIANTS.filter((v) => DEFAULT_VARIANTS.includes(v.id)); + expect(fallback.map((v) => v.id)).toEqual(['erudit_ru']); + expect(availableVariants([])).toEqual(fallback); + expect(availableVariants(undefined)).toEqual(fallback); }); it('offers only the preferred variants', () => { diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts index 5981007..b520082 100644 --- a/ui/src/lib/variants.ts +++ b/ui/src/lib/variants.ts @@ -62,13 +62,23 @@ export function usesStarBlank(v: Variant): boolean { return v === 'erudit_ru'; } -// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set -// they enabled in Settings). An empty or absent set is ungated (returns every variant) -// — a safety fallback; a real profile always carries at least one preference. +// DEFAULT_VARIANTS is the variant set a player sees before any preference is stored — a fresh +// or offline native client whose profile has not been synced yet (the device-local guest boots +// with no profile at all). It mirrors the backend's new-account default (the account service +// seeds Erudit only), so the local guest and a later synced account agree on what is enabled. +// Every dictionary is still bundled; the other variants are simply off until the player turns +// them on in Settings. +export const DEFAULT_VARIANTS: Variant[] = ['erudit_ru']; + +// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set they +// enabled in Settings). An empty or absent set falls back to DEFAULT_VARIANTS rather than every +// variant: a real profile always carries at least one preference, and a profileless client (a +// fresh offline native launch) must match the server's Erudit-only default instead of exposing +// the English game before the player opts in. export function availableVariants(preferences: Variant[] | undefined): VariantOption[] { const prefs = preferences ?? []; - if (prefs.length === 0) return ALL_VARIANTS; - return ALL_VARIANTS.filter((v) => prefs.includes(v.id)); + const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs; + return ALL_VARIANTS.filter((v) => effective.includes(v.id)); } // supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index ff4c1c5..e3f5ce9 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -200,7 +200,13 @@ openExternalUrl(order.redirectUrl); } } catch (e) { - handleError(e); + // A rail kill switch (or a per-account block) returns payment_unavailable with an operator's + // localized explanation — show it to the user instead of the generic error. + if (e instanceof GatewayError && e.code === 'payment_unavailable') { + showToast(e.message); + } else { + handleError(e); + } } finally { busyId = null; }
    TimeKindSourceOriginChipsOrderProviderDetail
    TimeKindSourceOriginChipsOrderProviderShopDetail
    {{.At}}{{.Kind}}{{.Source}}{{.Origin}}{{.ChipsDelta}}{{if .Order}}{{.Order}}{{end}}{{.Provider}}{{if .Snapshot}}{{.Snapshot}}{{end}}
    {{.At}}{{.Kind}}{{.Source}}{{.Origin}}{{.ChipsDelta}}{{if .Order}}{{.Order}}{{end}}{{.Provider}}{{.Shop}}{{if .Snapshot}}{{.Snapshot}}{{end}} {{if and (eq .Kind "fund") .Order}}
    {{end}}