Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

## Не выпущено

- [phone] Аккаунт: значок «Поддержал» у привязанного аккаунта с донатом и список благодарностей. В списке только те, кто сам открыл имя
- [tech] Донат в боте и админке: отдельный платёж, миграция 012, флаг и способы выключены. Подписку и реферальные бонусы не меняет. Crypto Pay доната берет токен и хост активного режима. Биллинг не выкатывался

## 0.30.1-beta.4

- [phone] Справка: сначала частые проблемы, чипы устройств в одну строку, «Для вашего телевизора». Кнопки ведут в режим разработчика, включение по сети, обновления, плеер и промокод. У ответа «Полезно» и «Не помогло»
Expand Down
56 changes: 56 additions & 0 deletions docs/superpowers/plans/2026-11-12-donate.md
Original file line number Diff line number Diff line change
@@ -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] Бейдж «Поддержал» и публичный список на экране аккаунта
15 changes: 15 additions & 0 deletions docs/superpowers/specs/2026-11-12-donate-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Донат OMP

Донат - отдельный платёж. Он не продлевает подписку и не пишет строку в `payments`, поэтому реферальное начисление, которое смотрит только оплаченные подписки, его не видит.

Флаг `settings.donate.enabled` и отдельные тумблеры ЮMoney, Stars и Crypto Pay выключены по умолчанию. Секреты остаются в env и в уже существующих полях оплаты.

