Compare commits
29 Commits
v1.19.0
...
b5fe61279b
| Author | SHA1 | Date | |
|---|---|---|---|
| b5fe61279b | |||
| e07886ecb1 | |||
| 4a3e12c85a | |||
| 54bc31c619 | |||
| d89438040b | |||
| 363f1632b1 | |||
| f0b7ad47d4 | |||
| 0e11817654 | |||
| d0e473920c | |||
| 0e56e4de9a | |||
| 8d3f862b41 | |||
| 74842dc624 | |||
| 1507ceb793 | |||
| 4b3c9d4fdd | |||
| eef90a152e | |||
| d8d1b06eee | |||
| c16008e67a | |||
| ff55d5de83 | |||
| 7cc2b50d23 | |||
| eb7fa98426 | |||
| 81d5383c42 | |||
| b6af682381 | |||
| ed79c30d2a | |||
| 471fadc6a4 | |||
| 564abdcf88 | |||
| c522e77599 | |||
| 28afcff551 | |||
| c34e2a8dbf | |||
| 25381f70a3 |
@@ -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 |
@@ -21,7 +21,29 @@
|
|||||||
<div><button type="submit">Add</button></div>
|
<div><button type="submit">Add</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="panel"><h2>Rewarded ads (watch for chips)</h2>
|
||||||
|
<p class="note">The chips a player earns per rewarded-video view (VK only), plus the per-day and per-hour caps that bound free chips — <strong>0 payout turns rewarded off</strong>. This is the shared reward config, not a product.</p>
|
||||||
|
<form class="form col" method="post" action="/_gm/catalog/reward">
|
||||||
|
<label>Chips per view <input type="number" name="reward_payout" min="0" value="{{.RewardPayout}}"></label>
|
||||||
|
<label>Daily cap <input type="number" name="reward_daily" min="0" value="{{.RewardDailyCap}}"></label>
|
||||||
|
<label>Hourly cap <input type="number" name="reward_hourly" min="0" value="{{.RewardHourlyCap}}"></label>
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
|
</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>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -677,6 +681,27 @@ type FeedbackDetailView struct {
|
|||||||
// CatalogView is the product-catalog list page.
|
// CatalogView is the product-catalog list page.
|
||||||
type CatalogView struct {
|
type CatalogView struct {
|
||||||
Products []ProductRow
|
Products []ProductRow
|
||||||
|
// ShowAll reports whether archived products are listed alongside active ones (the active/all
|
||||||
|
// toggle); it drives the toggle link and the list heading.
|
||||||
|
ShowAll bool
|
||||||
|
// RewardPayout / RewardDailyCap / RewardHourlyCap are the rewarded-video config (chips earned per
|
||||||
|
// view and the per-day / per-hour anti-abuse caps), shown in and edited from the page's
|
||||||
|
// rewarded-ads form. A 0 payout means rewarded is inert.
|
||||||
|
RewardPayout int
|
||||||
|
RewardDailyCap int
|
||||||
|
RewardHourlyCap int
|
||||||
|
// Rails is the per-rail operational kill-switch state (enabled + per-language off-message), shown
|
||||||
|
// in and edited from the page's payment-availability form.
|
||||||
|
Rails []RailStatusRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// RailStatusRow is one payment rail's operational availability in the kill-switch editor: the rail
|
||||||
|
// key, whether purchases are enabled, and the operator's per-language off-message shown to the user.
|
||||||
|
type RailStatusRow struct {
|
||||||
|
Rail string
|
||||||
|
Enabled bool
|
||||||
|
MessageRU string
|
||||||
|
MessageEN string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
// 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
// productByTitle finds a catalog product by its (unique in the test) title.
|
// productByTitle finds a catalog product by its (unique in the test) title.
|
||||||
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
all, err := pay.AdminCatalog(context.Background())
|
all, err := pay.AdminCatalog(context.Background(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("admin catalog: %v", err)
|
t.Fatalf("admin catalog: %v", err)
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
|||||||
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
||||||
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
||||||
}
|
}
|
||||||
if _, err := pay.AdminCatalog(ctx); err != nil {
|
if _, err := pay.AdminCatalog(ctx, true); err != nil {
|
||||||
t.Fatalf("catalog: %v", err)
|
t.Fatalf("catalog: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,3 +95,59 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
|||||||
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConsoleCatalogActiveToggle checks the catalog console lists only active products by default and
|
||||||
|
// includes the archived ones under ?all=1 (the active/all toggle).
|
||||||
|
func TestConsoleCatalogActiveToggle(t *testing.T) {
|
||||||
|
srv, _, _ := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
const catalog = "http://admin.test/_gm/catalog"
|
||||||
|
|
||||||
|
// An active value and an archived one (created without the active flag).
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-active&hints=5&price_chip=50&active=true", origin); code != http.StatusOK {
|
||||||
|
t.Fatal("create active failed")
|
||||||
|
}
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-archived&hints=9&price_chip=90", origin); code != http.StatusOK {
|
||||||
|
t.Fatal("create archived failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default view: active only.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, catalog, "", ""); !strings.Contains(body, "toggle-active") || strings.Contains(body, "toggle-archived") {
|
||||||
|
t.Errorf("default view: active listed=%v, archived listed=%v (want true/false)",
|
||||||
|
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?all=1: active and archived.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, catalog+"?all=1", "", ""); !strings.Contains(body, "toggle-active") || !strings.Contains(body, "toggle-archived") {
|
||||||
|
t.Errorf("all view: active listed=%v, archived listed=%v (want true/true)",
|
||||||
|
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleRewardConfig checks the rewarded-ads config form on the catalog page: a POST updates the
|
||||||
|
// payout and caps, the page pre-fills the current values, and a negative value is refused.
|
||||||
|
func TestConsoleRewardConfig(t *testing.T) {
|
||||||
|
srv, _, pay := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
const reward = "http://admin.test/_gm/catalog/reward"
|
||||||
|
|
||||||
|
// Set the payout and caps.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=7&reward_daily=40&reward_hourly=9", origin); code != http.StatusOK || !strings.Contains(body, "Saved") {
|
||||||
|
t.Fatalf("set reward = %d, has 'Saved' = %v", code, strings.Contains(body, "Saved"))
|
||||||
|
}
|
||||||
|
if payout, daily, hourly, err := pay.RewardConfig(context.Background()); err != nil || payout != 7 || daily != 40 || hourly != 9 {
|
||||||
|
t.Fatalf("reward config = %d/%d/%d (err %v), want 7/40/9", payout, daily, hourly, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalog page renders the form pre-filled with the current payout.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/catalog", "", ""); !strings.Contains(body, "reward_payout") || !strings.Contains(body, `value="7"`) {
|
||||||
|
t.Error("catalog page did not render the reward form with the current payout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A negative value is refused (the service validates non-negativity).
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=-1", origin); code != http.StatusOK || !strings.Contains(body, "non-negative") {
|
||||||
|
t.Errorf("negative payout = %d, has 'non-negative' = %v", code, strings.Contains(body, "non-negative"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,11 @@ const atomChips = "chips"
|
|||||||
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
||||||
// no method, so only values show; buying is gated server-side regardless.
|
// no method, so only values show; buying is gated server-side regardless.
|
||||||
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
||||||
|
// Present products in the canonical listing order shared with the offer and the admin console
|
||||||
|
// (packs first by rouble price, then values grouped hints → no-ads → combo and by chip price); the
|
||||||
|
// client renders them as received. Ordered in place — the caller passes a fresh loadCatalog result
|
||||||
|
// it does not reuse.
|
||||||
|
sortCatalogEntries(entries)
|
||||||
var out CatalogView
|
var out CatalogView
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
isPack := false
|
isPack := false
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package payments
|
package payments
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -144,10 +143,12 @@ func validateProduct(in ProductInput, sellable bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
|
// AdminCatalog lists products with their composition, prices and whether each has been transacted,
|
||||||
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
|
// for the admin editor. includeInactive adds the archived products to the active ones (the console's
|
||||||
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
// active/all toggle); with it false only active products are returned. Read uncached, straight from
|
||||||
return s.store.adminCatalog(ctx)
|
// the catalog tables.
|
||||||
|
func (s *Service) AdminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||||
|
return s.store.adminCatalog(ctx, includeInactive)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
||||||
@@ -157,18 +158,7 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
|||||||
// products the same as active ones (the admin list shows both).
|
// products the same as active ones (the admin list shows both).
|
||||||
func SortAdminCatalog(products []AdminProduct) {
|
func SortAdminCatalog(products []AdminProduct) {
|
||||||
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
||||||
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
|
return compareCatalogRank(adminRank(a), adminRank(b))
|
||||||
if pa {
|
|
||||||
return -1 // packs (sales) before values (chip exchange)
|
|
||||||
}
|
|
||||||
return 1
|
|
||||||
} else if pa {
|
|
||||||
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
|
|
||||||
}
|
|
||||||
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// catalogRank is the canonical listing key for a catalog product: whether it is a chip pack, its
|
||||||
|
// value subgroup (see [valueGroup]; unused for a pack) and the minor-unit amount its section sorts by
|
||||||
|
// (a pack's direct rouble price, a value's chip price; math.MaxInt64 when absent, so a misconfigured
|
||||||
|
// row sorts last rather than leading).
|
||||||
|
type catalogRank struct {
|
||||||
|
pack bool
|
||||||
|
group int
|
||||||
|
amount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareCatalogRank is the single canonical product order, shared by the storefront ([projectCatalog]),
|
||||||
|
// the public offer ([projectOfferPricing]) and the admin catalog ([SortAdminCatalog]): chip packs
|
||||||
|
// first, ascending by rouble price; then chip-priced values grouped hints → no-ads → combo →
|
||||||
|
// tournament and, within a group, ascending by chip price. A pack's group is unused (all packs share
|
||||||
|
// one section). Callers use it through [entryRank] / [adminRank], which build the key from each
|
||||||
|
// product shape, so the subgroup and ordering rules live in exactly one place.
|
||||||
|
func compareCatalogRank(a, b catalogRank) int {
|
||||||
|
if a.pack != b.pack {
|
||||||
|
if a.pack {
|
||||||
|
return -1 // packs (sales) before values (chip exchange)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if !a.pack {
|
||||||
|
if d := cmp.Compare(a.group, b.group); d != 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.amount, b.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryRank builds the canonical rank of a storefront / offer catalog entry.
|
||||||
|
func entryRank(e catalogEntry) catalogRank {
|
||||||
|
if isPackEntry(e) {
|
||||||
|
return catalogRank{pack: true, amount: offerSortAmount(e, string(SourceDirect), CurrencyRUB)}
|
||||||
|
}
|
||||||
|
return catalogRank{group: offerValueGroup(e), amount: offerSortAmount(e, "", CurrencyChip)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminRank builds the canonical rank of an admin catalog product.
|
||||||
|
func adminRank(p AdminProduct) catalogRank {
|
||||||
|
if adminIsPack(p) {
|
||||||
|
return catalogRank{pack: true, amount: adminPriceAmount(p, string(SourceDirect), CurrencyRUB)}
|
||||||
|
}
|
||||||
|
return catalogRank{group: adminValueGroup(p), amount: adminPriceAmount(p, "", CurrencyChip)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortCatalogEntries orders storefront / offer entries in place into the canonical listing order
|
||||||
|
// ([compareCatalogRank]). Stable, so equal-keyed products keep their incoming (creation) order.
|
||||||
|
func sortCatalogEntries(entries []catalogEntry) {
|
||||||
|
slices.SortStableFunc(entries, func(a, b catalogEntry) int {
|
||||||
|
return compareCatalogRank(entryRank(a), entryRank(b))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -135,6 +135,56 @@ func TestProjectCatalog_Empty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProjectCatalog_CanonicalOrder checks the storefront lists products in the shared canonical order
|
||||||
|
// (compareCatalogRank): chip packs first ascending by rouble price, then chip-priced values grouped
|
||||||
|
// hints → no-ads → combo and ascending by chip price — the same order as the offer and the admin
|
||||||
|
// console, regardless of catalog creation order.
|
||||||
|
func TestProjectCatalog_CanonicalOrder(t *testing.T) {
|
||||||
|
pack := func(title string, rub int64) catalogEntry {
|
||||||
|
return catalogEntry{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: title,
|
||||||
|
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
|
||||||
|
prices: []priceRow{{method: "direct", currency: CurrencyRUB, amount: rub}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value := func(title string, chips int64, atoms ...string) catalogEntry {
|
||||||
|
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
|
||||||
|
for _, a := range atoms {
|
||||||
|
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
// Deliberately shuffled on input; the projection must impose the canonical order.
|
||||||
|
entries := []catalogEntry{
|
||||||
|
pack("packDear", 30000),
|
||||||
|
value("bundle", 500, "hints", "noads_days"),
|
||||||
|
pack("packCheap", 10000),
|
||||||
|
value("adsOnly", 150, "noads_days"),
|
||||||
|
value("hintsBig", 200, "hints"),
|
||||||
|
value("hintsSmall", 50, "hints"),
|
||||||
|
}
|
||||||
|
got := projectCatalog(entries, Context{Kind: SourceDirect})
|
||||||
|
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||||
|
if len(got.Products) != len(want) {
|
||||||
|
t.Fatalf("projected %d products, want %d", len(got.Products), len(want))
|
||||||
|
}
|
||||||
|
for i, p := range got.Products {
|
||||||
|
if p.Title != want[i] {
|
||||||
|
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], productTitles(got.Products))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// productTitles extracts the projected product titles for a failure message.
|
||||||
|
func productTitles(products []CatalogProduct) []string {
|
||||||
|
out := make([]string, len(products))
|
||||||
|
for i, p := range products {
|
||||||
|
out[i] = p.Title
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
||||||
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
||||||
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package payments
|
package payments
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@@ -60,8 +59,13 @@ func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
|
|||||||
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
|
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
|
||||||
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
|
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
|
||||||
func projectOfferPricing(entries []catalogEntry) string {
|
func projectOfferPricing(entries []catalogEntry) string {
|
||||||
|
// Order the whole catalog once by the shared canonical rank, then split it into the two offer
|
||||||
|
// tables (packs first, then the chip-exchange values); each keeps that order. Sort a copy so the
|
||||||
|
// caller's slice is left untouched.
|
||||||
|
sorted := slices.Clone(entries)
|
||||||
|
sortCatalogEntries(sorted)
|
||||||
var packs, values []catalogEntry
|
var packs, values []catalogEntry
|
||||||
for _, e := range entries {
|
for _, e := range sorted {
|
||||||
if isPackEntry(e) {
|
if isPackEntry(e) {
|
||||||
packs = append(packs, e)
|
packs = append(packs, e)
|
||||||
} else {
|
} else {
|
||||||
@@ -69,18 +73,6 @@ func projectOfferPricing(entries []catalogEntry) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price
|
|
||||||
// sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties.
|
|
||||||
slices.SortStableFunc(packs, func(a, b catalogEntry) int {
|
|
||||||
return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB))
|
|
||||||
})
|
|
||||||
slices.SortStableFunc(values, func(a, b catalogEntry) int {
|
|
||||||
if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip))
|
|
||||||
})
|
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if len(packs) > 0 {
|
if len(packs) > 0 {
|
||||||
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
|
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -157,6 +168,22 @@ func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Sourc
|
|||||||
return payout, err
|
return payout, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RewardConfig reads the rewarded-video config for the admin editor: the payout (chips per view) and
|
||||||
|
// the per-day and per-hour caps.
|
||||||
|
func (s *Service) RewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
|
||||||
|
return s.store.rewardConfig(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRewardConfig updates the rewarded-video config (the payout and the two caps). All three must be
|
||||||
|
// non-negative; a 0 payout leaves rewarded inert. It is not a catalog product, so it does not mark the
|
||||||
|
// offer stale.
|
||||||
|
func (s *Service) SetRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||||
|
if payout < 0 || dailyCap < 0 || hourlyCap < 0 {
|
||||||
|
return errors.New("payments: reward payout and caps must be non-negative")
|
||||||
|
}
|
||||||
|
return s.store.setRewardConfig(ctx, payout, dailyCap, hourlyCap)
|
||||||
|
}
|
||||||
|
|
||||||
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
||||||
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
||||||
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -19,11 +19,17 @@ import (
|
|||||||
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
||||||
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
||||||
|
|
||||||
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
|
// adminCatalog lists products with their atoms, prices and transacted flag. includeInactive adds the
|
||||||
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
// archived products to the active ones; with it false only active products are returned.
|
||||||
|
func (s *Store) adminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||||
|
where := table.Product.Active.IS_TRUE()
|
||||||
|
if includeInactive {
|
||||||
|
where = postgres.Bool(true)
|
||||||
|
}
|
||||||
var prods []model.Product
|
var prods []model.Product
|
||||||
if err := postgres.SELECT(table.Product.AllColumns).
|
if err := postgres.SELECT(table.Product.AllColumns).
|
||||||
FROM(table.Product).
|
FROM(table.Product).
|
||||||
|
WHERE(where).
|
||||||
ORDER_BY(table.Product.CreatedAt.ASC()).
|
ORDER_BY(table.Product.CreatedAt.ASC()).
|
||||||
QueryContext(ctx, s.db, &prods); err != nil {
|
QueryContext(ctx, s.db, &prods); err != nil {
|
||||||
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -413,6 +414,18 @@ func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap i
|
|||||||
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
|
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setRewardConfig updates the rewarded payout and the per-day and per-hour caps on the singleton
|
||||||
|
// config row (no WHERE — the table holds exactly one row). Non-negativity is validated by the caller
|
||||||
|
// and also enforced by the config CHECK constraints.
|
||||||
|
func (s *Store) setRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
|
||||||
|
payout, dailyCap, hourlyCap); err != nil {
|
||||||
|
return fmt.Errorf("payments: set reward config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
|
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
|
||||||
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
|
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
|
||||||
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
|
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ var priceFields = []struct {
|
|||||||
{"price_chip", "", payments.CurrencyChip},
|
{"price_chip", "", payments.CurrencyChip},
|
||||||
}
|
}
|
||||||
|
|
||||||
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
|
// consoleCatalog lists the catalog products with their composition, prices, transacted flag, and the
|
||||||
// flag, and the inline create form.
|
// inline create form. It shows active products by default; ?all=1 also lists the archived ones (the
|
||||||
|
// active/all toggle).
|
||||||
func (s *Server) consoleCatalog(c *gin.Context) {
|
func (s *Server) consoleCatalog(c *gin.Context) {
|
||||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
showAll := c.Query("all") == "1"
|
||||||
|
products, err := s.payments.AdminCatalog(c.Request.Context(), showAll)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
@@ -44,13 +46,58 @@ func (s *Server) consoleCatalog(c *gin.Context) {
|
|||||||
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
||||||
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
||||||
payments.SortAdminCatalog(products)
|
payments.SortAdminCatalog(products)
|
||||||
var view adminconsole.CatalogView
|
payout, daily, hourly, err := s.payments.RewardConfig(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rails, err := s.payments.RailStatuses(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
|
||||||
|
for _, rail := range payments.KnownRails {
|
||||||
|
a := rails[rail]
|
||||||
|
view.Rails = append(view.Rails, adminconsole.RailStatusRow{Rail: rail, Enabled: a.Enabled, MessageRU: a.MessageRU, MessageEN: a.MessageEN})
|
||||||
|
}
|
||||||
for _, p := range products {
|
for _, p := range products {
|
||||||
view.Products = append(view.Products, catalogRow(p))
|
view.Products = append(view.Products, catalogRow(p))
|
||||||
}
|
}
|
||||||
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// consoleSetReward updates the rewarded-video config (chips per view plus the per-day and per-hour
|
||||||
|
// caps) from the catalog page's rewarded-ads form. A blank field reads as 0; a negative value is
|
||||||
|
// refused by the service.
|
||||||
|
func (s *Server) consoleSetReward(c *gin.Context) {
|
||||||
|
payout, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_payout")))
|
||||||
|
daily, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_daily")))
|
||||||
|
hourly, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_hourly")))
|
||||||
|
if err := s.payments.SetRewardConfig(c.Request.Context(), payout, daily, hourly); err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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}
|
||||||
@@ -69,7 +116,7 @@ func (s *Server) consoleCatalogDetail(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
products, err := s.payments.AdminCatalog(c.Request.Context(), true) // include archived: the detail form edits any product
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
@@ -242,7 +289,7 @@ func (s *Server) consoleGrantProduct(c *gin.Context) {
|
|||||||
// bundles, including archived ones — chips and tournament products are excluded).
|
// bundles, including archived ones — chips and tournament products are excluded).
|
||||||
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
||||||
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
||||||
products, err := s.payments.AdminCatalog(ctx)
|
products, err := s.payments.AdminCatalog(ctx, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fv
|
return fv
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -112,6 +113,8 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
if s.payments != nil {
|
if s.payments != nil {
|
||||||
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/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)
|
||||||
@@ -460,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)
|
||||||
}
|
}
|
||||||
@@ -481,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
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ func Middleware(logger *zap.Logger) gin.HandlerFunc {
|
|||||||
zap.String("path", route),
|
zap.String("path", route),
|
||||||
zap.Int("status", status),
|
zap.Int("status", status),
|
||||||
zap.Duration("latency", elapsed),
|
zap.Duration("latency", elapsed),
|
||||||
|
// The gateway forwards the real caller as X-Forwarded-For (and Caddy does for /_gm), which
|
||||||
|
// gin resolves here — so the access log carries the client IP, not the gateway's connection.
|
||||||
|
zap.String("client_ip", c.ClientIP()),
|
||||||
}
|
}
|
||||||
fields = append(fields, TraceFieldsFromContext(ctx)...)
|
fields = append(fields, TraceFieldsFromContext(ctx)...)
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
|
|||||||
|
|
||||||
# --- UI build args (baked into the gateway image) ---------------------------
|
# --- UI build args (baked into the gateway image) ---------------------------
|
||||||
VITE_TELEGRAM_BOT_ID=
|
VITE_TELEGRAM_BOT_ID=
|
||||||
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://telegram.me/<bot>/<app>)
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
||||||
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
||||||
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
||||||
@@ -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
|
||||||
@@ -107,8 +108,8 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
||||||
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
|
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
|
||||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://telegram.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://telegram.me/Erudit_Game`). |
|
||||||
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
|
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
|
||||||
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
|
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
|
||||||
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
|
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -150,6 +150,13 @@
|
|||||||
enabled: true
|
enabled: true
|
||||||
state: started
|
state: started
|
||||||
|
|
||||||
|
# Pin every host to UTC so host-level timestamps (journald, file mtimes, cron) line up
|
||||||
|
# across the fleet — some VPS images ship a local zone (the tg host came up on MSK). The
|
||||||
|
# services themselves run in UTC regardless; this is about host-side log correlation.
|
||||||
|
- name: Set the system timezone to UTC
|
||||||
|
community.general.timezone:
|
||||||
|
name: Etc/UTC
|
||||||
|
|
||||||
# --- Swap file (OOM cushion) ---------------------------------------------------
|
# --- Swap file (OOM cushion) ---------------------------------------------------
|
||||||
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
||||||
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -107,8 +107,10 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
|
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
|
||||||
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
|
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
|
||||||
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
|
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
|
||||||
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
|
implicit** — detected from failed calls, a reachability probe, a **live-stream heartbeat watchdog**
|
||||||
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
|
(a silence past ~25 s of the gateway's 10 s keep-alive `heartbeat` means a silently-dead stream — e.g.
|
||||||
|
the radio was cut with no stream error; `ui/src/lib/streamwatchdog.ts`, background-aware) and the OS hint
|
||||||
|
(`navigator` / `@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
|
||||||
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
|
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
|
||||||
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
|
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
|
||||||
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
|
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
|
||||||
@@ -116,10 +118,17 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
|
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
|
||||||
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
|
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
|
||||||
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
|
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
|
||||||
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an
|
drop/recovery feeds the same machine. **Telegram/VK are exempt from the offline-*play* model** —
|
||||||
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
|
`offlineMode.active` is hard-gated on `offlineCapable()` (false in those mini-apps), so nothing — not
|
||||||
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
|
even a version lock — puts them in offline mode: no blue chrome, no local lobby, no transport kill
|
||||||
local lobby, no transport kill switch and no device-local create paths.
|
switch and no device-local create paths. The machine still tracks reachability there, though: a
|
||||||
|
connection lost **inside an online game** is surfaced on **every platform**, driven off the machine's
|
||||||
|
confirmed-offline state (`netState.offline`, not `offlineMode.active`) — the game screen shows a
|
||||||
|
*connection lost* banner, **freezes** the tray and move controls, and hides the resign, add-friend/block
|
||||||
|
and chat/dictionary controls (a started move stays a draft, re-committed by the player on reconnect), and
|
||||||
|
the word-check tool falls back to the game's pinned on-device dictionary (`ui/src/lib/dict/check.ts`,
|
||||||
|
exact when that dawg is cached, else *unavailable*) with its network-only complaint and external
|
||||||
|
look-up hidden.
|
||||||
**Edge hardening:** every request body on the public listener is capped at
|
**Edge hardening:** every request body on the public listener is capped at
|
||||||
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
||||||
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||||
@@ -142,7 +151,10 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
||||||
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
||||||
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
||||||
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
requests, plus the caller's real IP as **`X-Forwarded-For`** on **every** call
|
||||||
|
(carried on the request context), so the backend records the real caller — the
|
||||||
|
account's last-login IP, chat/feedback moderation, the access log — rather than
|
||||||
|
the gateway's own connection address; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||||
gateway's REST client widens its keep-alive pool well past the stdlib default
|
gateway's REST client widens its keep-alive pool well past the stdlib default
|
||||||
of 2 idle connections per host; otherwise the per-request connection churn
|
of 2 idle connections per host; otherwise the per-request connection churn
|
||||||
exhausts ephemeral ports and burns gateway CPU under load (see
|
exhausts ephemeral ports and burns gateway CPU under load (see
|
||||||
@@ -1487,7 +1499,8 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
|||||||
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
||||||
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
||||||
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
||||||
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
|
used for the gateway's per-IP rate limiting and, forwarded on to the backend, the account's
|
||||||
|
last-login IP, chat/feedback moderation and the access log — survives the
|
||||||
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
||||||
real peer, so the single config is correct and spoof-safe in both contours. The
|
real peer, so the single config is correct and spoof-safe in both contours. The
|
||||||
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
|
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
|
||||||
|
|||||||
@@ -229,7 +229,10 @@ brief warm-up shows on the first open otherwise — and falls back to the server
|
|||||||
local dictionary is unavailable. The dictionary check tool is
|
local dictionary is unavailable. The dictionary check tool is
|
||||||
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
|
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
|
||||||
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
||||||
English) to look it up. Hints are governed per game —
|
English) to look it up. While offline in an online game the check runs against the game's own
|
||||||
|
dictionary on the device — the same verdict as the server when that dictionary is available,
|
||||||
|
otherwise it reads *unavailable offline* — and the complaint and the external look-up, which both
|
||||||
|
need the network, are hidden. Hints are governed per game —
|
||||||
whether they are allowed and how many each player starts with — and draw on a
|
whether they are allowed and how many each player starts with — and draw on a
|
||||||
personal hint wallet once the per-game allowance is spent. Against the robot
|
personal hint wallet once the per-game allowance is spent. Against the robot
|
||||||
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
||||||
@@ -322,6 +325,17 @@ offline on its own, and restoring the network returns it online by itself. There
|
|||||||
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
|
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
|
||||||
offline, so you are never interrupted for a hiccup.
|
offline, so you are never interrupted for a hiccup.
|
||||||
|
|
||||||
|
**Losing the connection while inside an online game** is surfaced on **every platform, including the
|
||||||
|
Telegram/VK mini-apps** — unlike the offline-play mode above, which is web/native only — because an
|
||||||
|
online game needs the server for every move. A slim *connection lost* banner appears at the top of the
|
||||||
|
board and the play area **freezes**: the rack and the move controls disable, and the resign, the
|
||||||
|
add-friend/block and the chat/dictionary controls are hidden, while a move you had started stays on the
|
||||||
|
board as a draft. Nothing
|
||||||
|
is sent; when the connection returns the banner clears, the controls come back and you commit the move
|
||||||
|
yourself. If you were already in the chat or the dictionary when the drop happened you stay there — chat
|
||||||
|
becomes read-only (send and nudge disable) and the dictionary keeps checking words against the game's
|
||||||
|
on-device dictionary.
|
||||||
|
|
||||||
### Staying up to date
|
### Staying up to date
|
||||||
When a new client version is required, the app does not lock you out. On the **native** app a
|
When a new client version is required, the app does not lock you out. On the **native** app a
|
||||||
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
|
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
|
||||||
|
|||||||
@@ -236,7 +236,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||||
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||||
чтобы его посмотреть. Подсказки управляются настройками
|
чтобы его посмотреть. В офлайне в онлайн-партии проверка идёт по собственному словарю партии на
|
||||||
|
устройстве — тот же вердикт, что и на сервере, если этот словарь доступен, иначе показывается
|
||||||
|
*недоступно офлайн*, — а жалоба и внешняя ссылка, которым нужна сеть, скрываются. Подсказки управляются настройками
|
||||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
||||||
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
||||||
@@ -327,6 +329,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
||||||
вас не прерывают из-за короткой икоты.
|
вас не прерывают из-за короткой икоты.
|
||||||
|
|
||||||
|
**Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения
|
||||||
|
Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, —
|
||||||
|
потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь
|
||||||
|
потеряна*, и игровая область **замораживается**: рэк и ходовые кнопки отключаются, а «сдаться»,
|
||||||
|
«в друзья»/«заблокировать» и вход в чат/словарь скрываются; начатый ход остаётся на доске черновиком.
|
||||||
|
Ничего не отправляется;
|
||||||
|
когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала,
|
||||||
|
когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка
|
||||||
|
и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве.
|
||||||
|
|
||||||
### Актуальная версия
|
### Актуальная версия
|
||||||
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
||||||
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -201,7 +201,9 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
|||||||
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
|
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
|
||||||
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
|
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
|
||||||
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
|
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
|
||||||
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге.
|
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге. (Ставка
|
||||||
|
rewarded + дневной/часовой потолки — строка общего конфига `payments.config`, правится в
|
||||||
|
секции «Rewarded ads» на странице каталога админки; это не продаваемый продукт-атом.)
|
||||||
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
|
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
|
||||||
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
|
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
|
||||||
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;
|
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;
|
||||||
@@ -232,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.
|
||||||
|
|
||||||
## Заметки к оформлению документов
|
## Заметки к оформлению документов
|
||||||
|
|
||||||
@@ -252,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,26 @@ func platformFromContext(ctx context.Context) string {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clientIPCtxKey types the request-context slot the originating client IP rides in.
|
||||||
|
type clientIPCtxKey struct{}
|
||||||
|
|
||||||
|
// WithClientIP returns a copy of ctx carrying the originating client IP that do injects as
|
||||||
|
// X-Forwarded-For on every downstream backend request — so the backend records the real caller
|
||||||
|
// (the account's last-login IP, chat/feedback moderation, the access log) rather than the gateway's
|
||||||
|
// own connection. An empty IP leaves ctx unchanged, so no header is sent.
|
||||||
|
func WithClientIP(ctx context.Context, ip string) context.Context {
|
||||||
|
if ip == "" {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, clientIPCtxKey{}, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIPFromContext returns the client IP stored by WithClientIP, or an empty string when none.
|
||||||
|
func clientIPFromContext(ctx context.Context) string {
|
||||||
|
ip, _ := ctx.Value(clientIPCtxKey{}).(string)
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
||||||
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
|
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
|
||||||
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
|
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
|
||||||
@@ -160,6 +180,13 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
|
|||||||
if userID != "" {
|
if userID != "" {
|
||||||
req.Header.Set("X-User-ID", userID)
|
req.Header.Set("X-User-ID", userID)
|
||||||
}
|
}
|
||||||
|
// The client IP rides X-Forwarded-For so the backend records the real caller. It comes from the
|
||||||
|
// explicit param (chat/feedback) or, for every other call, the request context (WithClientIP, set
|
||||||
|
// once per request in the Connect edge) — so the backend never falls back to the gateway's own
|
||||||
|
// connection address.
|
||||||
|
if clientIP == "" {
|
||||||
|
clientIP = clientIPFromContext(ctx)
|
||||||
|
}
|
||||||
if clientIP != "" {
|
if clientIP != "" {
|
||||||
req.Header.Set("X-Forwarded-For", clientIP)
|
req.Header.Set("X-Forwarded-For", clientIP)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,3 +122,44 @@ func TestXPlatformInjection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestXForwardedForInjection verifies the client IP carried on the context (WithClientIP) rides every
|
||||||
|
// backend call as X-Forwarded-For — not just chat/feedback, which pass it explicitly — so the backend
|
||||||
|
// records the real caller (e.g. the account's last-login IP on the profile fetch) rather than the
|
||||||
|
// gateway's own connection. Absent when no client IP is set.
|
||||||
|
func TestXForwardedForInjection(t *testing.T) {
|
||||||
|
var gotXFF string
|
||||||
|
var hadHeader bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotXFF = r.Header.Get("X-Forwarded-For")
|
||||||
|
_, hadHeader = r.Header["X-Forwarded-For"]
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backendclient: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = c.Close() }()
|
||||||
|
|
||||||
|
t.Run("client IP on ctx rides a non-chat call", func(t *testing.T) {
|
||||||
|
ctx := backendclient.WithClientIP(context.Background(), "203.0.113.7")
|
||||||
|
if _, err := c.Profile(ctx, "user-1"); err != nil {
|
||||||
|
t.Fatalf("Profile: %v", err)
|
||||||
|
}
|
||||||
|
if gotXFF != "203.0.113.7" {
|
||||||
|
t.Fatalf("X-Forwarded-For = %q, want 203.0.113.7", gotXFF)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no client IP omits the header", func(t *testing.T) {
|
||||||
|
hadHeader = true
|
||||||
|
if _, err := c.Profile(context.Background(), "user-1"); err != nil {
|
||||||
|
t.Fatalf("Profile: %v", err)
|
||||||
|
}
|
||||||
|
if hadHeader {
|
||||||
|
t.Fatal("X-Forwarded-For must be absent when no client IP is set")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
@@ -418,6 +421,10 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
|
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
|
||||||
}
|
}
|
||||||
clientIP := peerIP(req.Peer().Addr, req.Header())
|
clientIP := peerIP(req.Peer().Addr, req.Header())
|
||||||
|
// Carry the client IP on the context so the backend client injects it as X-Forwarded-For on every
|
||||||
|
// downstream REST call for this request — the account's last-login IP, chat/feedback moderation and
|
||||||
|
// the backend access log, not only the chat/feedback calls that pass it explicitly.
|
||||||
|
ctx = backendclient.WithClientIP(ctx, clientIP)
|
||||||
|
|
||||||
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
||||||
if op.Auth {
|
if op.Auth {
|
||||||
@@ -465,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))
|
||||||
@@ -633,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,
|
||||||
@@ -657,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
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func TestExecutorChatGateInvalidExternalID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExecutorCreateInvoice(t *testing.T) {
|
func TestExecutorCreateInvoice(t *testing.T) {
|
||||||
sender := &fakeSender{invoiceLink: "https://t.me/$abc"}
|
sender := &fakeSender{invoiceLink: "https://telegram.me/$abc"}
|
||||||
exec := NewExecutor(sender, 0, nil)
|
exec := NewExecutor(sender, 0, nil)
|
||||||
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
|
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
|
||||||
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
|
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
|
||||||
@@ -178,7 +178,7 @@ func TestExecutorCreateInvoice(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("handle: %v", err)
|
t.Fatalf("handle: %v", err)
|
||||||
}
|
}
|
||||||
if !delivered || result != "https://t.me/$abc" {
|
if !delivered || result != "https://telegram.me/$abc" {
|
||||||
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
|
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
|
||||||
}
|
}
|
||||||
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
|
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ type BotConfig struct {
|
|||||||
// bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs).
|
// bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs).
|
||||||
BotUsername string
|
BotUsername string
|
||||||
// BotLinkURL is the main bot's Mini App direct link — the same value the UI builds
|
// BotLinkURL is the main bot's Mini App direct link — the same value the UI builds
|
||||||
// share links from (VITE_TELEGRAM_LINK), e.g. https://t.me/<bot>/<app>. The promo
|
// share links from (VITE_TELEGRAM_LINK), e.g. https://telegram.me/<bot>/<app>. The promo
|
||||||
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
|
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
|
||||||
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
|
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
|
||||||
BotLinkURL string
|
BotLinkURL string
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
|
|||||||
t.Setenv("TELEGRAM_CHAT_ID", "-100222")
|
t.Setenv("TELEGRAM_CHAT_ID", "-100222")
|
||||||
t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token")
|
t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token")
|
||||||
t.Setenv("TELEGRAM_BOT_USERNAME", "@ScrabbleBot")
|
t.Setenv("TELEGRAM_BOT_USERNAME", "@ScrabbleBot")
|
||||||
t.Setenv("TELEGRAM_BOT_LINK", "https://t.me/ScrabbleBot/app")
|
t.Setenv("TELEGRAM_BOT_LINK", "https://telegram.me/ScrabbleBot/app")
|
||||||
c, err := LoadBot()
|
c, err := LoadBot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadBot: %v", err)
|
t.Fatalf("LoadBot: %v", err)
|
||||||
@@ -126,7 +126,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
|
|||||||
if c.BotUsername != "ScrabbleBot" {
|
if c.BotUsername != "ScrabbleBot" {
|
||||||
t.Errorf("BotUsername = %q, want the leading @ stripped", c.BotUsername)
|
t.Errorf("BotUsername = %q, want the leading @ stripped", c.BotUsername)
|
||||||
}
|
}
|
||||||
if c.BotLinkURL != "https://t.me/ScrabbleBot/app" {
|
if c.BotLinkURL != "https://telegram.me/ScrabbleBot/app" {
|
||||||
t.Errorf("BotLinkURL = %q", c.BotLinkURL)
|
t.Errorf("BotLinkURL = %q", c.BotLinkURL)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestPromoTextLocalization(t *testing.T) {
|
func TestPromoTextLocalization(t *testing.T) {
|
||||||
const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en"
|
const url = "https://telegram.me/bot/app?startapp=verudit_ru-scrabble_en"
|
||||||
// The @username is rendered as a clickable deep link (the same target as the button),
|
// The @username is rendered as a clickable deep link (the same target as the button),
|
||||||
// not a plain mention, so tapping it opens the seeded Mini App.
|
// not a plain mention, so tapping it opens the seeded Mini App.
|
||||||
wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
|
wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
|
||||||
@@ -41,17 +41,17 @@ func TestPromoTextLocalization(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLaunchURLAppendsStartapp(t *testing.T) {
|
func TestLaunchURLAppendsStartapp(t *testing.T) {
|
||||||
b := &Bot{linkURL: "https://t.me/bot/app"}
|
b := &Bot{linkURL: "https://telegram.me/bot/app"}
|
||||||
if got := b.launchURL(""); got != "https://t.me/bot/app" {
|
if got := b.launchURL(""); got != "https://telegram.me/bot/app" {
|
||||||
t.Errorf("empty payload = %q, want the base link unchanged", got)
|
t.Errorf("empty payload = %q, want the base link unchanged", got)
|
||||||
}
|
}
|
||||||
if got := b.launchURL("g123"); got != "https://t.me/bot/app?startapp=g123" {
|
if got := b.launchURL("g123"); got != "https://telegram.me/bot/app?startapp=g123" {
|
||||||
t.Errorf("launchURL = %q, want startapp=g123 appended", got)
|
t.Errorf("launchURL = %q, want startapp=g123 appended", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLaunchMarkupIsURLButton(t *testing.T) {
|
func TestLaunchMarkupIsURLButton(t *testing.T) {
|
||||||
b := &Bot{linkURL: "https://t.me/bot/app"}
|
b := &Bot{linkURL: "https://telegram.me/bot/app"}
|
||||||
btn := b.launchMarkup("Play", "f99").InlineKeyboard[0][0]
|
btn := b.launchMarkup("Play", "f99").InlineKeyboard[0][0]
|
||||||
if btn.WebApp != nil {
|
if btn.WebApp != nil {
|
||||||
t.Error("the promo button must not be a web_app button (it would sign initData with the promo token, which the main bot rejects)")
|
t.Error("the promo button must not be a web_app button (it would sign initData with the promo token, which the main bot rejects)")
|
||||||
@@ -84,7 +84,7 @@ func TestHandleStartReplies(t *testing.T) {
|
|||||||
api := &fakeAPI{}
|
api := &fakeAPI{}
|
||||||
srv := httptest.NewServer(api)
|
srv := httptest.NewServer(api)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://t.me/bot/app"}, zap.NewNop())
|
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://telegram.me/bot/app"}, zap.NewNop())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ func TestHandleStartReplies(t *testing.T) {
|
|||||||
if api.chatID != "42" {
|
if api.chatID != "42" {
|
||||||
t.Errorf("chat_id = %q, want 42", api.chatID)
|
t.Errorf("chat_id = %q, want 42", api.chatID)
|
||||||
}
|
}
|
||||||
if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
|
if !strings.Contains(api.text, `<a href="https://telegram.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
|
||||||
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
|
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
|
||||||
}
|
}
|
||||||
if strings.Contains(api.replyMarkup, "web_app") {
|
if strings.Contains(api.replyMarkup, "web_app") {
|
||||||
@@ -113,7 +113,7 @@ func TestHandleStartIgnoresGroup(t *testing.T) {
|
|||||||
api := &fakeAPI{}
|
api := &fakeAPI{}
|
||||||
srv := httptest.NewServer(api)
|
srv := httptest.NewServer(api)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "B", BotLinkURL: "https://t.me/b/a"}, zap.NewNop())
|
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "B", BotLinkURL: "https://telegram.me/b/a"}, zap.NewNop())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
|
|||||||
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID`
|
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID`
|
||||||
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
|
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
|
||||||
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
||||||
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
|
friend-invite Mini App link for the single bot (full URL `https://telegram.me/<bot>/<app>`).
|
||||||
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
|
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
|
||||||
The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version
|
The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version
|
||||||
the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases
|
the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases
|
||||||
|
|||||||
|
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 |
@@ -39,3 +39,77 @@ test('in-game UX: turn strip, bag badge + table footer, staged-play highlight an
|
|||||||
await expect(page.locator('.scorebadge')).toBeVisible();
|
await expect(page.locator('.scorebadge')).toBeVisible();
|
||||||
await expect(page.locator('.make')).toBeVisible();
|
await expect(page.locator('.make')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('online game offline: banner shows, the tray and comms entry freeze, and it thaws on recovery', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
await page.getByRole('button', { name: /🎲/ }).click();
|
||||||
|
await page.getByRole('button', { name: 'Random player' }).click();
|
||||||
|
await page.locator('.variant').first().click();
|
||||||
|
await page.getByRole('button', { name: /Start game/i }).click();
|
||||||
|
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
|
||||||
|
await expect(page.getByText('Robo')).toBeVisible();
|
||||||
|
|
||||||
|
// Baseline online: no banner, the tray is interactive, the comms (chat/dictionary) entry is enabled.
|
||||||
|
await expect(page.locator('.offline-banner')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.rack .tile').first()).toBeEnabled();
|
||||||
|
|
||||||
|
// The connection drops (the net hook rides past the hysteresis straight to offline).
|
||||||
|
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||||
|
|
||||||
|
// The explicit "connection lost" banner appears and every rack tile freezes (cannot compose a move).
|
||||||
|
await expect(page.locator('.offline-banner')).toBeVisible({ timeout: 15000 });
|
||||||
|
const tiles = page.locator('.rack .tile');
|
||||||
|
const n = await tiles.count();
|
||||||
|
expect(n).toBeGreaterThan(0);
|
||||||
|
for (let i = 0; i < n; i++) await expect(tiles.nth(i)).toBeDisabled();
|
||||||
|
|
||||||
|
// The comms entry (💬) and the resign control are HIDDEN offline (like the frozen social controls);
|
||||||
|
// both live in the history drawer, so open it and assert no action icons remain in its header.
|
||||||
|
await page.locator('.scoreboard').click();
|
||||||
|
await expect(page.locator('.hhead button.hicon')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Recovery: the network returns, the banner clears, the tray is interactive and the comms entry returns.
|
||||||
|
await page.evaluate(() => (window as unknown as { __net: { online(): void } }).__net.online());
|
||||||
|
await expect(page.locator('.offline-banner')).toHaveCount(0, { timeout: 15000 });
|
||||||
|
await expect(page.locator('.rack .tile').first()).toBeEnabled();
|
||||||
|
await expect(page.locator('button:has(.chat-ico)')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('online game offline: the dictionary still accepts input and checks (variant seeded from cache)', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
// A Russian (erudit_ru — first in the mock's variant preferences) game, so the offline dictionary is
|
||||||
|
// exercised with a non-Latin alphabet that differs from the panel's default 'scrabble_en'. The panel
|
||||||
|
// seeds its variant + pinned version from the cached game; the mock resolves gameState even offline
|
||||||
|
// (no kill switch), so on the real backend — where that fetch fails offline — the seed is what keeps
|
||||||
|
// the input and the dawg fallback working. This guards the offline dictionary flow end-to-end.
|
||||||
|
await page.getByRole('button', { name: /🎲/ }).click();
|
||||||
|
await page.getByRole('button', { name: 'Random player' }).click();
|
||||||
|
await page.locator('.variant').first().click();
|
||||||
|
await page.getByRole('button', { name: /Start game/i }).click();
|
||||||
|
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
|
||||||
|
await expect(page.getByText('Robo')).toBeVisible();
|
||||||
|
|
||||||
|
// Go offline, then open the dictionary directly (its in-game entry is hidden offline) so CheckScreen
|
||||||
|
// mounts with no network and must seed the variant/version from the cached game, not a failed fetch.
|
||||||
|
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||||
|
await expect(page.locator('.offline-banner')).toBeVisible({ timeout: 15000 });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const route = location.hash.slice(1); // /game/<id>
|
||||||
|
(window as unknown as { __router: { navigate(p: string): void } }).__router.navigate(route + '/check');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The check input keeps the Cyrillic word (the variant is known) and the Check button is usable —
|
||||||
|
// the outcome the cache seed guarantees on the real backend where the offline gameState fetch fails.
|
||||||
|
const input = page.locator('.check input');
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
await input.fill('СЛОВО');
|
||||||
|
await expect(input).toHaveValue('СЛОВО');
|
||||||
|
await expect(page.locator('.check button')).toBeEnabled();
|
||||||
|
|
||||||
|
// Running the check resolves to a verdict (or the neutral offline note), proving the fallback path
|
||||||
|
// executes rather than hanging.
|
||||||
|
await page.locator('.check button').click();
|
||||||
|
await expect(page.locator('.verdict')).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ test('the landing footer links to the legal documents and the feedback bot', asy
|
|||||||
['Пользовательское соглашение', '/eula/'],
|
['Пользовательское соглашение', '/eula/'],
|
||||||
['Политика конфиденциальности', '/privacy/'],
|
['Политика конфиденциальности', '/privacy/'],
|
||||||
['Публичная оферта', '/offer/'],
|
['Публичная оферта', '/offer/'],
|
||||||
['Обратная связь', 'https://t.me/Erudit_GameBot'],
|
['Обратная связь', 'https://telegram.me/Erudit_GameBot'],
|
||||||
];
|
];
|
||||||
for (const [name, href] of links) {
|
for (const [name, href] of links) {
|
||||||
const link = page.getByRole('link', { name });
|
const link = page.getByRole('link', { name });
|
||||||
|
|||||||
@@ -43,20 +43,22 @@ test.describe('native offline-first', () => {
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expectOfflineGuestLobby(page);
|
await expectOfflineGuestLobby(page);
|
||||||
|
|
||||||
// Play a local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). The dictionary
|
// Play a local Erudit vs_ai game with a pinned bag seed (deals the rack ОЖЬЯНАО). Erudit is the
|
||||||
// comes from the APK's bundled tier (./dict/scrabble_en@dev.dawg served from dist-e2e), so no
|
// only variant the profileless offline guest is offered (the new-account default), so the single
|
||||||
// network is needed — this is the device-local guest playing with zero connectivity.
|
// `.variant` click both selects it and asserts nothing else is on offer. The dictionary comes from
|
||||||
|
// the APK's bundled tier (./dict/ru_erudit@dev.dawg served from dist-e2e), so no network is needed
|
||||||
|
// — this is the device-local guest playing with zero connectivity.
|
||||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
||||||
await page.locator('button.tab').nth(0).click();
|
await page.locator('button.tab').nth(0).click();
|
||||||
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
await page.locator('.variant').click();
|
||||||
await page.locator('button.invite').click();
|
await page.locator('button.invite').click();
|
||||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||||
|
|
||||||
// The human plays WAY horizontally across the centre (7,5)-(7,7); the local robot replies with a
|
// The human plays НОЖ horizontally across the centre (7,5)-(7,7); the local robot replies with a
|
||||||
// real move (which needs the bundled dictionary), so the board gains tiles beyond WAY's three.
|
// real move (which needs the bundled dictionary), so the board gains tiles beyond НОЖ's three.
|
||||||
await placeTile(page, 'W', 7, 5);
|
await placeTile(page, 'Н', 7, 5);
|
||||||
await placeTile(page, 'A', 7, 6);
|
await placeTile(page, 'О', 7, 6);
|
||||||
await placeTile(page, 'Y', 7, 7);
|
await placeTile(page, 'Ж', 7, 7);
|
||||||
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
|
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
|
||||||
await page.locator('.make').click();
|
await page.locator('.make').click();
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
@@ -95,14 +97,15 @@ test.describe('native offline-first', () => {
|
|||||||
await expectOfflineGuestLobby(page);
|
await expectOfflineGuestLobby(page);
|
||||||
|
|
||||||
// New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest.
|
// New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest.
|
||||||
// A minimal create: set the mandatory host (master) PIN, decline a seat, English, two open seats.
|
// A minimal create: set the mandatory host (master) PIN, decline a seat, the sole offered variant
|
||||||
|
// (Erudit — the profileless guest's default), two open seats.
|
||||||
await page.locator('button.tab').nth(0).click();
|
await page.locator('button.tab').nth(0).click();
|
||||||
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
||||||
await page.locator('.hostpin .plink').click();
|
await page.locator('.hostpin .plink').click();
|
||||||
await typePin(page, '9999');
|
await typePin(page, '9999');
|
||||||
await typePin(page, '9999');
|
await typePin(page, '9999');
|
||||||
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
|
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
|
||||||
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
await page.locator('.variant').click();
|
||||||
await page.locator('.pname').nth(0).fill('Ann');
|
await page.locator('.pname').nth(0).fill('Ann');
|
||||||
await page.locator('.pname').nth(1).fill('Bob');
|
await page.locator('.pname').nth(1).fill('Bob');
|
||||||
await page.locator('button.invite').click();
|
await page.locator('button.invite').click();
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ If the limitation or exclusion of liability is prohibited by applicable law, the
|
|||||||
|
|
||||||
The Company cares about the protection of personal data. Personal data collected by the Company in the context of this document is subject to automated processing in accordance with applicable law. All information collected as part of providing the service is registered by the Company, which is the data controller. This is very important for the functioning of the services offered by the Company.
|
The Company cares about the protection of personal data. Personal data collected by the Company in the context of this document is subject to automated processing in accordance with applicable law. All information collected as part of providing the service is registered by the Company, which is the data controller. This is very important for the functioning of the services offered by the Company.
|
||||||
|
|
||||||
To exercise one or more of their rights, the User must provide an identity document and contact the person responsible for data protection at the Company (via service support on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot), or send us your request in writing to: 236020, Kaliningrad, Pribrezhny district, Parkovaya St. 1, recipient — Ilya Arkadyevich Denisov).
|
To exercise one or more of their rights, the User must provide an identity document and contact the person responsible for data protection at the Company (via service support on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot), or send us your request in writing to: 236020, Kaliningrad, Pribrezhny district, Parkovaya St. 1, recipient — Ilya Arkadyevich Denisov).
|
||||||
|
|
||||||
In the event of a complaint, the User may contact the personal-data supervisory authority of their country of residence.
|
In the event of a complaint, the User may contact the personal-data supervisory authority of their country of residence.
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@ Although the Company does everything possible to ensure data confidentiality and
|
|||||||
|
|
||||||
**15.8.** The Company reserves the right to revise the terms of this Licence Agreement, in particular, the Game Rules and/or the Forum Rules, by updating the Licence Agreement at [`erudit-game.ru/eula/`](/eula/) or by notifying the User by email. The revised Licence Agreement enters into force from the day of its publication. The User is advised to check the aforementioned website periodically for notices of such changes. The User's failure to take steps to review does not serve as grounds for the User's failure to perform their obligations and non-compliance with the restrictions established by this Licence Agreement. The User's continued use of the Game is deemed acceptance of any revised terms.
|
**15.8.** The Company reserves the right to revise the terms of this Licence Agreement, in particular, the Game Rules and/or the Forum Rules, by updating the Licence Agreement at [`erudit-game.ru/eula/`](/eula/) or by notifying the User by email. The revised Licence Agreement enters into force from the day of its publication. The User is advised to check the aforementioned website periodically for notices of such changes. The User's failure to take steps to review does not serve as grounds for the User's failure to perform their obligations and non-compliance with the restrictions established by this Licence Agreement. The User's continued use of the Game is deemed acceptance of any revised terms.
|
||||||
|
|
||||||
**15.9.** On matters related to the performance of this Licence Agreement and/or the use of the Game, the User may contact the Company on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) and at ilia.denissov@gmail.com.
|
**15.9.** On matters related to the performance of this Licence Agreement and/or the use of the Game, the User may contact the Company on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) and at ilia.denissov@gmail.com.
|
||||||
|
|
||||||
Denisov I.A. | 2026
|
Denisov I.A. | 2026
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ The Company reserves the right to identify and locate all of the User's Accounts
|
|||||||
|
|
||||||
**5.4.** The User is prohibited from attempting to benefit from deliberate (or repeated) participation in the game process as part of a game group (team) with other Users who have violated paragraphs 5.1, 5.5 and/or 5.6 of the Game Rules.
|
**5.4.** The User is prohibited from attempting to benefit from deliberate (or repeated) participation in the game process as part of a game group (team) with other Users who have violated paragraphs 5.1, 5.5 and/or 5.6 of the Game Rules.
|
||||||
|
|
||||||
**5.5.** The User is prohibited from using and distributing information, calling for the use of and publicly distributing any errors, both within the Game and in any other software. A User who discovers such errors in the Game must stop using it and report them to the Company on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) and at ilia.denissov@gmail.com, setting out in detail and truthfully all the circumstances of such discovery and use. If the User has any doubts as to whether the functioning of any particular game process, In-game items or In-game currency is currently normal or has deviations, malfunctions or errors, the User must stop using such process, In-game items or In-game currency and contact the Company at ilia.denissov@gmail.com for the relevant explanations.
|
**5.5.** The User is prohibited from using and distributing information, calling for the use of and publicly distributing any errors, both within the Game and in any other software. A User who discovers such errors in the Game must stop using it and report them to the Company on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) and at ilia.denissov@gmail.com, setting out in detail and truthfully all the circumstances of such discovery and use. If the User has any doubts as to whether the functioning of any particular game process, In-game items or In-game currency is currently normal or has deviations, malfunctions or errors, the User must stop using such process, In-game items or In-game currency and contact the Company at ilia.denissov@gmail.com for the relevant explanations.
|
||||||
|
|
||||||
**5.6.** The User is prohibited from decompiling, decoding and reconstructing data, bypassing data-protection systems, hacking or attempting to hack the software components of the Game or its services, and/or intercepting data coming to or from the server. Prohibited are: (in particular) any modification, alteration, decompilation, decoding, sale or distribution of modified materials of the Game in whole or in part (or the means and materials necessary to perform such actions), the use of programming errors, making changes to the program code and obtaining unauthorised access to the server and database of the Game. In certain special cases, the Company has the right to immediately suspend the User's access to the Game and send a request to the relevant authorities to prevent any violation of the Licence Agreement and/or provisions of applicable law.
|
**5.6.** The User is prohibited from decompiling, decoding and reconstructing data, bypassing data-protection systems, hacking or attempting to hack the software components of the Game or its services, and/or intercepting data coming to or from the server. Prohibited are: (in particular) any modification, alteration, decompilation, decoding, sale or distribution of modified materials of the Game in whole or in part (or the means and materials necessary to perform such actions), the use of programming errors, making changes to the program code and obtaining unauthorised access to the server and database of the Game. In certain special cases, the Company has the right to immediately suspend the User's access to the Game and send a request to the relevant authorities to prevent any violation of the Licence Agreement and/or provisions of applicable law.
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ The Company reserves the right to identify and locate all of the User's Accounts
|
|||||||
|
|
||||||
**8.2.** The User is prohibited from offering such arguments as "in accordance with the role"/"role-play" in defence of unlawful actions of any kind.
|
**8.2.** The User is prohibited from offering such arguments as "in accordance with the role"/"role-play" in defence of unlawful actions of any kind.
|
||||||
|
|
||||||
**8.3.** The User is prohibited from deliberately providing false information when contacting the Company on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) or at ilia.denissov@gmail.com, as well as from falsifying the data provided.
|
**8.3.** The User is prohibited from deliberately providing false information when contacting the Company on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) or at ilia.denissov@gmail.com, as well as from falsifying the data provided.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -406,7 +406,7 @@ The Company reserves the right to identify and locate all of the User's forum ac
|
|||||||
|
|
||||||
**3.9.** Trading (discussion of trading) in game characters, In-game items, In-game currency, forum accounts, character level boosting.
|
**3.9.** Trading (discussion of trading) in game characters, In-game items, In-game currency, forum accounts, character level boosting.
|
||||||
|
|
||||||
**3.10.** Discussion of vulnerabilities or shortcomings of the Game, as well as any actions that in any way violate the Game Rules, or their discussion. Upon discovering vulnerabilities or shortcomings, the User must report them to the Company on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) or at ilia.denissov@gmail.com.
|
**3.10.** Discussion of vulnerabilities or shortcomings of the Game, as well as any actions that in any way violate the Game Rules, or their discussion. Upon discovering vulnerabilities or shortcomings, the User must report them to the Company on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) or at ilia.denissov@gmail.com.
|
||||||
|
|
||||||
**3.11.** Publication of messages causing negative consequences for the game process; provocation of Users to violate the Game Rules.
|
**3.11.** Publication of messages causing negative consequences for the game process; provocation of Users to violate the Game Rules.
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,7 @@
|
|||||||
|
|
||||||
Компания заботится о защите персональных данных. Персональные данные, собранные Компанией в контексте настоящего документа, подлежат автоматизированной обработке в соответствии с действующим законодательством. Вся информация, собранная в рамках предоставления услуги, регистрируется Компанией, которая является контролером данных. Это очень важно для функционирования предлагаемых Компанией услуг.
|
Компания заботится о защите персональных данных. Персональные данные, собранные Компанией в контексте настоящего документа, подлежат автоматизированной обработке в соответствии с действующим законодательством. Вся информация, собранная в рамках предоставления услуги, регистрируется Компанией, которая является контролером данных. Это очень важно для функционирования предлагаемых Компанией услуг.
|
||||||
|
|
||||||
Для осуществления одного или нескольких своих прав Пользователь должен предоставить документ, удостоверяющий личность, и связаться с лицом, ответственным за защиту данных в Компании (через сервисную поддержку в Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot), либо отправьте нам свой запрос в письменном виде по адресу: 236020, г. Калининград, мкр. Прибрежный, ул. Парковая, д. 1, получатель — Денисов Илья Аркадьевич).
|
Для осуществления одного или нескольких своих прав Пользователь должен предоставить документ, удостоверяющий личность, и связаться с лицом, ответственным за защиту данных в Компании (через сервисную поддержку в Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot), либо отправьте нам свой запрос в письменном виде по адресу: 236020, г. Калининград, мкр. Прибрежный, ул. Парковая, д. 1, получатель — Денисов Илья Аркадьевич).
|
||||||
|
|
||||||
В случае возникновения жалобы Пользователь может обратиться в надзорный орган по защите персональных данных страны своего проживания.
|
В случае возникновения жалобы Пользователь может обратиться в надзорный орган по защите персональных данных страны своего проживания.
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
|
|
||||||
**15.8.** Компания оставляет за собой право пересматривать условия настоящего Лицензионного соглашения, в частности, Правила Игры и/или Правила форума, обновляя Лицензионное соглашение на странице [`erudit-game.ru/eula/`](/eula/) или уведомляя Пользователя по электронной почте. Пересмотренное Лицензионное соглашение вступает в силу со дня его опубликования. Пользователю рекомендуется периодически проверять вышеуказанный веб-сайт на наличие уведомлений о таких изменениях. Отказ Пользователя от действий по ознакомлению не может служить основанием для невыполнения обязательств Пользователя и несоблюдения Пользователем ограничений, установленных настоящим Лицензионным соглашением. Продолжение использования Игры Пользователем считается принятием любых пересмотренных условий.
|
**15.8.** Компания оставляет за собой право пересматривать условия настоящего Лицензионного соглашения, в частности, Правила Игры и/или Правила форума, обновляя Лицензионное соглашение на странице [`erudit-game.ru/eula/`](/eula/) или уведомляя Пользователя по электронной почте. Пересмотренное Лицензионное соглашение вступает в силу со дня его опубликования. Пользователю рекомендуется периодически проверять вышеуказанный веб-сайт на наличие уведомлений о таких изменениях. Отказ Пользователя от действий по ознакомлению не может служить основанием для невыполнения обязательств Пользователя и несоблюдения Пользователем ограничений, установленных настоящим Лицензионным соглашением. Продолжение использования Игры Пользователем считается принятием любых пересмотренных условий.
|
||||||
|
|
||||||
**15.9.** По вопросам, связанным с исполнением настоящего Лицензионного соглашения и/или использованием Игры, Пользователь может связаться с Компанией через Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) и по адресу ilia.denissov@gmail.com.
|
**15.9.** По вопросам, связанным с исполнением настоящего Лицензионного соглашения и/или использованием Игры, Пользователь может связаться с Компанией через Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) и по адресу ilia.denissov@gmail.com.
|
||||||
|
|
||||||
Денисов И.А. | 2026
|
Денисов И.А. | 2026
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@
|
|||||||
|
|
||||||
**5.4.** Пользователю запрещается пытаться извлечь выгоду из преднамеренного (или неоднократного) участия в игровом процессе в составе игровой группы (команды) с другими Пользователями, нарушившими пункты 5.1, 5.5 и/или 5.6 Правил Игры.
|
**5.4.** Пользователю запрещается пытаться извлечь выгоду из преднамеренного (или неоднократного) участия в игровом процессе в составе игровой группы (команды) с другими Пользователями, нарушившими пункты 5.1, 5.5 и/или 5.6 Правил Игры.
|
||||||
|
|
||||||
**5.5.** Пользователю запрещается использовать и распространять информацию, призывать к использованию и публично распространять любые ошибки, как внутри Игры, так и в любом другом программном обеспечении. Пользователь, обнаруживший такие ошибки в Игре, должен прекратить её использование и сообщить о них Компании через Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) и по адресу ilia.denissov@gmail.com, подробно и достоверно изложив все обстоятельства такого обнаружения и использования. В случае возникновения у Пользователя каких-либо сомнений в том, является ли такое функционирование какого-либо конкретного игрового процесса, Внутриигровых предметов или Внутриигровой валюты в настоящий момент нормальным или имеет отклонения, сбои, ошибки, Пользователь должен прекратить использование таких процесса, Внутриигровых предметов или Внутриигровой валюты и обратиться к Компании через ilia.denissov@gmail.com для получения соответствующих разъяснений.
|
**5.5.** Пользователю запрещается использовать и распространять информацию, призывать к использованию и публично распространять любые ошибки, как внутри Игры, так и в любом другом программном обеспечении. Пользователь, обнаруживший такие ошибки в Игре, должен прекратить её использование и сообщить о них Компании через Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) и по адресу ilia.denissov@gmail.com, подробно и достоверно изложив все обстоятельства такого обнаружения и использования. В случае возникновения у Пользователя каких-либо сомнений в том, является ли такое функционирование какого-либо конкретного игрового процесса, Внутриигровых предметов или Внутриигровой валюты в настоящий момент нормальным или имеет отклонения, сбои, ошибки, Пользователь должен прекратить использование таких процесса, Внутриигровых предметов или Внутриигровой валюты и обратиться к Компании через ilia.denissov@gmail.com для получения соответствующих разъяснений.
|
||||||
|
|
||||||
**5.6.** Пользователю запрещается декомпилировать, декодировать и реконструировать данные, обходить системы защиты данных, взламывать или пытаться взломать программные компоненты Игры или её сервисов, и/или перехватывать данные, поступающие на сервер или с него. Запрещается: (в частности) любое модифицирование, изменение, декомпиляция, декодирование, продажа или распространение модифицированных материалов Игры в целом или по частям (или средств и материалов, необходимых для выполнения таких действий), использование ошибок программирования, внесение изменений в программный код и получение несанкционированного доступа к серверу и базе данных Игры. В определённых особых случаях Компания имеет право немедленно приостановить доступ Пользователя к Игре и направить запрос в соответствующие органы о предотвращении любого нарушения Лицензионного соглашения и/или положений применимого законодательства.
|
**5.6.** Пользователю запрещается декомпилировать, декодировать и реконструировать данные, обходить системы защиты данных, взламывать или пытаться взломать программные компоненты Игры или её сервисов, и/или перехватывать данные, поступающие на сервер или с него. Запрещается: (в частности) любое модифицирование, изменение, декомпиляция, декодирование, продажа или распространение модифицированных материалов Игры в целом или по частям (или средств и материалов, необходимых для выполнения таких действий), использование ошибок программирования, внесение изменений в программный код и получение несанкционированного доступа к серверу и базе данных Игры. В определённых особых случаях Компания имеет право немедленно приостановить доступ Пользователя к Игре и направить запрос в соответствующие органы о предотвращении любого нарушения Лицензионного соглашения и/или положений применимого законодательства.
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@
|
|||||||
|
|
||||||
**8.2.** Пользователю запрещается предлагать такие аргументы, как «в соответствии с ролью»/«отыгрыш роли», в защиту неправомерных действий любого рода.
|
**8.2.** Пользователю запрещается предлагать такие аргументы, как «в соответствии с ролью»/«отыгрыш роли», в защиту неправомерных действий любого рода.
|
||||||
|
|
||||||
**8.3.** Пользователю запрещается преднамеренно предоставлять ложную информацию в случае обращения к Компании через Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) или по адресу ilia.denissov@gmail.com, а также фальсифицировать предоставленные данные.
|
**8.3.** Пользователю запрещается преднамеренно предоставлять ложную информацию в случае обращения к Компании через Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) или по адресу ilia.denissov@gmail.com, а также фальсифицировать предоставленные данные.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -406,7 +406,7 @@
|
|||||||
|
|
||||||
**3.9.** Торговля (обсуждение торговли) игровыми персонажами, Внутриигровыми предметами, Внутриигровой валютой, учётными записями на форумах, повышение уровня персонажа.
|
**3.9.** Торговля (обсуждение торговли) игровыми персонажами, Внутриигровыми предметами, Внутриигровой валютой, учётными записями на форумах, повышение уровня персонажа.
|
||||||
|
|
||||||
**3.10.** Обсуждение уязвимостей или недоработок Игры, а также любые действия, каким-либо образом нарушающие Правила Игры, или их обсуждение. При обнаружении уязвимостей или недоработок Пользователь должен сообщить об этом Компании через Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) или по адресу ilia.denissov@gmail.com.
|
**3.10.** Обсуждение уязвимостей или недоработок Игры, а также любые действия, каким-либо образом нарушающие Правила Игры, или их обсуждение. При обнаружении уязвимостей или недоработок Пользователь должен сообщить об этом Компании через Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) или по адресу ilia.denissov@gmail.com.
|
||||||
|
|
||||||
**3.11.** Публикация сообщений, вызывающих негативные последствия для игрового процесса; провокация нарушения Пользователями Правил Игры.
|
**3.11.** Публикация сообщений, вызывающих негативные последствия для игрового процесса; провокация нарушения Пользователями Правил Игры.
|
||||||
|
|
||||||
|
|||||||
@@ -152,4 +152,4 @@ Disputes or disagreements on which the Parties have not reached agreement are su
|
|||||||
|
|
||||||
Ilya Arkadyevich Denisov, TIN 290210610742.
|
Ilya Arkadyevich Denisov, TIN 290210610742.
|
||||||
|
|
||||||
Feedback on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot).
|
Feedback on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot).
|
||||||
|
|||||||
@@ -152,4 +152,4 @@
|
|||||||
|
|
||||||
Денисов Илья Аркадьевич, ИНН 290210610742.
|
Денисов Илья Аркадьевич, ИНН 290210610742.
|
||||||
|
|
||||||
Обратная связь в Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot).
|
Обратная связь в Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot).
|
||||||
|
|||||||