diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index b2ac274d7..c8db5b820 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -6,6 +6,9 @@ The English translation covers the latest versions only; the full history (in Ru ## Unreleased +- [phone] Account: a Supporter mark on a linked account that donated, and a thanks list. The list shows only people who opened their name +- [tech] Bot and admin donations: a separate payment, migration 012, flag and methods off. It does not change a subscription or referral bonuses. A donation Crypto Pay invoice uses the active mode token and host. Billing was not deployed + ## 0.30.1-beta.4 - [phone] Help lists common problems first, device chips on one row, and "For your TV". Buttons open Developer Mode, wake-on-LAN, updates, the player and a promo code. An answer can be marked useful or not diff --git a/CHANGELOG.md b/CHANGELOG.md index 41025fd41..cef22bb61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## Не выпущено +- [phone] Аккаунт: значок «Поддержал» у привязанного аккаунта с донатом и список благодарностей. В списке только те, кто сам открыл имя +- [tech] Донат в боте и админке: отдельный платёж, миграция 012, флаг и способы выключены. Подписку и реферальные бонусы не меняет. Crypto Pay доната берет токен и хост активного режима. Биллинг не выкатывался + ## 0.30.1-beta.4 - [phone] Справка: сначала частые проблемы, чипы устройств в одну строку, «Для вашего телевизора». Кнопки ведут в режим разработчика, включение по сети, обновления, плеер и промокод. У ответа «Полезно» и «Не помогло» diff --git a/docs/superpowers/plans/2026-11-12-donate.md b/docs/superpowers/plans/2026-11-12-donate.md new file mode 100644 index 000000000..7dae3df74 --- /dev/null +++ b/docs/superpowers/plans/2026-11-12-donate.md @@ -0,0 +1,56 @@ +# Донат OMP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Принять донат любой суммы в боте и показать его в админке, не продлевая подписку. + +**Architecture:** Модуль `ops/billing/src/donate.mjs` хранит черновик и оплату в коллекции `donations`. Вебхуки с меткой `don:` идут в `applyDonation`, остальные в `applyPayment`. Список благодарностей отдаёт `GET /v1/thanks` и рисуется на экране аккаунта. + +**Tech Stack:** Node, Postgres, Preact, React admin. + +**Spec:** `docs/superpowers/specs/2026-11-12-donate-design.md` + +## Global Constraints + +- Только дефис `-`. +- Флаг и способы доната выключены по умолчанию. +- Секреты не коммитить. +- Миграция `012_donations`, пока на main последняя `011_referral`. +- Не вливать PR и не деплоить биллинг. + +--- + +### Task 1: Правила суммы и звёзд + +**Files:** +- Create: `ops/billing/src/donate.mjs` +- Test: `ops/billing/test/donate.test.ts` + +- [x] Падающий тест на флаг, лимиты и `starsForRub` +- [x] Реализация `normalizeDonateSettings`, `validateDonateAmount`, `validateDonateComment`, `starsForRub` + +### Task 2: Бот + +**Files:** +- Modify: `ops/billing/src/bot.mjs` +- Test: `ops/billing/test/donate.test.ts` + +- [x] Кнопка «Поддержать проект», `/donate`, сумма, комментарий, имя, комиссия ЮMoney, счёт Stars + +### Task 3: Оплата и вебхук + +**Files:** +- Modify: `ops/billing/src/server.mjs`, `ops/billing/src/db/pg/schema.mjs`, `ops/billing/src/db/pg/spec.mjs`, `ops/billing/src/state.mjs` +- Test: `ops/billing/test/donateHttp.test.ts` + +- [x] Один вебхук - один донат, короткая сумма не проходит, подписка не меняется +- [x] Админ сохраняет флаги, скрывает имя, отмечает возврат, отдаёт CSV + +### Task 4: Админка и аккаунт + +**Files:** +- Create: `ops/billing/admin/src/panels/DonationsPanel.tsx` +- Modify: `mobile/src/screens/Account.tsx`, `mobile/src/screens/OmpJackettCard.tsx` + +- [x] Вкладка «Донаты» +- [x] Бейдж «Поддержал» и публичный список на экране аккаунта diff --git a/docs/superpowers/specs/2026-11-12-donate-design.md b/docs/superpowers/specs/2026-11-12-donate-design.md new file mode 100644 index 000000000..30ab46497 --- /dev/null +++ b/docs/superpowers/specs/2026-11-12-donate-design.md @@ -0,0 +1,15 @@ +# Донат OMP + +Донат - отдельный платёж. Он не продлевает подписку и не пишет строку в `payments`, поэтому реферальное начисление, которое смотрит только оплаченные подписки, его не видит. + +Флаг `settings.donate.enabled` и отдельные тумблеры ЮMoney, Stars и Crypto Pay выключены по умолчанию. Секреты остаются в env и в уже существующих полях оплаты. + +Метка платежа `don:`. Подпись вебхука и проверка суммы те же, что у подписки: ЮMoney через `settledCovers`, Stars и Crypto Pay через `altCovers`. Повтор того же `operationId` не создаёт второй донат. + +Кнопка «Поддержать проект» и команда `/donate` есть всегда. Пока флаг выключен, бот пишет, что поддержка выключена. Суммы 100/300/500/1000 и своя сумма с мин/макс из админки. Комментарий необязательный. Галочка имени по умолчанию выключена. После оплаты бот шлёт текст благодарности из админки. + +Список благодарностей живёт в экране аккаунта телефона и компьютера (один и тот же `mobile/src`). Экран уже читает биллинг. Сайт omp-player.online статический и выкатывается отдельно, поэтому публичный список там не ставим. Бейдж «Поддержал» берётся из `/v1/me` (`supporter`), список из `GET /v1/thanks`. + +Админка: вкладка «Донаты», лента по Москве, фильтры, итоги дня/недели/месяца, график 30 дней, средний чек, топ, CSV, возврат отметкой, скрытие имени и комментария, уведомление в Telegram о каждом донате и/или от порога. + +Миграция `012_donations`. Перед мерджем сверить номер со свежим main: на момент ветки последняя была `011_referral`. diff --git a/mobile/src/screens/Account.tsx b/mobile/src/screens/Account.tsx index 39c3baea3..1426ea5d4 100644 --- a/mobile/src/screens/Account.tsx +++ b/mobile/src/screens/Account.tsx @@ -1,6 +1,6 @@ -import { useState } from 'preact/hooks'; +import { useEffect, useState } from 'preact/hooks'; import { t } from '../../../src/i18n'; -import { billingPost } from '../../../src/billing/client'; +import { billingGet, billingPost } from '../../../src/billing/client'; import { emitBillingChange } from '../../../src/billing/session'; import { withDeviceToken } from '../../../src/billing/tokens'; import { goBack } from '../nav'; @@ -23,11 +23,58 @@ export function Account() {

{t('settings.account')}

+ ); } +function ThanksList() { + const [names, setNames] = useState<{ name: string; amount?: number }[] | null>(null); + const [showAmounts, setShowAmounts] = useState(false); + const [down, setDown] = useState(false); + useEffect(() => { + let stop = false; + billingGet(phoneSourceContext(), '/v1/thanks', { timeoutMs: 8000 }) + .then((res) => { + if (stop) return; + if (res.status < 200 || res.status >= 300) { + setDown(true); + return; + } + try { + const body = JSON.parse(res.text) as { names?: { name?: string; amount?: number }[]; showAmounts?: boolean }; + const rows = Array.isArray(body.names) ? body.names : []; + setNames(rows.filter((row) => row && row.name).map((row) => ({ name: String(row.name), amount: typeof row.amount === 'number' ? row.amount : undefined }))); + setShowAmounts(body.showAmounts === true); + } catch (e) { + setDown(true); + } + }) + .catch(() => { + if (!stop) setDown(true); + }); + return () => { + stop = true; + }; + }, []); + return ( +
+

{t('sources.billing.thanksTitle')}

+ {down ?

{t('sources.billing.thanksDown')}

: null} + {names && names.length === 0 && !down ?

{t('sources.billing.thanksEmpty')}

: null} + {names && names.length + ? names.map((row) => ( +
+ {row.name} + {showAmounts && row.amount != null ? ' - ' + row.amount + ' ₽' : ''} +
+ )) + : null} +
+ ); +} + function PromoEntry() { const [code, setCode] = useState(''); const [note, setNote] = useState(''); diff --git a/mobile/src/screens/OmpJackettCard.tsx b/mobile/src/screens/OmpJackettCard.tsx index 6472f9632..5080265cf 100644 --- a/mobile/src/screens/OmpJackettCard.tsx +++ b/mobile/src/screens/OmpJackettCard.tsx @@ -256,6 +256,7 @@ export function OmpJackettCard() { )} {account && account.overLimit ?
{t('sources.billing.overLimit')}
: null} + {account && account.supporter ?
{t('sources.billing.supporter')}
: null} {cryptoError ? (