Compare commits
13 Commits
v1.20.1
...
4a3e12c85a
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a3e12c85a | |||
| 54bc31c619 | |||
| d89438040b | |||
| 363f1632b1 | |||
| f0b7ad47d4 | |||
| 0e11817654 | |||
| d0e473920c | |||
| 0e56e4de9a | |||
| 8d3f862b41 | |||
| 74842dc624 | |||
| 1507ceb793 | |||
| 4b3c9d4fdd | |||
| eef90a152e |
@@ -3,7 +3,7 @@
|
|||||||
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
|
# 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).
|
# 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).
|
# 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 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
|
# 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
|
- name: Decode the release keystore
|
||||||
id: keystore
|
id: keystore
|
||||||
env:
|
env:
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_BASE64 }}
|
||||||
run: |
|
run: |
|
||||||
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
|
||||||
@@ -152,16 +152,16 @@ jobs:
|
|||||||
echo "keystore decoded -> signed release build"
|
echo "keystore decoded -> signed release build"
|
||||||
else
|
else
|
||||||
echo "file=" >> "$GITHUB_OUTPUT"
|
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
|
fi
|
||||||
|
|
||||||
- name: Assemble the release APK
|
- name: Assemble the release APK
|
||||||
working-directory: ui/android
|
working-directory: ui/android
|
||||||
env:
|
env:
|
||||||
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
|
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_PASSWORD }}
|
||||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RUSTORE_KEY_ALIAS }}
|
||||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEY_PASSWORD }}
|
||||||
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
|
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
|
||||||
|
|
||||||
- name: Upload the APK artifact
|
- name: Upload the APK artifact
|
||||||
|
|||||||
@@ -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.
|
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
|
> **Where we are.** The legal-documents track, the release-prep hygiene, the icon rebrand **and the
|
||||||
> DONE. The RuStore release is now gated on **owner-side** items only (keystore, RuStore account) plus
|
> release keystore** are DONE (the keystore + its Gitea secrets set by the owner — see below). The
|
||||||
> the merge/tag chain. Do not re-do or re-litigate the finished work; do not re-ask the locked decisions.
|
> 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`).**
|
> **Branches / PRs.** All merged to `development` (contour-green): legal documents (#253); release-prep
|
||||||
> - `development` — has the **legal-documents track**, merged via **PR #253** (green, deployed to the
|
> hygiene + the re-enabled `android-build` workflow (#254); the payments E10/E11 (#266/#267); the
|
||||||
> test contour). `/privacy/`, `/eula/`, `/offer/` are live there, bilingual RU/EN.
|
> signing-secret rename (#268). No open Android branch.
|
||||||
> - `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).
|
|
||||||
>
|
>
|
||||||
> **Done this session.**
|
> **Done this session.**
|
||||||
> - **Legal documents** (satisfies the RuStore *hosted privacy-policy URL* prerequisite): authored
|
> - **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
|
> 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
|
> **tag `vX.Y.Z`** for the release (the version stamps the APK `versionName`/`versionCode` and the
|
||||||
> `X-Client-Version` header).
|
> `X-Client-Version` header).
|
||||||
> 3. **Release keystore.** OWNER generates it (`keytool -genkeypair -v -keystore erudit-release.jks
|
> 3. **Release keystore — DONE (owner, 2026-07-14).** `erudit-release.jks` (RSA 4096, alias `erudit`,
|
||||||
> -alias erudit -keyalg RSA -keysize 4096 -validity 10000`), base64-encodes it, sets the four Gitea
|
> PKCS12) generated + backed up off-host; the four Gitea secrets are set under the RuStore-specific
|
||||||
> **secrets** `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` /
|
> names `ANDROID_RUSTORE_KEYSTORE_BASE64` / `ANDROID_RUSTORE_KEYSTORE_PASSWORD` /
|
||||||
> `ANDROID_KEY_PASSWORD`, and backs the `.jks` up **off-host** (loss ⇒ the app can never be updated).
|
> `ANDROID_RUSTORE_KEY_ALIAS` / `ANDROID_RUSTORE_KEY_PASSWORD` (loss ⇒ the app can never be updated —
|
||||||
> The AGENT provides the exact commands on request. Without the secrets the workflow builds an
|
> the cert is pinned at the first RuStore upload). Google Play, later, would use its own
|
||||||
> UNSIGNED APK (a dry run still proves the pipeline).
|
> `ANDROID_PLAY_*` upload key (Play App Signing) — a separate key.
|
||||||
> 4. **RuStore developer account.** OWNER (verified legal entity or self-employed / самозанятый). Gates
|
> 4. **RuStore developer account.** OWNER (verified legal entity or self-employed / самозанятый). Gates
|
||||||
> publication, not the build.
|
> publication, not the build.
|
||||||
> 5. **Dispatch the signed build.** AGENT: on `master`, dispatch `android-build` (`confirm=build`) via
|
> 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
|
> 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
|
> the store deep-link so the update button works on future gated releases (empty until then — the
|
||||||
> gate is dormant in the MVP).
|
> 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
|
- **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
|
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
|
||||||
|
|||||||
@@ -39,10 +39,13 @@ status — without re-deriving decisions.
|
|||||||
| E7 | Admin, reports & catalog | 2 | DONE |
|
| E7 | Admin, reports & catalog | 2 | DONE |
|
||||||
| E8 | Guest limits | — | DONE |
|
| E8 | Guest limits | — | DONE |
|
||||||
| E9 | Tournament fee | future | TODO |
|
| 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 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
|
**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` (`<kind>/<subtype>`, `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)
|
## Verification & CI (all stages)
|
||||||
|
|
||||||
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
|
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 19 KiB |
@@ -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 BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png'));
|
||||||
const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
|
const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
|
||||||
const MONO = uri(join(DIR, 'android-monochrome.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)]
|
// 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] };
|
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_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_foreground.png'), await shot(im(FG, fg), fg, true));
|
||||||
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
|
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
|
||||||
const comp = `<div style="position:relative;width:${lg}px;height:${lg}px">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
// Square launcher: the full-bleed master (large mark). Round launcher: the safe-zone
|
||||||
writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false));
|
// 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 = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
const round = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
||||||
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
|
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
|
||||||
console.log('mipmap-' + d, 'ok');
|
console.log('mipmap-' + d, 'ok');
|
||||||
|
|||||||
@@ -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):
|
// 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] };
|
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
|
// 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 ✻):
|
// 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] };
|
// 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 = {
|
const PALETTE = {
|
||||||
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
|
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 24 KiB |
@@ -30,6 +30,18 @@
|
|||||||
<div><button type="submit">Save</button></div>
|
<div><button type="submit">Save</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="panel"><h2>Payment availability (kill switch)</h2>
|
||||||
|
<p class="note">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.</p>
|
||||||
|
{{range .Rails}}
|
||||||
|
<form class="form col" method="post" action="/_gm/catalog/rail-status">
|
||||||
|
<input type="hidden" name="rail" value="{{.Rail}}">
|
||||||
|
<label><input type="checkbox" name="enabled" {{if .Enabled}}checked{{end}}> <code>{{.Rail}}</code> — purchases enabled</label>
|
||||||
|
<label>Message RU <input type="text" name="message_ru" maxlength="200" value="{{.MessageRU}}"></label>
|
||||||
|
<label>Message EN <input type="text" name="message_en" maxlength="200" value="{{.MessageEN}}"></label>
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
<section class="panel"><h2>Products</h2>
|
<section class="panel"><h2>Products</h2>
|
||||||
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
|
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
|
|||||||
@@ -72,14 +72,24 @@
|
|||||||
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
|
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{else}}<p class="note">no balances or benefits</p>{{end}}
|
{{else}}<p class="note">no balances or benefits</p>{{end}}
|
||||||
|
<h3>Payment override</h3>
|
||||||
|
<form class="form" method="post" action="/_gm/users/{{.ID}}/purchase-override">
|
||||||
|
<label>Purchases for this account
|
||||||
|
<select name="override">
|
||||||
|
<option value="default" {{if eq .PurchaseOverride "default"}}selected{{end}}>Default (follow the rail switch)</option>
|
||||||
|
<option value="allow" {{if eq .PurchaseOverride "allow"}}selected{{end}}>Always allow (bypasses the rail switch only, not security)</option>
|
||||||
|
<option value="deny" {{if eq .PurchaseOverride "deny"}}selected{{end}}>Always deny</option>
|
||||||
|
</select></label>
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
<h3>Ledger</h3>
|
<h3>Ledger</h3>
|
||||||
{{$uid := .ID}}
|
{{$uid := .ID}}
|
||||||
{{if .Finance.Ledger}}
|
{{if .Finance.Ledger}}
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th><th></th></tr></thead>
|
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Shop</th><th>Detail</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Finance.Ledger}}
|
{{range .Finance.Ledger}}
|
||||||
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
|
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{.Shop}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
|
||||||
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
|
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ type UserDetailView struct {
|
|||||||
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
||||||
// is false when the payments domain is unwired.
|
// is false when the payments domain is unwired.
|
||||||
Finance FinanceView
|
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
|
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
|
||||||
// payments domain is unwired.
|
// payments domain is unwired.
|
||||||
Grant GrantFormView
|
Grant GrantFormView
|
||||||
@@ -232,8 +235,8 @@ type BenefitRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
|
// 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
|
// delta, the product / order / provider / direct-rail shop it references (empty when none), the raw
|
||||||
// the pre-formatted time.
|
// snapshot JSON and the pre-formatted time.
|
||||||
type LedgerRow struct {
|
type LedgerRow struct {
|
||||||
Kind string
|
Kind string
|
||||||
Source string
|
Source string
|
||||||
@@ -242,6 +245,7 @@ type LedgerRow struct {
|
|||||||
Product string
|
Product string
|
||||||
Order string
|
Order string
|
||||||
Provider string
|
Provider string
|
||||||
|
Shop string
|
||||||
Snapshot string
|
Snapshot string
|
||||||
At string
|
At string
|
||||||
}
|
}
|
||||||
@@ -686,6 +690,18 @@ type CatalogView struct {
|
|||||||
RewardPayout int
|
RewardPayout int
|
||||||
RewardDailyCap int
|
RewardDailyCap int
|
||||||
RewardHourlyCap 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
|
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ type Config struct {
|
|||||||
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
||||||
// http://renderer:8090). Empty disables the PNG export artifact.
|
// http://renderer:8090). Empty disables the PNG export artifact.
|
||||||
RendererURL string
|
RendererURL string
|
||||||
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
|
// Robokassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
|
||||||
// leaves the direct order and Result-callback endpoints unregistered.
|
// (D42). An empty set leaves the direct order and Result-callback endpoints unregistered.
|
||||||
Robokassa robokassa.Config
|
Robokassa robokassa.Shops
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults applied when the corresponding environment variable is unset.
|
// Defaults applied when the corresponding environment variable is unset.
|
||||||
@@ -157,11 +157,19 @@ func Load() (Config, error) {
|
|||||||
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
||||||
}
|
}
|
||||||
|
|
||||||
robo := robokassa.Config{
|
// Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed
|
||||||
MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"),
|
// the web channel so existing deploys keep working; the per-channel vars add the rest. A shop
|
||||||
Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"),
|
// with no MerchantLogin is dropped (the rail stays dormant when none is configured).
|
||||||
Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"),
|
shops := robokassa.Shops{}
|
||||||
IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1",
|
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{
|
c := Config{
|
||||||
@@ -181,7 +189,7 @@ func Load() (Config, error) {
|
|||||||
GuestRetention: guestRetention,
|
GuestRetention: guestRetention,
|
||||||
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||||
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||||
Robokassa: robo,
|
Robokassa: shops,
|
||||||
}
|
}
|
||||||
if err := c.validate(); err != nil {
|
if err := c.validate(); err != nil {
|
||||||
return Config{}, err
|
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)
|
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 == "") {
|
for channel, shop := range c.Robokassa {
|
||||||
return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -276,3 +286,15 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) {
|
|||||||
}
|
}
|
||||||
return d, nil
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Cont
|
|||||||
amount: pack.price,
|
amount: pack.price,
|
||||||
origin: method,
|
origin: method,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
|
shop: directShop(cxt),
|
||||||
}
|
}
|
||||||
if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
|
if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
|
||||||
return OrderResult{}, err
|
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
|
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
|
// 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
|
// 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).
|
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ type RiskInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when
|
// 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 {
|
type LedgerEntry struct {
|
||||||
Kind string
|
Kind string
|
||||||
Source string
|
Source string
|
||||||
@@ -52,6 +53,7 @@ type LedgerEntry struct {
|
|||||||
OrderID string
|
OrderID string
|
||||||
Provider string
|
Provider string
|
||||||
ProviderPaymentID string
|
ProviderPaymentID string
|
||||||
|
Shop string
|
||||||
Snapshot string
|
Snapshot string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -151,6 +151,7 @@ type newOrder struct {
|
|||||||
amount Money
|
amount Money
|
||||||
origin Source
|
origin Source
|
||||||
provider string
|
provider string
|
||||||
|
shop string
|
||||||
}
|
}
|
||||||
|
|
||||||
// createOrder inserts a pending order.
|
// 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.OrderID, table.Orders.AccountID, table.Orders.Platform,
|
||||||
table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency,
|
table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency,
|
||||||
table.Orders.Origin, table.Orders.Status, table.Orders.Provider,
|
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(
|
).VALUES(
|
||||||
o.orderID, o.accountID, o.platform,
|
o.orderID, o.accountID, o.platform,
|
||||||
o.productID, o.amount.Minor(), string(o.amount.Currency()),
|
o.productID, o.amount.Minor(), string(o.amount.Currency()),
|
||||||
string(o.origin), "pending", o.provider,
|
string(o.origin), "pending", o.provider,
|
||||||
now, now,
|
now, now, o.shop,
|
||||||
)
|
)
|
||||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
return fmt.Errorf("payments: create order: %w", err)
|
return fmt.Errorf("payments: create order: %w", err)
|
||||||
|
|||||||
@@ -74,9 +74,39 @@ func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Stat
|
|||||||
CreatedAt: r.CreatedAt,
|
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
|
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.
|
// allLedger reads the entire append-only ledger (all accounts, newest first) for the admin export.
|
||||||
func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) {
|
func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) {
|
||||||
var rows []model.Ledger
|
var rows []model.Ledger
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ type Orders struct {
|
|||||||
ProviderPaymentID *string
|
ProviderPaymentID *string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
Shop string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type ordersTable struct {
|
|||||||
ProviderPaymentID postgres.ColumnString
|
ProviderPaymentID postgres.ColumnString
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
UpdatedAt postgres.ColumnTimestampz
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
|
Shop postgres.ColumnString
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -82,9 +83,10 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
|
|||||||
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
|
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
|
ShopColumn = postgres.StringColumn("shop")
|
||||||
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
|
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
|
||||||
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn}
|
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return ordersTable{
|
return ordersTable{
|
||||||
@@ -103,6 +105,7 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
|
|||||||
ProviderPaymentID: ProviderPaymentIDColumn,
|
ProviderPaymentID: ProviderPaymentIDColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
UpdatedAt: UpdatedAtColumn,
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
Shop: ShopColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ func (s *Server) registerRoutes() {
|
|||||||
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
|
// 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/precheckout", s.handleTelegramPreCheckout)
|
||||||
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
|
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
|
||||||
if s.robokassa.MerchantLogin != "" {
|
if s.robokassa.Configured() {
|
||||||
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,16 @@ func (s *Server) consoleCatalog(c *gin.Context) {
|
|||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
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}
|
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 {
|
for _, p := range products {
|
||||||
view.Products = append(view.Products, catalogRow(p))
|
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)
|
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.
|
// catalogRow projects a product into its list row.
|
||||||
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
||||||
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/users/:id/grant", s.consoleGrant)
|
gm.POST("/users/:id/grant", s.consoleGrant)
|
||||||
gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
|
gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
|
||||||
gm.POST("/users/:id/refund", s.consoleRefund)
|
gm.POST("/users/:id/refund", s.consoleRefund)
|
||||||
|
gm.POST("/users/:id/purchase-override", s.consoleSetPurchaseOverride)
|
||||||
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
||||||
gm.GET("/reasons", s.consoleReasons)
|
gm.GET("/reasons", s.consoleReasons)
|
||||||
gm.POST("/reasons", s.consoleCreateReason)
|
gm.POST("/reasons", s.consoleCreateReason)
|
||||||
@@ -113,6 +114,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.GET("/catalog", s.consoleCatalog)
|
gm.GET("/catalog", s.consoleCatalog)
|
||||||
gm.POST("/catalog", s.consoleCreateProduct)
|
gm.POST("/catalog", s.consoleCreateProduct)
|
||||||
gm.POST("/catalog/reward", s.consoleSetReward)
|
gm.POST("/catalog/reward", s.consoleSetReward)
|
||||||
|
gm.POST("/catalog/rail-status", s.consoleSetRailStatus)
|
||||||
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
||||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
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))
|
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
||||||
}
|
}
|
||||||
view.Grant = s.grantForm(ctx)
|
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)
|
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 {
|
for _, e := range stmt.Ledger {
|
||||||
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
|
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
|
||||||
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
|
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),
|
At: fmtTime(e.CreatedAt),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return fv
|
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
|
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
|
||||||
// user card renders.
|
// user card renders.
|
||||||
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
|
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"scrabble/backend/internal/payments"
|
"scrabble/backend/internal/payments"
|
||||||
|
"scrabble/backend/internal/robokassa"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ledger/order provider tags per rail.
|
// Ledger/order provider tags per rail.
|
||||||
@@ -66,9 +67,25 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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 {
|
switch cxt.Kind {
|
||||||
case payments.SourceDirect:
|
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"}})
|
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -89,7 +106,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, walletOrderResponse{
|
c.JSON(http.StatusOK, walletOrderResponse{
|
||||||
OrderID: res.OrderID.String(),
|
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,
|
Rail: providerRobokassa,
|
||||||
})
|
})
|
||||||
case payments.SourceVK:
|
case payments.SourceVK:
|
||||||
@@ -136,7 +153,16 @@ func (s *Server) handleRobokassaResult(c *gin.Context) {
|
|||||||
for k, val := range params {
|
for k, val := range params {
|
||||||
v.Set(k, val)
|
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 {
|
if !ok {
|
||||||
s.log.Warn("robokassa result: bad signature")
|
s.log.Warn("robokassa result: bad signature")
|
||||||
c.String(http.StatusBadRequest, "bad sign")
|
c.String(http.StatusBadRequest, "bad sign")
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ type Deps struct {
|
|||||||
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
// 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).
|
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
||||||
Renderer *render.Client
|
Renderer *render.Client
|
||||||
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
|
// Robokassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
|
||||||
// order and Result-callback endpoints unregistered.
|
// set leaves the order and Result-callback endpoints unregistered.
|
||||||
Robokassa robokassa.Config
|
Robokassa robokassa.Shops
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||||
@@ -146,7 +146,7 @@ type Server struct {
|
|||||||
ads *ads.Service
|
ads *ads.Service
|
||||||
payments *payments.Service
|
payments *payments.Service
|
||||||
gamelimits *gamelimits.Service
|
gamelimits *gamelimits.Service
|
||||||
robokassa robokassa.Config
|
robokassa robokassa.Shops
|
||||||
notifier notify.Publisher
|
notifier notify.Publisher
|
||||||
console *adminconsole.Renderer
|
console *adminconsole.Renderer
|
||||||
exportKey []byte
|
exportKey []byte
|
||||||
|
|||||||
@@ -142,6 +142,18 @@ ROBOKASSA_MERCHANT_LOGIN=
|
|||||||
ROBOKASSA_PASSWORD1=
|
ROBOKASSA_PASSWORD1=
|
||||||
ROBOKASSA_PASSWORD2=
|
ROBOKASSA_PASSWORD2=
|
||||||
ROBOKASSA_TEST=
|
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 ------------------------------------------------------
|
# --- Gateway anti-abuse ------------------------------------------------------
|
||||||
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
||||||
|
|||||||
@@ -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_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_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_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
|
**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
|
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.
|
the SDK is missing or unreadable.
|
||||||
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
|
- **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`,
|
`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`),
|
then set the Gitea **secrets** `ANDROID_RUSTORE_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
|
||||||
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
|
`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).
|
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
|
- **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
|
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
|
||||||
|
|||||||
@@ -171,6 +171,18 @@ services:
|
|||||||
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
|
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
|
||||||
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
|
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
|
||||||
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-}
|
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 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
|
# (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
|
# inherits). The admin console writes new version subdirectories here, and the
|
||||||
|
|||||||
@@ -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**
|
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):
|
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_foreground.png` + `ic_launcher_background.png` — adaptive layers
|
||||||
- `mipmap-*/ic_launcher_monochrome.png` — themed layer
|
- `mipmap-*/ic_launcher_monochrome.png` — themed layer
|
||||||
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` — **no `<inset>`**, with
|
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` — **no `<inset>`**, with
|
||||||
|
|||||||
@@ -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
|
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.
|
layer, so the sacrificial outer ring is just more wood.
|
||||||
- **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside
|
- **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside
|
||||||
the mask:
|
the mask. The whole mark is the master's optical placement (nudged right 8 % / down 3 %)
|
||||||
- «Э»: box height **53.9 %**, box center **52.3 % / 49.5 %**.
|
scaled **0.75 about the icon centre** — **25 % smaller**, its «Э» ↔ ✻ arrangement
|
||||||
- ✻: outer diameter **13.2 %** (smaller than the master's), center **76.3 % / 72.7 %**,
|
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.
|
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.
|
Everything else (palette, grain, gradients, star construction) is identical to the master.
|
||||||
|
|
||||||
|
|||||||
@@ -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 VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside
|
||||||
the stores. See §4.
|
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/<channel>`.
|
||||||
|
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
|
## 3. Three operations, kept distinct
|
||||||
|
|
||||||
Do not conflate these — they use different keys:
|
Do not conflate these — they use different keys:
|
||||||
|
|||||||
@@ -234,11 +234,53 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
|||||||
- **D40. Финансовый отчёт per-user в `/_gm`** — балансы сегментов, платежи, траты,
|
- **D40. Финансовый отчёт per-user в `/_gm`** — балансы сегментов, платежи, траты,
|
||||||
гранты, возвраты, полная история — расширение существующей карточки
|
гранты, возвраты, полная история — расширение существующей карточки
|
||||||
(`UserDetailView`, `handlers_admin_console.go:343`). Плюс экспорт журнала (D27).
|
(`UserDetailView`, `handlers_admin_console.go:343`). Плюс экспорт журнала (D27).
|
||||||
- **D41. Налоги/чеки — авто через провайдера.** **Robokassa** (direct) — авточек НПД
|
- **D41. Налоги/чеки — авто через провайдера (ревизия: владелец переходит на ИП).**
|
||||||
(режим самозанятого). **VK** — сам процессит Голоса через налоговую (владельцу делать
|
**Robokassa** (direct) — фискальный чек по **54-ФЗ** через облачную кассу Robokassa
|
||||||
нечего). **TG Stars** — налоговой стороны нет (для РФ-самозанятого невыводимы легально
|
(провайдер как фискальный агент): чек формирует касса, не банк (банковский слип об оплате —
|
||||||
= не доход по НПД; принимаем, чек не формируем). ОРД по VK-рекламе — на стороне VK.
|
отдельный документ, не фискальный чек). Настраивается владельцем в ЛКК 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` вида `<kind>/<subtype>`:
|
||||||
|
`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.
|
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 — «слоями»)
|
## План внедрения (черновик PLAN.md — «слоями»)
|
||||||
|
|
||||||
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
|
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
|
||||||
|
|||||||
@@ -57,6 +57,21 @@
|
|||||||
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
|
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
|
||||||
сторов. См. §4.
|
сторов. См. §4.
|
||||||
|
|
||||||
|
**Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин
|
||||||
|
Robokassa **на канал** — `web` и `android` (RuStore), позже `ios` — по трастовому подтипу
|
||||||
|
`X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет).
|
||||||
|
Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в
|
||||||
|
админ-отчёте), а Result-колбэк каждого магазина проверяется своим Password2 по
|
||||||
|
`/pay/robokassa/result/<channel>`. В 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. Три операции, которые нельзя путать
|
## 3. Три операции, которые нельзя путать
|
||||||
|
|
||||||
Не смешивать — у них разные ключи:
|
Не смешивать — у них разные ключи:
|
||||||
|
|||||||
@@ -498,10 +498,15 @@ type robokassaResultResp struct {
|
|||||||
|
|
||||||
// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the
|
// 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
|
// single writer, which verifies the signature and credits) and returns the body to echo to
|
||||||
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields.
|
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields; channel selects the
|
||||||
func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) {
|
// 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
|
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
|
return out.Response, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -274,8 +274,11 @@ func (s *Server) HTTPHandler() http.Handler {
|
|||||||
mux.Handle("/dict/", s.dictBytesHandler())
|
mux.Handle("/dict/", s.dictBytesHandler())
|
||||||
mux.Handle("/dl/", s.exportDownloadHandler())
|
mux.Handle("/dl/", s.exportDownloadHandler())
|
||||||
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single
|
// 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/<channel>); 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/robokassa/result/", s.robokassaResultHandler())
|
||||||
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
|
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
|
||||||
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
|
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
|
||||||
mux.Handle("/pay/robokassa/fail", 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
|
// 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);
|
// body on the public listener is then capped (the admin proxy POSTs included);
|
||||||
// the h2c server carries explicit stream/idle sizing.
|
// 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,
|
MaxConcurrentStreams: h2cMaxConcurrentStreams,
|
||||||
IdleTimeout: h2cIdleTimeout,
|
IdleTimeout: h2cIdleTimeout,
|
||||||
})
|
})
|
||||||
@@ -469,6 +472,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
return connect.NewResponse(&edgev1.ExecuteResponse{
|
return connect.NewResponse(&edgev1.ExecuteResponse{
|
||||||
RequestId: req.Msg.GetRequestId(),
|
RequestId: req.Msg.GetRequestId(),
|
||||||
ResultCode: code,
|
ResultCode: code,
|
||||||
|
Message: transcode.DomainMessage(err),
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
s.log.Error("execute failed", zap.String("message_type", msgType), zap.Error(err))
|
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
|
// 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
|
// writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's
|
||||||
// "OK<InvId>" to Robokassa on success; any error tells Robokassa the notification was not accepted,
|
// "OK<InvId>" 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 {
|
for k := range r.Form {
|
||||||
params[k] = r.Form.Get(k)
|
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 {
|
if err != nil {
|
||||||
s.log.Warn("robokassa result proxy failed", zap.Error(err))
|
s.log.Warn("robokassa result proxy failed", zap.Error(err))
|
||||||
http.Error(w, "not accepted", http.StatusBadGateway)
|
http.Error(w, "not accepted", http.StatusBadGateway)
|
||||||
|
|||||||
@@ -189,6 +189,17 @@ func (r *Registry) Lookup(messageType string) (Op, bool) {
|
|||||||
return op, ok
|
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
|
// 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
|
// envelope, reporting false for an unexpected error the caller should treat as a
|
||||||
// transport-level internal failure.
|
// transport-level internal failure.
|
||||||
|
|||||||
@@ -94,10 +94,14 @@ func (x *ExecuteRequest) GetRequestId() string {
|
|||||||
// ExecuteResponse is the unary reply. result_code is "ok" on success or a stable
|
// 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).
|
// error code; payload is the FlatBuffers-encoded response body (empty on error).
|
||||||
type ExecuteResponse struct {
|
type ExecuteResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -153,6 +157,13 @@ func (x *ExecuteResponse) GetPayload() []byte {
|
|||||||
return nil
|
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
|
// SubscribeRequest opens the live stream. It is empty: the session is taken from
|
||||||
// the Authorization header.
|
// the Authorization header.
|
||||||
type SubscribeRequest struct {
|
type SubscribeRequest struct {
|
||||||
@@ -262,13 +273,14 @@ const file_edge_v1_edge_proto_rawDesc = "" +
|
|||||||
"\fmessage_type\x18\x01 \x01(\tR\vmessageType\x12\x18\n" +
|
"\fmessage_type\x18\x01 \x01(\tR\vmessageType\x12\x18\n" +
|
||||||
"\apayload\x18\x02 \x01(\fR\apayload\x12\x1d\n" +
|
"\apayload\x18\x02 \x01(\fR\apayload\x12\x1d\n" +
|
||||||
"\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" +
|
"\x0fExecuteResponse\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n" +
|
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n" +
|
||||||
"\vresult_code\x18\x02 \x01(\tR\n" +
|
"\vresult_code\x18\x02 \x01(\tR\n" +
|
||||||
"resultCode\x12\x18\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" +
|
"\x10SubscribeRequest\"P\n" +
|
||||||
"\x05Event\x12\x12\n" +
|
"\x05Event\x12\x12\n" +
|
||||||
"\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n" +
|
"\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n" +
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ message ExecuteResponse {
|
|||||||
string request_id = 1;
|
string request_id = 1;
|
||||||
string result_code = 2;
|
string result_code = 2;
|
||||||
bytes payload = 3;
|
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
|
// SubscribeRequest opens the live stream. It is empty: the session is taken from
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
@@ -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) {
|
task clean(type: Delete) {
|
||||||
delete rootProject.buildDir
|
delete rootProject.buildDir
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
@@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf";
|
|||||||
* Describes the file edge/v1/edge.proto.
|
* Describes the file edge/v1/edge.proto.
|
||||||
*/
|
*/
|
||||||
export const file_edge_v1_edge: GenFile = /*@__PURE__*/
|
export const file_edge_v1_edge: GenFile = /*@__PURE__*/
|
||||||
fileDesc("ChJlZGdlL3YxL2VkZ2UucHJvdG8SEHNjcmFiYmxlLmVkZ2UudjEiSwoORXhlY3V0ZVJlcXVlc3QSFAoMbWVzc2FnZV90eXBlGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEgoKcmVxdWVzdF9pZBgDIAEoCSJLCg9FeGVjdXRlUmVzcG9uc2USEgoKcmVxdWVzdF9pZBgBIAEoCRITCgtyZXN1bHRfY29kZRgCIAEoCRIPCgdwYXlsb2FkGAMgASgMIhIKEFN1YnNjcmliZVJlcXVlc3QiOAoFRXZlbnQSDAoEa2luZBgBIAEoCRIPCgdwYXlsb2FkGAIgASgMEhAKCGV2ZW50X2lkGAMgASgJMqUBCgdHYXRld2F5Ek4KB0V4ZWN1dGUSIC5zY3JhYmJsZS5lZGdlLnYxLkV4ZWN1dGVSZXF1ZXN0GiEuc2NyYWJibGUuZWRnZS52MS5FeGVjdXRlUmVzcG9uc2USSgoJU3Vic2NyaWJlEiIuc2NyYWJibGUuZWRnZS52MS5TdWJzY3JpYmVSZXF1ZXN0Ghcuc2NyYWJibGUuZWRnZS52MS5FdmVudDABQidaJXNjcmFiYmxlL2dhdGV3YXkvcHJvdG8vZWRnZS92MTtlZGdldjFiBnByb3RvMw");
|
fileDesc("ChJlZGdlL3YxL2VkZ2UucHJvdG8SEHNjcmFiYmxlLmVkZ2UudjEiSwoORXhlY3V0ZVJlcXVlc3QSFAoMbWVzc2FnZV90eXBlGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEgoKcmVxdWVzdF9pZBgDIAEoCSJcCg9FeGVjdXRlUmVzcG9uc2USEgoKcmVxdWVzdF9pZBgBIAEoCRITCgtyZXN1bHRfY29kZRgCIAEoCRIPCgdwYXlsb2FkGAMgASgMEg8KB21lc3NhZ2UYBCABKAkiEgoQU3Vic2NyaWJlUmVxdWVzdCI4CgVFdmVudBIMCgRraW5kGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEAoIZXZlbnRfaWQYAyABKAkypQEKB0dhdGV3YXkSTgoHRXhlY3V0ZRIgLnNjcmFiYmxlLmVkZ2UudjEuRXhlY3V0ZVJlcXVlc3QaIS5zY3JhYmJsZS5lZGdlLnYxLkV4ZWN1dGVSZXNwb25zZRJKCglTdWJzY3JpYmUSIi5zY3JhYmJsZS5lZGdlLnYxLlN1YnNjcmliZVJlcXVlc3QaFy5zY3JhYmJsZS5lZGdlLnYxLkV2ZW50MAFCJ1olc2NyYWJibGUvZ2F0ZXdheS9wcm90by9lZGdlL3YxO2VkZ2V2MWIGcHJvdG8z");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ExecuteRequest is the unary envelope. message_type selects the operation;
|
* 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;
|
* @generated from field: bytes payload = 3;
|
||||||
*/
|
*/
|
||||||
payload: Uint8Array;
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
maintenanceRecovered();
|
maintenanceRecovered();
|
||||||
if (res.resultCode && res.resultCode !== 'ok') {
|
if (res.resultCode && res.resultCode !== 'ok') {
|
||||||
if (res.resultCode === UPDATE_REQUIRED && !opts?.silent) reportUpdateRequired();
|
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;
|
return res.payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ALL_VARIANTS,
|
ALL_VARIANTS,
|
||||||
|
DEFAULT_VARIANTS,
|
||||||
availableVariants,
|
availableVariants,
|
||||||
supportsMultipleWordsToggle,
|
supportsMultipleWordsToggle,
|
||||||
multipleWordsForRequest,
|
multipleWordsForRequest,
|
||||||
@@ -16,9 +17,11 @@ describe('ALL_VARIANTS', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('availableVariants', () => {
|
describe('availableVariants', () => {
|
||||||
it('is ungated (all variants) for an empty or absent preference set', () => {
|
it('falls back to the Erudit-only default for an empty or absent preference set', () => {
|
||||||
expect(availableVariants([])).toEqual(ALL_VARIANTS);
|
const fallback = ALL_VARIANTS.filter((v) => DEFAULT_VARIANTS.includes(v.id));
|
||||||
expect(availableVariants(undefined)).toEqual(ALL_VARIANTS);
|
expect(fallback.map((v) => v.id)).toEqual(['erudit_ru']);
|
||||||
|
expect(availableVariants([])).toEqual(fallback);
|
||||||
|
expect(availableVariants(undefined)).toEqual(fallback);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers only the preferred variants', () => {
|
it('offers only the preferred variants', () => {
|
||||||
|
|||||||
@@ -62,13 +62,23 @@ export function usesStarBlank(v: Variant): boolean {
|
|||||||
return v === 'erudit_ru';
|
return v === 'erudit_ru';
|
||||||
}
|
}
|
||||||
|
|
||||||
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
|
// DEFAULT_VARIANTS is the variant set a player sees before any preference is stored — a fresh
|
||||||
// they enabled in Settings). An empty or absent set is ungated (returns every variant)
|
// or offline native client whose profile has not been synced yet (the device-local guest boots
|
||||||
// — a safety fallback; a real profile always carries at least one preference.
|
// 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[] {
|
export function availableVariants(preferences: Variant[] | undefined): VariantOption[] {
|
||||||
const prefs = preferences ?? [];
|
const prefs = preferences ?? [];
|
||||||
if (prefs.length === 0) return ALL_VARIANTS;
|
const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs;
|
||||||
return ALL_VARIANTS.filter((v) => prefs.includes(v.id));
|
return ALL_VARIANTS.filter((v) => effective.includes(v.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle
|
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle
|
||||||
|
|||||||
@@ -200,7 +200,13 @@
|
|||||||
openExternalUrl(order.redirectUrl);
|
openExternalUrl(order.redirectUrl);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
busyId = null;
|
busyId = null;
|
||||||
}
|
}
|
||||||
|
|||||||