Метка платежа `don:<id>`. Подпись вебхука и проверка суммы те же, что у подписки: Ю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`.
51 changes: 49 additions & 2 deletions mobile/src/screens/Account.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,11 +23,58 @@ export function Account() {
<h1 class="m-bar-title">{t('settings.account')}</h1>
</div>
<OmpJackettCard />
<ThanksList />
<PromoEntry />
</div>
);
}

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 (
<section class="m-set-group" data-thanks="1">
<h2 class="m-small">{t('sources.billing.thanksTitle')}</h2>
{down ? <p class="m-small m-muted">{t('sources.billing.thanksDown')}</p> : null}
{names && names.length === 0 && !down ? <p class="m-small m-muted">{t('sources.billing.thanksEmpty')}</p> : null}
{names && names.length
? names.map((row) => (
<div class="m-src-row" key={row.name}>
{row.name}
{showAmounts && row.amount != null ? ' - ' + row.amount + ' ₽' : ''}
</div>
))
: null}
</section>
);
}

function PromoEntry() {
const [code, setCode] = useState('');
const [note, setNote] = useState('');
Expand Down
1 change: 1 addition & 0 deletions mobile/src/screens/OmpJackettCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ export function OmpJackettCard() {
</div>
)}
{account && account.overLimit ? <div class="m-src-row m-note">{t('sources.billing.overLimit')}</div> : null}
{account && account.supporter ? <div class="m-src-row m-note" data-supporter="1">{t('sources.billing.supporter')}</div> : null}
{cryptoError ? (
<div class="m-src-row m-note" role="alert">
{cryptoError}
Expand Down
4 changes: 4 additions & 0 deletions ops/billing/admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SupportPanel } from '@/panels/SupportPanel';
import { UserCard } from '@/panels/UserCard';
import { PendingPayments } from '@/panels/DeviceAdmin';
import { AdsPanel } from '@/panels/AdsPanel';
import { DonationsPanel } from '@/panels/DonationsPanel';
import { ReferralPanel } from '@/panels/ReferralPanel';
import { ChangelogPanel } from '@/panels/ChangelogPanel';
import { MonitorPanel } from '@/panels/MonitorPanel';
Expand Down Expand Up @@ -377,6 +378,9 @@ export function App() {
<SettingsCard run={run} settings={d.settings || {}} storage={d.storage} backup={d.backup} secrets={d.secrets} />
</TabsContent>
<TabsContent value="ads">{tab === 'ads' ? <AdsPanel csrf={d.csrf || ''} /> : null}</TabsContent>
<TabsContent value="donations">
<DonationsPanel settings={d.settings?.donate} report={d.donations} run={run} />
</TabsContent>
<TabsContent value="referral">
<ReferralPanel
csrf={d.csrf || ''}
Expand Down
2 changes: 1 addition & 1 deletion ops/billing/admin/src/components/palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function Palette({ open, onOpen, go }: { open: boolean; onOpen: (v: boole
export const NAV = [
{ title: 'Обзор', items: [['home', 'Обзор'], ['users', 'Пользователи'], ['subs', 'Подписки и оплаты'], ['devices', 'Устройства']] },
{ title: 'Сеть', items: [['sources', 'Источники'], ['rank', 'Подбор'], ['cache', 'Кэш'], ['nuc', 'NUC']] },
{ title: 'Деньги', items: [['funnel', 'Воронка'], ['revenue', 'Выручка'], ['finance', 'Финансы'], ['promos', 'Промо'], ['referral', 'Рефералы']] },
{ title: 'Деньги', items: [['funnel', 'Воронка'], ['revenue', 'Выручка'], ['finance', 'Финансы'], ['donations', 'Донаты'], ['promos', 'Промо'], ['referral', 'Рефералы']] },
{ title: 'Работа', items: [['support', 'Поддержка'], ['fails', 'Отказы'], ['ads', 'Реклама'], ['services', 'Сервисы'], ['dht', 'DHT'], ['monitor', 'Мониторинг'], ['changes', 'Изменения']] },
{ title: 'Система', items: [['settings', 'Настройки'], ['audit', 'Журнал'], ['security', 'Доступ'], ['crashes', 'Сбои'], ['broadcast', 'Рассылка'], ['versions', 'Версии']] },
];
79 changes: 79 additions & 0 deletions ops/billing/admin/src/lib/donateForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
export type DonateForm = {
enabled: boolean;
yoomoney: boolean;
stars: boolean;
crypto: boolean;
notifyAll: boolean;
showAmounts: boolean;
amounts: string;
minRub: string;
maxRub: string;
commentMax: string;
thanksText: string;
rubPerStar: string;
notifyMin: string;
};

type Settings = {
enabled?: boolean;
amounts?: string;
minRub?: number;
maxRub?: number;
commentMax?: number;
thanksText?: string;
yoomoney?: boolean;
stars?: boolean;
crypto?: boolean;
rubPerStar?: number;
notifyAll?: boolean;
notifyMin?: number;
showAmounts?: boolean;
};

export function formFromDonateSettings(src?: Settings): DonateForm {
const s = src || {};
return {
enabled: s.enabled === true,
yoomoney: s.yoomoney === true,
stars: s.stars === true,
crypto: s.crypto === true,
notifyAll: s.notifyAll === true,
showAmounts: s.showAmounts === true,
amounts: s.amounts || '100,300,500,1000',
minRub: String(s.minRub ?? 50),
maxRub: String(s.maxRub ?? 100000),
commentMax: String(s.commentMax ?? 200),
thanksText: s.thanksText || 'Спасибо! Вы поддержали проект OMP.',
rubPerStar: String(s.rubPerStar ?? 0),
notifyMin: String(s.notifyMin ?? 0),
};
}

/** A dirty form keeps what the admin typed. A clean form takes the server copy. */
export function keepOrRefreshForm(local: DonateForm, incoming: DonateForm, dirty: boolean): DonateForm {
return dirty ? local : incoming;
}

function sameForm(a: DonateForm, b: DonateForm): boolean {
const keys = Object.keys(a) as (keyof DonateForm)[];
for (let i = 0; i < keys.length; i++) if (a[keys[i]] !== b[keys[i]]) return false;
return true;
}

/** A save that did not succeed must not clear the form, even without flash.error. */
export function donateSaveFailed(res: { ok?: boolean; flash?: { error?: string } } | null | undefined): boolean {
if (!res || res.ok !== true) return true;
return !!(res.flash && res.flash.error);
}

/** Stay dirty when the save failed or the admin typed more while it was running. */
export function stillDirtyAfterSave(current: DonateForm, sent: DonateForm, failed: boolean): boolean {
if (failed) return true;
return !sameForm(current, sent);
}

/** A late response from an older filter selection is dropped. `null` means show the unfiltered report. */
export function takeFilterRows<T>(requestId: number, latestId: number, rows: T[] | null): T[] | null | undefined {
if (requestId !== latestId) return undefined;
return rows;
}
21 changes: 21 additions & 0 deletions ops/billing/admin/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,21 @@ export type HomeData = {
series?: SeriesPoint[];
secrets?: SecretView[];
settings?: Record<string, string | number | boolean> & {
donate?: {
enabled?: boolean;
amounts?: string;
minRub?: number;
maxRub?: number;
commentMax?: number;
thanksText?: string;
yoomoney?: boolean;
stars?: boolean;
crypto?: boolean;
rubPerStar?: number;
notifyAll?: boolean;
notifyMin?: number;
showAmounts?: boolean;
};
referral?: {
enabled?: boolean;
referrerDays?: number;
Expand All @@ -188,6 +203,12 @@ export type HomeData = {
};
};
referralJournal?: { id: string; friendId?: string; status: string; days: number; reason?: string }[];
donations?: {
rows?: { id: string; atText: string; who: string; telegramId: string; amountRub: number; method: string; comment: string; commentHidden: boolean; nameHidden: boolean; status: string }[];
summary?: { day: { sum: number; count: number; average: number }; week: { sum: number; count: number; average: number }; month: { sum: number; count: number; average: number } };
chart?: { day: string; rub: number }[];
top?: { who: string; sum: number; count: number }[];
};
storage?: { engine: string; path: string; bytes: number };
/** Postgres dumps; null when backups are not configured on the server. */
backup?: BackupStatus | null;
Expand Down
Loading
Loading