From 77849740ff698d8d4701d8fa1e2b654c6dde415b Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Sat, 21 Mar 2026 18:26:44 +0300 Subject: [PATCH 01/34] =?UTF-8?q?docs:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8E=20platega=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B5=D0=B9=D1=80=D0=BE=D0=BD=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/platega/01-authorization.md | 37 +++++++++ docs/platega/02-create-payment.md | 66 ++++++++++++++++ docs/platega/03-check-status.md | 53 +++++++++++++ docs/platega/04-callback.md | 90 +++++++++++++++++++++ docs/platega/05-rates.md | 36 +++++++++ docs/platega/06-conversions.md | 32 ++++++++ docs/platega/07-schemas.md | 89 +++++++++++++++++++++ docs/platega/08-bot-integration.md | 123 +++++++++++++++++++++++++++++ docs/platega/README.md | 30 +++++++ 9 files changed, 556 insertions(+) create mode 100644 docs/platega/01-authorization.md create mode 100644 docs/platega/02-create-payment.md create mode 100644 docs/platega/03-check-status.md create mode 100644 docs/platega/04-callback.md create mode 100644 docs/platega/05-rates.md create mode 100644 docs/platega/06-conversions.md create mode 100644 docs/platega/07-schemas.md create mode 100644 docs/platega/08-bot-integration.md create mode 100644 docs/platega/README.md diff --git a/docs/platega/01-authorization.md b/docs/platega/01-authorization.md new file mode 100644 index 0000000..1295c63 --- /dev/null +++ b/docs/platega/01-authorization.md @@ -0,0 +1,37 @@ +# Авторизация + +Все запросы к Platega API требуют два заголовка: + +| Заголовок | Значение | +|-----------|----------| +| `X-MerchantId` | UUID мерчанта | +| `X-Secret` | API-ключ | + +Данные выдаются менеджером при подключении и доступны в ЛК (Настройки). + +## Пример запроса + +```http +POST /transaction/process HTTP/1.1 +Host: app.platega.io +Content-Type: application/json +X-MerchantId: 1a021d91-9b26-4762-b303-5d4aac74e921 +X-Secret: your-api-secret-key + +{...} +``` + +## Для Go-клиента + +```go +req.Header.Set("X-MerchantId", cfg.PlategaMerchantID) +req.Header.Set("X-Secret", cfg.PlategaSecret) +req.Header.Set("Content-Type", "application/json") +``` + +## Переменные окружения (для нашего бота) + +```env +PLATEGA_MERCHANT_ID=uuid-мерчанта +PLATEGA_SECRET=api-ключ +``` diff --git a/docs/platega/02-create-payment.md b/docs/platega/02-create-payment.md new file mode 100644 index 0000000..0ba7e9b --- /dev/null +++ b/docs/platega/02-create-payment.md @@ -0,0 +1,66 @@ +# Создание ссылки на оплату + +**POST** `/transaction/process` + +Создает транзакцию и возвращает ссылку для оплаты. ID транзакции генерируется автоматически — не передавать `id` в запросе. + +## Request Body + +```json +{ + "paymentMethod": 2, + "paymentDetails": { + "amount": 500, + "currency": "RUB" + }, + "description": "Оплата VPN подписки", + "return": "https://t.me/your_bot", + "failedUrl": "https://t.me/your_bot", + "payload": "telegram_id:123456789" +} +``` + +### Поля + +| Поле | Тип | Обязательное | Описание | +|------|-----|:---:|----------| +| `paymentMethod` | integer | да | ID способа оплаты (2=СБП, 3=ЕРИП, 11=Карта, 12=Международная, 13=Крипта) | +| `paymentDetails.amount` | float | да | Сумма платежа | +| `paymentDetails.currency` | string | да | Валюта (например, `RUB`) | +| `description` | string | да | Назначение платежа | +| `return` | string (URI) | нет | Редирект при успешном платеже | +| `failedUrl` | string (URI) | нет | Редирект при неуспешном платеже | +| `payload` | string | нет | Дополнительная информация (передается обратно в callback) | + +## Response (200) + +```json +{ + "paymentMethod": "SBPQR", + "transactionId": "3fa85f64-5717-4562-b3fc-2c463f66afa6", + "redirect": "https://pay.platega.io?qrsbp", + "return": "https://t.me/your_bot", + "paymentDetails": "500 RUB", + "status": "PENDING", + "expiresIn": "00:15:00", + "merchantId": "1a021d91-9b26-4762-b303-5d4aac74e921", + "usdtRate": 93.45 +} +``` + +### Поля ответа + +| Поле | Тип | Описание | +|------|-----|----------| +| `transactionId` | UUID | ID созданной транзакции (сохранять!) | +| `redirect` | URI | Ссылка для оплаты — отправить пользователю | +| `status` | string | Всегда `PENDING` при создании | +| `expiresIn` | string | Время жизни платежа (HH:MM:SS), обычно 15 минут | +| `usdtRate` | float | Текущий курс USDT | + +## Ошибки + +| Код | Описание | +|-----|----------| +| 400 | Ошибка валидации запроса | +| 401 | Неверный X-MerchantId или X-Secret | diff --git a/docs/platega/03-check-status.md b/docs/platega/03-check-status.md new file mode 100644 index 0000000..9a93bc4 --- /dev/null +++ b/docs/platega/03-check-status.md @@ -0,0 +1,53 @@ +# Проверка статуса оплаты + +**GET** `/transaction/{id}` + +Возвращает статус и детали транзакции. + +## Параметры + +| Параметр | Место | Тип | Описание | +|----------|-------|-----|----------| +| `id` | path | UUID | ID транзакции | + +## Response (200) + +```json +{ + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "status": "PENDING", + "paymentDetails": { + "amount": 2000, + "currency": "RUB" + }, + "merchantName": "Demo Merchant", + "mechantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "comission": 0, + "paymentMethod": "SBPQR", + "expiresIn": "00:15:00", + "return": "https://example.com/success", + "comissionUsdt": 1.64, + "amountUsdt": 10.90, + "qr": "base64-qr-data-or-url", + "payformSuccessUrl": "https://pay.platega.io/success", + "payload": "telegram_id:123456789", + "comissionType": 1, + "externalId": "0000a4f3-0000-0000-b8ac-fcb675a0000a", + "description": "Оплата VPN подписки" +} +``` + +### Ключевые поля + +| Поле | Описание | +|------|----------| +| `status` | `PENDING` / `CONFIRMED` / `CANCELED` / `CHARGEBACKED` | +| `payload` | Данные, переданные при создании (наш telegram_id) | +| `qr` | QR-код для оплаты (base64 или URL) | +| `expiresIn` | Оставшееся время жизни платежа | + +## Ошибки + +| Код | Описание | +|-----|----------| +| 404 | Транзакция не найдена | diff --git a/docs/platega/04-callback.md b/docs/platega/04-callback.md new file mode 100644 index 0000000..ee74393 --- /dev/null +++ b/docs/platega/04-callback.md @@ -0,0 +1,90 @@ +# Callback (Webhook) об изменении статуса + +Platega отправляет POST-запрос на ваш endpoint при изменении статуса транзакции. + +## Настройка + +URL callback указывается в ЛК: **Настройки -> Callback URLs**. + +## Требования к endpoint + +- Только HTTPS (HTTP запрещен) +- Только публичные IP / доменные имена +- Корректный SSL-сертификат от доверенного CA +- Self-signed сертификаты НЕ допускаются +- Приватные IP-диапазоны запрещены (10.x, 172.16.x, 192.168.x, 127.x) + +## Заголовки входящего запроса + +| Заголовок | Описание | +|-----------|----------| +| `X-MerchantId` | UUID мерчанта (для верификации) | +| `X-Secret` | API-ключ (для верификации) | + +**Важно:** верифицировать `X-MerchantId` и `X-Secret` из заголовков callback, чтобы убедиться, что запрос пришел от Platega. + +## Request Body + +```json +{ + "id": "00000000-0000-0000-0000-000000000000", + "amount": 1000, + "currency": "RUB", + "status": "CONFIRMED", + "paymentMethod": 2, + "payload": "telegram_id:123456789" +} +``` + +### Поля + +| Поле | Тип | Описание | +|------|-----|----------| +| `id` | UUID | ID транзакции | +| `amount` | float | Сумма | +| `currency` | string | Валюта | +| `status` | string | `CONFIRMED` / `CANCELED` / `CHARGEBACKED` | +| `paymentMethod` | integer | ID метода оплаты | +| `payload` | string | Данные, переданные при создании платежа | + +## Статусы + +| Статус | Значение | +|--------|----------| +| `CONFIRMED` | Оплата успешна | +| `CANCELED` | Оплата отменена / не прошла | +| `CHARGEBACKED` | Возврат денежных средств | + +## Retry-логика + +- Таймаут ответа: **60 секунд** +- При отсутствии успешного ответа: до **3 повторных попыток** с интервалом **5 минут** +- Ваш endpoint должен вернуть HTTP 200 + +## Пример обработки (Go) + +```go +func handlePlategaCallback(w http.ResponseWriter, r *http.Request) { + // Верификация заголовков + merchantID := r.Header.Get("X-MerchantId") + secret := r.Header.Get("X-Secret") + if merchantID != cfg.PlategaMerchantID || secret != cfg.PlategaSecret { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var cb CallbackPayload + json.NewDecoder(r.Body).Decode(&cb) + + switch cb.Status { + case "CONFIRMED": + // Активировать подписку + case "CANCELED": + // Уведомить пользователя об отмене + case "CHARGEBACKED": + // Деактивировать подписку + } + + w.WriteHeader(http.StatusOK) +} +``` diff --git a/docs/platega/05-rates.md b/docs/platega/05-rates.md new file mode 100644 index 0000000..068b0b3 --- /dev/null +++ b/docs/platega/05-rates.md @@ -0,0 +1,36 @@ +# Получение курсов валют + +**GET** `/rates/payment_method_rate` + +Возвращает текущий курс обмена для указанного платежного метода и валют. + +## Query-параметры + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|:---:|----------| +| `merchantId` | UUID | да | ID мерчанта | +| `paymentMethod` | integer | да | ID метода оплаты | +| `currencyFrom` | string | да | Исходная валюта (напр. `RUB`) | +| `currencyTo` | string | да | Целевая валюта (напр. `USDT`) | + +## Пример запроса + +``` +GET /rates/payment_method_rate?merchantId=xxx&paymentMethod=2¤cyFrom=RUB¤cyTo=USDT +``` + +## Response (200) + +```json +{ + "paymentMethod": 2, + "currencyFrom": "RUB", + "currencyTo": "USDT", + "rate": 0.0105, + "updatedAt": "2025-08-11T10:15:00Z" +} +``` + +## Применение + +Может использоваться для отображения стоимости подписки в разных валютах. diff --git a/docs/platega/06-conversions.md b/docs/platega/06-conversions.md new file mode 100644 index 0000000..1591fec --- /dev/null +++ b/docs/platega/06-conversions.md @@ -0,0 +1,32 @@ +# Метод получения конвертаций + +**GET** `/transaction/balance-unlock-operations` + +Возвращает историю конвертаций за период. Используется для отчетности / аналитики. + +## Query-параметры + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|:---:|----------| +| `from` | string (datetime) | да | Начало периода, URL-encoded (напр. `2025-01-01T00:00:00Z`) | +| `to` | string (datetime) | да | Конец периода | +| `page` | string | да | Номер страницы | +| `size` | string | да | Размер страницы | + +## Заголовки + +Помимо стандартных `X-MerchantId` и `X-Secret`, требуется: + +| Заголовок | Значение | +|-----------|----------| +| `Accept` | `text/plain` | + +## Пример запроса + +``` +GET /transaction/balance-unlock-operations?from=2025-01-01T00%3A00%3A00Z&to=2025-12-31T23%3A59%3A59Z&page=1&size=20 +``` + +## Применение для бота + +Этот метод, скорее всего, не понадобится для основной интеграции — он предназначен для финансовой отчетности. diff --git a/docs/platega/07-schemas.md b/docs/platega/07-schemas.md new file mode 100644 index 0000000..8034fa2 --- /dev/null +++ b/docs/platega/07-schemas.md @@ -0,0 +1,89 @@ +# Схемы данных + +## PaymentStatus + +``` +PENDING — платеж создан, ожидает оплаты +CONFIRMED — оплата успешна +CANCELED — оплата отменена +CHARGEBACKED — возврат средств +``` + +## PaymentMethodInt + +| ID | Название | +|----|----------| +| 2 | СБП (QR-код) | +| 3 | ЕРИП | +| 11 | Карточный эквайринг | +| 12 | Международная оплата | +| 13 | Криптовалюта | + +## CreateTransactionRequest + +```json +{ + "paymentMethod": 2, // PaymentMethodInt, обязательное + "paymentDetails": { + "amount": 500.0, // float, обязательное + "currency": "RUB" // string, обязательное + }, + "description": "...", // string, обязательное + "return": "https://...", // URI, опционально + "failedUrl": "https://...", // URI, опционально + "payload": "..." // string, опционально +} +``` + +## CreateTransactionResponse + +```json +{ + "paymentMethod": "SBPQR", // string + "transactionId": "uuid", // UUID, обязательное + "redirect": "https://pay...", // URI — ссылка для оплаты + "return": "https://...", // URI + "paymentDetails": "500 RUB", // string или объект {amount, currency} + "status": "PENDING", // PaymentStatus, обязательное + "expiresIn": "00:15:00", // string HH:MM:SS + "merchantId": "uuid", // UUID + "usdtRate": 93.45 // float +} +``` + +## TransactionStatusResponse + +```json +{ + "id": "uuid", + "status": "PENDING", + "paymentDetails": {"amount": 2000, "currency": "RUB"}, + "merchantName": "...", + "mechantId": "uuid", + "comission": 0, + "paymentMethod": "SBPQR", + "expiresIn": "00:15:00", + "return": "https://...", + "comissionUsdt": 1.64, + "amountUsdt": 10.90, + "qr": "base64-or-url", + "payformSuccessUrl": "https://...", + "payload": "...", + "comissionType": 1, + "externalId": "uuid", + "description": "..." +} +``` + +## CallbackPayload + +```json +{ + "id": "uuid", // UUID транзакции + "amount": 1000, // float + "currency": "RUB", // string + "status": "CONFIRMED", // "CONFIRMED" | "CANCELED" | "CHARGEBACKED" + "paymentMethod": 2, // integer + "payload": "..." // string — наши данные +} +``` diff --git a/docs/platega/08-bot-integration.md b/docs/platega/08-bot-integration.md new file mode 100644 index 0000000..508a3f9 --- /dev/null +++ b/docs/platega/08-bot-integration.md @@ -0,0 +1,123 @@ +# План интеграции Platega с VPN-ботом + +## Общая идея + +Пользователь нажимает кнопку "Оплатить подписку" в боте -> бот создает платеж в Platega -> пользователь переходит по ссылке и оплачивает -> Platega отправляет callback -> бот активирует/продлевает подписку через Remnawave API. + +## Что нужно реализовать + +### 1. HTTP-клиент Platega (`internal/platega/client.go`) + +По аналогии с `internal/remnawave/client.go`: + +```go +type Client struct { + baseURL string + merchantID string + secret string + httpClient *http.Client +} + +// Создание платежа +func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (*CreatePaymentResponse, error) + +// Проверка статуса +func (c *Client) GetTransactionStatus(ctx context.Context, transactionID string) (*TransactionStatusResponse, error) +``` + +### 2. Callback-сервер + +Бот должен принимать входящие POST-запросы от Platega. Варианты: + +**Вариант A: Встроенный HTTP-сервер** +- Поднять `net/http` сервер на отдельном порту (напр. 8443) +- Роут: `POST /platega/callback` +- Верифицировать X-MerchantId и X-Secret из заголовков +- Требует публичный HTTPS-адрес (можно через reverse proxy / Caddy) + +**Вариант B: Polling (без callback)** +- После создания платежа периодически проверять статус через GET /transaction/{id} +- Проще в реализации, не требует публичного endpoint +- Минус: задержка до обнаружения оплаты + +**Рекомендация:** Вариант A (callback) — мгновенное подтверждение, стандартная практика. + +### 3. База данных — таблица `payments` + +```sql +CREATE TABLE payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_id INTEGER NOT NULL, + transaction_id TEXT NOT NULL UNIQUE, -- UUID из Platega + amount REAL NOT NULL, + currency TEXT NOT NULL DEFAULT 'RUB', + status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING/CONFIRMED/CANCELED/CHARGEBACKED + payment_method INTEGER NOT NULL, + payload TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + confirmed_at DATETIME, + FOREIGN KEY (telegram_id) REFERENCES users(telegram_id) +); +``` + +### 4. Обработчик в боте (`internal/bot/payment_handler.go`) + +Флоу: +1. Пользователь нажимает "Оплатить" -> выбирает тариф +2. Бот вызывает `platega.CreatePayment()` с `payload = telegram_id` +3. Сохраняет `transaction_id` в таблицу `payments` +4. Отправляет InlineKeyboard с кнопкой-ссылкой `redirect` +5. При получении callback с `status=CONFIRMED`: + - Обновляет запись в `payments` + - Продлевает подписку через Remnawave API (`POST /api/users/{uuid}/extend`) + - Уведомляет пользователя в Telegram + +### 5. Переменные окружения + +```env +# Platega +PLATEGA_MERCHANT_ID=uuid +PLATEGA_SECRET=ключ +PLATEGA_CALLBACK_URL=https://your-domain.com/platega/callback # опционально, для настройки +``` + +### 6. Тарифы + +Определить в конфиге или env: + +```env +PLATEGA_PRICE_1M=200 # цена за 1 месяц в RUB +PLATEGA_PRICE_3M=500 # цена за 3 месяца +PLATEGA_PRICE_6M=900 # цена за 6 месяцев +``` + +## Флоу взаимодействия + +``` +Пользователь Бот Platega Remnawave + | | | | + |-- /pay --------->| | | + |<- Выбери тариф --| | | + |-- 1 месяц ------>| | | + | |-- POST /transaction/process -->| | + | |<-- {redirect, transactionId} --| | + |<- [Оплатить] ----| | | + |-- (переходит) ---|--------------------->| | + | | | | + | (оплата проходит) | | + | | | | + | |<-- POST callback ----| | + | | status=CONFIRMED | | + | | | | + | |-- extend subscription ----------------->| + | |<---------------------------------------------| + |<- Подписка | | | + | активирована! | | | +``` + +## Безопасность + +1. **Верификация callback** — сравнивать X-MerchantId и X-Secret из заголовков с нашими значениями +2. **Идемпотентность** — проверять, что платеж не был обработан повторно (по transaction_id) +3. **payload** — передавать telegram_id для идентификации пользователя при callback +4. **HTTPS** — callback endpoint только по HTTPS с валидным сертификатом diff --git a/docs/platega/README.md b/docs/platega/README.md new file mode 100644 index 0000000..a09c387 --- /dev/null +++ b/docs/platega/README.md @@ -0,0 +1,30 @@ +# Platega API - Документация для интеграции + +Платежная система для приема платежей (СБП, карты, крипта, ЕРИП, международные). + +**Базовый URL:** `https://app.platega.io/` + +## Содержание + +1. [Авторизация](./01-authorization.md) — заголовки X-MerchantId / X-Secret +2. [Создание платежа](./02-create-payment.md) — POST /transaction/process +3. [Проверка статуса](./03-check-status.md) — GET /transaction/{id} +4. [Callback (вебхук)](./04-callback.md) — прием уведомлений об изменении статуса +5. [Курсы валют](./05-rates.md) — GET /rates/payment_method_rate +6. [Конвертации](./06-conversions.md) — GET /transaction/balance-unlock-operations +7. [Схемы данных](./07-schemas.md) — PaymentStatus, PaymentMethodInt, Request/Response +8. [Интеграция с VPN-ботом](./08-bot-integration.md) — план интеграции с нашим ботом + +## Способы оплаты + +| ID | Название | +|----|----------| +| 2 | СБП (QR-код) | +| 3 | ЕРИП | +| 11 | Карточный эквайринг | +| 12 | Международная оплата | +| 13 | Криптовалюта | + +## SDK + +Официальные SDK: PHP, Python (скачиваются с сайта). Для Go SDK нет — используем HTTP-клиент напрямую. From 9c6875a72adc2191ae013e8ccca2ba9aedeb0def Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Sat, 21 Mar 2026 18:59:29 +0300 Subject: [PATCH 02/34] =?UTF-8?q?chore:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=B8=D0=BD=D1=84=D1=83=20=D0=BE=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=B6=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 ++ CLAUDE.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4496862..792ea1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ Telegram-бот управления VPN на базе [Remnawave](https://remnawave.com) +./docs/platega/README.md содержит информацию по взаимодействию с платежной системой + ./docs/api-remnawave2.6.4.json содержит актуальную документацию для всего api панели, используй его, чтобы работать с панелью. Используй поиск по файлу, но не читай его целиком (~4k строк кода, быстро забьется контекст) ./docs/plans/ используется для хранения планов. После создания плана, необходимо документировать его выполнение в новом файле ./docs/progress/ (с соотстветсвующим названием и ссылкой на план для последующей верификации) diff --git a/CLAUDE.md b/CLAUDE.md index 78aaec9..b5a57b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ Telegram-бот управления VPN на базе [Remnawave](https://remnawave.com) +./docs/platega/README.md содержит информацию по взаимодействию с платежной системой + ./docs/api-remnawave2.6.4.json содержит актуальную документацию для всего api панели, используй его, чтобы работать с панелью. Используй поиск по файлу, но не читай его целиком (~4k строк кода, быстро забьется контекст) ./docs/plans/ используется для хранения планов. После создания плана, необходимо документировать его выполнение в новом файле ./docs/progress/ (с соотстветсвующим названием и ссылкой на план для последующей верификации) From 6f25c16f094b7797fe95104cd34d3b3a3ebfcc81 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Sun, 22 Mar 2026 17:19:26 +0300 Subject: [PATCH 03/34] =?UTF-8?q?plan:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BB=D0=B0=D0=BD=D1=8B=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD=D0=B0=20=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=20=D1=81=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B5=D0=B9=20Platega?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Бизнес-план перехода на оплату через Platega - Редизайн UI пользователя (триал, оплата, grace period) - Редизайн UI модератора (заработок, изменение цены) - Редизайн UI админа (общая статистика, управление тарифами) --- docs/plans/2026-03-21-admin-ui-redesign.md | 283 ++++++++++++++++ .../plans/2026-03-21-moderator-ui-redesign.md | 227 +++++++++++++ ...6-03-21-payment-business-model-redesign.md | 301 ++++++++++++++++++ docs/plans/2026-03-21-user-ui-redesign.md | 202 ++++++++++++ 4 files changed, 1013 insertions(+) create mode 100644 docs/plans/2026-03-21-admin-ui-redesign.md create mode 100644 docs/plans/2026-03-21-moderator-ui-redesign.md create mode 100644 docs/plans/2026-03-21-payment-business-model-redesign.md create mode 100644 docs/plans/2026-03-21-user-ui-redesign.md diff --git a/docs/plans/2026-03-21-admin-ui-redesign.md b/docs/plans/2026-03-21-admin-ui-redesign.md new file mode 100644 index 0000000..79d6bd4 --- /dev/null +++ b/docs/plans/2026-03-21-admin-ui-redesign.md @@ -0,0 +1,283 @@ +# UI админа: редизайн + +**Дата:** 2026-03-21 +**Связан с:** [Бизнес-план оплаты через Platega](./2026-03-21-payment-business-model-redesign.md), [UI пользователя](./2026-03-21-user-ui-redesign.md), [UI модератора](./2026-03-21-moderator-ui-redesign.md) + +--- + +## Главное меню админа + +### Раскладка кнопок + +``` +[📋 Управление] [👥 Модераторы] +[📢 Рассылка] [📊 Общая статистика] +[ 👤 Режим пользователя ] +``` + +### Что изменилось + +- Добавлена кнопка "📊 Общая статистика" — финансовая и операционная сводка по всему бизнесу + +--- + +## Подменю "Управление" + +### Раскладка кнопок + +``` +[🎟 Создать инвайт] [📋 Коды] +[🚫 Забанить] [🗑 Удалить код] +[ ♾️ Сменить тариф ] +[ 🔙 В меню админа ] +``` + +Без изменений в раскладке. + +--- + +### 🎟 Создать инвайт + +Без изменений. Создаёт бессрочный (админский) инвайт — без триала, безлимит, бесконечная подписка. + +``` +✅ Инвайт создан! + +Ссылка: https://t.me/bot?start=abc123 +``` + +--- + +### 📋 Коды + +Без изменений. Показывает все инвайт-коды (использованные / неиспользованные). + +--- + +### 🚫 Забанить + +Без изменений. + +``` +Введите telegram_id пользователя: + +[🚫 Отмена] +``` + +Результат: +``` +🚫 Пользователь 123456789 забанен +• Удалён из БД бота +• Удалён из Remnawave +``` + +--- + +### 🗑 Удалить код + +Без изменений. + +--- + +### ♾️ Сменить тариф + +**Было:** сразу спрашивал telegram_id для перевода на бессрочный. +**Стало:** ведёт в подменю с 3 кнопками. + +#### Подменю "Сменить тариф" + +``` +[♾️ Перевести на бессрочную] +[✏️ Изменить цену] +[🔙 Назад] +``` + +#### ♾️ Перевести на бессрочную + +Работает как текущий "Сменить тариф" — переводит пользователя на бессрочный тариф. + +**Шаг 1:** +``` +Введите telegram_id пользователя: + +[🚫 Отмена] +``` + +**Шаг 2:** +``` +Перевод на бессрочный тариф + +Имя: Иван (@ivan) +Куратор: @petr +Текущий срок: до 15.04.2026 + +Перевести на бессрочную подписку? + +[Да] [🚫 Отмена] +``` + +**Шаг 3:** +``` +✅ Пользователь Иван (@ivan) переведён на бессрочный тариф. +``` + +#### ✏️ Изменить цену + +Админ может изменить цену подписки любому пользователю в любой момент (в отличие от модератора, который может менять только триальным). + +**Шаг 1:** +``` +Введите telegram_id пользователя: + +[🚫 Отмена] +``` + +**Шаг 2:** +``` +Текущая цена для Иван (@ivan): 400 руб/мес +Введите новую цену (минимум 400 руб): + +[🚫 Отмена] +``` + +**Шаг 3:** +``` +✅ Цена подписки для Иван (@ivan) изменена: 400 → 500 руб/мес +``` + +**Граничные случаи:** + +| Ситуация | Реакция | +|----------|---------| +| Цена ниже минимума из .env | "❌ Минимальная цена: {MIN_SUBSCRIPTION_PRICE} руб." | +| Пользователь не найден | "❌ Пользователь не найден." | +| Пользователь забанен | "❌ Этот пользователь забанен." | +| Пользователь на бессрочном тарифе | "❌ У этого пользователя бессрочная подписка, цена не применяется." | +| Неверный telegram_id | "❌ Неверный telegram_id." | + +--- + +## Подменю "Модераторы" + +### Раскладка кнопок + +``` +[➕ Назначить модератора] +[📋 Список модераторов] [📊 Статистика] +[➖ Снять модератора] +[🔙 В меню админа] +``` + +Без изменений в раскладке. + +--- + +### ➕ Назначить модератора + +Без изменений. + +--- + +### 📋 Список модераторов + +Показывает действующих модераторов и сколько у них приглашений (активированные + неактивированные). + +``` +📋 Модераторы (3) + +👤 Иван (@ivan) + 📨 Приглашений: 15 + +👤 Пётр (@petr) + 📨 Приглашений: 8 + +👤 Анна (@anna) + 📨 Приглашений: 22 +``` + +--- + +### 📊 Статистика (модераторов) + +Финансовая и операционная сводка по каждому модератору отдельно. + +``` +📊 Статистика модераторов + +👤 Иван (@ivan) + 💳 Платящих: 8 │ ⏳ Триал: 2 │ ⚠️ Grace: 1 + 📥 Платежи за март: 3 200 руб + 📉 Комиссии Platega: -368 руб + 📉 Комиссия вывода (2%): -57 руб + 📊 Чистый доход: 2 775 руб + 💰 Доля модератора (15%): 416 руб + 💰 За всё время: 2 100 руб + +👤 Пётр (@petr) + 💳 Платящих: 17 │ ⏳ Триал: 3 │ ⚠️ Grace: 0 + 📥 Платежи за март: 8 500 руб + 📉 Комиссии Platega: -935 руб + 📉 Комиссия вывода (2%): -151 руб + 📊 Чистый доход: 7 414 руб + 💰 Доля модератора (20%): 1 483 руб + 💰 За всё время: 5 400 руб + +─── +Итого: 💳 25 платящих │ 📥 11 700 руб │ 💰 Выплаты: 1 899 руб +``` + +--- + +### ➖ Снять модератора + +Без изменений. + +--- + +## Подменю "Рассылка" + +### Раскладка кнопок + +``` +[📢 Рассылка активным] +[🔙 В меню админа] +``` + +Без изменений. + +--- + +## 📊 Общая статистика + +Финансовая и операционная сводка по всему бизнесу за текущий месяц. + +``` +📊 Общая статистика — март 2026 + +💰 Финансы +├ Платежей за месяц: 32 +├ Сумма платежей (грязная): 14 800 руб +├ Комиссии Platega: -1 672 руб +├ Комиссия вывода (2%): -263 руб +├ Чистый доход: 12 865 руб +├ Выплаты модераторам: -1 899 руб +└ Доход владельца: 10 966 руб + +👥 Пользователи +├ Всего в системе: 45 +├ 💳 Платящих: 28 +├ ⏳ Триал: 5 +├ ⚠️ Grace period: 3 +├ ♾️ Бессрочных: 9 +└ 📈 Конверсия триал → оплата: 72% (за месяц) +``` + +### Логика "Конверсия триал → оплата" + +За текущий месяц: количество пользователей, которые оплатили первый раз / количество пользователей, которые активировали триал. Показывает эффективность привлечения. + +--- + +## 👤 Режим пользователя + +Без изменений. Переключает админа в пользовательский интерфейс для тестирования (с кнопкой оплаты, даже если подписка бессрочная). diff --git a/docs/plans/2026-03-21-moderator-ui-redesign.md b/docs/plans/2026-03-21-moderator-ui-redesign.md new file mode 100644 index 0000000..449bc0d --- /dev/null +++ b/docs/plans/2026-03-21-moderator-ui-redesign.md @@ -0,0 +1,227 @@ +# UI модератора: редизайн + +**Дата:** 2026-03-21 +**Связан с:** [Бизнес-план оплаты через Platega](./2026-03-21-payment-business-model-redesign.md), [UI пользователя](./2026-03-21-user-ui-redesign.md) + +--- + +## Главное меню пользователя (модератор) + +Модератор видит обычное пользовательское меню + кнопка "Приглашения": + +``` +[ 👤 Мой статус ] +[💳 Оплатить] [📡 Серверы ] +[📚 Инструкции] [ℹ️ Информация] +[ 🎟 Приглашения ] +``` + +--- + +## Меню "Приглашения" (подменю модератора) + +### Раскладка кнопок + +``` +[ 📨 Создать приглашение ] +[📋 Мои приглашения] [👥 Мои подписчики] +[💰 Мой заработок] [🗑 Удалить приглашение] +[ 🔙 В меню ] +``` + +### Что убрано + +- "⏳ Продлить подписку" — модератор больше не продлевает вручную, клиент платит сам через Platega + +### Что добавлено + +- "💰 Мой заработок" — финансовая статистика модератора + +--- + +## Кнопка "Создать приглашение" + +Теперь при создании модератор указывает цену подписки. + +### Флоу + +``` +Шаг 1: Нажал "📨 Создать приглашение" + ↓ +Шаг 2: Бот спрашивает цену + + Введите цену подписки (руб/мес): + Минимум: {MIN_SUBSCRIPTION_PRICE} руб. + + [🚫 Отмена] + + ↓ +Шаг 3: Модератор вводит число + ↓ + Валидация: + - Не число → "❌ Введите число" + - Меньше MIN_SUBSCRIPTION_PRICE → "❌ Минимальная цена: {MIN_SUBSCRIPTION_PRICE} руб." + ↓ +Шаг 4: Инвайт создан + + ✅ Приглашение создано! + Цена подписки: {цена} руб/мес + + Ссылка: https://t.me/{bot}?start={code} + + (возврат в меню модератора) +``` + +--- + +## Кнопка "Мои приглашения" + +Без существенных изменений. Показывает список инвайтов (использованные / неиспользованные) как сейчас. + +--- + +## Кнопка "Мои подписчики" + +### Обогащённый список + +Теперь показывает тип подписки, цену и статус каждого клиента: + +``` +👥 Мои подписчики (5) + +⏳ Иван (@ivan) — триал + до 24.03.26 (осталось 2 дн.) + цена: 400 руб/мес + +💳 Пётр (@petr) — оплачено + до 15.04.26 (осталось 25 дн.) + цена: 500 руб/мес + +⚠️ Анна (@anna) — grace period + VPN деактивирован (кик через 2 дн.) + цена: 400 руб/мес + +⏰ Сергей (@sergey) — истёк + истёк 18.03.26 (кик через 1 дн.) + цена: 450 руб/мес + +❌ ID: 123456789 — удалён + +─── +💳 Платящих: 1 │ ⏳ Триал: 1 │ ⚠️ Grace: 1 │ ⏰ Истекших: 1 │ ❌ Удалённых: 1 +``` + +### Кнопки (в одну линию) + +``` +[✏️ Изменить цену] [🔙 Назад] +``` + +--- + +## Флоу "Изменить цену" + +Кнопки на каждом шаге — в одну линию, чтобы не заслонять интерфейс. + +``` +Шаг 1: Нажал "✏️ Изменить цену" + ↓ +Шаг 2: Бот спрашивает кому + + Введите telegram_id подписчика: + + [🚫 Отмена] (одна кнопка, половинчатая) + + ↓ +Шаг 3: Модератор вводит telegram_id + ↓ + Валидация: + - Не число → "❌ Введите корректный telegram_id" + - Не свой подписчик → "❌ Можно менять цену только своим подписчикам" + - Подписчик уже оплатил подписку → "❌ Нельзя изменить цену — клиент уже оплатил подписку. Обратитесь к администратору." + - Подписчик удалён → "❌ Пользователь удалён из системы" + ↓ +Шаг 4: Бот спрашивает новую цену + + Текущая цена для {имя}: {цена} руб/мес + Введите новую цену (минимум {MIN_SUBSCRIPTION_PRICE} руб): + + [🚫 Отмена] (одна кнопка, половинчатая) + + ↓ +Шаг 5: Модератор вводит число + ↓ + Валидация: + - Не число → "❌ Введите число" + - Меньше MIN_SUBSCRIPTION_PRICE → "❌ Минимальная цена: {MIN_SUBSCRIPTION_PRICE} руб." + ↓ +Шаг 6: Цена изменена + + ✅ Цена подписки для {имя} изменена: {старая} → {новая} руб/мес + + (возврат в меню подписчиков) +``` + +### Граничные случаи + +| Ситуация | Реакция | +|----------|---------| +| Подписчик уже оплатил (не на триале) | "❌ Нельзя изменить цену — клиент уже оплатил подписку. Обратитесь к администратору." | +| Цена ниже минимума | "❌ Минимальная цена: {MIN_SUBSCRIPTION_PRICE} руб." | +| Не свой подписчик | "❌ Можно менять цену только своим подписчикам." | +| Подписчик удалён | "❌ Пользователь удалён из системы." | +| Неверный telegram_id | "❌ Введите корректный telegram_id." | + +--- + +## Кнопка "Мой заработок" + +### Содержание + +``` +💰 Мой заработок + +За март 2026: +├ Платящих клиентов (на 01.03): 12 +├ Ваша доля: 15% (до 15 клиентов) +├ Сумма платежей: 5 600 руб +├ Комиссии Platega: -648 руб +├ Комиссия вывода (2%): -99 руб +├ Чистый доход: 4 853 руб +└ Ваша доля: 728 руб + +За всё время: 4 250 руб +``` + +### Логика расчёта + +1. Берём все платежи клиентов модератора за календарный месяц (со статусом CONFIRMED) +2. Для каждого платежа вычитаем комиссию Platega (зависит от способа оплаты: СБП 11%, карты 12%, крипта 5%) +3. Вычитаем 2% за вывод с биржи +4. Получаем чистый доход +5. Процент доли определяется по количеству **активных платящих клиентов на 1-е число текущего месяца** +6. Доля модератора = чистый доход × процент + +### Шкала долей + +| Активных клиентов (на 1-е число) | Доля | +|----------------------------------|------| +| До 15 | 15% | +| 15–25 | 20% | +| 25+ | 25% | + +### "За всё время" + +Накопительная сумма чистой прибыли модератора за все прошлые месяцы (с учётом всех комиссий и вычетов). + +--- + +## Кнопка "Удалить приглашение" + +Без изменений. Работает как сейчас — модератор вводит код, удаляется только неиспользованный свой инвайт. + +--- + +## Кнопка "В меню" + +Возврат в пользовательское меню (с кнопкой "Приглашения"). diff --git a/docs/plans/2026-03-21-payment-business-model-redesign.md b/docs/plans/2026-03-21-payment-business-model-redesign.md new file mode 100644 index 0000000..6c9cfe7 --- /dev/null +++ b/docs/plans/2026-03-21-payment-business-model-redesign.md @@ -0,0 +1,301 @@ +# Бизнес-план: переход на модель оплаты через Platega + +**Дата:** 2026-03-22 +**Статус:** Утверждён (бизнес-уровень, без кода) + +--- + +## Суть изменений + +Переход от модели "модератор собирает деньги на свою карту и продлевает вручную" к модели "оплата через Platega, деньги на счёт владельца, модератор получает долю". + +--- + +## Жизненный цикл пользователя + +### 1. Создание инвайта (модератор) + +- Модератор создаёт одноразовый инвайт +- При создании указывает цену подписки (минимум задаётся через `MIN_SUBSCRIPTION_PRICE` в .env, по умолчанию 400 руб/мес) +- Самостоятельно передаёт ссылку клиенту + +### 2. Активация инвайта (клиент) + +- Клиент активирует инвайт в боте +- Получает **триал на 3 дня** с лимитом трафика **1 ГБ** +- Сразу получает VPN-конфиг +- Сразу доступна кнопка "Оплатить подписку" + +### 3. Оплата + +- Пользователь нажимает "Оплатить подписку" → выбирает способ оплаты (СБП / карта / крипта) → создаётся платёж в Platega → пользователь переходит по ссылке и оплачивает +- Валюта: только **рубли** (СБП, карты) и **крипта**. ЕРИП и другие валюты не поддерживаются +- После оплаты: подписка продлевается на 1 месяц, лимит трафика снимается (безлимит) +- **Оплата во время триала:** месяц считается от момента оплаты (не от конца триала) +- **Досрочная оплата (оплаченная подписка):** месяц плюсуется к текущей дате окончания подписки +- **Лимит оплаты:** нельзя оплатить, если до конца подписки >= 90 дней (максимум ~3 месяца вперёд) + +### 4. Защита от двойных платежей + +- Если у пользователя есть PENDING платёж (не протухший) → новый не создаётся, отправляется та же ссылка +- Если PENDING платёж протух → помечается как EXPIRED, создаётся новый +- Если пришло два callback CONFIRMED → первый активирует подписку, второй плюсует ещё месяц (деньги пришли — честно зачисляем) + +### 5. Проверка оплаты + +- После отправки ссылки бот пишет: "Обычно это занимает до 1 минуты" +- Доступна кнопка **"🔄 Проверить оплату"** — ручной запрос статуса через Platega API на случай задержки или потери callback + +### 6. Уведомления при окончании триала + +- **За 1 день до конца триала (2-й день)** — "Завтра заканчивается пробный период, оплатите подписку" +- **В день окончания триала (3-й день)** — "Триал закончился, оплатите подписку" +- Если не оплатил → кик сразу (удаление из Remnawave + БД), без grace period +- **Защита от race condition:** scheduler перед киком проверяет наличие confirmed payment — если есть, не кикает + +### 7. Уведомления при окончании оплаченной подписки + +- **За 3 дня** — "Подписка заканчивается через 3 дня, не забудьте продлить" +- **За 1 день** — "Подписка заканчивается завтра, продлите сейчас" +- **В день истечения** — VPN деактивируется (disable в Remnawave), начинается grace period + +### 8. Grace period (3 дня после истечения оплаченной подписки) + +- VPN деактивирован (disabled в Remnawave), но аккаунт не удалён +- При входе в бот — главный экран показывает статус "VPN деактивирован, осталось X дней" + кнопка оплаты +- Пользователь может оплатить и восстановить доступ: + - Месяц считается **от момента оплаты** + - VPN реактивируется (enable в Remnawave) + - Лимит трафика — безлимит +- Если не оплатил за 3 дня → кик (удаление из Remnawave + БД) + +### 9. Повторное приглашение + +- Кикнутый пользователь может вернуться по новому инвайту +- Всё с нуля: новый Remnawave-юзер, новый триал, новая цена +- Приглашать повторно или нет — решение модератора + +--- + +## Сообщения пользователю (UX-тексты) + +Пользователь должен на каждом этапе понимать, что происходит и что от него ожидается. + +### При активации инвайта (начало триала) + +> Добро пожаловать! Вам активирован пробный период на **3 дня** (лимит трафика — 1 ГБ). +> +> За это время вы можете протестировать VPN и решить, подходит ли он вам. +> +> Если вам всё нравится — нажмите кнопку **"Оплатить подписку"**, чтобы получить безлимитный доступ. +> +> ⚠️ Если не оплатить подписку до конца пробного периода, доступ будет удалён. + +### После оплаты + +> ✅ Оплата прошла! Ваша подписка активна до **{дата}**. +> +> Лимит трафика снят — пользуйтесь без ограничений. +> +> Ближе к концу подписки мы напомним о продлении. + +### Экран выбора способа оплаты + +> Подписка на **1 месяц** — **{цена} руб.** +> +> Выберите способ оплаты: + +### После создания платежа (ожидание оплаты) + +> Платёж создан! Перейдите по ссылке для оплаты: +> {ссылка на Platega} +> +> После оплаты подписка будет активирована автоматически. +> Обычно это занимает до 1 минуты. + +### Уведомление — за 1 день до конца триала + +> ⏳ Завтра заканчивается ваш пробный период. +> +> Нажмите **"Оплатить подписку"**, чтобы продолжить пользоваться VPN. +> +> Если не оплатить, доступ будет удалён. + +### Уведомление — в день окончания триала (не оплатил) + +> ⏳ Ваш пробный период закончился. +> +> Нажмите **"Оплатить подписку"**, чтобы продолжить пользоваться VPN. +> +> Если не оплатить, доступ будет удалён. + +### Уведомление — за 3 дня до конца оплаченной подписки + +> ⏳ Ваша подписка заканчивается через **3 дня** ({дата}). +> +> Не забудьте продлить! Нажмите **"Оплатить подписку"**. +> +> Если не продлить вовремя — VPN будет деактивирован, а через 3 дня доступ удалён. + +### Уведомление — за 1 день до конца подписки + +> ⚠️ Ваша подписка заканчивается **завтра** ({дата}). +> +> Продлите сейчас, чтобы не потерять доступ! + +### Уведомление — в день истечения (начало grace period) + +> ❌ Ваша подписка истекла. VPN деактивирован. +> +> У вас есть **3 дня**, чтобы оплатить и восстановить доступ. +> +> Если не оплатить до **{дата+3}**, ваш аккаунт будет удалён. + +### Экран при входе в бот во время grace period + +> ⚠️ **Ваша подписка истекла. VPN деактивирован.** +> +> Осталось **{X} дн.** чтобы оплатить и восстановить доступ. +> +> [Оплатить подписку] + +### Уведомление — кик (не оплатил в grace period) + +> ❌ Ваш доступ удалён — подписка не была продлена. +> +> Вы можете получить новое приглашение для повторного подключения. + +### Уведомление — изменение цены + +> ℹ️ Стоимость вашей подписки изменена: {старая} → {новая} руб/мес +> +> Новая цена будет применена при следующей оплате. + +--- + +## Цена подписки + +- Привязана к конкретному пользователю (задаётся при создании инвайта) +- **Модератор** может изменить цену только пока клиент на триале (до первой оплаты) +- **Админ** может изменить цену любому пользователю в любой момент (минимум из .env действует и для админа) +- Минимальная цена: задаётся через `MIN_SUBSCRIPTION_PRICE` в .env (по умолчанию 400 руб) +- При продлении берётся актуальная цена пользователя +- **Модератор видит** актуальную цену каждого своего клиента (в т.ч. если админ её менял) +- При изменении цены пользователю автоматически отправляется уведомление + +### Хранение цены + +- **Таблица `invites`** — поле `subscription_price`: начальная цена, указанная при создании инвайта +- **Таблица `users`** — поле `subscription_price`: актуальная цена (копируется из invites при активации, обновляется при изменении) +- **Таблица `payments`** — поле `amount`: фактическая сумма платежа (фиксируется навсегда) + +При кике users удаляется, но invites и payments сохраняются — историческая информация не теряется. + +### Миграция: пользователи без цены + +Существующие пользователи с `subscription_price = NULL`: +- В "Мой статус" показывается "Цена подписки: не установлена" +- Кнопка "Оплатить" **скрыта** (нельзя создать платёж без суммы) +- Подписка продолжает работать как раньше +- После установки цены админом — кнопка появляется, пользователь переходит на новую модель + +--- + +## Бесконечные подписки (админские) + +- Админские инвайты — без триала, безлимит, бесконечная подписка (как сейчас) +- Кнопка оплаты **скрыта** для бесконечных подписок +- Исключение: **админ** видит кнопку оплаты для тестирования + +--- + +## Модераторы + +- При назначении модератора ему выдаётся **бессрочная подписка** (бесплатный VPN) +- Модератор видит обычный пользовательский интерфейс + меню "Приглашения" + +--- + +## Финансовая модель + +### Формула расчёта + +На примере одной оплаты: + +``` +Клиент заплатил: 400 руб (СБП) +1. Вычитаем комиссию Platega: 400 - 11% = 356 руб (чистый доход) +2. Вычитаем комиссию вывода: 356 - 2% = 348.88 руб (доход после вывода) +3. Доля модератора (15%): 348.88 × 15% = 52.33 руб +4. Доход владельца: 348.88 - 52.33 = 296.55 руб +``` + +### Комиссии Platega + +Хранятся в .env (могут быть изменены без правки кода): + +```env +PLATEGA_FEE_SBP=11 +PLATEGA_FEE_CARD=12 +PLATEGA_FEE_CRYPTO=5 +PLATEGA_FEE_WITHDRAWAL=2 +``` + +| Способ оплаты | Комиссия (по умолчанию) | +|---------------|-------------------------| +| СБП | 11% | +| Карты | 12% | +| Крипта | 5% | + +Плюс **2% за вывод с биржи** (вычитается после комиссии Platega). + +Комиссии считаются **по каждому платежу отдельно** (т.к. способ оплаты у каждого клиента разный). + +### Доля модератора + +Зависит от количества **активных платящих клиентов** модератора. Определяется на 1-е число при формировании отчёта — scheduler смотрит сколько платящих с активной подпиской **прямо сейчас** и применяет этот процент ко всем платежам за прошлый месяц. + +| Активных клиентов | Доля модератора | +|-------------------|-----------------| +| До 15 | 15% | +| 15–25 | 20% | +| 25+ | 25% | + +### Отчёты + +- **1-го числа каждого месяца** — автоматический отчёт модератору: + - Сколько заработал за прошлый месяц + - Грязная сумма платежей, комиссии, чистый доход, доля +- Модератор видит **заработок за всё время** +- Модератор видит **количество платящих клиентов** +- Модератор видит **актуальную цену подписки каждого клиента** + +### Админская панель + +- Админ видит сводку по всем модераторам (финансы + операционка) +- Общая статистика бизнеса: грязный доход, комиссии, чистый доход, выплаты модераторам, доход владельца +- Операционные метрики: всего пользователей по типам, конверсия триал→оплата (грубый подсчёт: оплаты за месяц / триалы за месяц) + +--- + +## Хранение данных + +- **Таблица `payments`** — все чеки платежей с привязкой к `telegram_id`, `moderator_id` (модератор на момент платежа) и способом оплаты. **Хранятся навсегда**, даже после кика пользователя +- У каждого модератора хранится накопительная сумма заработанная за всё время (чистая прибыль с учётом всех вычетов) + +--- + +## Техническое (высокоуровнево) + +- Платежи через **Platega API** (HTTP-клиент в боте) +- Callback от Platega: встроенный HTTP-сервер в процессе бота, проксируется через **существующий nginx** (location `/platega/callback`) +- Callback URL настраивается через `PLATEGA_CALLBACK_URL` в .env +- Способы оплаты: только СБП, карты (рубли) и крипта. ЕРИП не поддерживается + +--- + +## Миграция существующих пользователей + +- Текущие пользователи с модераторскими подписками — админ вручную устанавливает им индивидуальную цену через админскую панель +- До установки цены — подписка работает как раньше, кнопка оплаты скрыта +- Бесконечные подписки — без изменений diff --git a/docs/plans/2026-03-21-user-ui-redesign.md b/docs/plans/2026-03-21-user-ui-redesign.md new file mode 100644 index 0000000..76448ac --- /dev/null +++ b/docs/plans/2026-03-21-user-ui-redesign.md @@ -0,0 +1,202 @@ +# UI пользователя: редизайн + +**Дата:** 2026-03-21 +**Связан с:** [Бизнес-план оплаты через Platega](./2026-03-21-payment-business-model-redesign.md) + +--- + +## Главное меню + +### Раскладка кнопок + +``` +[ 👤 Мой статус ] ← на всю ширину +[💳 Оплатить] [📡 Серверы ] ← половинчатые +[📚 Инструкции] [ℹ️ Информация] ← половинчатые +``` + +Для модератора — дополнительная кнопка: +``` +[ 👤 Мой статус ] +[💳 Оплатить] [📡 Серверы ] +[📚 Инструкции] [ℹ️ Информация] +[ 🎟 Приглашения ] +``` + +### Что убрано + +- "🌐 Подключить" — ссылка подписки уже отображается в "Мой статус" +- "💸 Поддержать" — заменено на оплату через Platega + +--- + +## Кнопка "Мой статус" + +Данные берутся из Remnawave API (`ExpireAt`, `Status`, `UserTraffic`, `SubscriptionURL`, `hwidDeviceLimit`, `GET /api/hwid/devices/{userUuid}` для кол-ва подключённых устройств). +Цена подписки — из БД бота (привязана к пользователю). + +### Состояние: Триал + +``` +👤 Ваш статус + +Тип: ⏳ Пробный период +Статус: ✅ Активен +Осталось: 2 дня (до 24.03.2026) +Трафик: 0.15 / 1.00 GB +Устройства: 1 / 3 + +Цена подписки: 400 руб/мес + +Ссылка подписки: +{ссылка} + +💡 Оплатите подписку, чтобы снять лимит трафика +и получить безлимитный доступ. +``` + +### Состояние: Оплаченная подписка + +``` +👤 Ваш статус + +Тип: 💳 Подписка +Статус: ✅ Активен +Осталось: 25 дней (до 15.04.2026) +Трафик за месяц: 12.50 GB +Устройства: 2 / 3 +Цена продления: 400 руб/мес + +Ссылка подписки: +{ссылка} +``` + +### Состояние: Grace period + +``` +⚠️ Подписка истекла + +Статус: ⛔ VPN деактивирован +Осталось для оплаты: 2 дня (до 27.03.2026) +Цена подписки: 400 руб/мес + +Оплатите подписку, чтобы восстановить доступ. +Если не оплатить до 27.03.2026, аккаунт будет удалён. +``` + +### Состояние: Бесконечная подписка + +``` +👤 Ваш статус + +Тип: ♾️ Безлимитная подписка +Статус: ✅ Активен +Трафик за месяц: 45.20 GB +Устройства: 2 / 3 + +Ссылка подписки: +{ссылка} +``` + +--- + +## Кнопка "Оплатить подписку" + +### Видимость + +| Состояние пользователя | Кнопка видна? | +|---------------------------------|---------------| +| Триал | Да | +| Оплаченная подписка (< 90 дней) | Да | +| Оплаченная подписка (>= 90 дней)| Нет | +| Grace period | Да | +| Бесконечная подписка | Нет | +| Бесконечная + админ | Да (для тестов)| +| Цена не установлена (миграция) | Нет | + +### Флоу оплаты + +Все кнопки — reply keyboard (как в остальном интерфейсе), не inline. + +``` +Шаг 1: Нажал "💳 Оплатить" + ↓ +Шаг 2: Экран выбора способа оплаты + + Подписка на 1 месяц — 400 руб. + Выберите способ оплаты: + + [🏦 СБП] [💳 Карта] + [🪙 Крипта] [🚫 Отмена] + + ↓ +Шаг 3: Нажал способ → создаётся платёж в Platega + ↓ +Шаг 4: Бот отправляет сообщение со ссылкой на оплату + + Платёж создан! Перейдите по ссылке для оплаты: + {ссылка на Platega} + + После оплаты подписка будет активирована автоматически. + Обычно это занимает до 1 минуты. + + [🔄 Проверить оплату] [🚫 Отмена] + + ↓ +Шаг 5: Callback от Platega (или нажал "Проверить оплату") → бот подтверждает + + ✅ Оплата прошла! Ваша подписка активна до {дата}. + Лимит трафика снят — пользуйтесь без ограничений. +``` + +### Защита от двойных платежей + +- Если есть PENDING платёж (не протухший) → новый не создаётся, отправляется та же ссылка +- Если PENDING платёж протух → помечается как EXPIRED, создаётся новый +- Если пришло два callback CONFIRMED → первый активирует, второй плюсует ещё месяц + +### Обработка ошибок + +- Платёж не прошёл / отменён пользователем → бот получает callback со статусом CANCELED → сообщение: "Платёж отменён. Вы можете попробовать снова." +- Таймаут (пользователь не перешёл по ссылке) → платёж истекает на стороне Platega → при следующей попытке создаётся новый +- "🔄 Проверить оплату" → ручной запрос статуса через Platega API. Если CONFIRMED — активирует подписку. Если PENDING — "Оплата пока не поступила, подождите немного." + +--- + +## Кнопка "Информация" + +Без изменений: + +``` +💡 Помощь и контакты + +Если есть вопросы — пишите @fus1ond + +🔒 Политика конфиденциальности: читать +📜 Пользовательское соглашение: читать +``` + +--- + +## Главный экран при /start + +### Незарегистрированный пользователь + +Без изменений — ввод инвайт-кода. + +### Зарегистрированный — обычное состояние + +Приветствие + главное меню. + +### Зарегистрированный — grace period + +Вместо обычного приветствия — тревожный экран: + +``` +⚠️ Ваша подписка истекла. VPN деактивирован. + +Осталось 2 дня чтобы оплатить и восстановить доступ. +Если не оплатить до 27.03.2026, аккаунт будет удалён. +``` + +Главное меню при этом отображается как обычно (со всеми кнопками), чтобы пользователь мог посмотреть инструкции/серверы/информацию. From 8c7af40b88e5e21ed06c7c11d2908bb4540e52ba Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Sun, 22 Mar 2026 18:18:43 +0300 Subject: [PATCH 04/34] =?UTF-8?q?docs:=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BB=D0=B0=D0=BD=D1=8B?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=D0=BC=20UX-=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-03-21-admin-ui-redesign.md | 101 +++++++++++---- .../plans/2026-03-21-moderator-ui-redesign.md | 29 ++--- ...6-03-21-payment-business-model-redesign.md | 119 ++++++++++++++---- docs/plans/2026-03-21-user-ui-redesign.md | 49 ++++++-- 4 files changed, 220 insertions(+), 78 deletions(-) diff --git a/docs/plans/2026-03-21-admin-ui-redesign.md b/docs/plans/2026-03-21-admin-ui-redesign.md index 79d6bd4..729ae97 100644 --- a/docs/plans/2026-03-21-admin-ui-redesign.md +++ b/docs/plans/2026-03-21-admin-ui-redesign.md @@ -12,12 +12,14 @@ ``` [📋 Управление] [👥 Модераторы] [📢 Рассылка] [📊 Общая статистика] +[🔧 Режим обслуживания] [ 👤 Режим пользователя ] ``` ### Что изменилось - Добавлена кнопка "📊 Общая статистика" — финансовая и операционная сводка по всему бизнесу +- Добавлена кнопка "🔧 Режим обслуживания" — заморозка оплаты и киков --- @@ -29,11 +31,10 @@ [🎟 Создать инвайт] [📋 Коды] [🚫 Забанить] [🗑 Удалить код] [ ♾️ Сменить тариф ] +[ 🔍 Инфо о пользователе ] [ 🔙 В меню админа ] ``` -Без изменений в раскладке. - --- ### 🎟 Создать инвайт @@ -157,6 +158,43 @@ --- +### 🔍 Инфо о пользователе + +Админ вводит telegram_id и получает полную карточку пользователя. + +**Шаг 1:** +``` +Введите telegram_id пользователя: + +[🚫 Отмена] +``` + +**Шаг 2:** +``` +🔍 Информация о пользователе + +👤 Иван | @ivan | 123456789 +📋 Куратор: @petr +💳 Цена подписки: 400 руб/мес +📅 Подписка до: 15.04.2026 (осталось 25 дн.) +📊 Трафик за месяц: 12.50 GB +📡 Устройства: 2 / 3 +🏷 Тип: 💳 Подписка +✅ Статус: Активен +``` + +**Граничные случаи:** + +| Ситуация | Реакция | +|----------|---------| +| Пользователь не найден | "❌ Пользователь не найден." | +| Пользователь забанен | "🚫 Пользователь забанен." | +| Неверный telegram_id | "❌ Неверный telegram_id." | +| Бессрочная подписка | Тип: ♾️ Безлимитная, без строки "Подписка до" | +| Цена не установлена | "Цена подписки: не установлена" | + +--- + ## Подменю "Модераторы" ### Раскладка кнопок @@ -199,31 +237,38 @@ ### 📊 Статистика (модераторов) -Финансовая и операционная сводка по каждому модератору отдельно. +Финансовая и операционная сводка по каждому модератору отдельно. Каждый модератор — **отдельное сообщение**, в конце — итоговое сообщение. Все данные — **live-подсчёт** из `moderator_earnings`, фильтр по календарному месяцу. По умолчанию показывается **прошлый завершённый месяц** (для определения сумм к выплате). +Сообщение 1 (модератор): ``` -📊 Статистика модераторов +📊 Статистика: Иван (@ivan) — март 2026 -👤 Иван (@ivan) - 💳 Платящих: 8 │ ⏳ Триал: 2 │ ⚠️ Grace: 1 - 📥 Платежи за март: 3 200 руб - 📉 Комиссии Platega: -368 руб - 📉 Комиссия вывода (2%): -57 руб - 📊 Чистый доход: 2 775 руб - 💰 Доля модератора (15%): 416 руб - 💰 За всё время: 2 100 руб +💳 Платящих: 8 │ ⏳ Триал: 2 │ ⚠️ Grace: 1 +📥 Платежи: 3 200 руб +📉 Комиссии Platega: -368 руб +📉 Комиссия вывода (2%): -57 руб +📊 Чистый доход: 2 775 руб +💰 Доля модератора (15%): 416 руб +💰 За всё время: 2 100 руб +``` -👤 Пётр (@petr) - 💳 Платящих: 17 │ ⏳ Триал: 3 │ ⚠️ Grace: 0 - 📥 Платежи за март: 8 500 руб - 📉 Комиссии Platega: -935 руб - 📉 Комиссия вывода (2%): -151 руб - 📊 Чистый доход: 7 414 руб - 💰 Доля модератора (20%): 1 483 руб - 💰 За всё время: 5 400 руб +Сообщение 2 (модератор): +``` +📊 Статистика: Пётр (@petr) — март 2026 +💳 Платящих: 17 │ ⏳ Триал: 3 │ ⚠️ Grace: 0 +📥 Платежи: 8 500 руб +📉 Комиссии Platega: -935 руб +📉 Комиссия вывода (2%): -151 руб +📊 Чистый доход: 7 414 руб +💰 Доля модератора (20%): 1 483 руб +💰 За всё время: 5 400 руб +``` + +Сообщение 3 (итого): +``` ─── -Итого: 💳 25 платящих │ 📥 11 700 руб │ 💰 Выплаты: 1 899 руб +📊 Итого за март 2026: 💳 25 платящих │ 📥 11 700 руб │ 💰 К выплате: 1 899 руб ``` --- @@ -278,6 +323,20 @@ --- +## 🔧 Режим обслуживания + +На случай обновления бота, недоступности Platega или других форс-мажоров. Пока режим активен: + +- **Оплата недоступна** — кнопка "Оплатить/Продлить" скрыта, при попытке оплаты: "⚙️ Оплата временно недоступна. Попробуйте позже." +- **Scheduler не кикает и не disable-ит** пользователей +- **Всё остальное работает** — статус, серверы, инструкции, модераторский интерфейс, уведомления + +Кнопка-переключатель: +- Штатный режим → кнопка "🔧 Режим обслуживания". При нажатии: "🔧 Режим обслуживания включён. Оплата и кики приостановлены." +- Режим обслуживания → кнопка "▶️ Штатный режим". При нажатии: "▶️ Штатный режим восстановлен. Оплата и scheduler работают." + +--- + ## 👤 Режим пользователя Без изменений. Переключает админа в пользовательский интерфейс для тестирования (с кнопкой оплаты, даже если подписка бессрочная). diff --git a/docs/plans/2026-03-21-moderator-ui-redesign.md b/docs/plans/2026-03-21-moderator-ui-redesign.md index 449bc0d..4aa21cc 100644 --- a/docs/plans/2026-03-21-moderator-ui-redesign.md +++ b/docs/plans/2026-03-21-moderator-ui-redesign.md @@ -89,19 +89,19 @@ ``` 👥 Мои подписчики (5) -⏳ Иван (@ivan) — триал +⏳ Иван | @ivan | 123456001 — триал до 24.03.26 (осталось 2 дн.) цена: 400 руб/мес -💳 Пётр (@petr) — оплачено +💳 Пётр | @petr | 123456002 — оплачено до 15.04.26 (осталось 25 дн.) цена: 500 руб/мес -⚠️ Анна (@anna) — grace period +⚠️ Анна | @anna | 123456003 — grace period VPN деактивирован (кик через 2 дн.) цена: 400 руб/мес -⏰ Сергей (@sergey) — истёк +⏰ Сергей | @sergey | 123456004 — истёк истёк 18.03.26 (кик через 1 дн.) цена: 450 руб/мес @@ -195,24 +195,11 @@ ### Логика расчёта -1. Берём все платежи клиентов модератора за календарный месяц (со статусом CONFIRMED) -2. Для каждого платежа вычитаем комиссию Platega (зависит от способа оплаты: СБП 11%, карты 12%, крипта 5%) -3. Вычитаем 2% за вывод с биржи -4. Получаем чистый доход -5. Процент доли определяется по количеству **активных платящих клиентов на 1-е число текущего месяца** -6. Доля модератора = чистый доход × процент +Данные берутся из таблицы `moderator_earnings` (заполняется при каждом CONFIRMED-платеже, см. бизнес-план). -### Шкала долей - -| Активных клиентов (на 1-е число) | Доля | -|----------------------------------|------| -| До 15 | 15% | -| 15–25 | 20% | -| 25+ | 25% | - -### "За всё время" - -Накопительная сумма чистой прибыли модератора за все прошлые месяцы (с учётом всех комиссий и вычетов). +- **За текущий месяц** — live-подсчёт: `SELECT SUM(share_amount)` с фильтром по месяцу +- **За всё время** — `SELECT SUM(share_amount)` без фильтра +- Процент доли и все комиссии зафиксированы в каждой записи на момент платежа — пересчёта нет --- diff --git a/docs/plans/2026-03-21-payment-business-model-redesign.md b/docs/plans/2026-03-21-payment-business-model-redesign.md index 6c9cfe7..4410c90 100644 --- a/docs/plans/2026-03-21-payment-business-model-redesign.md +++ b/docs/plans/2026-03-21-payment-business-model-redesign.md @@ -22,7 +22,7 @@ ### 2. Активация инвайта (клиент) - Клиент активирует инвайт в боте -- Получает **триал на 3 дня** с лимитом трафика **1 ГБ** +- Получает **триал на 3 дня** (`expireAt = now() + 72h`) с лимитом трафика (задаётся через `TRIAL_TRAFFIC_LIMIT_GB` в .env, по умолчанию 1 ГБ) - Сразу получает VPN-конфиг - Сразу доступна кнопка "Оплатить подписку" @@ -37,29 +37,47 @@ ### 4. Защита от двойных платежей -- Если у пользователя есть PENDING платёж (не протухший) → новый не создаётся, отправляется та же ссылка +- Если у пользователя есть PENDING платёж (не протухший) **с тем же способом оплаты** → новый не создаётся, отправляется та же ссылка +- Если у пользователя есть PENDING платёж (не протухший) **с другим способом оплаты** → старый помечается как EXPIRED, создаётся новый - Если PENDING платёж протух → помечается как EXPIRED, создаётся новый - Если пришло два callback CONFIRMED → первый активирует подписку, второй плюсует ещё месяц (деньги пришли — честно зачисляем) +- **Защита от race condition:** обработка callback защищена мьютексом по `telegram_id` — параллельные callback для одного пользователя обрабатываются последовательно +- **Chargeback (CHARGEBACKED):** при получении callback со статусом `CHARGEBACKED` → disable пользователя в Remnawave + уведомление админу "⚠️ Chargeback от {имя} ({telegram_id}), сумма: {amount} руб" -### 5. Проверка оплаты +### 5. Отказоустойчивость при недоступности Remnawave + +- При CONFIRMED callback, если Remnawave не отвечает — retry с backoff (3 попытки: 30с, 1м, 5м) +- Если все попытки провалились — платёж помечается как `CONFIRMED_NOT_ACTIVATED` + уведомление админу +- Scheduler при каждом запуске проверяет наличие `CONFIRMED_NOT_ACTIVATED` платежей и пытается доактивировать их +- При ошибке Platega API (создание платежа) — пользователю: "❌ Не удалось создать платёж. Попробуйте позже." + возврат в главное меню + +### 6. Проверка оплаты - После отправки ссылки бот пишет: "Обычно это занимает до 1 минуты" - Доступна кнопка **"🔄 Проверить оплату"** — ручной запрос статуса через Platega API на случай задержки или потери callback -### 6. Уведомления при окончании триала +### 6. Scheduler (event-driven модель) + +Scheduler запускается **каждые 30 минут** и проверяет `expireAt` каждого пользователя. Все сроки привязаны к индивидуальному `expireAt`, а не к фиксированному времени суток. **При старте бота** scheduler сразу делает полный проход (не ждёт 30 минут), чтобы подхватить все пропущенные события после перезапуска. + +Таблица `notifications_sent` защищает от повторных уведомлений. + +**Защита от race condition:** scheduler перед киком проверяет наличие confirmed payment — если есть, не кикает. + +**Режим обслуживания:** админ может включить через кнопку в админ-панели (обновление, недоступность Platega, форс-мажоры). Пока режим активен: оплата недоступна, scheduler не кикает и не disable-ит пользователей. Всё остальное (статус, серверы, уведомления, модераторский интерфейс) работает. -- **За 1 день до конца триала (2-й день)** — "Завтра заканчивается пробный период, оплатите подписку" -- **В день окончания триала (3-й день)** — "Триал закончился, оплатите подписку" -- Если не оплатил → кик сразу (удаление из Remnawave + БД), без grace period -- **Защита от race condition:** scheduler перед киком проверяет наличие confirmed payment — если есть, не кикает +### 7. Уведомления при окончании триала -### 7. Уведомления при окончании оплаченной подписки +- **За 1 день до `expireAt`** — "Завтра заканчивается пробный период, оплатите подписку" +- **При наступлении `expireAt`** — отправка уведомления "Пробный период закончился — доступ удалён. Вы можете получить новое приглашение для повторного подключения." → кик (удаление из Remnawave + БД), без grace period -- **За 3 дня** — "Подписка заканчивается через 3 дня, не забудьте продлить" -- **За 1 день** — "Подписка заканчивается завтра, продлите сейчас" -- **В день истечения** — VPN деактивируется (disable в Remnawave), начинается grace period +### 8. Уведомления при окончании оплаченной подписки -### 8. Grace period (3 дня после истечения оплаченной подписки) +- **За 3 дня до `expireAt`** — "Подписка заканчивается через 3 дня, не забудьте продлить" +- **За 1 день до `expireAt`** — "Подписка заканчивается завтра, продлите сейчас" +- **При наступлении `expireAt`** — VPN деактивируется (disable в Remnawave), начинается grace period + +### 9. Grace period (3 дня после истечения оплаченной подписки) - VPN деактивирован (disabled в Remnawave), но аккаунт не удалён - При входе в бот — главный экран показывает статус "VPN деактивирован, осталось X дней" + кнопка оплаты @@ -67,13 +85,15 @@ - Месяц считается **от момента оплаты** - VPN реактивируется (enable в Remnawave) - Лимит трафика — безлимит -- Если не оплатил за 3 дня → кик (удаление из Remnawave + БД) +- **При наступлении `expireAt + 3 дня`** → кик (удаление из Remnawave + БД) ### 9. Повторное приглашение - Кикнутый пользователь может вернуться по новому инвайту - Всё с нуля: новый Remnawave-юзер, новый триал, новая цена - Приглашать повторно или нет — решение модератора +- При /start кикнутый пользователь видит стандартный экран незарегистрированного (ввод инвайт-кода) — его данных в БД нет +- При /start забаненный пользователь (есть в `banned_users`) видит: "🚫 Ваш доступ заблокирован." — ввод инвайт-кода не доступен --- @@ -87,11 +107,16 @@ > > За это время вы можете протестировать VPN и решить, подходит ли он вам. > +> Ваша ссылка подписки: +> {ссылка} +> +> Скопируйте и вставьте её в приложение (подробнее — в 📚 Инструкции). +> > Если вам всё нравится — нажмите кнопку **"Оплатить подписку"**, чтобы получить безлимитный доступ. > > ⚠️ Если не оплатить подписку до конца пробного периода, доступ будет удалён. -### После оплаты +### После оплаты (из триала) > ✅ Оплата прошла! Ваша подписка активна до **{дата}**. > @@ -99,12 +124,27 @@ > > Ближе к концу подписки мы напомним о продлении. -### Экран выбора способа оплаты +### После оплаты (из grace period) + +> ✅ Оплата прошла! Ваша подписка активна до **{дата}**. +> +> VPN восстановлен — ваша конфигурация осталась прежней, ничего перенастраивать не нужно. +> +> Ближе к концу подписки мы напомним о продлении. + +### Экран выбора способа оплаты (триал / grace period) > Подписка на **1 месяц** — **{цена} руб.** > > Выберите способ оплаты: +### Экран выбора способа оплаты (досрочное продление) + +> Продление на **1 месяц** — **{цена} руб.** +> Текущая подписка до {дата}. После оплаты — до {дата+30}. +> +> Выберите способ оплаты: + ### После создания платежа (ожидание оплаты) > Платёж создан! Перейдите по ссылке для оплаты: @@ -214,6 +254,14 @@ - При назначении модератора ему выдаётся **бессрочная подписка** (бесплатный VPN) - Модератор видит обычный пользовательский интерфейс + меню "Приглашения" +### Снятие модератора + +- Подписчики переходят к админу (`moderator_id = NULL` в таблице `users`) +- Доля с последующих платежей этих подписчиков **не начисляется** никому +- Бессрочная подписка бывшего модератора **сохраняется** (он остаётся обычным пользователем) +- Меню "Приглашения" скрывается +- Исторические данные в `payments` и `moderator_earnings` сохраняются (moderator_id зафиксирован на момент платежа) + --- ## Финансовая модель @@ -251,9 +299,9 @@ PLATEGA_FEE_WITHDRAWAL=2 Комиссии считаются **по каждому платежу отдельно** (т.к. способ оплаты у каждого клиента разный). -### Доля модератора +### Доля модератора (event-driven) -Зависит от количества **активных платящих клиентов** модератора. Определяется на 1-е число при формировании отчёта — scheduler смотрит сколько платящих с активной подпиской **прямо сейчас** и применяет этот процент ко всем платежам за прошлый месяц. +Зависит от количества **активных платящих клиентов** модератора. Рассчитывается **при каждом платеже** — в момент CONFIRMED callback бот считает текущее количество платящих клиентов модератора и применяет соответствующий процент. | Активных клиентов | Доля модератора | |-------------------|-----------------| @@ -261,12 +309,25 @@ PLATEGA_FEE_WITHDRAWAL=2 | 15–25 | 20% | | 25+ | 25% | +Прошлые начисления **не пересчитываются** — каждый платёж зафиксирован с тем процентом, который действовал на момент оплаты. + +### Таблица `moderator_earnings` + +При каждом CONFIRMED-платеже создаётся запись: +- `payment_id` — ссылка на платёж +- `moderator_id` — telegram_id модератора +- `gross_amount` — сумма платежа +- `platega_fee` — комиссия Platega +- `withdrawal_fee` — комиссия вывода (2%) +- `net_amount` — чистый доход (после всех комиссий) +- `share_percent` — процент доли на момент платежа +- `share_amount` — сумма доли модератора +- `created_at` — дата + ### Отчёты -- **1-го числа каждого месяца** — автоматический отчёт модератору: - - Сколько заработал за прошлый месяц - - Грязная сумма платежей, комиссии, чистый доход, доля -- Модератор видит **заработок за всё время** +- "Мой заработок" — **live-подсчёт** (SELECT SUM по `moderator_earnings` с фильтром по месяцу). Данные всегда актуальны, scheduler для отчётов не нужен +- Модератор видит **заработок за текущий месяц** и **за всё время** - Модератор видит **количество платящих клиентов** - Модератор видит **актуальную цену подписки каждого клиента** @@ -280,8 +341,17 @@ PLATEGA_FEE_WITHDRAWAL=2 ## Хранение данных -- **Таблица `payments`** — все чеки платежей с привязкой к `telegram_id`, `moderator_id` (модератор на момент платежа) и способом оплаты. **Хранятся навсегда**, даже после кика пользователя -- У каждого модератора хранится накопительная сумма заработанная за всё время (чистая прибыль с учётом всех вычетов) +- **Таблица `payments`** — все платежи. **Хранятся навсегда**, даже после кика пользователя. Поля: + - `id` — первичный ключ + - `telegram_id` — пользователь + - `moderator_id` — модератор на момент платежа + - `amount` — сумма платежа (руб) + - `payment_method` — способ оплаты (sbp/card/crypto) + - `status` — pending / confirmed / expired / canceled / chargebacked / confirmed_not_activated + - `platega_transaction_id` — UUID транзакции в Platega + - `created_at` — дата создания + - `confirmed_at` — дата подтверждения +- **Таблица `moderator_earnings`** — начисления модераторам (см. раздел "Доля модератора") --- @@ -290,6 +360,7 @@ PLATEGA_FEE_WITHDRAWAL=2 - Платежи через **Platega API** (HTTP-клиент в боте) - Callback от Platega: встроенный HTTP-сервер в процессе бота, проксируется через **существующий nginx** (location `/platega/callback`) - Callback URL настраивается через `PLATEGA_CALLBACK_URL` в .env +- **Верификация callback:** бот проверяет заголовки `X-MerchantId` и `X-Secret` из входящего запроса. Если не совпадают с `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET` из .env — возвращает 401 Unauthorized. Обязательное требование безопасности - Способы оплаты: только СБП, карты (рубли) и крипта. ЕРИП не поддерживается --- diff --git a/docs/plans/2026-03-21-user-ui-redesign.md b/docs/plans/2026-03-21-user-ui-redesign.md index 76448ac..6bffcc9 100644 --- a/docs/plans/2026-03-21-user-ui-redesign.md +++ b/docs/plans/2026-03-21-user-ui-redesign.md @@ -9,16 +9,22 @@ ### Раскладка кнопок +Название кнопки оплаты — **динамическое**: + +- Триал / grace period → "💳 Оплатить подписку" +- Активная оплаченная подписка → "💳 Продлить подписку" + ``` [ 👤 Мой статус ] ← на всю ширину -[💳 Оплатить] [📡 Серверы ] ← половинчатые +[💳 Оплатить/Продлить] [📡 Серверы ] ← половинчатые [📚 Инструкции] [ℹ️ Информация] ← половинчатые ``` Для модератора — дополнительная кнопка: + ``` [ 👤 Мой статус ] -[💳 Оплатить] [📡 Серверы ] +[💳 Оплатить/Продлить] [📡 Серверы ] [📚 Инструкции] [ℹ️ Информация] [ 🎟 Приглашения ] ``` @@ -55,6 +61,12 @@ и получить безлимитный доступ. ``` +При исчерпании трафика (usedTraffic >= trafficLimit) подсказка меняется: +``` +⚠️ Лимит трафика исчерпан. VPN не работает. +Оплатите подписку для безлимитного доступа. +``` + ### Состояние: Оплаченная подписка ``` @@ -104,15 +116,18 @@ ### Видимость -| Состояние пользователя | Кнопка видна? | -|---------------------------------|---------------| -| Триал | Да | -| Оплаченная подписка (< 90 дней) | Да | -| Оплаченная подписка (>= 90 дней)| Нет | -| Grace period | Да | -| Бесконечная подписка | Нет | -| Бесконечная + админ | Да (для тестов)| -| Цена не установлена (миграция) | Нет | +| Состояние пользователя | Кнопка видна? | +| -------------------------------- | --------------- | +| Триал | Да | +| Оплаченная подписка (< 90 дней) | Да | +| Оплаченная подписка (>= 90 дней) | Да (с объяснением при нажатии) | +| Grace period | Да | +| Бесконечная подписка | Нет | +| Бесконечная + админ | Да (для тестов) | +| Цена не установлена (миграция) | Нет | + +При нажатии "Продлить" с подпиской >= 90 дней: +> ℹ️ Подписка уже оплачена до {дата}. Продлить можно не раньше чем за 90 дней до окончания. ### Флоу оплаты @@ -149,9 +164,15 @@ Лимит трафика снят — пользуйтесь без ограничений. ``` +### Кнопка "Отмена" на экране ожидания оплаты + +- Возвращает в главное меню. PENDING-платёж **не отменяется** (протухнет сам по TTL) +- При повторном нажатии "Оплатить" → если PENDING-платёж ещё жив — вернётся та же ссылка (см. ниже) + ### Защита от двойных платежей -- Если есть PENDING платёж (не протухший) → новый не создаётся, отправляется та же ссылка +- Если есть PENDING платёж (не протухший) **с тем же способом оплаты** → новый не создаётся, отправляется та же ссылка +- Если есть PENDING платёж (не протухший) **с другим способом оплаты** → старый помечается как EXPIRED, создаётся новый с выбранным способом - Если PENDING платёж протух → помечается как EXPIRED, создаётся новый - Если пришло два callback CONFIRMED → первый активирует, второй плюсует ещё месяц @@ -161,6 +182,10 @@ - Таймаут (пользователь не перешёл по ссылке) → платёж истекает на стороне Platega → при следующей попытке создаётся новый - "🔄 Проверить оплату" → ручной запрос статуса через Platega API. Если CONFIRMED — активирует подписку. Если PENDING — "Оплата пока не поступила, подождите немного." +### Прерывание флоу + +Если пользователь нажимает кнопку главного меню (например "Мой статус") из середины любого флоу (оплата, выбор способа и т.д.) — state сбрасывается, возврат в главное меню. PENDING-платёж при этом остаётся — при повторном нажатии "Оплатить" с тем же способом вернётся та же ссылка. + --- ## Кнопка "Информация" From 8cc61b0f2f8f5b7737ec1cd17800b21565475db2 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 11:02:19 +0300 Subject: [PATCH 05/34] =?UTF-8?q?plan:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=91=D0=B6=D0=BD=D0=BE=D0=B9=20=D1=81=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=D1=8B=20=D0=B8=20=D1=87=D0=B5=D0=BA=D0=BB?= =?UTF-8?q?=D0=B8=D1=81=D1=82=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-03-22-deployment-checklist.md | 442 ++++ .../2026-03-22-payment-implementation-plan.md | 2084 +++++++++++++++++ 2 files changed, 2526 insertions(+) create mode 100644 docs/plans/2026-03-22-deployment-checklist.md create mode 100644 docs/plans/2026-03-22-payment-implementation-plan.md diff --git a/docs/plans/2026-03-22-deployment-checklist.md b/docs/plans/2026-03-22-deployment-checklist.md new file mode 100644 index 0000000..131e641 --- /dev/null +++ b/docs/plans/2026-03-22-deployment-checklist.md @@ -0,0 +1,442 @@ +# Чеклист развёртывания платёжной системы Platega + +**Дата:** 2026-03-22 +**Связан с:** [План реализации](./2026-03-22-payment-implementation-plan.md) + +--- + +## 1. Переменные окружения (.env) + +### Обязательные (новые) + +```env +# Platega API (получить в ЛК Platega) +PLATEGA_MERCHANT_ID=ваш_merchant_id +PLATEGA_SECRET=ваш_secret_key + +# Полный URL для callback (HTTPS обязательно!) +PLATEGA_CALLBACK_URL=https://vpn.fus1ond.ru/platega/callback +``` + +### Опциональные (с дефолтами) + +```env +# Порт callback-сервера внутри контейнера (nginx проксирует на него) +CALLBACK_PORT=8080 + +# Минимальная цена подписки (руб/мес) +MIN_SUBSCRIPTION_PRICE=400 + +# Лимит трафика триала (ГБ) +TRIAL_TRAFFIC_LIMIT_GB=1 + +# Комиссии Platega (%). Менять только если Platega изменит тарифы +PLATEGA_FEE_SBP=11 +PLATEGA_FEE_CARD=12 +PLATEGA_FEE_CRYPTO=5 +PLATEGA_FEE_WITHDRAWAL=2 +``` + +### Полный пример .env после обновления + +```env +# Telegram +BOT_TOKEN=123456:ABC-DEF +ADMIN_ID=123456789 + +# Remnawave +REMNAWAVE_URL=https://panel.example.com +REMNAWAVE_API_TOKEN=your-token +REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid1,uuid2 + +# База данных +DB_PATH=/app/data/bot.db + +# Мониторинг +SD_CONFIGS_PATH=/app/sd_configs +VICTORIA_METRICS_URL=http://victoriametrics:8428 + +# Platega (НОВОЕ) +PLATEGA_MERCHANT_ID=your-merchant-id +PLATEGA_SECRET=your-secret-key +PLATEGA_CALLBACK_URL=https://vpn.fus1ond.ru/platega/callback +CALLBACK_PORT=8080 +MIN_SUBSCRIPTION_PRICE=400 +TRIAL_TRAFFIC_LIMIT_GB=1 +``` + +--- + +## 2. Настройка nginx + +### Текущая ситуация на сервере + +- **VPS:** `5.53.125.146` (Selectel) +- **nginx** работает как Docker-контейнер `mycvwebsite-nginx-1` (image: `nginx:alpine`) +- **Конфиг nginx:** `/root/MyCVWEBsite/nginx.prod.conf` (монтируется в контейнер как `/etc/nginx/nginx.conf`) +- **Docker-compose nginx:** `/root/MyCVWEBsite/docker-compose.prod.yml` +- **vpn-bot:** `/root/vpn_bot/docker-compose.yml`, контейнер `vpn-bot` +- **Сети:** nginx в `mycvwebsite_pwp-network`, vpn-bot в `vpn_bot_vpn-network` — **разные сети!** +- **Домены:** `fus1ond.ru` (портфолио), `moto-23.ru` (магазин). Для VPN-бота домена пока нет +- **SSL:** Let's Encrypt через certbot (контейнер `mycvwebsite-certbot-1`) + +### Что нужно сделать + +#### 2.1. Выделить домен/субдомен для callback + +Platega требует HTTPS. Варианты: + +- **Вариант A (рекомендуется):** Субдомен `vpn.fus1ond.ru` — добавить A-запись в DNS, указывающую на `5.53.125.146` +- **Вариант B:** Отдельный домен +- **Вариант C:** Использовать `fus1ond.ru` с отдельным location — проще, но мешает основному сайту + +#### 2.2. Подключить vpn-bot к сети nginx + +nginx и vpn-bot в разных Docker-сетях. Нужно подключить vpn-bot к сети nginx, чтобы nginx мог проксировать на него по имени контейнера. + +**Способ: добавить external network в docker-compose vpn-bot** + +В файле `/root/vpn_bot/docker-compose.yml` добавить: + +```yaml +services: + vpn-bot: + # ... существующие настройки ... + ports: + - "127.0.0.1:8080:8080" # Fallback доступ с хоста (порт можно поменять через CALLBACK_PORT в .env) + networks: + - vpn-network + - mycvwebsite_pwp-network # Подключение к сети nginx + +# ... существующие сервисы ... + +networks: + vpn-network: + driver: bridge + mycvwebsite_pwp-network: + external: true # Сеть создана docker-compose из MyCVWEBsite +``` + +После изменения: +```bash +cd /root/vpn_bot +docker compose down && docker compose up -d +``` + +Теперь nginx сможет обращаться к `vpn-bot:8080` по имени контейнера. + +#### 2.3. Получить SSL-сертификат для субдомена + +```bash +# Добавить субдомен в certbot (из директории MyCVWEBsite) +cd /root/MyCVWEBsite +docker compose -f docker-compose.prod.yml exec certbot certbot certonly --webroot \ + --webroot-path=/var/www/certbot \ + -d vpn.fus1ond.ru \ + --agree-tos --no-eff-email +``` + +Или расширить существующий сертификат: +```bash +docker compose -f docker-compose.prod.yml exec certbot certbot certonly --webroot \ + --webroot-path=/var/www/certbot \ + -d fus1ond.ru -d vpn.fus1ond.ru \ + --agree-tos --no-eff-email --expand +``` + +#### 2.4. Добавить server block в nginx.prod.conf + +В файле `/root/MyCVWEBsite/nginx.prod.conf` добавить **перед** закрывающей `}` блока `http`: + +```nginx + # VPN Bot Platega callback + server { + listen 80; + server_name vpn.fus1ond.ru; + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + location / { + return 301 https://$host$request_uri; + } + } + + server { + listen 443 ssl; + server_name vpn.fus1ond.ru; + ssl_certificate /etc/letsencrypt/live/vpn.fus1ond.ru/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/vpn.fus1ond.ru/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Platega callback + location /platega/callback { + proxy_pass http://vpn-bot:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Передаём кастомные заголовки Platega (X-MerchantId, X-Secret) + proxy_pass_request_headers on; + + # Таймаут (Platega ждёт до 60 сек) + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + + # Health check + location /health { + proxy_pass http://vpn-bot:8080; + } + + # Всё остальное — 404 + location / { + return 404; + } + } +``` + +**Примечание:** `proxy_pass http://vpn-bot:8080` работает потому что vpn-bot подключён к сети `mycvwebsite_pwp-network` (шаг 2.2). Если вместо этого используется проброс порта на хост, заменить на `proxy_pass http://host.docker.internal:8080` или `proxy_pass http://172.17.0.1:8080` (IP хоста из Docker). + +#### 2.5. Перезагрузить nginx + +```bash +cd /root/MyCVWEBsite +docker compose -f docker-compose.prod.yml exec nginx nginx -t +docker compose -f docker-compose.prod.yml exec nginx nginx -s reload +``` + +### Проверка + +```bash +# Health check через HTTPS +curl -s https://vpn.fus1ond.ru/health +# Ответ: OK + +# Callback без заголовков — 401 +curl -s -o /dev/null -w "%{http_code}" -X POST https://vpn.fus1ond.ru/platega/callback +# Ответ: 401 +``` + +--- + +## 3. Настройки в ЛК Platega + +1. **Зайти в ЛК:** https://app.platega.io +2. **Создать мерчанта** (если ещё нет) +3. **Получить credentials:** + - `Merchant ID` → в `PLATEGA_MERCHANT_ID` + - `Secret Key` → в `PLATEGA_SECRET` +4. **Callback URL в ЛК оставить пустым** — URL передаётся при создании каждого платежа через параметр `callbackUrl` в API-запросе +5. **Проверить доступные методы оплаты:** + - СБП (method 2) — должен быть включён + - Карточный эквайринг (method 11) — должен быть включён + - Крипто (method 13) — опционально + +--- + +## 4. Порядок действий при первом деплое + +### Подготовка (ДО деплоя) + +```bash +# 1. Бэкап базы данных +cd /root/vpn_bot +cp data/bot.db data/bot.db.backup-$(date +%Y%m%d) + +# 2. Добавить DNS-запись vpn.fus1ond.ru → 5.53.125.146 +# (в панели управления DNS провайдера) + +# 2.1. Проверить DNS-пропагацию (certbot упадёт если DNS ещё не готов) +dig +short vpn.fus1ond.ru +# Ожидаемый ответ: 5.53.125.146 +# Если пусто — подождать (обычно 5-30 минут, иногда до 48 часов) + +# 3. Бэкап nginx-конфига +cp /root/MyCVWEBsite/nginx.prod.conf /root/MyCVWEBsite/nginx.prod.conf.backup + +# 4. Обновить .env файл vpn-bot (добавить PLATEGA_* переменные) +nano /root/vpn_bot/.env + +# 5. Обновить docker-compose.yml vpn-bot (добавить сеть и порт, см. раздел 2.2) +nano /root/vpn_bot/docker-compose.yml + +# 6. Получить SSL-сертификат для vpn.fus1ond.ru (см. раздел 2.3) +cd /root/MyCVWEBsite +docker compose -f docker-compose.prod.yml exec certbot certbot certonly --webroot \ + --webroot-path=/var/www/certbot -d vpn.fus1ond.ru --agree-tos --no-eff-email + +# 7. Добавить server block для vpn.fus1ond.ru в nginx (см. раздел 2.4) +nano /root/MyCVWEBsite/nginx.prod.conf + +# 8. Проверить и перезагрузить nginx +docker compose -f docker-compose.prod.yml exec nginx nginx -t +docker compose -f docker-compose.prod.yml exec nginx nginx -s reload +``` + +### Деплой + +```bash +# 8. Перезапустить vpn-bot (с новым кодом, сетью и портом) +cd /root/vpn_bot +docker compose down && docker compose up -d + +# 9. Проверить логи +docker compose logs -f vpn-bot +``` + +### Проверка (ПОСЛЕ деплоя) + +```bash +# 10. Проверить health endpoint (с хоста) +curl -s http://127.0.0.1:8080/health +# Ожидаемый ответ: OK + +# 11. Проверить через nginx (HTTPS) +curl -s https://vpn.fus1ond.ru/health +# Ожидаемый ответ: OK + +# 12. Проверить что callback endpoint доступен +curl -s -o /dev/null -w "%{http_code}" -X POST https://vpn.fus1ond.ru/platega/callback +# Ожидаемый ответ: 401 (нет заголовков — это правильно) + +# 13. Проверить логи на ошибки +docker compose logs vpn-bot | grep -i "callback\|platega" +# Ожидаем: "Callback server starting", "Platega client initialized" +``` + +--- + +## 5. Миграция существующих пользователей + +### Автоматическая миграция + +При первом запуске нового кода: +- Таблицы `payments` и `moderator_earnings` создаются автоматически +- Поля `subscription_price` и `moderator_id` добавляются в `users` (NULL для всех) +- Поле `subscription_price` добавляется в `invites` (NULL для существующих) + +### Ручная настройка (админ через бот) + +Существующие пользователи с `subscription_price = NULL`: +- Кнопка "Оплатить" **не показывается** (бот продолжает работать как раньше) +- Подписки работают по старой модели (модератор продлевает вручную) + +**Для перевода на новую модель:** +1. Админ заходит в бот → "Управление" → "Сменить тариф" → "Изменить цену" +2. Вводит telegram_id пользователя +3. Устанавливает цену подписки +4. После установки цены кнопка "Оплатить" появляется у пользователя + +**Важно:** переводить пользователей можно постепенно, в своём темпе. Старая модель продолжает работать параллельно. + +### Модераторы + +Модераторам при назначении выдаётся бессрочная подписка (как раньше). Новые инвайты модераторов уже будут с ценой. + +--- + +## 6. Smoke test — проверка что всё работает + +### Тест 1: Health check + +```bash +curl https://vpn.fus1ond.ru/health +# Ответ: OK +``` + +### Тест 2: Callback верификация + +```bash +# Без заголовков — должен быть 401 +curl -s -o /dev/null -w "%{http_code}" -X POST https://vpn.fus1ond.ru/platega/callback +# Ответ: 401 + +# С неверными заголовками — должен быть 401 +curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "X-MerchantId: wrong" \ + -H "X-Secret: wrong" \ + https://vpn.fus1ond.ru/platega/callback +# Ответ: 401 +``` + +### Тест 3: Бот работает + +1. Написать `/start` боту → должен показать меню +2. Нажать "Мой статус" → должен показать статус + +### Тест 4: Оплата (рекомендуется тестовый платёж) + +1. Создать тестовый инвайт (с ценой) через модератора +2. Активировать инвайт тестовым аккаунтом → триал 3 дня +3. Нажать "Оплатить подписку" → выбрать СБП → получить ссылку +4. Оплатить → подписка активирована на месяц + +### Тест 5: Scheduler + +```bash +# Проверить в логах что scheduler запустился +docker compose logs vpn-bot | grep -i scheduler +# Ожидаем: "Scheduler: running initial pass on startup" +``` + +--- + +## 7. Откат при проблемах + +### Быстрый откат (< 5 минут) + +```bash +# 1. Остановить бота +make down + +# 2. Восстановить бэкап БД (если миграция повредила данные) +cp data/bot.db.backup-YYYYMMDD data/bot.db + +# 3. Откатить код на предыдущую версию +git checkout main # или предыдущий тег/коммит + +# 4. Запустить старую версию +make up + +# 5. Проверить +make logs +``` + +### Частичный откат (отключить только оплату) + +Если бот работает, но оплата глючит: + +1. Включить **режим обслуживания** через админ-панель → кнопка "🔧 Режим обслуживания" +2. Это скрывает кнопку оплаты и останавливает кики +3. Всё остальное работает + +Альтернативно, удалить PLATEGA_* из .env и перезапустить: + +```bash +# Убрать PLATEGA_MERCHANT_ID и PLATEGA_SECRET из .env +make down && make up +``` + +Бот запустится без Platega-клиента и callback-сервера — как раньше. + +### Откат nginx + +```bash +# Удалить server block для vpn.fus1ond.ru из конфига +nano /root/MyCVWEBsite/nginx.prod.conf +cd /root/MyCVWEBsite +docker compose -f docker-compose.prod.yml exec nginx nginx -t +docker compose -f docker-compose.prod.yml exec nginx nginx -s reload +``` + +--- + +## 8. Важные заметки + +1. **БД не откатывается автоматически.** Новые таблицы и колонки останутся после отката кода — это безопасно, SQLite игнорирует неиспользуемые колонки +2. **PENDING платежи протухают сами** — Platega отменяет их через ~15 минут +3. **Callback может прийти после отката** — nginx вернёт 502 (бот не слушает порт), Platega сделает retry до 3 раз. Если платёж прошёл, но callback не дошёл — пользователь может нажать "Проверить оплату" после восстановления +4. **Логи** — все платёжные события логируются (callback received, confirmed, errors). Для диагностики: `docker compose logs vpn-bot | grep -i "callback\|payment\|platega"` +5. **Порт 8080** — должен быть открыт только для localhost (127.0.0.1). Внешний доступ только через nginx (HTTPS) diff --git a/docs/plans/2026-03-22-payment-implementation-plan.md b/docs/plans/2026-03-22-payment-implementation-plan.md new file mode 100644 index 0000000..9fb5962 --- /dev/null +++ b/docs/plans/2026-03-22-payment-implementation-plan.md @@ -0,0 +1,2084 @@ +# Реализация платёжной системы Platega — План реализации + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Интеграция платёжной системы Platega в Telegram-бота для автоматической оплаты VPN-подписок. + +**Architecture:** Бот получает встроенный HTTP-сервер для приёма callback от Platega (проксируется через nginx). Platega HTTP-клиент создаёт платежи и проверяет статусы. Scheduler переработан на event-driven модель (каждые 30 минут). Новые таблицы `payments` и `moderator_earnings` хранят финансовую историю. Поле `subscription_price` добавляется в `users` и `invites`. + +**Tech Stack:** Go 1.25, SQLite, telebot.v3, net/http (callback-сервер), Platega REST API + +**Связанные документы:** +- [Бизнес-план](./2026-03-21-payment-business-model-redesign.md) +- [UI пользователя](./2026-03-21-user-ui-redesign.md) +- [UI модератора](./2026-03-21-moderator-ui-redesign.md) +- [UI админа](./2026-03-21-admin-ui-redesign.md) +- [Platega API](../platega/README.md) + +--- + +## Этап 1: Миграция БД (новые таблицы и поля) + +**Цель:** Добавить таблицы `payments`, `moderator_earnings` и новые поля в существующие таблицы. После этого этапа бот работает как раньше — новые таблицы просто существуют. + +**Файлы:** +- Изменить: `internal/database/db.go` — добавить миграции +- Изменить: `internal/database/users.go` — добавить поля `subscription_price`, `moderator_id` в структуру `User` +- Изменить: `internal/database/invites.go` — добавить поле `subscription_price` в структуру `Invite` +- Создать: `internal/database/payments.go` — CRUD для таблицы `payments` +- Создать: `internal/database/earnings.go` — CRUD для таблицы `moderator_earnings` +- Создать: `internal/database/payments_test.go` +- Создать: `internal/database/earnings_test.go` + +### Шаг 1: Добавить миграции в `db.go` + +В массив `alterMigrations` в функции `migrate()` добавить: + +```go +// Миграция: цена подписки пользователя (руб/мес, NULL = не установлена) +`ALTER TABLE users ADD COLUMN subscription_price INTEGER`, +// Миграция: telegram_id модератора-куратора (NULL = админский или снят модератор) +`ALTER TABLE users ADD COLUMN moderator_id INTEGER`, +// Миграция: цена подписки при создании инвайта +`ALTER TABLE invites ADD COLUMN subscription_price INTEGER`, +``` + +В массив `migrations` (CREATE TABLE) добавить: + +```sql +CREATE TABLE IF NOT EXISTS payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_id INTEGER NOT NULL, + moderator_id INTEGER, + amount INTEGER NOT NULL, + payment_method TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + platega_transaction_id TEXT UNIQUE, + redirect_url TEXT, + expires_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + confirmed_at TIMESTAMP +) +``` + +```sql +CREATE TABLE IF NOT EXISTS moderator_earnings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + payment_id INTEGER NOT NULL REFERENCES payments(id), + moderator_id INTEGER NOT NULL, + gross_amount INTEGER NOT NULL, + platega_fee INTEGER NOT NULL, + withdrawal_fee INTEGER NOT NULL, + net_amount INTEGER NOT NULL, + share_percent INTEGER NOT NULL, + share_amount INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) +``` + +Индексы: + +```sql +CREATE INDEX IF NOT EXISTS idx_payments_telegram_id ON payments(telegram_id) +CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status) +CREATE INDEX IF NOT EXISTS idx_payments_platega_tx ON payments(platega_transaction_id) +CREATE INDEX IF NOT EXISTS idx_earnings_moderator ON moderator_earnings(moderator_id) +CREATE INDEX IF NOT EXISTS idx_earnings_payment ON moderator_earnings(payment_id) +``` + +### Шаг 2: Обновить структуру `User` в `db.go` + +```go +type User struct { + TelegramID int64 + Username string + FirstName string + RemnawaveUUID string + SubscriptionPrice *int // Цена подписки руб/мес (NULL = не установлена) + ModeratorID *int64 // Telegram ID куратора (NULL = админский/снят) + CreatedAt time.Time +} +``` + +### Шаг 3: Обновить все SELECT-запросы в `users.go` + +Все функции, читающие из `users`, должны добавить `subscription_price, moderator_id` в SELECT и Scan. + +Обновить: `GetUserByTelegramID`, `GetAllUsers`, `GetUserByRemnawaveUUID`. + +Обновить `CreateUser` — добавить параметры `subscriptionPrice *int, moderatorID *int64`: + +```go +func (db *DB) CreateUser(telegramID int64, username, firstName string, remnawaveUUID string, subscriptionPrice *int, moderatorID *int64) error { + _, err := db.conn.Exec( + `INSERT INTO users (telegram_id, username, first_name, remnawave_uuid, subscription_price, moderator_id) VALUES (?, ?, ?, ?, ?, ?)`, + telegramID, username, firstName, remnawaveUUID, subscriptionPrice, moderatorID, + ) + return err +} +``` + +Добавить `UpdateSubscriptionPrice`: + +```go +func (db *DB) UpdateSubscriptionPrice(telegramID int64, price int) error { + _, err := db.conn.Exec(`UPDATE users SET subscription_price = ? WHERE telegram_id = ?`, price, telegramID) + return err +} +``` + +### Шаг 4: Обновить структуру `Invite` в `db.go` + +```go +type Invite struct { + Code string + CreatedBy int64 + UsedBy *int64 + UsedAt *time.Time + ExpireDays *int + SubscriptionPrice *int // Цена подписки при создании инвайта + KickedAt *time.Time + CreatedAt time.Time +} +``` + +Добавить `CreateInviteWithPrice` (для модератора): + +```go +func (db *DB) CreateInviteWithPrice(createdBy int64, expireDays int, price int) (string, error) { + code := generateCode() + _, err := db.conn.Exec( + `INSERT INTO invites (code, created_by, expire_days, subscription_price) VALUES (?, ?, ?, ?)`, + code, createdBy, expireDays, price, + ) + return code, err +} +``` + +**Важно:** Существующий `CreateInvite` (админский, бессрочный) остаётся без изменений — он создаёт инвайт с `expire_days=NULL, subscription_price=NULL`. Админские инвайты не участвуют в платёжной модели. + +Обновить все SELECT-запросы для invites — добавить `subscription_price` в SELECT и Scan. + +### Шаг 5: Создать `internal/database/payments.go` + +```go +package database + +import ( + "database/sql" + "time" +) + +// Payment представляет запись платежа +type Payment struct { + ID int64 + TelegramID int64 + ModeratorID *int64 + Amount int + PaymentMethod string // "sbp", "card", "crypto" + Status string // "pending", "confirmed", "expired", "canceled", "chargebacked", "confirmed_not_activated" + PlategaTransactionID *string + RedirectURL *string + ExpiresAt *time.Time + CreatedAt time.Time + ConfirmedAt *time.Time +} + +// CreatePayment создаёт новый платёж +func (db *DB) CreatePayment(p *Payment) (int64, error) { + res, err := db.conn.Exec( + `INSERT INTO payments (telegram_id, moderator_id, amount, payment_method, status, platega_transaction_id, redirect_url, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + p.TelegramID, p.ModeratorID, p.Amount, p.PaymentMethod, p.Status, p.PlategaTransactionID, p.RedirectURL, p.ExpiresAt, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// GetPaymentByID возвращает платёж по ID +func (db *DB) GetPaymentByID(id int64) (*Payment, error) { + p := &Payment{} + err := db.conn.QueryRow( + `SELECT id, telegram_id, moderator_id, amount, payment_method, status, platega_transaction_id, redirect_url, expires_at, created_at, confirmed_at + FROM payments WHERE id = ?`, id, + ).Scan(&p.ID, &p.TelegramID, &p.ModeratorID, &p.Amount, &p.PaymentMethod, &p.Status, &p.PlategaTransactionID, &p.RedirectURL, &p.ExpiresAt, &p.CreatedAt, &p.ConfirmedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return p, err +} + +// GetPendingPayment возвращает активный PENDING платёж пользователя (не протухший) +func (db *DB) GetPendingPayment(telegramID int64) (*Payment, error) { + p := &Payment{} + err := db.conn.QueryRow( + `SELECT id, telegram_id, moderator_id, amount, payment_method, status, platega_transaction_id, redirect_url, expires_at, created_at, confirmed_at + FROM payments WHERE telegram_id = ? AND status = 'pending' AND (expires_at IS NULL OR expires_at > datetime('now')) + ORDER BY created_at DESC LIMIT 1`, telegramID, + ).Scan(&p.ID, &p.TelegramID, &p.ModeratorID, &p.Amount, &p.PaymentMethod, &p.Status, &p.PlategaTransactionID, &p.RedirectURL, &p.ExpiresAt, &p.CreatedAt, &p.ConfirmedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return p, err +} + +// GetPaymentByPlategaTxID возвращает платёж по ID транзакции Platega +func (db *DB) GetPaymentByPlategaTxID(txID string) (*Payment, error) { + p := &Payment{} + err := db.conn.QueryRow( + `SELECT id, telegram_id, moderator_id, amount, payment_method, status, platega_transaction_id, redirect_url, expires_at, created_at, confirmed_at + FROM payments WHERE platega_transaction_id = ?`, txID, + ).Scan(&p.ID, &p.TelegramID, &p.ModeratorID, &p.Amount, &p.PaymentMethod, &p.Status, &p.PlategaTransactionID, &p.RedirectURL, &p.ExpiresAt, &p.CreatedAt, &p.ConfirmedAt) + if err == sql.ErrNoRows { + return nil, nil + } + return p, err +} + +// UpdatePaymentStatus обновляет статус платежа +func (db *DB) UpdatePaymentStatus(id int64, status string) error { + _, err := db.conn.Exec(`UPDATE payments SET status = ? WHERE id = ?`, status, id) + return err +} + +// ConfirmPayment помечает платёж как confirmed с датой +func (db *DB) ConfirmPayment(id int64) error { + _, err := db.conn.Exec( + `UPDATE payments SET status = 'confirmed', confirmed_at = datetime('now') WHERE id = ?`, id, + ) + return err +} + +// ExpireOldPendingPayments помечает протухшие PENDING как expired +func (db *DB) ExpireOldPendingPayments() (int64, error) { + res, err := db.conn.Exec( + `UPDATE payments SET status = 'expired' WHERE status = 'pending' AND expires_at <= datetime('now')`, + ) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// GetConfirmedNotActivated возвращает платежи со статусом confirmed_not_activated +func (db *DB) GetConfirmedNotActivated() ([]Payment, error) { + rows, err := db.conn.Query( + `SELECT id, telegram_id, moderator_id, amount, payment_method, status, platega_transaction_id, redirect_url, expires_at, created_at, confirmed_at + FROM payments WHERE status = 'confirmed_not_activated'`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var payments []Payment + for rows.Next() { + var p Payment + if err := rows.Scan(&p.ID, &p.TelegramID, &p.ModeratorID, &p.Amount, &p.PaymentMethod, &p.Status, &p.PlategaTransactionID, &p.RedirectURL, &p.ExpiresAt, &p.CreatedAt, &p.ConfirmedAt); err != nil { + return nil, err + } + payments = append(payments, p) + } + return payments, rows.Err() +} + +// HasConfirmedPayment проверяет, была ли у пользователя хотя бы одна подтверждённая оплата +func (db *DB) HasConfirmedPayment(telegramID int64) (bool, error) { + var exists bool + err := db.conn.QueryRow( + `SELECT EXISTS(SELECT 1 FROM payments WHERE telegram_id = ? AND status = 'confirmed')`, telegramID, + ).Scan(&exists) + return exists, err +} + +// CountConfirmedPaymentsByMonth считает платежи за месяц (для статистики) +func (db *DB) CountConfirmedPaymentsByMonth(year int, month int) (int, error) { + var count int + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + err := db.conn.QueryRow( + `SELECT COUNT(*) FROM payments WHERE status = 'confirmed' AND confirmed_at >= ? AND confirmed_at < ?`, + start, end, + ).Scan(&count) + return count, err +} + +// SumConfirmedPaymentsByMonth возвращает сумму платежей за месяц +func (db *DB) SumConfirmedPaymentsByMonth(year int, month int) (int, error) { + var sum int + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + err := db.conn.QueryRow( + `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE status = 'confirmed' AND confirmed_at >= ? AND confirmed_at < ?`, + start, end, + ).Scan(&sum) + return sum, err +} + +// CountTrialsByMonth считает триалы (активации инвайтов) за месяц +func (db *DB) CountTrialsByMonth(year int, month int) (int, error) { + var count int + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + err := db.conn.QueryRow( + `SELECT COUNT(*) FROM invites WHERE used_at >= ? AND used_at < ? AND expire_days IS NOT NULL`, + start, end, + ).Scan(&count) + return count, err +} + +// CountFirstPaymentsByMonth считает первые оплаты (конверсия триал→оплата) за месяц +func (db *DB) CountFirstPaymentsByMonth(year int, month int) (int, error) { + var count int + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + // Считаем пользователей, у которых первый confirmed платёж попал в этот месяц + err := db.conn.QueryRow( + `SELECT COUNT(*) FROM ( + SELECT telegram_id, MIN(confirmed_at) as first_payment + FROM payments WHERE status = 'confirmed' + GROUP BY telegram_id + HAVING first_payment >= ? AND first_payment < ? + )`, start, end, + ).Scan(&count) + return count, err +} + +// CountPayingSubscribersByModerator считает активных платящих подписчиков модератора +// Считаются только пользователи, которые: 1) существуют в БД (не удалены), 2) имеют confirmed платёж, +// 3) имеют последний платёж не старше 60 дней (активный платящий клиент) +func (db *DB) CountPayingSubscribersByModerator(moderatorID int64) (int, error) { + var count int + err := db.conn.QueryRow( + `SELECT COUNT(DISTINCT u.telegram_id) FROM users u + JOIN payments p ON p.telegram_id = u.telegram_id + WHERE u.moderator_id = ? AND p.status = 'confirmed' + AND p.confirmed_at >= datetime('now', '-60 days')`, + moderatorID, + ).Scan(&count) + return count, err +} +``` + +### Шаг 6: Создать `internal/database/earnings.go` + +```go +package database + +import ( + "database/sql" + "time" +) + +// ModeratorEarning представляет запись начисления модератору +type ModeratorEarning struct { + ID int64 + PaymentID int64 + ModeratorID int64 + GrossAmount int // Сумма платежа + PlategaFee int // Комиссия Platega + WithdrawalFee int // Комиссия вывода (2%) + NetAmount int // Чистый доход после всех комиссий + SharePercent int // Процент доли модератора + ShareAmount int // Сумма доли модератора + CreatedAt time.Time +} + +// CreateEarning создаёт запись начисления модератору +func (db *DB) CreateEarning(e *ModeratorEarning) (int64, error) { + res, err := db.conn.Exec( + `INSERT INTO moderator_earnings (payment_id, moderator_id, gross_amount, platega_fee, withdrawal_fee, net_amount, share_percent, share_amount) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + e.PaymentID, e.ModeratorID, e.GrossAmount, e.PlategaFee, e.WithdrawalFee, e.NetAmount, e.SharePercent, e.ShareAmount, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// GetModeratorEarningsByMonth возвращает агрегированные данные за месяц +type MonthlyEarnings struct { + TotalPayments int // Количество платежей + GrossAmount int // Сумма платежей + TotalPlategaFee int // Суммарная комиссия Platega + TotalWithdrawal int // Суммарная комиссия вывода + TotalNetAmount int // Суммарный чистый доход + TotalShareAmount int // Суммарная доля модератора + SharePercent int // Последний актуальный процент +} + +func (db *DB) GetModeratorEarningsByMonth(moderatorID int64, year int, month int) (*MonthlyEarnings, error) { + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + + me := &MonthlyEarnings{} + err := db.conn.QueryRow( + `SELECT COUNT(*), COALESCE(SUM(gross_amount), 0), COALESCE(SUM(platega_fee), 0), + COALESCE(SUM(withdrawal_fee), 0), COALESCE(SUM(net_amount), 0), COALESCE(SUM(share_amount), 0) + FROM moderator_earnings WHERE moderator_id = ? AND created_at >= ? AND created_at < ?`, + moderatorID, start, end, + ).Scan(&me.TotalPayments, &me.GrossAmount, &me.TotalPlategaFee, &me.TotalWithdrawal, &me.TotalNetAmount, &me.TotalShareAmount) + if err != nil { + return nil, err + } + + // Получаем актуальный процент (из последнего начисления) + var pct sql.NullInt64 + db.conn.QueryRow( + `SELECT share_percent FROM moderator_earnings WHERE moderator_id = ? ORDER BY created_at DESC LIMIT 1`, + moderatorID, + ).Scan(&pct) + if pct.Valid { + me.SharePercent = int(pct.Int64) + } + + return me, nil +} + +// GetModeratorTotalEarnings возвращает суммарную долю модератора за всё время +func (db *DB) GetModeratorTotalEarnings(moderatorID int64) (int, error) { + var sum sql.NullInt64 + err := db.conn.QueryRow( + `SELECT COALESCE(SUM(share_amount), 0) FROM moderator_earnings WHERE moderator_id = ?`, + moderatorID, + ).Scan(&sum) + if sum.Valid { + return int(sum.Int64), err + } + return 0, err +} + +// GetAllEarningsByMonth возвращает общую статистику за месяц (для админа) +func (db *DB) GetAllEarningsByMonth(year int, month int) (*MonthlyEarnings, error) { + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + + me := &MonthlyEarnings{} + err := db.conn.QueryRow( + `SELECT COUNT(*), COALESCE(SUM(gross_amount), 0), COALESCE(SUM(platega_fee), 0), + COALESCE(SUM(withdrawal_fee), 0), COALESCE(SUM(net_amount), 0), COALESCE(SUM(share_amount), 0) + FROM moderator_earnings WHERE created_at >= ? AND created_at < ?`, + start, end, + ).Scan(&me.TotalPayments, &me.GrossAmount, &me.TotalPlategaFee, &me.TotalWithdrawal, &me.TotalNetAmount, &me.TotalShareAmount) + return me, err +} +``` + +### Шаг 7: Написать тесты + +Файл `internal/database/payments_test.go` — тесты на CRUD payments: +- `TestCreatePayment` — создание и получение по ID +- `TestGetPendingPayment` — поиск активного PENDING платежа +- `TestGetPaymentByPlategaTxID` — поиск по transaction_id +- `TestConfirmPayment` — подтверждение и проверка confirmed_at +- `TestExpireOldPendingPayments` — протухание старых платежей +- `TestHasConfirmedPayment` — проверка наличия оплаты + +Файл `internal/database/earnings_test.go` — тесты на earnings: +- `TestCreateEarning` — создание записи +- `TestGetModeratorEarningsByMonth` — агрегация за месяц +- `TestGetModeratorTotalEarnings` — суммарная доля за всё время + +### Шаг 8: Запустить тесты и коммит + +```bash +make tests +make fmt +``` + +**Критерии приёмки этапа 1:** +- Бот запускается без ошибок (таблицы создаются при старте) +- Новые поля `subscription_price` и `moderator_id` в `users` доступны (NULL для существующих) +- Таблицы `payments` и `moderator_earnings` созданы +- Все тесты проходят +- Существующая функциональность не сломана + +--- + +## Этап 2: Platega HTTP-клиент + +**Цель:** Создать HTTP-клиент для работы с Platega API — создание платежей, проверка статуса. После этого этапа клиент готов к использованию, но нигде не вызывается. + +**Файлы:** +- Создать: `internal/platega/client.go` +- Создать: `internal/platega/client_test.go` + +### Шаг 1: Добавить конфигурацию Platega в `config.go` + +В структуру `Config` добавить: + +```go +// Platega +PlategaMerchantID string +PlategaSecret string +PlategaCallbackURL string // Полный URL для callback (https://domain.com/platega/callback) +MinSubscriptionPrice int // Минимальная цена подписки (руб), по умолчанию 400 +TrialTrafficLimitGB int // Лимит трафика триала (ГБ), по умолчанию 1 +PlategaFeeSBP int // Комиссия Platega СБП (%), по умолчанию 11 +PlategaFeeCard int // Комиссия Platega карты (%), по умолчанию 12 +PlategaFeeCrypto int // Комиссия Platega крипта (%), по умолчанию 5 +PlategaFeeWithdrawal int // Комиссия вывода (%), по умолчанию 2 +``` + +В функции `Load()`: + +```go +cfg.PlategaMerchantID = os.Getenv("PLATEGA_MERCHANT_ID") +cfg.PlategaSecret = os.Getenv("PLATEGA_SECRET") +cfg.PlategaCallbackURL = os.Getenv("PLATEGA_CALLBACK_URL") +cfg.MinSubscriptionPrice = getEnvOrDefaultInt("MIN_SUBSCRIPTION_PRICE", 400) +cfg.TrialTrafficLimitGB = getEnvOrDefaultInt("TRIAL_TRAFFIC_LIMIT_GB", 1) +cfg.PlategaFeeSBP = getEnvOrDefaultInt("PLATEGA_FEE_SBP", 11) +cfg.PlategaFeeCard = getEnvOrDefaultInt("PLATEGA_FEE_CARD", 12) +cfg.PlategaFeeCrypto = getEnvOrDefaultInt("PLATEGA_FEE_CRYPTO", 5) +cfg.PlategaFeeWithdrawal = getEnvOrDefaultInt("PLATEGA_FEE_WITHDRAWAL", 2) +``` + +Добавить хелпер: + +```go +func getEnvOrDefaultInt(key string, defaultValue int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return defaultValue +} +``` + +**Важно:** Platega-переменные НЕ обязательные — если не заданы, платёжный функционал просто отключён (как render-сервис). + +### Шаг 2: Создать `internal/platega/client.go` + +```go +package platega + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const baseURL = "https://app.platega.io" + +// Способы оплаты (Platega paymentMethod int) +const ( + PaymentMethodSBP = 2 + PaymentMethodCard = 11 + PaymentMethodCrypto = 13 +) + +// Статусы платежа +const ( + StatusPending = "PENDING" + StatusConfirmed = "CONFIRMED" + StatusCanceled = "CANCELED" + StatusChargebacked = "CHARGEBACKED" +) + +// Client — HTTP-клиент Platega API +type Client struct { + merchantID string + secret string + http *http.Client +} + +// NewClient создаёт клиент Platega +func NewClient(merchantID, secret string) *Client { + return &Client{ + merchantID: merchantID, + secret: secret, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// MerchantID возвращает merchant_id (для верификации callback) +func (c *Client) MerchantID() string { + return c.merchantID +} + +// Secret возвращает secret (для верификации callback) +func (c *Client) Secret() string { + return c.secret +} + +// CreateTransactionRequest — запрос на создание платежа +type CreateTransactionRequest struct { + PaymentMethod int `json:"paymentMethod"` + Amount int `json:"amount"` // В рублях (целое число) + Currency string `json:"currency"` // "RUB" + Description string `json:"description"` + ReturnURL string `json:"return"` // URL возврата после оплаты (бот Telegram) + FailedURL string `json:"failedUrl"` // URL при ошибке + CallbackURL string `json:"callbackUrl"` // URL для callback + Payload string `json:"payload"` // Произвольные данные (telegram_id) +} + +// CreateTransactionResponse — ответ на создание платежа +type CreateTransactionResponse struct { + TransactionID string `json:"transactionId"` + Redirect string `json:"redirect"` // Ссылка для перенаправления пользователя + Status string `json:"status"` + ExpiresIn int `json:"expiresIn"` // Время жизни в секундах +} + +// TransactionStatus — полный статус транзакции +type TransactionStatus struct { + ID string `json:"id"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` + PaymentMethod int `json:"paymentMethod"` + Payload string `json:"payload"` +} + +// CallbackPayload — тело callback-запроса от Platega. +// Единственное определение — используется и в platega-клиенте, и в callback-сервере (импортируется оттуда). +type CallbackPayload struct { + ID string `json:"id"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` + PaymentMethod int `json:"paymentMethod"` + Payload string `json:"payload"` +} + +// CreatePayment создаёт платёж в Platega +func (c *Client) CreatePayment(req CreateTransactionRequest) (*CreateTransactionResponse, error) { + // Формируем тело запроса согласно API + body := map[string]interface{}{ + "paymentMethod": req.PaymentMethod, + "paymentDetails": map[string]interface{}{ + "amount": req.Amount, + "currency": req.Currency, + }, + "description": req.Description, + "return": req.ReturnURL, + "failedUrl": req.FailedURL, + "callbackUrl": req.CallbackURL, + "payload": req.Payload, + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + httpReq, err := http.NewRequest("POST", baseURL+"/transaction/process", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + c.setHeaders(httpReq) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("platega API error %d: %s", resp.StatusCode, string(respBody)) + } + + var result CreateTransactionResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + + return &result, nil +} + +// GetTransactionStatus проверяет статус транзакции +func (c *Client) GetTransactionStatus(transactionID string) (*TransactionStatus, error) { + httpReq, err := http.NewRequest("GET", baseURL+"/transaction/"+transactionID, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + c.setHeaders(httpReq) + + resp, err := c.http.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("platega API error %d: %s", resp.StatusCode, string(respBody)) + } + + var result TransactionStatus + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + + return &result, nil +} + +// setHeaders устанавливает авторизационные заголовки +func (c *Client) setHeaders(req *http.Request) { + req.Header.Set("X-MerchantId", c.merchantID) + req.Header.Set("X-Secret", c.secret) +} + +// PaymentMethodName возвращает человекочитаемое название способа оплаты +func PaymentMethodName(method int) string { + switch method { + case PaymentMethodSBP: + return "СБП" + case PaymentMethodCard: + return "Карта" + case PaymentMethodCrypto: + return "Крипта" + default: + return "Неизвестно" + } +} + +// PaymentMethodString возвращает строковый идентификатор для БД +func PaymentMethodString(method int) string { + switch method { + case PaymentMethodSBP: + return "sbp" + case PaymentMethodCard: + return "card" + case PaymentMethodCrypto: + return "crypto" + default: + return "unknown" + } +} + +// PaymentMethodFromString возвращает int из строкового идентификатора +func PaymentMethodFromString(s string) int { + switch s { + case "sbp": + return PaymentMethodSBP + case "card": + return PaymentMethodCard + case "crypto": + return PaymentMethodCrypto + default: + return 0 + } +} +``` + +### Шаг 3: Написать тесты + +Файл `internal/platega/client_test.go`: +- `TestPaymentMethodConversion` — конвертация method int ↔ string +- `TestSetHeaders` — проверка установки заголовков +- Интеграционные тесты с httptest.Server (мок Platega API) для `CreatePayment` и `GetTransactionStatus` + +### Шаг 4: Запустить тесты и коммит + +```bash +make tests +make fmt +``` + +**Критерии приёмки этапа 2:** +- Platega-клиент компилируется и тесты проходят +- Конфигурация расширена новыми переменными (все опциональные) +- Бот запускается без PLATEGA_* переменных (клиент не создаётся) + +--- + +## Этап 3: Callback HTTP-сервер + верификация + +**Цель:** Добавить встроенный HTTP-сервер в процесс бота для приёма callback от Platega. После этого этапа сервер стартует, принимает запросы, верифицирует заголовки, но ещё не обрабатывает платежи (только логирует). + +**Файлы:** +- Создать: `internal/callback/server.go` +- Создать: `internal/callback/server_test.go` +- Изменить: `cmd/bot/main.go` — запуск callback-сервера +- Изменить: `internal/config/config.go` — порт callback-сервера + +### Шаг 1: Добавить конфигурацию порта + +В `Config` добавить: + +```go +CallbackPort int // Порт для callback-сервера (по умолчанию 8080) +``` + +В `Load()`: + +```go +cfg.CallbackPort = getEnvOrDefaultInt("CALLBACK_PORT", 8080) +``` + +### Шаг 2: Создать `internal/callback/server.go` + +```go +package callback + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "github.com/fus1ond/vpn_bot/internal/platega" +) + +// PaymentHandler — интерфейс обработки подтверждённых платежей. +// Использует platega.CallbackPayload (единственное определение, без дублирования). +type PaymentHandler interface { + HandlePaymentCallback(payload platega.CallbackPayload) error +} + +// Server — HTTP-сервер для приёма callback от Platega +type Server struct { + merchantID string + secret string + handler PaymentHandler + httpServer *http.Server +} + +// NewServer создаёт callback-сервер +func NewServer(port int, merchantID, secret string, handler PaymentHandler) *Server { + s := &Server{ + merchantID: merchantID, + secret: secret, + handler: handler, + } + + mux := http.NewServeMux() + mux.HandleFunc("/platega/callback", s.handleCallback) + mux.HandleFunc("/health", s.handleHealth) + + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + return s +} + +// Start запускает сервер (блокирующий вызов) +func (s *Server) Start() error { + slog.Info("Callback server starting", "addr", s.httpServer.Addr) + return s.httpServer.ListenAndServe() +} + +// Shutdown останавливает сервер +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpServer.Shutdown(ctx) +} + +// handleCallback обрабатывает callback от Platega +func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Верификация заголовков + merchantID := r.Header.Get("X-MerchantId") + secret := r.Header.Get("X-Secret") + + if merchantID != s.merchantID || secret != s.secret { + slog.Warn("Callback rejected: invalid credentials", + "merchant_id", merchantID, + "remote_addr", r.RemoteAddr, + ) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Чтение и парсинг тела + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB лимит + if err != nil { + slog.Error("Callback: failed to read body", "error", err) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + var payload platega.CallbackPayload + if err := json.Unmarshal(body, &payload); err != nil { + slog.Error("Callback: failed to parse JSON", "error", err, "body", string(body)) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + slog.Info("Callback received", + "transaction_id", payload.ID, + "status", payload.Status, + "amount", payload.Amount, + "payload", payload.Payload, + ) + + // Обработка через handler + if err := s.handler.HandlePaymentCallback(payload); err != nil { + slog.Error("Callback: handler error", + "error", err, + "transaction_id", payload.ID, + ) + // Возвращаем 500, чтобы Platega сделала retry + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +// handleHealth — эндпоинт для проверки работоспособности +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} +``` + +### Шаг 3: Запуск сервера в `cmd/bot/main.go` + +После создания бота, перед `telegramBot.Run()`: + +```go +// Запуск callback-сервера (если Platega настроена) +if cfg.PlategaMerchantID != "" && cfg.PlategaSecret != "" { + callbackHandler := telegramBot.PaymentCallbackHandler() + callbackServer := callback.NewServer(cfg.CallbackPort, cfg.PlategaMerchantID, cfg.PlategaSecret, callbackHandler) + + go func() { + if err := callbackServer.Start(); err != nil && err != http.ErrServerClosed { + slog.Error("Callback server error", "error", err) + } + }() + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + callbackServer.Shutdown(shutdownCtx) + }() + + slog.Info("Platega callback server started", "port", cfg.CallbackPort) +} +``` + +### Шаг 4: Добавить порт в docker-compose.yml + +```yaml +vpn-bot: + ports: + - "127.0.0.1:8080:8080" +``` + +### Шаг 5: Написать тесты + +Файл `internal/callback/server_test.go`: +- `TestCallbackVerification` — отклонение запросов с неверными заголовками (401) +- `TestCallbackValidRequest` — приём запроса с корректными заголовками (200) +- `TestCallbackHealth` — проверка /health (200) +- `TestCallbackInvalidJSON` — некорректный JSON (400) + +### Шаг 6: Запустить тесты и коммит + +```bash +make tests +make fmt +``` + +**Критерии приёмки этапа 3:** +- Callback-сервер стартует на порту 8080 при наличии PLATEGA_* переменных +- `/health` возвращает 200 +- `/platega/callback` отклоняет запросы без корректных X-MerchantId/X-Secret +- Корректные callback-запросы логируются +- Без PLATEGA_* переменных бот работает как раньше + +--- + +## Этап 4: Платёжный флоу (создание платежа, callback, активация подписки) + +**Цель:** Реализовать полный цикл оплаты: создание платежа → пользователь платит → callback → продление подписки. Это ядро платёжной логики. + +**Файлы:** +- Создать: `internal/bot/payment.go` — бизнес-логика платежей +- Создать: `internal/bot/payment_test.go` +- Изменить: `internal/bot/handlers.go` — добавить поля Platega-клиента и метод `PaymentCallbackHandler()` +- Изменить: `internal/remnawave/client.go` — добавить `EnableUser` и обновить `CreateUser` для лимита трафика + +### Шаг 1: Обновить `remnawave/client.go` + +Добавить параметр `trafficLimitBytes` в `CreateUser`: + +```go +func (c *Client) CreateUser(telegramID int64, username string, expireAt time.Time, trafficLimitBytes int64) (*User, error) +``` + +Добавить метод `EnableUser` (реактивация после grace period): + +```go +func (c *Client) EnableUser(uuid string, newExpireAt time.Time) error { + return c.UpdateUser(uuid, UpdateUserRequest{ + Status: strPtr(StatusActive), + ExpireAt: &newExpireAt, + TrafficLimitBytes: int64Ptr(0), // Безлимит после оплаты + }) +} +``` + +Добавить `DisableUser` (деактивация при начале grace period): + +```go +func (c *Client) DisableUser(uuid string) error { + return c.UpdateUser(uuid, UpdateUserRequest{ + Status: strPtr(StatusDisabled), + }) +} +``` + +### Шаг 2: Создать `internal/bot/payment.go` + +```go +package bot + +import ( + "fmt" + "log/slog" + "strconv" + "sync" + "time" + + "github.com/fus1ond/vpn_bot/internal/callback" + "github.com/fus1ond/vpn_bot/internal/database" + "github.com/fus1ond/vpn_bot/internal/platega" +) + +// paymentMu — мьютексы по telegram_id для защиты от race condition при обработке callback. +// TODO: sync.Map не чистится — за годы работы накопятся тысячи мьютексов. +// Не критично (мьютекс маленький), но при необходимости можно добавить периодическую чистку. +var paymentMu sync.Map // map[int64]*sync.Mutex + +func getPaymentMutex(telegramID int64) *sync.Mutex { + mu, _ := paymentMu.LoadOrStore(telegramID, &sync.Mutex{}) + return mu.(*sync.Mutex) +} + +// paymentCallbackHandler реализует callback.PaymentHandler +type paymentCallbackHandler struct { + bot *Bot +} + +// PaymentCallbackHandler возвращает обработчик callback от Platega +func (b *Bot) PaymentCallbackHandler() callback.PaymentHandler { + return &paymentCallbackHandler{bot: b} +} + +// HandlePaymentCallback обрабатывает callback от Platega +func (h *paymentCallbackHandler) HandlePaymentCallback(payload callback.CallbackPayload) error { + // Находим платёж по platega_transaction_id + payment, err := h.bot.db.GetPaymentByPlategaTxID(payload.ID) + if err != nil { + return fmt.Errorf("get payment by tx: %w", err) + } + if payment == nil { + slog.Warn("Callback for unknown transaction", "transaction_id", payload.ID) + return nil // Не возвращаем ошибку, чтобы Platega не retry-ила + } + + // Блокируем обработку по telegram_id + mu := getPaymentMutex(payment.TelegramID) + mu.Lock() + defer mu.Unlock() + + switch payload.Status { + case platega.StatusConfirmed: + return h.handleConfirmed(payment) + case platega.StatusCanceled: + return h.handleCanceled(payment) + case platega.StatusChargebacked: + return h.handleChargeback(payment) + default: + slog.Warn("Callback with unexpected status", "status", payload.Status, "transaction_id", payload.ID) + return nil + } +} + +// handleConfirmed обрабатывает успешный платёж +func (h *paymentCallbackHandler) handleConfirmed(payment *database.Payment) error { + // Идемпотентность: если платёж уже confirmed — пропускаем + if payment.Status == "confirmed" { + slog.Info("Payment already confirmed, skipping", "payment_id", payment.ID) + return nil + } + + // Подтверждаем платёж в БД + if err := h.bot.db.ConfirmPayment(payment.ID); err != nil { + return fmt.Errorf("confirm payment: %w", err) + } + + // Активируем подписку в Remnawave с retry и backoff (3 попытки: 30с, 1м, 5м) + retryDelays := []time.Duration{30 * time.Second, 1 * time.Minute, 5 * time.Minute} + var activateErr error + for attempt, delay := range retryDelays { + activateErr = h.activateSubscription(payment) + if activateErr == nil { + break + } + slog.Warn("Failed to activate subscription, retrying", + "error", activateErr, "payment_id", payment.ID, + "attempt", attempt+1, "next_retry_in", delay) + time.Sleep(delay) + } + + if activateErr != nil { + // Все попытки провалились — помечаем для retry через scheduler + slog.Error("All retry attempts failed, marking for scheduler retry", + "error", activateErr, "payment_id", payment.ID) + h.bot.db.UpdatePaymentStatus(payment.ID, "confirmed_not_activated") + + // Уведомляем админа + h.bot.sendAdminAlert(fmt.Sprintf( + "⚠️ Платёж #%d подтверждён, но не удалось активировать подписку для %d после 3 попыток. Требуется ручная проверка.", + payment.ID, payment.TelegramID, + )) + return nil // Не возвращаем ошибку — платёж уже сохранён + } + + // Создаём запись в moderator_earnings (если есть модератор) + h.createEarningRecord(payment) + + // Уведомляем пользователя + user, _ := h.bot.db.GetUserByTelegramID(payment.TelegramID) + remUser, _ := h.bot.remnawave.GetUserByTelegramID(payment.TelegramID) + + var msg string + if remUser != nil { + expireDate := remUser.ExpireAt.Format("02.01.2006") + msg = fmt.Sprintf("✅ Оплата прошла! Ваша подписка активна до %s.\n\nЛимит трафика снят — пользуйтесь без ограничений.\n\nБлиже к концу подписки мы напомним о продлении.", expireDate) + } else { + msg = "✅ Оплата прошла! Подписка активирована." + } + + _ = h.bot.sendSchedulerMessage(payment.TelegramID, msg) + + // Очищаем уведомления (пользователь мог быть в grace period) + h.bot.db.ClearNotifications(payment.TelegramID) + + _ = user // подавление unused warning + + return nil +} + +// activateSubscription продлевает подписку в Remnawave +func (h *paymentCallbackHandler) activateSubscription(payment *database.Payment) error { + user, err := h.bot.db.GetUserByTelegramID(payment.TelegramID) + if err != nil || user == nil { + return fmt.Errorf("user not found: telegram_id=%d", payment.TelegramID) + } + + remUser, err := h.bot.remnawave.GetUser(user.RemnawaveUUID) + if err != nil { + return fmt.Errorf("get remnawave user: %w", err) + } + + now := time.Now().UTC() + var newExpireAt time.Time + + // Если подписка ещё активна (досрочное продление) — плюсуем к текущему expireAt + if remUser.ExpireAt.After(now) && remUser.Status == "ACTIVE" { + newExpireAt = remUser.ExpireAt.AddDate(0, 1, 0) + } else { + // Триал, grace period или истёк — считаем от момента оплаты + newExpireAt = now.AddDate(0, 1, 0) + } + + // Реактивируем пользователя: ставит Status=ACTIVE, ExpireAt=newExpireAt, TrafficLimitBytes=0. + // Работает одинаково для всех случаев: первая оплата из триала (снимает лимит трафика), + // досрочное продление (просто продлевает), восстановление после grace period (реактивирует). + return h.bot.remnawave.EnableUser(user.RemnawaveUUID, newExpireAt) +} + +// createEarningRecord создаёт запись начисления модератору +func (h *paymentCallbackHandler) createEarningRecord(payment *database.Payment) { + if payment.ModeratorID == nil { + return // Админский пользователь — без начислений + } + + moderatorID := *payment.ModeratorID + + // Проверяем, что модератор ещё активен + if !h.bot.isModerator(moderatorID) { + return + } + + // Считаем количество платящих клиентов для определения доли + payingCount, err := h.bot.db.CountPayingSubscribersByModerator(moderatorID) + if err != nil { + slog.Error("Failed to count paying subscribers", "error", err, "moderator_id", moderatorID) + return + } + + sharePercent := calculateSharePercent(payingCount) + + // Определяем комиссию Platega по методу оплаты + feePercent := h.bot.getPlategaFeePercent(payment.PaymentMethod) + withdrawalPercent := h.bot.config.PlategaFeeWithdrawal + + grossAmount := payment.Amount + plategaFee := grossAmount * feePercent / 100 + afterPlatega := grossAmount - plategaFee + withdrawalFee := afterPlatega * withdrawalPercent / 100 + netAmount := afterPlatega - withdrawalFee + shareAmount := netAmount * sharePercent / 100 + + earning := &database.ModeratorEarning{ + PaymentID: payment.ID, + ModeratorID: moderatorID, + GrossAmount: grossAmount, + PlategaFee: plategaFee, + WithdrawalFee: withdrawalFee, + NetAmount: netAmount, + SharePercent: sharePercent, + ShareAmount: shareAmount, + } + + if _, err := h.bot.db.CreateEarning(earning); err != nil { + slog.Error("Failed to create earning record", "error", err, "payment_id", payment.ID) + } +} + +// calculateSharePercent определяет долю модератора по количеству платящих клиентов +func calculateSharePercent(payingCount int) int { + switch { + case payingCount >= 25: + return 25 + case payingCount >= 15: + return 20 + default: + return 15 + } +} + +// getPlategaFeePercent возвращает процент комиссии Platega для метода оплаты +func (b *Bot) getPlategaFeePercent(paymentMethod string) int { + switch paymentMethod { + case "sbp": + return b.config.PlategaFeeSBP + case "card": + return b.config.PlategaFeeCard + case "crypto": + return b.config.PlategaFeeCrypto + default: + return b.config.PlategaFeeSBP // Fallback + } +} + +// handleCanceled обрабатывает отменённый платёж +func (h *paymentCallbackHandler) handleCanceled(payment *database.Payment) error { + if payment.Status != "pending" { + return nil + } + if err := h.bot.db.UpdatePaymentStatus(payment.ID, "canceled"); err != nil { + return fmt.Errorf("update status to canceled: %w", err) + } + _ = h.bot.sendSchedulerMessage(payment.TelegramID, "❌ Платёж отменён. Вы можете попробовать снова.") + return nil +} + +// handleChargeback обрабатывает chargeback +func (h *paymentCallbackHandler) handleChargeback(payment *database.Payment) error { + if err := h.bot.db.UpdatePaymentStatus(payment.ID, "chargebacked"); err != nil { + return fmt.Errorf("update status to chargebacked: %w", err) + } + + // Деактивируем пользователя + user, err := h.bot.db.GetUserByTelegramID(payment.TelegramID) + if err == nil && user != nil { + _ = h.bot.remnawave.DisableUser(user.RemnawaveUUID) + } + + // Уведомляем админа + h.bot.sendAdminAlert(fmt.Sprintf( + "⚠️ Chargeback от %d, сумма: %d руб. Пользователь деактивирован.", + payment.TelegramID, payment.Amount, + )) + + return nil +} + +// sendAdminAlert отправляет сообщение админу +func (b *Bot) sendAdminAlert(msg string) { + _ = b.sendSchedulerMessage(b.config.AdminID, msg) +} + +// createPaymentForUser создаёт платёж для пользователя +func (b *Bot) createPaymentForUser(telegramID int64, paymentMethodInt int) (*database.Payment, string, error) { + user, err := b.db.GetUserByTelegramID(telegramID) + if err != nil || user == nil { + return nil, "", fmt.Errorf("user not found") + } + + if user.SubscriptionPrice == nil { + return nil, "", fmt.Errorf("subscription price not set") + } + + // Проверка лимита 90 дней: нельзя оплатить, если до конца подписки >= 90 дней + remUser, err := b.remnawave.GetUserByTelegramID(telegramID) + if err == nil && remUser != nil && remUser.Status == "ACTIVE" && remUser.ExpireAt.Year() < 2099 { + daysLeft := int(time.Until(remUser.ExpireAt).Hours() / 24) + if daysLeft >= 90 { + return nil, "", fmt.Errorf("subscription_too_far: %d days left", daysLeft) + } + } + + paymentMethodStr := platega.PaymentMethodString(paymentMethodInt) + + // Проверяем наличие активного PENDING платежа + pending, err := b.db.GetPendingPayment(telegramID) + if err != nil { + return nil, "", fmt.Errorf("check pending: %w", err) + } + + if pending != nil { + if pending.PaymentMethod == paymentMethodStr { + // Тот же способ — возвращаем ту же ссылку + url := "" + if pending.RedirectURL != nil { + url = *pending.RedirectURL + } + return pending, url, nil + } + // Другой способ — помечаем старый как expired + b.db.UpdatePaymentStatus(pending.ID, "expired") + } + + // Создаём платёж в Platega + callbackURL := b.config.PlategaCallbackURL + telegramIDStr := strconv.FormatInt(telegramID, 10) + + resp, err := b.platega.CreatePayment(platega.CreateTransactionRequest{ + PaymentMethod: paymentMethodInt, + Amount: *user.SubscriptionPrice, + Currency: "RUB", + Description: "VPN подписка на 1 месяц", + ReturnURL: fmt.Sprintf("https://t.me/%s", b.bot.Me.Username), + FailedURL: fmt.Sprintf("https://t.me/%s", b.bot.Me.Username), + CallbackURL: callbackURL, + Payload: telegramIDStr, + }) + if err != nil { + return nil, "", fmt.Errorf("platega create payment: %w", err) + } + + // Вычисляем время жизни + var expiresAt *time.Time + if resp.ExpiresIn > 0 { + t := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second) + expiresAt = &t + } + + // Сохраняем в БД + payment := &database.Payment{ + TelegramID: telegramID, + ModeratorID: user.ModeratorID, + Amount: *user.SubscriptionPrice, + PaymentMethod: paymentMethodStr, + Status: "pending", + PlategaTransactionID: &resp.TransactionID, + RedirectURL: &resp.Redirect, + ExpiresAt: expiresAt, + } + + id, err := b.db.CreatePayment(payment) + if err != nil { + return nil, "", fmt.Errorf("save payment: %w", err) + } + payment.ID = id + + return payment, resp.Redirect, nil +} + +// checkPaymentStatus ручная проверка статуса платежа через Platega API. +// Защищён мьютексом по telegram_id для предотвращения race condition +// с параллельным callback от Platega. +func (b *Bot) checkPaymentStatus(telegramID int64) (string, error) { + // Берём мьютекс ДО чтения из БД — та же блокировка, что и в callback + mu := getPaymentMutex(telegramID) + mu.Lock() + defer mu.Unlock() + + // Попутно помечаем протухшие PENDING как expired (не ждём scheduler) + b.db.ExpireOldPendingPayments() + + pending, err := b.db.GetPendingPayment(telegramID) + if err != nil { + return "", fmt.Errorf("get pending: %w", err) + } + if pending == nil { + return "not_found", nil + } + if pending.PlategaTransactionID == nil { + return "pending", nil + } + + status, err := b.platega.GetTransactionStatus(*pending.PlategaTransactionID) + if err != nil { + return "", fmt.Errorf("check status: %w", err) + } + + if status.Status == platega.StatusConfirmed { + // Платёж подтверждён — обрабатываем как callback (мьютекс уже взят) + handler := &paymentCallbackHandler{bot: b} + handler.handleConfirmed(pending) + return "confirmed", nil + } + + return status.Status, nil +} +``` + +### Шаг 3: Обновить структуру `Bot` в `handlers.go` + +Добавить поля: + +```go +platega *platega.Client // Platega API клиент (nil если не настроен) +maintenanceMode bool // Режим обслуживания (не персистится — сбрасывается при перезапуске, это ОК: включается осознанно перед обновлением) +``` + +В функции `New()` — инициализация Platega-клиента: + +```go +if cfg.PlategaMerchantID != "" && cfg.PlategaSecret != "" { + bot.platega = platega.NewClient(cfg.PlategaMerchantID, cfg.PlategaSecret) + slog.Info("Platega client initialized") +} +``` + +### Шаг 4: Написать тесты + +Файл `internal/bot/payment_test.go`: +- `TestCalculateSharePercent` — проверка шкалы долей (15%, 20%, 25%) +- `TestGetPlategaFeePercent` — проверка комиссий по методам оплаты +- `TestHandleConfirmedIdempotency` — повторный callback не дублирует обработку + +### Шаг 5: Запустить тесты и коммит + +```bash +make tests +make fmt +``` + +**Критерии приёмки этапа 4:** +- Полный цикл: createPayment → Platega API → callback → confirm → activateSubscription +- Защита от двойных платежей (PENDING с тем же/другим способом) +- Chargeback деактивирует пользователя + алерт админу +- confirmed_not_activated при недоступности Remnawave + алерт +- Race condition защищён мьютексом по telegram_id +- Все тесты проходят + +--- + +## Этап 5: Переработка scheduler (event-driven) + +**Цель:** Перевести scheduler с модели "раз в день в 12:00" на "каждые 30 минут с полным проходом при старте". Добавить логику триала, grace period, disable, retry confirmed_not_activated. Режим обслуживания. + +**Файлы:** +- Изменить: `internal/bot/scheduler.go` — полная переработка +- Изменить: `internal/bot/scheduler_test.go` (если есть, или создать) +- Изменить: `internal/database/notifications.go` — добавить новые типы уведомлений + +### Шаг 1: Обновить константы уведомлений + +```go +const ( + // Триал + notificationTrialExpire1d = "trial_expire_1d" // За 1 день до конца триала + notificationTrialExpired = "trial_expired" // Триал истёк + + // Оплаченная подписка + notificationExpire3d = "expire_3d" // За 3 дня до конца + notificationExpire1d = "expire_1d" // За 1 день до конца + notificationExpired = "expired" // Подписка истекла (начало grace) + + // Grace period + notificationGraceKick = "grace_kick" // Кик после grace period +) +``` + +### Шаг 2: Переработать `StartScheduler` + +```go +func (b *Bot) StartScheduler(ctx context.Context) { + // Первый проход при старте — не ждём 30 минут + slog.Info("Scheduler: running initial pass on startup") + b.runSubscriptionSchedulerPass() + + ticker := time.NewTicker(30 * time.Minute) + defer ticker.Stop() + + slog.Info("Subscription scheduler started", "interval", "30m") + + for { + select { + case <-ctx.Done(): + slog.Info("Subscription scheduler stopped") + return + case <-ticker.C: + b.runSubscriptionSchedulerPass() + } + } +} +``` + +### Шаг 3: Переработать `runSubscriptionSchedulerPass` + +Новая логика: + +1. Протухание старых PENDING платежей: `b.db.ExpireOldPendingPayments()` +2. Retry confirmed_not_activated платежей: `b.retryConfirmedNotActivated()` +3. Для каждого пользователя: + - Бесконечная подписка (expireAt >= 2099) → пропуск + - Определить тип подписки через `b.isTrialUser(telegramID)` + - **Триал:** + - За 1 день до expireAt → уведомление `notificationTrialExpire1d` + - При expireAt (now >= expireAt) → если НЕ режим обслуживания → кик (удаление из Remnawave + БД, без grace period) + - **Оплаченная подписка:** + - За 3 дня до expireAt → уведомление `notificationExpire3d` + - За 1 день до expireAt → уведомление `notificationExpire1d` + - При expireAt (now >= expireAt) → если НЕ режим обслуживания → disable в Remnawave (не удалять!), уведомление `notificationExpired` о начале grace period + - **Grace period кик (expireAt + 3 дня):** если `now >= expireAt + 72h` → если НЕ режим обслуживания → проверить нет ли confirmed payment с даты expireAt (пользователь мог оплатить) → если нет → кик (удаление из Remnawave + БД) + - **Защита от ложного кика:** перед любым киком/disable проверяем `HasConfirmedPayment` с даты истечения — если есть confirmed платёж новее expireAt, пропускаем (callback уже обработал) + +```go +// Ключевая проверка grace period кика: +graceDeadline := remUser.ExpireAt.Add(72 * time.Hour) +if time.Now().UTC().After(graceDeadline) && !b.maintenanceMode { + // Проверяем, не оплатил ли пользователь во время grace period + // (callback мог прийти, но scheduler ещё не видел обновлённый expireAt) + freshUser, err := b.remnawave.GetUser(dbUser.RemnawaveUUID) + if err == nil && freshUser.Status == "ACTIVE" && freshUser.ExpireAt.After(time.Now().UTC()) { + continue // Пользователь оплатил — пропускаем + } + b.handleAutoKick(telegramID, dbUser.RemnawaveUUID) +} +``` + +### Шаг 4: Определение типа подписки + +```go +// isTrialUser проверяет, находится ли пользователь на триале. +// Триальный = приглашён модераторским инвайтом (expire_days != NULL) И ни разу не платил. +// Пользователи, созданные админским инвайтом (expire_days = NULL), НЕ считаются триальными +// — у них бесконечная подписка, другая логика. +func (b *Bot) isTrialUser(telegramID int64) bool { + // Проверяем инвайт — должен быть модераторский (expire_days != NULL) + invite, err := b.db.GetInviteByUsedBy(telegramID) + if err != nil || invite == nil || invite.ExpireDays == nil { + return false // Админский инвайт или нет инвайта — не триал + } + + // Проверяем, была ли оплата + hasPaid, err := b.db.HasConfirmedPayment(telegramID) + if err != nil { + return false + } + return !hasPaid +} +``` + +### Шаг 5: Написать тесты + +- `TestSchedulerTrialExpire` — триал: уведомление за 1 день → кик при expireAt +- `TestSchedulerPaidGracePeriod` — оплаченная: уведомления → disable → кик через 3 дня +- `TestSchedulerMaintenanceMode` — в режиме обслуживания не кикает и не disable-ит +- `TestSchedulerRetryConfirmedNotActivated` — retry подтверждённых но не активированных + +### Шаг 6: Запустить тесты и коммит + +```bash +make tests +make fmt +``` + +**Критерии приёмки этапа 5:** +- Scheduler запускается каждые 30 минут + при старте бота +- Триал: уведомление за 1 день → кик при expireAt (без grace period) +- Оплаченная: уведомления за 3д/1д → disable при expireAt → кик через 3 дня +- Режим обслуживания блокирует кик и disable +- confirmed_not_activated ретраятся +- Существующие пользователи не ломаются + +--- + +## Этап 6: UI пользователя (статус, оплата, динамические кнопки) + +**Цель:** Переработать пользовательский интерфейс: динамические кнопки (оплата/продление), обновлённый "Мой статус", флоу оплаты (выбор способа → создание платежа → ожидание → результат). + +**Файлы:** +- Изменить: `internal/bot/keyboards.go` — новые кнопки и раскладки +- Изменить: `internal/bot/messages.go` — новые UX-тексты, переработка `FormatUserStatus` +- Изменить: `internal/bot/handlers.go` — новые состояния, обработчики оплаты +- Создать: `internal/bot/payment_handler.go` — обработчики UI оплаты + +### Шаг 1: Обновить кнопки в `keyboards.go` + +Удалить: `BtnConnect`, `BtnDonate`. + +Добавить: + +```go +// Кнопки оплаты +BtnPay = "💳 Оплатить подписку" +BtnRenew = "💳 Продлить подписку" +BtnPaySBP = "🏦 СБП" +BtnPayCard = "💳 Карта" +BtnPayCrypto = "🪙 Крипта" +BtnCheckPayment = "🔄 Проверить оплату" + +// Кнопка информации (переименование) +BtnInfo = "ℹ️ Информация" +``` + +Новая функция `UserMenuKeyboardDynamic`: + +```go +// UserMenuKeyboardDynamic строит главное меню с динамической кнопкой оплаты. +// isModerator — добавляет кнопку "🎟 Приглашения" для модераторов. +func UserMenuKeyboardDynamic(payButtonText string, showPayButton bool, isModerator bool) *tele.ReplyMarkup { + menu := &tele.ReplyMarkup{ResizeKeyboard: true} + rows := []tele.Row{ + menu.Row(menu.Text(BtnStatus)), + } + if showPayButton && payButtonText != "" { + rows = append(rows, menu.Row(menu.Text(payButtonText), menu.Text(BtnServers))) + } else { + rows = append(rows, menu.Row(menu.Text(BtnServers))) + } + rows = append(rows, menu.Row(menu.Text(BtnInstructions), menu.Text(BtnInfo))) + if isModerator { + rows = append(rows, menu.Row(menu.Text(BtnModInvites))) + } + menu.Reply(rows...) + return menu +} +``` + +Это заменяет как `UserMenuKeyboard()`, так и `UserMenuKeyboardModerator()` — одна функция вместо трёх. + +`PaymentMethodKeyboard`: + +```go +func PaymentMethodKeyboard() *tele.ReplyMarkup { + menu := &tele.ReplyMarkup{ResizeKeyboard: true} + menu.Reply( + menu.Row(menu.Text(BtnPaySBP), menu.Text(BtnPayCard)), + menu.Row(menu.Text(BtnPayCrypto), menu.Text(BtnCancel)), + ) + return menu +} +``` + +`PaymentWaitKeyboard`: + +```go +func PaymentWaitKeyboard() *tele.ReplyMarkup { + menu := &tele.ReplyMarkup{ResizeKeyboard: true} + menu.Reply( + menu.Row(menu.Text(BtnCheckPayment), menu.Text(BtnCancel)), + ) + return menu +} +``` + +### Шаг 2: Обновить `FormatUserStatus` в `messages.go` + +Полная переработка с учётом типов подписки (триал, оплаченная, grace period, бесконечная). + +Входные данные: remnawave.User + database.User (для цены, типа). + +Добавить вспомогательную функцию `determineSubscriptionType`: + +```go +type subscriptionType int + +const ( + subTypeTrial subscriptionType = iota // Триал + subTypePaid // Оплаченная подписка + subTypeGrace // Grace period (disabled + не кикнут) + subTypeInfinite // Бесконечная (expireAt >= 2099) +) +``` + +### Шаг 3: Добавить состояния и обработчики оплаты + +Новые состояния: + +```go +StateWaitPaymentMethod = "wait_payment_method" // Ожидание выбора способа оплаты +StateWaitPaymentResult = "wait_payment_result" // Ожидание оплаты (показана ссылка) +``` + +В `handleTextMessage` добавить обработку: +- `BtnPay` / `BtnRenew` → показать экран выбора способа оплаты +- `BtnPaySBP` / `BtnPayCard` / `BtnPayCrypto` → создать платёж +- `BtnCheckPayment` → проверить статус через API +- `BtnStatus` → переработанный статус + +### Шаг 4: Создать `internal/bot/payment_handler.go` + +```go +package bot + +// handlePayButton обрабатывает нажатие "Оплатить/Продлить" +func (b *Bot) handlePayButton(c tele.Context) error { + // Проверка режима обслуживания + // Проверка лимита 90 дней + // Проверка наличия цены + // Показ экрана выбора способа оплаты +} + +// handlePaymentMethodSelected обрабатывает выбор способа оплаты +func (b *Bot) handlePaymentMethodSelected(c tele.Context, methodInt int) error { + // Создание платежа через createPaymentForUser + // Отправка ссылки + // Установка состояния StateWaitPaymentResult +} + +// handleCheckPayment обрабатывает кнопку "Проверить оплату" +func (b *Bot) handleCheckPayment(c tele.Context) error { + // Вызов checkPaymentStatus + // Если confirmed — показ подтверждения + // Если pending — "Оплата пока не поступила" +} +``` + +### Шаг 5: Обновить `handleStart` + +Для зарегистрированного пользователя в grace period — показать тревожный экран. Динамическая кнопка оплаты на основании типа подписки. + +### Шаг 6: Обновить обработку активации инвайта + +При `processInviteCode`: +- Создавать пользователя с `trafficLimitBytes = TrialTrafficLimitGB * 1024^3` +- Устанавливать `expireAt = now + 72h` +- Копировать `subscription_price` из инвайта в users +- Устанавливать `moderator_id` из инвайта `created_by` (если модератор) + +### Шаг 7: Запустить тесты и коммит + +```bash +make tests +make fmt +``` + +**Критерии приёмки этапа 6:** +- Главное меню показывает динамическую кнопку оплаты +- "Мой статус" показывает тип подписки, трафик, цену, устройства +- Флоу оплаты работает: выбор способа → ссылка → проверка +- Кнопка оплаты скрыта для бесконечных подписок и пользователей без цены +- Grace period показывает тревожный экран при /start +- Кнопки "Подключить" и "Поддержать" удалены + +--- + +## Этап 7: UI модератора (создание инвайта с ценой, подписчики, заработок) + +**Цель:** Переработать интерфейс модератора: при создании инвайта запрашивать цену, обогатить список подписчиков, добавить "Мой заработок" и "Изменить цену". + +**Файлы:** +- Изменить: `internal/bot/moderator.go` — переработка хендлеров +- Изменить: `internal/bot/keyboards.go` — новые кнопки модератора + +### Шаг 1: Обновить кнопки модератора + +Удалить: `BtnModExtend` (модератор больше не продлевает вручную). + +Добавить: + +```go +BtnModEarnings = "💰 Мой заработок" +BtnModChangePrice = "✏️ Изменить цену" +``` + +Обновить `ModeratorMenuKeyboard`: + +```go +func ModeratorMenuKeyboard() *tele.ReplyMarkup { + menu := &tele.ReplyMarkup{ResizeKeyboard: true} + menu.Reply( + menu.Row(menu.Text(BtnModCreate)), + menu.Row(menu.Text(BtnModView), menu.Text(BtnModSubscribers)), + menu.Row(menu.Text(BtnModEarnings), menu.Text(BtnModDelete)), + menu.Row(menu.Text(BtnModBack)), + ) + return menu +} +``` + +### Шаг 2: Переработать создание инвайта + +Новый флоу: +1. Модератор нажимает "Создать приглашение" +2. Бот: "Введите цену подписки (руб/мес). Минимум: {MIN_SUBSCRIPTION_PRICE} руб." +3. Модератор вводит число → валидация → создание инвайта с ценой + +Новые состояния: + +```go +StateWaitModInvitePrice = "wait_mod_invite_price" +``` + +### Шаг 3: Обогатить список подписчиков + +Показывать тип подписки (триал/оплачено/grace/истёк), дату, цену. Добавить кнопку "Изменить цену". + +### Шаг 4: Добавить "Мой заработок" + +```go +func (b *Bot) handleModeratorEarnings(c tele.Context) error { + // Получить данные из moderator_earnings за текущий месяц и за всё время + // Показать: платящих клиентов, долю, суммы комиссий, чистый доход, долю модератора +} +``` + +### Шаг 5: Добавить "Изменить цену" + +Модератор может менять цену только триальным клиентам (до первой оплаты). Валидация: свой подписчик, на триале, цена >= MIN_SUBSCRIPTION_PRICE. + +Новые состояния: + +```go +StateWaitModChangePriceID = "wait_mod_change_price_id" +StateWaitModChangePriceValue = "wait_mod_change_price_value" +``` + +### Шаг 6: Удалить логику продления + +Удалить: `handleModExtend`, `processModExtendID`, `processModExtendConfirm`, `modExtendSessionTimeout`. Удалить поля `modExtendMu`, `modExtendData` из структуры `Bot`. + +### Шаг 7: Запустить тесты и коммит + +```bash +make tests +make fmt +``` + +**Критерии приёмки этапа 7:** +- Модератор при создании инвайта указывает цену +- Список подписчиков показывает тип, дату, цену +- "Мой заработок" показывает финансовую статистику +- Модератор может изменить цену триальным клиентам +- Кнопка "Продлить подписку" убрана из модераторского меню + +--- + +## Этап 8: UI админа (статистика, инфо, обслуживание, цена) + +**Цель:** Добавить "Общую статистику", "Инфо о пользователе", "Режим обслуживания", подменю "Сменить тариф" с опцией "Изменить цену". + +**Файлы:** +- Изменить: `internal/bot/admin.go` — новые хендлеры +- Изменить: `internal/bot/keyboards.go` — новые кнопки админа + +### Шаг 1: Обновить кнопки админа + +Добавить: + +```go +BtnAdminStats = "📊 Общая статистика" +BtnAdminMaintenance = "🔧 Режим обслуживания" +BtnAdminMaintenanceOff = "▶️ Штатный режим" +BtnAdminUserInfo = "🔍 Инфо о пользователе" +BtnAdminSwitchInfinite = "♾️ Перевести на бессрочную" +BtnAdminChangePrice = "✏️ Изменить цену" +``` + +Обновить `AdminKeyboard`: + +```go +func AdminKeyboard(maintenanceMode bool) *tele.ReplyMarkup { + menu := &tele.ReplyMarkup{ResizeKeyboard: true} + maintenanceBtn := BtnAdminMaintenance + if maintenanceMode { + maintenanceBtn = BtnAdminMaintenanceOff + } + menu.Reply( + menu.Row(menu.Text(BtnAdminManage), menu.Text(BtnAdminModerators)), + menu.Row(menu.Text(BtnAdminBroadcast), menu.Text(BtnAdminStats)), + menu.Row(menu.Text(maintenanceBtn)), + menu.Row(menu.Text(BtnAdminUserMode)), + ) + return menu +} +``` + +Обновить `AdminManageKeyboard` — добавить `BtnAdminUserInfo`. + +Добавить `AdminSwitchSubmenu`: + +```go +func AdminSwitchSubmenu() *tele.ReplyMarkup { + menu := &tele.ReplyMarkup{ResizeKeyboard: true} + menu.Reply( + menu.Row(menu.Text(BtnAdminSwitchInfinite)), + menu.Row(menu.Text(BtnAdminChangePrice)), + menu.Row(menu.Text(BtnAdminBack)), + ) + return menu +} +``` + +### Шаг 2: Добавить "Общую статистику" + +```go +func (b *Bot) handleAdminStats(c tele.Context) error { + // Финансы: платежей, сумма, комиссии, чистый доход, выплаты модераторам, доход владельца + // Пользователи: всего, платящих, триал, grace, бессрочных + // Конверсия: первые оплаты / триалы за месяц +} +``` + +### Шаг 3: Добавить "Статистику модераторов" (обновить существующую) + +Для каждого модератора — финансовая сводка за прошлый завершённый месяц. Данные из `moderator_earnings`. + +### Шаг 4: Добавить "Инфо о пользователе" + +```go +StateWaitAdminUserInfo = "wait_admin_user_info" +``` + +Показывает: имя, куратор, цена, подписка до, трафик, устройства, тип, статус. + +### Шаг 5: Добавить "Режим обслуживания" + +Переключатель `maintenanceMode` в структуре `Bot`. При включении: скрыть кнопку оплаты, scheduler не кикает/не disable-ит. + +### Шаг 6: Переработать "Сменить тариф" + +Текущий "Сменить тариф" → подменю с двумя опциями: +- "Перевести на бессрочную" — существующая логика +- "Изменить цену" — админ вводит telegram_id → новую цену → уведомление пользователю + +Новые состояния: + +```go +StateWaitAdminChangePriceID = "wait_admin_change_price_id" +StateWaitAdminChangePriceValue = "wait_admin_change_price_value" +``` + +### Шаг 7: Запустить тесты и коммит + +```bash +make tests +make fmt +``` + +**Критерии приёмки этапа 8:** +- "Общая статистика" показывает финансы и операционку +- "Инфо о пользователе" показывает полную карточку +- "Режим обслуживания" скрывает оплату и останавливает кики +- "Сменить тариф" имеет подменю с бессрочной и изменением цены +- Статистика модераторов показывает финансы за прошлый месяц + +--- + +## Этап 9: Финализация и интеграция + +**Цель:** Связать все компоненты, обновить docker-compose, добавить переменные окружения, протестировать e2e. + +**Файлы:** +- Изменить: `docker-compose.yml` — проброс порта callback +- Изменить: `Dockerfile` — без изменений (binary тот же) +- Изменить: `cmd/bot/main.go` — финальная интеграция +- Обновить: `CLAUDE.md` — описание новой архитектуры + +### Шаг 1: Обновить `docker-compose.yml` + +```yaml +vpn-bot: + ports: + - "127.0.0.1:8080:8080" # Callback-сервер для Platega + environment: + # ... существующие ... + - PLATEGA_MERCHANT_ID=${PLATEGA_MERCHANT_ID} + - PLATEGA_SECRET=${PLATEGA_SECRET} + - PLATEGA_CALLBACK_URL=${PLATEGA_CALLBACK_URL} + - CALLBACK_PORT=${CALLBACK_PORT:-8080} + - MIN_SUBSCRIPTION_PRICE=${MIN_SUBSCRIPTION_PRICE:-400} + - TRIAL_TRAFFIC_LIMIT_GB=${TRIAL_TRAFFIC_LIMIT_GB:-1} + - PLATEGA_FEE_SBP=${PLATEGA_FEE_SBP:-11} + - PLATEGA_FEE_CARD=${PLATEGA_FEE_CARD:-12} + - PLATEGA_FEE_CRYPTO=${PLATEGA_FEE_CRYPTO:-5} + - PLATEGA_FEE_WITHDRAWAL=${PLATEGA_FEE_WITHDRAWAL:-2} +``` + +### Шаг 2: Проверить обратную совместимость + +- Бот без PLATEGA_* переменных работает как раньше +- Существующие пользователи с NULL subscription_price не видят кнопку оплаты +- Scheduler для триальных пользователей без инвайта (старые) — пропускает + +### Шаг 3: Полное тестирование + +```bash +make tests +make fmt +``` + +### Шаг 4: Обновить CLAUDE.md + +Добавить новые компоненты в описание архитектуры, переменные окружения, таблицы. + +**Критерии приёмки этапа 9:** +- `make tests` — все тесты проходят +- `make fmt` — без ошибок +- Бот запускается и работает с Platega-переменными +- Бот запускается и работает без Platega-переменных (обратная совместимость) +- Docker compose поддерживает проброс callback-порта + +--- + +## Порядок реализации и зависимости + +``` +Этап 1 (БД) + ↓ +Этап 2 (Platega-клиент) ← параллельно с Этапом 1 + ↓ +Этап 3 (Callback-сервер) ← зависит от Этапа 2 + ↓ +Этап 4 (Платёжный флоу) ← зависит от Этапов 1, 2, 3 + ↓ +Этап 5 (Scheduler) ← зависит от Этапа 4 + ↓ +Этап 6 (UI пользователя) ← зависит от Этапов 4, 5 + ↓ +Этап 7 (UI модератора) ← зависит от Этапа 6 + ↓ +Этап 8 (UI админа) ← зависит от Этапов 6, 7 + ↓ +Этап 9 (Финализация) ← зависит от всех этапов +``` + +Этапы 1 и 2 можно реализовывать **параллельно**. From 6da7e0b7e18e41677ba4f3384bd4a5673d8a2317 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 11:10:55 +0300 Subject: [PATCH 06/34] =?UTF-8?q?feat:=20=D1=8D=D1=82=D0=B0=D0=BF=201=20?= =?UTF-8?q?=E2=80=94=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=91=D0=94=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D1=91=D0=B6=D0=BD=D0=BE=D0=B9=20=D1=81=D0=B8=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/migrator/main.go | 2 +- docs/progress/2026-03-23-payment-stage-1.md | 39 +++ internal/bot/handlers.go | 2 +- internal/database/db.go | 67 ++++- internal/database/earnings.go | 101 +++++++ internal/database/earnings_test.go | 148 ++++++++++ internal/database/invites.go | 53 +++- internal/database/payments.go | 300 ++++++++++++++++++++ internal/database/payments_test.go | 219 ++++++++++++++ internal/database/users.go | 51 +++- 10 files changed, 951 insertions(+), 31 deletions(-) create mode 100644 docs/progress/2026-03-23-payment-stage-1.md create mode 100644 internal/database/earnings.go create mode 100644 internal/database/earnings_test.go create mode 100644 internal/database/payments.go create mode 100644 internal/database/payments_test.go diff --git a/cmd/migrator/main.go b/cmd/migrator/main.go index a6dea3c..649ac89 100644 --- a/cmd/migrator/main.go +++ b/cmd/migrator/main.go @@ -138,7 +138,7 @@ func main() { } // Сохраняем в новую БД (first_name пустой, так как старая БД его не хранила) - _, err = newDB.CreateUser(oldUser.TelegramID, username, "", remnawaveUser.UUID) + _, err = newDB.CreateUser(oldUser.TelegramID, username, "", remnawaveUser.UUID, nil, nil) if err != nil { logLine := fmt.Sprintf("[ERROR] telegram_id=%d — DB error: %v\n", oldUser.TelegramID, err) fmt.Print(logLine) diff --git a/docs/progress/2026-03-23-payment-stage-1.md b/docs/progress/2026-03-23-payment-stage-1.md new file mode 100644 index 0000000..5e0b701 --- /dev/null +++ b/docs/progress/2026-03-23-payment-stage-1.md @@ -0,0 +1,39 @@ +# Этап 1: Миграция БД — таблицы payments и moderator_earnings +**План:** [2026-03-22-payment-implementation-plan.md](../plans/2026-03-22-payment-implementation-plan.md) +**Статус:** Выполнен + +## Что сделано + +### Изменённые файлы + +- **`internal/database/db.go`** + - Структура `User` расширена: добавлены `SubscriptionPrice *int` и `ModeratorID *int64` + - Структура `Invite` расширена: добавлено `SubscriptionPrice *int` + - В `migrations`: добавлены таблицы `payments` и `moderator_earnings` с индексами + - В `alterMigrations`: добавлены ALTER TABLE для `users.subscription_price`, `users.moderator_id`, `invites.subscription_price` + +- **`internal/database/users.go`** + - `CreateUser` — сигнатура расширена параметрами `subscriptionPrice *int, moderatorID *int64` + - `GetUserByTelegramID`, `GetUserByRemnawaveUUID`, `GetAllUsers` — SELECT и Scan обновлены под новые поля + - Добавлена `UpdateSubscriptionPrice` + +- **`internal/database/invites.go`** + - `GetInviteByCode`, `GetAllInvites`, `GetUnusedInvites`, `GetInviteByUsedBy` — SELECT и Scan обновлены под `subscription_price` + - Добавлена `CreateInviteWithPrice(createdBy int64, expireDays int, price int) (string, error)` + +- **`internal/bot/handlers.go`** — вызов `CreateUser` дополнен `nil, nil` +- **`cmd/migrator/main.go`** — вызов `CreateUser` дополнен `nil, nil` +- Все тестовые файлы в `internal/database/` и `internal/bot/` — вызовы `CreateUser` обновлены + +### Новые файлы + +- **`internal/database/payments.go`** — CRUD для таблицы `payments`: `CreatePayment`, `GetPaymentByID`, `GetPendingPayment`, `GetPaymentByPlategaTxID`, `UpdatePaymentStatus`, `ConfirmPayment`, `ExpireOldPendingPayments`, `GetConfirmedNotActivated`, `HasConfirmedPayment`, статистика за месяц +- **`internal/database/earnings.go`** — CRUD для `moderator_earnings`: `CreateEarning`, `GetModeratorEarningsByMonth`, `GetModeratorTotalEarnings`, `GetAllEarningsByMonth` +- **`internal/database/payments_test.go`** — 6 тестов (TDD: сначала написаны тесты) +- **`internal/database/earnings_test.go`** — 3 теста (TDD: сначала написаны тесты) + +## Отклонения от плана + +- Нет: все шаги выполнены строго по плану +- Тесты написаны в стиле TDD: сначала RED (тесты без реализации), затем GREEN (реализация) +- Исправлена проблема с временными зонами: `time.Now()` → `time.Now().UTC()` в тестах протухших платежей diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index b601efe..4b3169d 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -415,7 +415,7 @@ func (b *Bot) processInviteCode(c tele.Context, code string) error { } // Сохраняем связку в БД - _, err = b.db.CreateUser(telegramID, username, c.Sender().FirstName, remnawaveUser.UUID) + _, err = b.db.CreateUser(telegramID, username, c.Sender().FirstName, remnawaveUser.UUID, nil, nil) if err != nil { slog.Error("Failed to create user in DB", "error", err) // Откатываем: удаляем из Remnawave и освобождаем инвайт diff --git a/internal/database/db.go b/internal/database/db.go index b1fc727..f01225c 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -17,22 +17,25 @@ type DB struct { // User представляет запись пользователя type User struct { - TelegramID int64 - Username string - FirstName string // Имя пользователя из Telegram - RemnawaveUUID string - CreatedAt time.Time + TelegramID int64 + Username string + FirstName string // Имя пользователя из Telegram + RemnawaveUUID string + SubscriptionPrice *int // Цена подписки руб/мес (NULL = не установлена) + ModeratorID *int64 // Telegram ID куратора (NULL = админский/снят) + CreatedAt time.Time } // Invite представляет запись инвайта type Invite struct { - Code string - CreatedBy int64 - UsedBy *int64 - UsedAt *time.Time // Время активации кода - ExpireDays *int // NULL = бессрочный инвайт - KickedAt *time.Time // Время автокика — инвайт нельзя переиспользовать - CreatedAt time.Time + Code string + CreatedBy int64 + UsedBy *int64 + UsedAt *time.Time // Время активации кода + ExpireDays *int // NULL = бессрочный инвайт + SubscriptionPrice *int // Цена подписки при создании инвайта + KickedAt *time.Time // Время автокика — инвайт нельзя переиспользовать + CreatedAt time.Time } // New создаёт новое подключение к БД и инициализирует схему @@ -110,9 +113,43 @@ func migrate(conn *sql.DB) error { PRIMARY KEY (telegram_id, type) )`, + // Таблица платежей + `CREATE TABLE IF NOT EXISTS payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_id INTEGER NOT NULL, + moderator_id INTEGER, + amount INTEGER NOT NULL, + payment_method TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + platega_transaction_id TEXT UNIQUE, + redirect_url TEXT, + expires_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + confirmed_at TIMESTAMP + )`, + + // Таблица начислений модераторов + `CREATE TABLE IF NOT EXISTS moderator_earnings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + payment_id INTEGER NOT NULL REFERENCES payments(id), + moderator_id INTEGER NOT NULL, + gross_amount INTEGER NOT NULL, + platega_fee INTEGER NOT NULL, + withdrawal_fee INTEGER NOT NULL, + net_amount INTEGER NOT NULL, + share_percent INTEGER NOT NULL, + share_amount INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + // Индексы `CREATE INDEX IF NOT EXISTS idx_users_remnawave_uuid ON users(remnawave_uuid)`, `CREATE INDEX IF NOT EXISTS idx_invites_used_by ON invites(used_by)`, + `CREATE INDEX IF NOT EXISTS idx_payments_telegram_id ON payments(telegram_id)`, + `CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)`, + `CREATE INDEX IF NOT EXISTS idx_payments_platega_tx ON payments(platega_transaction_id)`, + `CREATE INDEX IF NOT EXISTS idx_earnings_moderator ON moderator_earnings(moderator_id)`, + `CREATE INDEX IF NOT EXISTS idx_earnings_payment ON moderator_earnings(payment_id)`, } for _, m := range migrations { @@ -131,6 +168,12 @@ func migrate(conn *sql.DB) error { `ALTER TABLE invites ADD COLUMN expire_days INTEGER`, // Миграция: метка автокика — инвайт нельзя использовать повторно, но история сохраняется `ALTER TABLE invites ADD COLUMN kicked_at TIMESTAMP`, + // Миграция: цена подписки пользователя (руб/мес, NULL = не установлена) + `ALTER TABLE users ADD COLUMN subscription_price INTEGER`, + // Миграция: telegram_id модератора-куратора (NULL = админский или снят модератор) + `ALTER TABLE users ADD COLUMN moderator_id INTEGER`, + // Миграция: цена подписки при создании инвайта + `ALTER TABLE invites ADD COLUMN subscription_price INTEGER`, } for _, m := range alterMigrations { diff --git a/internal/database/earnings.go b/internal/database/earnings.go new file mode 100644 index 0000000..1d86fd8 --- /dev/null +++ b/internal/database/earnings.go @@ -0,0 +1,101 @@ +package database + +import ( + "database/sql" + "time" +) + +// ModeratorEarning представляет запись начисления модератору +type ModeratorEarning struct { + ID int64 + PaymentID int64 + ModeratorID int64 + GrossAmount int // Сумма платежа + PlategaFee int // Комиссия Platega + WithdrawalFee int // Комиссия вывода (2%) + NetAmount int // Чистый доход после всех комиссий + SharePercent int // Процент доли модератора + ShareAmount int // Сумма доли модератора + CreatedAt time.Time +} + +// CreateEarning создаёт запись начисления модератору +func (db *DB) CreateEarning(e *ModeratorEarning) (int64, error) { + res, err := db.conn.Exec( + `INSERT INTO moderator_earnings (payment_id, moderator_id, gross_amount, platega_fee, withdrawal_fee, net_amount, share_percent, share_amount) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + e.PaymentID, e.ModeratorID, e.GrossAmount, e.PlategaFee, e.WithdrawalFee, e.NetAmount, e.SharePercent, e.ShareAmount, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// MonthlyEarnings содержит агрегированные данные о доходах за месяц +type MonthlyEarnings struct { + TotalPayments int // Количество платежей + GrossAmount int // Сумма платежей + TotalPlategaFee int // Суммарная комиссия Platega + TotalWithdrawal int // Суммарная комиссия вывода + TotalNetAmount int // Суммарный чистый доход + TotalShareAmount int // Суммарная доля модератора + SharePercent int // Последний актуальный процент +} + +// GetModeratorEarningsByMonth возвращает агрегированные данные за месяц для модератора +func (db *DB) GetModeratorEarningsByMonth(moderatorID int64, year int, month int) (*MonthlyEarnings, error) { + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + + me := &MonthlyEarnings{} + err := db.conn.QueryRow( + `SELECT COUNT(*), COALESCE(SUM(gross_amount), 0), COALESCE(SUM(platega_fee), 0), + COALESCE(SUM(withdrawal_fee), 0), COALESCE(SUM(net_amount), 0), COALESCE(SUM(share_amount), 0) + FROM moderator_earnings WHERE moderator_id = ? AND created_at >= ? AND created_at < ?`, + moderatorID, start, end, + ).Scan(&me.TotalPayments, &me.GrossAmount, &me.TotalPlategaFee, &me.TotalWithdrawal, &me.TotalNetAmount, &me.TotalShareAmount) + if err != nil { + return nil, err + } + + // Получаем актуальный процент (из последнего начисления модератора) + var pct sql.NullInt64 + db.conn.QueryRow( + `SELECT share_percent FROM moderator_earnings WHERE moderator_id = ? ORDER BY created_at DESC LIMIT 1`, + moderatorID, + ).Scan(&pct) + if pct.Valid { + me.SharePercent = int(pct.Int64) + } + + return me, nil +} + +// GetModeratorTotalEarnings возвращает суммарную долю модератора за всё время +func (db *DB) GetModeratorTotalEarnings(moderatorID int64) (int, error) { + var sum sql.NullInt64 + err := db.conn.QueryRow( + `SELECT COALESCE(SUM(share_amount), 0) FROM moderator_earnings WHERE moderator_id = ?`, + moderatorID, + ).Scan(&sum) + if sum.Valid { + return int(sum.Int64), err + } + return 0, err +} + +// GetAllEarningsByMonth возвращает общую статистику за месяц (для админа) +func (db *DB) GetAllEarningsByMonth(year int, month int) (*MonthlyEarnings, error) { + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + + me := &MonthlyEarnings{} + err := db.conn.QueryRow( + `SELECT COUNT(*), COALESCE(SUM(gross_amount), 0), COALESCE(SUM(platega_fee), 0), + COALESCE(SUM(withdrawal_fee), 0), COALESCE(SUM(net_amount), 0), COALESCE(SUM(share_amount), 0) + FROM moderator_earnings WHERE created_at >= ? AND created_at < ?`, + start, end, + ).Scan(&me.TotalPayments, &me.GrossAmount, &me.TotalPlategaFee, &me.TotalWithdrawal, &me.TotalNetAmount, &me.TotalShareAmount) + return me, err +} diff --git a/internal/database/earnings_test.go b/internal/database/earnings_test.go new file mode 100644 index 0000000..30c897b --- /dev/null +++ b/internal/database/earnings_test.go @@ -0,0 +1,148 @@ +package database + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateEarning(t *testing.T) { + dbFile := "test_earnings.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + // Сначала создаём платёж (внешний ключ) + p := &Payment{ + TelegramID: 12345, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + } + paymentID, err := db.CreatePayment(p) + require.NoError(t, err) + + modID := int64(777) + e := &ModeratorEarning{ + PaymentID: paymentID, + ModeratorID: modID, + GrossAmount: 500, + PlategaFee: 15, + WithdrawalFee: 10, + NetAmount: 475, + SharePercent: 70, + ShareAmount: 332, + } + + id, err := db.CreateEarning(e) + require.NoError(t, err) + assert.Greater(t, id, int64(0)) +} + +func TestGetModeratorEarningsByMonth(t *testing.T) { + dbFile := "test_earnings_month.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + modID := int64(777) + + // Нет данных — возвращает нули + me, err := db.GetModeratorEarningsByMonth(modID, 2026, 3) + require.NoError(t, err) + require.NotNil(t, me) + assert.Equal(t, 0, me.TotalPayments) + assert.Equal(t, 0, me.GrossAmount) + + // Создаём два платежа и начисления + for i := 0; i < 2; i++ { + p := &Payment{ + TelegramID: int64(12345 + i), + Amount: 500, + PaymentMethod: "sbp", + Status: "confirmed", + } + paymentID, err := db.CreatePayment(p) + require.NoError(t, err) + + e := &ModeratorEarning{ + PaymentID: paymentID, + ModeratorID: modID, + GrossAmount: 500, + PlategaFee: 15, + WithdrawalFee: 10, + NetAmount: 475, + SharePercent: 70, + ShareAmount: 332, + } + _, err = db.CreateEarning(e) + require.NoError(t, err) + } + + // Текущий месяц — должны найти оба начисления + me, err = db.GetModeratorEarningsByMonth(modID, 2026, 3) + require.NoError(t, err) + assert.Equal(t, 2, me.TotalPayments) + assert.Equal(t, 1000, me.GrossAmount) + assert.Equal(t, 664, me.TotalShareAmount) + assert.Equal(t, 70, me.SharePercent) + + // Другой месяц — нули + me, err = db.GetModeratorEarningsByMonth(modID, 2026, 2) + require.NoError(t, err) + assert.Equal(t, 0, me.TotalPayments) +} + +func TestGetModeratorTotalEarnings(t *testing.T) { + dbFile := "test_earnings_total.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + modID := int64(777) + + // Нет данных — 0 + total, err := db.GetModeratorTotalEarnings(modID) + require.NoError(t, err) + assert.Equal(t, 0, total) + + // Добавляем начисления + for i := 0; i < 3; i++ { + p := &Payment{ + TelegramID: int64(10000 + i), + Amount: 500, + PaymentMethod: "sbp", + Status: "confirmed", + } + paymentID, err := db.CreatePayment(p) + require.NoError(t, err) + + e := &ModeratorEarning{ + PaymentID: paymentID, + ModeratorID: modID, + GrossAmount: 500, + PlategaFee: 15, + WithdrawalFee: 10, + NetAmount: 475, + SharePercent: 70, + ShareAmount: 100, // Ровно для простоты + } + _, err = db.CreateEarning(e) + require.NoError(t, err) + } + + total, err = db.GetModeratorTotalEarnings(modID) + require.NoError(t, err) + assert.Equal(t, 300, total) +} diff --git a/internal/database/invites.go b/internal/database/invites.go index aa2d56c..99389f2 100644 --- a/internal/database/invites.go +++ b/internal/database/invites.go @@ -60,12 +60,13 @@ func (db *DB) GetInviteByCode(code string) (*Invite, error) { var usedBy sql.NullInt64 var usedAt sql.NullTime var expireDays sql.NullInt64 + var subscriptionPrice sql.NullInt64 var kickedAt sql.NullTime err := db.conn.QueryRow( - `SELECT code, created_by, used_by, used_at, expire_days, kicked_at, created_at FROM invites WHERE code = ?`, + `SELECT code, created_by, used_by, used_at, expire_days, subscription_price, kicked_at, created_at FROM invites WHERE code = ?`, code, - ).Scan(&invite.Code, &invite.CreatedBy, &usedBy, &usedAt, &expireDays, &kickedAt, &invite.CreatedAt) + ).Scan(&invite.Code, &invite.CreatedBy, &usedBy, &usedAt, &expireDays, &subscriptionPrice, &kickedAt, &invite.CreatedAt) if err == sql.ErrNoRows { return nil, nil @@ -84,6 +85,10 @@ func (db *DB) GetInviteByCode(code string) (*Invite, error) { v := int(expireDays.Int64) invite.ExpireDays = &v } + if subscriptionPrice.Valid { + v := int(subscriptionPrice.Int64) + invite.SubscriptionPrice = &v + } if kickedAt.Valid { invite.KickedAt = &kickedAt.Time } @@ -213,7 +218,7 @@ func (db *DB) IsInviteValid(code string) (bool, error) { // GetAllInvites получает все инвайты func (db *DB) GetAllInvites() ([]Invite, error) { rows, err := db.conn.Query( - `SELECT code, created_by, used_by, used_at, expire_days, created_at FROM invites ORDER BY created_at DESC`, + `SELECT code, created_by, used_by, used_at, expire_days, subscription_price, created_at FROM invites ORDER BY created_at DESC`, ) if err != nil { return nil, fmt.Errorf("failed to query invites: %w", err) @@ -226,8 +231,9 @@ func (db *DB) GetAllInvites() ([]Invite, error) { var usedBy sql.NullInt64 var usedAt sql.NullTime var expireDays sql.NullInt64 + var subscriptionPrice sql.NullInt64 - if err := rows.Scan(&invite.Code, &invite.CreatedBy, &usedBy, &usedAt, &expireDays, &invite.CreatedAt); err != nil { + if err := rows.Scan(&invite.Code, &invite.CreatedBy, &usedBy, &usedAt, &expireDays, &subscriptionPrice, &invite.CreatedAt); err != nil { return nil, fmt.Errorf("failed to scan invite: %w", err) } @@ -241,6 +247,10 @@ func (db *DB) GetAllInvites() ([]Invite, error) { v := int(expireDays.Int64) invite.ExpireDays = &v } + if subscriptionPrice.Valid { + v := int(subscriptionPrice.Int64) + invite.SubscriptionPrice = &v + } invites = append(invites, invite) } @@ -255,7 +265,7 @@ func (db *DB) GetAllInvites() ([]Invite, error) { // GetUnusedInvites получает неиспользованные инвайты func (db *DB) GetUnusedInvites() ([]Invite, error) { rows, err := db.conn.Query( - `SELECT code, created_by, used_by, used_at, expire_days, created_at FROM invites WHERE used_by IS NULL ORDER BY created_at DESC`, + `SELECT code, created_by, used_by, used_at, expire_days, subscription_price, created_at FROM invites WHERE used_by IS NULL ORDER BY created_at DESC`, ) if err != nil { return nil, fmt.Errorf("failed to query invites: %w", err) @@ -268,8 +278,9 @@ func (db *DB) GetUnusedInvites() ([]Invite, error) { var usedBy sql.NullInt64 var usedAt sql.NullTime var expireDays sql.NullInt64 + var subscriptionPrice sql.NullInt64 - if err := rows.Scan(&invite.Code, &invite.CreatedBy, &usedBy, &usedAt, &expireDays, &invite.CreatedAt); err != nil { + if err := rows.Scan(&invite.Code, &invite.CreatedBy, &usedBy, &usedAt, &expireDays, &subscriptionPrice, &invite.CreatedAt); err != nil { return nil, fmt.Errorf("failed to scan invite: %w", err) } if usedAt.Valid { @@ -279,6 +290,10 @@ func (db *DB) GetUnusedInvites() ([]Invite, error) { v := int(expireDays.Int64) invite.ExpireDays = &v } + if subscriptionPrice.Valid { + v := int(subscriptionPrice.Int64) + invite.SubscriptionPrice = &v + } invites = append(invites, invite) } @@ -457,16 +472,17 @@ func (db *DB) GetInviteByUsedBy(usedBy int64) (*Invite, error) { var usedByNullable sql.NullInt64 var usedAt sql.NullTime var expireDays sql.NullInt64 + var subscriptionPrice sql.NullInt64 var kickedAt sql.NullTime err := db.conn.QueryRow( - `SELECT code, created_by, used_by, used_at, expire_days, kicked_at, created_at + `SELECT code, created_by, used_by, used_at, expire_days, subscription_price, kicked_at, created_at FROM invites WHERE used_by = ? AND kicked_at IS NULL ORDER BY used_at DESC LIMIT 1`, usedBy, - ).Scan(&invite.Code, &invite.CreatedBy, &usedByNullable, &usedAt, &expireDays, &kickedAt, &invite.CreatedAt) + ).Scan(&invite.Code, &invite.CreatedBy, &usedByNullable, &usedAt, &expireDays, &subscriptionPrice, &kickedAt, &invite.CreatedAt) if err == sql.ErrNoRows { return nil, nil } @@ -484,6 +500,10 @@ func (db *DB) GetInviteByUsedBy(usedBy int64) (*Invite, error) { v := int(expireDays.Int64) invite.ExpireDays = &v } + if subscriptionPrice.Valid { + v := int(subscriptionPrice.Int64) + invite.SubscriptionPrice = &v + } if kickedAt.Valid { invite.KickedAt = &kickedAt.Time } @@ -628,6 +648,23 @@ func (db *DB) DeleteUnusedInvitesByCreator(createdBy int64) (int64, error) { return rows, nil } +// CreateInviteWithPrice создаёт модераторский инвайт с ценой подписки. +// expireDays = срок действия инвайта в днях, price = цена подписки в руб/мес. +func (db *DB) CreateInviteWithPrice(createdBy int64, expireDays int, price int) (string, error) { + code, err := generateInviteCode() + if err != nil { + return "", fmt.Errorf("failed to generate invite code: %w", err) + } + _, err = db.conn.Exec( + `INSERT INTO invites (code, created_by, expire_days, subscription_price) VALUES (?, ?, ?, ?)`, + code, createdBy, expireDays, price, + ) + if err != nil { + return "", fmt.Errorf("failed to create invite with price: %w", err) + } + return code, nil +} + // generateInviteCode генерирует случайный 8-символьный код func generateInviteCode() (string, error) { bytes := make([]byte, 4) diff --git a/internal/database/payments.go b/internal/database/payments.go new file mode 100644 index 0000000..13e9d48 --- /dev/null +++ b/internal/database/payments.go @@ -0,0 +1,300 @@ +package database + +import ( + "database/sql" + "time" +) + +// Payment представляет запись платежа +type Payment struct { + ID int64 + TelegramID int64 + ModeratorID *int64 + Amount int + PaymentMethod string // "sbp", "card", "crypto" + Status string // "pending", "confirmed", "expired", "canceled", "chargebacked", "confirmed_not_activated" + PlategaTransactionID *string + RedirectURL *string + ExpiresAt *time.Time + CreatedAt time.Time + ConfirmedAt *time.Time +} + +// CreatePayment создаёт новый платёж +func (db *DB) CreatePayment(p *Payment) (int64, error) { + res, err := db.conn.Exec( + `INSERT INTO payments (telegram_id, moderator_id, amount, payment_method, status, platega_transaction_id, redirect_url, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + p.TelegramID, p.ModeratorID, p.Amount, p.PaymentMethod, p.Status, p.PlategaTransactionID, p.RedirectURL, p.ExpiresAt, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// GetPaymentByID возвращает платёж по ID +func (db *DB) GetPaymentByID(id int64) (*Payment, error) { + p := &Payment{} + var modID sql.NullInt64 + var txID sql.NullString + var redirectURL sql.NullString + var expiresAt sql.NullTime + var confirmedAt sql.NullTime + + err := db.conn.QueryRow( + `SELECT id, telegram_id, moderator_id, amount, payment_method, status, platega_transaction_id, redirect_url, expires_at, created_at, confirmed_at + FROM payments WHERE id = ?`, id, + ).Scan(&p.ID, &p.TelegramID, &modID, &p.Amount, &p.PaymentMethod, &p.Status, &txID, &redirectURL, &expiresAt, &p.CreatedAt, &confirmedAt) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + if modID.Valid { + p.ModeratorID = &modID.Int64 + } + if txID.Valid { + p.PlategaTransactionID = &txID.String + } + if redirectURL.Valid { + p.RedirectURL = &redirectURL.String + } + if expiresAt.Valid { + p.ExpiresAt = &expiresAt.Time + } + if confirmedAt.Valid { + p.ConfirmedAt = &confirmedAt.Time + } + + return p, nil +} + +// GetPendingPayment возвращает активный PENDING платёж пользователя (не протухший) +func (db *DB) GetPendingPayment(telegramID int64) (*Payment, error) { + p := &Payment{} + var modID sql.NullInt64 + var txID sql.NullString + var redirectURL sql.NullString + var expiresAt sql.NullTime + var confirmedAt sql.NullTime + + err := db.conn.QueryRow( + `SELECT id, telegram_id, moderator_id, amount, payment_method, status, platega_transaction_id, redirect_url, expires_at, created_at, confirmed_at + FROM payments WHERE telegram_id = ? AND status = 'pending' AND (expires_at IS NULL OR expires_at > datetime('now')) + ORDER BY created_at DESC LIMIT 1`, telegramID, + ).Scan(&p.ID, &p.TelegramID, &modID, &p.Amount, &p.PaymentMethod, &p.Status, &txID, &redirectURL, &expiresAt, &p.CreatedAt, &confirmedAt) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + if modID.Valid { + p.ModeratorID = &modID.Int64 + } + if txID.Valid { + p.PlategaTransactionID = &txID.String + } + if redirectURL.Valid { + p.RedirectURL = &redirectURL.String + } + if expiresAt.Valid { + p.ExpiresAt = &expiresAt.Time + } + if confirmedAt.Valid { + p.ConfirmedAt = &confirmedAt.Time + } + + return p, nil +} + +// GetPaymentByPlategaTxID возвращает платёж по ID транзакции Platega +func (db *DB) GetPaymentByPlategaTxID(txID string) (*Payment, error) { + p := &Payment{} + var modID sql.NullInt64 + var txIDNull sql.NullString + var redirectURL sql.NullString + var expiresAt sql.NullTime + var confirmedAt sql.NullTime + + err := db.conn.QueryRow( + `SELECT id, telegram_id, moderator_id, amount, payment_method, status, platega_transaction_id, redirect_url, expires_at, created_at, confirmed_at + FROM payments WHERE platega_transaction_id = ?`, txID, + ).Scan(&p.ID, &p.TelegramID, &modID, &p.Amount, &p.PaymentMethod, &p.Status, &txIDNull, &redirectURL, &expiresAt, &p.CreatedAt, &confirmedAt) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + if modID.Valid { + p.ModeratorID = &modID.Int64 + } + if txIDNull.Valid { + p.PlategaTransactionID = &txIDNull.String + } + if redirectURL.Valid { + p.RedirectURL = &redirectURL.String + } + if expiresAt.Valid { + p.ExpiresAt = &expiresAt.Time + } + if confirmedAt.Valid { + p.ConfirmedAt = &confirmedAt.Time + } + + return p, nil +} + +// UpdatePaymentStatus обновляет статус платежа +func (db *DB) UpdatePaymentStatus(id int64, status string) error { + _, err := db.conn.Exec(`UPDATE payments SET status = ? WHERE id = ?`, status, id) + return err +} + +// ConfirmPayment помечает платёж как confirmed с датой подтверждения +func (db *DB) ConfirmPayment(id int64) error { + _, err := db.conn.Exec( + `UPDATE payments SET status = 'confirmed', confirmed_at = datetime('now') WHERE id = ?`, id, + ) + return err +} + +// ExpireOldPendingPayments помечает протухшие PENDING как expired +func (db *DB) ExpireOldPendingPayments() (int64, error) { + res, err := db.conn.Exec( + `UPDATE payments SET status = 'expired' WHERE status = 'pending' AND expires_at <= datetime('now')`, + ) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// GetConfirmedNotActivated возвращает платежи со статусом confirmed_not_activated +func (db *DB) GetConfirmedNotActivated() ([]Payment, error) { + rows, err := db.conn.Query( + `SELECT id, telegram_id, moderator_id, amount, payment_method, status, platega_transaction_id, redirect_url, expires_at, created_at, confirmed_at + FROM payments WHERE status = 'confirmed_not_activated'`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var payments []Payment + for rows.Next() { + var p Payment + var modID sql.NullInt64 + var txID sql.NullString + var redirectURL sql.NullString + var expiresAt sql.NullTime + var confirmedAt sql.NullTime + + if err := rows.Scan(&p.ID, &p.TelegramID, &modID, &p.Amount, &p.PaymentMethod, &p.Status, &txID, &redirectURL, &expiresAt, &p.CreatedAt, &confirmedAt); err != nil { + return nil, err + } + + if modID.Valid { + p.ModeratorID = &modID.Int64 + } + if txID.Valid { + p.PlategaTransactionID = &txID.String + } + if redirectURL.Valid { + p.RedirectURL = &redirectURL.String + } + if expiresAt.Valid { + p.ExpiresAt = &expiresAt.Time + } + if confirmedAt.Valid { + p.ConfirmedAt = &confirmedAt.Time + } + + payments = append(payments, p) + } + return payments, rows.Err() +} + +// HasConfirmedPayment проверяет, была ли у пользователя хотя бы одна подтверждённая оплата +func (db *DB) HasConfirmedPayment(telegramID int64) (bool, error) { + var exists bool + err := db.conn.QueryRow( + `SELECT EXISTS(SELECT 1 FROM payments WHERE telegram_id = ? AND status = 'confirmed')`, telegramID, + ).Scan(&exists) + return exists, err +} + +// CountConfirmedPaymentsByMonth считает платежи за месяц (для статистики) +func (db *DB) CountConfirmedPaymentsByMonth(year int, month int) (int, error) { + var count int + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + err := db.conn.QueryRow( + `SELECT COUNT(*) FROM payments WHERE status = 'confirmed' AND confirmed_at >= ? AND confirmed_at < ?`, + start, end, + ).Scan(&count) + return count, err +} + +// SumConfirmedPaymentsByMonth возвращает сумму платежей за месяц +func (db *DB) SumConfirmedPaymentsByMonth(year int, month int) (int, error) { + var sum int + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + err := db.conn.QueryRow( + `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE status = 'confirmed' AND confirmed_at >= ? AND confirmed_at < ?`, + start, end, + ).Scan(&sum) + return sum, err +} + +// CountTrialsByMonth считает триалы (активации модераторских инвайтов) за месяц +func (db *DB) CountTrialsByMonth(year int, month int) (int, error) { + var count int + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + err := db.conn.QueryRow( + `SELECT COUNT(*) FROM invites WHERE used_at >= ? AND used_at < ? AND expire_days IS NOT NULL`, + start, end, + ).Scan(&count) + return count, err +} + +// CountFirstPaymentsByMonth считает первые оплаты (конверсия триал→оплата) за месяц +func (db *DB) CountFirstPaymentsByMonth(year int, month int) (int, error) { + var count int + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + // Считаем пользователей, у которых первый confirmed платёж попал в этот месяц + err := db.conn.QueryRow( + `SELECT COUNT(*) FROM ( + SELECT telegram_id, MIN(confirmed_at) as first_payment + FROM payments WHERE status = 'confirmed' + GROUP BY telegram_id + HAVING first_payment >= ? AND first_payment < ? + )`, start, end, + ).Scan(&count) + return count, err +} + +// CountPayingSubscribersByModerator считает активных платящих подписчиков модератора +func (db *DB) CountPayingSubscribersByModerator(moderatorID int64) (int, error) { + var count int + err := db.conn.QueryRow( + `SELECT COUNT(DISTINCT u.telegram_id) FROM users u + JOIN payments p ON p.telegram_id = u.telegram_id + WHERE u.moderator_id = ? AND p.status = 'confirmed' + AND p.confirmed_at >= datetime('now', '-60 days')`, + moderatorID, + ).Scan(&count) + return count, err +} diff --git a/internal/database/payments_test.go b/internal/database/payments_test.go new file mode 100644 index 0000000..6e95a84 --- /dev/null +++ b/internal/database/payments_test.go @@ -0,0 +1,219 @@ +package database + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreatePayment(t *testing.T) { + dbFile := "test_payments.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + p := &Payment{ + TelegramID: 12345, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + } + + id, err := db.CreatePayment(p) + require.NoError(t, err) + assert.Greater(t, id, int64(0)) + + // Получаем созданный платёж + got, err := db.GetPaymentByID(id) + require.NoError(t, err) + require.NotNil(t, got) + + assert.Equal(t, int64(12345), got.TelegramID) + assert.Equal(t, 500, got.Amount) + assert.Equal(t, "sbp", got.PaymentMethod) + assert.Equal(t, "pending", got.Status) + assert.Nil(t, got.ModeratorID) + assert.Nil(t, got.PlategaTransactionID) + assert.Nil(t, got.ConfirmedAt) +} + +func TestGetPendingPayment(t *testing.T) { + dbFile := "test_payments_pending.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + // Нет платежей — возвращает nil + got, err := db.GetPendingPayment(12345) + require.NoError(t, err) + assert.Nil(t, got) + + // Создаём активный PENDING платёж + future := time.Now().UTC().Add(30 * time.Minute) + p := &Payment{ + TelegramID: 12345, + Amount: 500, + PaymentMethod: "card", + Status: "pending", + ExpiresAt: &future, + } + id, err := db.CreatePayment(p) + require.NoError(t, err) + + got, err = db.GetPendingPayment(12345) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, id, got.ID) + + // Протухший PENDING — не возвращается + past := time.Now().UTC().Add(-1 * time.Minute) + expired := &Payment{ + TelegramID: 99999, + Amount: 300, + PaymentMethod: "sbp", + Status: "pending", + ExpiresAt: &past, + } + _, err = db.CreatePayment(expired) + require.NoError(t, err) + + got, err = db.GetPendingPayment(99999) + require.NoError(t, err) + assert.Nil(t, got) +} + +func TestGetPaymentByPlategaTxID(t *testing.T) { + dbFile := "test_payments_tx.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + txID := "platega-tx-abc123" + p := &Payment{ + TelegramID: 12345, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + PlategaTransactionID: &txID, + } + id, err := db.CreatePayment(p) + require.NoError(t, err) + + // Находим по transaction ID + got, err := db.GetPaymentByPlategaTxID(txID) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, id, got.ID) + assert.Equal(t, txID, *got.PlategaTransactionID) + + // Несуществующий — nil + got, err = db.GetPaymentByPlategaTxID("nonexistent") + require.NoError(t, err) + assert.Nil(t, got) +} + +func TestConfirmPayment(t *testing.T) { + dbFile := "test_payments_confirm.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + p := &Payment{ + TelegramID: 12345, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(p) + require.NoError(t, err) + + // Подтверждаем платёж + err = db.ConfirmPayment(id) + require.NoError(t, err) + + got, err := db.GetPaymentByID(id) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "confirmed", got.Status) + assert.NotNil(t, got.ConfirmedAt) +} + +func TestExpireOldPendingPayments(t *testing.T) { + dbFile := "test_payments_expire.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + // Создаём протухший платёж напрямую + past := time.Now().UTC().Add(-2 * time.Minute) + p := &Payment{ + TelegramID: 12345, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + ExpiresAt: &past, + } + id, err := db.CreatePayment(p) + require.NoError(t, err) + + n, err := db.ExpireOldPendingPayments() + require.NoError(t, err) + assert.Equal(t, int64(1), n) + + got, err := db.GetPaymentByID(id) + require.NoError(t, err) + assert.Equal(t, "expired", got.Status) +} + +func TestHasConfirmedPayment(t *testing.T) { + dbFile := "test_payments_has.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + // Без платежей — false + has, err := db.HasConfirmedPayment(12345) + require.NoError(t, err) + assert.False(t, has) + + // Создаём PENDING — всё ещё false + p := &Payment{ + TelegramID: 12345, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(p) + require.NoError(t, err) + + has, err = db.HasConfirmedPayment(12345) + require.NoError(t, err) + assert.False(t, has) + + // Подтверждаем — true + require.NoError(t, db.ConfirmPayment(id)) + has, err = db.HasConfirmedPayment(12345) + require.NoError(t, err) + assert.True(t, has) +} diff --git a/internal/database/users.go b/internal/database/users.go index 10a61d2..c4d625a 100644 --- a/internal/database/users.go +++ b/internal/database/users.go @@ -6,10 +6,10 @@ import ( ) // CreateUser создаёт нового пользователя -func (db *DB) CreateUser(telegramID int64, username, firstName, remnawaveUUID string) (*User, error) { +func (db *DB) CreateUser(telegramID int64, username, firstName, remnawaveUUID string, subscriptionPrice *int, moderatorID *int64) (*User, error) { _, err := db.conn.Exec( - `INSERT INTO users (telegram_id, username, first_name, remnawave_uuid) VALUES (?, ?, ?, ?)`, - telegramID, username, firstName, remnawaveUUID, + `INSERT INTO users (telegram_id, username, first_name, remnawave_uuid, subscription_price, moderator_id) VALUES (?, ?, ?, ?, ?, ?)`, + telegramID, username, firstName, remnawaveUUID, subscriptionPrice, moderatorID, ) if err != nil { return nil, fmt.Errorf("failed to create user: %w", err) @@ -22,10 +22,12 @@ func (db *DB) CreateUser(telegramID int64, username, firstName, remnawaveUUID st func (db *DB) GetUserByTelegramID(telegramID int64) (*User, error) { var user User var firstName sql.NullString + var subPrice sql.NullInt64 + var modID sql.NullInt64 err := db.conn.QueryRow( - `SELECT telegram_id, username, first_name, remnawave_uuid, created_at FROM users WHERE telegram_id = ?`, + `SELECT telegram_id, username, first_name, remnawave_uuid, subscription_price, moderator_id, created_at FROM users WHERE telegram_id = ?`, telegramID, - ).Scan(&user.TelegramID, &user.Username, &firstName, &user.RemnawaveUUID, &user.CreatedAt) + ).Scan(&user.TelegramID, &user.Username, &firstName, &user.RemnawaveUUID, &subPrice, &modID, &user.CreatedAt) if err == sql.ErrNoRows { return nil, nil @@ -37,6 +39,13 @@ func (db *DB) GetUserByTelegramID(telegramID int64) (*User, error) { if firstName.Valid { user.FirstName = firstName.String } + if subPrice.Valid { + v := int(subPrice.Int64) + user.SubscriptionPrice = &v + } + if modID.Valid { + user.ModeratorID = &modID.Int64 + } return &user, nil } @@ -45,10 +54,12 @@ func (db *DB) GetUserByTelegramID(telegramID int64) (*User, error) { func (db *DB) GetUserByRemnawaveUUID(uuid string) (*User, error) { var user User var firstName sql.NullString + var subPrice sql.NullInt64 + var modID sql.NullInt64 err := db.conn.QueryRow( - `SELECT telegram_id, username, first_name, remnawave_uuid, created_at FROM users WHERE remnawave_uuid = ?`, + `SELECT telegram_id, username, first_name, remnawave_uuid, subscription_price, moderator_id, created_at FROM users WHERE remnawave_uuid = ?`, uuid, - ).Scan(&user.TelegramID, &user.Username, &firstName, &user.RemnawaveUUID, &user.CreatedAt) + ).Scan(&user.TelegramID, &user.Username, &firstName, &user.RemnawaveUUID, &subPrice, &modID, &user.CreatedAt) if err == sql.ErrNoRows { return nil, nil @@ -60,6 +71,13 @@ func (db *DB) GetUserByRemnawaveUUID(uuid string) (*User, error) { if firstName.Valid { user.FirstName = firstName.String } + if subPrice.Valid { + v := int(subPrice.Int64) + user.SubscriptionPrice = &v + } + if modID.Valid { + user.ModeratorID = &modID.Int64 + } return &user, nil } @@ -67,7 +85,7 @@ func (db *DB) GetUserByRemnawaveUUID(uuid string) (*User, error) { // GetAllUsers получает всех пользователей func (db *DB) GetAllUsers() ([]User, error) { rows, err := db.conn.Query( - `SELECT telegram_id, username, first_name, remnawave_uuid, created_at FROM users ORDER BY created_at`, + `SELECT telegram_id, username, first_name, remnawave_uuid, subscription_price, moderator_id, created_at FROM users ORDER BY created_at`, ) if err != nil { return nil, fmt.Errorf("failed to query users: %w", err) @@ -78,12 +96,21 @@ func (db *DB) GetAllUsers() ([]User, error) { for rows.Next() { var user User var firstName sql.NullString - if err := rows.Scan(&user.TelegramID, &user.Username, &firstName, &user.RemnawaveUUID, &user.CreatedAt); err != nil { + var subPrice sql.NullInt64 + var modID sql.NullInt64 + if err := rows.Scan(&user.TelegramID, &user.Username, &firstName, &user.RemnawaveUUID, &subPrice, &modID, &user.CreatedAt); err != nil { return nil, fmt.Errorf("failed to scan user: %w", err) } if firstName.Valid { user.FirstName = firstName.String } + if subPrice.Valid { + v := int(subPrice.Int64) + user.SubscriptionPrice = &v + } + if modID.Valid { + user.ModeratorID = &modID.Int64 + } users = append(users, user) } @@ -94,6 +121,12 @@ func (db *DB) GetAllUsers() ([]User, error) { return users, nil } +// UpdateSubscriptionPrice обновляет цену подписки пользователя +func (db *DB) UpdateSubscriptionPrice(telegramID int64, price int) error { + _, err := db.conn.Exec(`UPDATE users SET subscription_price = ? WHERE telegram_id = ?`, price, telegramID) + return err +} + // UpdateUsername обновляет username пользователя func (db *DB) UpdateUsername(telegramID int64, username string) error { _, err := db.conn.Exec( From a379ad7d2a80c0eb278cad75c455e291aef7d3d4 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 11:14:06 +0300 Subject: [PATCH 07/34] =?UTF-8?q?chore:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=D1=8B=20Cre?= =?UTF-8?q?ateUser=20=D0=B2=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D1=85=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=20=D0=BD=D0=BE=D0=B2=D1=83=D1=8E=20=D1=81?= =?UTF-8?q?=D0=B8=D0=B3=D0=BD=D0=B0=D1=82=D1=83=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bot/admin_test.go | 14 +++++++------- internal/bot/handlers_test.go | 4 ++-- internal/bot/moderator_test.go | 22 +++++++++++----------- internal/bot/scheduler_test.go | 4 ++-- internal/database/invites_ext_test.go | 22 +++++++++++----------- internal/database/invites_test.go | 4 ++-- internal/database/moderators_test.go | 12 ++++++------ 7 files changed, 41 insertions(+), 41 deletions(-) diff --git a/internal/bot/admin_test.go b/internal/bot/admin_test.go index 13a9d45..fd1c698 100644 --- a/internal/bot/admin_test.go +++ b/internal/bot/admin_test.go @@ -108,7 +108,7 @@ func TestProcessBanUser_PersistsBanAndKeepsInviteHistory(t *testing.T) { adminID := int64(999999) targetID := int64(12345) - _, err = db.CreateUser(targetID, "target", "Target", "uuid-target") + _, err = db.CreateUser(targetID, "target", "Target", "uuid-target", nil, nil) require.NoError(t, err) inv, err := db.CreateInviteWithExpiry(adminID, nil) require.NoError(t, err) @@ -175,11 +175,11 @@ func TestHandleAdminModStats(t *testing.T) { modID := int64(100) subID := int64(200) - _, err = db.CreateUser(modID, "moderator", "Модератор", "uuid-mod") + _, err = db.CreateUser(modID, "moderator", "Модератор", "uuid-mod", nil, nil) require.NoError(t, err) require.NoError(t, db.AddModerator(modID, adminID)) - _, err = db.CreateUser(subID, "sub", "Subscriber", "uuid-sub") + _, err = db.CreateUser(subID, "sub", "Subscriber", "uuid-sub", nil, nil) require.NoError(t, err) inv, err := db.CreateInviteWithExpiry(modID, intPtrAdmin(30)) require.NoError(t, err) @@ -271,7 +271,7 @@ func TestProcessSwitchSubscriptionID_ValidationErrors(t *testing.T) { adminID := int64(999999) targetID := int64(12345) - _, err = db.CreateUser(targetID, "target", "Target", "uuid-target") + _, err = db.CreateUser(targetID, "target", "Target", "uuid-target", nil, nil) require.NoError(t, err) b := &Bot{ @@ -297,7 +297,7 @@ func TestProcessSwitchSubscriptionID_ValidationErrors(t *testing.T) { t.Run("забанен", func(t *testing.T) { otherID := int64(22334) - _, err := db.CreateUser(otherID, "banned", "Banned", "uuid-banned") + _, err := db.CreateUser(otherID, "banned", "Banned", "uuid-banned", nil, nil) require.NoError(t, err) days := 30 invite, err := db.CreateInviteWithExpiry(777, &days) @@ -329,9 +329,9 @@ func TestProcessSwitchSubscription_ConfirmFlow(t *testing.T) { modID := int64(100) targetID := int64(12345) - _, err = db.CreateUser(modID, "moderator", "Moderator", "uuid-mod") + _, err = db.CreateUser(modID, "moderator", "Moderator", "uuid-mod", nil, nil) require.NoError(t, err) - _, err = db.CreateUser(targetID, "target", "Target", "uuid-target") + _, err = db.CreateUser(targetID, "target", "Target", "uuid-target", nil, nil) require.NoError(t, err) days := 30 diff --git a/internal/bot/handlers_test.go b/internal/bot/handlers_test.go index f22c915..54d71e0 100644 --- a/internal/bot/handlers_test.go +++ b/internal/bot/handlers_test.go @@ -88,7 +88,7 @@ func TestHandleStart(t *testing.T) { t.Run("ExistingUser", func(t *testing.T) { userID := int64(222) - _, err := db.CreateUser(userID, "olduser", "OldFirstName", "uuid-123") + _, err := db.CreateUser(userID, "olduser", "OldFirstName", "uuid-123", nil, nil) assert.NoError(t, err) user := &tele.User{ID: userID, Username: "olduser"} @@ -114,7 +114,7 @@ func TestHandleStart(t *testing.T) { t.Run("ExistingUserWithPayload_IgnoresCode", func(t *testing.T) { // Существующий пользователь с payload — код игнорируется, не расходуется userID := int64(333) - _, err := db.CreateUser(userID, "existing", "Existing", "uuid-333") + _, err := db.CreateUser(userID, "existing", "Existing", "uuid-333", nil, nil) require.NoError(t, err) // Создаём инвайт diff --git a/internal/bot/moderator_test.go b/internal/bot/moderator_test.go index 5fb9eb8..cddf5fe 100644 --- a/internal/bot/moderator_test.go +++ b/internal/bot/moderator_test.go @@ -40,7 +40,7 @@ func setupModeratorTestBot(t *testing.T) (*Bot, *database.DB, int64, int64) { } // Создаём пользователя-модератора - _, err = db.CreateUser(modID, "moderator", "Модератор", "uuid-mod") + _, err = db.CreateUser(modID, "moderator", "Модератор", "uuid-mod", nil, nil) require.NoError(t, err) err = db.AddModerator(modID, adminID) require.NoError(t, err) @@ -222,14 +222,14 @@ func TestHandleModSubscribers(t *testing.T) { b, db, _, modID := setupModeratorTestBot(t) // Активный подписчик - _, err := db.CreateUser(300, "alive", "Alive", "uuid-300") + _, err := db.CreateUser(300, "alive", "Alive", "uuid-300", nil, nil) require.NoError(t, err) inv1, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) require.NoError(t, err) require.NoError(t, db.ClaimInvite(inv1.Code, 300)) // Удалённый подписчик - _, err = db.CreateUser(301, "gone", "Gone", "uuid-301") + _, err = db.CreateUser(301, "gone", "Gone", "uuid-301", nil, nil) require.NoError(t, err) inv2, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) require.NoError(t, err) @@ -284,7 +284,7 @@ func TestHandleModSubscribers(t *testing.T) { func TestHandleModExtend_StartsDialog(t *testing.T) { b, db, _, modID := setupModeratorTestBot(t) - _, err := db.CreateUser(300, "alive", "Alive", "uuid-300") + _, err := db.CreateUser(300, "alive", "Alive", "uuid-300", nil, nil) require.NoError(t, err) inv, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) require.NoError(t, err) @@ -339,7 +339,7 @@ func TestHandleTextMessage_ModeratorButtons(t *testing.T) { }) t.Run("Кнопка_Продлить_ставит_состояние", func(t *testing.T) { - _, err := db.CreateUser(8080, "sub8080", "Sub", "uuid-8080") + _, err := db.CreateUser(8080, "sub8080", "Sub", "uuid-8080", nil, nil) require.NoError(t, err) inv, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) require.NoError(t, err) @@ -361,7 +361,7 @@ func TestAdminAddModerator(t *testing.T) { b, db, adminID, _ := setupModeratorTestBot(t) // Создаём нового пользователя для назначения - _, err := db.CreateUser(200, "newmod", "Новый", "uuid-200") + _, err := db.CreateUser(200, "newmod", "Новый", "uuid-200", nil, nil) require.NoError(t, err) admin := &tele.User{ID: adminID, Username: "admin"} @@ -410,7 +410,7 @@ func TestAdminAddModerator_NotRegistered(t *testing.T) { func TestAdminAddModerator_RejectsMonthlyInviteUser(t *testing.T) { b, db, adminID, modID := setupModeratorTestBot(t) - _, err := db.CreateUser(201, "monthly_user", "Месячный", "uuid-201") + _, err := db.CreateUser(201, "monthly_user", "Месячный", "uuid-201", nil, nil) require.NoError(t, err) inv, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) require.NoError(t, err) @@ -532,7 +532,7 @@ func TestProcessModExtendID_ClearsStateOnTerminalErrors(t *testing.T) { }) t.Run("подписка слишком далеко в будущем очищает состояние", func(t *testing.T) { - _, err := db.CreateUser(9002, "future", "Future", "uuid-9002") + _, err := db.CreateUser(9002, "future", "Future", "uuid-9002", nil, nil) require.NoError(t, err) inv, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) require.NoError(t, err) @@ -580,13 +580,13 @@ func TestHandleModSubscribers_UsesBatchAPI(t *testing.T) { b, db, _, modID := setupModeratorTestBot(t) // Создаём двух подписчиков - _, err := db.CreateUser(310, "alice", "Alice", "uuid-310") + _, err := db.CreateUser(310, "alice", "Alice", "uuid-310", nil, nil) require.NoError(t, err) inv1, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) require.NoError(t, err) require.NoError(t, db.ClaimInvite(inv1.Code, 310)) - _, err = db.CreateUser(311, "bob", "Bob", "uuid-311") + _, err = db.CreateUser(311, "bob", "Bob", "uuid-311", nil, nil) require.NoError(t, err) inv2, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) require.NoError(t, err) @@ -654,7 +654,7 @@ func TestProcessModExtendConfirm_ClearsStateOnExtendError(t *testing.T) { b, db, _, modID := setupModeratorTestBot(t) // Создаём подписчика - _, err := db.CreateUser(400, "sub400", "Sub", "uuid-400") + _, err := db.CreateUser(400, "sub400", "Sub", "uuid-400", nil, nil) require.NoError(t, err) inv, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) require.NoError(t, err) diff --git a/internal/bot/scheduler_test.go b/internal/bot/scheduler_test.go index 0af2ca0..7a0774a 100644 --- a/internal/bot/scheduler_test.go +++ b/internal/bot/scheduler_test.go @@ -42,10 +42,10 @@ func TestHandleAutoKick_404IsNotFatalError(t *testing.T) { b, db := setupSchedulerTestBot(t) // Создаём пользователя в БД бота - _, err := db.CreateUser(700, "victim", "Victim", "uuid-700") + _, err := db.CreateUser(700, "victim", "Victim", "uuid-700", nil, nil) require.NoError(t, err) modID := int64(50) - _, err = db.CreateUser(modID, "mod", "Mod", "uuid-mod") + _, err = db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) require.NoError(t, err) expireDays := 30 inv, err := db.CreateInviteWithExpiry(modID, &expireDays) diff --git a/internal/database/invites_ext_test.go b/internal/database/invites_ext_test.go index 7e7815e..ebebdd3 100644 --- a/internal/database/invites_ext_test.go +++ b/internal/database/invites_ext_test.go @@ -88,14 +88,14 @@ func TestGetSubscribersByModerator(t *testing.T) { db := setupTestDBInvites(t) // Подписчик, который остался в users - _, err := db.CreateUser(300, "alive", "Alive", "uuid-300") + _, err := db.CreateUser(300, "alive", "Alive", "uuid-300", nil, nil) require.NoError(t, err) inv1, err := db.CreateInviteWithExpiry(100, intPtr(30)) require.NoError(t, err) require.NoError(t, db.ClaimInvite(inv1.Code, 300)) // Подписчик, удалённый из users - _, err = db.CreateUser(301, "gone", "Gone", "uuid-301") + _, err = db.CreateUser(301, "gone", "Gone", "uuid-301", nil, nil) require.NoError(t, err) inv2, err := db.CreateInviteWithExpiry(100, intPtr(30)) require.NoError(t, err) @@ -179,9 +179,9 @@ func TestGetInvitesWithUsersByCreator(t *testing.T) { db := setupTestDBInvites(t) // Создаём двух модераторов - _, err := db.CreateUser(100, "mod1", "Модератор1", "uuid-100") + _, err := db.CreateUser(100, "mod1", "Модератор1", "uuid-100", nil, nil) require.NoError(t, err) - _, err = db.CreateUser(200, "mod2", "Модератор2", "uuid-200") + _, err = db.CreateUser(200, "mod2", "Модератор2", "uuid-200", nil, nil) require.NoError(t, err) // Создаём инвайты от разных авторов @@ -193,7 +193,7 @@ func TestGetInvitesWithUsersByCreator(t *testing.T) { require.NoError(t, err) // Активируем один инвайт от mod1 - _, err = db.CreateUser(300, "user300", "Юзер", "uuid-300") + _, err = db.CreateUser(300, "user300", "Юзер", "uuid-300", nil, nil) require.NoError(t, err) err = db.UseInvite(inv1.Code, 300) require.NoError(t, err) @@ -232,9 +232,9 @@ func TestGetInvitesWithUsersByCreator(t *testing.T) { func TestDeleteUnusedInviteByOwner(t *testing.T) { db := setupTestDBInvites(t) - _, err := db.CreateUser(100, "mod1", "Мод1", "uuid-100") + _, err := db.CreateUser(100, "mod1", "Мод1", "uuid-100", nil, nil) require.NoError(t, err) - _, err = db.CreateUser(200, "mod2", "Мод2", "uuid-200") + _, err = db.CreateUser(200, "mod2", "Мод2", "uuid-200", nil, nil) require.NoError(t, err) inv1, err := db.CreateInvite(100) @@ -260,7 +260,7 @@ func TestDeleteUnusedInviteByOwner(t *testing.T) { t.Run("Удаление использованного кода — ошибка", func(t *testing.T) { usedInv, err := db.CreateInvite(100) require.NoError(t, err) - _, err = db.CreateUser(300, "user300", "Юзер", "uuid-300") + _, err = db.CreateUser(300, "user300", "Юзер", "uuid-300", nil, nil) require.NoError(t, err) err = db.UseInvite(usedInv.Code, 300) require.NoError(t, err) @@ -278,9 +278,9 @@ func TestDeleteUnusedInviteByOwner(t *testing.T) { func TestDeleteUnusedInvitesByCreator(t *testing.T) { db := setupTestDBInvites(t) - _, err := db.CreateUser(100, "mod1", "Мод1", "uuid-100") + _, err := db.CreateUser(100, "mod1", "Мод1", "uuid-100", nil, nil) require.NoError(t, err) - _, err = db.CreateUser(200, "mod2", "Мод2", "uuid-200") + _, err = db.CreateUser(200, "mod2", "Мод2", "uuid-200", nil, nil) require.NoError(t, err) // Создаём несколько инвайтов от mod1 @@ -292,7 +292,7 @@ func TestDeleteUnusedInvitesByCreator(t *testing.T) { require.NoError(t, err) // Активируем один инвайт от mod1 - _, err = db.CreateUser(300, "user300", "Юзер", "uuid-300") + _, err = db.CreateUser(300, "user300", "Юзер", "uuid-300", nil, nil) require.NoError(t, err) err = db.UseInvite(inv1.Code, 300) require.NoError(t, err) diff --git a/internal/database/invites_test.go b/internal/database/invites_test.go index 0b20fd1..319354e 100644 --- a/internal/database/invites_test.go +++ b/internal/database/invites_test.go @@ -72,7 +72,7 @@ func TestReconcileOrphanedInvites_SkipsValidClaims(t *testing.T) { }() // Создаём реального пользователя - _, err = db.CreateUser(111, "user111", "User", "uuid-111") + _, err = db.CreateUser(111, "user111", "User", "uuid-111", nil, nil) require.NoError(t, err) // Создаём инвайт и claim-им его — пользователь есть в users @@ -103,7 +103,7 @@ func TestReconcileOrphanedInvites_SkipsBannedUserInvites(t *testing.T) { }() // Создаём пользователя, claim-им его инвайт, потом "баним" (удаляем из users) - _, err = db.CreateUser(111, "user111", "User", "uuid-111") + _, err = db.CreateUser(111, "user111", "User", "uuid-111", nil, nil) require.NoError(t, err) invite, err := db.CreateInvite(999) diff --git a/internal/database/moderators_test.go b/internal/database/moderators_test.go index d0c1244..79240ca 100644 --- a/internal/database/moderators_test.go +++ b/internal/database/moderators_test.go @@ -37,7 +37,7 @@ func TestAddModerator(t *testing.T) { db := setupTestDB(t) // Создаём пользователя (модератор должен быть зарегистрированным пользователем) - _, err := db.CreateUser(100, "testmod", "Тест", "uuid-mod-1") + _, err := db.CreateUser(100, "testmod", "Тест", "uuid-mod-1", nil, nil) require.NoError(t, err) t.Run("Успешное назначение", func(t *testing.T) { @@ -59,7 +59,7 @@ func TestAddModerator(t *testing.T) { func TestIsModerator(t *testing.T) { db := setupTestDB(t) - _, err := db.CreateUser(200, "user200", "Юзер", "uuid-200") + _, err := db.CreateUser(200, "user200", "Юзер", "uuid-200", nil, nil) require.NoError(t, err) t.Run("Не модератор", func(t *testing.T) { @@ -87,7 +87,7 @@ func TestIsModerator(t *testing.T) { func TestGetModerator(t *testing.T) { db := setupTestDB(t) - _, err := db.CreateUser(300, "mod300", "Мод", "uuid-300") + _, err := db.CreateUser(300, "mod300", "Мод", "uuid-300", nil, nil) require.NoError(t, err) t.Run("Не найден", func(t *testing.T) { @@ -118,9 +118,9 @@ func TestGetAllModerators(t *testing.T) { }) t.Run("Несколько модераторов", func(t *testing.T) { - _, err := db.CreateUser(400, "mod1", "Первый", "uuid-400") + _, err := db.CreateUser(400, "mod1", "Первый", "uuid-400", nil, nil) require.NoError(t, err) - _, err = db.CreateUser(401, "mod2", "Второй", "uuid-401") + _, err = db.CreateUser(401, "mod2", "Второй", "uuid-401", nil, nil) require.NoError(t, err) err = db.AddModerator(400, 999) @@ -137,7 +137,7 @@ func TestGetAllModerators(t *testing.T) { func TestRemoveModerator(t *testing.T) { db := setupTestDB(t) - _, err := db.CreateUser(500, "mod500", "Мод", "uuid-500") + _, err := db.CreateUser(500, "mod500", "Мод", "uuid-500", nil, nil) require.NoError(t, err) err = db.AddModerator(500, 999) require.NoError(t, err) From 203e87b81f02282ea0651b814b0ec510cad08205 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 11:18:59 +0300 Subject: [PATCH 08/34] =?UTF-8?q?feat:=20=D1=8D=D1=82=D0=B0=D0=BF=202=20?= =?UTF-8?q?=E2=80=94=20Platega=20HTTP-=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=20=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/progress/2026-03-23-payment-stage-2.md | 54 +++++ internal/config/config.go | 50 ++++- internal/config/config_test.go | 44 ++++ internal/platega/client.go | 233 ++++++++++++++++++++ internal/platega/client_test.go | 176 +++++++++++++++ 5 files changed, 547 insertions(+), 10 deletions(-) create mode 100644 docs/progress/2026-03-23-payment-stage-2.md create mode 100644 internal/platega/client.go create mode 100644 internal/platega/client_test.go diff --git a/docs/progress/2026-03-23-payment-stage-2.md b/docs/progress/2026-03-23-payment-stage-2.md new file mode 100644 index 0000000..3fc62cd --- /dev/null +++ b/docs/progress/2026-03-23-payment-stage-2.md @@ -0,0 +1,54 @@ +# Прогресс: Этап 2 — Platega HTTP-клиент + +**План:** [docs/plans/2026-03-22-payment-implementation-plan.md](../plans/2026-03-22-payment-implementation-plan.md) (строки 501–806) + +**Дата:** 2026-03-23 + +## Что сделано + +### Шаг 1: Конфигурация Platega в `config.go` + +Добавлены поля в структуру `Config`: +- `PlategaMerchantID`, `PlategaSecret`, `PlategaCallbackURL` — строки +- `MinSubscriptionPrice` (default: 400), `TrialTrafficLimitGB` (default: 1) +- `PlategaFeeSBP` (default: 11), `PlategaFeeCard` (default: 12), `PlategaFeeCrypto` (default: 5), `PlategaFeeWithdrawal` (default: 2) + +Добавлен хелпер `getEnvOrDefaultInt` для чтения int из env с default-значением. + +### Шаг 2: `internal/platega/client.go` + +Создан HTTP-клиент с: +- `NewClient(merchantID, secret)` — production клиент +- `NewClientWithBaseURL(merchantID, secret, baseURL)` — для тестов с httptest.Server +- `CreatePayment(req)` — POST /transaction/process +- `GetTransactionStatus(id)` — GET /transaction/{id} +- `MerchantID()`, `Secret()` — геттеры для верификации callback +- Константы `PaymentMethodSBP/Card/Crypto`, `StatusPending/Confirmed/Canceled/Chargebacked` +- Хелперы `PaymentMethodName`, `PaymentMethodString`, `PaymentMethodFromString` +- Типы `CreateTransactionRequest`, `CreateTransactionResponse`, `TransactionStatus`, `CallbackPayload` + +### Шаг 3: `internal/platega/client_test.go` + +Написаны тесты (TDD: сначала тест, потом реализация): +- `TestPaymentMethodConversion` — конвертация int ↔ string для всех 3 способов +- `TestPaymentMethodConversionUnknown` — обработка неизвестных значений +- `TestClientHeaders` — проверка X-MerchantId и X-Secret заголовков через httptest +- `TestCreatePayment` — мок-сервер, проверка метода/пути/тела/ответа +- `TestCreatePaymentError` — обработка 401 ошибки +- `TestGetTransactionStatus` — мок GET запроса и парсинга ответа +- `TestGetTransactionStatusNotFound` — обработка 404 +- `TestClientMerchantAndSecretAccessors` — геттеры + +## Критерии приёмки + +- [x] Platega-клиент компилируется и тесты проходят (`make tests` — ok) +- [x] Конфигурация расширена новыми переменными (все опциональные) +- [x] Бот запускается без PLATEGA_* переменных (клиент не создаётся) +- [x] `make fmt` без ошибок + +## Изменённые файлы + +- `internal/config/config.go` — поля Platega + хелпер getEnvOrDefaultInt +- `internal/config/config_test.go` — тесты TestLoadPlategaConfig +- `internal/platega/client.go` — создан +- `internal/platega/client_test.go` — создан diff --git a/internal/config/config.go b/internal/config/config.go index d430e09..321167f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,17 @@ type Config struct { // Render-сервис (субтитры) RenderURL string // URL render-сервиса (опционально) RenderAPIKey string // API-ключ для render-сервиса + + // Platega — платёжная система (опционально, отключена если не заданы) + PlategaMerchantID string + PlategaSecret string + PlategaCallbackURL string // Полный URL для callback (https://domain.com/platega/callback) + MinSubscriptionPrice int // Минимальная цена подписки (руб), по умолчанию 400 + TrialTrafficLimitGB int // Лимит трафика триала (ГБ), по умолчанию 1 + PlategaFeeSBP int // Комиссия Platega СБП (%), по умолчанию 11 + PlategaFeeCard int // Комиссия Platega карты (%), по умолчанию 12 + PlategaFeeCrypto int // Комиссия Platega крипта (%), по умолчанию 5 + PlategaFeeWithdrawal int // Комиссия вывода (%), по умолчанию 2 } // Load читает конфигурацию из переменных окружения @@ -41,16 +52,25 @@ func Load() (*Config, error) { _ = godotenv.Load() cfg := &Config{ - BotToken: os.Getenv("BOT_TOKEN"), - RemnawaveURL: os.Getenv("REMNAWAVE_URL"), - RemnawaveAPIToken: os.Getenv("REMNAWAVE_API_TOKEN"), - RemnawaveSquadUUIDs: getRemnawaveSquadUUIDs(), - DBPath: getEnvOrDefault("DB_PATH", "/app/data/bot.db"), - DonateText: os.Getenv("DONATE_TEXT"), - SDConfigsPath: getEnvOrDefault("SD_CONFIGS_PATH", "/app/sd_configs"), - VictoriaMetricsURL: getEnvOrDefault("VICTORIA_METRICS_URL", "http://victoriametrics:8428"), - RenderURL: os.Getenv("RENDER_URL"), - RenderAPIKey: os.Getenv("RENDER_API_KEY"), + BotToken: os.Getenv("BOT_TOKEN"), + RemnawaveURL: os.Getenv("REMNAWAVE_URL"), + RemnawaveAPIToken: os.Getenv("REMNAWAVE_API_TOKEN"), + RemnawaveSquadUUIDs: getRemnawaveSquadUUIDs(), + DBPath: getEnvOrDefault("DB_PATH", "/app/data/bot.db"), + DonateText: os.Getenv("DONATE_TEXT"), + SDConfigsPath: getEnvOrDefault("SD_CONFIGS_PATH", "/app/sd_configs"), + VictoriaMetricsURL: getEnvOrDefault("VICTORIA_METRICS_URL", "http://victoriametrics:8428"), + RenderURL: os.Getenv("RENDER_URL"), + RenderAPIKey: os.Getenv("RENDER_API_KEY"), + PlategaMerchantID: os.Getenv("PLATEGA_MERCHANT_ID"), + PlategaSecret: os.Getenv("PLATEGA_SECRET"), + PlategaCallbackURL: os.Getenv("PLATEGA_CALLBACK_URL"), + MinSubscriptionPrice: getEnvOrDefaultInt("MIN_SUBSCRIPTION_PRICE", 400), + TrialTrafficLimitGB: getEnvOrDefaultInt("TRIAL_TRAFFIC_LIMIT_GB", 1), + PlategaFeeSBP: getEnvOrDefaultInt("PLATEGA_FEE_SBP", 11), + PlategaFeeCard: getEnvOrDefaultInt("PLATEGA_FEE_CARD", 12), + PlategaFeeCrypto: getEnvOrDefaultInt("PLATEGA_FEE_CRYPTO", 5), + PlategaFeeWithdrawal: getEnvOrDefaultInt("PLATEGA_FEE_WITHDRAWAL", 2), } // Парсинг AdminID @@ -74,6 +94,16 @@ func Load() (*Config, error) { return cfg, nil } +// getEnvOrDefaultInt возвращает int-значение переменной окружения или значение по умолчанию +func getEnvOrDefaultInt(key string, defaultValue int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return defaultValue +} + // getEnvOrDefault возвращает значение переменной окружения или значение по умолчанию func getEnvOrDefault(key, defaultValue string) string { if value := os.Getenv(key); value != "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cee74bf..4e25856 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -28,6 +28,50 @@ func TestLoadRemnawaveSquadUUIDs(t *testing.T) { }) } +func TestLoadPlategaConfig(t *testing.T) { + setRequiredEnv(t) + + t.Run("использует значения по умолчанию если переменные не заданы", func(t *testing.T) { + t.Setenv("PLATEGA_MERCHANT_ID", "") + t.Setenv("PLATEGA_SECRET", "") + t.Setenv("PLATEGA_CALLBACK_URL", "") + t.Setenv("MIN_SUBSCRIPTION_PRICE", "") + t.Setenv("TRIAL_TRAFFIC_LIMIT_GB", "") + t.Setenv("PLATEGA_FEE_SBP", "") + t.Setenv("PLATEGA_FEE_CARD", "") + t.Setenv("PLATEGA_FEE_CRYPTO", "") + t.Setenv("PLATEGA_FEE_WITHDRAWAL", "") + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, "", cfg.PlategaMerchantID) + require.Equal(t, "", cfg.PlategaSecret) + require.Equal(t, "", cfg.PlategaCallbackURL) + require.Equal(t, 400, cfg.MinSubscriptionPrice) + require.Equal(t, 1, cfg.TrialTrafficLimitGB) + require.Equal(t, 11, cfg.PlategaFeeSBP) + require.Equal(t, 12, cfg.PlategaFeeCard) + require.Equal(t, 5, cfg.PlategaFeeCrypto) + require.Equal(t, 2, cfg.PlategaFeeWithdrawal) + }) + + t.Run("читает Platega-переменные из окружения", func(t *testing.T) { + t.Setenv("PLATEGA_MERCHANT_ID", "merchant-123") + t.Setenv("PLATEGA_SECRET", "secret-abc") + t.Setenv("PLATEGA_CALLBACK_URL", "https://example.com/platega/callback") + t.Setenv("MIN_SUBSCRIPTION_PRICE", "500") + t.Setenv("PLATEGA_FEE_SBP", "9") + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, "merchant-123", cfg.PlategaMerchantID) + require.Equal(t, "secret-abc", cfg.PlategaSecret) + require.Equal(t, "https://example.com/platega/callback", cfg.PlategaCallbackURL) + require.Equal(t, 500, cfg.MinSubscriptionPrice) + require.Equal(t, 9, cfg.PlategaFeeSBP) + }) +} + func setRequiredEnv(t *testing.T) { t.Helper() t.Setenv("BOT_TOKEN", "test-token") diff --git a/internal/platega/client.go b/internal/platega/client.go new file mode 100644 index 0000000..09f3364 --- /dev/null +++ b/internal/platega/client.go @@ -0,0 +1,233 @@ +package platega + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const defaultBaseURL = "https://app.platega.io" + +// Способы оплаты (Platega paymentMethod int) +const ( + PaymentMethodSBP = 2 + PaymentMethodCard = 11 + PaymentMethodCrypto = 13 +) + +// Статусы платежа +const ( + StatusPending = "PENDING" + StatusConfirmed = "CONFIRMED" + StatusCanceled = "CANCELED" + StatusChargebacked = "CHARGEBACKED" +) + +// Client — HTTP-клиент Platega API +type Client struct { + merchantID string + secret string + baseURL string + http *http.Client +} + +// NewClient создаёт клиент Platega с production URL +func NewClient(merchantID, secret string) *Client { + return NewClientWithBaseURL(merchantID, secret, defaultBaseURL) +} + +// NewClientWithBaseURL создаёт клиент Platega с заданным базовым URL (для тестов) +func NewClientWithBaseURL(merchantID, secret, baseURL string) *Client { + return &Client{ + merchantID: merchantID, + secret: secret, + baseURL: baseURL, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// MerchantID возвращает merchant_id (для верификации callback) +func (c *Client) MerchantID() string { + return c.merchantID +} + +// Secret возвращает secret (для верификации callback) +func (c *Client) Secret() string { + return c.secret +} + +// CreateTransactionRequest — запрос на создание платежа +type CreateTransactionRequest struct { + PaymentMethod int `json:"paymentMethod"` + Amount int `json:"amount"` // В рублях (целое число) + Currency string `json:"currency"` // "RUB" + Description string `json:"description"` + ReturnURL string `json:"return"` // URL возврата после оплаты (бот Telegram) + FailedURL string `json:"failedUrl"` // URL при ошибке + CallbackURL string `json:"callbackUrl"` // URL для callback + Payload string `json:"payload"` // Произвольные данные (telegram_id) +} + +// CreateTransactionResponse — ответ на создание платежа +type CreateTransactionResponse struct { + TransactionID string `json:"transactionId"` + Redirect string `json:"redirect"` // Ссылка для перенаправления пользователя + Status string `json:"status"` + ExpiresIn int `json:"expiresIn"` // Время жизни в секундах +} + +// TransactionStatus — полный статус транзакции +type TransactionStatus struct { + ID string `json:"id"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` + PaymentMethod int `json:"paymentMethod"` + Payload string `json:"payload"` +} + +// CallbackPayload — тело callback-запроса от Platega. +// Используется и в platega-клиенте, и в callback-сервере (импортируется оттуда). +type CallbackPayload struct { + ID string `json:"id"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` + PaymentMethod int `json:"paymentMethod"` + Payload string `json:"payload"` +} + +// CreatePayment создаёт платёж в Platega +func (c *Client) CreatePayment(req CreateTransactionRequest) (*CreateTransactionResponse, error) { + // Формируем тело запроса согласно API + body := map[string]interface{}{ + "paymentMethod": req.PaymentMethod, + "paymentDetails": map[string]interface{}{ + "amount": req.Amount, + "currency": req.Currency, + }, + "description": req.Description, + "return": req.ReturnURL, + "failedUrl": req.FailedURL, + "callbackUrl": req.CallbackURL, + "payload": req.Payload, + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + httpReq, err := http.NewRequest("POST", c.baseURL+"/transaction/process", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + c.setHeaders(httpReq) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("platega API error %d: %s", resp.StatusCode, string(respBody)) + } + + var result CreateTransactionResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + + return &result, nil +} + +// GetTransactionStatus проверяет статус транзакции +func (c *Client) GetTransactionStatus(transactionID string) (*TransactionStatus, error) { + httpReq, err := http.NewRequest("GET", c.baseURL+"/transaction/"+transactionID, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + c.setHeaders(httpReq) + + resp, err := c.http.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("platega API error %d: %s", resp.StatusCode, string(respBody)) + } + + var result TransactionStatus + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + + return &result, nil +} + +// setHeaders устанавливает авторизационные заголовки +func (c *Client) setHeaders(req *http.Request) { + req.Header.Set("X-MerchantId", c.merchantID) + req.Header.Set("X-Secret", c.secret) +} + +// PaymentMethodName возвращает человекочитаемое название способа оплаты +func PaymentMethodName(method int) string { + switch method { + case PaymentMethodSBP: + return "СБП" + case PaymentMethodCard: + return "Карта" + case PaymentMethodCrypto: + return "Крипта" + default: + return "Неизвестно" + } +} + +// PaymentMethodString возвращает строковый идентификатор для БД +func PaymentMethodString(method int) string { + switch method { + case PaymentMethodSBP: + return "sbp" + case PaymentMethodCard: + return "card" + case PaymentMethodCrypto: + return "crypto" + default: + return "unknown" + } +} + +// PaymentMethodFromString возвращает int из строкового идентификатора +func PaymentMethodFromString(s string) int { + switch s { + case "sbp": + return PaymentMethodSBP + case "card": + return PaymentMethodCard + case "crypto": + return PaymentMethodCrypto + default: + return 0 + } +} diff --git a/internal/platega/client_test.go b/internal/platega/client_test.go new file mode 100644 index 0000000..8988b24 --- /dev/null +++ b/internal/platega/client_test.go @@ -0,0 +1,176 @@ +package platega_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/fus1ond/vpn_bot/internal/platega" + "github.com/stretchr/testify/require" +) + +// TestPaymentMethodConversion проверяет конвертацию между int и строковым идентификатором +func TestPaymentMethodConversion(t *testing.T) { + tests := []struct { + method int + name string + strID string + }{ + {platega.PaymentMethodSBP, "СБП", "sbp"}, + {platega.PaymentMethodCard, "Карта", "card"}, + {platega.PaymentMethodCrypto, "Крипта", "crypto"}, + } + + for _, tt := range tests { + t.Run(tt.strID, func(t *testing.T) { + require.Equal(t, tt.name, platega.PaymentMethodName(tt.method)) + require.Equal(t, tt.strID, platega.PaymentMethodString(tt.method)) + require.Equal(t, tt.method, platega.PaymentMethodFromString(tt.strID)) + }) + } +} + +// TestPaymentMethodConversionUnknown проверяет обработку неизвестных способов оплаты +func TestPaymentMethodConversionUnknown(t *testing.T) { + require.Equal(t, "Неизвестно", platega.PaymentMethodName(999)) + require.Equal(t, "unknown", platega.PaymentMethodString(999)) + require.Equal(t, 0, platega.PaymentMethodFromString("unknown_method")) +} + +// TestClientHeaders проверяет, что клиент устанавливает правильные заголовки авторизации +func TestClientHeaders(t *testing.T) { + var receivedMerchantID, receivedSecret string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMerchantID = r.Header.Get("X-MerchantId") + receivedSecret = r.Header.Get("X-Secret") + // Возвращаем минимальный валидный ответ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "tx-123", + "status": "PENDING", + }) + })) + defer server.Close() + + client := platega.NewClientWithBaseURL("merchant-id-test", "secret-test", server.URL) + _, _ = client.GetTransactionStatus("tx-123") + + require.Equal(t, "merchant-id-test", receivedMerchantID) + require.Equal(t, "secret-test", receivedSecret) +} + +// TestCreatePayment проверяет создание платежа через мок-сервер +func TestCreatePayment(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + require.Equal(t, "/transaction/process", r.URL.Path) + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var body map[string]interface{} + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, float64(2), body["paymentMethod"]) // СБП = 2 + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "transactionId": "tx-abc", + "redirect": "https://pay.platega.io/tx-abc", + "status": "PENDING", + "expiresIn": 900, + }) + })) + defer server.Close() + + client := platega.NewClientWithBaseURL("merchant", "secret", server.URL) + resp, err := client.CreatePayment(platega.CreateTransactionRequest{ + PaymentMethod: platega.PaymentMethodSBP, + Amount: 500, + Currency: "RUB", + Description: "VPN подписка", + ReturnURL: "https://t.me/bot", + FailedURL: "https://t.me/bot", + CallbackURL: "https://example.com/callback", + Payload: "123456", + }) + + require.NoError(t, err) + require.Equal(t, "tx-abc", resp.TransactionID) + require.Equal(t, "https://pay.platega.io/tx-abc", resp.Redirect) + require.Equal(t, "PENDING", resp.Status) + require.Equal(t, 900, resp.ExpiresIn) +} + +// TestCreatePaymentError проверяет обработку ошибки от API +func TestCreatePaymentError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error": "unauthorized"}`)) + })) + defer server.Close() + + client := platega.NewClientWithBaseURL("wrong", "wrong", server.URL) + _, err := client.CreatePayment(platega.CreateTransactionRequest{ + PaymentMethod: platega.PaymentMethodSBP, + Amount: 500, + Currency: "RUB", + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "401") +} + +// TestGetTransactionStatus проверяет получение статуса транзакции +func TestGetTransactionStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "GET", r.Method) + require.Equal(t, "/transaction/tx-xyz", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "tx-xyz", + "amount": "500", + "currency": "RUB", + "status": "CONFIRMED", + "paymentMethod": 2, + "payload": "789012", + }) + })) + defer server.Close() + + client := platega.NewClientWithBaseURL("merchant", "secret", server.URL) + status, err := client.GetTransactionStatus("tx-xyz") + + require.NoError(t, err) + require.Equal(t, "tx-xyz", status.ID) + require.Equal(t, "500", status.Amount) + require.Equal(t, "CONFIRMED", status.Status) + require.Equal(t, 2, status.PaymentMethod) + require.Equal(t, "789012", status.Payload) +} + +// TestGetTransactionStatusNotFound проверяет обработку 404 +func TestGetTransactionStatusNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error": "not found"}`)) + })) + defer server.Close() + + client := platega.NewClientWithBaseURL("merchant", "secret", server.URL) + _, err := client.GetTransactionStatus("nonexistent") + + require.Error(t, err) + require.Contains(t, err.Error(), "404") +} + +// TestClientMerchantAndSecretAccessors проверяет геттеры merchant_id и secret +func TestClientMerchantAndSecretAccessors(t *testing.T) { + client := platega.NewClient("my-merchant", "my-secret") + + require.Equal(t, "my-merchant", client.MerchantID()) + require.Equal(t, "my-secret", client.Secret()) +} From 388000f982120a92fb91ea8abf47a7af04ecf58c Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 11:26:24 +0300 Subject: [PATCH 09/34] =?UTF-8?q?feat:=20=D1=8D=D1=82=D0=B0=D0=BF=203=20?= =?UTF-8?q?=E2=80=94=20callback=20HTTP-=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/bot/main.go | 37 ++++++ docker-compose.yml | 2 + docs/progress/2026-03-23-payment-stage-3.md | 41 ++++++ internal/callback/server.go | 130 +++++++++++++++++++ internal/callback/server_test.go | 137 ++++++++++++++++++++ internal/config/config.go | 2 + 6 files changed, 349 insertions(+) create mode 100644 docs/progress/2026-03-23-payment-stage-3.md create mode 100644 internal/callback/server.go create mode 100644 internal/callback/server_test.go diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 70b7a4a..5e41a6f 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -3,14 +3,18 @@ package main import ( "context" "log/slog" + "net/http" "os" "os/signal" "syscall" + "time" "github.com/fus1ond/vpn_bot/internal/bot" + "github.com/fus1ond/vpn_bot/internal/callback" "github.com/fus1ond/vpn_bot/internal/config" "github.com/fus1ond/vpn_bot/internal/database" "github.com/fus1ond/vpn_bot/internal/monitoring" + "github.com/fus1ond/vpn_bot/internal/platega" "github.com/fus1ond/vpn_bot/internal/remnawave" ) @@ -71,6 +75,31 @@ func main() { cancel() }() + // Запуск callback-сервера (если Platega настроена) + if cfg.PlategaMerchantID != "" && cfg.PlategaSecret != "" { + // TODO(этап 4): заменить stubHandler на telegramBot.PaymentCallbackHandler() + // когда метод будет реализован в боте + stubHandler := &noopPaymentHandler{} + callbackServer := callback.NewServer(cfg.CallbackPort, cfg.PlategaMerchantID, cfg.PlategaSecret, stubHandler) + + go func() { + if err := callbackServer.Start(); err != nil && err != http.ErrServerClosed { + slog.Error("Callback server error", "error", err) + } + }() + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := callbackServer.Shutdown(shutdownCtx); err != nil { + slog.Error("Callback server shutdown error", "error", err) + } + }() + + slog.Info("Platega callback server started", "port", cfg.CallbackPort) + } + // Запуск фоновой синхронизации targets.json для мониторинга нод go monitoring.StartSyncLoop(ctx, remnawaveClient, cfg.SDConfigsPath) @@ -87,3 +116,11 @@ func main() { <-ctx.Done() slog.Info("Bot stopped") } + +// noopPaymentHandler — заглушка обработчика платежей до реализации в этапе 4 +type noopPaymentHandler struct{} + +func (n *noopPaymentHandler) HandlePaymentCallback(payload platega.CallbackPayload) error { + slog.Info("Callback получен (заглушка, этап 4 не реализован)", "transaction_id", payload.ID) + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml index e5c7b61..311de26 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,8 @@ services: - VICTORIA_METRICS_URL=${VICTORIA_METRICS_URL:-http://victoriametrics:8428} env_file: - .env + ports: + - "127.0.0.1:8080:8080" networks: - vpn-network depends_on: diff --git a/docs/progress/2026-03-23-payment-stage-3.md b/docs/progress/2026-03-23-payment-stage-3.md new file mode 100644 index 0000000..103f913 --- /dev/null +++ b/docs/progress/2026-03-23-payment-stage-3.md @@ -0,0 +1,41 @@ +# Прогресс: Этап 3 — Callback HTTP-сервер + +**План:** [docs/plans/2026-03-22-payment-implementation-plan.md](../plans/2026-03-22-payment-implementation-plan.md) (строки 807–1016) + +## Что сделано + +Реализован встроенный HTTP-сервер для приёма callback от Platega. Сервер стартует в горутине при наличии `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET`. Поддерживает graceful shutdown через context. + +## Изменённые файлы + +| Файл | Что изменено | +|------|-------------| +| `internal/config/config.go` | Добавлено поле `CallbackPort int` и чтение `CALLBACK_PORT` (default 8080) | +| `internal/callback/server.go` | Создан новый файл: `Server`, `NewServer`, `Start`, `Shutdown`, `handleCallback`, `handleHealth`, интерфейс `PaymentHandler` | +| `internal/callback/server_test.go` | Создан новый файл: 4 теста (TDD — написаны до реализации) | +| `cmd/bot/main.go` | Запуск callback-сервера в горутине, `noopPaymentHandler` заглушка | +| `docker-compose.yml` | Добавлен проброс порта `127.0.0.1:8080:8080` для сервиса `vpn-bot` | + +## Подход + +Использован TDD: +1. **RED** — написан `server_test.go`, тесты не компилировались (пакет отсутствовал) +2. **GREEN** — написан `server.go`, все 4 теста прошли +3. Обновлены конфиг и main.go + +## Отклонения от плана + +- В `main.go` вместо `telegramBot.PaymentCallbackHandler()` (не реализован) добавлена `noopPaymentHandler` — заглушка, которая логирует callback и возвращает nil. Это согласовано с заданием: "добавь заглушку-комментарий TODO или пропусти условие запуска сервера". Выбран вариант с рабочей заглушкой — сервер стартует и отвечает на запросы. +- Добавлен метод `Server.Handler() http.Handler` для удобства тестирования (без запуска реального TCP-сервера). В плане явно не описан, но необходим для чистых unit-тестов. + +## Статус критериев приёмки + +| Критерий | Статус | +|----------|--------| +| Callback-сервер стартует на порту 8080 при наличии PLATEGA_* | ✅ | +| `/health` возвращает 200 | ✅ TestCallbackHealth | +| `/platega/callback` отклоняет запросы без X-MerchantId/X-Secret | ✅ TestCallbackVerification (4 сценария) | +| Корректные callback-запросы логируются | ✅ handleCallback логирует через slog | +| Без PLATEGA_* бот работает как раньше | ✅ проверка `if cfg.PlategaMerchantID != ""` | +| `make fmt` без ошибок | ✅ | +| `make tests` без ошибок | ✅ все 8 пакетов OK | diff --git a/internal/callback/server.go b/internal/callback/server.go new file mode 100644 index 0000000..8a2351e --- /dev/null +++ b/internal/callback/server.go @@ -0,0 +1,130 @@ +package callback + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "github.com/fus1ond/vpn_bot/internal/platega" +) + +// PaymentHandler — интерфейс обработки подтверждённых платежей. +// Использует platega.CallbackPayload (единственное определение, без дублирования). +type PaymentHandler interface { + HandlePaymentCallback(payload platega.CallbackPayload) error +} + +// Server — HTTP-сервер для приёма callback от Platega +type Server struct { + merchantID string + secret string + handler PaymentHandler + httpServer *http.Server + mux *http.ServeMux +} + +// NewServer создаёт callback-сервер. port=0 означает автовыбор ОС (для тестов). +func NewServer(port int, merchantID, secret string, handler PaymentHandler) *Server { + s := &Server{ + merchantID: merchantID, + secret: secret, + handler: handler, + } + + mux := http.NewServeMux() + mux.HandleFunc("/platega/callback", s.handleCallback) + mux.HandleFunc("/health", s.handleHealth) + s.mux = mux + + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + return s +} + +// Handler возвращает http.Handler для использования в тестах +func (s *Server) Handler() http.Handler { + return s.mux +} + +// Start запускает сервер (блокирующий вызов) +func (s *Server) Start() error { + slog.Info("Callback server starting", "addr", s.httpServer.Addr) + return s.httpServer.ListenAndServe() +} + +// Shutdown останавливает сервер +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpServer.Shutdown(ctx) +} + +// handleCallback обрабатывает callback от Platega +func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Верификация заголовков + merchantID := r.Header.Get("X-MerchantId") + secret := r.Header.Get("X-Secret") + + if merchantID != s.merchantID || secret != s.secret { + slog.Warn("Callback rejected: invalid credentials", + "merchant_id", merchantID, + "remote_addr", r.RemoteAddr, + ) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Чтение и парсинг тела (лимит 1 МБ) + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + slog.Error("Callback: не удалось прочитать тело", "error", err) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + var payload platega.CallbackPayload + if err := json.Unmarshal(body, &payload); err != nil { + slog.Error("Callback: ошибка разбора JSON", "error", err, "body", string(body)) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + slog.Info("Callback получен", + "transaction_id", payload.ID, + "status", payload.Status, + "amount", payload.Amount, + "payload", payload.Payload, + ) + + // Обработка через handler + if err := s.handler.HandlePaymentCallback(payload); err != nil { + slog.Error("Callback: ошибка handler", + "error", err, + "transaction_id", payload.ID, + ) + // Возвращаем 500, чтобы Platega сделала retry + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) +} + +// handleHealth — эндпоинт для проверки работоспособности +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) +} diff --git a/internal/callback/server_test.go b/internal/callback/server_test.go new file mode 100644 index 0000000..7b80637 --- /dev/null +++ b/internal/callback/server_test.go @@ -0,0 +1,137 @@ +package callback_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/fus1ond/vpn_bot/internal/callback" + "github.com/fus1ond/vpn_bot/internal/platega" +) + +// mockHandler — тестовая реализация PaymentHandler +type mockHandler struct { + called bool + payload platega.CallbackPayload + err error +} + +func (m *mockHandler) HandlePaymentCallback(p platega.CallbackPayload) error { + m.called = true + m.payload = p + return m.err +} + +const ( + testMerchantID = "merchant-123" + testSecret = "secret-abc" +) + +func newTestServer(handler callback.PaymentHandler) *callback.Server { + return callback.NewServer(0, testMerchantID, testSecret, handler) +} + +// TestCallbackVerification — отклонение запросов с неверными заголовками (401) +func TestCallbackVerification(t *testing.T) { + srv := newTestServer(&mockHandler{}) + + tests := []struct { + name string + merchantID string + secret string + }{ + {"пустые заголовки", "", ""}, + {"неверный merchantID", "wrong-merchant", testSecret}, + {"неверный secret", testMerchantID, "wrong-secret"}, + {"оба неверны", "wrong-merchant", "wrong-secret"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + body := makeCallbackBody(t, platega.CallbackPayload{ID: "tx-1", Status: platega.StatusConfirmed}) + req := httptest.NewRequest(http.MethodPost, "/platega/callback", bytes.NewReader(body)) + req.Header.Set("X-MerchantId", tc.merchantID) + req.Header.Set("X-Secret", tc.secret) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("ожидали 401, получили %d", w.Code) + } + }) + } +} + +// TestCallbackValidRequest — приём запроса с корректными заголовками (200) +func TestCallbackValidRequest(t *testing.T) { + handler := &mockHandler{} + srv := newTestServer(handler) + + payload := platega.CallbackPayload{ + ID: "tx-42", + Status: platega.StatusConfirmed, + Amount: "500.00", + } + body := makeCallbackBody(t, payload) + + req := httptest.NewRequest(http.MethodPost, "/platega/callback", bytes.NewReader(body)) + req.Header.Set("X-MerchantId", testMerchantID) + req.Header.Set("X-Secret", testSecret) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("ожидали 200, получили %d", w.Code) + } + if !handler.called { + t.Error("HandlePaymentCallback не был вызван") + } + if handler.payload.ID != "tx-42" { + t.Errorf("ожидали ID=tx-42, получили %s", handler.payload.ID) + } +} + +// TestCallbackHealth — проверка /health (200) +func TestCallbackHealth(t *testing.T) { + srv := newTestServer(&mockHandler{}) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("ожидали 200, получили %d", w.Code) + } +} + +// TestCallbackInvalidJSON — некорректный JSON (400) +func TestCallbackInvalidJSON(t *testing.T) { + srv := newTestServer(&mockHandler{}) + + req := httptest.NewRequest(http.MethodPost, "/platega/callback", bytes.NewReader([]byte("not-json"))) + req.Header.Set("X-MerchantId", testMerchantID) + req.Header.Set("X-Secret", testSecret) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("ожидали 400, получили %d", w.Code) + } +} + +func makeCallbackBody(t *testing.T, p platega.CallbackPayload) []byte { + t.Helper() + b, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} diff --git a/internal/config/config.go b/internal/config/config.go index 321167f..c2bceda 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,7 @@ type Config struct { PlategaFeeCard int // Комиссия Platega карты (%), по умолчанию 12 PlategaFeeCrypto int // Комиссия Platega крипта (%), по умолчанию 5 PlategaFeeWithdrawal int // Комиссия вывода (%), по умолчанию 2 + CallbackPort int // Порт для callback-сервера (по умолчанию 8080) } // Load читает конфигурацию из переменных окружения @@ -71,6 +72,7 @@ func Load() (*Config, error) { PlategaFeeCard: getEnvOrDefaultInt("PLATEGA_FEE_CARD", 12), PlategaFeeCrypto: getEnvOrDefaultInt("PLATEGA_FEE_CRYPTO", 5), PlategaFeeWithdrawal: getEnvOrDefaultInt("PLATEGA_FEE_WITHDRAWAL", 2), + CallbackPort: getEnvOrDefaultInt("CALLBACK_PORT", 8080), } // Парсинг AdminID From cbc2b3e5d244be7125d1f30f0cd2a7f89f467842 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 11:37:13 +0300 Subject: [PATCH 10/34] =?UTF-8?q?feat:=20=D1=8D=D1=82=D0=B0=D0=BF=204=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=BB=D0=B0=D1=82=D1=91=D0=B6=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=84=D0=BB=D0=BE=D1=83=20=D0=B8=20callback-=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/bot/main.go | 14 +- cmd/migrator/main.go | 1 + docs/progress/2026-03-23-payment-stage-4.md | 70 ++++ internal/bot/admin.go | 6 +- internal/bot/admin_test.go | 27 +- internal/bot/handlers.go | 17 +- internal/bot/handlers_test.go | 8 +- internal/bot/payment.go | 391 ++++++++++++++++++++ internal/bot/payment_test.go | 96 +++++ internal/remnawave/client.go | 44 ++- internal/remnawave/client_test.go | 21 +- 11 files changed, 634 insertions(+), 61 deletions(-) create mode 100644 docs/progress/2026-03-23-payment-stage-4.md create mode 100644 internal/bot/payment.go create mode 100644 internal/bot/payment_test.go diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 5e41a6f..81865b4 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -14,7 +14,6 @@ import ( "github.com/fus1ond/vpn_bot/internal/config" "github.com/fus1ond/vpn_bot/internal/database" "github.com/fus1ond/vpn_bot/internal/monitoring" - "github.com/fus1ond/vpn_bot/internal/platega" "github.com/fus1ond/vpn_bot/internal/remnawave" ) @@ -77,10 +76,7 @@ func main() { // Запуск callback-сервера (если Platega настроена) if cfg.PlategaMerchantID != "" && cfg.PlategaSecret != "" { - // TODO(этап 4): заменить stubHandler на telegramBot.PaymentCallbackHandler() - // когда метод будет реализован в боте - stubHandler := &noopPaymentHandler{} - callbackServer := callback.NewServer(cfg.CallbackPort, cfg.PlategaMerchantID, cfg.PlategaSecret, stubHandler) + callbackServer := callback.NewServer(cfg.CallbackPort, cfg.PlategaMerchantID, cfg.PlategaSecret, telegramBot.PaymentCallbackHandler()) go func() { if err := callbackServer.Start(); err != nil && err != http.ErrServerClosed { @@ -116,11 +112,3 @@ func main() { <-ctx.Done() slog.Info("Bot stopped") } - -// noopPaymentHandler — заглушка обработчика платежей до реализации в этапе 4 -type noopPaymentHandler struct{} - -func (n *noopPaymentHandler) HandlePaymentCallback(payload platega.CallbackPayload) error { - slog.Info("Callback получен (заглушка, этап 4 не реализован)", "transaction_id", payload.ID) - return nil -} diff --git a/cmd/migrator/main.go b/cmd/migrator/main.go index 649ac89..6f584b6 100644 --- a/cmd/migrator/main.go +++ b/cmd/migrator/main.go @@ -128,6 +128,7 @@ func main() { oldUser.TelegramID, username, time.Date(2099, time.January, 1, 0, 0, 0, 0, time.UTC), + 0, // Безлимит для мигрирующих пользователей ) if err != nil { logLine := fmt.Sprintf("[ERROR] telegram_id=%d — API error: %v\n", oldUser.TelegramID, err) diff --git a/docs/progress/2026-03-23-payment-stage-4.md b/docs/progress/2026-03-23-payment-stage-4.md new file mode 100644 index 0000000..2c18766 --- /dev/null +++ b/docs/progress/2026-03-23-payment-stage-4.md @@ -0,0 +1,70 @@ +# Этап 4: Платёжный флоу — прогресс + +**План:** [docs/plans/2026-03-22-payment-implementation-plan.md](../plans/2026-03-22-payment-implementation-plan.md), строки 1018–1499 + +## Что сделано + +### Шаг 1: `internal/remnawave/client.go` +- Добавлен параметр `trafficLimitBytes int64` в `CreateUser` — триал получает лимит трафика, админские инвайты — безлимит +- Переработан `EnableUser(uuid, newExpireAt)` — теперь одним PATCH-запросом ставит Status=ACTIVE, обновляет ExpireAt и снимает лимит трафика (TrafficLimitBytes=0) +- Добавлен `DisableUser(uuid)` — деактивация пользователя через PATCH (Status=DISABLED) +- Обновлён `UpdateUserRequest`: `Status` и `TrafficLimitBytes` теперь указатели для корректного omitempty +- Добавлены хелперы `strPtr`, `int64Ptr` +- `ExtendUserSubscription` обновлён — для EXPIRED/DISABLED теперь вызывает `EnableUser` вместо отдельных enable+patch + +### Шаг 2: `internal/bot/payment.go` (создан) +- `paymentMu sync.Map` + `getPaymentMutex` — мьютексы по telegram_id +- `paymentCallbackHandler` + `PaymentCallbackHandler()` — реализует `callback.PaymentHandler` +- `HandlePaymentCallback` — диспетчер по статусу (CONFIRMED/CANCELED/CHARGEBACKED) +- `handleConfirmed` — идемпотентная обработка с retry/backoff (30с, 1м, 5м), fallback на `confirmed_not_activated` +- `activateSubscription` — продление через `EnableUser` (досрочное = +1 месяц к текущему, иначе = от now) +- `createEarningRecord` — начисление модератору с расчётом комиссий +- `calculateSharePercent` — шкала 15%/20%/25% +- `getPlategaFeePercent` — комиссии SBP/Card/Crypto из конфига +- `handleCanceled`, `handleChargeback` — отмена и chargeback с уведомлениями +- `sendAdminAlert` — уведомление админа +- `createPaymentForUser` — создание платежа в Platega + сохранение в БД, защита от дублей +- `checkPaymentStatus` — ручная проверка через API (с мьютексом) + +### Шаг 3: `internal/bot/handlers.go` +- Добавлены поля `platega *platega.Client` и `maintenanceMode bool` в структуру `Bot` +- В `New()` — условная инициализация Platega-клиента +- В `processInviteCode` — триал получает лимит трафика `TrialTrafficLimitGB * 1 GiB` + +### Шаг 4: `cmd/bot/main.go` +- Заменён `noopPaymentHandler` на `telegramBot.PaymentCallbackHandler()` +- Удалена заглушка `noopPaymentHandler` + +### Шаг 5: `internal/bot/payment_test.go` (создан) +- `TestCalculateSharePercent` — шкала 15/20/25% (6 кейсов) +- `TestGetPlategaFeePercent` — комиссии SBP/Card/Crypto + fallback +- `TestHandleConfirmedIdempotency` — повторный callback не дублирует обработку + +## Изменённые файлы + +| Файл | Действие | +|------|----------| +| `internal/remnawave/client.go` | Изменён (CreateUser, EnableUser, DisableUser, UpdateUserRequest) | +| `internal/remnawave/client_test.go` | Изменён (обновлены под новые сигнатуры) | +| `internal/bot/payment.go` | Создан | +| `internal/bot/payment_test.go` | Создан | +| `internal/bot/handlers.go` | Изменён (platega, maintenanceMode, триал трафик) | +| `internal/bot/admin.go` | Изменён (EnableUser с новой сигнатурой) | +| `internal/bot/admin_test.go` | Изменён (обновлён тест switch subscription) | +| `internal/bot/handlers_test.go` | Изменён (TrialTrafficLimitGB, проверка trafficLimitBytes) | +| `cmd/bot/main.go` | Изменён (убрана заглушка, подключён PaymentCallbackHandler) | +| `cmd/migrator/main.go` | Изменён (trafficLimitBytes=0) | + +## Отклонения от плана + +1. **`EnableUser` переработан глубже чем в плане:** план предлагал метод, обёртывающий `UpdateUser`, но в коде уже был `EnableUser` вызывающий `POST /actions/enable`. Переработал его на PATCH с Status+ExpireAt+TrafficLimitBytes, как указано в плане. Это потребовало обновления тестов `ExtendUserSubscription` и `SwitchSubscription`. +2. **`UpdateUserRequest.Status` стал `*string`:** необходимо для корректного omitempty — пустая строка `""` не должна отправляться в JSON. + +## Статус критериев приёмки + +- [x] Полный цикл: createPayment → Platega API → callback → confirm → activateSubscription +- [x] Защита от двойных платежей (PENDING с тем же/другим способом) +- [x] Chargeback деактивирует пользователя + алерт админу +- [x] confirmed_not_activated при недоступности Remnawave + алерт +- [x] Race condition защищён мьютексом по telegram_id +- [x] Все тесты проходят (`make tests` + `make fmt`) diff --git a/internal/bot/admin.go b/internal/bot/admin.go index 0f77c44..0d80b06 100644 --- a/internal/bot/admin.go +++ b/internal/bot/admin.go @@ -199,8 +199,11 @@ func (b *Bot) processSwitchSubscriptionConfirm(c tele.Context, text string) erro return c.Send("Ошибка при обновлении подписки, попробуйте позже", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) } + unlimitedExpireAt := time.Date(2099, time.January, 1, 0, 0, 0, 0, time.UTC) + if remUser.Status == remnawave.StatusExpired || remUser.Status == remnawave.StatusDisabled { - if err := b.remnawave.EnableUser(session.UserUUID); err != nil { + // EnableUser одним вызовом ставит ACTIVE + ExpireAt + безлимит трафика + if err := b.remnawave.EnableUser(session.UserUUID, unlimitedExpireAt); err != nil { slog.Error("Failed to enable user before switching subscription", "error", err, "telegram_id", session.TargetTelegramID) b.userStates.Delete(adminID) b.clearAdminSwitchSession(adminID) @@ -208,7 +211,6 @@ func (b *Bot) processSwitchSubscriptionConfirm(c tele.Context, text string) erro } } - unlimitedExpireAt := time.Date(2099, time.January, 1, 0, 0, 0, 0, time.UTC) if err := b.remnawave.UpdateUser(session.UserUUID, remnawave.UpdateUserRequest{ UUID: session.UserUUID, ExpireAt: strPtr(unlimitedExpireAt.Format(time.RFC3339)), diff --git a/internal/bot/admin_test.go b/internal/bot/admin_test.go index fd1c698..03435b1 100644 --- a/internal/bot/admin_test.go +++ b/internal/bot/admin_test.go @@ -340,9 +340,8 @@ func TestProcessSwitchSubscription_ConfirmFlow(t *testing.T) { require.NoError(t, db.ClaimInvite(invite.Code, targetID)) require.NoError(t, db.MarkNotificationSent(targetID, "expire_3d")) - var gotEnable bool - var gotPatch bool - var patchReq remnawave.UpdateUserRequest + var patchCount int + var lastPatchReq remnawave.UpdateUserRequest client := remnawave.NewClient("https://panel.example.com", "test-token", nil) client.SetHTTPClient(&http.Client{ @@ -355,17 +354,9 @@ func TestProcessSwitchSubscription_ConfirmFlow(t *testing.T) { Body: io.NopCloser(strings.NewReader(payload)), Header: make(http.Header), }, nil - case r.Method == http.MethodPost && r.URL.Path == "/api/users/uuid-target/actions/enable": - gotEnable = true - payload := `{"response":{"success":true}}` - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(payload)), - Header: make(http.Header), - }, nil case r.Method == http.MethodPatch && r.URL.Path == "/api/users": - gotPatch = true - require.NoError(t, json.NewDecoder(r.Body).Decode(&patchReq)) + patchCount++ + require.NoError(t, json.NewDecoder(r.Body).Decode(&lastPatchReq)) payload := `{"response":{"uuid":"uuid-target"}}` return &http.Response{ StatusCode: http.StatusOK, @@ -401,11 +392,11 @@ func TestProcessSwitchSubscription_ConfirmFlow(t *testing.T) { err = b.processSwitchSubscriptionConfirm(ctxConfirm, BtnConfirmYes) require.NoError(t, err) - require.True(t, gotEnable) - require.True(t, gotPatch) - require.Equal(t, "uuid-target", patchReq.UUID) - require.NotNil(t, patchReq.ExpireAt) - assert.Equal(t, "2099-01-01T00:00:00Z", *patchReq.ExpireAt) + // EnableUser (PATCH) + UpdateUser (PATCH) = 2 вызова + require.Equal(t, 2, patchCount) + require.Equal(t, "uuid-target", lastPatchReq.UUID) + require.NotNil(t, lastPatchReq.ExpireAt) + assert.Equal(t, "2099-01-01T00:00:00Z", *lastPatchReq.ExpireAt) gotInvite, err := db.GetInviteByUsedBy(targetID) require.NoError(t, err) diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 4b3169d..16dc6b4 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -10,6 +10,7 @@ import ( "github.com/fus1ond/vpn_bot/internal/config" "github.com/fus1ond/vpn_bot/internal/database" "github.com/fus1ond/vpn_bot/internal/monitoring" + "github.com/fus1ond/vpn_bot/internal/platega" "github.com/fus1ond/vpn_bot/internal/remnawave" "github.com/fus1ond/vpn_bot/internal/render" tele "gopkg.in/telebot.v3" @@ -33,6 +34,8 @@ type Bot struct { dashboardMgr *dashboardManager // менеджер сессий дашборда sdConfigsPath string // путь к sd_configs (для чтения targets) render *render.Client // клиент render-сервиса (nil если не настроен) + platega *platega.Client // Platega API клиент (nil если не настроен) + maintenanceMode bool // Режим обслуживания (сбрасывается при перезапуске) modExtendMu sync.RWMutex modExtendData map[int64]modExtendSession // pending-данные продления для модератора adminSwitchMu sync.RWMutex @@ -91,6 +94,12 @@ func New(cfg *config.Config, db *database.DB, remnawaveClient *remnawave.Client) slog.Info("Render service enabled", "url", cfg.RenderURL) } + // Инициализация Platega-клиента (опционально) + if cfg.PlategaMerchantID != "" && cfg.PlategaSecret != "" { + bot.platega = platega.NewClient(cfg.PlategaMerchantID, cfg.PlategaSecret) + slog.Info("Platega client initialized") + } + // Регистрация обработчиков b.Handle("/start", bot.handleStart) b.Handle(tele.OnText, bot.handleTextMessage) @@ -406,7 +415,13 @@ func (b *Bot) processInviteCode(c tele.Context, code string) error { expireAt = time.Now().UTC().AddDate(0, 0, *invite.ExpireDays) } - remnawaveUser, err := b.remnawave.CreateUser(telegramID, username, expireAt) + // Определяем лимит трафика: триал получает ограничение, админский инвайт — безлимит + var trafficLimitBytes int64 + if invite.ExpireDays != nil { + trafficLimitBytes = int64(b.config.TrialTrafficLimitGB) * 1024 * 1024 * 1024 + } + + remnawaveUser, err := b.remnawave.CreateUser(telegramID, username, expireAt, trafficLimitBytes) if err != nil { slog.Error("Failed to create user in Remnawave", "error", err) // Откатываем инвайт — пользователь не создан diff --git a/internal/bot/handlers_test.go b/internal/bot/handlers_test.go index 54d71e0..04668d8 100644 --- a/internal/bot/handlers_test.go +++ b/internal/bot/handlers_test.go @@ -58,7 +58,10 @@ func setupTestBot(t *testing.T) (*Bot, *database.DB) { os.Remove(dbFile) }) - cfg := &config.Config{AdminID: 999999} + cfg := &config.Config{ + AdminID: 999999, + TrialTrafficLimitGB: 1, + } b := &Bot{ db: db, config: cfg, @@ -247,6 +250,7 @@ func TestProcessInviteCode_UsesInviteExpireDays(t *testing.T) { err = b.processInviteCode(ctx, invite.Code) require.NoError(t, err) assert.Equal(t, "2099-01-01T00:00:00Z", captured.ExpireAt) + assert.Equal(t, int64(0), captured.TrafficLimitBytes) // Бессрочный инвайт — безлимит assert.Equal(t, []string{"uuid-1", "uuid-2"}, captured.ActiveInternalSquads) }) @@ -302,6 +306,8 @@ func TestProcessInviteCode_UsesInviteExpireDays(t *testing.T) { require.NoError(t, err) assert.False(t, gotExpireAt.Before(before.AddDate(0, 0, 30).Add(-2*time.Second))) assert.False(t, gotExpireAt.After(after.AddDate(0, 0, 30).Add(2*time.Second))) + // Месячный инвайт — лимит трафика (TrialTrafficLimitGB=1 по умолчанию) + assert.Equal(t, int64(1*1024*1024*1024), captured.TrafficLimitBytes) }) } diff --git a/internal/bot/payment.go b/internal/bot/payment.go new file mode 100644 index 0000000..59c83d1 --- /dev/null +++ b/internal/bot/payment.go @@ -0,0 +1,391 @@ +package bot + +import ( + "fmt" + "log/slog" + "strconv" + "sync" + "time" + + "github.com/fus1ond/vpn_bot/internal/callback" + "github.com/fus1ond/vpn_bot/internal/database" + "github.com/fus1ond/vpn_bot/internal/platega" +) + +// paymentMu — мьютексы по telegram_id для защиты от race condition при обработке callback. +// TODO: sync.Map не чистится — за годы работы накопятся тысячи мьютексов. +// Не критично (мьютекс маленький), но при необходимости можно добавить периодическую чистку. +var paymentMu sync.Map // map[int64]*sync.Mutex + +func getPaymentMutex(telegramID int64) *sync.Mutex { + mu, _ := paymentMu.LoadOrStore(telegramID, &sync.Mutex{}) + return mu.(*sync.Mutex) +} + +// paymentCallbackHandler реализует callback.PaymentHandler +type paymentCallbackHandler struct { + bot *Bot +} + +// PaymentCallbackHandler возвращает обработчик callback от Platega +func (b *Bot) PaymentCallbackHandler() callback.PaymentHandler { + return &paymentCallbackHandler{bot: b} +} + +// HandlePaymentCallback обрабатывает callback от Platega +func (h *paymentCallbackHandler) HandlePaymentCallback(payload platega.CallbackPayload) error { + // Находим платёж по platega_transaction_id + payment, err := h.bot.db.GetPaymentByPlategaTxID(payload.ID) + if err != nil { + return fmt.Errorf("get payment by tx: %w", err) + } + if payment == nil { + slog.Warn("Callback для неизвестной транзакции", "transaction_id", payload.ID) + return nil // Не возвращаем ошибку, чтобы Platega не retry-ила + } + + // Блокируем обработку по telegram_id + mu := getPaymentMutex(payment.TelegramID) + mu.Lock() + defer mu.Unlock() + + switch payload.Status { + case platega.StatusConfirmed: + return h.handleConfirmed(payment) + case platega.StatusCanceled: + return h.handleCanceled(payment) + case platega.StatusChargebacked: + return h.handleChargeback(payment) + default: + slog.Warn("Callback с неожиданным статусом", "status", payload.Status, "transaction_id", payload.ID) + return nil + } +} + +// handleConfirmed обрабатывает успешный платёж +func (h *paymentCallbackHandler) handleConfirmed(payment *database.Payment) error { + // Идемпотентность: если платёж уже confirmed — пропускаем + if payment.Status == "confirmed" { + slog.Info("Платёж уже подтверждён, пропускаем", "payment_id", payment.ID) + return nil + } + + // Подтверждаем платёж в БД + if err := h.bot.db.ConfirmPayment(payment.ID); err != nil { + return fmt.Errorf("confirm payment: %w", err) + } + + // Активируем подписку в Remnawave с retry и backoff (3 попытки: 30с, 1м, 5м) + retryDelays := []time.Duration{30 * time.Second, 1 * time.Minute, 5 * time.Minute} + var activateErr error + for attempt, delay := range retryDelays { + activateErr = h.activateSubscription(payment) + if activateErr == nil { + break + } + slog.Warn("Не удалось активировать подписку, повторяем", + "error", activateErr, "payment_id", payment.ID, + "attempt", attempt+1, "next_retry_in", delay) + time.Sleep(delay) + } + + if activateErr != nil { + // Все попытки провалились — помечаем для retry через scheduler + slog.Error("Все попытки активации провалились, помечаем для scheduler", + "error", activateErr, "payment_id", payment.ID) + h.bot.db.UpdatePaymentStatus(payment.ID, "confirmed_not_activated") + + // Уведомляем админа + h.bot.sendAdminAlert(fmt.Sprintf( + "⚠️ Платёж #%d подтверждён, но не удалось активировать подписку для %d после 3 попыток. Требуется ручная проверка.", + payment.ID, payment.TelegramID, + )) + return nil // Не возвращаем ошибку — платёж уже сохранён + } + + // Создаём запись в moderator_earnings (если есть модератор) + h.createEarningRecord(payment) + + // Уведомляем пользователя + remUser, _ := h.bot.remnawave.GetUserByTelegramID(payment.TelegramID) + + var msg string + if remUser != nil { + expireDate := remUser.ExpireAt.Format("02.01.2006") + msg = fmt.Sprintf("✅ Оплата прошла! Ваша подписка активна до %s.\n\nЛимит трафика снят — пользуйтесь без ограничений.\n\nБлиже к концу подписки мы напомним о продлении.", expireDate) + } else { + msg = "✅ Оплата прошла! Подписка активирована." + } + + _ = h.bot.sendSchedulerMessage(payment.TelegramID, msg) + + // Очищаем уведомления (пользователь мог быть в grace period) + h.bot.db.ClearNotifications(payment.TelegramID) + + return nil +} + +// activateSubscription продлевает подписку в Remnawave +func (h *paymentCallbackHandler) activateSubscription(payment *database.Payment) error { + user, err := h.bot.db.GetUserByTelegramID(payment.TelegramID) + if err != nil || user == nil { + return fmt.Errorf("user not found: telegram_id=%d", payment.TelegramID) + } + + remUser, err := h.bot.remnawave.GetUser(user.RemnawaveUUID) + if err != nil { + return fmt.Errorf("get remnawave user: %w", err) + } + + now := time.Now().UTC() + var newExpireAt time.Time + + // Если подписка ещё активна (досрочное продление) — плюсуем к текущему expireAt + if remUser.ExpireAt.After(now) && remUser.Status == "ACTIVE" { + newExpireAt = remUser.ExpireAt.AddDate(0, 1, 0) + } else { + // Триал, grace period или истёк — считаем от момента оплаты + newExpireAt = now.AddDate(0, 1, 0) + } + + // Реактивируем пользователя: ставит Status=ACTIVE, ExpireAt=newExpireAt, TrafficLimitBytes=0. + return h.bot.remnawave.EnableUser(user.RemnawaveUUID, newExpireAt) +} + +// createEarningRecord создаёт запись начисления модератору +func (h *paymentCallbackHandler) createEarningRecord(payment *database.Payment) { + if payment.ModeratorID == nil { + return // Админский пользователь — без начислений + } + + moderatorID := *payment.ModeratorID + + // Проверяем, что модератор ещё активен + if !h.bot.isModerator(moderatorID) { + return + } + + // Считаем количество платящих клиентов для определения доли + payingCount, err := h.bot.db.CountPayingSubscribersByModerator(moderatorID) + if err != nil { + slog.Error("Ошибка подсчёта платящих подписчиков", "error", err, "moderator_id", moderatorID) + return + } + + sharePercent := calculateSharePercent(payingCount) + + // Определяем комиссию Platega по методу оплаты + feePercent := h.bot.getPlategaFeePercent(payment.PaymentMethod) + withdrawalPercent := h.bot.config.PlategaFeeWithdrawal + + grossAmount := payment.Amount + plategaFee := grossAmount * feePercent / 100 + afterPlatega := grossAmount - plategaFee + withdrawalFee := afterPlatega * withdrawalPercent / 100 + netAmount := afterPlatega - withdrawalFee + shareAmount := netAmount * sharePercent / 100 + + earning := &database.ModeratorEarning{ + PaymentID: payment.ID, + ModeratorID: moderatorID, + GrossAmount: grossAmount, + PlategaFee: plategaFee, + WithdrawalFee: withdrawalFee, + NetAmount: netAmount, + SharePercent: sharePercent, + ShareAmount: shareAmount, + } + + if _, err := h.bot.db.CreateEarning(earning); err != nil { + slog.Error("Ошибка создания записи начисления", "error", err, "payment_id", payment.ID) + } +} + +// calculateSharePercent определяет долю модератора по количеству платящих клиентов +func calculateSharePercent(payingCount int) int { + switch { + case payingCount >= 25: + return 25 + case payingCount >= 15: + return 20 + default: + return 15 + } +} + +// getPlategaFeePercent возвращает процент комиссии Platega для метода оплаты +func (b *Bot) getPlategaFeePercent(paymentMethod string) int { + switch paymentMethod { + case "sbp": + return b.config.PlategaFeeSBP + case "card": + return b.config.PlategaFeeCard + case "crypto": + return b.config.PlategaFeeCrypto + default: + return b.config.PlategaFeeSBP // Fallback + } +} + +// handleCanceled обрабатывает отменённый платёж +func (h *paymentCallbackHandler) handleCanceled(payment *database.Payment) error { + if payment.Status != "pending" { + return nil + } + if err := h.bot.db.UpdatePaymentStatus(payment.ID, "canceled"); err != nil { + return fmt.Errorf("update status to canceled: %w", err) + } + _ = h.bot.sendSchedulerMessage(payment.TelegramID, "❌ Платёж отменён. Вы можете попробовать снова.") + return nil +} + +// handleChargeback обрабатывает chargeback +func (h *paymentCallbackHandler) handleChargeback(payment *database.Payment) error { + if err := h.bot.db.UpdatePaymentStatus(payment.ID, "chargebacked"); err != nil { + return fmt.Errorf("update status to chargebacked: %w", err) + } + + // Деактивируем пользователя + user, err := h.bot.db.GetUserByTelegramID(payment.TelegramID) + if err == nil && user != nil { + _ = h.bot.remnawave.DisableUser(user.RemnawaveUUID) + } + + // Уведомляем админа + h.bot.sendAdminAlert(fmt.Sprintf( + "⚠️ Chargeback от %d, сумма: %d руб. Пользователь деактивирован.", + payment.TelegramID, payment.Amount, + )) + + return nil +} + +// sendAdminAlert отправляет сообщение админу +func (b *Bot) sendAdminAlert(msg string) { + _ = b.sendSchedulerMessage(b.config.AdminID, msg) +} + +// createPaymentForUser создаёт платёж для пользователя +func (b *Bot) createPaymentForUser(telegramID int64, paymentMethodInt int) (*database.Payment, string, error) { + user, err := b.db.GetUserByTelegramID(telegramID) + if err != nil || user == nil { + return nil, "", fmt.Errorf("user not found") + } + + if user.SubscriptionPrice == nil { + return nil, "", fmt.Errorf("subscription price not set") + } + + // Проверка лимита 90 дней: нельзя оплатить, если до конца подписки >= 90 дней + remUser, err := b.remnawave.GetUserByTelegramID(telegramID) + if err == nil && remUser != nil && remUser.Status == "ACTIVE" && remUser.ExpireAt.Year() < 2099 { + daysLeft := int(time.Until(remUser.ExpireAt).Hours() / 24) + if daysLeft >= 90 { + return nil, "", fmt.Errorf("subscription_too_far: %d days left", daysLeft) + } + } + + paymentMethodStr := platega.PaymentMethodString(paymentMethodInt) + + // Проверяем наличие активного PENDING платежа + pending, err := b.db.GetPendingPayment(telegramID) + if err != nil { + return nil, "", fmt.Errorf("check pending: %w", err) + } + + if pending != nil { + if pending.PaymentMethod == paymentMethodStr { + // Тот же способ — возвращаем ту же ссылку + url := "" + if pending.RedirectURL != nil { + url = *pending.RedirectURL + } + return pending, url, nil + } + // Другой способ — помечаем старый как expired + b.db.UpdatePaymentStatus(pending.ID, "expired") + } + + // Создаём платёж в Platega + callbackURL := b.config.PlategaCallbackURL + telegramIDStr := strconv.FormatInt(telegramID, 10) + + resp, err := b.platega.CreatePayment(platega.CreateTransactionRequest{ + PaymentMethod: paymentMethodInt, + Amount: *user.SubscriptionPrice, + Currency: "RUB", + Description: "VPN подписка на 1 месяц", + ReturnURL: fmt.Sprintf("https://t.me/%s", b.bot.Me.Username), + FailedURL: fmt.Sprintf("https://t.me/%s", b.bot.Me.Username), + CallbackURL: callbackURL, + Payload: telegramIDStr, + }) + if err != nil { + return nil, "", fmt.Errorf("platega create payment: %w", err) + } + + // Вычисляем время жизни + var expiresAt *time.Time + if resp.ExpiresIn > 0 { + t := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second) + expiresAt = &t + } + + // Сохраняем в БД + payment := &database.Payment{ + TelegramID: telegramID, + ModeratorID: user.ModeratorID, + Amount: *user.SubscriptionPrice, + PaymentMethod: paymentMethodStr, + Status: "pending", + PlategaTransactionID: &resp.TransactionID, + RedirectURL: &resp.Redirect, + ExpiresAt: expiresAt, + } + + id, err := b.db.CreatePayment(payment) + if err != nil { + return nil, "", fmt.Errorf("save payment: %w", err) + } + payment.ID = id + + return payment, resp.Redirect, nil +} + +// checkPaymentStatus ручная проверка статуса платежа через Platega API. +// Защищён мьютексом по telegram_id для предотвращения race condition +// с параллельным callback от Platega. +func (b *Bot) checkPaymentStatus(telegramID int64) (string, error) { + // Берём мьютекс ДО чтения из БД — та же блокировка, что и в callback + mu := getPaymentMutex(telegramID) + mu.Lock() + defer mu.Unlock() + + // Попутно помечаем протухшие PENDING как expired (не ждём scheduler) + b.db.ExpireOldPendingPayments() + + pending, err := b.db.GetPendingPayment(telegramID) + if err != nil { + return "", fmt.Errorf("get pending: %w", err) + } + if pending == nil { + return "not_found", nil + } + if pending.PlategaTransactionID == nil { + return "pending", nil + } + + status, err := b.platega.GetTransactionStatus(*pending.PlategaTransactionID) + if err != nil { + return "", fmt.Errorf("check status: %w", err) + } + + if status.Status == platega.StatusConfirmed { + // Платёж подтверждён — обрабатываем как callback (мьютекс уже взят) + handler := &paymentCallbackHandler{bot: b} + handler.handleConfirmed(pending) + return "confirmed", nil + } + + return status.Status, nil +} diff --git a/internal/bot/payment_test.go b/internal/bot/payment_test.go new file mode 100644 index 0000000..6f43b76 --- /dev/null +++ b/internal/bot/payment_test.go @@ -0,0 +1,96 @@ +package bot + +import ( + "os" + "testing" + + "github.com/fus1ond/vpn_bot/internal/config" + "github.com/fus1ond/vpn_bot/internal/database" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCalculateSharePercent(t *testing.T) { + tests := []struct { + name string + payingCount int + wantPercent int + }{ + {"Менее 15 — 15%", 0, 15}, + {"Ровно 14 — 15%", 14, 15}, + {"Ровно 15 — 20%", 15, 20}, + {"Между 15 и 25 — 20%", 20, 20}, + {"Ровно 25 — 25%", 25, 25}, + {"Более 25 — 25%", 50, 25}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := calculateSharePercent(tt.payingCount) + assert.Equal(t, tt.wantPercent, got) + }) + } +} + +func TestGetPlategaFeePercent(t *testing.T) { + b := &Bot{ + config: &config.Config{ + PlategaFeeSBP: 11, + PlategaFeeCard: 12, + PlategaFeeCrypto: 5, + }, + } + + assert.Equal(t, 11, b.getPlategaFeePercent("sbp")) + assert.Equal(t, 12, b.getPlategaFeePercent("card")) + assert.Equal(t, 5, b.getPlategaFeePercent("crypto")) + assert.Equal(t, 11, b.getPlategaFeePercent("unknown")) // Fallback на SBP +} + +func TestHandleConfirmedIdempotency(t *testing.T) { + dbFile := "test_payment_idempotency.db" + db, err := database.New(dbFile) + require.NoError(t, err) + t.Cleanup(func() { + db.Close() + os.Remove(dbFile) + }) + + // Создаём пользователя + _, err = db.CreateUser(500, "payer", "Payer", "uuid-500", nil, nil) + require.NoError(t, err) + + // Создаём платёж и сразу подтверждаем + txID := "tx-idempotent" + payment := &database.Payment{ + TelegramID: 500, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + PlategaTransactionID: &txID, + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + + // Подтверждаем + err = db.ConfirmPayment(id) + require.NoError(t, err) + + // Перечитываем — статус confirmed + confirmed, err := db.GetPaymentByID(id) + require.NoError(t, err) + require.Equal(t, "confirmed", confirmed.Status) + + // Повторный вызов handleConfirmed на уже подтверждённом платеже — должен быть noop + cfg := &config.Config{AdminID: 999} + b := &Bot{db: db, config: cfg, userStates: newStateMap()} + handler := &paymentCallbackHandler{bot: b} + + err = handler.handleConfirmed(confirmed) + assert.NoError(t, err) // Идемпотентность — нет ошибки + + // Статус не изменился + after, err := db.GetPaymentByID(id) + require.NoError(t, err) + assert.Equal(t, "confirmed", after.Status) +} diff --git a/internal/remnawave/client.go b/internal/remnawave/client.go index fc0bbb8..59102f4 100644 --- a/internal/remnawave/client.go +++ b/internal/remnawave/client.go @@ -106,10 +106,11 @@ type CreateUserRequest struct { // UpdateUserRequest — запрос на обновление пользователя type UpdateUserRequest struct { - UUID string `json:"uuid"` - Username *string `json:"username,omitempty"` - Status string `json:"status,omitempty"` - ExpireAt *string `json:"expireAt,omitempty"` + UUID string `json:"uuid"` + Username *string `json:"username,omitempty"` + Status *string `json:"status,omitempty"` + ExpireAt *string `json:"expireAt,omitempty"` + TrafficLimitBytes *int64 `json:"trafficLimitBytes,omitempty"` } // apiResponse — обёртка ответа API @@ -118,11 +119,12 @@ type apiResponse struct { } // CreateUser создаёт нового пользователя в Remnawave. -func (c *Client) CreateUser(telegramID int64, username string, expireAt time.Time) (*User, error) { +// trafficLimitBytes=0 означает безлимит. +func (c *Client) CreateUser(telegramID int64, username string, expireAt time.Time, trafficLimitBytes int64) (*User, error) { req := CreateUserRequest{ Username: username, TelegramID: telegramID, - TrafficLimitBytes: 0, + TrafficLimitBytes: trafficLimitBytes, TrafficLimitStrategy: TrafficStrategyMonth, ExpireAt: expireAt.UTC().Format(time.RFC3339), } @@ -260,12 +262,29 @@ func (c *Client) DeleteUser(uuid string) error { return err } -// EnableUser включает ранее выключенного пользователя. -func (c *Client) EnableUser(uuid string) error { - _, err := c.doRequest("POST", "/api/users/"+uuid+"/actions/enable", nil) - return err +// EnableUser реактивирует пользователя: ставит ACTIVE, обновляет ExpireAt, снимает лимит трафика. +func (c *Client) EnableUser(uuid string, newExpireAt time.Time) error { + expireStr := newExpireAt.UTC().Format(time.RFC3339) + return c.UpdateUser(uuid, UpdateUserRequest{ + Status: strPtr(StatusActive), + ExpireAt: &expireStr, + TrafficLimitBytes: int64Ptr(0), // Безлимит после оплаты + }) +} + +// DisableUser деактивирует пользователя (grace period). +func (c *Client) DisableUser(uuid string) error { + return c.UpdateUser(uuid, UpdateUserRequest{ + Status: strPtr(StatusDisabled), + }) } +// strPtr возвращает указатель на строку +func strPtr(s string) *string { return &s } + +// int64Ptr возвращает указатель на int64 +func int64Ptr(n int64) *int64 { return &n } + // CalculateExtendedExpireAt рассчитывает новую дату окончания подписки. func CalculateExtendedExpireAt(currentExpireAt, now time.Time, days int) (time.Time, error) { current := currentExpireAt.UTC() @@ -298,10 +317,9 @@ func (c *Client) ExtendUserSubscription(uuid string, days int) error { return err } + // EnableUser одним вызовом ставит ACTIVE + обновляет ExpireAt + снимает лимит трафика if user.Status == StatusExpired || user.Status == StatusDisabled { - if err := c.EnableUser(uuid); err != nil { - return fmt.Errorf("failed to enable user: %w", err) - } + return c.EnableUser(uuid, newExpireAt) } expireAt := newExpireAt.UTC().Format(time.RFC3339) diff --git a/internal/remnawave/client_test.go b/internal/remnawave/client_test.go index 07aac94..8d09b68 100644 --- a/internal/remnawave/client_test.go +++ b/internal/remnawave/client_test.go @@ -50,7 +50,7 @@ func TestCreateUserSetsUnlimitedTraffic(t *testing.T) { }), } - user, err := client.CreateUser(12345, "alice", expectedExpireAt) + user, err := client.CreateUser(12345, "alice", expectedExpireAt, 0) require.NoError(t, err) require.NotNil(t, user) @@ -94,7 +94,7 @@ func TestCreateUserSetsMultipleInternalSquads(t *testing.T) { }), } - user, err := client.CreateUser(54321, "bob", expectedExpireAt) + user, err := client.CreateUser(54321, "bob", expectedExpireAt, 0) require.NoError(t, err) require.NotNil(t, user) require.Equal(t, []string{"uuid-1", "uuid-2"}, capturedRequest.ActiveInternalSquads) @@ -133,7 +133,7 @@ func TestCreateUserOmitsEmptyInternalSquads(t *testing.T) { }), } - user, err := client.CreateUser(98765, "carol", expectedExpireAt) + user, err := client.CreateUser(98765, "carol", expectedExpireAt, 0) require.NoError(t, err) require.NotNil(t, user) _, exists := capturedBody["activeInternalSquads"] @@ -175,7 +175,6 @@ func TestExtendUserSubscription_EnableAndPatch(t *testing.T) { client := NewClient("https://panel.example.com", "test-token", nil) var patchReq UpdateUserRequest - var gotEnable bool var gotPatch bool client.http = &http.Client{ @@ -188,14 +187,6 @@ func TestExtendUserSubscription_EnableAndPatch(t *testing.T) { Body: io.NopCloser(strings.NewReader(payload)), Header: make(http.Header), }, nil - case r.Method == http.MethodPost && r.URL.Path == "/api/users/uuid-1/actions/enable": - gotEnable = true - payload := `{"response":{"success":true}}` - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(payload)), - Header: make(http.Header), - }, nil case r.Method == http.MethodPatch && r.URL.Path == "/api/users": gotPatch = true require.NoError(t, json.NewDecoder(r.Body).Decode(&patchReq)) @@ -214,9 +205,13 @@ func TestExtendUserSubscription_EnableAndPatch(t *testing.T) { err := client.ExtendUserSubscription("uuid-1", 30) require.NoError(t, err) - require.True(t, gotEnable) require.True(t, gotPatch) + // EnableUser теперь делает PATCH с Status=ACTIVE, ExpireAt и TrafficLimitBytes=0 + require.NotNil(t, patchReq.Status) + require.Equal(t, StatusActive, *patchReq.Status) require.NotNil(t, patchReq.ExpireAt) + require.NotNil(t, patchReq.TrafficLimitBytes) + require.Equal(t, int64(0), *patchReq.TrafficLimitBytes) } func TestExtendUserSubscription_RejectTooEarly(t *testing.T) { From bd0448da5189dac86edbed02c97c94edb3be5508 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 11:49:15 +0300 Subject: [PATCH 11/34] =?UTF-8?q?feat:=20=D1=8D=D1=82=D0=B0=D0=BF=205=20?= =?UTF-8?q?=E2=80=94=20event-driven=20scheduler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Переработка scheduler: ticker 30 мин + первый проход при старте, разделение логики триал/оплаченная подписка, grace period 72ч, retry confirmed_not_activated, maintenance mode, HasConfirmedPaymentSince --- internal/bot/scheduler.go | 320 +++++++++++++++++--------- internal/bot/scheduler_test.go | 403 +++++++++++++++++++++++++++++---- internal/database/payments.go | 12 + 3 files changed, 590 insertions(+), 145 deletions(-) diff --git a/internal/bot/scheduler.go b/internal/bot/scheduler.go index 4e462f7..dc9f46d 100644 --- a/internal/bot/scheduler.go +++ b/internal/bot/scheduler.go @@ -13,46 +13,28 @@ import ( ) const ( - notificationExpire3d = "expire_3d" - notificationExpireToday = "expire_today" + // Триал + notificationTrialExpire1d = "trial_expire_1d" // За 1 день до конца триала + notificationTrialExpired = "trial_expired" // Триал истёк + + // Оплаченная подписка + notificationExpire3d = "expire_3d" // За 3 дня до конца + notificationExpire1d = "expire_1d" // За 1 день до конца + notificationExpired = "expired" // Подписка истекла (начало grace) + notificationGraceKick = "grace_kick" // Кик после grace period ) -type subscriptionDecision struct { - ThreeDaysMessage string - ExpireTodayMessage string - ShouldKick bool -} - -// StartScheduler запускает ежедневную проверку подписок в 12:00 по Москве. +// StartScheduler запускает проверку подписок каждые 30 минут + первый проход при старте. func (b *Bot) StartScheduler(ctx context.Context) { - msk, err := time.LoadLocation("Europe/Moscow") - if err != nil { - slog.Error("Failed to load Europe/Moscow location", "error", err) - return - } - - now := time.Now().In(msk) - nextRun := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, msk) - if !now.Before(nextRun) { - nextRun = nextRun.AddDate(0, 0, 1) - } - - firstTimer := time.NewTimer(time.Until(nextRun)) - defer firstTimer.Stop() + // Первый проход при старте — не ждём 30 минут + slog.Info("Scheduler: running initial pass on startup") + b.runSubscriptionSchedulerPass() - slog.Info("Subscription scheduler initialized", "first_run", nextRun.Format(time.RFC3339)) - - select { - case <-ctx.Done(): - slog.Info("Subscription scheduler stopped before first run") - return - case <-firstTimer.C: - b.runSubscriptionSchedulerPass() - } - - ticker := time.NewTicker(24 * time.Hour) + ticker := time.NewTicker(30 * time.Minute) defer ticker.Stop() + slog.Info("Subscription scheduler started", "interval", "30m") + for { select { case <-ctx.Done(): @@ -65,6 +47,20 @@ func (b *Bot) StartScheduler(ctx context.Context) { } func (b *Bot) runSubscriptionSchedulerPass() { + now := time.Now().UTC() + + // 1. Протухание старых PENDING платежей + expired, err := b.db.ExpireOldPendingPayments() + if err != nil { + slog.Error("Scheduler: ошибка при протухании pending платежей", "error", err) + } else if expired > 0 { + slog.Info("Scheduler: протухли pending платежи", "count", expired) + } + + // 2. Retry confirmed_not_activated платежей + b.retryConfirmedNotActivated() + + // 3. Получаем пользователей remUsers, err := b.remnawave.GetAllUsers() if err != nil { slog.Error("Scheduler failed to get users from Remnawave", "error", err) @@ -82,8 +78,6 @@ func (b *Bot) runSubscriptionSchedulerPass() { dbByTelegramID[user.TelegramID] = user } - now := time.Now().UTC() - for _, user := range remUsers { if user.TelegramID == nil || *user.TelegramID == 0 { continue @@ -95,59 +89,213 @@ func (b *Bot) runSubscriptionSchedulerPass() { continue } + // Бесконечная подписка — пропуск if user.ExpireAt.Year() >= 2099 { continue } - invite, err := b.db.GetInviteByUsedBy(telegramID) - if err != nil { - slog.Error("Scheduler failed to get invite by used_by", "error", err, "telegram_id", telegramID) - continue + // Определяем тип подписки + isTrial := b.isTrialUser(telegramID) + + if isTrial { + b.processTrialUser(telegramID, dbUser, user.ExpireAt, now) + } else { + b.processPaidUser(telegramID, dbUser, user.ExpireAt, now) } + } +} - // Админские (бессрочные) и старые записи без инвайта не участвуют в монетизационной логике. - if invite == nil || invite.ExpireDays == nil { - continue +// processTrialUser обрабатывает триального пользователя. +// Триал: уведомление за 1 день → кик при expireAt (без grace period). +func (b *Bot) processTrialUser(telegramID int64, dbUser database.User, expireAt, now time.Time) { + expireDay := dayUTC(expireAt) + nowDay := dayUTC(now) + + // За 1 день до конца триала + if expireDay.Equal(nowDay.AddDate(0, 0, 1)) { + b.sendNotification(telegramID, notificationTrialExpire1d, + "⏳ Ваш пробный период заканчивается завтра.\n\nОплатите подписку, чтобы сохранить доступ к VPN.") + } + + // Триал истёк — кик + if !expireDay.After(nowDay) { + if b.maintenanceMode { + slog.Info("Scheduler: maintenance mode, пропускаем кик триального пользователя", "telegram_id", telegramID) + return + } + + // Защита: проверяем, не оплатил ли пользователь + hasPaid, err := b.db.HasConfirmedPaymentSince(telegramID, expireAt.Add(-24*time.Hour)) + if err != nil { + slog.Error("Scheduler: ошибка проверки оплаты при кике триала", "error", err, "telegram_id", telegramID) + return } + if hasPaid { + slog.Info("Scheduler: пользователь оплатил во время триала, пропускаем кик", "telegram_id", telegramID) + return + } + + b.sendNotification(telegramID, notificationTrialExpired, + "❌ Ваш пробный период закончился.\n\nДля продолжения использования VPN оплатите подписку по новому приглашению.") + b.handleAutoKick(telegramID, dbUser.RemnawaveUUID) + } +} + +// processPaidUser обрабатывает пользователя с оплаченной подпиской. +// Уведомления за 3д/1д → disable при expireAt → кик через 72ч grace. +func (b *Bot) processPaidUser(telegramID int64, dbUser database.User, expireAt, now time.Time) { + expireDay := dayUTC(expireAt) + nowDay := dayUTC(now) + + // За 3 дня до конца + if expireDay.Equal(nowDay.AddDate(0, 0, 3)) { + b.sendNotification(telegramID, notificationExpire3d, + "⏳ Ваша подписка заканчивается через 3 дня.\n\nНажмите \"💳 Продлить подписку\" чтобы продлить доступ.") + } - curatorActive := b.isModerator(invite.CreatedBy) + // За 1 день до конца + if expireDay.Equal(nowDay.AddDate(0, 0, 1)) { + b.sendNotification(telegramID, notificationExpire1d, + "⚠️ Ваша подписка заканчивается завтра!\n\nПродлите сейчас, чтобы не потерять доступ к VPN.") + } - sent3d, err := b.db.WasNotificationSent(telegramID, notificationExpire3d) + // Подписка истекла — disable + начало grace period + if !expireDay.After(nowDay) { + // Защита: проверяем, не оплатил ли пользователь после expireAt + hasPaid, err := b.db.HasConfirmedPaymentSince(telegramID, expireAt.Add(-24*time.Hour)) if err != nil { - slog.Error("Scheduler failed to check expire_3d marker", "error", err, "telegram_id", telegramID) - continue + slog.Error("Scheduler: ошибка проверки оплаты", "error", err, "telegram_id", telegramID) + return + } + if hasPaid { + return // Оплатил — callback уже обработал + } + + if !b.maintenanceMode { + // Disable в Remnawave (если ещё не disabled) + if err := b.remnawave.DisableUser(dbUser.RemnawaveUUID); err != nil { + slog.Warn("Scheduler: не удалось disable пользователя", "error", err, "telegram_id", telegramID) + } + } + + b.sendNotification(telegramID, notificationExpired, + "⚠️ Ваша подписка истекла. VPN деактивирован.\n\nУ вас есть 3 дня, чтобы оплатить и восстановить доступ.\nПосле этого аккаунт будет удалён.") + } + + // Grace period кик: expireAt + 72 часа + graceDeadline := expireAt.Add(72 * time.Hour) + if now.After(graceDeadline) { + if b.maintenanceMode { + slog.Info("Scheduler: maintenance mode, пропускаем grace kick", "telegram_id", telegramID) + return } - sentToday, err := b.db.WasNotificationSent(telegramID, notificationExpireToday) + + // Защита: проверяем оплату за весь grace period + hasPaid, err := b.db.HasConfirmedPaymentSince(telegramID, expireAt.Add(-24*time.Hour)) if err != nil { - slog.Error("Scheduler failed to check expire_today marker", "error", err, "telegram_id", telegramID) - continue + slog.Error("Scheduler: ошибка проверки оплаты перед grace kick", "error", err, "telegram_id", telegramID) + return + } + if hasPaid { + return } - decision := decideSubscriptionActions(user.ExpireAt, now, curatorActive, sent3d, sentToday) + // Перед киком проверяем свежий статус через API — вдруг callback прошёл + freshUser, err := b.remnawave.GetUser(dbUser.RemnawaveUUID) + if err == nil && freshUser.Status == "ACTIVE" && freshUser.ExpireAt.After(now) { + slog.Info("Scheduler: пользователь активен при проверке перед grace kick, пропускаем", + "telegram_id", telegramID) + return + } - if decision.ThreeDaysMessage != "" { - if err := b.sendSchedulerMessage(telegramID, decision.ThreeDaysMessage); err == nil { - if err := b.db.MarkNotificationSent(telegramID, notificationExpire3d); err != nil { - slog.Error("Scheduler failed to persist expire_3d marker", "error", err, "telegram_id", telegramID) - } - } else { - logSchedulerSendError("expire_3d", telegramID, err) - } + b.sendNotification(telegramID, notificationGraceKick, + "❌ Ваш доступ удалён. Вы можете получить новое приглашение для повторного подключения.") + b.handleAutoKick(telegramID, dbUser.RemnawaveUUID) + } +} + +// isTrialUser проверяет, находится ли пользователь на триале. +// Триальный = приглашён модераторским инвайтом (expire_days != NULL) И ни разу не платил. +func (b *Bot) isTrialUser(telegramID int64) bool { + invite, err := b.db.GetInviteByUsedBy(telegramID) + if err != nil || invite == nil || invite.ExpireDays == nil { + return false // Админский инвайт или нет инвайта — не триал + } + + hasPaid, err := b.db.HasConfirmedPayment(telegramID) + if err != nil { + return false + } + return !hasPaid +} + +// retryConfirmedNotActivated повторяет активацию для платежей со статусом confirmed_not_activated +func (b *Bot) retryConfirmedNotActivated() { + payments, err := b.db.GetConfirmedNotActivated() + if err != nil { + slog.Error("Scheduler: ошибка получения confirmed_not_activated", "error", err) + return + } + + if len(payments) == 0 { + return + } + + slog.Info("Scheduler: retry confirmed_not_activated", "count", len(payments)) + handler := &paymentCallbackHandler{bot: b} + + for _, p := range payments { + payment := p // копируем для замыкания + if err := handler.activateSubscription(&payment); err != nil { + slog.Warn("Scheduler: retry активации не удался", + "error", err, "payment_id", payment.ID, "telegram_id", payment.TelegramID) + continue } - if decision.ExpireTodayMessage != "" { - if err := b.sendSchedulerMessage(telegramID, decision.ExpireTodayMessage); err == nil { - if err := b.db.MarkNotificationSent(telegramID, notificationExpireToday); err != nil { - slog.Error("Scheduler failed to persist expire_today marker", "error", err, "telegram_id", telegramID) - } - } else { - logSchedulerSendError("expire_today", telegramID, err) - } + // Успешно активирован — обновляем статус + if err := b.db.ConfirmPayment(payment.ID); err != nil { + slog.Error("Scheduler: ошибка обновления статуса после retry", + "error", err, "payment_id", payment.ID) + continue } - if decision.ShouldKick { - b.handleAutoKick(telegramID, dbUser.RemnawaveUUID) + // Создаём earnings + handler.createEarningRecord(&payment) + + // Уведомляем пользователя + remUser, _ := b.remnawave.GetUserByTelegramID(payment.TelegramID) + var msg string + if remUser != nil { + expireDate := remUser.ExpireAt.Format("02.01.2006") + msg = fmt.Sprintf("✅ Оплата прошла! Ваша подписка активна до %s.\n\nЛимит трафика снят — пользуйтесь без ограничений.", expireDate) + } else { + msg = "✅ Оплата прошла! Подписка активирована." } + _ = b.sendSchedulerMessage(payment.TelegramID, msg) + b.db.ClearNotifications(payment.TelegramID) + + slog.Info("Scheduler: retry активации успешен", "payment_id", payment.ID, "telegram_id", payment.TelegramID) + } +} + +// sendNotification отправляет уведомление, если оно ещё не было отправлено +func (b *Bot) sendNotification(telegramID int64, notificationType, message string) { + sent, err := b.db.WasNotificationSent(telegramID, notificationType) + if err != nil { + slog.Error("Scheduler: ошибка проверки уведомления", "error", err, "type", notificationType, "telegram_id", telegramID) + return + } + if sent { + return + } + + if err := b.sendSchedulerMessage(telegramID, message); err != nil { + logSchedulerSendError(notificationType, telegramID, err) + return + } + + if err := b.db.MarkNotificationSent(telegramID, notificationType); err != nil { + slog.Error("Scheduler: ошибка сохранения маркера уведомления", "error", err, "type", notificationType, "telegram_id", telegramID) } } @@ -178,20 +326,17 @@ func (b *Bot) handleAutoKick(telegramID int64, userUUID string) { if err := b.db.ClearNotifications(telegramID); err != nil { slog.Warn("Scheduler failed to clear notifications during auto-kick", "error", err, "telegram_id", telegramID) } - - _ = b.sendSchedulerMessage(telegramID, "❌ Ваш доступ удалён. Вы можете получить новое приглашение для повторного подключения.") } func (b *Bot) sendSchedulerMessage(telegramID int64, message string) error { if b.bot == nil { return fmt.Errorf("telegram bot is not initialized") } - _, err := b.bot.Send(&tele.User{ID: telegramID}, message) + _, err := b.bot.Send(&tele.User{ID: telegramID}, message, &tele.SendOptions{ParseMode: tele.ModeHTML}) return err } // isSchedulerForbiddenError проверяет, заблокировал ли пользователь бот или деактивирован. -// Использует errors.Is вместо хрупкого strings.Contains("403"). func isSchedulerForbiddenError(err error) bool { return errors.Is(err, tele.ErrBlockedByUser) || errors.Is(err, tele.ErrUserIsDeactivated) || @@ -206,35 +351,6 @@ func logSchedulerSendError(msgType string, telegramID int64, err error) { slog.Warn("Scheduler failed to send message", "type", msgType, "telegram_id", telegramID, "error", err) } -func decideSubscriptionActions(expireAt, now time.Time, curatorActive bool, sent3d, sentToday bool) subscriptionDecision { - expireDay := dayUTC(expireAt) - nowDay := dayUTC(now) - - decision := subscriptionDecision{} - - if expireDay.Equal(nowDay.AddDate(0, 0, 3)) && !sent3d { - if curatorActive { - decision.ThreeDaysMessage = "⏳ Ваша подписка заканчивается через 3 дня.\nОбратитесь к вашему куратору для продления." - } else { - decision.ThreeDaysMessage = "⏳ Ваша подписка заканчивается через 3 дня.\nВаш куратор больше не обслуживает подписки.\nПодписка не будет продлена." - } - } - - if !expireDay.After(nowDay) && !sentToday { - if curatorActive { - decision.ExpireTodayMessage = "⚠️ Ваша подписка истекла.\nУ вас есть 3 дня, чтобы продлить через куратора,\nиначе доступ будет удалён." - } else { - decision.ExpireTodayMessage = "⚠️ Ваша подписка истекла.\nВаш куратор больше не обслуживает подписки.\nДоступ будет удалён через 3 дня." - } - } - - if expireDay.AddDate(0, 0, 3).Before(nowDay) { - decision.ShouldKick = true - } - - return decision -} - func dayUTC(t time.Time) time.Time { utc := t.UTC() return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC) diff --git a/internal/bot/scheduler_test.go b/internal/bot/scheduler_test.go index 7a0774a..17e9424 100644 --- a/internal/bot/scheduler_test.go +++ b/internal/bot/scheduler_test.go @@ -1,6 +1,7 @@ package bot import ( + "encoding/json" "fmt" "io" "net/http" @@ -41,7 +42,6 @@ func setupSchedulerTestBot(t *testing.T) (*Bot, *database.DB) { func TestHandleAutoKick_404IsNotFatalError(t *testing.T) { b, db := setupSchedulerTestBot(t) - // Создаём пользователя в БД бота _, err := db.CreateUser(700, "victim", "Victim", "uuid-700", nil, nil) require.NoError(t, err) modID := int64(50) @@ -52,7 +52,6 @@ func TestHandleAutoKick_404IsNotFatalError(t *testing.T) { require.NoError(t, err) require.NoError(t, db.ClaimInvite(inv.Code, 700)) - // Настраиваем Remnawave — DELETE возвращает 404 (пользователь уже удалён) client := remnawave.NewClient("https://panel.example.com", "test-token", nil) client.SetHTTPClient(&http.Client{ Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { @@ -68,15 +67,12 @@ func TestHandleAutoKick_404IsNotFatalError(t *testing.T) { }) b.remnawave = client - // Выполняем автокик b.handleAutoKick(700, "uuid-700") - // Несмотря на 404 в Remnawave, пользователь должен быть удалён из БД бота dbUser, err := db.GetUserByTelegramID(700) require.NoError(t, err) assert.Nil(t, dbUser, "пользователь должен быть удалён из БД даже если Remnawave вернул 404") - // Инвайт должен быть помечен как кикнутый: used_by остаётся (история), kicked_at проставлен invite, err := db.GetInviteByCode(inv.Code) require.NoError(t, err) require.NotNil(t, invite) @@ -84,72 +80,393 @@ func TestHandleAutoKick_404IsNotFatalError(t *testing.T) { assert.NotNil(t, invite.KickedAt, "kicked_at должен быть проставлен после автокика") } -// TestHandleAutoKick_SkipsAlreadyDeletedInRemnawave проверяет, что функция классификации -// 404-ошибки работает корректно. func TestHandleAutoKick_SkipsAlreadyDeletedInRemnawave(t *testing.T) { err := fmt.Errorf("API error 404: not found") - assert.True(t, isAutoKickNotFoundError(err), "API error 404 должна распознаваться как not found") + assert.True(t, isAutoKickNotFoundError(err)) otherErr := fmt.Errorf("API error 500: internal server error") - assert.False(t, isAutoKickNotFoundError(otherErr), "API error 500 не должна распознаваться как not found") + assert.False(t, isAutoKickNotFoundError(otherErr)) } -// TestIsSchedulerForbiddenError проверяет что функция корректно распознаёт -// telebot-ошибки типа "бот заблокирован" без хрупкого strings.Contains("403"). func TestIsSchedulerForbiddenError(t *testing.T) { t.Run("ErrBlockedByUser распознаётся", func(t *testing.T) { assert.True(t, isSchedulerForbiddenError(tele.ErrBlockedByUser)) }) - t.Run("ErrUserIsDeactivated распознаётся", func(t *testing.T) { assert.True(t, isSchedulerForbiddenError(tele.ErrUserIsDeactivated)) }) - t.Run("ErrNotStartedByUser распознаётся", func(t *testing.T) { assert.True(t, isSchedulerForbiddenError(tele.ErrNotStartedByUser)) }) - - t.Run("Обычная ошибка НЕ распознаётся как Forbidden", func(t *testing.T) { + t.Run("Обычная ошибка НЕ распознаётся", func(t *testing.T) { assert.False(t, isSchedulerForbiddenError(fmt.Errorf("network timeout"))) }) - - t.Run("Строковая ошибка с 403 НЕ распознаётся (хрупкий паттерн устранён)", func(t *testing.T) { - // Убеждаемся что новый код НЕ полагается на строку "403" + t.Run("Строковая ошибка с 403 НЕ распознаётся", func(t *testing.T) { assert.False(t, isSchedulerForbiddenError(fmt.Errorf("some error with 403 code"))) }) } -func TestDecideSubscriptionActions(t *testing.T) { - now := time.Date(2026, time.March, 4, 12, 0, 0, 0, time.UTC) +// TestIsTrialUser проверяет определение типа подписки +func TestIsTrialUser(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(100) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(200) + price := 400 + _, err = db.CreateUser(userID, "user", "User", "uuid-200", &price, &modID) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) - t.Run("За 3 дня до истечения", func(t *testing.T) { - expireAt := time.Date(2026, time.March, 7, 0, 0, 0, 0, time.UTC) - decision := decideSubscriptionActions(expireAt, now, true, false, false) - assert.NotEmpty(t, decision.ThreeDaysMessage) - assert.Empty(t, decision.ExpireTodayMessage) - assert.False(t, decision.ShouldKick) + t.Run("пользователь без оплаты — триал", func(t *testing.T) { + assert.True(t, b.isTrialUser(userID)) }) - t.Run("Подписка истекла сегодня", func(t *testing.T) { - expireAt := time.Date(2026, time.March, 4, 0, 0, 0, 0, time.UTC) - decision := decideSubscriptionActions(expireAt, now, false, true, false) - assert.Empty(t, decision.ThreeDaysMessage) - assert.NotEmpty(t, decision.ExpireTodayMessage) - assert.Contains(t, decision.ExpireTodayMessage, "куратор больше не обслуживает") - assert.False(t, decision.ShouldKick) + t.Run("после оплаты — не триал", func(t *testing.T) { + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + + assert.False(t, b.isTrialUser(userID)) }) - t.Run("Автокик через 3 дня после истечения", func(t *testing.T) { - expireAt := time.Date(2026, time.February, 28, 0, 0, 0, 0, time.UTC) - decision := decideSubscriptionActions(expireAt, now, true, true, true) - assert.True(t, decision.ShouldKick) + t.Run("админский инвайт — не триал", func(t *testing.T) { + adminUserID := int64(300) + _, err := db.CreateUser(adminUserID, "admin_user", "Admin User", "uuid-300", nil, nil) + require.NoError(t, err) + + invAdmin, err := db.CreateInviteWithExpiry(999, nil) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(invAdmin.Code, adminUserID)) + + assert.False(t, b.isTrialUser(adminUserID)) }) +} + +// TestSchedulerTrialKick проверяет кик триального пользователя после expireAt +func TestSchedulerTrialKick(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(100) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(200) + price := 400 + _, err = db.CreateUser(userID, "user", "User", "uuid-200", &price, &modID) + require.NoError(t, err) - t.Run("Повторные уведомления не отправляются", func(t *testing.T) { - expireAt := time.Date(2026, time.March, 7, 0, 0, 0, 0, time.UTC) - decision := decideSubscriptionActions(expireAt, now, true, true, true) - assert.Empty(t, decision.ThreeDaysMessage) - assert.Empty(t, decision.ExpireTodayMessage) - assert.False(t, decision.ShouldKick) + expireDays := 3 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + }), }) + b.remnawave = client + + // expireAt вчера — триал истёк + yesterday := time.Now().UTC().AddDate(0, 0, -1) + b.processTrialUser(userID, database.User{TelegramID: userID, RemnawaveUUID: "uuid-200"}, yesterday, time.Now().UTC()) + + dbUser, err := db.GetUserByTelegramID(userID) + require.NoError(t, err) + assert.Nil(t, dbUser, "триальный пользователь должен быть удалён после expireAt") +} + +// TestSchedulerTrialNotKickedIfPaid проверяет, что оплативший триальный не кикается +func TestSchedulerTrialNotKickedIfPaid(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(100) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(201) + price := 400 + _, err = db.CreateUser(userID, "user_paid_trial", "User", "uuid-201", &price, &modID) + require.NoError(t, err) + + expireDays := 3 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + + // Платёж подтверждён только что — isTrialUser вернёт false, но processTrialUser + // проверяет HasConfirmedPaymentSince и пропустит кик + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + + // expireAt вчера + yesterday := time.Now().UTC().AddDate(0, 0, -1) + b.processTrialUser(userID, database.User{TelegramID: userID, RemnawaveUUID: "uuid-201"}, yesterday, time.Now().UTC()) + + dbUser, err := db.GetUserByTelegramID(userID) + require.NoError(t, err) + assert.NotNil(t, dbUser, "оплативший пользователь не должен быть кикнут") +} + +// TestSchedulerPaidDisableAndGraceKick проверяет disable при expireAt и кик через 72ч +func TestSchedulerPaidDisableAndGraceKick(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(100) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(300) + price := 400 + _, err = db.CreateUser(userID, "paiduser", "PaidUser", "uuid-300", &price, &modID) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + + // Делаем пользователя оплаченным (не триал), подтверждение 60 дней назад + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = datetime('now', '-60 days') WHERE id = ?`, id) + require.NoError(t, err) + + t.Run("disable при expireAt", func(t *testing.T) { + var disableCalled bool + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodPatch { + disableCalled = true + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + } + return nil, fmt.Errorf("unexpected: %s %s", r.Method, r.URL.Path) + }), + }) + b.remnawave = client + + yesterday := time.Now().UTC().AddDate(0, 0, -1) + b.processPaidUser(userID, database.User{TelegramID: userID, RemnawaveUUID: "uuid-300"}, yesterday, time.Now().UTC()) + + assert.True(t, disableCalled, "DisableUser должен быть вызван при expireAt") + }) + + t.Run("кик через 72ч grace", func(t *testing.T) { + var deleteCalled bool + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/users/uuid-300") { + user := remnawave.User{ + UUID: "uuid-300", + Status: "DISABLED", + ExpireAt: time.Now().UTC().AddDate(0, 0, -5), + } + body, _ := json.Marshal(map[string]interface{}{"response": user}) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(body))), + Header: make(http.Header), + }, nil + } + if r.Method == http.MethodPatch { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + } + if r.Method == http.MethodDelete { + deleteCalled = true + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + } + return nil, fmt.Errorf("unexpected: %s %s", r.Method, r.URL.Path) + }), + }) + b.remnawave = client + + // expireAt 4 дня назад — grace period (72ч) истёк + expireAt := time.Now().UTC().Add(-96 * time.Hour) + b.processPaidUser(userID, database.User{TelegramID: userID, RemnawaveUUID: "uuid-300"}, expireAt, time.Now().UTC()) + + assert.True(t, deleteCalled, "пользователь должен быть удалён после grace period") + }) +} + +// TestSchedulerMaintenanceMode проверяет, что в maintenance mode кики и disable не выполняются +func TestSchedulerMaintenanceMode(t *testing.T) { + b, db := setupSchedulerTestBot(t) + b.maintenanceMode = true + + modID := int64(100) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + t.Run("триал: не кикает в maintenance mode", func(t *testing.T) { + userID := int64(400) + price := 400 + _, err := db.CreateUser(userID, "trial_maint", "Trial", "uuid-400", &price, &modID) + require.NoError(t, err) + + expireDays := 3 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + + yesterday := time.Now().UTC().AddDate(0, 0, -1) + b.processTrialUser(userID, database.User{TelegramID: userID, RemnawaveUUID: "uuid-400"}, yesterday, time.Now().UTC()) + + dbUser, err := db.GetUserByTelegramID(userID) + require.NoError(t, err) + assert.NotNil(t, dbUser, "в maintenance mode пользователь не должен быть кикнут") + }) + + t.Run("grace: не кикает в maintenance mode", func(t *testing.T) { + userID := int64(500) + price := 400 + _, err := db.CreateUser(userID, "paid_maint", "Paid", "uuid-500", &price, &modID) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + + // Оплата 60 дней назад + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + } + pid, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(pid)) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = datetime('now', '-60 days') WHERE id = ?`, pid) + require.NoError(t, err) + + expireAt := time.Now().UTC().Add(-96 * time.Hour) + b.processPaidUser(userID, database.User{TelegramID: userID, RemnawaveUUID: "uuid-500"}, expireAt, time.Now().UTC()) + + dbUser, err := db.GetUserByTelegramID(userID) + require.NoError(t, err) + assert.NotNil(t, dbUser, "в maintenance mode пользователь не должен быть кикнут при grace") + }) +} + +// TestSchedulerRetryConfirmedNotActivated проверяет retry подтверждённых, но не активированных платежей +func TestSchedulerRetryConfirmedNotActivated(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + userID := int64(600) + _, err := db.CreateUser(userID, "retry_user", "Retry", "uuid-600", nil, nil) + require.NoError(t, err) + + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + require.NoError(t, db.UpdatePaymentStatus(id, "confirmed_not_activated")) + + var enableCalled bool + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/users/uuid-600") { + user := remnawave.User{ + UUID: "uuid-600", + Status: "EXPIRED", + ExpireAt: time.Now().UTC().AddDate(0, 0, -1), + } + body, _ := json.Marshal(map[string]interface{}{"response": user}) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(body))), + Header: make(http.Header), + }, nil + } + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/users/by-telegram-id") { + user := remnawave.User{ + UUID: "uuid-600", + Status: "ACTIVE", + ExpireAt: time.Now().UTC().AddDate(0, 1, 0), + } + body, _ := json.Marshal(map[string]interface{}{"response": user}) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(body))), + Header: make(http.Header), + }, nil + } + if r.Method == http.MethodPatch { + enableCalled = true + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + } + return nil, fmt.Errorf("unexpected: %s %s", r.Method, r.URL.Path) + }), + }) + b.remnawave = client + + b.retryConfirmedNotActivated() + + assert.True(t, enableCalled, "EnableUser должен быть вызван при retry confirmed_not_activated") + + p, err := db.GetPaymentByID(id) + require.NoError(t, err) + assert.Equal(t, "confirmed", p.Status, "статус должен стать confirmed после retry") } diff --git a/internal/database/payments.go b/internal/database/payments.go index 13e9d48..514da11 100644 --- a/internal/database/payments.go +++ b/internal/database/payments.go @@ -233,6 +233,18 @@ func (db *DB) HasConfirmedPayment(telegramID int64) (bool, error) { return exists, err } +// HasConfirmedPaymentSince проверяет, есть ли подтверждённый платёж после указанной даты. +// Используется scheduler для защиты от ложного кика/disable — если пользователь оплатил +// после expireAt, подписка уже активирована через callback. +func (db *DB) HasConfirmedPaymentSince(telegramID int64, since time.Time) (bool, error) { + var exists bool + err := db.conn.QueryRow( + `SELECT EXISTS(SELECT 1 FROM payments WHERE telegram_id = ? AND status = 'confirmed' AND confirmed_at >= ?)`, + telegramID, since, + ).Scan(&exists) + return exists, err +} + // CountConfirmedPaymentsByMonth считает платежи за месяц (для статистики) func (db *DB) CountConfirmedPaymentsByMonth(year int, month int) (int, error) { var count int From caecad7bf6bad2c9aa2024b0cf4e90c1543a114b Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 12:16:40 +0300 Subject: [PATCH 12/34] =?UTF-8?q?feat:=20=D1=8D=D1=82=D0=B0=D0=BF=205=20?= =?UTF-8?q?=E2=80=94=20event-driven=20scheduler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/progress/2026-03-23-payment-stage-5.md | 41 ++++++ internal/bot/scheduler.go | 38 +++--- internal/bot/scheduler_test.go | 135 ++++++++++++++++++++ 3 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 docs/progress/2026-03-23-payment-stage-5.md diff --git a/docs/progress/2026-03-23-payment-stage-5.md b/docs/progress/2026-03-23-payment-stage-5.md new file mode 100644 index 0000000..1ad1413 --- /dev/null +++ b/docs/progress/2026-03-23-payment-stage-5.md @@ -0,0 +1,41 @@ +# Этап 5: Event-driven scheduler + +**Дата:** 2026-03-23 +**План:** [2026-03-22-payment-implementation-plan.md](../plans/2026-03-22-payment-implementation-plan.md), строки 1501–1631 +**Коммит:** `feat: этап 5 — event-driven scheduler` + +## Что сделано + +### scheduler.go — полная переработка +- **Новые константы уведомлений:** `trial_expire_1d`, `trial_expired`, `expire_3d`, `expire_1d`, `expired`, `grace_kick` +- **StartScheduler:** ticker 30 минут + первый проход при старте (вместо 24ч в 12:00 МСК) +- **runSubscriptionSchedulerPass:** + - `ExpireOldPendingPayments()` — протухание PENDING платежей + - `retryConfirmedNotActivated()` — retry активации после сбоя + - Для каждого пользователя: бесконечные → пропуск, триал → кик при точном `expireAt`, оплаченная → disable + grace 72ч +- **processTrialUser:** уведомление менее чем за 24 часа, кик при точном `expireAt` (без grace period) +- **processPaidUser:** уведомления в окнах 3д/1д, disable при точном `expireAt`, кик через 72ч grace +- **isTrialUser:** invite.ExpireDays != NULL + нет confirmed платежей +- **Защита от ложного кика:** `HasConfirmedPaymentSince(expireAt)` + свежий статус через API перед grace kick +- **maintenanceMode:** блокирует кик и disable +- **sendNotification:** вспомогательная функция с идемпотентной отправкой +- **sendSchedulerMessage:** добавлен ParseMode HTML для поддержки форматирования + +### payments.go +- **HasConfirmedPaymentSince:** проверка подтверждённого платежа после указанной даты + +### scheduler_test.go — новые тесты +- `TestIsTrialUser` — определение типа подписки (триал/оплаченная/админская) +- `TestSchedulerTrialKick` — кик триального после expireAt +- `TestSchedulerTrialWaitsForExactExpireAt` — триал не кикается раньше точного времени +- `TestSchedulerTrialNotKickedIfPaid` — защита от кика оплатившего +- `TestSchedulerPaidDisableAndGraceKick` — disable + кик через 72ч +- `TestSchedulerPaidWaitsForExactExpireAt` — disable не происходит раньше времени +- `TestSchedulerPaidDisableIgnoresPaymentsBeforeExpireAt` — старые оплаты не блокируют expire-обработку +- `TestSchedulerMaintenanceMode` — maintenance блокирует кики +- `TestSchedulerRetryConfirmedNotActivated` — retry активации + +## Удалено +- `decideSubscriptionActions` — заменена на `processTrialUser`/`processPaidUser` +- `subscriptionDecision` struct — больше не нужна +- `notificationExpireToday` — заменена на раздельные типы diff --git a/internal/bot/scheduler.go b/internal/bot/scheduler.go index dc9f46d..f4ddb8f 100644 --- a/internal/bot/scheduler.go +++ b/internal/bot/scheduler.go @@ -108,24 +108,21 @@ func (b *Bot) runSubscriptionSchedulerPass() { // processTrialUser обрабатывает триального пользователя. // Триал: уведомление за 1 день → кик при expireAt (без grace period). func (b *Bot) processTrialUser(telegramID int64, dbUser database.User, expireAt, now time.Time) { - expireDay := dayUTC(expireAt) - nowDay := dayUTC(now) - // За 1 день до конца триала - if expireDay.Equal(nowDay.AddDate(0, 0, 1)) { + if notificationWindow(now, expireAt, 0, 24*time.Hour) { b.sendNotification(telegramID, notificationTrialExpire1d, - "⏳ Ваш пробный период заканчивается завтра.\n\nОплатите подписку, чтобы сохранить доступ к VPN.") + "⏳ Ваш пробный период заканчивается менее чем через 24 часа.\n\nОплатите подписку, чтобы сохранить доступ к VPN.") } // Триал истёк — кик - if !expireDay.After(nowDay) { + if !now.Before(expireAt) { if b.maintenanceMode { slog.Info("Scheduler: maintenance mode, пропускаем кик триального пользователя", "telegram_id", telegramID) return } // Защита: проверяем, не оплатил ли пользователь - hasPaid, err := b.db.HasConfirmedPaymentSince(telegramID, expireAt.Add(-24*time.Hour)) + hasPaid, err := b.db.HasConfirmedPaymentSince(telegramID, expireAt) if err != nil { slog.Error("Scheduler: ошибка проверки оплаты при кике триала", "error", err, "telegram_id", telegramID) return @@ -144,25 +141,22 @@ func (b *Bot) processTrialUser(telegramID int64, dbUser database.User, expireAt, // processPaidUser обрабатывает пользователя с оплаченной подпиской. // Уведомления за 3д/1д → disable при expireAt → кик через 72ч grace. func (b *Bot) processPaidUser(telegramID int64, dbUser database.User, expireAt, now time.Time) { - expireDay := dayUTC(expireAt) - nowDay := dayUTC(now) - // За 3 дня до конца - if expireDay.Equal(nowDay.AddDate(0, 0, 3)) { + if notificationWindow(now, expireAt, 48*time.Hour, 72*time.Hour) { b.sendNotification(telegramID, notificationExpire3d, "⏳ Ваша подписка заканчивается через 3 дня.\n\nНажмите \"💳 Продлить подписку\" чтобы продлить доступ.") } // За 1 день до конца - if expireDay.Equal(nowDay.AddDate(0, 0, 1)) { + if notificationWindow(now, expireAt, 0, 24*time.Hour) { b.sendNotification(telegramID, notificationExpire1d, - "⚠️ Ваша подписка заканчивается завтра!\n\nПродлите сейчас, чтобы не потерять доступ к VPN.") + "⚠️ Ваша подписка заканчивается менее чем через 24 часа.\n\nПродлите сейчас, чтобы не потерять доступ к VPN.") } // Подписка истекла — disable + начало grace period - if !expireDay.After(nowDay) { + if !now.Before(expireAt) { // Защита: проверяем, не оплатил ли пользователь после expireAt - hasPaid, err := b.db.HasConfirmedPaymentSince(telegramID, expireAt.Add(-24*time.Hour)) + hasPaid, err := b.db.HasConfirmedPaymentSince(telegramID, expireAt) if err != nil { slog.Error("Scheduler: ошибка проверки оплаты", "error", err, "telegram_id", telegramID) return @@ -184,14 +178,14 @@ func (b *Bot) processPaidUser(telegramID int64, dbUser database.User, expireAt, // Grace period кик: expireAt + 72 часа graceDeadline := expireAt.Add(72 * time.Hour) - if now.After(graceDeadline) { + if !now.Before(graceDeadline) { if b.maintenanceMode { slog.Info("Scheduler: maintenance mode, пропускаем grace kick", "telegram_id", telegramID) return } // Защита: проверяем оплату за весь grace period - hasPaid, err := b.db.HasConfirmedPaymentSince(telegramID, expireAt.Add(-24*time.Hour)) + hasPaid, err := b.db.HasConfirmedPaymentSince(telegramID, expireAt) if err != nil { slog.Error("Scheduler: ошибка проверки оплаты перед grace kick", "error", err, "telegram_id", telegramID) return @@ -351,7 +345,11 @@ func logSchedulerSendError(msgType string, telegramID int64, err error) { slog.Warn("Scheduler failed to send message", "type", msgType, "telegram_id", telegramID, "error", err) } -func dayUTC(t time.Time) time.Time { - utc := t.UTC() - return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC) +func notificationWindow(now, target time.Time, minLeft, maxLeft time.Duration) bool { + if !target.After(now) { + return false + } + + timeLeft := target.Sub(now) + return timeLeft > minLeft && timeLeft <= maxLeft } diff --git a/internal/bot/scheduler_test.go b/internal/bot/scheduler_test.go index 17e9424..61c39ab 100644 --- a/internal/bot/scheduler_test.go +++ b/internal/bot/scheduler_test.go @@ -33,6 +33,7 @@ func setupSchedulerTestBot(t *testing.T) (*Bot, *database.DB) { db: db, config: cfg, userStates: newStateMap(), + remnawave: remnawave.NewClient("https://panel.example.com", "test-token", nil), } return b, db } @@ -196,6 +197,32 @@ func TestSchedulerTrialKick(t *testing.T) { assert.Nil(t, dbUser, "триальный пользователь должен быть удалён после expireAt") } +func TestSchedulerTrialWaitsForExactExpireAt(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(110) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(210) + price := 400 + _, err = db.CreateUser(userID, "trial_exact", "Trial", "uuid-210", &price, &modID) + require.NoError(t, err) + + expireDays := 3 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + + expireAt := time.Now().UTC().Add(2 * time.Hour) + b.processTrialUser(userID, database.User{TelegramID: userID, RemnawaveUUID: "uuid-210"}, expireAt, time.Now().UTC()) + + dbUser, err := db.GetUserByTelegramID(userID) + require.NoError(t, err) + assert.NotNil(t, dbUser, "триальный пользователь не должен кикаться раньше точного expireAt") +} + // TestSchedulerTrialNotKickedIfPaid проверяет, что оплативший триальный не кикается func TestSchedulerTrialNotKickedIfPaid(t *testing.T) { b, db := setupSchedulerTestBot(t) @@ -338,6 +365,114 @@ func TestSchedulerPaidDisableAndGraceKick(t *testing.T) { }) } +func TestSchedulerPaidWaitsForExactExpireAt(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(120) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(310) + price := 400 + _, err = db.CreateUser(userID, "paid_exact", "Paid", "uuid-310", &price, &modID) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = datetime('now', '-60 days') WHERE id = ?`, id) + require.NoError(t, err) + + var disableCalled bool + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodPatch { + disableCalled = true + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + } + return nil, fmt.Errorf("unexpected: %s %s", r.Method, r.URL.Path) + }), + }) + b.remnawave = client + + expireAt := time.Now().UTC().Add(2 * time.Hour) + b.processPaidUser(userID, database.User{TelegramID: userID, RemnawaveUUID: "uuid-310"}, expireAt, time.Now().UTC()) + + assert.False(t, disableCalled, "оплаченный пользователь не должен disable-иться раньше точного expireAt") +} + +func TestSchedulerPaidDisableIgnoresPaymentsBeforeExpireAt(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(130) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(320) + price := 400 + _, err = db.CreateUser(userID, "paid_before_expire", "Paid", "uuid-320", &price, &modID) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + + expireAt := time.Now().UTC().Add(-30 * time.Minute) + confirmedAt := expireAt.Add(-2 * time.Hour) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, confirmedAt, id) + require.NoError(t, err) + + var disableCalled bool + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodPatch { + disableCalled = true + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + } + return nil, fmt.Errorf("unexpected: %s %s", r.Method, r.URL.Path) + }), + }) + b.remnawave = client + + b.processPaidUser(userID, database.User{TelegramID: userID, RemnawaveUUID: "uuid-320"}, expireAt, time.Now().UTC()) + + assert.True(t, disableCalled, "старый платёж до expireAt не должен блокировать disable после истечения подписки") +} + // TestSchedulerMaintenanceMode проверяет, что в maintenance mode кики и disable не выполняются func TestSchedulerMaintenanceMode(t *testing.T) { b, db := setupSchedulerTestBot(t) From 6942b30875c7f196bcb6e1b24e5b8099dd41ae7a Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 12:16:49 +0300 Subject: [PATCH 13/34] =?UTF-8?q?feat:=20=D1=8D=D1=82=D0=B0=D0=BF=206=20?= =?UTF-8?q?=E2=80=94=20UI=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=D1=81=20=D0=BE=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 20 +- README.md | 41 ++-- docs/progress/2026-03-23-payment-stage-6.md | 55 +++++ internal/bot/handlers.go | 196 ++++++++++++++---- internal/bot/handlers_test.go | 134 +++++++++++- internal/bot/keyboards.go | 67 ++++-- internal/bot/keyboards_test.go | 79 ++++++-- internal/bot/messages.go | 213 +++++++++++++++++--- internal/bot/messages_test.go | 22 +- internal/bot/moderator.go | 2 +- internal/bot/moderator_test.go | 5 +- internal/bot/payment_handler.go | 157 +++++++++++++++ 12 files changed, 857 insertions(+), 134 deletions(-) create mode 100644 docs/progress/2026-03-23-payment-stage-6.md create mode 100644 internal/bot/payment_handler.go diff --git a/.env.example b/.env.example index 5e65df3..2aa24d6 100644 --- a/.env.example +++ b/.env.example @@ -12,5 +12,21 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2 # опционально, UUID inte # База данных DB_PATH=/app/data/bot.db -# Донат -DONATE_TEXT=Перевод по СБП: +7 999 000-00-00 (Т-Банк), Константин К. +# Мониторинг +VICTORIA_METRICS_URL=http://victoriametrics:8428 + +# Субтитры (опционально) +RENDER_URL=http://render:8080 +RENDER_API_KEY=your_render_api_key_here + +# Платежи Platega (опционально) +PLATEGA_MERCHANT_ID=your_platega_merchant_id +PLATEGA_SECRET=your_platega_secret +PLATEGA_CALLBACK_URL=https://your-domain.example.com/api/platega/callback +PLATEGA_FEE_SBP=11 +PLATEGA_FEE_CARD=12 +PLATEGA_FEE_CRYPTO=5 +PLATEGA_FEE_WITHDRAWAL=2 + +# Триал +TRIAL_TRAFFIC_LIMIT_GB=1 diff --git a/README.md b/README.md index 983bb1e..d24b312 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,17 @@ make logs | `REMNAWAVE_API_TOKEN` | ✅ | JWT-токен из панели Remnawave | | `DB_PATH` | — | Путь к SQLite-базе (дефолт: `/app/data/bot.db`) | | `REMNAWAVE_DEFAULT_SQUAD_UUIDS` | — | Список UUID internal squads через запятую; новые пользователи добавляются во все перечисленные сквады | -| `DONATE_TEXT` | — | Текст с реквизитами для кнопки "Поддержать" | | `VICTORIA_METRICS_URL` | — | URL VictoriaMetrics (дефолт: `http://victoriametrics:8428`) | +| `TRIAL_TRAFFIC_LIMIT_GB` | — | Лимит трафика для триала в ГБ (дефолт: `1`) | +| `PLATEGA_MERCHANT_ID` | — | Merchant ID Platega для пользовательской оплаты | +| `PLATEGA_SECRET` | — | Секретный ключ Platega | +| `PLATEGA_CALLBACK_URL` | — | Callback URL для подтверждения платежей | +| `PLATEGA_FEE_SBP` | — | Комиссия Platega для СБП в процентах | +| `PLATEGA_FEE_CARD` | — | Комиссия Platega для оплаты картой | +| `PLATEGA_FEE_CRYPTO` | — | Комиссия Platega для крипты | +| `PLATEGA_FEE_WITHDRAWAL` | — | Комиссия вывода средств для расчёта доли модератора | +| `RENDER_URL` | — | URL render-сервиса субтитров | +| `RENDER_API_KEY` | — | API-ключ render-сервиса | Пример: @@ -49,12 +58,11 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 | --------------- | -------------------------------------------------------------------- | | `/start` | Регистрация по инвайт-коду или вход для зарегистрированных | | `/start ` | Автоматическая активация кода из ссылки-приглашения | -| `👤 Мой статус` | Статус подписки (активен / истёк / заблокирован) и трафик за месяц | -| `🌐 Подключить` | Ссылка подписки для копирования в VPN-клиент | +| `👤 Мой статус` | Статус подписки по типу (триал / оплаченная / grace / бессрочная), трафик и ссылка | +| `💳 Оплатить подписку` / `💳 Продлить подписку` | Запуск flow оплаты: выбор способа, ссылка, ручная проверка | | `📡 Серверы` | Live-дашборд мониторинга нод (обновляется каждые 5 сек) | | `📚 Инструкции` | Инструкции по настройке клиентов: iOS, Android, ПК | -| `💸 Поддержать` | Показывает реквизиты из `DONATE_TEXT` | -| `Информация` | Помощь, контакт для вопросов и ссылки на документы сервиса | +| `ℹ️ Информация` | Помощь, контакт для вопросов и ссылки на документы сервиса | ### Модератор @@ -94,19 +102,28 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 ## Поведение в граничных случаях **Инвайт от модератора vs от администратора**: -- модераторский инвайт: подписка на 30 дней; -- админский инвайт: бессрочно (`2099-01-01`). +- модераторский инвайт: триал на 72 часа с лимитом трафика и ценой подписки из инвайта; +- админский инвайт: бессрочно (`2099-01-01`), без ограничения трафика. **Срок подписки и продление**: - модератор может продлевать только своих подписчиков; - продление добавляет 30 дней к текущему сроку (или от текущей даты, если уже истекло); -- раннее продление запрещено, если до истечения больше 30 дней. +- раннее продление через Platega запрещено, если до истечения больше 90 дней; - администратор может перевести месячную подписку в бессрочную; при этом `invites.created_by` сохраняется, а `expire_days` становится `NULL`. -**Истёкший статус** — бот ежедневно в 12:00 (MSK) проверяет подписки: -1. за 3 дня до истечения — предупреждение пользователю; -2. в день истечения — уведомление о 3-дневном grace-периоде; -3. через 3 дня после истечения — автокик (удаление из Remnawave и БД бота). +**Scheduler подписок**: +1. стартует с полным проходом при запуске бота и далее работает каждые 30 минут; +2. триал предупреждает менее чем за 24 часа и кикает сразу в точный `expireAt`; +3. оплаченную подписку предупреждает за 3 дня и менее чем за 24 часа; +4. в точный `expireAt` оплаченная подписка переводится в `DISABLED`, затем даётся 72 часа grace period; +5. после grace period пользователь удаляется, если свежая оплата не подтверждена; +6. в `maintenance mode` disable и автокики блокируются. + +**Flow оплаты**: +1. пользователь выбирает `💳 Оплатить подписку` или `💳 Продлить подписку`; +2. бот показывает методы оплаты: `🏦 СБП`, `💳 Карта`, `🪙 Крипта`; +3. после создания платежа бот отправляет ссылку и кнопку `🔄 Проверить оплату`; +4. при успешной оплате подписка активируется автоматически, лимит трафика снимается. **Бан пользователя** — перманентная операция: diff --git a/docs/progress/2026-03-23-payment-stage-6.md b/docs/progress/2026-03-23-payment-stage-6.md new file mode 100644 index 0000000..c98684f --- /dev/null +++ b/docs/progress/2026-03-23-payment-stage-6.md @@ -0,0 +1,55 @@ +# Этап 6: UI пользователя с оплатой + +**Дата:** 2026-03-23 +**План:** [2026-03-22-payment-implementation-plan.md](../plans/2026-03-22-payment-implementation-plan.md), строки 1633–1801 +**Доп. контекст:** [2026-03-21-user-ui-redesign.md](../plans/2026-03-21-user-ui-redesign.md) +**Коммит:** `feat: этап 6 — UI пользователя с оплатой` + +## Что сделано + +### keyboards.go +- Удалены старые пользовательские кнопки `BtnConnect` и `BtnDonate` +- Добавлены кнопки оплаты: `BtnPay`, `BtnRenew`, `BtnPaySBP`, `BtnPayCard`, `BtnPayCrypto`, `BtnCheckPayment` +- `BtnInfo` переименована в `ℹ️ Информация` +- Добавлена единая динамическая клавиатура `UserMenuKeyboardDynamic(payButtonText, showPayButton, isModerator)` +- Добавлены `PaymentMethodKeyboard()` и `PaymentWaitKeyboard()` + +### messages.go +- Добавлен `subscriptionType` для UI: `trial`, `paid`, `grace`, `infinite` +- `FormatUserStatus` переработан под четыре типа подписки +- Добавлен отдельный `MsgGraceWarning` для тревожного экрана при `/start` + +### payment_handler.go +- Создан обработчик пользовательского flow оплаты +- Добавлены состояния `StateWaitPaymentMethod` и `StateWaitPaymentResult` +- Реализованы: + - `handlePayButton` + - `handlePaymentMethodSelected` + - `handleCheckPayment` + +### handlers.go +- `handleTextMessage` обрабатывает: + - `BtnPay` / `BtnRenew` + - выбор метода оплаты + - `BtnCheckPayment` +- При нажатии кнопок главного меню во время payment flow состояние оплаты сбрасывается +- `handleStart` показывает тревожный экран для пользователей в grace period +- `userKeyboard` строит динамическое меню по цене, типу подписки, Platega и роли модератора +- `processInviteCode` теперь: + - создаёт trial на фиксированные `72h` + - задаёт `trafficLimitBytes` из `TRIAL_TRAFFIC_LIMIT_GB` + - копирует `subscription_price` из инвайта + - заполняет `moderator_id` только для модераторских инвайтов + +### Тесты +- Обновлены тесты клавиатур под новый UI +- Обновлены тесты `FormatUserStatus` под новую сигнатуру и grace period +- Добавлены тесты: + - trial создаётся на 72 часа + - `moderator_id` копируется только у модераторских инвайтов + - payment flow сбрасывается при возврате в главное меню + +## Проверка + +- `GOCACHE=/tmp/go-build go test ./internal/bot/... -count=1` +- Далее полный обязательный прогон: `make fmt && make tests` diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 16dc6b4..27d89dd 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -190,6 +190,33 @@ func (b *Bot) handleStart(c tele.Context) error { // Актуализируем username и first_name в БД и Remnawave b.syncUserInfo(c) + // Проверяем grace period — показываем тревожный экран + remUser, err := b.remnawave.GetUserByTelegramID(telegramID) + if err == nil && remUser != nil { + subType := determineSubscriptionType(remUser, b.isTrialUser(telegramID)) + if subType == subTypeGrace { + graceDeadline := remUser.ExpireAt.Add(72 * time.Hour) + remaining := time.Until(graceDeadline) + var remainStr string + days := int(remaining.Hours() / 24) + if days > 0 { + remainStr = fmt.Sprintf("%d дн.", days) + } else { + hours := int(remaining.Hours()) + if hours > 0 { + remainStr = fmt.Sprintf("%d ч.", hours) + } else { + remainStr = "менее часа" + } + } + msg := fmt.Sprintf(MsgGraceWarning, remainStr, graceDeadline.Format("02.01.2006")) + return c.Send(msg, &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: b.userKeyboard(telegramID), + }) + } + } + return c.Send(MsgWelcomeBack, &tele.SendOptions{ ParseMode: tele.ModeHTML, ReplyMarkup: b.userKeyboard(telegramID), @@ -202,6 +229,11 @@ func (b *Bot) handleTextMessage(c tele.Context) error { state := b.userStates.Get(telegramID) text := c.Text() + if isPaymentFlowState(state) && isMenuNavigationButton(text) { + b.userStates.Delete(telegramID) + state = StateNone + } + // Обработка состояний switch state { case StateWaitInvite: @@ -299,6 +331,29 @@ func (b *Bot) handleTextMessage(c tele.Context) error { return b.processRemoveModerator(c, text) } + case StateWaitPaymentMethod: + if text == BtnCancel { + b.userStates.Delete(telegramID) + return c.Send("Отменено.", &tele.SendOptions{ReplyMarkup: b.userKeyboard(telegramID)}) + } + if method, ok := paymentMethodFromButton(text); ok { + return b.handlePaymentMethodSelected(c, method) + } + return c.Send("Выберите способ оплаты из меню:", &tele.SendOptions{ReplyMarkup: PaymentMethodKeyboard()}) + + case StateWaitPaymentResult: + if text == BtnCancel { + b.userStates.Delete(telegramID) + return c.Send("Возврат в меню. Платёж не отменён — он протухнет автоматически.", &tele.SendOptions{ + ReplyMarkup: b.userKeyboard(telegramID), + }) + } + if text == BtnCheckPayment { + return b.handleCheckPayment(c) + } + return c.Send("Нажмите \"🔄 Проверить оплату\" или \"🚫 Отмена\".", &tele.SendOptions{ + ReplyMarkup: PaymentWaitKeyboard(), + }) } // Админ-кнопки @@ -361,10 +416,10 @@ func (b *Bot) handleTextMessage(c tele.Context) error { switch text { case BtnStatus: return b.handleStatus(c) - case BtnConnect: - return b.handleConnect(c) - case BtnDonate: - return b.handleDonate(c) + case BtnPay, BtnRenew: + return b.handlePayButton(c) + case BtnCheckPayment: + return b.handleCheckPayment(c) case BtnInfo: return b.handleInfo(c) case BtnServers: @@ -412,7 +467,7 @@ func (b *Bot) processInviteCode(c tele.Context, code string) error { expireAt := time.Date(2099, time.January, 1, 0, 0, 0, 0, time.UTC) if invite.ExpireDays != nil { - expireAt = time.Now().UTC().AddDate(0, 0, *invite.ExpireDays) + expireAt = time.Now().UTC().Add(72 * time.Hour) } // Определяем лимит трафика: триал получает ограничение, админский инвайт — безлимит @@ -429,8 +484,19 @@ func (b *Bot) processInviteCode(c tele.Context, code string) error { return c.Send("Ошибка создания аккаунта. Попробуйте позже или обратитесь к администратору.") } + // Определяем subscription_price и moderator_id из инвайта + var subscriptionPrice *int + var moderatorID *int64 + if invite.SubscriptionPrice != nil { + subscriptionPrice = invite.SubscriptionPrice + } + if invite.ExpireDays != nil && b.isModerator(invite.CreatedBy) { + // Модераторский инвайт — ставим created_by как moderator_id + moderatorID = &invite.CreatedBy + } + // Сохраняем связку в БД - _, err = b.db.CreateUser(telegramID, username, c.Sender().FirstName, remnawaveUser.UUID, nil, nil) + _, err = b.db.CreateUser(telegramID, username, c.Sender().FirstName, remnawaveUser.UUID, subscriptionPrice, moderatorID) if err != nil { slog.Error("Failed to create user in DB", "error", err) // Откатываем: удаляем из Remnawave и освобождаем инвайт @@ -502,45 +568,13 @@ func (b *Bot) handleStatus(c tele.Context) error { return c.Send("Ошибка получения статуса. Попробуйте позже.") } - msg := FormatUserStatus(remnawaveUser) - return c.Send(msg, &tele.SendOptions{ - ParseMode: tele.ModeHTML, - ReplyMarkup: b.userKeyboard(telegramID), - }) -} - -// handleConnect показывает ссылку подключения -func (b *Bot) handleConnect(c tele.Context) error { - telegramID := c.Sender().ID - - user, err := b.db.GetUserByTelegramID(telegramID) - if err != nil || user == nil { - return c.Send(MsgNotRegistered, &tele.SendOptions{ParseMode: tele.ModeHTML}) - } - - // Получаем данные из Remnawave - remnawaveUser, err := b.remnawave.GetUser(user.RemnawaveUUID) - if err != nil { - slog.Error("Failed to get user from Remnawave", "error", err) - return c.Send("Ошибка получения ссылки. Попробуйте позже.") - } - - msg := fmt.Sprintf(MsgSubscriptionLink, remnawaveUser.SubscriptionURL) + msg := FormatUserStatus(remnawaveUser, user, b.isTrialUser(telegramID)) return c.Send(msg, &tele.SendOptions{ ParseMode: tele.ModeHTML, ReplyMarkup: b.userKeyboard(telegramID), }) } -// handleDonate показывает информацию о донате -func (b *Bot) handleDonate(c tele.Context) error { - msg := fmt.Sprintf(MsgDonate, b.config.DonateText) - return c.Send(msg, &tele.SendOptions{ - ParseMode: tele.ModeHTML, - ReplyMarkup: b.userKeyboard(c.Sender().ID), - }) -} - // handleInfo показывает помощь, контакты и ссылки на документы сервиса func (b *Bot) handleInfo(c tele.Context) error { return c.Send(MsgInfo, &tele.SendOptions{ @@ -623,12 +657,44 @@ func (b *Bot) handleInstructionDesktop(c tele.Context) error { }) } -// userKeyboard возвращает правильную клавиатуру для пользователя (с учётом роли модератора) +// userKeyboard возвращает правильную клавиатуру для пользователя +// с динамической кнопкой оплаты и учётом роли модератора func (b *Bot) userKeyboard(telegramID int64) *tele.ReplyMarkup { - if b.isModerator(telegramID) { - return UserMenuKeyboardModerator() + isMod := b.isModerator(telegramID) + + // Определяем, показывать ли кнопку оплаты и какой текст + user, err := b.db.GetUserByTelegramID(telegramID) + if err != nil || user == nil { + return UserMenuKeyboardDynamic("", false, isMod) } - return UserMenuKeyboard() + + // Нет цены — кнопка оплаты скрыта + if user.SubscriptionPrice == nil { + return UserMenuKeyboardDynamic("", false, isMod) + } + + // Нет Platega — кнопка оплаты скрыта + if b.platega == nil { + return UserMenuKeyboardDynamic("", false, isMod) + } + + // Проверяем тип подписки для определения текста кнопки + remUser, err := b.remnawave.GetUserByTelegramID(telegramID) + if err != nil || remUser == nil { + return UserMenuKeyboardDynamic(BtnPay, true, isMod) + } + + // Бесконечная подписка — кнопка скрыта (если не админ) + if remUser.ExpireAt.Year() >= 2099 && telegramID != b.config.AdminID { + return UserMenuKeyboardDynamic("", false, isMod) + } + + // Триал или grace → "Оплатить", оплаченная → "Продлить" + if b.isTrialUser(telegramID) || remUser.Status == remnawave.StatusDisabled { + return UserMenuKeyboardDynamic(BtnPay, true, isMod) + } + + return UserMenuKeyboardDynamic(BtnRenew, true, isMod) } // getBotUsername возвращает username бота для формирования deep link @@ -677,3 +743,47 @@ func (b *Bot) syncUserInfo(c tele.Context) { } } } + +func isPaymentFlowState(state string) bool { + return state == StateWaitPaymentMethod || state == StateWaitPaymentResult +} + +func isMenuNavigationButton(text string) bool { + switch text { + case BtnStatus, + BtnPay, + BtnRenew, + BtnInfo, + BtnServers, + BtnInstructions, + BtnBack, + BtnInstIOS, + BtnInstAndroid, + BtnInstDesktop, + BtnModInvites, + BtnModCreate, + BtnModView, + BtnModSubscribers, + BtnModExtend, + BtnModDelete, + BtnModBack, + BtnAdminManage, + BtnAdminBroadcast, + BtnAdminUserMode, + BtnAdminBack, + BtnAdminCreateInvite, + BtnAdminViewInvites, + BtnAdminDeleteInvite, + BtnAdminBanUser, + BtnAdminSwitchSubscription, + BtnBroadcastActive, + BtnAdminModerators, + BtnAdminAddModerator, + BtnAdminListMods, + BtnAdminModStats, + BtnAdminRemoveMod: + return true + default: + return false + } +} diff --git a/internal/bot/handlers_test.go b/internal/bot/handlers_test.go index 04668d8..eca79bd 100644 --- a/internal/bot/handlers_test.go +++ b/internal/bot/handlers_test.go @@ -66,6 +66,7 @@ func setupTestBot(t *testing.T) (*Bot, *database.DB) { db: db, config: cfg, userStates: newStateMap(), + remnawave: remnawave.NewClient("https://panel.example.com", "test-token", nil), } return b, db } @@ -204,7 +205,7 @@ func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } -func TestProcessInviteCode_UsesInviteExpireDays(t *testing.T) { +func TestProcessInviteCode_UsesExpectedTrialPeriod(t *testing.T) { t.Run("Бессрочный инвайт", func(t *testing.T) { b, db := setupTestBot(t) @@ -254,7 +255,7 @@ func TestProcessInviteCode_UsesInviteExpireDays(t *testing.T) { assert.Equal(t, []string{"uuid-1", "uuid-2"}, captured.ActiveInternalSquads) }) - t.Run("Месячный инвайт", func(t *testing.T) { + t.Run("Триальный инвайт всегда создаёт доступ на 72 часа", func(t *testing.T) { b, db := setupTestBot(t) days := 30 @@ -304,13 +305,117 @@ func TestProcessInviteCode_UsesInviteExpireDays(t *testing.T) { gotExpireAt, err := time.Parse(time.RFC3339, captured.ExpireAt) require.NoError(t, err) - assert.False(t, gotExpireAt.Before(before.AddDate(0, 0, 30).Add(-2*time.Second))) - assert.False(t, gotExpireAt.After(after.AddDate(0, 0, 30).Add(2*time.Second))) - // Месячный инвайт — лимит трафика (TrialTrafficLimitGB=1 по умолчанию) + assert.False(t, gotExpireAt.Before(before.Add(72*time.Hour).Add(-2*time.Second))) + assert.False(t, gotExpireAt.After(after.Add(72*time.Hour).Add(2*time.Second))) + // Триальный инвайт получает лимит трафика. assert.Equal(t, int64(1*1024*1024*1024), captured.TrafficLimitBytes) }) } +func TestProcessInviteCode_SetsModeratorIDOnlyForModeratorInvites(t *testing.T) { + t.Run("модераторский инвайт копирует moderator_id и цену", func(t *testing.T) { + b, db := setupTestBot(t) + + modID := int64(1234) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + require.NoError(t, db.AddModerator(modID, b.config.AdminID)) + + price := 450 + inviteCode, err := db.CreateInviteWithPrice(modID, 30, price) + require.NoError(t, err) + + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + payload, err := json.Marshal(map[string]any{ + "response": map[string]any{ + "uuid": "uuid-moderator-user", + "shortUuid": "short-moderator-user", + "username": "trial_user", + "status": remnawave.StatusActive, + "subscriptionUrl": "vless://example", + "createdAt": time.Now().UTC().Format(time.RFC3339), + "expireAt": time.Now().UTC().Add(72 * time.Hour).Format(time.RFC3339), + }, + }) + require.NoError(t, err) + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(payload))), + Header: make(http.Header), + }, nil + }), + }) + b.remnawave = client + + ctx := &MockContext{ + sender: &tele.User{ID: 7101, Username: "trial_user", FirstName: "Trial"}, + message: &tele.Message{}, + } + + err = b.processInviteCode(ctx, inviteCode) + require.NoError(t, err) + + user, err := db.GetUserByTelegramID(7101) + require.NoError(t, err) + require.NotNil(t, user) + require.NotNil(t, user.SubscriptionPrice) + require.NotNil(t, user.ModeratorID) + assert.Equal(t, price, *user.SubscriptionPrice) + assert.Equal(t, modID, *user.ModeratorID) + }) + + t.Run("админский срочный инвайт не заполняет moderator_id", func(t *testing.T) { + b, db := setupTestBot(t) + + price := 500 + inviteCode, err := db.CreateInviteWithPrice(b.config.AdminID, 30, price) + require.NoError(t, err) + + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + payload, err := json.Marshal(map[string]any{ + "response": map[string]any{ + "uuid": "uuid-admin-user", + "shortUuid": "short-admin-user", + "username": "admin_trial_user", + "status": remnawave.StatusActive, + "subscriptionUrl": "vless://example", + "createdAt": time.Now().UTC().Format(time.RFC3339), + "expireAt": time.Now().UTC().Add(72 * time.Hour).Format(time.RFC3339), + }, + }) + require.NoError(t, err) + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(payload))), + Header: make(http.Header), + }, nil + }), + }) + b.remnawave = client + + ctx := &MockContext{ + sender: &tele.User{ID: 7102, Username: "admin_trial_user", FirstName: "Admin Trial"}, + message: &tele.Message{}, + } + + err = b.processInviteCode(ctx, inviteCode) + require.NoError(t, err) + + user, err := db.GetUserByTelegramID(7102) + require.NoError(t, err) + require.NotNil(t, user) + require.NotNil(t, user.SubscriptionPrice) + assert.Equal(t, price, *user.SubscriptionPrice) + assert.Nil(t, user.ModeratorID) + }) +} + func TestHandleInstructionDesktopUsesUnifiedPCMessage(t *testing.T) { b, _ := setupTestBot(t) ctx := &MockContext{ @@ -359,3 +464,22 @@ func TestHandleTextMessage_InfoButtonRoutesToHelpMessage(t *testing.T) { require.True(t, ok) assert.Equal(t, MsgInfo, msg) } + +func TestHandleTextMessage_PaymentFlowResetsOnMainMenuButtons(t *testing.T) { + b, _ := setupTestBot(t) + userID := int64(12345) + b.userStates.Set(userID, StateWaitPaymentMethod) + + ctx := &MockContext{ + sender: &tele.User{ID: userID, Username: "payer"}, + message: &tele.Message{Text: BtnInfo}, + } + + err := b.handleTextMessage(ctx) + require.NoError(t, err) + + msg, ok := ctx.sentMsg.(string) + require.True(t, ok) + assert.Equal(t, MsgInfo, msg) + assert.Equal(t, StateNone, b.userStates.Get(userID), "при выходе в главное меню state оплаты должен сбрасываться") +} diff --git a/internal/bot/keyboards.go b/internal/bot/keyboards.go index f5c45b5..b98c268 100644 --- a/internal/bot/keyboards.go +++ b/internal/bot/keyboards.go @@ -8,13 +8,19 @@ import ( const ( // Кнопки пользователя BtnStatus = "👤 Мой статус" - BtnConnect = "🌐 Подключить" - BtnDonate = "💸 Поддержать" - BtnInfo = "Информация" + BtnInfo = "ℹ️ Информация" BtnInstructions = "📚 Инструкции" BtnBack = "🔙 Назад" BtnCancel = "🚫 Отмена" + // Кнопки оплаты + BtnPay = "💳 Оплатить подписку" + BtnRenew = "💳 Продлить подписку" + BtnPaySBP = "🏦 СБП" + BtnPayCard = "💳 Карта" + BtnPayCrypto = "🪙 Крипта" + BtnCheckPayment = "🔄 Проверить оплату" + // Кнопки инструкций BtnInstIOS = "🍎 iOS" BtnInstAndroid = "🤖 Android" @@ -57,14 +63,24 @@ const ( BtnAdminRemoveMod = "➖ Снять модератора" ) -// UserMenuKeyboard возвращает главное меню пользователя -func UserMenuKeyboard() *tele.ReplyMarkup { +// UserMenuKeyboardDynamic строит главное меню с динамической кнопкой оплаты. +// payButtonText — текст кнопки ("Оплатить" / "Продлить"), showPayButton — показывать ли, +// isModerator — добавляет кнопку "Приглашения". +func UserMenuKeyboardDynamic(payButtonText string, showPayButton bool, isModerator bool) *tele.ReplyMarkup { menu := &tele.ReplyMarkup{ResizeKeyboard: true} - menu.Reply( - menu.Row(menu.Text(BtnStatus), menu.Text(BtnConnect)), - menu.Row(menu.Text(BtnServers), menu.Text(BtnInstructions)), - menu.Row(menu.Text(BtnDonate), menu.Text(BtnInfo)), - ) + rows := []tele.Row{ + menu.Row(menu.Text(BtnStatus)), + } + if showPayButton && payButtonText != "" { + rows = append(rows, menu.Row(menu.Text(payButtonText), menu.Text(BtnServers))) + } else { + rows = append(rows, menu.Row(menu.Text(BtnServers))) + } + rows = append(rows, menu.Row(menu.Text(BtnInstructions), menu.Text(BtnInfo))) + if isModerator { + rows = append(rows, menu.Row(menu.Text(BtnModInvites))) + } + menu.Reply(rows...) return menu } @@ -111,18 +127,6 @@ func AdminBroadcastKeyboard() *tele.ReplyMarkup { return menu } -// UserMenuKeyboardModerator возвращает меню пользователя с кнопкой приглашений (для модераторов) -func UserMenuKeyboardModerator() *tele.ReplyMarkup { - menu := &tele.ReplyMarkup{ResizeKeyboard: true} - menu.Reply( - menu.Row(menu.Text(BtnStatus), menu.Text(BtnConnect)), - menu.Row(menu.Text(BtnServers), menu.Text(BtnInstructions)), - menu.Row(menu.Text(BtnModInvites)), - menu.Row(menu.Text(BtnDonate), menu.Text(BtnInfo)), - ) - return menu -} - // ModeratorMenuKeyboard возвращает подменю модератора func ModeratorMenuKeyboard() *tele.ReplyMarkup { menu := &tele.ReplyMarkup{ResizeKeyboard: true} @@ -165,3 +169,22 @@ func ConfirmKeyboard() *tele.ReplyMarkup { ) return menu } + +// PaymentMethodKeyboard возвращает меню выбора способа оплаты +func PaymentMethodKeyboard() *tele.ReplyMarkup { + menu := &tele.ReplyMarkup{ResizeKeyboard: true} + menu.Reply( + menu.Row(menu.Text(BtnPaySBP), menu.Text(BtnPayCard)), + menu.Row(menu.Text(BtnPayCrypto), menu.Text(BtnCancel)), + ) + return menu +} + +// PaymentWaitKeyboard возвращает меню ожидания оплаты +func PaymentWaitKeyboard() *tele.ReplyMarkup { + menu := &tele.ReplyMarkup{ResizeKeyboard: true} + menu.Reply( + menu.Row(menu.Text(BtnCheckPayment), menu.Text(BtnCancel)), + ) + return menu +} diff --git a/internal/bot/keyboards_test.go b/internal/bot/keyboards_test.go index 853ff49..0d8ebcd 100644 --- a/internal/bot/keyboards_test.go +++ b/internal/bot/keyboards_test.go @@ -70,30 +70,77 @@ func TestInstructionsKeyboardContainsUnifiedDesktopButton(t *testing.T) { assert.NotContains(t, buttons, "🍏 macOS") } -func TestUserMenuKeyboardContainsInfoButton(t *testing.T) { - keyboard := UserMenuKeyboard() +func TestUserMenuKeyboardDynamicContainsPayButton(t *testing.T) { + t.Run("с кнопкой оплаты", func(t *testing.T) { + keyboard := UserMenuKeyboardDynamic(BtnPay, true, false) + + var buttons []string + for _, row := range keyboard.ReplyKeyboard { + for _, btn := range row { + buttons = append(buttons, btn.Text) + } + } - var buttons []string - for _, row := range keyboard.ReplyKeyboard { - for _, btn := range row { - buttons = append(buttons, btn.Text) + assert.Contains(t, buttons, BtnStatus) + assert.Contains(t, buttons, BtnPay) + assert.Contains(t, buttons, BtnServers) + assert.Contains(t, buttons, BtnInfo) + assert.NotContains(t, buttons, BtnModInvites) + }) + + t.Run("без кнопки оплаты", func(t *testing.T) { + keyboard := UserMenuKeyboardDynamic("", false, false) + + var buttons []string + for _, row := range keyboard.ReplyKeyboard { + for _, btn := range row { + buttons = append(buttons, btn.Text) + } } - } - assert.Contains(t, buttons, BtnInfo) - assert.Contains(t, buttons, BtnDonate) + assert.Contains(t, buttons, BtnStatus) + assert.NotContains(t, buttons, BtnPay) + assert.NotContains(t, buttons, BtnRenew) + assert.Contains(t, buttons, BtnServers) + }) + + t.Run("модератор — с кнопкой приглашений", func(t *testing.T) { + keyboard := UserMenuKeyboardDynamic(BtnRenew, true, true) + + var buttons []string + for _, row := range keyboard.ReplyKeyboard { + for _, btn := range row { + buttons = append(buttons, btn.Text) + } + } + + assert.Contains(t, buttons, BtnModInvites) + assert.Contains(t, buttons, BtnRenew) + }) } -func TestUserMenuKeyboardModeratorContainsInfoButton(t *testing.T) { - keyboard := UserMenuKeyboardModerator() +func TestPaymentKeyboardsContainExpectedButtons(t *testing.T) { + methods := PaymentMethodKeyboard() + wait := PaymentWaitKeyboard() - var buttons []string - for _, row := range keyboard.ReplyKeyboard { + var methodButtons []string + for _, row := range methods.ReplyKeyboard { for _, btn := range row { - buttons = append(buttons, btn.Text) + methodButtons = append(methodButtons, btn.Text) + } + } + + var waitButtons []string + for _, row := range wait.ReplyKeyboard { + for _, btn := range row { + waitButtons = append(waitButtons, btn.Text) } } - assert.Contains(t, buttons, BtnInfo) - assert.Contains(t, buttons, BtnModInvites) + assert.Contains(t, methodButtons, BtnPaySBP) + assert.Contains(t, methodButtons, BtnPayCard) + assert.Contains(t, methodButtons, BtnPayCrypto) + assert.Contains(t, methodButtons, BtnCancel) + assert.Contains(t, waitButtons, BtnCheckPayment) + assert.Contains(t, waitButtons, BtnCancel) } diff --git a/internal/bot/messages.go b/internal/bot/messages.go index 4492d72..d2f8ebc 100644 --- a/internal/bot/messages.go +++ b/internal/bot/messages.go @@ -2,10 +2,22 @@ package bot import ( "fmt" + "time" + "github.com/fus1ond/vpn_bot/internal/database" "github.com/fus1ond/vpn_bot/internal/remnawave" ) +// subscriptionType определяет тип подписки для отображения в UI +type subscriptionType int + +const ( + subTypeTrial subscriptionType = iota // Триал (пробный период) + subTypePaid // Оплаченная подписка + subTypeGrace // Grace period (disabled + не кикнут) + subTypeInfinite // Бесконечная (expireAt >= 2099) +) + // Сообщения пользователя const ( MsgWelcomeInvite = `🔒 Доступ по приглашению @@ -39,12 +51,6 @@ const ( Скопируйте ссылку и вставьте в приложение VPN-клиента.` - MsgDonate = `💸 Поддержать проект - -Если вам нравится сервис, вы можете поддержать его развитие: - -%s` - MsgInfo = `💡 Помощь и контакты Если есть вопросы — пишите @fus1ond @@ -117,6 +123,11 @@ const ( MsgSubtitlesTimeout = `⏰ Рендеринг занял слишком долго. Попробуйте позже.` MsgSubtitlesUnavailable = `❌ Сервис временно недоступен. Попробуйте позже.` + + MsgGraceWarning = `⚠️ Ваша подписка истекла. VPN деактивирован. + +Осталось %s чтобы оплатить и восстановить доступ. +Если не оплатить до %s, аккаунт будет удалён.` ) // Сообщения админа @@ -153,38 +164,180 @@ const ( ❌ Ошибок: %d` ) -// FormatUserStatus форматирует статус пользователя -func FormatUserStatus(user *remnawave.User) string { - statusEmoji := "❌" - statusText := "Неизвестно" +// determineSubscriptionType определяет тип подписки на основе данных из Remnawave и БД +func determineSubscriptionType(remUser *remnawave.User, isTrial bool) subscriptionType { + if remUser.ExpireAt.Year() >= 2099 { + return subTypeInfinite + } + // Grace period: disabled, но подписка истекла + if remUser.Status == remnawave.StatusDisabled && !remUser.ExpireAt.After(time.Now().UTC()) { + return subTypeGrace + } + if isTrial { + return subTypeTrial + } + return subTypePaid +} - switch user.Status { - case remnawave.StatusActive: - statusEmoji = "✅" - statusText = "Активен" - case remnawave.StatusDisabled: - statusEmoji = "⛔" - statusText = "Заблокирован" - case remnawave.StatusLimited: - statusEmoji = "⚠️" - statusText = "Лимит исчерпан" - case remnawave.StatusExpired: - statusEmoji = "⏰" - statusText = "Истёк" +// FormatUserStatus форматирует статус пользователя с учётом типа подписки +func FormatUserStatus(remUser *remnawave.User, dbUser *database.User, isTrial bool) string { + subType := determineSubscriptionType(remUser, isTrial) + + var msg string + + switch subType { + case subTypeInfinite: + msg = formatInfiniteStatus(remUser) + case subTypeGrace: + msg = formatGraceStatus(remUser, dbUser) + case subTypeTrial: + msg = formatTrialStatus(remUser, dbUser) + case subTypePaid: + msg = formatPaidStatus(remUser, dbUser) } - msg := fmt.Sprintf("👤 Ваш статус\n\n") - msg += fmt.Sprintf("Статус: %s %s\n", statusEmoji, statusText) + return msg +} - // Использованный трафик за текущий месяц - if user.UserTraffic != nil { - usedGB := float64(user.UserTraffic.UsedTrafficBytes) / (1024 * 1024 * 1024) +func formatInfiniteStatus(remUser *remnawave.User) string { + msg := "👤 Ваш статус\n\n" + msg += "Тип: ♾️ Безлимитная подписка\n" + msg += "Статус: ✅ Активен\n" + + if remUser.UserTraffic != nil { + usedGB := float64(remUser.UserTraffic.UsedTrafficBytes) / (1024 * 1024 * 1024) msg += fmt.Sprintf("\nТрафик за месяц: %.2f GB\n", usedGB) } - if user.Status == remnawave.StatusActive { - msg += fmt.Sprintf("\nСсылка подписки:\n%s", user.SubscriptionURL) + msg += fmt.Sprintf("\nСсылка подписки:\n%s", remUser.SubscriptionURL) + return msg +} + +func formatGraceStatus(remUser *remnawave.User, dbUser *database.User) string { + graceDeadline := remUser.ExpireAt.Add(72 * time.Hour) + remaining := time.Until(graceDeadline) + + var remainStr string + days := int(remaining.Hours() / 24) + if days > 0 { + remainStr = fmt.Sprintf("%d дн.", days) + } else { + hours := int(remaining.Hours()) + if hours > 0 { + remainStr = fmt.Sprintf("%d ч.", hours) + } else { + remainStr = "менее часа" + } + } + + msg := "⚠️ Подписка истекла\n\n" + msg += "Статус: ⛔ VPN деактивирован\n" + msg += fmt.Sprintf("Осталось для оплаты: %s (до %s)\n", remainStr, graceDeadline.Format("02.01.2006")) + + if dbUser != nil && dbUser.SubscriptionPrice != nil { + msg += fmt.Sprintf("\nЦена подписки: %d руб/мес\n", *dbUser.SubscriptionPrice) + } + + msg += "\nОплатите подписку, чтобы восстановить доступ." + return msg +} + +func formatTrialStatus(remUser *remnawave.User, dbUser *database.User) string { + msg := "👤 Ваш статус\n\n" + msg += "Тип: ⏳ Пробный период\n" + + // Статус + statusEmoji, statusText := formatStatusLine(remUser.Status) + msg += fmt.Sprintf("Статус: %s %s\n", statusEmoji, statusText) + + // Осталось дней + remaining := time.Until(remUser.ExpireAt) + days := int(remaining.Hours() / 24) + if days > 0 { + msg += fmt.Sprintf("Осталось: %d дн. (до %s)\n", days, remUser.ExpireAt.Format("02.01.2006")) + } else if remaining > 0 { + hours := int(remaining.Hours()) + msg += fmt.Sprintf("Осталось: %d ч.\n", hours) + } + + // Трафик с лимитом + if remUser.UserTraffic != nil { + usedGB := float64(remUser.UserTraffic.UsedTrafficBytes) / (1024 * 1024 * 1024) + if remUser.TrafficLimitBytes > 0 { + limitGB := float64(remUser.TrafficLimitBytes) / (1024 * 1024 * 1024) + msg += fmt.Sprintf("Трафик: %.2f / %.2f GB\n", usedGB, limitGB) + } else { + msg += fmt.Sprintf("Трафик за месяц: %.2f GB\n", usedGB) + } + } + + if dbUser != nil && dbUser.SubscriptionPrice != nil { + msg += fmt.Sprintf("\nЦена подписки: %d руб/мес\n", *dbUser.SubscriptionPrice) + } + + // Ссылка подписки + if remUser.Status == remnawave.StatusActive { + msg += fmt.Sprintf("\nСсылка подписки:\n%s", remUser.SubscriptionURL) + } + + // Подсказка + if remUser.UserTraffic != nil && remUser.TrafficLimitBytes > 0 && + remUser.UserTraffic.UsedTrafficBytes >= remUser.TrafficLimitBytes { + msg += "\n\n⚠️ Лимит трафика исчерпан. VPN не работает.\nОплатите подписку для безлимитного доступа." + } else { + msg += "\n\n💡 Оплатите подписку, чтобы снять лимит трафика\nи получить безлимитный доступ." + } + + return msg +} + +func formatPaidStatus(remUser *remnawave.User, dbUser *database.User) string { + msg := "👤 Ваш статус\n\n" + msg += "Тип: 💳 Подписка\n" + + statusEmoji, statusText := formatStatusLine(remUser.Status) + msg += fmt.Sprintf("Статус: %s %s\n", statusEmoji, statusText) + + // Осталось дней + remaining := time.Until(remUser.ExpireAt) + days := int(remaining.Hours() / 24) + if days > 0 { + msg += fmt.Sprintf("Осталось: %d дн. (до %s)\n", days, remUser.ExpireAt.Format("02.01.2006")) + } else if remaining > 0 { + hours := int(remaining.Hours()) + msg += fmt.Sprintf("Осталось: %d ч.\n", hours) + } + + // Трафик (безлимит для оплаченных) + if remUser.UserTraffic != nil { + usedGB := float64(remUser.UserTraffic.UsedTrafficBytes) / (1024 * 1024 * 1024) + msg += fmt.Sprintf("Трафик за месяц: %.2f GB\n", usedGB) + } + + if dbUser != nil && dbUser.SubscriptionPrice != nil { + msg += fmt.Sprintf("\nЦена продления: %d руб/мес\n", *dbUser.SubscriptionPrice) + } + + // Ссылка подписки + if remUser.Status == remnawave.StatusActive { + msg += fmt.Sprintf("\nСсылка подписки:\n%s", remUser.SubscriptionURL) } return msg } + +// formatStatusLine возвращает эмоджи и текст для статуса +func formatStatusLine(status string) (string, string) { + switch status { + case remnawave.StatusActive: + return "✅", "Активен" + case remnawave.StatusDisabled: + return "⛔", "Заблокирован" + case remnawave.StatusLimited: + return "⚠️", "Лимит исчерпан" + case remnawave.StatusExpired: + return "⏰", "Истёк" + default: + return "❌", "Неизвестно" + } +} diff --git a/internal/bot/messages_test.go b/internal/bot/messages_test.go index b8d0b23..31c3d45 100644 --- a/internal/bot/messages_test.go +++ b/internal/bot/messages_test.go @@ -2,7 +2,9 @@ package bot import ( "testing" + "time" + "github.com/fus1ond/vpn_bot/internal/database" "github.com/fus1ond/vpn_bot/internal/remnawave" "github.com/stretchr/testify/assert" ) @@ -12,12 +14,13 @@ func TestFormatUserStatusShowsUsedTrafficPerMonthWithoutLimit(t *testing.T) { Status: remnawave.StatusActive, TrafficLimitBytes: 0, SubscriptionURL: "vless://example", + ExpireAt: time.Now().UTC().AddDate(0, 0, 10), UserTraffic: &remnawave.Traffic{ UsedTrafficBytes: 5 * 1024 * 1024 * 1024, }, } - msg := FormatUserStatus(user) + msg := FormatUserStatus(user, nil, false) assert.Contains(t, msg, "Трафик за месяц: 5.00 GB") assert.NotContains(t, msg, "Трафик:") @@ -27,6 +30,23 @@ func TestFormatUserStatusShowsUsedTrafficPerMonthWithoutLimit(t *testing.T) { assert.Contains(t, msg, "Ссылка подписки:") } +func TestFormatUserStatusGraceShowsPaymentWindow(t *testing.T) { + price := 400 + msg := FormatUserStatus(&remnawave.User{ + Status: remnawave.StatusDisabled, + TrafficLimitBytes: 0, + SubscriptionURL: "vless://example", + ExpireAt: time.Now().UTC().Add(-12 * time.Hour), + }, &database.User{ + SubscriptionPrice: &price, + }, false) + + assert.Contains(t, msg, "⚠️ Подписка истекла") + assert.Contains(t, msg, "VPN деактивирован") + assert.Contains(t, msg, "Цена подписки") + assert.Contains(t, msg, "Осталось для оплаты") +} + func TestMsgAccountCreatedHasNoTrafficLimitDetails(t *testing.T) { assert.NotContains(t, MsgAccountCreated, "Лимит трафика") assert.NotContains(t, MsgAccountCreated, "Сброс трафика") diff --git a/internal/bot/moderator.go b/internal/bot/moderator.go index c5af5fa..b77ee53 100644 --- a/internal/bot/moderator.go +++ b/internal/bot/moderator.go @@ -407,7 +407,7 @@ func (b *Bot) handleModeratorBack(c tele.Context) error { b.userStates.Delete(c.Sender().ID) return c.Send(MsgWelcomeBack, &tele.SendOptions{ ParseMode: tele.ModeHTML, - ReplyMarkup: UserMenuKeyboardModerator(), + ReplyMarkup: b.userKeyboard(c.Sender().ID), }) } diff --git a/internal/bot/moderator_test.go b/internal/bot/moderator_test.go index cddf5fe..e1336a5 100644 --- a/internal/bot/moderator_test.go +++ b/internal/bot/moderator_test.go @@ -37,6 +37,7 @@ func setupModeratorTestBot(t *testing.T) (*Bot, *database.DB, int64, int64) { db: db, config: cfg, userStates: newStateMap(), + remnawave: remnawave.NewClient("https://panel.example.com", "test-token", nil), } // Создаём пользователя-модератора @@ -58,7 +59,7 @@ func TestModeratorMenuKeyboard(t *testing.T) { func TestUserMenuKeyboardForModerator(t *testing.T) { // Для модератора должна быть кнопка "Приглашения" - kb := UserMenuKeyboardModerator() + kb := UserMenuKeyboardDynamic(BtnRenew, true, true) assert.NotNil(t, kb) } @@ -732,7 +733,7 @@ func TestHandleStart_ModeratorGetsModeratorMenu(t *testing.T) { err := b.handleStart(ctx) assert.NoError(t, err) - // Модератор должен получить UserMenuKeyboardModerator (с кнопкой приглашений) + // Модератор должен получить пользовательское меню с кнопкой приглашений. assert.Equal(t, MsgWelcomeBack, ctx.sentMsg) // Проверяем что в opts есть клавиатура с кнопкой приглашений diff --git a/internal/bot/payment_handler.go b/internal/bot/payment_handler.go new file mode 100644 index 0000000..9b6cf51 --- /dev/null +++ b/internal/bot/payment_handler.go @@ -0,0 +1,157 @@ +package bot + +import ( + "fmt" + "log/slog" + "time" + + "github.com/fus1ond/vpn_bot/internal/platega" + tele "gopkg.in/telebot.v3" +) + +// Состояния оплаты +const ( + StateWaitPaymentMethod = "wait_payment_method" // Ожидание выбора способа оплаты + StateWaitPaymentResult = "wait_payment_result" // Ожидание оплаты (показана ссылка) +) + +// handlePayButton обрабатывает нажатие "Оплатить подписку" / "Продлить подписку" +func (b *Bot) handlePayButton(c tele.Context) error { + telegramID := c.Sender().ID + + // Проверка режима обслуживания + if b.maintenanceMode { + return c.Send("⚙️ Платёжная система временно на обслуживании. Попробуйте позже.", &tele.SendOptions{ + ReplyMarkup: b.userKeyboard(telegramID), + }) + } + + // Проверка, что Platega настроена + if b.platega == nil { + return c.Send("❌ Платёжная система не настроена.", &tele.SendOptions{ + ReplyMarkup: b.userKeyboard(telegramID), + }) + } + + // Получаем пользователя + user, err := b.db.GetUserByTelegramID(telegramID) + if err != nil || user == nil { + return c.Send(MsgNotRegistered, &tele.SendOptions{ParseMode: tele.ModeHTML}) + } + + // Проверка наличия цены + if user.SubscriptionPrice == nil { + return c.Send("❌ Цена подписки не установлена. Обратитесь к администратору.", &tele.SendOptions{ + ReplyMarkup: b.userKeyboard(telegramID), + }) + } + + // Проверка лимита 90 дней + remUser, err := b.remnawave.GetUserByTelegramID(telegramID) + if err == nil && remUser != nil && remUser.Status == "ACTIVE" && remUser.ExpireAt.Year() < 2099 { + daysLeft := int(time.Until(remUser.ExpireAt).Hours() / 24) + if daysLeft >= 90 { + msg := fmt.Sprintf("ℹ️ Подписка уже оплачена до %s.\nПродлить можно не раньше чем за 90 дней до окончания.", + remUser.ExpireAt.Format("02.01.2006")) + return c.Send(msg, &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: b.userKeyboard(telegramID), + }) + } + } + + // Показываем экран выбора способа оплаты + b.userStates.Set(telegramID, StateWaitPaymentMethod) + msg := fmt.Sprintf("💳 Подписка на 1 месяц — %d руб.\n\nВыберите способ оплаты:", *user.SubscriptionPrice) + return c.Send(msg, &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: PaymentMethodKeyboard(), + }) +} + +// handlePaymentMethodSelected обрабатывает выбор способа оплаты +func (b *Bot) handlePaymentMethodSelected(c tele.Context, methodInt int) error { + telegramID := c.Sender().ID + + payment, redirectURL, err := b.createPaymentForUser(telegramID, methodInt) + if err != nil { + slog.Error("Ошибка создания платежа", "error", err, "telegram_id", telegramID) + + // Обработка специфических ошибок + if err.Error() == "subscription price not set" { + return c.Send("❌ Цена подписки не установлена.", &tele.SendOptions{ + ReplyMarkup: b.userKeyboard(telegramID), + }) + } + + b.userStates.Delete(telegramID) + return c.Send("❌ Не удалось создать платёж. Попробуйте позже.", &tele.SendOptions{ + ReplyMarkup: b.userKeyboard(telegramID), + }) + } + + b.userStates.Set(telegramID, StateWaitPaymentResult) + + msg := fmt.Sprintf("✅ Платёж создан!\n\n"+ + "Перейдите по ссылке для оплаты:\n%s\n\n"+ + "Сумма: %d руб.\n\n"+ + "После оплаты подписка будет активирована автоматически.\n"+ + "Обычно это занимает до 1 минуты.", + redirectURL, payment.Amount) + + return c.Send(msg, &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: PaymentWaitKeyboard(), + }) +} + +// handleCheckPayment обрабатывает кнопку "Проверить оплату" +func (b *Bot) handleCheckPayment(c tele.Context) error { + telegramID := c.Sender().ID + + status, err := b.checkPaymentStatus(telegramID) + if err != nil { + slog.Error("Ошибка проверки статуса платежа", "error", err, "telegram_id", telegramID) + return c.Send("❌ Ошибка проверки. Попробуйте позже.", &tele.SendOptions{ + ReplyMarkup: PaymentWaitKeyboard(), + }) + } + + switch status { + case "confirmed": + b.userStates.Delete(telegramID) + return c.Send("✅ Оплата подтверждена! Подписка активирована.", &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: b.userKeyboard(telegramID), + }) + case "not_found": + b.userStates.Delete(telegramID) + return c.Send("Активных платежей не найдено.", &tele.SendOptions{ + ReplyMarkup: b.userKeyboard(telegramID), + }) + case platega.StatusCanceled: + b.userStates.Delete(telegramID) + return c.Send("❌ Платёж отменён. Вы можете попробовать снова.", &tele.SendOptions{ + ReplyMarkup: b.userKeyboard(telegramID), + }) + default: + // pending или другой промежуточный статус + return c.Send("⏳ Оплата пока не поступила. Подождите немного и проверьте снова.", &tele.SendOptions{ + ReplyMarkup: PaymentWaitKeyboard(), + }) + } +} + +// paymentMethodFromButton определяет метод оплаты по тексту кнопки +func paymentMethodFromButton(text string) (int, bool) { + switch text { + case BtnPaySBP: + return platega.PaymentMethodSBP, true + case BtnPayCard: + return platega.PaymentMethodCard, true + case BtnPayCrypto: + return platega.PaymentMethodCrypto, true + default: + return 0, false + } +} From d5fe29bf6bdd189c9dfbe4c5d7511e750aa8b3a3 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 12:40:24 +0300 Subject: [PATCH 14/34] =?UTF-8?q?feat:=20=D1=8D=D1=82=D0=B0=D0=BF=207=20?= =?UTF-8?q?=E2=80=94=20UI=20=D0=BC=D0=BE=D0=B4=D0=B5=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B0=20=D1=81=20=D0=B7=D0=B0=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/progress/2026-03-23-payment-stage-7.md | 64 +++ internal/bot/handlers.go | 80 ++-- internal/bot/keyboards.go | 15 +- internal/bot/keyboards_test.go | 5 +- internal/bot/moderator.go | 414 +++++++++++++------- internal/bot/moderator_test.go | 372 ++++++++++-------- internal/database/invites.go | 42 +- 7 files changed, 650 insertions(+), 342 deletions(-) create mode 100644 docs/progress/2026-03-23-payment-stage-7.md diff --git a/docs/progress/2026-03-23-payment-stage-7.md b/docs/progress/2026-03-23-payment-stage-7.md new file mode 100644 index 0000000..67ea410 --- /dev/null +++ b/docs/progress/2026-03-23-payment-stage-7.md @@ -0,0 +1,64 @@ +# Этап 7: UI модератора с заработком + +**Дата:** 2026-03-23 +**План:** [2026-03-22-payment-implementation-plan.md](../plans/2026-03-22-payment-implementation-plan.md), строки 1804–1892 +**Доп. контекст:** [2026-03-21-moderator-ui-redesign.md](../plans/2026-03-21-moderator-ui-redesign.md) +**Коммит:** `feat: этап 7 — UI модератора с заработком` + +## Что сделано + +### keyboards.go +- Удалена кнопка ручного продления `BtnModExtend` +- Добавлены новые кнопки `BtnModEarnings` и `BtnModChangePrice` +- `ModeratorMenuKeyboard()` обновлён под новый сценарий модератора +- Добавлен `ModeratorSubscribersKeyboard()` для списка подписчиков + +### moderator.go +- Создание инвайта переведено на двухшаговый flow: + - `handleModeratorCreateInvite()` + - `StateWaitModInvitePrice` + - `processModeratorInvitePrice()` +- Инвайт модератора теперь создаётся через `CreateInviteWithPrice()` с валидацией `MIN_SUBSCRIPTION_PRICE` +- `handleModSubscribers()` показывает: + - тип подписки (`триал`, `оплачено`, `grace period`, `истёк`, `удалён`) + - дату / остаток дней + - цену подписки + - агрегаты по типам внизу списка +- Добавлен `handleModeratorEarnings()` с live-статистикой за текущий месяц и за всё время +- Добавлен flow изменения цены триального подписчика: + - `StateWaitModChangePriceID` + - `StateWaitModChangePriceValue` + - `handleModChangePriceRequest()` + - `processModChangePriceID()` + - `processModChangePriceValue()` +- Полностью удалён старый сценарий ручного продления подписки + +### handlers.go +- Удалены состояния и роутинг старого moderator extend flow +- Добавлена обработка: + - `StateWaitModInvitePrice` + - `StateWaitModChangePriceID` + - `StateWaitModChangePriceValue` +- Добавлены маршруты для `BtnModEarnings` и `BtnModChangePrice` +- Из `Bot` удалены pending-данные продления, вместо них добавлена сессия изменения цены + +### invites.go +- `Subscriber` расширен полем `SubscriptionPrice` +- `GetSubscribersByModerator()` теперь возвращает цену подписки через `COALESCE(u.subscription_price, i.subscription_price)` +- Добавлен `UpdateInviteSubscriptionPrice()` для синхронизации цены в истории инвайта + +### Тесты +- Обновлены тесты клавиатуры под новый модераторский UI +- Добавлены тесты на: + - запуск flow создания инвайта с ценой + - создание инвайта с `subscription_price` + - валидацию минимальной цены + - enriched-список подписчиков с типом и ценой + - экран заработка модератора + - смену цены триального подписчика + - запрет смены цены уже оплатившему подписчику + +## Проверка + +- `make fmt` +- `make tests` diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 27d89dd..1dce310 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -25,21 +25,21 @@ const ( // Bot представляет Telegram бота type Bot struct { - bot *tele.Bot - db *database.DB - remnawave *remnawave.Client - config *config.Config - userStates *stateMap - metricsClient *monitoring.MetricsClient // клиент метрик VM - dashboardMgr *dashboardManager // менеджер сессий дашборда - sdConfigsPath string // путь к sd_configs (для чтения targets) - render *render.Client // клиент render-сервиса (nil если не настроен) - platega *platega.Client // Platega API клиент (nil если не настроен) - maintenanceMode bool // Режим обслуживания (сбрасывается при перезапуске) - modExtendMu sync.RWMutex - modExtendData map[int64]modExtendSession // pending-данные продления для модератора - adminSwitchMu sync.RWMutex - adminSwitchData map[int64]adminSwitchSession // pending-данные перевода тарифа для админа + bot *tele.Bot + db *database.DB + remnawave *remnawave.Client + config *config.Config + userStates *stateMap + metricsClient *monitoring.MetricsClient // клиент метрик VM + dashboardMgr *dashboardManager // менеджер сессий дашборда + sdConfigsPath string // путь к sd_configs (для чтения targets) + render *render.Client // клиент render-сервиса (nil если не настроен) + platega *platega.Client // Platega API клиент (nil если не настроен) + maintenanceMode bool // Режим обслуживания (сбрасывается при перезапуске) + modChangePriceMu sync.RWMutex + modChangePriceData map[int64]modChangePriceSession // pending-данные изменения цены для модератора + adminSwitchMu sync.RWMutex + adminSwitchData map[int64]adminSwitchSession // pending-данные перевода тарифа для админа } // New создаёт нового Telegram бота @@ -55,16 +55,16 @@ func New(cfg *config.Config, db *database.DB, remnawaveClient *remnawave.Client) } bot := &Bot{ - bot: b, - db: db, - remnawave: remnawaveClient, - config: cfg, - userStates: newStateMap(), - metricsClient: monitoring.NewMetricsClient(cfg.VictoriaMetricsURL), - dashboardMgr: newDashboardManager(), - sdConfigsPath: cfg.SDConfigsPath, - modExtendData: make(map[int64]modExtendSession), - adminSwitchData: make(map[int64]adminSwitchSession), + bot: b, + db: db, + remnawave: remnawaveClient, + config: cfg, + userStates: newStateMap(), + metricsClient: monitoring.NewMetricsClient(cfg.VictoriaMetricsURL), + dashboardMgr: newDashboardManager(), + sdConfigsPath: cfg.SDConfigsPath, + modChangePriceData: make(map[int64]modChangePriceSession), + adminSwitchData: make(map[int64]adminSwitchSession), } // Middleware для логирования @@ -297,21 +297,28 @@ func (b *Bot) handleTextMessage(c tele.Context) error { } return b.processModeratorDeleteInvite(c, text) - case StateWaitModExtendID: + case StateWaitModInvitePrice: if text == BtnCancel { b.userStates.Delete(telegramID) - b.clearModExtendSession(telegramID) return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) } - return b.processModExtendID(c, text) + return b.processModeratorInvitePrice(c, text) - case StateWaitModExtendConfirm: + case StateWaitModChangePriceID: if text == BtnCancel { b.userStates.Delete(telegramID) - b.clearModExtendSession(telegramID) - return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) + b.clearModChangePriceSession(telegramID) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: ModeratorSubscribersKeyboard()}) + } + return b.processModChangePriceID(c, text) + + case StateWaitModChangePriceValue: + if text == BtnCancel { + b.userStates.Delete(telegramID) + b.clearModChangePriceSession(telegramID) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: ModeratorSubscribersKeyboard()}) } - return b.processModExtendConfirm(c, text) + return b.processModChangePriceValue(c, text) case StateWaitAddModerator: if text == BtnCancel { @@ -405,8 +412,10 @@ func (b *Bot) handleTextMessage(c tele.Context) error { return b.handleModeratorDeleteInviteRequest(c) case BtnModSubscribers: return b.handleModSubscribers(c) - case BtnModExtend: - return b.handleModExtend(c) + case BtnModEarnings: + return b.handleModeratorEarnings(c) + case BtnModChangePrice: + return b.handleModChangePriceRequest(c) case BtnModBack: return b.handleModeratorBack(c) } @@ -764,7 +773,8 @@ func isMenuNavigationButton(text string) bool { BtnModCreate, BtnModView, BtnModSubscribers, - BtnModExtend, + BtnModEarnings, + BtnModChangePrice, BtnModDelete, BtnModBack, BtnAdminManage, diff --git a/internal/bot/keyboards.go b/internal/bot/keyboards.go index b98c268..b15b40e 100644 --- a/internal/bot/keyboards.go +++ b/internal/bot/keyboards.go @@ -51,7 +51,8 @@ const ( BtnModCreate = "📨 Создать приглашение" BtnModView = "📋 Мои приглашения" BtnModSubscribers = "👥 Мои подписчики" - BtnModExtend = "⏳ Продлить подписку" + BtnModEarnings = "💰 Мой заработок" + BtnModChangePrice = "✏️ Изменить цену" BtnModDelete = "🗑 Удалить приглашение" BtnModBack = "🔙 В меню" @@ -133,13 +134,21 @@ func ModeratorMenuKeyboard() *tele.ReplyMarkup { menu.Reply( menu.Row(menu.Text(BtnModCreate)), menu.Row(menu.Text(BtnModView), menu.Text(BtnModSubscribers)), - menu.Row(menu.Text(BtnModExtend)), - menu.Row(menu.Text(BtnModDelete)), + menu.Row(menu.Text(BtnModEarnings), menu.Text(BtnModDelete)), menu.Row(menu.Text(BtnModBack)), ) return menu } +// ModeratorSubscribersKeyboard возвращает клавиатуру для списка подписчиков модератора. +func ModeratorSubscribersKeyboard() *tele.ReplyMarkup { + menu := &tele.ReplyMarkup{ResizeKeyboard: true} + menu.Reply( + menu.Row(menu.Text(BtnModChangePrice), menu.Text(BtnModBack)), + ) + return menu +} + // AdminModeratorKeyboard возвращает подменю управления модераторами func AdminModeratorKeyboard() *tele.ReplyMarkup { menu := &tele.ReplyMarkup{ResizeKeyboard: true} diff --git a/internal/bot/keyboards_test.go b/internal/bot/keyboards_test.go index 0d8ebcd..99d96ab 100644 --- a/internal/bot/keyboards_test.go +++ b/internal/bot/keyboards_test.go @@ -25,7 +25,7 @@ func TestAdminManageKeyboardDoesNotContainAddTrafficButton(t *testing.T) { assert.Contains(t, buttons, BtnAdminBack) } -func TestModeratorMenuKeyboardContainsSubscriptionButtons(t *testing.T) { +func TestModeratorMenuKeyboardContainsNewButtons(t *testing.T) { keyboard := ModeratorMenuKeyboard() var buttons []string @@ -36,7 +36,8 @@ func TestModeratorMenuKeyboardContainsSubscriptionButtons(t *testing.T) { } assert.Contains(t, buttons, BtnModSubscribers) - assert.Contains(t, buttons, BtnModExtend) + assert.Contains(t, buttons, BtnModEarnings) + assert.NotContains(t, buttons, "⏳ Продлить подписку") } func TestAdminModeratorKeyboardContainsStatsButton(t *testing.T) { diff --git a/internal/bot/moderator.go b/internal/bot/moderator.go index b77ee53..52ab994 100644 --- a/internal/bot/moderator.go +++ b/internal/bot/moderator.go @@ -13,21 +13,21 @@ import ( tele "gopkg.in/telebot.v3" ) -// Состояния модератора +// Состояния модератора. const ( - StateWaitModDeleteInvite = "wait_mod_delete_invite" // Модератор ждёт код для удаления - StateWaitModExtendID = "wait_mod_extend_id" // Ожидание telegram_id подписчика для продления - StateWaitModExtendConfirm = "wait_mod_extend_confirm" // Ожидание подтверждения продления + StateWaitModDeleteInvite = "wait_mod_delete_invite" // Модератор ждёт код для удаления + StateWaitModInvitePrice = "wait_mod_invite_price" // Ожидание цены нового инвайта + StateWaitModChangePriceID = "wait_mod_change_price_id" // Ожидание telegram_id подписчика + StateWaitModChangePriceValue = "wait_mod_change_price_value" // Ожидание новой цены подписки ) -type modExtendSession struct { +type modChangePriceSession struct { SubscriberTelegramID int64 SubscriberLabel string - UserUUID string - NewExpireAt time.Time + CurrentPrice int } -// isModerator проверяет, является ли пользователь модератором +// isModerator проверяет, является ли пользователь модератором. func (b *Bot) isModerator(telegramID int64) bool { ok, err := b.db.IsModerator(telegramID) if err != nil { @@ -37,7 +37,7 @@ func (b *Bot) isModerator(telegramID int64) bool { return ok } -// handleModeratorMenu показывает подменю модератора +// handleModeratorMenu показывает подменю модератора. func (b *Bot) handleModeratorMenu(c tele.Context) error { return c.Send("🎟 Приглашения\n\nВыберите действие:", &tele.SendOptions{ ParseMode: tele.ModeHTML, @@ -45,25 +45,46 @@ func (b *Bot) handleModeratorMenu(c tele.Context) error { }) } -// handleModeratorCreateInvite создаёт инвайт от имени модератора +// handleModeratorCreateInvite запускает создание инвайта от имени модератора. func (b *Bot) handleModeratorCreateInvite(c tele.Context) error { - telegramID := c.Sender().ID + b.userStates.Set(c.Sender().ID, StateWaitModInvitePrice) + return c.Send( + fmt.Sprintf("Введите цену подписки (руб/мес).\nМинимум: %d руб.", b.minSubscriptionPrice()), + &tele.SendOptions{ReplyMarkup: CancelKeyboard()}, + ) +} + +// processModeratorInvitePrice создаёт инвайт с ценой после ввода модератора. +func (b *Bot) processModeratorInvitePrice(c tele.Context, text string) error { + moderatorID := c.Sender().ID + price, err := strconv.Atoi(strings.TrimSpace(text)) + if err != nil { + return c.Send("❌ Введите число.", &tele.SendOptions{ReplyMarkup: CancelKeyboard()}) + } + if price < b.minSubscriptionPrice() { + return c.Send( + fmt.Sprintf("❌ Минимальная цена: %d руб.", b.minSubscriptionPrice()), + &tele.SendOptions{ReplyMarkup: CancelKeyboard()}, + ) + } - expireDays := 30 - invite, err := b.db.CreateInviteWithExpiry(telegramID, &expireDays) + inviteCode, err := b.db.CreateInviteWithPrice(moderatorID, 30, price) if err != nil { - slog.Error("Failed to create invite by moderator", "error", err, "moderator_id", telegramID) + slog.Error("Failed to create invite with price", "error", err, "moderator_id", moderatorID) return c.Send("Ошибка создания приглашения", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) } - msg := fmt.Sprintf(MsgInviteCreated, b.getBotUsername(), invite.Code) - return c.Send(msg, &tele.SendOptions{ - ParseMode: tele.ModeHTML, - ReplyMarkup: ModeratorMenuKeyboard(), - }) + b.userStates.Delete(moderatorID) + msg := fmt.Sprintf( + "✅ Приглашение создано!\nЦена подписки: %d руб/мес\n\nСсылка: https://t.me/%s?start=%s", + price, + b.getBotUsername(), + inviteCode, + ) + return c.Send(msg, &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) } -// handleModeratorViewInvites показывает список инвайтов модератора +// handleModeratorViewInvites показывает список инвайтов модератора. func (b *Bot) handleModeratorViewInvites(c tele.Context) error { telegramID := c.Sender().ID @@ -87,10 +108,7 @@ func (b *Bot) handleModeratorViewInvites(c tele.Context) error { if inv.UsedBy != nil { msg.WriteString("✅ Использован\n") fmt.Fprintf(&msg, "🔹 Код: %s\n", inv.Code) - - // Информация о пользователе fmt.Fprintf(&msg, "👤 %s\n", formatUserLabel(inv.UserFirstName, inv.UserUsername, *inv.UsedBy)) - if inv.UsedAt != nil { fmt.Fprintf(&msg, "📅 %s\n", inv.UsedAt.Format("02.01.06 15:04")) } @@ -109,7 +127,6 @@ func (b *Bot) handleModeratorViewInvites(c tele.Context) error { } // handleModSubscribers показывает подписчиков модератора и их статусы. -// Использует batch-запрос к Remnawave для получения всех пользователей сразу. func (b *Bot) handleModSubscribers(c tele.Context) error { telegramID := c.Sender().ID @@ -126,23 +143,23 @@ func (b *Bot) handleModSubscribers(c tele.Context) error { }) } - // Загружаем всех пользователей из Remnawave одним batch-запросом remUsers, err := b.remnawave.GetAllUsers() if err != nil { slog.Error("Failed to get all users from Remnawave for subscribers list", "error", err, "moderator_id", telegramID) return c.Send("Ошибка получения данных из системы", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) } - // Строим lookup-таблицу uuid -> User remByUUID := make(map[string]remnawave.User, len(remUsers)) - for _, u := range remUsers { - remByUUID[u.UUID] = u + for _, user := range remUsers { + remByUUID[user.UUID] = user } sort.Slice(subscribers, func(i, j int) bool { return subscribers[i].TelegramID < subscribers[j].TelegramID }) now := time.Now().UTC() - activeCount := 0 + trialCount := 0 + paidCount := 0 + graceCount := 0 expiredCount := 0 deletedCount := 0 @@ -158,92 +175,137 @@ func (b *Bot) handleModSubscribers(c tele.Context) error { remUser, exists := remByUUID[*sub.RemnawaveUUID] if !exists { - // Пользователь есть в БД бота, но уже нет в Remnawave deletedCount++ fmt.Fprintf(&msg, "❌ ID: %d — удалён\n\n", sub.TelegramID) continue } label := formatSubscriberLabel(sub) - if remUser.Status == remnawave.StatusExpired || remUser.ExpireAt.Before(now) { + priceLabel := formatPriceLabel(sub.SubscriptionPrice) + + switch b.describeSubscriberStatus(sub.TelegramID, remUser, now) { + case "trial": + trialCount++ + fmt.Fprintf(&msg, "⏳ %s — триал\n", label) + fmt.Fprintf(&msg, " до %s (осталось %d дн.)\n", remUser.ExpireAt.Format("02.01.06"), daysUntil(remUser.ExpireAt, now)) + fmt.Fprintf(&msg, " цена: %s\n\n", priceLabel) + case "grace": + graceCount++ + fmt.Fprintf(&msg, "⚠️ %s — grace period\n", label) + fmt.Fprintf(&msg, " VPN деактивирован (кик через %d дн.)\n", daysUntil(remUser.ExpireAt.Add(72*time.Hour), now)) + fmt.Fprintf(&msg, " цена: %s\n\n", priceLabel) + case "expired": expiredCount++ - daysToKick := int(remUser.ExpireAt.AddDate(0, 0, 3).Sub(now).Hours()/24) + 1 - if daysToKick < 0 { - daysToKick = 0 - } - fmt.Fprintf(&msg, "⏰ %s\n", label) - fmt.Fprintf(&msg, " истёк %s (кик через %d дн.)\n\n", remUser.ExpireAt.Format("02.01.06"), daysToKick) - continue + fmt.Fprintf(&msg, "⏰ %s — истёк\n", label) + fmt.Fprintf(&msg, " истёк %s (кик через %d дн.)\n", remUser.ExpireAt.Format("02.01.06"), daysUntil(remUser.ExpireAt.Add(72*time.Hour), now)) + fmt.Fprintf(&msg, " цена: %s\n\n", priceLabel) + default: + paidCount++ + fmt.Fprintf(&msg, "💳 %s — оплачено\n", label) + fmt.Fprintf(&msg, " до %s (осталось %d дн.)\n", remUser.ExpireAt.Format("02.01.06"), daysUntil(remUser.ExpireAt, now)) + fmt.Fprintf(&msg, " цена: %s\n\n", priceLabel) } - - activeCount++ - daysLeft := int(remUser.ExpireAt.Sub(now).Hours()/24) + 1 - if daysLeft < 0 { - daysLeft = 0 - } - fmt.Fprintf(&msg, "✅ %s\n", label) - fmt.Fprintf(&msg, " до %s (осталось %d дн.)\n\n", remUser.ExpireAt.Format("02.01.06"), daysLeft) } msg.WriteString("───\n") - fmt.Fprintf(&msg, "✅ Активных: %d │ ⏰ Истекших: %d │ ❌ Удалённых: %d", activeCount, expiredCount, deletedCount) + fmt.Fprintf( + &msg, + "💳 Платящих: %d │ ⏳ Триал: %d │ ⚠️ Grace: %d │ ⏰ Истекших: %d │ ❌ Удалённых: %d", + paidCount, + trialCount, + graceCount, + expiredCount, + deletedCount, + ) return c.Send(msg.String(), &tele.SendOptions{ ParseMode: tele.ModeHTML, - ReplyMarkup: ModeratorMenuKeyboard(), + ReplyMarkup: ModeratorSubscribersKeyboard(), }) } -// handleModExtend запускает диалог продления подписки. -func (b *Bot) handleModExtend(c tele.Context) error { - telegramID := c.Sender().ID - subscribers, err := b.db.GetSubscribersByModerator(telegramID) +// handleModeratorEarnings показывает финансовую сводку модератора. +func (b *Bot) handleModeratorEarnings(c tele.Context) error { + moderatorID := c.Sender().ID + now := time.Now().UTC() + + monthStats, err := b.db.GetModeratorEarningsByMonth(moderatorID, now.Year(), int(now.Month())) if err != nil { - slog.Error("Failed to load moderator subscribers for extend", "error", err, "moderator_id", telegramID) - return c.Send("Ошибка получения подписчиков", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) + slog.Error("Failed to load moderator earnings by month", "error", err, "moderator_id", moderatorID) + return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) } - if len(subscribers) == 0 { - return c.Send("👥 У вас пока нет подписчиков для продления.", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) + + totalEarnings, err := b.db.GetModeratorTotalEarnings(moderatorID) + if err != nil { + slog.Error("Failed to load moderator total earnings", "error", err, "moderator_id", moderatorID) + return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) } - sort.Slice(subscribers, func(i, j int) bool { return subscribers[i].TelegramID < subscribers[j].TelegramID }) + payingCount, err := b.db.CountPayingSubscribersByModerator(moderatorID) + if err != nil { + slog.Error("Failed to count paying subscribers", "error", err, "moderator_id", moderatorID) + return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) + } - var msg strings.Builder - msg.WriteString("⏳ Продление подписки\n\n") - msg.WriteString("Подписчики:\n") - for _, sub := range subscribers { - if sub.RemnawaveUUID == nil { - fmt.Fprintf(&msg, "❌ %d — удалён\n", sub.TelegramID) - continue - } - fmt.Fprintf(&msg, "• %s\n", formatSubscriberLabel(sub)) + sharePercent := monthStats.SharePercent + if sharePercent == 0 { + sharePercent = calculateSharePercent(payingCount) } - msg.WriteString("\nВведите telegram_id подписчика:") - b.userStates.Set(telegramID, StateWaitModExtendID) - b.clearModExtendSession(telegramID) + msg := fmt.Sprintf( + "💰 Мой заработок\n\nЗа %s %d:\n"+ + "├ Платящих клиентов: %d\n"+ + "├ Ваша доля: %d%%\n"+ + "├ Сумма платежей: %d руб\n"+ + "├ Комиссии Platega: -%d руб\n"+ + "├ Комиссия вывода: -%d руб\n"+ + "├ Чистый доход: %d руб\n"+ + "└ Ваша доля: %d руб\n\n"+ + "За всё время: %d руб", + monthNameRu(now.Month()), + now.Year(), + payingCount, + sharePercent, + monthStats.GrossAmount, + monthStats.TotalPlategaFee, + monthStats.TotalWithdrawal, + monthStats.TotalNetAmount, + monthStats.TotalShareAmount, + totalEarnings, + ) - return c.Send(msg.String(), &tele.SendOptions{ + return c.Send(msg, &tele.SendOptions{ ParseMode: tele.ModeHTML, + ReplyMarkup: ModeratorMenuKeyboard(), + }) +} + +// handleModChangePriceRequest запускает диалог изменения цены. +func (b *Bot) handleModChangePriceRequest(c tele.Context) error { + telegramID := c.Sender().ID + b.userStates.Set(telegramID, StateWaitModChangePriceID) + b.clearModChangePriceSession(telegramID) + return c.Send("Введите telegram_id подписчика:", &tele.SendOptions{ ReplyMarkup: CancelKeyboard(), }) } -func (b *Bot) processModExtendID(c tele.Context, text string) error { +// processModChangePriceID выбирает подписчика для изменения цены. +func (b *Bot) processModChangePriceID(c tele.Context, text string) error { moderatorID := c.Sender().ID targetID, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64) if err != nil { - return c.Send("❌ Неверный telegram_id. Введите число.", &tele.SendOptions{ReplyMarkup: CancelKeyboard()}) + return c.Send("❌ Введите корректный telegram_id.", &tele.SendOptions{ReplyMarkup: CancelKeyboard()}) } owned, err := b.db.IsSubscriberOfModerator(moderatorID, targetID) if err != nil { slog.Error("Failed to verify subscriber ownership", "error", err, "moderator_id", moderatorID, "target_id", targetID) b.userStates.Delete(moderatorID) - return c.Send("Ошибка проверки подписчика", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) + return c.Send("Ошибка проверки подписчика", &tele.SendOptions{ReplyMarkup: ModeratorSubscribersKeyboard()}) } if !owned { - return c.Send("❌ Можно продлевать только своих подписчиков.", &tele.SendOptions{ReplyMarkup: CancelKeyboard()}) + return c.Send("❌ Можно менять цену только своим подписчикам.", &tele.SendOptions{ReplyMarkup: CancelKeyboard()}) } dbUser, err := b.db.GetUserByTelegramID(targetID) @@ -253,87 +315,94 @@ func (b *Bot) processModExtendID(c tele.Context, text string) error { } if dbUser == nil { b.userStates.Delete(moderatorID) - return c.Send("❌ Пользователь уже удалён из системы.", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) + return c.Send("❌ Пользователь удалён из системы.", &tele.SendOptions{ReplyMarkup: ModeratorSubscribersKeyboard()}) } - remUser, err := b.remnawave.GetUser(dbUser.RemnawaveUUID) + hasPaid, err := b.db.HasConfirmedPayment(targetID) if err != nil { - if strings.Contains(err.Error(), "API error 404") { - b.userStates.Delete(moderatorID) - return c.Send("❌ Пользователь уже удалён из системы.", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) - } - slog.Error("Failed to get user from Remnawave", "error", err, "target_id", targetID) - return c.Send("Ошибка получения статуса пользователя", &tele.SendOptions{ReplyMarkup: CancelKeyboard()}) + slog.Error("Failed to check confirmed payments", "error", err, "target_id", targetID) + return c.Send("Ошибка проверки подписчика", &tele.SendOptions{ReplyMarkup: CancelKeyboard()}) } - - newExpireAt, err := remnawave.CalculateExtendedExpireAt(remUser.ExpireAt, time.Now().UTC(), 30) - if err != nil { + if hasPaid { b.userStates.Delete(moderatorID) - return c.Send(err.Error(), &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) + return c.Send( + "❌ Нельзя изменить цену — клиент уже оплатил подписку. Обратитесь к администратору.", + &tele.SendOptions{ReplyMarkup: ModeratorSubscribersKeyboard()}, + ) } - label := dbUser.Username - if label == "" { - label = fmt.Sprintf("%d", targetID) - } else { - label = "@" + label + currentPrice := 0 + if dbUser.SubscriptionPrice != nil { + currentPrice = *dbUser.SubscriptionPrice } - b.setModExtendSession(moderatorID, modExtendSession{ + label := formatAdminSwitchTargetLabel(dbUser) + b.setModChangePriceSession(moderatorID, modChangePriceSession{ SubscriberTelegramID: targetID, SubscriberLabel: label, - UserUUID: dbUser.RemnawaveUUID, - NewExpireAt: newExpireAt, + CurrentPrice: currentPrice, }) - b.userStates.Set(moderatorID, StateWaitModExtendConfirm) + b.userStates.Set(moderatorID, StateWaitModChangePriceValue) return c.Send( fmt.Sprintf( - "Продлить подписку %s на 30 дней? (до %s).\nОтправьте 'да' для подтверждения или 'нет' для отмены.", + "Текущая цена для %s: %d руб/мес\nВведите новую цену (минимум %d руб):", label, - newExpireAt.Format("02.01.06"), + currentPrice, + b.minSubscriptionPrice(), ), - &tele.SendOptions{ReplyMarkup: CancelKeyboard()}, + &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: CancelKeyboard(), + }, ) } -func (b *Bot) processModExtendConfirm(c tele.Context, text string) error { +// processModChangePriceValue завершает изменение цены. +func (b *Bot) processModChangePriceValue(c tele.Context, text string) error { moderatorID := c.Sender().ID - answer := strings.ToLower(strings.TrimSpace(text)) - - switch answer { - case "нет": - b.userStates.Delete(moderatorID) - b.clearModExtendSession(moderatorID) - return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) - case "да": - // Продолжаем. - default: - return c.Send("Ответьте 'да' или 'нет'.", &tele.SendOptions{ReplyMarkup: CancelKeyboard()}) + newPrice, err := strconv.Atoi(strings.TrimSpace(text)) + if err != nil { + return c.Send("❌ Введите число.", &tele.SendOptions{ReplyMarkup: CancelKeyboard()}) + } + if newPrice < b.minSubscriptionPrice() { + return c.Send( + fmt.Sprintf("❌ Минимальная цена: %d руб.", b.minSubscriptionPrice()), + &tele.SendOptions{ReplyMarkup: CancelKeyboard()}, + ) } - session, ok := b.getModExtendSession(moderatorID) + session, ok := b.getModChangePriceSession(moderatorID) if !ok { b.userStates.Delete(moderatorID) - return c.Send("Сессия продления потеряна. Начните заново.", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) + return c.Send("Сессия изменения цены потеряна. Начните заново.", &tele.SendOptions{ReplyMarkup: ModeratorSubscribersKeyboard()}) } - if err := b.remnawave.ExtendUserSubscription(session.UserUUID, 30); err != nil { + if err := b.db.UpdateSubscriptionPrice(session.SubscriberTelegramID, newPrice); err != nil { + slog.Error("Failed to update subscription price", "error", err, "telegram_id", session.SubscriberTelegramID) b.userStates.Delete(moderatorID) - b.clearModExtendSession(moderatorID) - return c.Send(err.Error(), &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) + b.clearModChangePriceSession(moderatorID) + return c.Send("Ошибка изменения цены", &tele.SendOptions{ReplyMarkup: ModeratorSubscribersKeyboard()}) } - if err := b.db.ClearNotifications(session.SubscriberTelegramID); err != nil { - slog.Error("Failed to clear notification markers after extension", "error", err, "telegram_id", session.SubscriberTelegramID) + if err := b.db.UpdateInviteSubscriptionPrice(session.SubscriberTelegramID, newPrice); err != nil { + slog.Error("Failed to update invite subscription price", "error", err, "telegram_id", session.SubscriberTelegramID) } b.userStates.Delete(moderatorID) - b.clearModExtendSession(moderatorID) + b.clearModChangePriceSession(moderatorID) return c.Send( - fmt.Sprintf("✅ Подписка %s продлена до %s.", session.SubscriberLabel, session.NewExpireAt.Format("02.01.06")), - &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}, + fmt.Sprintf( + "✅ Цена подписки для %s изменена: %d → %d руб/мес", + session.SubscriberLabel, + session.CurrentPrice, + newPrice, + ), + &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: ModeratorSubscribersKeyboard(), + }, ) } @@ -349,29 +418,29 @@ func formatSubscriberLabel(sub database.Subscriber) string { return formatUserLabel(firstName, username, sub.TelegramID) } -func (b *Bot) setModExtendSession(moderatorID int64, session modExtendSession) { - b.modExtendMu.Lock() - defer b.modExtendMu.Unlock() - if b.modExtendData == nil { - b.modExtendData = make(map[int64]modExtendSession) +func (b *Bot) setModChangePriceSession(moderatorID int64, session modChangePriceSession) { + b.modChangePriceMu.Lock() + defer b.modChangePriceMu.Unlock() + if b.modChangePriceData == nil { + b.modChangePriceData = make(map[int64]modChangePriceSession) } - b.modExtendData[moderatorID] = session + b.modChangePriceData[moderatorID] = session } -func (b *Bot) getModExtendSession(moderatorID int64) (modExtendSession, bool) { - b.modExtendMu.RLock() - defer b.modExtendMu.RUnlock() - session, ok := b.modExtendData[moderatorID] +func (b *Bot) getModChangePriceSession(moderatorID int64) (modChangePriceSession, bool) { + b.modChangePriceMu.RLock() + defer b.modChangePriceMu.RUnlock() + session, ok := b.modChangePriceData[moderatorID] return session, ok } -func (b *Bot) clearModExtendSession(moderatorID int64) { - b.modExtendMu.Lock() - defer b.modExtendMu.Unlock() - delete(b.modExtendData, moderatorID) +func (b *Bot) clearModChangePriceSession(moderatorID int64) { + b.modChangePriceMu.Lock() + defer b.modChangePriceMu.Unlock() + delete(b.modChangePriceData, moderatorID) } -// handleModeratorDeleteInviteRequest запрашивает код для удаления +// handleModeratorDeleteInviteRequest запрашивает код для удаления. func (b *Bot) handleModeratorDeleteInviteRequest(c tele.Context) error { b.userStates.Set(c.Sender().ID, StateWaitModDeleteInvite) return c.Send("🗑 Удаление приглашения\n\nВведите код для удаления:", &tele.SendOptions{ @@ -380,7 +449,7 @@ func (b *Bot) handleModeratorDeleteInviteRequest(c tele.Context) error { }) } -// processModeratorDeleteInvite обрабатывает удаление инвайта модератором +// processModeratorDeleteInvite обрабатывает удаление инвайта модератором. func (b *Bot) processModeratorDeleteInvite(c tele.Context, code string) error { b.userStates.Delete(c.Sender().ID) code = strings.TrimSpace(code) @@ -402,18 +471,18 @@ func (b *Bot) processModeratorDeleteInvite(c tele.Context, code string) error { }) } -// handleModeratorBack возвращает модератора в пользовательское меню +// handleModeratorBack возвращает модератора в пользовательское меню. func (b *Bot) handleModeratorBack(c tele.Context) error { b.userStates.Delete(c.Sender().ID) + b.clearModChangePriceSession(c.Sender().ID) return c.Send(MsgWelcomeBack, &tele.SendOptions{ ParseMode: tele.ModeHTML, ReplyMarkup: b.userKeyboard(c.Sender().ID), }) } -// cascadeDeleteModerator удаляет все неиспользованные инвайты модератора и снимает роль +// cascadeDeleteModerator удаляет все неиспользованные инвайты модератора и снимает роль. func (b *Bot) cascadeDeleteModerator(telegramID int64) { - // Удаляем неиспользованные инвайты count, err := b.db.DeleteUnusedInvitesByCreator(telegramID) if err != nil { slog.Error("Failed to delete moderator invites", "error", err, "telegram_id", telegramID) @@ -421,8 +490,73 @@ func (b *Bot) cascadeDeleteModerator(telegramID int64) { slog.Info("Deleted unused invites of moderator", "count", count, "telegram_id", telegramID) } - // Удаляем из таблицы модераторов if err := b.db.RemoveModerator(telegramID); err != nil { slog.Error("Failed to remove moderator", "error", err, "telegram_id", telegramID) } } + +func (b *Bot) minSubscriptionPrice() int { + if b.config != nil && b.config.MinSubscriptionPrice > 0 { + return b.config.MinSubscriptionPrice + } + return 400 +} + +func (b *Bot) describeSubscriberStatus(telegramID int64, remUser remnawave.User, now time.Time) string { + if remUser.Status == remnawave.StatusDisabled && !remUser.ExpireAt.After(now) { + return "grace" + } + if b.isTrialUser(telegramID) { + return "trial" + } + if remUser.Status == remnawave.StatusExpired || !remUser.ExpireAt.After(now) { + return "expired" + } + return "paid" +} + +func formatPriceLabel(price *int) string { + if price == nil { + return "не установлена" + } + return fmt.Sprintf("%d руб/мес", *price) +} + +func daysUntil(target, now time.Time) int { + days := int(target.Sub(now).Hours()/24) + 1 + if days < 0 { + return 0 + } + return days +} + +func monthNameRu(month time.Month) string { + switch month { + case time.January: + return "январь" + case time.February: + return "февраль" + case time.March: + return "март" + case time.April: + return "апрель" + case time.May: + return "май" + case time.June: + return "июнь" + case time.July: + return "июль" + case time.August: + return "август" + case time.September: + return "сентябрь" + case time.October: + return "октябрь" + case time.November: + return "ноябрь" + case time.December: + return "декабрь" + default: + return "" + } +} diff --git a/internal/bot/moderator_test.go b/internal/bot/moderator_test.go index e1336a5..07627f4 100644 --- a/internal/bot/moderator_test.go +++ b/internal/bot/moderator_test.go @@ -32,7 +32,10 @@ func setupModeratorTestBot(t *testing.T) (*Bot, *database.DB, int64, int64) { adminID := int64(999999) modID := int64(100) - cfg := &config.Config{AdminID: adminID} + cfg := &config.Config{ + AdminID: adminID, + MinSubscriptionPrice: 400, + } b := &Bot{ db: db, config: cfg, @@ -88,8 +91,8 @@ func TestAdminModeratorKeyboard(t *testing.T) { // --- Тесты обработчиков модератора --- -func TestModeratorCreateInvite(t *testing.T) { - b, db, _, modID := setupModeratorTestBot(t) +func TestModeratorCreateInvite_StartsPriceFlow(t *testing.T) { + b, _, _, modID := setupModeratorTestBot(t) user := &tele.User{ID: modID, Username: "moderator"} ctx := &MockContext{ @@ -99,22 +102,63 @@ func TestModeratorCreateInvite(t *testing.T) { err := b.handleModeratorCreateInvite(ctx) assert.NoError(t, err) + assert.Equal(t, StateWaitModInvitePrice, b.userStates.Get(modID)) + + sentStr, ok := ctx.sentMsg.(string) + assert.True(t, ok) + assert.Contains(t, sentStr, "Введите цену подписки") + assert.Contains(t, sentStr, "400") +} + +func TestProcessModeratorInvitePrice_CreatesInviteWithPrice(t *testing.T) { + b, db, _, modID := setupModeratorTestBot(t) + + ctx := &MockContext{ + sender: &tele.User{ID: modID, Username: "moderator"}, + message: &tele.Message{Text: "500"}, + } + + err := b.processModeratorInvitePrice(ctx, "500") + require.NoError(t, err) - // Проверяем что инвайт создан invites, err := db.GetInvitesWithUsersByCreator(modID) - assert.NoError(t, err) - assert.Len(t, invites, 1) + require.NoError(t, err) + require.Len(t, invites, 1) + invite, err := db.GetInviteByCode(invites[0].Code) require.NoError(t, err) require.NotNil(t, invite) require.NotNil(t, invite.ExpireDays) + require.NotNil(t, invite.SubscriptionPrice) assert.Equal(t, 30, *invite.ExpireDays) + assert.Equal(t, 500, *invite.SubscriptionPrice) + assert.Empty(t, b.userStates.Get(modID)) - // Проверяем что сообщение содержит deep link sentStr, ok := ctx.sentMsg.(string) - assert.True(t, ok) - assert.Contains(t, sentStr, "Приглашение в VPN") - assert.Contains(t, sentStr, "t.me/") + require.True(t, ok) + assert.Contains(t, sentStr, "Цена подписки: 500 руб/мес") +} + +func TestProcessModeratorInvitePrice_RejectsTooLowPrice(t *testing.T) { + b, db, _, modID := setupModeratorTestBot(t) + b.userStates.Set(modID, StateWaitModInvitePrice) + + ctx := &MockContext{ + sender: &tele.User{ID: modID, Username: "moderator"}, + message: &tele.Message{Text: "399"}, + } + + err := b.processModeratorInvitePrice(ctx, "399") + require.NoError(t, err) + + invites, err := db.GetInvitesWithUsersByCreator(modID) + require.NoError(t, err) + assert.Empty(t, invites) + assert.Equal(t, StateWaitModInvitePrice, b.userStates.Get(modID)) + + sentStr, ok := ctx.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, sentStr, "Минимальная цена: 400 руб") } func TestModeratorViewInvites(t *testing.T) { @@ -222,20 +266,60 @@ func TestModeratorDeleteInvite_NotOwned(t *testing.T) { func TestHandleModSubscribers(t *testing.T) { b, db, _, modID := setupModeratorTestBot(t) - // Активный подписчик - _, err := db.CreateUser(300, "alive", "Alive", "uuid-300", nil, nil) + priceTrial := 400 + pricePaid := 500 + priceGrace := 450 + + // Триальный подписчик + _, err := db.CreateUser(300, "trial", "Trial", "uuid-300", &priceTrial, &modID) require.NoError(t, err) - inv1, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) + code1, err := db.CreateInviteWithPrice(modID, 30, priceTrial) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(code1, 300)) + + // Оплаченный подписчик + _, err = db.CreateUser(301, "paid", "Paid", "uuid-301", &pricePaid, &modID) + require.NoError(t, err) + code2, err := db.CreateInviteWithPrice(modID, 30, pricePaid) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(code2, 301)) + + paymentPaid := &database.Payment{ + TelegramID: 301, + ModeratorID: &modID, + Amount: pricePaid, + PaymentMethod: "sbp", + Status: "pending", + } + paymentPaidID, err := db.CreatePayment(paymentPaid) require.NoError(t, err) - require.NoError(t, db.ClaimInvite(inv1.Code, 300)) + require.NoError(t, db.ConfirmPayment(paymentPaidID)) + + // Grace period + _, err = db.CreateUser(302, "grace", "Grace", "uuid-302", &priceGrace, &modID) + require.NoError(t, err) + code3, err := db.CreateInviteWithPrice(modID, 30, priceGrace) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(code3, 302)) + + paymentGrace := &database.Payment{ + TelegramID: 302, + ModeratorID: &modID, + Amount: priceGrace, + PaymentMethod: "card", + Status: "pending", + } + paymentGraceID, err := db.CreatePayment(paymentGrace) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(paymentGraceID)) // Удалённый подписчик - _, err = db.CreateUser(301, "gone", "Gone", "uuid-301", nil, nil) + _, err = db.CreateUser(303, "gone", "Gone", "uuid-303", &priceTrial, &modID) require.NoError(t, err) - inv2, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) + code4, err := db.CreateInviteWithPrice(modID, 30, priceTrial) require.NoError(t, err) - require.NoError(t, db.ClaimInvite(inv2.Code, 301)) - require.NoError(t, db.DeleteUser(301)) + require.NoError(t, db.ClaimInvite(code4, 303)) + require.NoError(t, db.DeleteUser(303)) client := remnawave.NewClient("https://panel.example.com", "test-token", nil) client.SetHTTPClient(&http.Client{ @@ -247,13 +331,27 @@ func TestHandleModSubscribers(t *testing.T) { "users": []map[string]any{ { "uuid": "uuid-300", - "username": "alive", + "username": "trial", "status": remnawave.StatusActive, "expireAt": time.Now().UTC().AddDate(0, 0, 10).Format(time.RFC3339), "createdAt": time.Now().UTC().Format(time.RFC3339), }, + { + "uuid": "uuid-301", + "username": "paid", + "status": remnawave.StatusActive, + "expireAt": time.Now().UTC().AddDate(0, 0, 25).Format(time.RFC3339), + "createdAt": time.Now().UTC().Format(time.RFC3339), + }, + { + "uuid": "uuid-302", + "username": "grace", + "status": remnawave.StatusDisabled, + "expireAt": time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339), + "createdAt": time.Now().UTC().Format(time.RFC3339), + }, }, - "total": 1, + "total": 3, }, }) require.NoError(t, err) @@ -279,30 +377,62 @@ func TestHandleModSubscribers(t *testing.T) { sentStr, ok := ctx.sentMsg.(string) require.True(t, ok) assert.Contains(t, sentStr, "Мои подписчики") - assert.Contains(t, sentStr, `300`) + assert.Contains(t, sentStr, "триал") + assert.Contains(t, sentStr, "оплачено") + assert.Contains(t, sentStr, "grace") + assert.Contains(t, sentStr, "цена: 400 руб/мес") + assert.Contains(t, sentStr, "цена: 500 руб/мес") + assert.Contains(t, sentStr, "цена: 450 руб/мес") assert.Contains(t, sentStr, "удалён") } -func TestHandleModExtend_StartsDialog(t *testing.T) { +func TestHandleModeratorEarnings(t *testing.T) { b, db, _, modID := setupModeratorTestBot(t) - _, err := db.CreateUser(300, "alive", "Alive", "uuid-300", nil, nil) + price := 500 + + _, err := db.CreateUser(300, "paid", "Paid", "uuid-300", &price, &modID) require.NoError(t, err) - inv, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) + code, err := db.CreateInviteWithPrice(modID, 30, price) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(code, 300)) + + payment := &database.Payment{ + TelegramID: 300, + ModeratorID: &modID, + Amount: price, + PaymentMethod: "sbp", + Status: "pending", + } + paymentID, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(paymentID)) + + _, err = db.CreateEarning(&database.ModeratorEarning{ + PaymentID: paymentID, + ModeratorID: modID, + GrossAmount: 500, + PlategaFee: 55, + WithdrawalFee: 8, + NetAmount: 437, + SharePercent: 15, + ShareAmount: 65, + }) require.NoError(t, err) - require.NoError(t, db.ClaimInvite(inv.Code, 300)) ctx := &MockContext{ sender: &tele.User{ID: modID, Username: "moderator"}, message: &tele.Message{}, } - err = b.handleModExtend(ctx) + err = b.handleModeratorEarnings(ctx) require.NoError(t, err) - assert.Equal(t, StateWaitModExtendID, b.userStates.Get(modID)) sentStr, ok := ctx.sentMsg.(string) require.True(t, ok) - assert.Contains(t, sentStr, "telegram_id") + assert.Contains(t, sentStr, "Мой заработок") + assert.Contains(t, sentStr, "Платящих клиентов") + assert.Contains(t, sentStr, "500 руб") + assert.Contains(t, sentStr, "65 руб") } func intPtrBot(v int) *int { @@ -312,7 +442,7 @@ func intPtrBot(v int) *int { // --- Тесты роутинга модератора --- func TestHandleTextMessage_ModeratorButtons(t *testing.T) { - b, db, _, modID := setupModeratorTestBot(t) + b, _, _, modID := setupModeratorTestBot(t) user := &tele.User{ID: modID, Username: "moderator"} @@ -339,20 +469,27 @@ func TestHandleTextMessage_ModeratorButtons(t *testing.T) { assert.NoError(t, err) }) - t.Run("Кнопка_Продлить_ставит_состояние", func(t *testing.T) { - _, err := db.CreateUser(8080, "sub8080", "Sub", "uuid-8080", nil, nil) - require.NoError(t, err) - inv, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) - require.NoError(t, err) - require.NoError(t, db.ClaimInvite(inv.Code, 8080)) + t.Run("Кнопка_Создать_запрашивает_цену", func(t *testing.T) { + ctx := &MockContext{ + sender: user, + message: &tele.Message{Text: BtnModCreate}, + } + err := b.handleTextMessage(ctx) + assert.NoError(t, err) + assert.Equal(t, StateWaitModInvitePrice, b.userStates.Get(modID)) + b.userStates.Delete(modID) + }) + t.Run("Кнопка_Заработок_открывает_сводку", func(t *testing.T) { ctx := &MockContext{ sender: user, - message: &tele.Message{Text: BtnModExtend}, + message: &tele.Message{Text: BtnModEarnings}, } - err = b.handleTextMessage(ctx) + err := b.handleTextMessage(ctx) assert.NoError(t, err) - assert.Equal(t, StateWaitModExtendID, b.userStates.Get(modID)) + sentStr, ok := ctx.sentMsg.(string) + assert.True(t, ok) + assert.Contains(t, sentStr, "Мой заработок") }) } @@ -509,68 +646,32 @@ func TestBanModerator_CascadeDelete(t *testing.T) { assert.Empty(t, invites) } -// --- Тест очистки состояния в терминальных ветках processModExtendID --- - -// TestProcessModExtendID_ClearsStateOnTerminalErrors проверяет что при ошибках, -// которые возвращают пользователя в главное меню, состояние диалога сбрасывается. -// Без этого следующее сообщение модератора снова парсится как telegram_id. -func TestProcessModExtendID_ClearsStateOnTerminalErrors(t *testing.T) { +func TestProcessModChangePrice_UpdatesTrialSubscriber(t *testing.T) { b, db, _, modID := setupModeratorTestBot(t) + price := 400 + _, err := db.CreateUser(9001, "trial", "Trial", "uuid-9001", &price, &modID) + require.NoError(t, err) + code, err := db.CreateInviteWithPrice(modID, 30, price) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(code, 9001)) - t.Run("dbUser==nil очищает состояние", func(t *testing.T) { - // Создаём инвайт с used_by=9001, но без записи в users - inv, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) - require.NoError(t, err) - require.NoError(t, db.ClaimInvite(inv.Code, 9001)) - // Намеренно НЕ создаём пользователя 9001 в users — GetUserByTelegramID вернёт nil - - b.userStates.Set(modID, StateWaitModExtendID) + ctxID := &MockContext{sender: &tele.User{ID: modID}, message: &tele.Message{Text: "9001"}} + require.NoError(t, b.processModChangePriceID(ctxID, "9001")) + require.Equal(t, StateWaitModChangePriceValue, b.userStates.Get(modID)) - ctx := &MockContext{sender: &tele.User{ID: modID}, message: &tele.Message{Text: "9001"}} - require.NoError(t, b.processModExtendID(ctx, "9001")) + ctxValue := &MockContext{sender: &tele.User{ID: modID}, message: &tele.Message{Text: "550"}} + require.NoError(t, b.processModChangePriceValue(ctxValue, "550")) - assert.Empty(t, b.userStates.Get(modID), "состояние должно быть очищено когда пользователь удалён") - }) + user, err := db.GetUserByTelegramID(9001) + require.NoError(t, err) + require.NotNil(t, user) + require.NotNil(t, user.SubscriptionPrice) + assert.Equal(t, 550, *user.SubscriptionPrice) + assert.Empty(t, b.userStates.Get(modID)) - t.Run("подписка слишком далеко в будущем очищает состояние", func(t *testing.T) { - _, err := db.CreateUser(9002, "future", "Future", "uuid-9002", nil, nil) - require.NoError(t, err) - inv, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) - require.NoError(t, err) - require.NoError(t, db.ClaimInvite(inv.Code, 9002)) - - // Remnawave: подписка истекает через 60 дней (>30) — слишком рано продлевать - client := remnawave.NewClient("https://panel.example.com", "test-token", nil) - client.SetHTTPClient(&http.Client{ - Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { - if strings.Contains(r.URL.Path, "uuid-9002") { - payload, _ := json.Marshal(map[string]any{ - "response": map[string]any{ - "uuid": "uuid-9002", - "username": "future", - "status": remnawave.StatusActive, - "expireAt": time.Now().UTC().AddDate(0, 0, 60).Format(time.RFC3339), - "createdAt": time.Now().UTC().Format(time.RFC3339), - }, - }) - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(string(payload))), - Header: make(http.Header), - }, nil - } - return nil, fmt.Errorf("unexpected: %s", r.URL.Path) - }), - }) - b.remnawave = client - - b.userStates.Set(modID, StateWaitModExtendID) - - ctx := &MockContext{sender: &tele.User{ID: modID}, message: &tele.Message{Text: "9002"}} - require.NoError(t, b.processModExtendID(ctx, "9002")) - - assert.Empty(t, b.userStates.Get(modID), "состояние должно быть очищено когда подписка ещё не может быть продлена") - }) + msg, ok := ctxValue.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, msg, "400 → 550") } // --- Тест batch API для handleModSubscribers --- @@ -646,77 +747,36 @@ func TestHandleModSubscribers_UsesBatchAPI(t *testing.T) { assert.Contains(t, sentStr, "bob") } -// --- Тест утечки состояния при ошибке продления --- - -// TestProcessModExtendConfirm_ClearsStateOnExtendError проверяет, что при ошибке -// продления подписки состояние и сессия очищаются, иначе следующее сообщение -// модератора снова попадёт в обработчик подтверждения. -func TestProcessModExtendConfirm_ClearsStateOnExtendError(t *testing.T) { +func TestProcessModChangePriceID_RejectsPaidSubscriber(t *testing.T) { b, db, _, modID := setupModeratorTestBot(t) - - // Создаём подписчика - _, err := db.CreateUser(400, "sub400", "Sub", "uuid-400", nil, nil) + price := 500 + _, err := db.CreateUser(400, "paid", "Paid", "uuid-400", &price, &modID) require.NoError(t, err) - inv, err := db.CreateInviteWithExpiry(modID, intPtrBot(30)) + code, err := db.CreateInviteWithPrice(modID, 30, price) require.NoError(t, err) - require.NoError(t, db.ClaimInvite(inv.Code, 400)) + require.NoError(t, db.ClaimInvite(code, 400)) - // Настраиваем Remnawave — первый вызов (preview) успешен, второй (extend) — ошибка - callCount := 0 - client := remnawave.NewClient("https://panel.example.com", "test-token", nil) - client.SetHTTPClient(&http.Client{ - Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { - callCount++ - if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "uuid-400") { - payload, _ := json.Marshal(map[string]any{ - "response": map[string]any{ - "uuid": "uuid-400", - "username": "sub400", - "status": remnawave.StatusActive, - "expireAt": time.Now().UTC().AddDate(0, 0, 10).Format(time.RFC3339), - "createdAt": time.Now().UTC().Format(time.RFC3339), - }, - }) - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(string(payload))), - Header: make(http.Header), - }, nil - } - // PATCH — симулируем ошибку API при продлении - if r.Method == http.MethodPatch { - return &http.Response{ - StatusCode: http.StatusInternalServerError, - Body: io.NopCloser(strings.NewReader(`{"error":"internal"}`)), - Header: make(http.Header), - }, nil - } - return nil, fmt.Errorf("unexpected: %s %s", r.Method, r.URL.Path) - }), - }) - b.remnawave = client - - // Шаг 1: ввод ID подписчика - ctxID := &MockContext{ - sender: &tele.User{ID: modID}, - message: &tele.Message{Text: "400"}, + payment := &database.Payment{ + TelegramID: 400, + ModeratorID: &modID, + Amount: price, + PaymentMethod: "sbp", + Status: "pending", } - err = b.processModExtendID(ctxID, "400") + paymentID, err := db.CreatePayment(payment) require.NoError(t, err) - require.Equal(t, StateWaitModExtendConfirm, b.userStates.Get(modID)) + require.NoError(t, db.ConfirmPayment(paymentID)) - // Шаг 2: подтверждение — получаем ошибку API - ctxConfirm := &MockContext{ + ctx := &MockContext{ sender: &tele.User{ID: modID}, - message: &tele.Message{Text: "да"}, + message: &tele.Message{Text: "400"}, } - err = b.processModExtendConfirm(ctxConfirm, "да") - require.NoError(t, err) + require.NoError(t, b.processModChangePriceID(ctx, "400")) - // После ошибки продления: состояние и сессия должны быть очищены - assert.Empty(t, b.userStates.Get(modID), "состояние должно быть очищено после ошибки") - _, sessionExists := b.getModExtendSession(modID) - assert.False(t, sessionExists, "сессия должна быть очищена после ошибки") + msg, ok := ctx.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, msg, "уже оплатил") + assert.Empty(t, b.userStates.Get(modID)) } // --- Тесты handleStart с меню модератора --- diff --git a/internal/database/invites.go b/internal/database/invites.go index 99389f2..c795b3c 100644 --- a/internal/database/invites.go +++ b/internal/database/invites.go @@ -24,10 +24,11 @@ type InviteWithUser struct { // Subscriber содержит подписчика модератора. // Поля профиля могут быть nil, если пользователь удалён из users. type Subscriber struct { - TelegramID int64 - Username *string - FirstName *string - RemnawaveUUID *string + TelegramID int64 + Username *string + FirstName *string + RemnawaveUUID *string + SubscriptionPrice *int } // CreateInvite создаёт новый инвайт @@ -180,6 +181,29 @@ func (db *DB) UpdateInviteExpireDays(usedBy int64, expireDays *int) error { return nil } +// UpdateInviteSubscriptionPrice обновляет цену подписки в инвайте пользователя. +func (db *DB) UpdateInviteSubscriptionPrice(usedBy int64, price int) error { + result, err := db.conn.Exec( + `UPDATE invites + SET subscription_price = ? + WHERE used_by = ? AND kicked_at IS NULL`, + price, usedBy, + ) + if err != nil { + return fmt.Errorf("failed to update invite subscription price: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get affected rows: %w", err) + } + if rows == 0 { + return fmt.Errorf("invite not found") + } + + return nil +} + // UseInvite помечает инвайт как использованный с временем активации // Deprecated: используй ClaimInvite для атомарной операции func (db *DB) UseInvite(code string, usedBy int64) error { @@ -515,7 +539,8 @@ func (db *DB) GetInviteByUsedBy(usedBy int64) (*Invite, error) { // Пользователи без записи в users также возвращаются (LEFT JOIN). func (db *DB) GetSubscribersByModerator(moderatorID int64) ([]Subscriber, error) { rows, err := db.conn.Query( - `SELECT i.used_by, u.username, u.first_name, u.remnawave_uuid + `SELECT i.used_by, u.username, u.first_name, u.remnawave_uuid, + COALESCE(u.subscription_price, i.subscription_price) FROM invites i LEFT JOIN users u ON i.used_by = u.telegram_id WHERE i.created_by = ? AND i.used_by IS NOT NULL @@ -534,8 +559,9 @@ func (db *DB) GetSubscribersByModerator(moderatorID int64) ([]Subscriber, error) var username sql.NullString var firstName sql.NullString var remnawaveUUID sql.NullString + var subscriptionPrice sql.NullInt64 - if err := rows.Scan(&usedBy, &username, &firstName, &remnawaveUUID); err != nil { + if err := rows.Scan(&usedBy, &username, &firstName, &remnawaveUUID, &subscriptionPrice); err != nil { return nil, fmt.Errorf("failed to scan subscriber: %w", err) } @@ -555,6 +581,10 @@ func (db *DB) GetSubscribersByModerator(moderatorID int64) ([]Subscriber, error) v := remnawaveUUID.String sub.RemnawaveUUID = &v } + if subscriptionPrice.Valid { + v := int(subscriptionPrice.Int64) + sub.SubscriptionPrice = &v + } subscribers = append(subscribers, sub) } From d30058c7e0531305125545861029456f3cb978c9 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 13:16:10 +0300 Subject: [PATCH 15/34] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20UI=20=D0=B0=D0=B4=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B8=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=81=D0=BB=D1=83=D0=B6=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/progress/2026-03-23-payment-stage-8.md | 82 +++ internal/bot/admin.go | 588 +++++++++++++++++++- internal/bot/admin_test.go | 335 ++++++++++- internal/bot/format_test.go | 5 +- internal/bot/handlers.go | 62 ++- internal/bot/handlers_test.go | 56 +- internal/bot/keyboards.go | 28 +- internal/bot/keyboards_test.go | 48 ++ internal/remnawave/client.go | 20 + internal/remnawave/client_test.go | 21 + 10 files changed, 1208 insertions(+), 37 deletions(-) create mode 100644 docs/progress/2026-03-23-payment-stage-8.md diff --git a/docs/progress/2026-03-23-payment-stage-8.md b/docs/progress/2026-03-23-payment-stage-8.md new file mode 100644 index 0000000..a217509 --- /dev/null +++ b/docs/progress/2026-03-23-payment-stage-8.md @@ -0,0 +1,82 @@ +# Этап 8: UI админа + +**Дата:** 2026-03-23 +**План:** [2026-03-22-payment-implementation-plan.md](../plans/2026-03-22-payment-implementation-plan.md), строки 1895–2003 +**Доп. контекст:** [2026-03-21-admin-ui-redesign.md](../plans/2026-03-21-admin-ui-redesign.md) +**Коммит:** `feat: этап 8 — доработать UI админа и режим обслуживания` + +## Что сделано + +### `internal/bot/keyboards.go` +- Добавлены кнопки: + - `BtnAdminStats` + - `BtnAdminMaintenance` + - `BtnAdminMaintenanceOff` + - `BtnAdminUserInfo` + - `BtnAdminSwitchInfinite` + - `BtnAdminChangePrice` +- `AdminKeyboard` переведён на сигнатуру `AdminKeyboard(maintenanceMode bool)` и теперь показывает: + - общую статистику + - toggle режима обслуживания +- `AdminManageKeyboard` дополнен кнопкой `🔍 Инфо о пользователе` +- Добавлено подменю `AdminSwitchSubmenu()` + +### `internal/bot/admin.go` +- Добавлена `handleAdminStats`: + - финансы за текущий месяц + - выплаты модераторам + - доход владельца + - операционные счётчики пользователей + - конверсия trial → first payment +- Добавлен flow `Инфо о пользователе`: + - `StateWaitAdminUserInfo` + - карточка с куратором, ценой, сроком, трафиком, устройствами, типом и статусом +- Добавлен toggle режима обслуживания: + - `maintenanceMode` переключается из админ-меню + - возвращается разная клавиатура под состояние +- `Сменить тариф` переработан в подменю: + - `♾️ Перевести на бессрочную` использует существующую логику + - `✏️ Изменить цену` реализован через + - `StateWaitAdminChangePriceID` + - `StateWaitAdminChangePriceValue` +- Добавлено уведомление пользователю при смене цены (если Telegram-бот инициализирован) +- Статистика модераторов переписана: + - каждый модератор отправляется отдельным сообщением + - расчёт идёт за прошлый завершённый месяц + - финансовые данные берутся из `moderator_earnings` + +### `internal/bot/handlers.go` +- Обновлена маршрутизация новых admin-кнопок и состояний +- Добавлены pending-сессии админской смены цены +- `userKeyboard` теперь скрывает оплату при `maintenanceMode=true` +- Обновлён `isMenuNavigationButton` под новые кнопки + +### `internal/remnawave/client.go` +- В `User` добавлен `HwidDeviceLimit` +- Добавлен `GetUserHwidDevicesCount(uuid string)` для карточки пользователя + +## Тесты + +### Обновлены / добавлены тесты +- `internal/bot/keyboards_test.go` + - новые кнопки главного админ-меню + - toggle режима обслуживания + - подменю смены тарифа +- `internal/bot/format_test.go` + - верхнеуровневое админ-меню обновлено под новый layout +- `internal/bot/admin_test.go` + - общая статистика + - карточка пользователя + - admin flow смены цены + - статистика модераторов отдельными сообщениями +- `internal/bot/handlers_test.go` + - скрытие оплаты в maintenance mode +- `internal/remnawave/client_test.go` + - получение количества HWID-устройств + +## Проверка + +- `GOCACHE=/tmp/go-build go test ./internal/bot ./internal/remnawave -count=1` +- Далее обязательный прогон: + - `make fmt` + - `make tests` diff --git a/internal/bot/admin.go b/internal/bot/admin.go index 0d80b06..286a660 100644 --- a/internal/bot/admin.go +++ b/internal/bot/admin.go @@ -19,6 +19,9 @@ const ( StateWaitDeleteInvite = "wait_delete_invite" // Ожидание кода для удаления StateWaitAddModerator = "wait_add_moderator" // Ожидание telegram_id для назначения модератора StateWaitRemoveModerator = "wait_remove_moderator" // Ожидание telegram_id для снятия модератора + StateWaitAdminUserInfo = "wait_admin_user_info" // Ожидание telegram_id для карточки пользователя + StateWaitAdminChangePriceID = "wait_admin_change_price_id" // Ожидание telegram_id для смены цены + StateWaitAdminChangePriceValue = "wait_admin_change_price_value" // Ожидание новой цены подписки StateWaitSwitchSubscriptionID = "wait_switch_subscription_id" // Ожидание telegram_id для смены тарифа StateWaitSwitchSubscriptionConfirm = "wait_switch_subscription_confirm" // Ожидание подтверждения смены тарифа ) @@ -29,6 +32,13 @@ type adminSwitchSession struct { UserUUID string } +type adminChangePriceSession struct { + TargetTelegramID int64 + TargetLabel string + CurrentPrice int + HasCurrentPrice bool +} + // isAdmin проверяет, является ли пользователь админом func (b *Bot) isAdmin(c tele.Context) bool { return c.Sender().ID == b.config.AdminID @@ -38,7 +48,7 @@ func (b *Bot) isAdmin(c tele.Context) bool { func (b *Bot) handleAdminStart(c tele.Context) error { return c.Send(MsgAdminWelcome, &tele.SendOptions{ ParseMode: tele.ModeHTML, - ReplyMarkup: AdminKeyboard(), + ReplyMarkup: AdminKeyboard(b.maintenanceMode), }) } @@ -86,12 +96,24 @@ func (b *Bot) handleBanUserRequest(c tele.Context) error { }) } -// handleSwitchSubscription запрашивает telegram_id для смены тарифа. +// handleSwitchSubscription показывает подменю смены тарифа. func (b *Bot) handleSwitchSubscription(c tele.Context) error { if !b.isAdmin(c) { return nil } + return c.Send("♾️ Смена тарифа\n\nВыберите действие:", &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: AdminSwitchSubmenu(), + }) +} + +// handleAdminSwitchInfiniteRequest запрашивает telegram_id для перевода на бессрочный тариф. +func (b *Bot) handleAdminSwitchInfiniteRequest(c tele.Context) error { + if !b.isAdmin(c) { + return nil + } + b.userStates.Set(c.Sender().ID, StateWaitSwitchSubscriptionID) b.clearAdminSwitchSession(c.Sender().ID) @@ -177,6 +199,243 @@ func (b *Bot) processSwitchSubscriptionID(c tele.Context, text string) error { }) } +// handleAdminUserInfoRequest запускает диалог просмотра карточки пользователя. +func (b *Bot) handleAdminUserInfoRequest(c tele.Context) error { + if !b.isAdmin(c) { + return nil + } + + b.userStates.Set(c.Sender().ID, StateWaitAdminUserInfo) + return c.Send("Введите telegram_id пользователя:", &tele.SendOptions{ + ReplyMarkup: CancelKeyboard(), + }) +} + +// processAdminUserInfo показывает полную карточку пользователя. +func (b *Bot) processAdminUserInfo(c tele.Context, text string) error { + adminID := c.Sender().ID + targetID, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64) + if err != nil { + return c.Send("❌ Неверный telegram_id.", &tele.SendOptions{ReplyMarkup: CancelKeyboard()}) + } + + b.userStates.Delete(adminID) + + isBanned, err := b.db.IsBanned(targetID) + if err != nil { + slog.Error("Failed to check ban status for admin user info", "error", err, "telegram_id", targetID) + return c.Send("Ошибка проверки пользователя", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + if isBanned { + return c.Send("🚫 Пользователь забанен.", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + + dbUser, err := b.db.GetUserByTelegramID(targetID) + if err != nil { + slog.Error("Failed to load DB user for admin info", "error", err, "telegram_id", targetID) + return c.Send("Ошибка получения пользователя", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + if dbUser == nil { + return c.Send("❌ Пользователь не найден.", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + + remUser, err := b.remnawave.GetUser(dbUser.RemnawaveUUID) + if err != nil { + slog.Error("Failed to load Remnawave user for admin info", "error", err, "telegram_id", targetID) + return c.Send("Ошибка получения данных подписки", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + + invite, err := b.db.GetInviteByUsedBy(targetID) + if err != nil { + slog.Error("Failed to load invite for admin info", "error", err, "telegram_id", targetID) + return c.Send("Ошибка получения данных пользователя", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + + curatorLabel := "админ" + switch { + case dbUser.ModeratorID != nil: + curatorLabel = b.formatAdminSwitchCurator(*dbUser.ModeratorID) + case invite != nil: + curatorLabel = b.formatAdminSwitchCurator(invite.CreatedBy) + } + + devicesLabel := "н/д" + devicesCount, err := b.remnawave.GetUserHwidDevicesCount(dbUser.RemnawaveUUID) + if err != nil { + slog.Error("Failed to load user HWID devices for admin info", "error", err, "telegram_id", targetID) + } else if remUser.HwidDeviceLimit > 0 { + devicesLabel = fmt.Sprintf("%d / %d", devicesCount, remUser.HwidDeviceLimit) + } else { + devicesLabel = fmt.Sprintf("%d", devicesCount) + } + + trafficLabel := "0.00 GB" + if remUser.UserTraffic != nil { + trafficLabel = fmt.Sprintf("%.2f GB", float64(remUser.UserTraffic.UsedTrafficBytes)/(1024*1024*1024)) + } + + typeLabel, statusLabel := b.describeAdminUserSubscription(targetID, remUser) + + var msg strings.Builder + msg.WriteString("🔍 Информация о пользователе\n\n") + fmt.Fprintf(&msg, "👤 %s\n", formatUserLabel(dbUser.FirstName, dbUser.Username, dbUser.TelegramID)) + fmt.Fprintf(&msg, "📋 Куратор: %s\n", curatorLabel) + fmt.Fprintf(&msg, "💳 Цена подписки: %s\n", formatPriceLabel(dbUser.SubscriptionPrice)) + if remUser.ExpireAt.Year() < 2099 { + fmt.Fprintf( + &msg, + "📅 Подписка до: %s (%s)\n", + remUser.ExpireAt.UTC().Format("02.01.2006"), + describeAdminRemaining(remUser.ExpireAt, time.Now().UTC()), + ) + } + fmt.Fprintf(&msg, "📊 Трафик за месяц: %s\n", trafficLabel) + fmt.Fprintf(&msg, "📡 Устройства: %s\n", devicesLabel) + fmt.Fprintf(&msg, "🏷 Тип: %s\n", typeLabel) + fmt.Fprintf(&msg, "✅ Статус: %s", statusLabel) + + return c.Send(msg.String(), &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: AdminManageKeyboard(), + }) +} + +// handleAdminChangePriceRequest запускает диалог изменения цены. +func (b *Bot) handleAdminChangePriceRequest(c tele.Context) error { + if !b.isAdmin(c) { + return nil + } + + telegramID := c.Sender().ID + b.userStates.Set(telegramID, StateWaitAdminChangePriceID) + b.clearAdminChangePriceSession(telegramID) + return c.Send("Введите telegram_id пользователя:", &tele.SendOptions{ + ReplyMarkup: CancelKeyboard(), + }) +} + +// processAdminChangePriceID выбирает пользователя для смены цены. +func (b *Bot) processAdminChangePriceID(c tele.Context, text string) error { + adminID := c.Sender().ID + targetID, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64) + if err != nil { + return c.Send("❌ Неверный telegram_id.", &tele.SendOptions{ReplyMarkup: CancelKeyboard()}) + } + + isBanned, err := b.db.IsBanned(targetID) + if err != nil { + slog.Error("Failed to check ban status before admin price change", "error", err, "telegram_id", targetID) + b.userStates.Delete(adminID) + return c.Send("Ошибка проверки пользователя", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + if isBanned { + b.userStates.Delete(adminID) + return c.Send("❌ Этот пользователь забанен.", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + + dbUser, err := b.db.GetUserByTelegramID(targetID) + if err != nil { + slog.Error("Failed to load DB user before admin price change", "error", err, "telegram_id", targetID) + b.userStates.Delete(adminID) + return c.Send("Ошибка получения пользователя", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + if dbUser == nil { + b.userStates.Delete(adminID) + return c.Send("❌ Пользователь не найден.", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + + invite, err := b.db.GetInviteByUsedBy(targetID) + if err != nil { + slog.Error("Failed to load invite before admin price change", "error", err, "telegram_id", targetID) + b.userStates.Delete(adminID) + return c.Send("Ошибка получения пользователя", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + if invite == nil { + b.userStates.Delete(adminID) + return c.Send("❌ Пользователь не найден.", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + if invite.ExpireDays == nil { + b.userStates.Delete(adminID) + return c.Send("❌ У этого пользователя бессрочная подписка, цена не применяется.", &tele.SendOptions{ + ReplyMarkup: AdminManageKeyboard(), + }) + } + + label := formatAdminSwitchTargetLabel(dbUser) + session := adminChangePriceSession{ + TargetTelegramID: targetID, + TargetLabel: label, + } + if dbUser.SubscriptionPrice != nil { + session.CurrentPrice = *dbUser.SubscriptionPrice + session.HasCurrentPrice = true + } + + b.setAdminChangePriceSession(adminID, session) + b.userStates.Set(adminID, StateWaitAdminChangePriceValue) + + return c.Send( + fmt.Sprintf( + "Текущая цена для %s: %s\nВведите новую цену (минимум %d руб):", + label, + formatAdminPriceValue(dbUser.SubscriptionPrice), + b.minSubscriptionPrice(), + ), + &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: CancelKeyboard(), + }, + ) +} + +// processAdminChangePriceValue завершает изменение цены. +func (b *Bot) processAdminChangePriceValue(c tele.Context, text string) error { + adminID := c.Sender().ID + newPrice, err := strconv.Atoi(strings.TrimSpace(text)) + if err != nil { + return c.Send("❌ Введите число.", &tele.SendOptions{ReplyMarkup: CancelKeyboard()}) + } + if newPrice < b.minSubscriptionPrice() { + return c.Send( + fmt.Sprintf("❌ Минимальная цена: %d руб.", b.minSubscriptionPrice()), + &tele.SendOptions{ReplyMarkup: CancelKeyboard()}, + ) + } + + session, ok := b.getAdminChangePriceSession(adminID) + if !ok { + b.userStates.Delete(adminID) + return c.Send("Сессия изменения цены потеряна. Начните заново.", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + + if err := b.db.UpdateSubscriptionPrice(session.TargetTelegramID, newPrice); err != nil { + slog.Error("Failed to update subscription price by admin", "error", err, "telegram_id", session.TargetTelegramID) + b.userStates.Delete(adminID) + b.clearAdminChangePriceSession(adminID) + return c.Send("Ошибка изменения цены", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + if err := b.db.UpdateInviteSubscriptionPrice(session.TargetTelegramID, newPrice); err != nil { + slog.Error("Failed to update invite subscription price by admin", "error", err, "telegram_id", session.TargetTelegramID) + } + + b.userStates.Delete(adminID) + b.clearAdminChangePriceSession(adminID) + b.notifyUserAboutPriceChange(session.TargetTelegramID, newPrice) + + return c.Send( + fmt.Sprintf( + "✅ Цена подписки для %s изменена: %s → %d руб/мес", + session.TargetLabel, + formatAdminOldPrice(session), + newPrice, + ), + &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: AdminManageKeyboard(), + }, + ) +} + // processSwitchSubscriptionConfirm подтверждает перевод на бессрочный тариф. func (b *Bot) processSwitchSubscriptionConfirm(c tele.Context, text string) error { adminID := c.Sender().ID @@ -359,6 +618,143 @@ func strPtr(v string) *string { return &v } +// handleAdminStats показывает общую финансовую и пользовательскую статистику за текущий месяц. +func (b *Bot) handleAdminStats(c tele.Context) error { + if !b.isAdmin(c) { + return nil + } + + now := time.Now().UTC() + year := now.Year() + month := int(now.Month()) + + monthEarnings, err := b.db.GetAllEarningsByMonth(year, month) + if err != nil { + slog.Error("Failed to load monthly earnings for admin stats", "error", err) + return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + } + + trialsThisMonth, err := b.db.CountTrialsByMonth(year, month) + if err != nil { + slog.Error("Failed to count trials for admin stats", "error", err) + return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + } + + firstPayments, err := b.db.CountFirstPaymentsByMonth(year, month) + if err != nil { + slog.Error("Failed to count first payments for admin stats", "error", err) + return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + } + + dbUsers, err := b.db.GetAllUsers() + if err != nil { + slog.Error("Failed to load DB users for admin stats", "error", err) + return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + } + + remUsers, err := b.remnawave.GetAllUsers() + if err != nil { + slog.Error("Failed to load Remnawave users for admin stats", "error", err) + return c.Send("Ошибка получения статистики из панели", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + } + + byTelegramID := make(map[int64]remnawave.User, len(remUsers)) + for _, user := range remUsers { + if user.TelegramID == nil || *user.TelegramID == 0 { + continue + } + byTelegramID[*user.TelegramID] = user + } + + payingCount := 0 + trialCount := 0 + graceCount := 0 + infiniteCount := 0 + + for _, user := range dbUsers { + remUser, ok := byTelegramID[user.TelegramID] + if !ok { + continue + } + + switch { + case remUser.ExpireAt.Year() >= 2099: + infiniteCount++ + case remUser.Status == remnawave.StatusDisabled && !remUser.ExpireAt.After(now): + graceCount++ + case b.isTrialUser(user.TelegramID): + trialCount++ + case remUser.Status == remnawave.StatusActive && remUser.ExpireAt.After(now): + payingCount++ + } + } + totalUsers := payingCount + trialCount + graceCount + infiniteCount + + conversion := 0 + if trialsThisMonth > 0 { + conversion = (firstPayments*100 + trialsThisMonth/2) / trialsThisMonth + } + + ownerIncome := monthEarnings.TotalNetAmount - monthEarnings.TotalShareAmount + + msg := fmt.Sprintf( + "📊 Общая статистика — %s %d\n\n"+ + "💰 Финансы\n"+ + "├ Платежей за месяц: %d\n"+ + "├ Сумма платежей (грязная): %d руб\n"+ + "├ Комиссии Platega: -%d руб\n"+ + "├ Комиссия вывода (2%%): -%d руб\n"+ + "├ Чистый доход: %d руб\n"+ + "├ Выплаты модераторам: -%d руб\n"+ + "└ Доход владельца: %d руб\n\n"+ + "👥 Пользователи\n"+ + "├ Всего в системе: %d\n"+ + "├ 💳 Платящих: %d\n"+ + "├ ⏳ Триал: %d\n"+ + "├ ⚠️ Grace period: %d\n"+ + "├ ♾️ Бессрочных: %d\n"+ + "└ 📈 Конверсия триал → оплата: %d%%", + monthNameRu(now.Month()), + now.Year(), + monthEarnings.TotalPayments, + monthEarnings.GrossAmount, + monthEarnings.TotalPlategaFee, + monthEarnings.TotalWithdrawal, + monthEarnings.TotalNetAmount, + monthEarnings.TotalShareAmount, + ownerIncome, + totalUsers, + payingCount, + trialCount, + graceCount, + infiniteCount, + conversion, + ) + + return c.Send(msg, &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: AdminKeyboard(b.maintenanceMode), + }) +} + +// handleAdminMaintenanceToggle переключает режим обслуживания. +func (b *Bot) handleAdminMaintenanceToggle(c tele.Context) error { + if !b.isAdmin(c) { + return nil + } + + b.maintenanceMode = !b.maintenanceMode + + msg := "🔧 Режим обслуживания включён. Оплата и кики приостановлены." + if !b.maintenanceMode { + msg = "▶️ Штатный режим восстановлен. Оплата и scheduler работают." + } + + return c.Send(msg, &tele.SendOptions{ + ReplyMarkup: AdminKeyboard(b.maintenanceMode), + }) +} + // handleAdminBroadcastMenu показывает меню рассылки func (b *Bot) handleAdminBroadcastMenu(c tele.Context) error { if !b.isAdmin(c) { @@ -738,11 +1134,12 @@ func (b *Bot) handleAdminModStats(c tele.Context) error { } now := time.Now().UTC() - totalActive := 0 - totalExpired := 0 - - var msg strings.Builder - msg.WriteString("📊 Статистика модераторов\n\n") + reportDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, -1) + reportYear := reportDate.Year() + reportMonth := int(reportDate.Month()) + totalPayments := 0 + totalGross := 0 + totalShare := 0 for _, mod := range mods { subs, err := b.db.GetSubscribersByModerator(mod.TelegramID) @@ -751,33 +1148,79 @@ func (b *Bot) handleAdminModStats(c tele.Context) error { continue } - active := 0 - expired := 0 + paying := 0 + trial := 0 + grace := 0 for _, sub := range subs { remUser, ok := byTelegramID[sub.TelegramID] if !ok { continue } - if remUser.Status == remnawave.StatusExpired || remUser.ExpireAt.Before(now) { - expired++ - } else { - active++ + switch b.describeSubscriberStatus(sub.TelegramID, remUser, now) { + case "paid": + paying++ + case "trial": + trial++ + case "grace": + grace++ } } - totalActive += active - totalExpired += expired + monthStats, err := b.db.GetModeratorEarningsByMonth(mod.TelegramID, reportYear, reportMonth) + if err != nil { + slog.Error("Failed to load moderator month earnings", "error", err, "moderator_id", mod.TelegramID) + continue + } + totalEarnings, err := b.db.GetModeratorTotalEarnings(mod.TelegramID) + if err != nil { + slog.Error("Failed to load moderator total earnings", "error", err, "moderator_id", mod.TelegramID) + continue + } - fmt.Fprintf(&msg, "👤 %s\n", formatUserLabel(mod.FirstName, mod.Username, mod.TelegramID)) - fmt.Fprintf(&msg, " ✅ Активных: %d\n", active) - fmt.Fprintf(&msg, " ⏰ Истекших: %d\n", expired) - fmt.Fprintf(&msg, " 👥 Всего приглашено: %d\n\n", len(subs)) + totalPayments += monthStats.TotalPayments + totalGross += monthStats.GrossAmount + totalShare += monthStats.TotalShareAmount + + sharePercent := monthStats.SharePercent + msg := fmt.Sprintf( + "📊 Статистика: %s — %s %d\n\n"+ + "💳 Платящих: %d │ ⏳ Триал: %d │ ⚠️ Grace: %d\n"+ + "📥 Платежи: %d руб\n"+ + "📉 Комиссии Platega: -%d руб\n"+ + "📉 Комиссия вывода (2%%): -%d руб\n"+ + "📊 Чистый доход: %d руб\n"+ + "💰 Доля модератора (%d%%): %d руб\n"+ + "💰 За всё время: %d руб", + formatAdminModeratorLabel(mod.FirstName, mod.Username, mod.TelegramID), + monthNameRu(reportDate.Month()), + reportYear, + paying, + trial, + grace, + monthStats.GrossAmount, + monthStats.TotalPlategaFee, + monthStats.TotalWithdrawal, + monthStats.TotalNetAmount, + sharePercent, + monthStats.TotalShareAmount, + totalEarnings, + ) + + if err := c.Send(msg, &tele.SendOptions{ParseMode: tele.ModeHTML}); err != nil { + return err + } } - msg.WriteString("───\n") - fmt.Fprintf(&msg, "Итого: ✅ %d активных │ ⏰ %d истекших", totalActive, totalExpired) + summary := fmt.Sprintf( + "Итого за %s %d\n\n📥 Платежей: %d\n💰 Сумма платежей: %d руб\n💸 Выплаты модераторам: %d руб", + monthNameRu(reportDate.Month()), + reportYear, + totalPayments, + totalGross, + totalShare, + ) - return c.Send(msg.String(), &tele.SendOptions{ + return c.Send(summary, &tele.SendOptions{ ParseMode: tele.ModeHTML, ReplyMarkup: AdminModeratorKeyboard(), }) @@ -818,3 +1261,104 @@ func (b *Bot) processRemoveModerator(c tele.Context, text string) error { ReplyMarkup: AdminModeratorKeyboard(), }) } + +func (b *Bot) setAdminChangePriceSession(adminID int64, session adminChangePriceSession) { + b.adminPriceMu.Lock() + defer b.adminPriceMu.Unlock() + if b.adminPriceData == nil { + b.adminPriceData = make(map[int64]adminChangePriceSession) + } + b.adminPriceData[adminID] = session +} + +func (b *Bot) getAdminChangePriceSession(adminID int64) (adminChangePriceSession, bool) { + b.adminPriceMu.RLock() + defer b.adminPriceMu.RUnlock() + session, ok := b.adminPriceData[adminID] + return session, ok +} + +func (b *Bot) clearAdminChangePriceSession(adminID int64) { + b.adminPriceMu.Lock() + defer b.adminPriceMu.Unlock() + delete(b.adminPriceData, adminID) +} + +func (b *Bot) notifyUserAboutPriceChange(telegramID int64, newPrice int) { + if b.bot == nil { + return + } + + _, err := b.bot.Send(&tele.User{ID: telegramID}, fmt.Sprintf("💳 Цена вашей подписки изменена: %d руб/мес", newPrice)) + if err != nil { + slog.Error("Failed to notify user about price change", "error", err, "telegram_id", telegramID) + } +} + +func (b *Bot) describeAdminUserSubscription(telegramID int64, remUser *remnawave.User) (string, string) { + if remUser == nil { + return "неизвестно", "неизвестно" + } + + switch { + case remUser.ExpireAt.Year() >= 2099: + return "♾️ Безлимитная", "Активен" + case remUser.Status == remnawave.StatusDisabled && !remUser.ExpireAt.After(time.Now().UTC()): + return "💳 Подписка", "Grace period" + case b.isTrialUser(telegramID): + return "⏳ Триал", "Активен" + default: + return "💳 Подписка", humanizeAdminStatus(remUser.Status) + } +} + +func describeAdminRemaining(expireAt, now time.Time) string { + if !expireAt.After(now) { + return "истекла" + } + + days := daysUntil(expireAt, now) + return fmt.Sprintf("осталось %d дн.", days) +} + +func humanizeAdminStatus(status string) string { + switch status { + case remnawave.StatusActive: + return "Активен" + case remnawave.StatusDisabled: + return "Отключён" + case remnawave.StatusExpired: + return "Истёк" + case remnawave.StatusLimited: + return "Лимит трафика" + default: + return status + } +} + +func formatAdminPriceValue(price *int) string { + if price == nil { + return "не установлена" + } + return fmt.Sprintf("%d руб/мес", *price) +} + +func formatAdminOldPrice(session adminChangePriceSession) string { + if !session.HasCurrentPrice { + return "не установлена" + } + return fmt.Sprintf("%d", session.CurrentPrice) +} + +func formatAdminModeratorLabel(firstName, username string, telegramID int64) string { + switch { + case firstName != "" && username != "": + return fmt.Sprintf("%s (@%s)", html.EscapeString(firstName), username) + case username != "": + return "@" + username + case firstName != "": + return html.EscapeString(firstName) + default: + return fmt.Sprintf("%d", telegramID) + } +} diff --git a/internal/bot/admin_test.go b/internal/bot/admin_test.go index 03435b1..d3dca81 100644 --- a/internal/bot/admin_test.go +++ b/internal/bot/admin_test.go @@ -1,6 +1,7 @@ package bot import ( + "database/sql" "encoding/json" "io" "net/http" @@ -185,6 +186,39 @@ func TestHandleAdminModStats(t *testing.T) { require.NoError(t, err) require.NoError(t, db.ClaimInvite(inv.Code, subID)) + paymentID, err := db.CreatePayment(&database.Payment{ + TelegramID: subID, + ModeratorID: &modID, + Amount: 500, + PaymentMethod: "card", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(paymentID)) + + _, err = db.CreateEarning(&database.ModeratorEarning{ + PaymentID: paymentID, + ModeratorID: modID, + GrossAmount: 500, + PlategaFee: 50, + WithdrawalFee: 10, + NetAmount: 440, + SharePercent: 15, + ShareAmount: 66, + }) + require.NoError(t, err) + + rawDB, err := sql.Open("sqlite3", dbFile) + require.NoError(t, err) + defer rawDB.Close() + + prevMonth := time.Now().UTC().AddDate(0, -1, 0) + _, err = rawDB.Exec( + `UPDATE moderator_earnings SET created_at = ?`, + time.Date(prevMonth.Year(), prevMonth.Month(), 15, 12, 0, 0, 0, time.UTC), + ) + require.NoError(t, err) + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) client.SetHTTPClient(&http.Client{ Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { @@ -228,11 +262,20 @@ func TestHandleAdminModStats(t *testing.T) { err = b.handleAdminModStats(ctx) require.NoError(t, err) - msg, ok := ctx.sentMsg.(string) + require.Len(t, ctx.sentMsgs, 2) + + msg, ok := ctx.sentMsgs[0].(string) require.True(t, ok) - assert.Contains(t, msg, "Статистика модераторов") + assert.Contains(t, msg, "Статистика:") assert.Contains(t, msg, "@moderator") - assert.Contains(t, msg, "Активных: 1") + assert.Contains(t, msg, "Платящих: 1") + assert.Contains(t, msg, "Платежи: 500 руб") + assert.Contains(t, msg, "Доля модератора (15%)") + + summary, ok := ctx.sentMsgs[1].(string) + require.True(t, ok) + assert.Contains(t, summary, "Итого") + assert.Contains(t, summary, "66 руб") } // TestFormatAdminSwitchTargetLabel_HTMLEscaping проверяет экранирование HTML в имени пользователя @@ -413,6 +456,292 @@ func TestProcessSwitchSubscription_ConfirmFlow(t *testing.T) { assert.Empty(t, b.userStates.Get(adminID)) } +func TestHandleAdminStats_ShowsFinanceAndConversion(t *testing.T) { + dbFile := "test_admin_stats.db" + db, err := database.New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + adminID := int64(999999) + modID := int64(100) + payingID := int64(200) + trialID := int64(201) + graceID := int64(202) + infiniteID := int64(203) + + _, err = db.CreateUser(modID, "moderator", "Модератор", "uuid-mod", nil, nil) + require.NoError(t, err) + require.NoError(t, db.AddModerator(modID, adminID)) + + price := 500 + _, err = db.CreateUser(payingID, "paid", "Paid", "uuid-paid", &price, &modID) + require.NoError(t, err) + _, err = db.CreateUser(trialID, "trial", "Trial", "uuid-trial", &price, &modID) + require.NoError(t, err) + _, err = db.CreateUser(graceID, "grace", "Grace", "uuid-grace", &price, &modID) + require.NoError(t, err) + _, err = db.CreateUser(infiniteID, "inf", "Infinite", "uuid-inf", nil, nil) + require.NoError(t, err) + + finiteInvite, err := db.CreateInviteWithExpiry(modID, intPtrAdmin(30)) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(finiteInvite.Code, payingID)) + + finiteInvite2, err := db.CreateInviteWithExpiry(modID, intPtrAdmin(30)) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(finiteInvite2.Code, trialID)) + + finiteInvite3, err := db.CreateInviteWithExpiry(modID, intPtrAdmin(30)) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(finiteInvite3.Code, graceID)) + + infiniteInvite, err := db.CreateInviteWithExpiry(adminID, nil) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(infiniteInvite.Code, infiniteID)) + + paymentID, err := db.CreatePayment(&database.Payment{ + TelegramID: payingID, + ModeratorID: &modID, + Amount: 500, + PaymentMethod: "card", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(paymentID)) + + _, err = db.CreateEarning(&database.ModeratorEarning{ + PaymentID: paymentID, + ModeratorID: modID, + GrossAmount: 500, + PlategaFee: 50, + WithdrawalFee: 10, + NetAmount: 440, + SharePercent: 15, + ShareAmount: 66, + }) + require.NoError(t, err) + + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/api/users" { + payload, err := json.Marshal(map[string]any{ + "response": map[string]any{ + "users": []map[string]any{ + { + "uuid": "uuid-paid", + "telegramId": payingID, + "status": remnawave.StatusActive, + "expireAt": time.Now().UTC().AddDate(0, 0, 20).Format(time.RFC3339), + }, + { + "uuid": "uuid-trial", + "telegramId": trialID, + "status": remnawave.StatusActive, + "expireAt": time.Now().UTC().AddDate(0, 0, 2).Format(time.RFC3339), + }, + { + "uuid": "uuid-grace", + "telegramId": graceID, + "status": remnawave.StatusDisabled, + "expireAt": time.Now().UTC().Add(-12 * time.Hour).Format(time.RFC3339), + }, + { + "uuid": "uuid-inf", + "telegramId": infiniteID, + "status": remnawave.StatusActive, + "expireAt": time.Date(2099, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339), + }, + }, + "total": 4, + }, + }) + require.NoError(t, err) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(payload))), + Header: make(http.Header), + }, nil + } + return nil, assert.AnError + }), + }) + + b := &Bot{ + db: db, + remnawave: client, + config: &config.Config{AdminID: adminID}, + userStates: newStateMap(), + } + + ctx := &MockContext{sender: &tele.User{ID: adminID}} + err = b.handleAdminStats(ctx) + require.NoError(t, err) + + msg, ok := ctx.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, msg, "Общая статистика") + assert.Contains(t, msg, "Платежей за месяц: 1") + assert.Contains(t, msg, "Сумма платежей (грязная): 500 руб") + assert.Contains(t, msg, "Комиссии Platega: -50 руб") + assert.Contains(t, msg, "Комиссия вывода (2%): -10 руб") + assert.Contains(t, msg, "Чистый доход: 440 руб") + assert.Contains(t, msg, "Выплаты модераторам: -66 руб") + assert.Contains(t, msg, "Доход владельца: 374 руб") + assert.Contains(t, msg, "Всего в системе: 4") + assert.Contains(t, msg, "💳 Платящих: 1") + assert.Contains(t, msg, "⏳ Триал: 1") + assert.Contains(t, msg, "⚠️ Grace period: 1") + assert.Contains(t, msg, "♾️ Бессрочных: 1") + assert.Contains(t, msg, "Конверсия триал → оплата: 33%") +} + +func TestProcessAdminUserInfo_ShowsFullCard(t *testing.T) { + dbFile := "test_admin_user_info.db" + db, err := database.New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + adminID := int64(999999) + modID := int64(100) + targetID := int64(12345) + price := 500 + + _, err = db.CreateUser(modID, "petr", "Пётр", "uuid-mod", nil, nil) + require.NoError(t, err) + require.NoError(t, db.AddModerator(modID, adminID)) + + _, err = db.CreateUser(targetID, "ivan", "Иван", "uuid-target", &price, &modID) + require.NoError(t, err) + invite, err := db.CreateInviteWithExpiry(modID, intPtrAdmin(30)) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(invite.Code, targetID)) + paymentID, err := db.CreatePayment(&database.Payment{ + TelegramID: targetID, + ModeratorID: &modID, + Amount: price, + PaymentMethod: "card", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(paymentID)) + + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-target": + payload := `{"response":{"uuid":"uuid-target","username":"ivan","status":"ACTIVE","expireAt":"2026-04-15T00:00:00Z","hwidDeviceLimit":3,"userTraffic":{"usedTrafficBytes":13421772800,"lifetimeUsedTrafficBytes":13421772800}}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodGet && r.URL.Path == "/api/hwid/devices/uuid-target": + payload := `{"response":{"total":2,"devices":[{"hwid":"a"},{"hwid":"b"}]}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + default: + return nil, assert.AnError + } + }), + }) + + b := &Bot{ + db: db, + remnawave: client, + config: &config.Config{AdminID: adminID}, + userStates: newStateMap(), + } + + ctx := &MockContext{sender: &tele.User{ID: adminID}} + err = b.processAdminUserInfo(ctx, strconv.FormatInt(targetID, 10)) + require.NoError(t, err) + + msg, ok := ctx.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, msg, "Информация о пользователе") + assert.Contains(t, msg, "@ivan") + assert.Contains(t, msg, "@petr") + assert.Contains(t, msg, "500 руб/мес") + assert.Contains(t, msg, "15.04.2026") + assert.Contains(t, msg, "12.50 GB") + assert.Contains(t, msg, "2 / 3") + assert.Contains(t, msg, "💳 Подписка") + assert.Contains(t, msg, "Статус: Активен") +} + +func TestAdminChangePriceFlow_UpdatesPaidUser(t *testing.T) { + dbFile := "test_admin_change_price.db" + db, err := database.New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + adminID := int64(999999) + targetID := int64(12345) + oldPrice := 500 + + _, err = db.CreateUser(targetID, "paid", "Paid", "uuid-target", &oldPrice, nil) + require.NoError(t, err) + invite, err := db.CreateInviteWithExpiry(adminID, intPtrAdmin(30)) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(invite.Code, targetID)) + + paymentID, err := db.CreatePayment(&database.Payment{ + TelegramID: targetID, + Amount: oldPrice, + PaymentMethod: "card", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(paymentID)) + + b := &Bot{ + db: db, + config: &config.Config{AdminID: adminID, MinSubscriptionPrice: 400}, + userStates: newStateMap(), + } + + ctxID := &MockContext{sender: &tele.User{ID: adminID}} + require.NoError(t, b.processAdminChangePriceID(ctxID, strconv.FormatInt(targetID, 10))) + require.Equal(t, StateWaitAdminChangePriceValue, b.userStates.Get(adminID)) + + msgID, ok := ctxID.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, msgID, "Текущая цена") + assert.Contains(t, msgID, "500 руб/мес") + + ctxValue := &MockContext{sender: &tele.User{ID: adminID}} + require.NoError(t, b.processAdminChangePriceValue(ctxValue, "650")) + + updatedUser, err := db.GetUserByTelegramID(targetID) + require.NoError(t, err) + require.NotNil(t, updatedUser.SubscriptionPrice) + assert.Equal(t, 650, *updatedUser.SubscriptionPrice) + + updatedInvite, err := db.GetInviteByUsedBy(targetID) + require.NoError(t, err) + require.NotNil(t, updatedInvite.SubscriptionPrice) + assert.Equal(t, 650, *updatedInvite.SubscriptionPrice) + + msgValue, ok := ctxValue.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, msgValue, "изменена: 500 → 650 руб/мес") + assert.Empty(t, b.userStates.Get(adminID)) +} + func intPtrAdmin(v int) *int { return &v } diff --git a/internal/bot/format_test.go b/internal/bot/format_test.go index d87abce..debed6d 100644 --- a/internal/bot/format_test.go +++ b/internal/bot/format_test.go @@ -62,7 +62,7 @@ func TestFormatSubscriberLabel_ContainsIDOnce(t *testing.T) { // TestAdminKeyboardContainsModeratorsOnTopLevel проверяет что Модераторы на верхнем уровне func TestAdminKeyboardContainsModeratorsOnTopLevel(t *testing.T) { - keyboard := AdminKeyboard() + keyboard := AdminKeyboard(false) var buttons []string for _, row := range keyboard.ReplyKeyboard { @@ -74,6 +74,8 @@ func TestAdminKeyboardContainsModeratorsOnTopLevel(t *testing.T) { assert.Contains(t, buttons, BtnAdminModerators) assert.Contains(t, buttons, BtnAdminManage) assert.Contains(t, buttons, BtnAdminBroadcast) + assert.Contains(t, buttons, BtnAdminStats) + assert.Contains(t, buttons, BtnAdminMaintenance) assert.Contains(t, buttons, BtnAdminUserMode) } @@ -170,4 +172,5 @@ func TestAdminManageKeyboardDoesNotContainModerators(t *testing.T) { assert.Contains(t, buttons, BtnAdminDeleteInvite) assert.Contains(t, buttons, BtnAdminBanUser) assert.Contains(t, buttons, BtnAdminSwitchSubscription) + assert.Contains(t, buttons, BtnAdminUserInfo) } diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 1dce310..c936818 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -40,6 +40,8 @@ type Bot struct { modChangePriceData map[int64]modChangePriceSession // pending-данные изменения цены для модератора adminSwitchMu sync.RWMutex adminSwitchData map[int64]adminSwitchSession // pending-данные перевода тарифа для админа + adminPriceMu sync.RWMutex + adminPriceData map[int64]adminChangePriceSession // pending-данные изменения цены для админа } // New создаёт нового Telegram бота @@ -65,6 +67,7 @@ func New(cfg *config.Config, db *database.DB, remnawaveClient *remnawave.Client) sdConfigsPath: cfg.SDConfigsPath, modChangePriceData: make(map[int64]modChangePriceSession), adminSwitchData: make(map[int64]adminSwitchSession), + adminPriceData: make(map[int64]adminChangePriceSession), } // Middleware для логирования @@ -246,7 +249,7 @@ func (b *Bot) handleTextMessage(c tele.Context) error { case StateWaitBroadcastActive: if text == BtnCancel { b.userStates.Delete(telegramID) - return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard()}) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) } if b.isAdmin(c) { return b.processBroadcastMessage(c) @@ -255,7 +258,7 @@ func (b *Bot) handleTextMessage(c tele.Context) error { case StateWaitBanUser: if text == BtnCancel { b.userStates.Delete(telegramID) - return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard()}) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) } if b.isAdmin(c) { return b.processBanUser(c, text) @@ -264,7 +267,7 @@ func (b *Bot) handleTextMessage(c tele.Context) error { case StateWaitDeleteInvite: if text == BtnCancel { b.userStates.Delete(telegramID) - return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard()}) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) } if b.isAdmin(c) { return b.processDeleteInvite(c, text) @@ -290,6 +293,35 @@ func (b *Bot) handleTextMessage(c tele.Context) error { return b.processSwitchSubscriptionConfirm(c, text) } + case StateWaitAdminUserInfo: + if text == BtnCancel { + b.userStates.Delete(telegramID) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + if b.isAdmin(c) { + return b.processAdminUserInfo(c, text) + } + + case StateWaitAdminChangePriceID: + if text == BtnCancel { + b.userStates.Delete(telegramID) + b.clearAdminChangePriceSession(telegramID) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + if b.isAdmin(c) { + return b.processAdminChangePriceID(c, text) + } + + case StateWaitAdminChangePriceValue: + if text == BtnCancel { + b.userStates.Delete(telegramID) + b.clearAdminChangePriceSession(telegramID) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + if b.isAdmin(c) { + return b.processAdminChangePriceValue(c, text) + } + case StateWaitModDeleteInvite: if text == BtnCancel { b.userStates.Delete(telegramID) @@ -323,7 +355,7 @@ func (b *Bot) handleTextMessage(c tele.Context) error { case StateWaitAddModerator: if text == BtnCancel { b.userStates.Delete(telegramID) - return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard()}) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) } if b.isAdmin(c) { return b.processAddModerator(c, text) @@ -332,7 +364,7 @@ func (b *Bot) handleTextMessage(c tele.Context) error { case StateWaitRemoveModerator: if text == BtnCancel { b.userStates.Delete(telegramID) - return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard()}) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) } if b.isAdmin(c) { return b.processRemoveModerator(c, text) @@ -370,6 +402,10 @@ func (b *Bot) handleTextMessage(c tele.Context) error { return b.handleAdminManageMenu(c) case BtnAdminBroadcast: return b.handleAdminBroadcastMenu(c) + case BtnAdminStats: + return b.handleAdminStats(c) + case BtnAdminMaintenance, BtnAdminMaintenanceOff: + return b.handleAdminMaintenanceToggle(c) case BtnAdminUserMode: return b.handleUserMode(c) case BtnAdminBack: @@ -384,6 +420,12 @@ func (b *Bot) handleTextMessage(c tele.Context) error { return b.handleDeleteInviteRequest(c) case BtnAdminSwitchSubscription: return b.handleSwitchSubscription(c) + case BtnAdminSwitchInfinite: + return b.handleAdminSwitchInfiniteRequest(c) + case BtnAdminChangePrice: + return b.handleAdminChangePriceRequest(c) + case BtnAdminUserInfo: + return b.handleAdminUserInfoRequest(c) case BtnBroadcastActive: return b.handleBroadcastActiveRequest(c) case BtnAdminModerators: @@ -687,6 +729,11 @@ func (b *Bot) userKeyboard(telegramID int64) *tele.ReplyMarkup { return UserMenuKeyboardDynamic("", false, isMod) } + // В режиме обслуживания скрываем оплату для всех. + if b.maintenanceMode { + return UserMenuKeyboardDynamic("", false, isMod) + } + // Проверяем тип подписки для определения текста кнопки remUser, err := b.remnawave.GetUserByTelegramID(telegramID) if err != nil || remUser == nil { @@ -779,13 +826,18 @@ func isMenuNavigationButton(text string) bool { BtnModBack, BtnAdminManage, BtnAdminBroadcast, + BtnAdminStats, + BtnAdminMaintenance, + BtnAdminMaintenanceOff, BtnAdminUserMode, BtnAdminBack, BtnAdminCreateInvite, BtnAdminViewInvites, BtnAdminDeleteInvite, BtnAdminBanUser, + BtnAdminUserInfo, BtnAdminSwitchSubscription, + BtnAdminSwitchInfinite, BtnBroadcastActive, BtnAdminModerators, BtnAdminAddModerator, diff --git a/internal/bot/handlers_test.go b/internal/bot/handlers_test.go index eca79bd..320bfaa 100644 --- a/internal/bot/handlers_test.go +++ b/internal/bot/handlers_test.go @@ -11,6 +11,7 @@ import ( "github.com/fus1ond/vpn_bot/internal/config" "github.com/fus1ond/vpn_bot/internal/database" + "github.com/fus1ond/vpn_bot/internal/platega" "github.com/fus1ond/vpn_bot/internal/remnawave" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,10 +21,11 @@ import ( // MockContext реализует интерфейс tele.Context для тестов type MockContext struct { tele.Context - sender *tele.User - message *tele.Message - sentMsg any - opts []any + sender *tele.User + message *tele.Message + sentMsg any + sentMsgs []any + opts []any } func (c *MockContext) Sender() *tele.User { @@ -36,6 +38,7 @@ func (c *MockContext) Message() *tele.Message { func (c *MockContext) Send(what any, opts ...any) error { c.sentMsg = what + c.sentMsgs = append(c.sentMsgs, what) c.opts = opts return nil } @@ -199,6 +202,51 @@ func TestHandleStart(t *testing.T) { }) } +func TestUserKeyboardHidesPaymentButtonInMaintenanceMode(t *testing.T) { + b, db := setupTestBot(t) + + userID := int64(777) + price := 500 + _, err := db.CreateUser(userID, "paid", "Paid", "uuid-paid", &price, nil) + require.NoError(t, err) + + b.platega = platega.NewClient("merchant", "secret") + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/api/users/by-telegram-id/777" { + payload := `{"response":{"uuid":"uuid-paid","username":"paid","status":"ACTIVE","expireAt":"2026-04-15T00:00:00Z"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + } + return nil, assert.AnError + }), + }) + + kb := b.userKeyboard(userID) + var normalButtons []string + for _, row := range kb.ReplyKeyboard { + for _, btn := range row { + normalButtons = append(normalButtons, btn.Text) + } + } + assert.Contains(t, normalButtons, BtnRenew) + + b.maintenanceMode = true + + kb = b.userKeyboard(userID) + var maintenanceButtons []string + for _, row := range kb.ReplyKeyboard { + for _, btn := range row { + maintenanceButtons = append(maintenanceButtons, btn.Text) + } + } + assert.NotContains(t, maintenanceButtons, BtnPay) + assert.NotContains(t, maintenanceButtons, BtnRenew) +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { diff --git a/internal/bot/keyboards.go b/internal/bot/keyboards.go index b15b40e..a0c7b75 100644 --- a/internal/bot/keyboards.go +++ b/internal/bot/keyboards.go @@ -32,13 +32,19 @@ const ( // Админ-кнопки BtnAdminManage = "📋 Управление" BtnAdminBroadcast = "📢 Рассылка" + BtnAdminStats = "📊 Общая статистика" + BtnAdminMaintenance = "🔧 Режим обслуживания" + BtnAdminMaintenanceOff = "▶️ Штатный режим" BtnAdminUserMode = "👤 Режим пользователя" BtnAdminBack = "🔙 В меню админа" BtnAdminCreateInvite = "🎟 Создать инвайт" BtnAdminViewInvites = "📋 Коды" BtnAdminDeleteInvite = "🗑 Удалить код" BtnAdminBanUser = "🚫 Забанить" + BtnAdminUserInfo = "🔍 Инфо о пользователе" BtnAdminSwitchSubscription = "♾️ Сменить тариф" + BtnAdminSwitchInfinite = "♾️ Перевести на бессрочную" + BtnAdminChangePrice = "✏️ Изменить цену" // Кнопки подтверждения BtnConfirmYes = "Да" @@ -97,11 +103,17 @@ func InstructionsKeyboard() *tele.ReplyMarkup { } // AdminKeyboard возвращает главное меню админа -func AdminKeyboard() *tele.ReplyMarkup { +func AdminKeyboard(maintenanceMode bool) *tele.ReplyMarkup { menu := &tele.ReplyMarkup{ResizeKeyboard: true} + maintenanceBtn := BtnAdminMaintenance + if maintenanceMode { + maintenanceBtn = BtnAdminMaintenanceOff + } menu.Reply( menu.Row(menu.Text(BtnAdminManage), menu.Text(BtnAdminModerators)), - menu.Row(menu.Text(BtnAdminBroadcast), menu.Text(BtnAdminUserMode)), + menu.Row(menu.Text(BtnAdminBroadcast), menu.Text(BtnAdminStats)), + menu.Row(menu.Text(maintenanceBtn)), + menu.Row(menu.Text(BtnAdminUserMode)), ) return menu } @@ -113,6 +125,18 @@ func AdminManageKeyboard() *tele.ReplyMarkup { menu.Row(menu.Text(BtnAdminCreateInvite), menu.Text(BtnAdminViewInvites)), menu.Row(menu.Text(BtnAdminBanUser), menu.Text(BtnAdminDeleteInvite)), menu.Row(menu.Text(BtnAdminSwitchSubscription)), + menu.Row(menu.Text(BtnAdminUserInfo)), + menu.Row(menu.Text(BtnAdminBack)), + ) + return menu +} + +// AdminSwitchSubmenu возвращает подменю смены тарифа. +func AdminSwitchSubmenu() *tele.ReplyMarkup { + menu := &tele.ReplyMarkup{ResizeKeyboard: true} + menu.Reply( + menu.Row(menu.Text(BtnAdminSwitchInfinite)), + menu.Row(menu.Text(BtnAdminChangePrice)), menu.Row(menu.Text(BtnAdminBack)), ) return menu diff --git a/internal/bot/keyboards_test.go b/internal/bot/keyboards_test.go index 99d96ab..8e9d509 100644 --- a/internal/bot/keyboards_test.go +++ b/internal/bot/keyboards_test.go @@ -22,6 +22,54 @@ func TestAdminManageKeyboardDoesNotContainAddTrafficButton(t *testing.T) { assert.Contains(t, buttons, BtnAdminDeleteInvite) assert.Contains(t, buttons, BtnAdminBanUser) assert.Contains(t, buttons, BtnAdminSwitchSubscription) + assert.Contains(t, buttons, BtnAdminUserInfo) + assert.Contains(t, buttons, BtnAdminBack) +} + +func TestAdminKeyboardShowsStatsAndMaintenanceToggle(t *testing.T) { + t.Run("штатный режим", func(t *testing.T) { + keyboard := AdminKeyboard(false) + + var buttons []string + for _, row := range keyboard.ReplyKeyboard { + for _, btn := range row { + buttons = append(buttons, btn.Text) + } + } + + assert.Contains(t, buttons, BtnAdminStats) + assert.Contains(t, buttons, BtnAdminMaintenance) + assert.NotContains(t, buttons, BtnAdminMaintenanceOff) + }) + + t.Run("режим обслуживания", func(t *testing.T) { + keyboard := AdminKeyboard(true) + + var buttons []string + for _, row := range keyboard.ReplyKeyboard { + for _, btn := range row { + buttons = append(buttons, btn.Text) + } + } + + assert.Contains(t, buttons, BtnAdminStats) + assert.Contains(t, buttons, BtnAdminMaintenanceOff) + assert.NotContains(t, buttons, BtnAdminMaintenance) + }) +} + +func TestAdminSwitchSubmenuContainsExpectedButtons(t *testing.T) { + keyboard := AdminSwitchSubmenu() + + var buttons []string + for _, row := range keyboard.ReplyKeyboard { + for _, btn := range row { + buttons = append(buttons, btn.Text) + } + } + + assert.Contains(t, buttons, BtnAdminSwitchInfinite) + assert.Contains(t, buttons, BtnAdminChangePrice) assert.Contains(t, buttons, BtnAdminBack) } diff --git a/internal/remnawave/client.go b/internal/remnawave/client.go index 59102f4..2c56ae6 100644 --- a/internal/remnawave/client.go +++ b/internal/remnawave/client.go @@ -60,6 +60,7 @@ type User struct { Status string `json:"status"` TelegramID *int64 `json:"telegramId"` TrafficLimitBytes int64 `json:"trafficLimitBytes"` + HwidDeviceLimit int `json:"hwidDeviceLimit"` SubscriptionURL string `json:"subscriptionUrl"` CreatedAt time.Time `json:"createdAt"` ExpireAt time.Time `json:"expireAt"` @@ -225,6 +226,25 @@ func (c *Client) GetAllUsers() ([]User, error) { return result.Response.Users, nil } +// GetUserHwidDevicesCount возвращает количество HWID-устройств пользователя. +func (c *Client) GetUserHwidDevicesCount(uuid string) (int, error) { + resp, err := c.doRequest("GET", "/api/hwid/devices/"+uuid, nil) + if err != nil { + return 0, err + } + + var result struct { + Response struct { + Total int `json:"total"` + } `json:"response"` + } + if err := json.Unmarshal(resp, &result); err != nil { + return 0, fmt.Errorf("failed to unmarshal hwid devices response: %w", err) + } + + return result.Response.Total, nil +} + // UpdateUsername обновляет username пользователя в панели Remnawave func (c *Client) UpdateUsername(uuid string, username string) error { req := UpdateUserRequest{ diff --git a/internal/remnawave/client_test.go b/internal/remnawave/client_test.go index 8d09b68..4670552 100644 --- a/internal/remnawave/client_test.go +++ b/internal/remnawave/client_test.go @@ -251,3 +251,24 @@ func TestExtendUserSubscription_RejectTooEarly(t *testing.T) { require.Error(t, err) require.False(t, gotPatch) } + +func TestGetUserHwidDevicesCount(t *testing.T) { + client := NewClient("https://panel.example.com", "test-token", nil) + client.http = &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/api/hwid/devices/uuid-1", r.URL.Path) + + payload := `{"response":{"total":2,"devices":[{"hwid":"a"},{"hwid":"b"}]}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + }), + } + + count, err := client.GetUserHwidDevicesCount("uuid-1") + require.NoError(t, err) + require.Equal(t, 2, count) +} From 3c483502ba639eb99ff34b52c06915fae5994881 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 13:24:51 +0300 Subject: [PATCH 16/34] =?UTF-8?q?feat:=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=88=D0=B8=D1=82=D1=8C=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8E=20=D0=BF=D0=BB=D0=B0=D1=82=D0=B5=D0=B6?= =?UTF-8?q?=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + CLAUDE.md | 48 ++++++++++++----- README.md | 11 +++- docker-compose.yml | 12 ++++- docs/progress/2026-03-23-payment-stage-9.md | 54 +++++++++++++++++++ internal/bot/scheduler.go | 13 +++++ internal/bot/scheduler_test.go | 57 +++++++++++++++++++++ 7 files changed, 180 insertions(+), 17 deletions(-) create mode 100644 docs/progress/2026-03-23-payment-stage-9.md diff --git a/.env.example b/.env.example index 2aa24d6..ea57c8e 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,8 @@ RENDER_API_KEY=your_render_api_key_here PLATEGA_MERCHANT_ID=your_platega_merchant_id PLATEGA_SECRET=your_platega_secret PLATEGA_CALLBACK_URL=https://your-domain.example.com/api/platega/callback +CALLBACK_PORT=8080 +MIN_SUBSCRIPTION_PRICE=400 PLATEGA_FEE_SBP=11 PLATEGA_FEE_CARD=12 PLATEGA_FEE_CRYPTO=5 diff --git a/CLAUDE.md b/CLAUDE.md index b5a57b0..91ad843 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,13 +15,19 @@ Telegram-бот управления VPN на базе [Remnawave](https://remna ### Основные компоненты - **`internal/remnawave/client.go`** — HTTP-клиент Remnawave API -- **`internal/database/users.go`** — таблица users (telegram_id, username, first_name, remnawave_uuid) -- **`internal/database/invites.go`** — таблица invites (система инвайтов с датой активации) +- **`internal/platega/client.go`** — HTTP-клиент Platega +- **`internal/callback/server.go`** — встроенный HTTP-сервер для callback и health-check +- **`internal/database/users.go`** — таблица users (`telegram_id`, `username`, `first_name`, `remnawave_uuid`, `subscription_price`, `moderator_id`) +- **`internal/database/invites.go`** — таблица invites (`code`, `created_by`, `used_by`, `expire_days`, `subscription_price`, `kicked_at`) +- **`internal/database/payments.go`** — таблица payments и логика подтверждения/ретраев платежей +- **`internal/database/moderator_earnings.go`** — таблица `moderator_earnings` и расчёт долей модераторов - **`internal/database/bans.go`** — таблица `banned_users` и проверки перманентных банов - **`internal/database/notifications.go`** — таблица `notifications_sent` (защита от повторных уведомлений) - **`internal/bot/handlers.go`** — обработчики сообщений, команд и синхронизация данных пользователей -- **`internal/bot/admin.go`** — админ-панель (инвайты, просмотр кодов, бан, уведомления) -- **`internal/bot/scheduler.go`** — ежедневный scheduler подписок (12:00 MSK): уведомления и автокик +- **`internal/bot/admin.go`** — админ-панель (инвайты, просмотр кодов, бан, уведомления, статистика, режим обслуживания) +- **`internal/bot/payment_handler.go`** — пользовательский flow оплаты и ручная проверка платежей +- **`internal/bot/payment.go`** — callback-активация, retry и расчёт earnings +- **`internal/bot/scheduler.go`** — scheduler подписок и платежей: каждые 30 минут + первый проход при старте - **`internal/bot/dashboard.go`** — Session Manager и движок live-дашборда мониторинга - **`internal/bot/dashboard_render.go`** — визуализация дашборда (прогресс-бары, флаги, метрики) - **`internal/monitoring/`** — пакет мониторинга (MetricsClient, SyncNodes, LoadIndex, Alerter) @@ -48,6 +54,18 @@ DB_PATH=/app/data/bot.db SD_CONFIGS_PATH=/app/sd_configs VICTORIA_METRICS_URL=http://victoriametrics:8428 +# Платежи Platega (опционально; если не заданы — бот работает как раньше) +PLATEGA_MERCHANT_ID=... +PLATEGA_SECRET=... +PLATEGA_CALLBACK_URL=https://vpn.example.com/platega/callback +CALLBACK_PORT=8080 +MIN_SUBSCRIPTION_PRICE=400 +TRIAL_TRAFFIC_LIMIT_GB=1 +PLATEGA_FEE_SBP=11 +PLATEGA_FEE_CARD=12 +PLATEGA_FEE_CRYPTO=5 +PLATEGA_FEE_WITHDRAWAL=2 + # Render-сервис субтитров (опционально, кнопка скрыта если не задан) RENDER_URL=http://render:8080 RENDER_API_KEY=ключ_render_сервиса @@ -91,16 +109,18 @@ make logs # Показать логи ## Важные заметки 1. **Рассылка** отправляется только активным пользователям (status=ACTIVE в Remnawave) -2. **Типы инвайтов:** админский — бессрочный (`expire_days=NULL`), модераторский — месячный (`expire_days=30`) -3. **Продление подписки:** модератор продлевает только своих подписчиков на +30 дней, с защитой от раннего продления -4. **Плановый scheduler:** ежедневно в 12:00 MSK отправляет уведомления об истечении и выполняет автокик через 3 дня после истечения -5. **Бан и автокик различаются:** бан пишет в `banned_users` (перманентно), автокик бан не ставит (пользователь может вернуться по новому инвайту) -6. **Сброс трафика** — счётчик `usedTrafficBytes` автоматически сбрасывается Remnawave 1-го числа при стратегии `MONTH` -7. **Сквады** опциональны — если пользователи не видят серверы, создайте internal squads в панели и добавьте UUID в `REMNAWAVE_DEFAULT_SQUAD_UUIDS` -8. **Трафик** — без лимита (`trafficLimitBytes=0`) -9. **Актуализация данных** — при каждом /start бот обновляет username и first_name в БД и синхронизирует username с Remnawave -10. **Удаление кодов** — можно удалять только неиспользованные коды (защита истории активаций) -11. **Субтитры** — опционально, требует запущенный render-сервис. Голосовое → видео с субтитрами, кружок → кружок с субтитрами +2. **Типы инвайтов:** админский — бессрочный (`expire_days=NULL`), модераторский — триал на 72 часа с ценой подписки из инвайта +3. **Платежи Platega опциональны:** без `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET` бот работает как раньше, callback-сервер не стартует +4. **Legacy-пользователи:** при `subscription_price = NULL` кнопка оплаты скрыта; scheduler пропускает старые записи без инвайта и цены +5. **Плановый scheduler:** стартует сразу при запуске и далее работает каждые 30 минут; обрабатывает pending/confirmed_not_activated, уведомления, disable и grace kick +6. **Maintenance mode:** скрывает оплату и блокирует disable/автокики, но остальная функциональность бота продолжает работать +7. **Бан и автокик различаются:** бан пишет в `banned_users` (перманентно), автокик бан не ставит (пользователь может вернуться по новому инвайту) +8. **Сброс трафика** — счётчик `usedTrafficBytes` автоматически сбрасывается Remnawave 1-го числа при стратегии `MONTH` +9. **Сквады** опциональны — если пользователи не видят серверы, создайте internal squads в панели и добавьте UUID в `REMNAWAVE_DEFAULT_SQUAD_UUIDS` +10. **Трафик** — без лимита для оплаченных и админских пользователей (`trafficLimitBytes=0`); для триала лимит задаётся через `TRIAL_TRAFFIC_LIMIT_GB` +11. **Актуализация данных** — при каждом /start бот обновляет username и first_name в БД и синхронизирует username с Remnawave +12. **Удаление кодов** — можно удалять только неиспользованные коды (защита истории активаций) +13. **Субтитры** — опционально, требует запущенный render-сервис. Голосовое → видео с субтитрами, кружок → кружок с субтитрами ## Мониторинг нод diff --git a/README.md b/README.md index d24b312..022ec6d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ make logs | `DB_PATH` | — | Путь к SQLite-базе (дефолт: `/app/data/bot.db`) | | `REMNAWAVE_DEFAULT_SQUAD_UUIDS` | — | Список UUID internal squads через запятую; новые пользователи добавляются во все перечисленные сквады | | `VICTORIA_METRICS_URL` | — | URL VictoriaMetrics (дефолт: `http://victoriametrics:8428`) | +| `CALLBACK_PORT` | — | Порт встроенного callback-сервера Platega (дефолт: `8080`) | +| `MIN_SUBSCRIPTION_PRICE` | — | Минимальная цена подписки для модераторов (дефолт: `400`) | | `TRIAL_TRAFFIC_LIMIT_GB` | — | Лимит трафика для триала в ГБ (дефолт: `1`) | | `PLATEGA_MERCHANT_ID` | — | Merchant ID Platega для пользовательской оплаты | | `PLATEGA_SECRET` | — | Секретный ключ Platega | @@ -50,6 +52,8 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 Для обратной совместимости бот также понимает legacy-переменную `REMNAWAVE_DEFAULT_SQUAD_UUID`, если новый список не задан. +Если `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET` не заданы, бот запускается как раньше: callback-сервер не поднимается, кнопки оплаты не показываются. + ## Функциональность ### Пользователь @@ -59,7 +63,7 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 | `/start` | Регистрация по инвайт-коду или вход для зарегистрированных | | `/start ` | Автоматическая активация кода из ссылки-приглашения | | `👤 Мой статус` | Статус подписки по типу (триал / оплаченная / grace / бессрочная), трафик и ссылка | -| `💳 Оплатить подписку` / `💳 Продлить подписку` | Запуск flow оплаты: выбор способа, ссылка, ручная проверка | +| `💳 Оплатить подписку` / `💳 Продлить подписку` | Запуск flow оплаты: выбор способа, ссылка, ручная проверка; кнопка скрыта без Platega или при `subscription_price = NULL` | | `📡 Серверы` | Live-дашборд мониторинга нод (обновляется каждые 5 сек) | | `📚 Инструкции` | Инструкции по настройке клиентов: iOS, Android, ПК | | `ℹ️ Информация` | Помощь, контакт для вопросов и ссылки на документы сервиса | @@ -118,6 +122,7 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 4. в точный `expireAt` оплаченная подписка переводится в `DISABLED`, затем даётся 72 часа grace period; 5. после grace period пользователь удаляется, если свежая оплата не подтверждена; 6. в `maintenance mode` disable и автокики блокируются. +7. legacy-пользователи без инвайта и без `subscription_price` пропускаются новым payment-scheduler и продолжают жить по старой модели. **Flow оплаты**: 1. пользователь выбирает `💳 Оплатить подписку` или `💳 Продлить подписку`; @@ -181,7 +186,9 @@ vpn_bot/ │ └── migrator/ → Утилита миграции пользователей из старой БД ├── internal/ │ ├── bot/ → Обработчики, клавиатуры, дашборд, состояния, scheduler -│ ├── database/ → SQLite (users, invites, moderators, bans, notifications) +│ ├── callback/ → HTTP callback-сервер Platega +│ ├── database/ → SQLite (users, invites, payments, moderator_earnings, moderators, bans, notifications) +│ ├── platega/ → HTTP-клиент Platega │ ├── remnawave/ → HTTP-клиент Remnawave API │ ├── monitoring/ → Метрики, алерты, service discovery │ └── config/ → Загрузка конфигурации diff --git a/docker-compose.yml b/docker-compose.yml index 311de26..4153b28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,10 +16,20 @@ services: - DONATE_TEXT=${DONATE_TEXT} - SD_CONFIGS_PATH=${SD_CONFIGS_PATH:-/app/sd_configs} - VICTORIA_METRICS_URL=${VICTORIA_METRICS_URL:-http://victoriametrics:8428} + - PLATEGA_MERCHANT_ID=${PLATEGA_MERCHANT_ID} + - PLATEGA_SECRET=${PLATEGA_SECRET} + - PLATEGA_CALLBACK_URL=${PLATEGA_CALLBACK_URL} + - CALLBACK_PORT=${CALLBACK_PORT:-8080} + - MIN_SUBSCRIPTION_PRICE=${MIN_SUBSCRIPTION_PRICE:-400} + - TRIAL_TRAFFIC_LIMIT_GB=${TRIAL_TRAFFIC_LIMIT_GB:-1} + - PLATEGA_FEE_SBP=${PLATEGA_FEE_SBP:-11} + - PLATEGA_FEE_CARD=${PLATEGA_FEE_CARD:-12} + - PLATEGA_FEE_CRYPTO=${PLATEGA_FEE_CRYPTO:-5} + - PLATEGA_FEE_WITHDRAWAL=${PLATEGA_FEE_WITHDRAWAL:-2} env_file: - .env ports: - - "127.0.0.1:8080:8080" + - "127.0.0.1:${CALLBACK_PORT:-8080}:${CALLBACK_PORT:-8080}" networks: - vpn-network depends_on: diff --git a/docs/progress/2026-03-23-payment-stage-9.md b/docs/progress/2026-03-23-payment-stage-9.md new file mode 100644 index 0000000..9ea6881 --- /dev/null +++ b/docs/progress/2026-03-23-payment-stage-9.md @@ -0,0 +1,54 @@ +# Этап 9: Финализация + +**Дата:** 2026-03-23 +**План:** [2026-03-22-payment-implementation-plan.md](../plans/2026-03-22-payment-implementation-plan.md), строки 2006–2059 +**Предлагаемый коммит:** `feat: завершить интеграцию платежей` + +## Что сделано + +### `docker-compose.yml` +- Добавлены все переменные платёжной интеграции в `environment`: + - `PLATEGA_MERCHANT_ID` + - `PLATEGA_SECRET` + - `PLATEGA_CALLBACK_URL` + - `CALLBACK_PORT` + - `MIN_SUBSCRIPTION_PRICE` + - `TRIAL_TRAFFIC_LIMIT_GB` + - `PLATEGA_FEE_SBP` + - `PLATEGA_FEE_CARD` + - `PLATEGA_FEE_CRYPTO` + - `PLATEGA_FEE_WITHDRAWAL` +- Проброс callback-порта переведён на env-переменную: + - `127.0.0.1:${CALLBACK_PORT:-8080}:${CALLBACK_PORT:-8080}` + +### Backward compatibility +- Добавлен регрессионный тест `TestSchedulerSkipsLegacyUserWithoutInvite` + - RED: scheduler ошибочно пытался `disable` legacy-пользователя без инвайта + - GREEN: legacy-пользователь без инвайта и без `subscription_price` пропускается +- В `runSubscriptionSchedulerPass()` добавлен guard: + - если у пользователя нет инвайта и `subscription_price = NULL`, payment-scheduler его не обрабатывает +- Подтверждён существующий fallback без Platega через `TestLoadPlategaConfig` + - пустые `PLATEGA_*` не ломают запуск и используют дефолты +- Кейс с `NULL subscription_price` не менялся: + - в `internal/bot/handlers.go` кнопка оплаты по-прежнему скрывается до назначения цены + +### Документация +- `README.md` + - добавлены `CALLBACK_PORT` и `MIN_SUBSCRIPTION_PRICE` + - задокументирована обратная совместимость без `PLATEGA_*` + - уточнено скрытие кнопки оплаты при `subscription_price = NULL` + - обновлена структура модулей (`callback`, `platega`, платежные таблицы) +- `CLAUDE.md` + - обновлено описание архитектуры платёжных компонентов + - обновлён блок env-переменных + - исправлено описание scheduler и legacy-совместимости +- `.env.example` + - добавлены `CALLBACK_PORT` и `MIN_SUBSCRIPTION_PRICE` + +## Проверка + +- `GOCACHE=/tmp/go-build go test ./internal/bot -run TestSchedulerSkipsLegacyUserWithoutInvite -count=1` +- `GOCACHE=/tmp/go-build go test ./internal/config -run TestLoadPlategaConfig -count=1` +- Далее обязательная полная проверка: + - `make fmt` + - `make tests` diff --git a/internal/bot/scheduler.go b/internal/bot/scheduler.go index f4ddb8f..5f9faad 100644 --- a/internal/bot/scheduler.go +++ b/internal/bot/scheduler.go @@ -89,6 +89,19 @@ func (b *Bot) runSubscriptionSchedulerPass() { continue } + invite, err := b.db.GetInviteByUsedBy(telegramID) + if err != nil { + slog.Warn("Scheduler: не удалось получить инвайт пользователя", "error", err, "telegram_id", telegramID) + continue + } + + // Legacy-пользователи без инвайта и без цены остаются на старой модели: + // scheduler оплаты их не трогает, чтобы не ломать обратную совместимость. + if invite == nil && dbUser.SubscriptionPrice == nil { + slog.Info("Scheduler: пропускаем legacy-пользователя без инвайта и цены", "telegram_id", telegramID) + continue + } + // Бесконечная подписка — пропуск if user.ExpireAt.Year() >= 2099 { continue diff --git a/internal/bot/scheduler_test.go b/internal/bot/scheduler_test.go index 61c39ab..3f32856 100644 --- a/internal/bot/scheduler_test.go +++ b/internal/bot/scheduler_test.go @@ -263,6 +263,63 @@ func TestSchedulerTrialNotKickedIfPaid(t *testing.T) { assert.NotNil(t, dbUser, "оплативший пользователь не должен быть кикнут") } +func TestSchedulerSkipsLegacyUserWithoutInvite(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + userID := int64(260) + _, err := db.CreateUser(userID, "legacy_user", "Legacy", "uuid-260", nil, nil) + require.NoError(t, err) + + invite, err := db.GetInviteByUsedBy(userID) + require.NoError(t, err) + require.Nil(t, invite, "legacy-пользователь не должен иметь связанного инвайта") + + var disableCalled bool + var deleteCalled bool + expireAt := time.Now().UTC().Add(-2 * time.Hour) + + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users": + payload := fmt.Sprintf(`{"response":{"users":[{"uuid":"uuid-260","username":"legacy_user","status":"ACTIVE","telegramId":260,"expireAt":"%s"}],"total":1}}`, + expireAt.Format(time.RFC3339)) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodPatch && r.URL.Path == "/api/users": + disableCalled = true + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodDelete && r.URL.Path == "/api/users/uuid-260": + deleteCalled = true + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + default: + return nil, fmt.Errorf("unexpected: %s %s", r.Method, r.URL.Path) + } + }), + }) + b.remnawave = client + + b.runSubscriptionSchedulerPass() + + dbUser, err := db.GetUserByTelegramID(userID) + require.NoError(t, err) + assert.NotNil(t, dbUser, "legacy-пользователь без инвайта не должен обрабатываться scheduler") + assert.False(t, disableCalled, "scheduler не должен disable-ить legacy-пользователя без инвайта") + assert.False(t, deleteCalled, "scheduler не должен кикать legacy-пользователя без инвайта") +} + // TestSchedulerPaidDisableAndGraceKick проверяет disable при expireAt и кик через 72ч func TestSchedulerPaidDisableAndGraceKick(t *testing.T) { b, db := setupSchedulerTestBot(t) From 0126c171e872b40a9f97f9be4468b9af3e31892a Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 13:36:31 +0300 Subject: [PATCH 17/34] =?UTF-8?q?chore:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D0=B5=D0=BB=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F=20happ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bot/messages.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/bot/messages.go b/internal/bot/messages.go index d2f8ebc..cc0ed79 100644 --- a/internal/bot/messages.go +++ b/internal/bot/messages.go @@ -64,14 +64,14 @@ const ( MsgInstructionIOS = `Настройка на iOS (iPhone/iPad) -1. Скачайте приложение v2raytun из App Store: - https://apps.apple.com/app/v2raytun/id6476628951 +1. Скачайте приложение Happ из App Store: + https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973 2. Откройте приложение 3. Нажмите "+" в правом верхнем углу -4. Выберите "Добавить из буфера обмена" +4. Выберите "Вставить из буфера обмена" 5. Выберите сервер и включите VPN переключателем @@ -80,10 +80,10 @@ const ( MsgInstructionAndroid = `Настройка на Android -1. Скачайте приложение v2raytun из Play Market: - https://play.google.com/store/apps/details?id=com.v2raytun.android +1. Скачайте приложение Happ из Play Market: + https://play.google.com/store/apps/details?id=com.happproxy - Или APK: https://github.com/DigneZzZ/v2raytun/releases/ + Или APK: https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk 2. Откройте приложение From a17a82218e8d5090c05aa5e0b93eb634555a421a Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 14:04:05 +0300 Subject: [PATCH 18/34] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20payment=20flow=20Platega=20=D0=B8=20?= =?UTF-8?q?=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8E=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- CLAUDE.md | 23 +-- README.md | 17 +- internal/bot/handlers.go | 10 +- internal/bot/handlers_test.go | 64 +++++++ internal/bot/messages.go | 29 ++- internal/bot/messages_test.go | 4 +- internal/bot/payment.go | 96 +++++++--- internal/bot/payment_handler.go | 11 ++ internal/bot/payment_handler_test.go | 274 +++++++++++++++++++++++++++ internal/bot/payment_test.go | 61 ++++++ internal/bot/scheduler_test.go | 5 + internal/callback/server.go | 2 +- internal/callback/server_test.go | 5 +- internal/database/payments.go | 5 +- internal/database/payments_test.go | 35 ++++ internal/platega/client.go | 141 +++++++++++--- internal/platega/client_test.go | 156 ++++++++------- 18 files changed, 784 insertions(+), 156 deletions(-) create mode 100644 internal/bot/payment_handler_test.go diff --git a/.env.example b/.env.example index ea57c8e..0593e67 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,7 @@ RENDER_API_KEY=your_render_api_key_here # Платежи Platega (опционально) PLATEGA_MERCHANT_ID=your_platega_merchant_id PLATEGA_SECRET=your_platega_secret -PLATEGA_CALLBACK_URL=https://your-domain.example.com/api/platega/callback +PLATEGA_CALLBACK_URL=https://your-domain.example.com/platega/callback CALLBACK_PORT=8080 MIN_SUBSCRIPTION_PRICE=400 PLATEGA_FEE_SBP=11 diff --git a/CLAUDE.md b/CLAUDE.md index 91ad843..e3cce8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Telegram-бот управления VPN на базе [Remnawave](https://remna - **`internal/database/users.go`** — таблица users (`telegram_id`, `username`, `first_name`, `remnawave_uuid`, `subscription_price`, `moderator_id`) - **`internal/database/invites.go`** — таблица invites (`code`, `created_by`, `used_by`, `expire_days`, `subscription_price`, `kicked_at`) - **`internal/database/payments.go`** — таблица payments и логика подтверждения/ретраев платежей -- **`internal/database/moderator_earnings.go`** — таблица `moderator_earnings` и расчёт долей модераторов +- **`internal/database/earnings.go`** — таблица `moderator_earnings` и расчёт долей модераторов - **`internal/database/bans.go`** — таблица `banned_users` и проверки перманентных банов - **`internal/database/notifications.go`** — таблица `notifications_sent` (защита от повторных уведомлений) - **`internal/bot/handlers.go`** — обработчики сообщений, команд и синхронизация данных пользователей @@ -110,17 +110,18 @@ make logs # Показать логи 1. **Рассылка** отправляется только активным пользователям (status=ACTIVE в Remnawave) 2. **Типы инвайтов:** админский — бессрочный (`expire_days=NULL`), модераторский — триал на 72 часа с ценой подписки из инвайта -3. **Платежи Platega опциональны:** без `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET` бот работает как раньше, callback-сервер не стартует +3. **Платежи Platega опциональны:** без `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET` бот работает как раньше, callback-сервер не стартует, кнопки оплаты не показываются 4. **Legacy-пользователи:** при `subscription_price = NULL` кнопка оплаты скрыта; scheduler пропускает старые записи без инвайта и цены -5. **Плановый scheduler:** стартует сразу при запуске и далее работает каждые 30 минут; обрабатывает pending/confirmed_not_activated, уведомления, disable и grace kick -6. **Maintenance mode:** скрывает оплату и блокирует disable/автокики, но остальная функциональность бота продолжает работать -7. **Бан и автокик различаются:** бан пишет в `banned_users` (перманентно), автокик бан не ставит (пользователь может вернуться по новому инвайту) -8. **Сброс трафика** — счётчик `usedTrafficBytes` автоматически сбрасывается Remnawave 1-го числа при стратегии `MONTH` -9. **Сквады** опциональны — если пользователи не видят серверы, создайте internal squads в панели и добавьте UUID в `REMNAWAVE_DEFAULT_SQUAD_UUIDS` -10. **Трафик** — без лимита для оплаченных и админских пользователей (`trafficLimitBytes=0`); для триала лимит задаётся через `TRIAL_TRAFFIC_LIMIT_GB` -11. **Актуализация данных** — при каждом /start бот обновляет username и first_name в БД и синхронизирует username с Remnawave -12. **Удаление кодов** — можно удалять только неиспользованные коды (защита истории активаций) -13. **Субтитры** — опционально, требует запущенный render-сервис. Голосовое → видео с субтитрами, кружок → кружок с субтитрами +5. **Платёжный flow:** callback обрабатывается быстро, долгие retry не держат HTTP-запрос открытым; при сбое активации платёж переходит в `confirmed_not_activated`, а scheduler повторяет активацию без перезаписи исходного `confirmed_at` +6. **Плановый scheduler:** стартует сразу при запуске и далее работает каждые 30 минут; обрабатывает pending/confirmed_not_activated, уведомления, disable и grace kick +7. **Maintenance mode:** скрывает оплату и блокирует disable/автокики, но остальная функциональность бота продолжает работать +8. **Бан и автокик различаются:** бан пишет в `banned_users` (перманентно), автокик бан не ставит (пользователь может вернуться по новому инвайту) +9. **Сброс трафика** — счётчик `usedTrafficBytes` автоматически сбрасывается Remnawave 1-го числа при стратегии `MONTH` +10. **Сквады** опциональны — если пользователи не видят серверы, создайте internal squads в панели и добавьте UUID в `REMNAWAVE_DEFAULT_SQUAD_UUIDS` +11. **Трафик** — без лимита для оплаченных и админских пользователей (`trafficLimitBytes=0`); для триала лимит задаётся через `TRIAL_TRAFFIC_LIMIT_GB` +12. **Актуализация данных** — при каждом /start бот обновляет username и first_name в БД и синхронизирует username с Remnawave +13. **Удаление кодов** — можно удалять только неиспользованные коды (защита истории активаций) +14. **Субтитры** — опционально, требует запущенный render-сервис. Голосовое → видео с субтитрами, кружок → кружок с субтитрами ## Мониторинг нод diff --git a/README.md b/README.md index 022ec6d..265930f 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ make logs | `TRIAL_TRAFFIC_LIMIT_GB` | — | Лимит трафика для триала в ГБ (дефолт: `1`) | | `PLATEGA_MERCHANT_ID` | — | Merchant ID Platega для пользовательской оплаты | | `PLATEGA_SECRET` | — | Секретный ключ Platega | -| `PLATEGA_CALLBACK_URL` | — | Callback URL для подтверждения платежей | +| `PLATEGA_CALLBACK_URL` | — | Публичный HTTPS callback URL вида `https://domain/platega/callback` | | `PLATEGA_FEE_SBP` | — | Комиссия Platega для СБП в процентах | | `PLATEGA_FEE_CARD` | — | Комиссия Platega для оплаты картой | | `PLATEGA_FEE_CRYPTO` | — | Комиссия Platega для крипты | @@ -62,7 +62,7 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 | --------------- | -------------------------------------------------------------------- | | `/start` | Регистрация по инвайт-коду или вход для зарегистрированных | | `/start ` | Автоматическая активация кода из ссылки-приглашения | -| `👤 Мой статус` | Статус подписки по типу (триал / оплаченная / grace / бессрочная), трафик и ссылка | +| `👤 Мой статус` | Статус подписки по типу (триал / оплаченная / grace / бессрочная), трафик, устройства и ссылка | | `💳 Оплатить подписку` / `💳 Продлить подписку` | Запуск flow оплаты: выбор способа, ссылка, ручная проверка; кнопка скрыта без Platega или при `subscription_price = NULL` | | `📡 Серверы` | Live-дашборд мониторинга нод (обновляется каждые 5 сек) | | `📚 Инструкции` | Инструкции по настройке клиентов: iOS, Android, ПК | @@ -76,8 +76,9 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 | ------------------------ | -------------------------------------------------- | | `📨 Создать приглашение` | Генерирует инвайт-код на 30 дней и ссылку для отправки | | `📋 Мои приглашения` | Список своих кодов (статус, кто активировал, дата, Telegram ID) | -| `👥 Мои подписчики` | Статусы подписчиков: активен / истёк / удалён | -| `⏳ Продлить подписку` | Продление подписчика на 30 дней (только своих) | +| `👥 Мои подписчики` | Карточки подписчиков: статус, цена, подписка до, трафик, устройства | +| `💰 Мой заработок` | Доход модератора за месяц и накопительный итог | +| `✏️ Изменить цену` | Смена цены подписки для своего подписчика | | `🗑 Удалить приглашение` | Удаление своего неиспользованного кода | ### Администратор @@ -110,9 +111,9 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 - админский инвайт: бессрочно (`2099-01-01`), без ограничения трафика. **Срок подписки и продление**: -- модератор может продлевать только своих подписчиков; -- продление добавляет 30 дней к текущему сроку (или от текущей даты, если уже истекло); +- продление через Platega добавляет 30 дней к текущему сроку (или от текущей даты, если уже истекло); - раннее продление через Platega запрещено, если до истечения больше 90 дней; +- при сбое активации платёж остаётся подтверждённым, получает статус `confirmed_not_activated` и повторно обрабатывается scheduler без перезаписи исходного `confirmed_at`; - администратор может перевести месячную подписку в бессрочную; при этом `invites.created_by` сохраняется, а `expire_days` становится `NULL`. **Scheduler подписок**: @@ -128,7 +129,9 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 1. пользователь выбирает `💳 Оплатить подписку` или `💳 Продлить подписку`; 2. бот показывает методы оплаты: `🏦 СБП`, `💳 Карта`, `🪙 Крипта`; 3. после создания платежа бот отправляет ссылку и кнопку `🔄 Проверить оплату`; -4. при успешной оплате подписка активируется автоматически, лимит трафика снимается. +4. callback и ручная проверка синхронизируют локальный статус платежа (`confirmed`, `canceled`, `chargebacked`); +5. при успешной оплате подписка активируется автоматически, лимит трафика снимается; +6. если подтверждение получено, но Remnawave временно недоступен, пользователю не показывается ложное сообщение об активации: платёж остаётся в `confirmed_not_activated`, а scheduler повторяет активацию позже. **Бан пользователя** — перманентная операция: diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index c936818..99d22b2 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -619,7 +619,15 @@ func (b *Bot) handleStatus(c tele.Context) error { return c.Send("Ошибка получения статуса. Попробуйте позже.") } - msg := FormatUserStatus(remnawaveUser, user, b.isTrialUser(telegramID)) + var devicesCount *int + count, err := b.remnawave.GetUserHwidDevicesCount(user.RemnawaveUUID) + if err != nil { + slog.Warn("Failed to get user HWID devices for status", "error", err, "telegram_id", telegramID) + } else { + devicesCount = &count + } + + msg := FormatUserStatus(remnawaveUser, user, b.isTrialUser(telegramID), devicesCount) return c.Send(msg, &tele.SendOptions{ ParseMode: tele.ModeHTML, ReplyMarkup: b.userKeyboard(telegramID), diff --git a/internal/bot/handlers_test.go b/internal/bot/handlers_test.go index 320bfaa..be4765a 100644 --- a/internal/bot/handlers_test.go +++ b/internal/bot/handlers_test.go @@ -247,6 +247,70 @@ func TestUserKeyboardHidesPaymentButtonInMaintenanceMode(t *testing.T) { assert.NotContains(t, maintenanceButtons, BtnRenew) } +func TestUserKeyboardHidesPaymentButtonWithoutPlatega(t *testing.T) { + b, db := setupTestBot(t) + + userID := int64(778) + price := 500 + _, err := db.CreateUser(userID, "paid", "Paid", "uuid-paid", &price, nil) + require.NoError(t, err) + + kb := b.userKeyboard(userID) + var buttons []string + for _, row := range kb.ReplyKeyboard { + for _, btn := range row { + buttons = append(buttons, btn.Text) + } + } + + assert.NotContains(t, buttons, BtnPay) + assert.NotContains(t, buttons, BtnRenew) +} + +func TestHandleStatusShowsDevices(t *testing.T) { + b, db := setupTestBot(t) + + userID := int64(779) + price := 500 + _, err := db.CreateUser(userID, "paid", "Paid", "uuid-devices", &price, nil) + require.NoError(t, err) + + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-devices": + payload := `{"response":{"uuid":"uuid-devices","username":"paid","status":"ACTIVE","expireAt":"2026-04-15T00:00:00Z","subscriptionUrl":"vless://example","hwidDeviceLimit":3,"userTraffic":{"usedTrafficBytes":2147483648}}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodGet && r.URL.Path == "/api/hwid/devices/uuid-devices": + payload := `{"response":{"total":2,"devices":[{"hwid":"a"},{"hwid":"b"}]}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + default: + return nil, assert.AnError + } + }), + }) + + ctx := &MockContext{ + sender: &tele.User{ID: userID}, + message: &tele.Message{}, + } + + err = b.handleStatus(ctx) + require.NoError(t, err) + + msg, ok := ctx.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, msg, "Устройства: 2 / 3") +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { diff --git a/internal/bot/messages.go b/internal/bot/messages.go index cc0ed79..459edac 100644 --- a/internal/bot/messages.go +++ b/internal/bot/messages.go @@ -180,26 +180,26 @@ func determineSubscriptionType(remUser *remnawave.User, isTrial bool) subscripti } // FormatUserStatus форматирует статус пользователя с учётом типа подписки -func FormatUserStatus(remUser *remnawave.User, dbUser *database.User, isTrial bool) string { +func FormatUserStatus(remUser *remnawave.User, dbUser *database.User, isTrial bool, devicesCount *int) string { subType := determineSubscriptionType(remUser, isTrial) var msg string switch subType { case subTypeInfinite: - msg = formatInfiniteStatus(remUser) + msg = formatInfiniteStatus(remUser, devicesCount) case subTypeGrace: msg = formatGraceStatus(remUser, dbUser) case subTypeTrial: - msg = formatTrialStatus(remUser, dbUser) + msg = formatTrialStatus(remUser, dbUser, devicesCount) case subTypePaid: - msg = formatPaidStatus(remUser, dbUser) + msg = formatPaidStatus(remUser, dbUser, devicesCount) } return msg } -func formatInfiniteStatus(remUser *remnawave.User) string { +func formatInfiniteStatus(remUser *remnawave.User, devicesCount *int) string { msg := "👤 Ваш статус\n\n" msg += "Тип: ♾️ Безлимитная подписка\n" msg += "Статус: ✅ Активен\n" @@ -208,6 +208,7 @@ func formatInfiniteStatus(remUser *remnawave.User) string { usedGB := float64(remUser.UserTraffic.UsedTrafficBytes) / (1024 * 1024 * 1024) msg += fmt.Sprintf("\nТрафик за месяц: %.2f GB\n", usedGB) } + msg += formatDevicesLine(remUser, devicesCount) msg += fmt.Sprintf("\nСсылка подписки:\n%s", remUser.SubscriptionURL) return msg @@ -242,7 +243,7 @@ func formatGraceStatus(remUser *remnawave.User, dbUser *database.User) string { return msg } -func formatTrialStatus(remUser *remnawave.User, dbUser *database.User) string { +func formatTrialStatus(remUser *remnawave.User, dbUser *database.User, devicesCount *int) string { msg := "👤 Ваш статус\n\n" msg += "Тип: ⏳ Пробный период\n" @@ -270,6 +271,7 @@ func formatTrialStatus(remUser *remnawave.User, dbUser *database.User) string { msg += fmt.Sprintf("Трафик за месяц: %.2f GB\n", usedGB) } } + msg += formatDevicesLine(remUser, devicesCount) if dbUser != nil && dbUser.SubscriptionPrice != nil { msg += fmt.Sprintf("\nЦена подписки: %d руб/мес\n", *dbUser.SubscriptionPrice) @@ -291,7 +293,7 @@ func formatTrialStatus(remUser *remnawave.User, dbUser *database.User) string { return msg } -func formatPaidStatus(remUser *remnawave.User, dbUser *database.User) string { +func formatPaidStatus(remUser *remnawave.User, dbUser *database.User, devicesCount *int) string { msg := "👤 Ваш статус\n\n" msg += "Тип: 💳 Подписка\n" @@ -313,6 +315,7 @@ func formatPaidStatus(remUser *remnawave.User, dbUser *database.User) string { usedGB := float64(remUser.UserTraffic.UsedTrafficBytes) / (1024 * 1024 * 1024) msg += fmt.Sprintf("Трафик за месяц: %.2f GB\n", usedGB) } + msg += formatDevicesLine(remUser, devicesCount) if dbUser != nil && dbUser.SubscriptionPrice != nil { msg += fmt.Sprintf("\nЦена продления: %d руб/мес\n", *dbUser.SubscriptionPrice) @@ -326,6 +329,18 @@ func formatPaidStatus(remUser *remnawave.User, dbUser *database.User) string { return msg } +func formatDevicesLine(remUser *remnawave.User, devicesCount *int) string { + if devicesCount == nil { + return "" + } + + if remUser.HwidDeviceLimit > 0 { + return fmt.Sprintf("Устройства: %d / %d\n", *devicesCount, remUser.HwidDeviceLimit) + } + + return fmt.Sprintf("Устройства: %d\n", *devicesCount) +} + // formatStatusLine возвращает эмоджи и текст для статуса func formatStatusLine(status string) (string, string) { switch status { diff --git a/internal/bot/messages_test.go b/internal/bot/messages_test.go index 31c3d45..7fb411a 100644 --- a/internal/bot/messages_test.go +++ b/internal/bot/messages_test.go @@ -20,7 +20,7 @@ func TestFormatUserStatusShowsUsedTrafficPerMonthWithoutLimit(t *testing.T) { }, } - msg := FormatUserStatus(user, nil, false) + msg := FormatUserStatus(user, nil, false, nil) assert.Contains(t, msg, "Трафик за месяц: 5.00 GB") assert.NotContains(t, msg, "Трафик:") @@ -39,7 +39,7 @@ func TestFormatUserStatusGraceShowsPaymentWindow(t *testing.T) { ExpireAt: time.Now().UTC().Add(-12 * time.Hour), }, &database.User{ SubscriptionPrice: &price, - }, false) + }, false, nil) assert.Contains(t, msg, "⚠️ Подписка истекла") assert.Contains(t, msg, "VPN деактивирован") diff --git a/internal/bot/payment.go b/internal/bot/payment.go index 59c83d1..ea44f92 100644 --- a/internal/bot/payment.go +++ b/internal/bot/payment.go @@ -50,7 +50,7 @@ func (h *paymentCallbackHandler) HandlePaymentCallback(payload platega.CallbackP defer mu.Unlock() switch payload.Status { - case platega.StatusConfirmed: + case platega.StatusConfirmed, platega.StatusManualConfirmed: return h.handleConfirmed(payment) case platega.StatusCanceled: return h.handleCanceled(payment) @@ -70,39 +70,37 @@ func (h *paymentCallbackHandler) handleConfirmed(payment *database.Payment) erro return nil } + alreadyMarkedForRetry := payment.Status == "confirmed_not_activated" + // Подтверждаем платёж в БД if err := h.bot.db.ConfirmPayment(payment.ID); err != nil { return fmt.Errorf("confirm payment: %w", err) } - // Активируем подписку в Remnawave с retry и backoff (3 попытки: 30с, 1м, 5м) - retryDelays := []time.Duration{30 * time.Second, 1 * time.Minute, 5 * time.Minute} - var activateErr error - for attempt, delay := range retryDelays { - activateErr = h.activateSubscription(payment) - if activateErr == nil { - break + // Пытаемся активировать подписку один раз. + // Долгие retry выполняет scheduler, чтобы не держать callback/manual-check path открытым. + if err := h.activateSubscription(payment); err != nil { + slog.Error("Не удалось активировать подписку после подтверждения, помечаем для scheduler", + "error", err, "payment_id", payment.ID) + if updateErr := h.bot.db.UpdatePaymentStatus(payment.ID, "confirmed_not_activated"); updateErr != nil { + return fmt.Errorf("update status to confirmed_not_activated: %w", updateErr) } - slog.Warn("Не удалось активировать подписку, повторяем", - "error", activateErr, "payment_id", payment.ID, - "attempt", attempt+1, "next_retry_in", delay) - time.Sleep(delay) - } - - if activateErr != nil { - // Все попытки провалились — помечаем для retry через scheduler - slog.Error("Все попытки активации провалились, помечаем для scheduler", - "error", activateErr, "payment_id", payment.ID) - h.bot.db.UpdatePaymentStatus(payment.ID, "confirmed_not_activated") // Уведомляем админа - h.bot.sendAdminAlert(fmt.Sprintf( - "⚠️ Платёж #%d подтверждён, но не удалось активировать подписку для %d после 3 попыток. Требуется ручная проверка.", - payment.ID, payment.TelegramID, - )) + if !alreadyMarkedForRetry { + h.bot.sendAdminAlert(fmt.Sprintf( + "⚠️ Платёж #%d подтверждён, но не удалось активировать подписку для %d. Платёж помечен как confirmed_not_activated и будет повторно обработан scheduler.", + payment.ID, payment.TelegramID, + )) + } return nil // Не возвращаем ошибку — платёж уже сохранён } + h.finalizeActivatedPayment(payment) + return nil +} + +func (h *paymentCallbackHandler) finalizeActivatedPayment(payment *database.Payment) { // Создаём запись в moderator_earnings (если есть модератор) h.createEarningRecord(payment) @@ -121,8 +119,6 @@ func (h *paymentCallbackHandler) handleConfirmed(payment *database.Payment) erro // Очищаем уведомления (пользователь мог быть в grace period) h.bot.db.ClearNotifications(payment.TelegramID) - - return nil } // activateSubscription продлевает подписку в Remnawave @@ -327,7 +323,7 @@ func (b *Bot) createPaymentForUser(telegramID int64, paymentMethodInt int) (*dat // Вычисляем время жизни var expiresAt *time.Time if resp.ExpiresIn > 0 { - t := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second) + t := time.Now().Add(resp.ExpiresIn) expiresAt = &t } @@ -383,8 +379,52 @@ func (b *Bot) checkPaymentStatus(telegramID int64) (string, error) { if status.Status == platega.StatusConfirmed { // Платёж подтверждён — обрабатываем как callback (мьютекс уже взят) handler := &paymentCallbackHandler{bot: b} - handler.handleConfirmed(pending) - return "confirmed", nil + if err := handler.handleConfirmed(pending); err != nil { + return "", err + } + + updated, err := b.db.GetPaymentByID(pending.ID) + if err != nil { + return "", fmt.Errorf("reload payment after confirm: %w", err) + } + if updated == nil { + return "confirmed", nil + } + + return updated.Status, nil + } + + if status.Status == platega.StatusManualConfirmed { + handler := &paymentCallbackHandler{bot: b} + if err := handler.handleConfirmed(pending); err != nil { + return "", err + } + + updated, err := b.db.GetPaymentByID(pending.ID) + if err != nil { + return "", fmt.Errorf("reload payment after manual confirm: %w", err) + } + if updated == nil { + return "confirmed", nil + } + + return updated.Status, nil + } + + if status.Status == platega.StatusCanceled { + handler := &paymentCallbackHandler{bot: b} + if err := handler.handleCanceled(pending); err != nil { + return "", err + } + return platega.StatusCanceled, nil + } + + if status.Status == platega.StatusChargebacked { + handler := &paymentCallbackHandler{bot: b} + if err := handler.handleChargeback(pending); err != nil { + return "", err + } + return platega.StatusChargebacked, nil } return status.Status, nil diff --git a/internal/bot/payment_handler.go b/internal/bot/payment_handler.go index 9b6cf51..23cd23e 100644 --- a/internal/bot/payment_handler.go +++ b/internal/bot/payment_handler.go @@ -124,6 +124,12 @@ func (b *Bot) handleCheckPayment(c tele.Context) error { ParseMode: tele.ModeHTML, ReplyMarkup: b.userKeyboard(telegramID), }) + case "confirmed_not_activated": + b.userStates.Delete(telegramID) + return c.Send("✅ Оплата подтверждена, но активация подписки ещё не завершена.\n\nМы повторим попытку автоматически и отдельно сообщим о результате.", &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: b.userKeyboard(telegramID), + }) case "not_found": b.userStates.Delete(telegramID) return c.Send("Активных платежей не найдено.", &tele.SendOptions{ @@ -134,6 +140,11 @@ func (b *Bot) handleCheckPayment(c tele.Context) error { return c.Send("❌ Платёж отменён. Вы можете попробовать снова.", &tele.SendOptions{ ReplyMarkup: b.userKeyboard(telegramID), }) + case platega.StatusChargebacked: + b.userStates.Delete(telegramID) + return c.Send("⚠️ По платежу выполнен возврат средств. Доступ будет отключён или уже отключён. Если это ошибка, обратитесь к администратору.", &tele.SendOptions{ + ReplyMarkup: b.userKeyboard(telegramID), + }) default: // pending или другой промежуточный статус return c.Send("⏳ Оплата пока не поступила. Подождите немного и проверьте снова.", &tele.SendOptions{ diff --git a/internal/bot/payment_handler_test.go b/internal/bot/payment_handler_test.go new file mode 100644 index 0000000..9c89af8 --- /dev/null +++ b/internal/bot/payment_handler_test.go @@ -0,0 +1,274 @@ +package bot + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/fus1ond/vpn_bot/internal/database" + "github.com/fus1ond/vpn_bot/internal/platega" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tele "gopkg.in/telebot.v3" +) + +func TestCheckPaymentStatusSyncsCanceledAndChargebacked(t *testing.T) { + tests := []struct { + name string + remoteStatus string + wantStatus string + }{ + {name: "canceled", remoteStatus: platega.StatusCanceled, wantStatus: "canceled"}, + {name: "chargebacked", remoteStatus: platega.StatusChargebacked, wantStatus: "chargebacked"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, db := setupTestBot(t) + + userID := int64(810) + price := 500 + _, err := db.CreateUser(userID, "payer", "Payer", "uuid-810", &price, nil) + require.NoError(t, err) + + txID := "tx-810" + redirect := "https://pay.example/tx-810" + expiresAt := time.Now().UTC().Add(15 * time.Minute) + payment := &database.Payment{ + TelegramID: userID, + Amount: price, + PaymentMethod: "sbp", + Status: "pending", + PlategaTransactionID: &txID, + RedirectURL: &redirect, + ExpiresAt: &expiresAt, + } + paymentID, err := db.CreatePayment(payment) + require.NoError(t, err) + + b.platega = platega.NewClientWithBaseURL("merchant", "secret", "https://platega.test") + b.platega.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/transaction/"+txID, r.URL.Path) + + respBody, err := json.Marshal(map[string]any{ + "id": txID, + "paymentDetails": map[string]any{ + "amount": price, + "currency": "RUB", + }, + "status": tt.remoteStatus, + "paymentMethod": "SBPQR", + "expiresIn": "00:15:00", + "payload": "810", + }) + require.NoError(t, err) + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(respBody))), + Header: make(http.Header), + }, nil + }), + }) + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if tt.remoteStatus == platega.StatusChargebacked && r.Method == http.MethodPatch { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + } + return nil, assert.AnError + }), + }) + + status, err := b.checkPaymentStatus(userID) + require.NoError(t, err) + assert.Equal(t, tt.remoteStatus, status) + + stored, err := db.GetPaymentByID(paymentID) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, tt.wantStatus, stored.Status) + }) + } +} + +func TestHandleCheckPaymentDoesNotClaimActivationWhenEnableFails(t *testing.T) { + b, db := setupTestBot(t) + + userID := int64(811) + price := 500 + _, err := db.CreateUser(userID, "payer", "Payer", "uuid-811", &price, nil) + require.NoError(t, err) + + txID := "tx-811" + redirect := "https://pay.example/tx-811" + expiresAt := time.Now().UTC().Add(15 * time.Minute) + payment := &database.Payment{ + TelegramID: userID, + Amount: price, + PaymentMethod: "sbp", + Status: "pending", + PlategaTransactionID: &txID, + RedirectURL: &redirect, + ExpiresAt: &expiresAt, + } + paymentID, err := db.CreatePayment(payment) + require.NoError(t, err) + + b.platega = platega.NewClientWithBaseURL("merchant", "secret", "https://platega.test") + b.platega.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/transaction/"+txID, r.URL.Path) + + respBody, err := json.Marshal(map[string]any{ + "id": txID, + "paymentDetails": map[string]any{ + "amount": price, + "currency": "RUB", + }, + "status": platega.StatusConfirmed, + "paymentMethod": "SBPQR", + "expiresIn": "00:15:00", + "payload": "811", + }) + require.NoError(t, err) + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(respBody))), + Header: make(http.Header), + }, nil + }), + }) + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-811" { + payload := `{"response":{"uuid":"uuid-811","username":"payer","status":"EXPIRED","expireAt":"2026-03-01T00:00:00Z"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + } + return nil, assert.AnError + }), + }) + + ctx := &MockContext{ + sender: &tele.User{ID: userID}, + message: &tele.Message{}, + } + + err = b.handleCheckPayment(ctx) + require.NoError(t, err) + + msg, ok := ctx.sentMsg.(string) + require.True(t, ok) + assert.NotContains(t, msg, "Подписка активирована") + assert.Contains(t, msg, "Оплата подтверждена") + assert.Contains(t, msg, "активация") + + stored, err := db.GetPaymentByID(paymentID) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, "confirmed_not_activated", stored.Status) + require.NotNil(t, stored.ConfirmedAt) +} + +func TestCheckPaymentStatusTreatsManualConfirmedAsConfirmed(t *testing.T) { + b, db := setupTestBot(t) + + userID := int64(812) + price := 500 + _, err := db.CreateUser(userID, "payer", "Payer", "uuid-812", &price, nil) + require.NoError(t, err) + + txID := "tx-812" + redirect := "https://pay.example/tx-812" + expiresAt := time.Now().UTC().Add(15 * time.Minute) + payment := &database.Payment{ + TelegramID: userID, + Amount: price, + PaymentMethod: "sbp", + Status: "pending", + PlategaTransactionID: &txID, + RedirectURL: &redirect, + ExpiresAt: &expiresAt, + } + paymentID, err := db.CreatePayment(payment) + require.NoError(t, err) + + b.platega = platega.NewClientWithBaseURL("merchant", "secret", "https://platega.test") + b.platega.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/transaction/"+txID, r.URL.Path) + + respBody, err := json.Marshal(map[string]any{ + "id": txID, + "paymentDetails": map[string]any{ + "amount": price, + "currency": "RUB", + }, + "status": "MANUAL_CONFIRMED", + "paymentMethod": "SBPQR", + "expiresIn": "00:15:00", + "payload": "812", + }) + require.NoError(t, err) + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(respBody))), + Header: make(http.Header), + }, nil + }), + }) + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-812": + payload := `{"response":{"uuid":"uuid-812","username":"payer","status":"EXPIRED","expireAt":"2026-03-01T00:00:00Z"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodGet && r.URL.Path == "/api/users/by-telegram-id/812": + payload := `{"response":{"uuid":"uuid-812","username":"payer","status":"ACTIVE","expireAt":"2026-04-20T00:00:00Z"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodPatch && r.URL.Path == "/api/users": + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + default: + return nil, assert.AnError + } + }), + }) + + status, err := b.checkPaymentStatus(userID) + require.NoError(t, err) + assert.Equal(t, "confirmed", status) + + stored, err := db.GetPaymentByID(paymentID) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, "confirmed", stored.Status) + require.NotNil(t, stored.ConfirmedAt) +} diff --git a/internal/bot/payment_test.go b/internal/bot/payment_test.go index 6f43b76..b7e8b15 100644 --- a/internal/bot/payment_test.go +++ b/internal/bot/payment_test.go @@ -1,11 +1,16 @@ package bot import ( + "io" + "net/http" "os" + "strings" "testing" + "time" "github.com/fus1ond/vpn_bot/internal/config" "github.com/fus1ond/vpn_bot/internal/database" + "github.com/fus1ond/vpn_bot/internal/remnawave" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -94,3 +99,59 @@ func TestHandleConfirmedIdempotency(t *testing.T) { require.NoError(t, err) assert.Equal(t, "confirmed", after.Status) } + +func TestHandleConfirmedReturnsQuicklyWhenActivationFails(t *testing.T) { + dbFile := "test_payment_confirm_retry.db" + db, err := database.New(dbFile) + require.NoError(t, err) + t.Cleanup(func() { + db.Close() + os.Remove(dbFile) + }) + + _, err = db.CreateUser(501, "payer", "Payer", "uuid-501", nil, nil) + require.NoError(t, err) + + txID := "tx-quick-fail" + payment := &database.Payment{ + TelegramID: 501, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + PlategaTransactionID: &txID, + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + payment.ID = id + + cfg := &config.Config{AdminID: 999} + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-501" { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`{"error":"boom"}`)), + Header: make(http.Header), + }, nil + } + return nil, assert.AnError + }), + }) + + b := &Bot{db: db, config: cfg, userStates: newStateMap(), remnawave: client} + handler := &paymentCallbackHandler{bot: b} + + start := time.Now() + err = handler.handleConfirmed(payment) + duration := time.Since(start) + + assert.NoError(t, err) + assert.Less(t, duration, 2*time.Second, "handleConfirmed не должен держать request path на retry/sleep") + + stored, err := db.GetPaymentByID(id) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, "confirmed_not_activated", stored.Status) + require.NotNil(t, stored.ConfirmedAt) +} diff --git a/internal/bot/scheduler_test.go b/internal/bot/scheduler_test.go index 3f32856..ed808db 100644 --- a/internal/bot/scheduler_test.go +++ b/internal/bot/scheduler_test.go @@ -609,6 +609,9 @@ func TestSchedulerRetryConfirmedNotActivated(t *testing.T) { id, err := db.CreatePayment(payment) require.NoError(t, err) require.NoError(t, db.ConfirmPayment(id)) + confirmedAt := time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, confirmedAt, id) + require.NoError(t, err) require.NoError(t, db.UpdatePaymentStatus(id, "confirmed_not_activated")) var enableCalled bool @@ -661,4 +664,6 @@ func TestSchedulerRetryConfirmedNotActivated(t *testing.T) { p, err := db.GetPaymentByID(id) require.NoError(t, err) assert.Equal(t, "confirmed", p.Status, "статус должен стать confirmed после retry") + require.NotNil(t, p.ConfirmedAt) + assert.True(t, p.ConfirmedAt.Equal(confirmedAt), "confirmed_at должен сохраниться после retry") } diff --git a/internal/callback/server.go b/internal/callback/server.go index 8a2351e..0ba71fb 100644 --- a/internal/callback/server.go +++ b/internal/callback/server.go @@ -44,7 +44,7 @@ func NewServer(port int, merchantID, secret string, handler PaymentHandler) *Ser Addr: fmt.Sprintf(":%d", port), Handler: mux, ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, + WriteTimeout: 65 * time.Second, } return s diff --git a/internal/callback/server_test.go b/internal/callback/server_test.go index 7b80637..b1c688f 100644 --- a/internal/callback/server_test.go +++ b/internal/callback/server_test.go @@ -74,7 +74,7 @@ func TestCallbackValidRequest(t *testing.T) { payload := platega.CallbackPayload{ ID: "tx-42", Status: platega.StatusConfirmed, - Amount: "500.00", + Amount: 500, } body := makeCallbackBody(t, payload) @@ -95,6 +95,9 @@ func TestCallbackValidRequest(t *testing.T) { if handler.payload.ID != "tx-42" { t.Errorf("ожидали ID=tx-42, получили %s", handler.payload.ID) } + if handler.payload.Amount != 500 { + t.Errorf("ожидали amount=500, получили %v", handler.payload.Amount) + } } // TestCallbackHealth — проверка /health (200) diff --git a/internal/database/payments.go b/internal/database/payments.go index 514da11..312f39d 100644 --- a/internal/database/payments.go +++ b/internal/database/payments.go @@ -163,7 +163,10 @@ func (db *DB) UpdatePaymentStatus(id int64, status string) error { // ConfirmPayment помечает платёж как confirmed с датой подтверждения func (db *DB) ConfirmPayment(id int64) error { _, err := db.conn.Exec( - `UPDATE payments SET status = 'confirmed', confirmed_at = datetime('now') WHERE id = ?`, id, + `UPDATE payments + SET status = 'confirmed', + confirmed_at = COALESCE(confirmed_at, datetime('now')) + WHERE id = ?`, id, ) return err } diff --git a/internal/database/payments_test.go b/internal/database/payments_test.go index 6e95a84..3d66c87 100644 --- a/internal/database/payments_test.go +++ b/internal/database/payments_test.go @@ -153,6 +153,41 @@ func TestConfirmPayment(t *testing.T) { assert.NotNil(t, got.ConfirmedAt) } +func TestConfirmPaymentPreservesExistingConfirmedAt(t *testing.T) { + dbFile := "test_payments_confirm_preserve.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + p := &Payment{ + TelegramID: 12345, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(p) + require.NoError(t, err) + + require.NoError(t, db.ConfirmPayment(id)) + + original := time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, original, id) + require.NoError(t, err) + + require.NoError(t, db.UpdatePaymentStatus(id, "confirmed_not_activated")) + require.NoError(t, db.ConfirmPayment(id)) + + got, err := db.GetPaymentByID(id) + require.NoError(t, err) + require.NotNil(t, got) + require.NotNil(t, got.ConfirmedAt) + assert.Equal(t, "confirmed", got.Status) + assert.True(t, got.ConfirmedAt.Equal(original), "confirmed_at не должен перезаписываться при retry") +} + func TestExpireOldPendingPayments(t *testing.T) { dbFile := "test_payments_expire.db" db, err := New(dbFile) diff --git a/internal/platega/client.go b/internal/platega/client.go index 09f3364..bcf81cc 100644 --- a/internal/platega/client.go +++ b/internal/platega/client.go @@ -20,10 +20,11 @@ const ( // Статусы платежа const ( - StatusPending = "PENDING" - StatusConfirmed = "CONFIRMED" - StatusCanceled = "CANCELED" - StatusChargebacked = "CHARGEBACKED" + StatusPending = "PENDING" + StatusConfirmed = "CONFIRMED" + StatusManualConfirmed = "MANUAL_CONFIRMED" + StatusCanceled = "CANCELED" + StatusChargebacked = "CHARGEBACKED" ) // Client — HTTP-клиент Platega API @@ -49,6 +50,13 @@ func NewClientWithBaseURL(merchantID, secret, baseURL string) *Client { } } +// SetHTTPClient переопределяет HTTP-клиент (используется в тестах). +func (c *Client) SetHTTPClient(httpClient *http.Client) { + if httpClient != nil { + c.http = httpClient + } +} + // MerchantID возвращает merchant_id (для верификации callback) func (c *Client) MerchantID() string { return c.merchantID @@ -73,31 +81,91 @@ type CreateTransactionRequest struct { // CreateTransactionResponse — ответ на создание платежа type CreateTransactionResponse struct { - TransactionID string `json:"transactionId"` - Redirect string `json:"redirect"` // Ссылка для перенаправления пользователя - Status string `json:"status"` - ExpiresIn int `json:"expiresIn"` // Время жизни в секундах + TransactionID string `json:"transactionId"` + Redirect string `json:"redirect"` // Ссылка для перенаправления пользователя + Status string `json:"status"` + ExpiresIn time.Duration `json:"-"` +} + +// PaymentDetails — денежные реквизиты платежа. +type PaymentDetails struct { + Amount float64 `json:"amount"` + Currency string `json:"currency"` } -// TransactionStatus — полный статус транзакции +// TransactionStatus — полный статус транзакции. type TransactionStatus struct { - ID string `json:"id"` - Amount string `json:"amount"` - Currency string `json:"currency"` - Status string `json:"status"` - PaymentMethod int `json:"paymentMethod"` - Payload string `json:"payload"` + ID string `json:"id"` + PaymentDetails PaymentDetails `json:"paymentDetails"` + Status string `json:"status"` + PaymentMethod string `json:"paymentMethod"` + Payload string `json:"payload"` + ExpiresIn time.Duration `json:"-"` } // CallbackPayload — тело callback-запроса от Platega. // Используется и в platega-клиенте, и в callback-сервере (импортируется оттуда). type CallbackPayload struct { - ID string `json:"id"` - Amount string `json:"amount"` - Currency string `json:"currency"` - Status string `json:"status"` - PaymentMethod int `json:"paymentMethod"` - Payload string `json:"payload"` + ID string `json:"id"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` + PaymentMethod int `json:"paymentMethod"` + Payload string `json:"payload"` +} + +func (r *CreateTransactionResponse) UnmarshalJSON(data []byte) error { + var raw struct { + TransactionID string `json:"transactionId"` + Redirect string `json:"redirect"` + Status string `json:"status"` + ExpiresIn string `json:"expiresIn"` + } + + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + expiresIn, err := parseHHMMSSDuration(raw.ExpiresIn) + if err != nil { + return fmt.Errorf("parse expiresIn: %w", err) + } + + r.TransactionID = raw.TransactionID + r.Redirect = raw.Redirect + r.Status = raw.Status + r.ExpiresIn = expiresIn + + return nil +} + +func (r *TransactionStatus) UnmarshalJSON(data []byte) error { + var raw struct { + ID string `json:"id"` + PaymentDetails PaymentDetails `json:"paymentDetails"` + Status string `json:"status"` + PaymentMethod string `json:"paymentMethod"` + Payload string `json:"payload"` + ExpiresIn string `json:"expiresIn"` + } + + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + expiresIn, err := parseHHMMSSDuration(raw.ExpiresIn) + if err != nil { + return fmt.Errorf("parse expiresIn: %w", err) + } + + r.ID = raw.ID + r.PaymentDetails = raw.PaymentDetails + r.Status = raw.Status + r.PaymentMethod = raw.PaymentMethod + r.Payload = raw.Payload + r.ExpiresIn = expiresIn + + return nil } // CreatePayment создаёт платёж в Platega @@ -110,10 +178,18 @@ func (c *Client) CreatePayment(req CreateTransactionRequest) (*CreateTransaction "currency": req.Currency, }, "description": req.Description, - "return": req.ReturnURL, - "failedUrl": req.FailedURL, - "callbackUrl": req.CallbackURL, - "payload": req.Payload, + } + if req.ReturnURL != "" { + body["return"] = req.ReturnURL + } + if req.FailedURL != "" { + body["failedUrl"] = req.FailedURL + } + if req.CallbackURL != "" { + body["callbackUrl"] = req.CallbackURL + } + if req.Payload != "" { + body["payload"] = req.Payload } data, err := json.Marshal(body) @@ -190,6 +266,21 @@ func (c *Client) setHeaders(req *http.Request) { req.Header.Set("X-Secret", c.secret) } +func parseHHMMSSDuration(raw string) (time.Duration, error) { + if raw == "" { + return 0, nil + } + + parsed, err := time.Parse("15:04:05", raw) + if err != nil { + return 0, fmt.Errorf("invalid HH:MM:SS value %q: %w", raw, err) + } + + return time.Duration(parsed.Hour())*time.Hour + + time.Duration(parsed.Minute())*time.Minute + + time.Duration(parsed.Second())*time.Second, nil +} + // PaymentMethodName возвращает человекочитаемое название способа оплаты func PaymentMethodName(method int) string { switch method { diff --git a/internal/platega/client_test.go b/internal/platega/client_test.go index 8988b24..a546f9e 100644 --- a/internal/platega/client_test.go +++ b/internal/platega/client_test.go @@ -2,9 +2,11 @@ package platega_test import ( "encoding/json" + "io" "net/http" - "net/http/httptest" + "strings" "testing" + "time" "github.com/fus1ond/vpn_bot/internal/platega" "github.com/stretchr/testify/require" @@ -42,20 +44,20 @@ func TestPaymentMethodConversionUnknown(t *testing.T) { func TestClientHeaders(t *testing.T) { var receivedMerchantID, receivedSecret string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - receivedMerchantID = r.Header.Get("X-MerchantId") - receivedSecret = r.Header.Get("X-Secret") - // Возвращаем минимальный валидный ответ - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{ - "id": "tx-123", - "status": "PENDING", - }) - })) - defer server.Close() - - client := platega.NewClientWithBaseURL("merchant-id-test", "secret-test", server.URL) + client := platega.NewClientWithBaseURL("merchant-id-test", "secret-test", "https://platega.test") + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + receivedMerchantID = r.Header.Get("X-MerchantId") + receivedSecret = r.Header.Get("X-Secret") + + body := `{"id":"tx-123","paymentDetails":{"amount":500,"currency":"RUB"},"paymentMethod":"SBPQR","status":"PENDING"}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }), + }) _, _ = client.GetTransactionStatus("tx-123") require.Equal(t, "merchant-id-test", receivedMerchantID) @@ -64,27 +66,29 @@ func TestClientHeaders(t *testing.T) { // TestCreatePayment проверяет создание платежа через мок-сервер func TestCreatePayment(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "POST", r.Method) - require.Equal(t, "/transaction/process", r.URL.Path) - require.Equal(t, "application/json", r.Header.Get("Content-Type")) - - var body map[string]interface{} - require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - require.Equal(t, float64(2), body["paymentMethod"]) // СБП = 2 - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{ - "transactionId": "tx-abc", - "redirect": "https://pay.platega.io/tx-abc", - "status": "PENDING", - "expiresIn": 900, - }) - })) - defer server.Close() - - client := platega.NewClientWithBaseURL("merchant", "secret", server.URL) + client := platega.NewClientWithBaseURL("merchant", "secret", "https://platega.test") + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "POST", r.Method) + require.Equal(t, "/transaction/process", r.URL.Path) + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var body map[string]interface{} + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, float64(2), body["paymentMethod"]) // СБП = 2 + paymentDetails, ok := body["paymentDetails"].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, float64(500), paymentDetails["amount"]) + require.Equal(t, "RUB", paymentDetails["currency"]) + + resp := `{"transactionId":"tx-abc","redirect":"https://pay.platega.io/tx-abc","status":"PENDING","expiresIn":"00:15:00"}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(resp)), + Header: make(http.Header), + }, nil + }), + }) resp, err := client.CreatePayment(platega.CreateTransactionRequest{ PaymentMethod: platega.PaymentMethodSBP, Amount: 500, @@ -100,18 +104,21 @@ func TestCreatePayment(t *testing.T) { require.Equal(t, "tx-abc", resp.TransactionID) require.Equal(t, "https://pay.platega.io/tx-abc", resp.Redirect) require.Equal(t, "PENDING", resp.Status) - require.Equal(t, 900, resp.ExpiresIn) + require.Equal(t, 15*time.Minute, resp.ExpiresIn) } // TestCreatePaymentError проверяет обработку ошибки от API func TestCreatePaymentError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte(`{"error": "unauthorized"}`)) - })) - defer server.Close() - - client := platega.NewClientWithBaseURL("wrong", "wrong", server.URL) + client := platega.NewClientWithBaseURL("wrong", "wrong", "https://platega.test") + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader(`{"error":"unauthorized"}`)), + Header: make(http.Header), + }, nil + }), + }) _, err := client.CreatePayment(platega.CreateTransactionRequest{ PaymentMethod: platega.PaymentMethodSBP, Amount: 500, @@ -124,43 +131,44 @@ func TestCreatePaymentError(t *testing.T) { // TestGetTransactionStatus проверяет получение статуса транзакции func TestGetTransactionStatus(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "GET", r.Method) - require.Equal(t, "/transaction/tx-xyz", r.URL.Path) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{ - "id": "tx-xyz", - "amount": "500", - "currency": "RUB", - "status": "CONFIRMED", - "paymentMethod": 2, - "payload": "789012", - }) - })) - defer server.Close() - - client := platega.NewClientWithBaseURL("merchant", "secret", server.URL) + client := platega.NewClientWithBaseURL("merchant", "secret", "https://platega.test") + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "GET", r.Method) + require.Equal(t, "/transaction/tx-xyz", r.URL.Path) + + resp := `{"id":"tx-xyz","paymentDetails":{"amount":500,"currency":"RUB"},"status":"CONFIRMED","paymentMethod":"SBPQR","expiresIn":"00:15:00","payload":"789012"}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(resp)), + Header: make(http.Header), + }, nil + }), + }) status, err := client.GetTransactionStatus("tx-xyz") require.NoError(t, err) require.Equal(t, "tx-xyz", status.ID) - require.Equal(t, "500", status.Amount) + require.Equal(t, 500.0, status.PaymentDetails.Amount) + require.Equal(t, "RUB", status.PaymentDetails.Currency) require.Equal(t, "CONFIRMED", status.Status) - require.Equal(t, 2, status.PaymentMethod) + require.Equal(t, "SBPQR", status.PaymentMethod) + require.Equal(t, 15*time.Minute, status.ExpiresIn) require.Equal(t, "789012", status.Payload) } // TestGetTransactionStatusNotFound проверяет обработку 404 func TestGetTransactionStatusNotFound(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - w.Write([]byte(`{"error": "not found"}`)) - })) - defer server.Close() - - client := platega.NewClientWithBaseURL("merchant", "secret", server.URL) + client := platega.NewClientWithBaseURL("merchant", "secret", "https://platega.test") + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)), + Header: make(http.Header), + }, nil + }), + }) _, err := client.GetTransactionStatus("nonexistent") require.Error(t, err) @@ -174,3 +182,9 @@ func TestClientMerchantAndSecretAccessors(t *testing.T) { require.Equal(t, "my-merchant", client.MerchantID()) require.Equal(t, "my-secret", client.Secret()) } + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} From 00c4425eda069108bb60b32348883f6b59ac17e7 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 14:11:44 +0300 Subject: [PATCH 19/34] =?UTF-8?q?fix:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BA=D0=BE=D1=80=D0=BE=D1=82=D0=BA=D0=B8?= =?UTF-8?q?=D0=B9=20background=20retry=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bot/handlers.go | 36 ++++++------ internal/bot/payment.go | 103 +++++++++++++++++++++++++++++++++++ internal/bot/payment_test.go | 94 ++++++++++++++++++++++++++++++++ internal/bot/scheduler.go | 30 +--------- 4 files changed, 217 insertions(+), 46 deletions(-) diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 99d22b2..1fe9f89 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -25,23 +25,25 @@ const ( // Bot представляет Telegram бота type Bot struct { - bot *tele.Bot - db *database.DB - remnawave *remnawave.Client - config *config.Config - userStates *stateMap - metricsClient *monitoring.MetricsClient // клиент метрик VM - dashboardMgr *dashboardManager // менеджер сессий дашборда - sdConfigsPath string // путь к sd_configs (для чтения targets) - render *render.Client // клиент render-сервиса (nil если не настроен) - platega *platega.Client // Platega API клиент (nil если не настроен) - maintenanceMode bool // Режим обслуживания (сбрасывается при перезапуске) - modChangePriceMu sync.RWMutex - modChangePriceData map[int64]modChangePriceSession // pending-данные изменения цены для модератора - adminSwitchMu sync.RWMutex - adminSwitchData map[int64]adminSwitchSession // pending-данные перевода тарифа для админа - adminPriceMu sync.RWMutex - adminPriceData map[int64]adminChangePriceSession // pending-данные изменения цены для админа + bot *tele.Bot + db *database.DB + remnawave *remnawave.Client + config *config.Config + userStates *stateMap + metricsClient *monitoring.MetricsClient // клиент метрик VM + dashboardMgr *dashboardManager // менеджер сессий дашборда + sdConfigsPath string // путь к sd_configs (для чтения targets) + render *render.Client // клиент render-сервиса (nil если не настроен) + platega *platega.Client // Platega API клиент (nil если не настроен) + maintenanceMode bool // Режим обслуживания (сбрасывается при перезапуске) + paymentRetryDelays []time.Duration // Тестовые override-задержки для короткого background retry активации + paymentRetryInFlight sync.Map // payment_id -> struct{}, чтобы не плодить дублирующие retry-воркеры + modChangePriceMu sync.RWMutex + modChangePriceData map[int64]modChangePriceSession // pending-данные изменения цены для модератора + adminSwitchMu sync.RWMutex + adminSwitchData map[int64]adminSwitchSession // pending-данные перевода тарифа для админа + adminPriceMu sync.RWMutex + adminPriceData map[int64]adminChangePriceSession // pending-данные изменения цены для админа } // New создаёт нового Telegram бота diff --git a/internal/bot/payment.go b/internal/bot/payment.go index ea44f92..03342c8 100644 --- a/internal/bot/payment.go +++ b/internal/bot/payment.go @@ -17,6 +17,12 @@ import ( // Не критично (мьютекс маленький), но при необходимости можно добавить периодическую чистку. var paymentMu sync.Map // map[int64]*sync.Mutex +var defaultPaymentRetryDelays = []time.Duration{ + 30 * time.Second, + 1 * time.Minute, + 5 * time.Minute, +} + func getPaymentMutex(telegramID int64) *sync.Mutex { mu, _ := paymentMu.LoadOrStore(telegramID, &sync.Mutex{}) return mu.(*sync.Mutex) @@ -85,6 +91,7 @@ func (h *paymentCallbackHandler) handleConfirmed(payment *database.Payment) erro if updateErr := h.bot.db.UpdatePaymentStatus(payment.ID, "confirmed_not_activated"); updateErr != nil { return fmt.Errorf("update status to confirmed_not_activated: %w", updateErr) } + h.bot.schedulePaymentActivationRetry(payment.ID) // Уведомляем админа if !alreadyMarkedForRetry { @@ -121,6 +128,102 @@ func (h *paymentCallbackHandler) finalizeActivatedPayment(payment *database.Paym h.bot.db.ClearNotifications(payment.TelegramID) } +func (b *Bot) paymentActivationRetryDelays() []time.Duration { + if len(b.paymentRetryDelays) > 0 { + delays := make([]time.Duration, len(b.paymentRetryDelays)) + copy(delays, b.paymentRetryDelays) + return delays + } + + delays := make([]time.Duration, len(defaultPaymentRetryDelays)) + copy(delays, defaultPaymentRetryDelays) + return delays +} + +func (b *Bot) schedulePaymentActivationRetry(paymentID int64) { + if _, loaded := b.paymentRetryInFlight.LoadOrStore(paymentID, struct{}{}); loaded { + return + } + + delays := b.paymentActivationRetryDelays() + go func() { + defer b.paymentRetryInFlight.Delete(paymentID) + + for attempt, delay := range delays { + time.Sleep(delay) + + if b.retryConfirmedPaymentActivation(paymentID, "background_retry") { + return + } + + slog.Warn("Background retry активации не удался", + "payment_id", paymentID, + "attempt", attempt+1, + "next_retry_in", nextRetryDelay(delays, attempt), + ) + } + }() +} + +func nextRetryDelay(delays []time.Duration, attempt int) string { + if attempt+1 >= len(delays) { + return "scheduler" + } + return delays[attempt+1].String() +} + +func (b *Bot) retryConfirmedPaymentActivation(paymentID int64, source string) bool { + payment, err := b.db.GetPaymentByID(paymentID) + if err != nil { + slog.Error("Не удалось загрузить платёж для retry активации", + "error", err, "payment_id", paymentID, "source", source) + return false + } + if payment == nil || payment.Status != "confirmed_not_activated" { + return true + } + + mu := getPaymentMutex(payment.TelegramID) + mu.Lock() + defer mu.Unlock() + + payment, err = b.db.GetPaymentByID(paymentID) + if err != nil { + slog.Error("Не удалось перечитать платёж под mutex для retry активации", + "error", err, "payment_id", paymentID, "source", source) + return false + } + if payment == nil || payment.Status != "confirmed_not_activated" { + return true + } + + handler := &paymentCallbackHandler{bot: b} + if err := handler.activateSubscription(payment); err != nil { + slog.Warn("Не удалось активировать подписку при retry", + "error", err, + "payment_id", paymentID, + "telegram_id", payment.TelegramID, + "source", source, + ) + return false + } + + if err := b.db.ConfirmPayment(payment.ID); err != nil { + slog.Error("Не удалось обновить статус после retry активации", + "error", err, "payment_id", paymentID, "source", source) + return false + } + + handler.finalizeActivatedPayment(payment) + slog.Info("Retry активации успешен", + "payment_id", paymentID, + "telegram_id", payment.TelegramID, + "source", source, + ) + + return true +} + // activateSubscription продлевает подписку в Remnawave func (h *paymentCallbackHandler) activateSubscription(payment *database.Payment) error { user, err := h.bot.db.GetUserByTelegramID(payment.TelegramID) diff --git a/internal/bot/payment_test.go b/internal/bot/payment_test.go index b7e8b15..2d92379 100644 --- a/internal/bot/payment_test.go +++ b/internal/bot/payment_test.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "strings" + "sync/atomic" "testing" "time" @@ -155,3 +156,96 @@ func TestHandleConfirmedReturnsQuicklyWhenActivationFails(t *testing.T) { assert.Equal(t, "confirmed_not_activated", stored.Status) require.NotNil(t, stored.ConfirmedAt) } + +func TestHandleConfirmedRetriesActivationInBackground(t *testing.T) { + dbFile := "test_payment_background_retry.db" + db, err := database.New(dbFile) + require.NoError(t, err) + t.Cleanup(func() { + db.Close() + os.Remove(dbFile) + }) + + _, err = db.CreateUser(502, "payer", "Payer", "uuid-502", nil, nil) + require.NoError(t, err) + + txID := "tx-background-retry" + payment := &database.Payment{ + TelegramID: 502, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + PlategaTransactionID: &txID, + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + payment.ID = id + + var getUserAttempts atomic.Int32 + enabledCh := make(chan struct{}, 1) + + cfg := &config.Config{AdminID: 999} + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-502": + attempt := getUserAttempts.Add(1) + if attempt == 1 { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`{"error":"boom"}`)), + Header: make(http.Header), + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{"uuid":"uuid-502","status":"EXPIRED","expireAt":"2026-03-01T00:00:00Z"}}`)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodPatch && r.URL.Path == "/api/users": + select { + case enabledCh <- struct{}{}: + default: + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodGet && r.URL.Path == "/api/users/by-telegram-id/502": + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{"uuid":"uuid-502","status":"ACTIVE","expireAt":"2026-04-20T00:00:00Z"}}`)), + Header: make(http.Header), + }, nil + default: + return nil, assert.AnError + } + }), + }) + + b := &Bot{ + db: db, + config: cfg, + userStates: newStateMap(), + remnawave: client, + paymentRetryDelays: []time.Duration{10 * time.Millisecond}, + } + handler := &paymentCallbackHandler{bot: b} + + err = handler.handleConfirmed(payment) + require.NoError(t, err) + + select { + case <-enabledCh: + case <-time.After(time.Second): + t.Fatal("ожидали background retry активации") + } + + require.Eventually(t, func() bool { + stored, getErr := db.GetPaymentByID(id) + require.NoError(t, getErr) + return stored != nil && stored.Status == "confirmed" + }, time.Second, 20*time.Millisecond) +} diff --git a/internal/bot/scheduler.go b/internal/bot/scheduler.go index 5f9faad..c5fdf59 100644 --- a/internal/bot/scheduler.go +++ b/internal/bot/scheduler.go @@ -249,39 +249,11 @@ func (b *Bot) retryConfirmedNotActivated() { } slog.Info("Scheduler: retry confirmed_not_activated", "count", len(payments)) - handler := &paymentCallbackHandler{bot: b} for _, p := range payments { - payment := p // копируем для замыкания - if err := handler.activateSubscription(&payment); err != nil { - slog.Warn("Scheduler: retry активации не удался", - "error", err, "payment_id", payment.ID, "telegram_id", payment.TelegramID) + if !b.retryConfirmedPaymentActivation(p.ID, "scheduler") { continue } - - // Успешно активирован — обновляем статус - if err := b.db.ConfirmPayment(payment.ID); err != nil { - slog.Error("Scheduler: ошибка обновления статуса после retry", - "error", err, "payment_id", payment.ID) - continue - } - - // Создаём earnings - handler.createEarningRecord(&payment) - - // Уведомляем пользователя - remUser, _ := b.remnawave.GetUserByTelegramID(payment.TelegramID) - var msg string - if remUser != nil { - expireDate := remUser.ExpireAt.Format("02.01.2006") - msg = fmt.Sprintf("✅ Оплата прошла! Ваша подписка активна до %s.\n\nЛимит трафика снят — пользуйтесь без ограничений.", expireDate) - } else { - msg = "✅ Оплата прошла! Подписка активирована." - } - _ = b.sendSchedulerMessage(payment.TelegramID, msg) - b.db.ClearNotifications(payment.TelegramID) - - slog.Info("Scheduler: retry активации успешен", "payment_id", payment.ID, "telegram_id", payment.TelegramID) } } From f8be94efa0ac19ec2aed51cc19bf25d085d53d0b Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 14:44:14 +0300 Subject: [PATCH 20/34] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20confirmed=5Fnot=5Factivated=20=D0=BE=D1=82?= =?UTF-8?q?=20scheduler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...rmed-not-activated-scheduler-guard-plan.md | 334 ++++++++++++++++++ ...confirmed-not-activated-scheduler-guard.md | 54 +++ internal/bot/scheduler.go | 13 +- internal/bot/scheduler_test.go | 271 ++++++++++++++ internal/database/payments.go | 13 +- internal/database/payments_test.go | 53 +++ 6 files changed, 730 insertions(+), 8 deletions(-) create mode 100644 docs/plans/2026-03-23-confirmed-not-activated-scheduler-guard-plan.md create mode 100644 docs/progress/2026-03-23-confirmed-not-activated-scheduler-guard.md diff --git a/docs/plans/2026-03-23-confirmed-not-activated-scheduler-guard-plan.md b/docs/plans/2026-03-23-confirmed-not-activated-scheduler-guard-plan.md new file mode 100644 index 0000000..9696988 --- /dev/null +++ b/docs/plans/2026-03-23-confirmed-not-activated-scheduler-guard-plan.md @@ -0,0 +1,334 @@ +# Защита confirmed_not_activated от scheduler — план реализации + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Не допускать `disable` и `kick` для пользователей с уже подтверждённой оплатой, если платёж временно находится в статусе `confirmed_not_activated`. + +**Architecture:** Исправление должно быть минимальным по поверхности: scheduler и классификация trial/paid должны считать `confirmed_not_activated` защитным платёжным состоянием наравне с `confirmed`. При этом логика retry активации в Remnawave остаётся без изменений, а временный статус не должен менять финансовую статистику и прочие выборки, где нужен именно финальный `confirmed`. + +**Tech Stack:** Go 1.25, SQLite, telebot.v3, testify + +--- + +### Task 1: Зафиксировать ожидаемую семантику статуса в тестах БД + +**Files:** +- Modify: `internal/database/payments_test.go` +- Modify: `internal/database/payments.go` + +**Step 1: Написать падающий тест для `HasConfirmedPayment`** + +В `internal/database/payments_test.go` добавить тест: + +```go +func TestHasConfirmedPaymentTreatsConfirmedNotActivatedAsPaid(t *testing.T) { + dbFile := "test_payments_confirmed_not_activated_paid.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + p := &Payment{ + TelegramID: 12345, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(p) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + require.NoError(t, db.UpdatePaymentStatus(id, "confirmed_not_activated")) + + has, err := db.HasConfirmedPayment(12345) + require.NoError(t, err) + assert.True(t, has, "confirmed_not_activated должен считаться подтверждённой оплатой для защитных проверок") +} +``` + +**Step 2: Написать падающий тест для `HasConfirmedPaymentSince`** + +В тот же файл добавить тест: + +```go +func TestHasConfirmedPaymentSinceTreatsConfirmedNotActivatedAsPaid(t *testing.T) { + dbFile := "test_payments_confirmed_not_activated_since.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + p := &Payment{ + TelegramID: 12345, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(p) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + require.NoError(t, db.UpdatePaymentStatus(id, "confirmed_not_activated")) + + since := time.Now().UTC().Add(-1 * time.Hour) + has, err := db.HasConfirmedPaymentSince(12345, since) + require.NoError(t, err) + assert.True(t, has, "confirmed_not_activated должен защищать пользователя в проверках scheduler по времени") +} +``` + +**Step 3: Запустить только новые DB-тесты и убедиться, что они падают** + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/database/ -run 'TestHasConfirmedPaymentTreatsConfirmedNotActivatedAsPaid|TestHasConfirmedPaymentSinceTreatsConfirmedNotActivatedAsPaid' -v +``` + +Expected: `FAIL`, потому что текущие запросы смотрят только `status = 'confirmed'`. + +**Step 4: Исправить защитные выборки в `payments.go`** + +В `internal/database/payments.go` обновить только защитные helper-методы: + +- `HasConfirmedPayment` +- `HasConfirmedPaymentSince` + +SQL должен использовать: + +```sql +status IN ('confirmed', 'confirmed_not_activated') +``` + +Комментарии к методам тоже обновить: явно указать, что для scheduler-защиты оба статуса означают «оплата подтверждена, пользователя нельзя считать неоплатившим». + +**Step 5: Повторно запустить DB-тесты** + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/database/ -run 'TestHasConfirmedPaymentTreatsConfirmedNotActivatedAsPaid|TestHasConfirmedPaymentSinceTreatsConfirmedNotActivatedAsPaid' -v +``` + +Expected: `PASS`. + +**Step 6: Коммит** + +```bash +git add internal/database/payments.go internal/database/payments_test.go +git commit -m "fix: защитить confirmed_not_activated в проверках оплаты" +``` + +--- + +### Task 2: Закрыть регрессию тестами scheduler для trial, disable и grace + +**Files:** +- Modify: `internal/bot/scheduler_test.go` +- Modify: `internal/bot/scheduler.go` + +**Step 1: Написать падающий тест для trial-пользователя** + +В `internal/bot/scheduler_test.go` добавить тест, который создаёт: + +- trial-пользователя с уже истёкшим `expireAt` +- платёж со статусом `confirmed_not_activated` + +Проверка: + +```go +func TestSchedulerTrialNotKickedIfPaymentConfirmedNotActivated(t *testing.T) { + // setup trial user + // create payment -> ConfirmPayment -> UpdatePaymentStatus(..., "confirmed_not_activated") + // вызвать processTrialUser(...) + // убедиться, что пользователь не удалён из БД +} +``` + +Ожидание: до фикса тест падает, потому что scheduler считает пользователя неоплатившим. + +**Step 2: Написать падающий тест для paid disable-path** + +Добавить тест для пользователя с платной подпиской: + +```go +func TestSchedulerPaidDisableSkippedIfPaymentConfirmedNotActivated(t *testing.T) { + // setup paid user + // платёж confirmed_not_activated после expireAt + // мок Remnawave с флагом disableCalled + // вызвать processPaidUser(...) + // assert.False(t, disableCalled) +} +``` + +Ожидание: до фикса `disableCalled == true`. + +**Step 3: Написать падающий тест для grace kick-path** + +Добавить отдельный тест: + +```go +func TestSchedulerGraceKickSkippedIfPaymentConfirmedNotActivated(t *testing.T) { + // setup paid user + // expireAt = now - 96h + // платёж confirmed_not_activated после expireAt + // мок Remnawave: свежая проверка пользователя не должна приводить к kick + // assert, что пользователь не удалён +} +``` + +Это нужен отдельный регресс-тест, потому что reported bug затрагивает не только `disable`, но и `kick` после grace period. + +**Step 4: Добавить интеграционный тест на полный scheduler-pass с неуспешным retry** + +Сделать сценарий ближе к реальному: + +```go +func TestSchedulerPassDoesNotPunishConfirmedNotActivatedWhenRetryStillFails(t *testing.T) { + // 1. Пользователь уже просрочен + // 2. Платёж = confirmed_not_activated + // 3. retryConfirmedNotActivated вызывается в начале pass, но Remnawave на активации снова падает + // 4. scheduler продолжает обход пользователей + // 5. Проверяем, что ни disable, ни kick не случились +} +``` + +Мок клиента должен различать: + +- запросы списка пользователей для основного scheduler-pass +- запрос активации/получения пользователя для retry +- попытки `PATCH`/`DELETE`, которые должны остаться не вызванными + +Этот тест обязателен, потому что баг проявляется именно в одном scheduler-pass: retry может не сработать, но это не даёт права считать пользователя неоплатившим. + +**Step 5: Запустить только scheduler-регрессионные тесты** + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestSchedulerTrialNotKickedIfPaymentConfirmedNotActivated|TestSchedulerPaidDisableSkippedIfPaymentConfirmedNotActivated|TestSchedulerGraceKickSkippedIfPaymentConfirmedNotActivated|TestSchedulerPassDoesNotPunishConfirmedNotActivatedWhenRetryStillFails' -v +``` + +Expected: `FAIL` до реализации. + +**Step 6: Уточнить комментарии в `scheduler.go`** + +После фикса обновить комментарии рядом с: + +- `processTrialUser` +- `processPaidUser` +- `isTrialUser` + +Нужно явно зафиксировать в коде, что: + +- `confirmed` и `confirmed_not_activated` оба означают подтверждённую оплату для защитных решений scheduler +- `confirmed_not_activated` не означает успешную активацию в панели, но уже запрещает считать пользователя должником + +Комментарии писать по-русски. + +**Step 7: Повторно запустить scheduler-тесты** + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestSchedulerTrialNotKickedIfPaymentConfirmedNotActivated|TestSchedulerPaidDisableSkippedIfPaymentConfirmedNotActivated|TestSchedulerGraceKickSkippedIfPaymentConfirmedNotActivated|TestSchedulerPassDoesNotPunishConfirmedNotActivatedWhenRetryStillFails' -v +``` + +Expected: `PASS`. + +**Step 8: Коммит** + +```bash +git add internal/bot/scheduler.go internal/bot/scheduler_test.go +git commit -m "fix: запретить scheduler наказывать confirmed_not_activated" +``` + +--- + +### Task 3: Проверить, что побочные выборки не изменили смысл + +**Files:** +- Review only: `internal/database/payments.go` +- Review only: `internal/bot/payment.go` + +**Step 1: Проверить, что не были изменены агрегаты и статистика** + +Убедиться, что без необходимости не затронуты: + +- `CountConfirmedPaymentsByMonth` +- `SumConfirmedPaymentsByMonth` +- `CountFirstPaymentsByMonth` +- `CountPayingSubscribersByModerator` + +Для этого бага достаточно исправить только защитные helper-методы scheduler. Если в ходе реализации появилось желание расширить остальные выборки на `confirmed_not_activated`, этого делать не нужно без отдельного требования. + +**Step 2: Проверить, что retry-механизм не сломан** + +Убедиться, что всё ещё сохраняется поведение: + +- callback фиксирует платёж +- при ошибке активации ставится `confirmed_not_activated` +- scheduler продолжает retry +- после успешной активации статус возвращается в `confirmed` + +**Step 3: При необходимости добавить короткий комментарий в код** + +Если при ревью видно, что причина различия между «защитными» и «финансовыми» запросами неочевидна, добавить краткий комментарий в `payments.go` рядом с helper-методами. Комментарий должен объяснять, почему scheduler-защита шире, чем финансовая статистика. + +**Step 4: Коммит** + +```bash +git add internal/database/payments.go internal/bot/payment.go +git commit -m "refactor: задокументировать семантику статусов оплаты" +``` + +--- + +### Task 4: Полная проверка и документация выполнения + +**Files:** +- Modify: `docs/progress/2026-03-23-confirmed-not-activated-scheduler-guard.md` +- Modify: `README.md` (только если по итогу появится пользовательское или операционное изменение) + +**Step 1: Прогнать форматирование** + +Run: + +```bash +make fmt +``` + +Expected: успешное завершение без ошибок. + +**Step 2: Прогнать тесты** + +Run: + +```bash +make tests +``` + +Expected: все тесты зелёные. + +**Step 3: Создать progress-документ** + +Создать `docs/progress/2026-03-23-confirmed-not-activated-scheduler-guard.md` и зафиксировать: + +- ссылку на этот план +- какие файлы изменены +- какие регресс-тесты добавлены +- результаты `make fmt` +- результаты `make tests` + +**Step 4: Проверить необходимость обновления `README.md`** + +Если поведение системы поменялось только внутренне и пользовательский/операционный контракт не изменился, `README.md` не трогать. Если по ходу реализации появится новая важная оговорка по `confirmed_not_activated` для эксплуатации, добавить короткое пояснение. + +**Step 5: Финальный коммит** + +```bash +git add docs/progress/2026-03-23-confirmed-not-activated-scheduler-guard.md README.md +git commit -m "docs: описать защиту confirmed_not_activated от scheduler" +``` diff --git a/docs/progress/2026-03-23-confirmed-not-activated-scheduler-guard.md b/docs/progress/2026-03-23-confirmed-not-activated-scheduler-guard.md new file mode 100644 index 0000000..08cd8b3 --- /dev/null +++ b/docs/progress/2026-03-23-confirmed-not-activated-scheduler-guard.md @@ -0,0 +1,54 @@ +# Защита confirmed_not_activated от scheduler + +**Дата:** 2026-03-23 +**План:** [2026-03-23-confirmed-not-activated-scheduler-guard-plan.md](../plans/2026-03-23-confirmed-not-activated-scheduler-guard-plan.md) +**Коммит:** `fix: защитить confirmed_not_activated от scheduler` + +## Что сделано + +### `internal/database/payments.go` +- `HasConfirmedPayment` теперь считает защитной оплатой оба статуса: + - `confirmed` + - `confirmed_not_activated` +- `HasConfirmedPaymentSince` расширен аналогично +- Добавлены поясняющие комментарии, почему это касается только защитных проверок scheduler, а не финансовой статистики + +### `internal/database/payments_test.go` +- Добавлен регрессионный тест `TestHasConfirmedPaymentTreatsConfirmedNotActivatedAsPaid` +- Добавлен регрессионный тест `TestHasConfirmedPaymentSinceTreatsConfirmedNotActivatedAsPaid` +- RED зафиксирован: до правки оба теста падали, потому что helper-методы искали только `status='confirmed'` + +### `internal/bot/scheduler_test.go` +- Добавлен регрессионный тест `TestSchedulerTrialNotKickedIfPaymentConfirmedNotActivated` +- Добавлен регрессионный тест `TestSchedulerPaidDisableSkippedIfPaymentConfirmedNotActivated` +- Добавлен регрессионный тест `TestSchedulerGraceKickSkippedIfPaymentConfirmedNotActivated` +- Добавлен интеграционный тест `TestSchedulerPassDoesNotPunishConfirmedNotActivatedWhenRetryStillFails` + +### `internal/bot/scheduler.go` +- Уточнены комментарии в `processTrialUser`, `processPaidUser` и `isTrialUser` +- В коде явно зафиксировано, что `confirmed_not_activated` не означает успешную активацию в панели, но уже запрещает считать пользователя неоплатившим + +## Что не менялось + +- Финансовые агрегаты в `payments.go`: + - `CountConfirmedPaymentsByMonth` + - `SumConfirmedPaymentsByMonth` + - `CountFirstPaymentsByMonth` + - `CountPayingSubscribersByModerator` +- Механика retry в `payment.go` +- `README.md` не обновлялся: изменение внутреннее, без новой пользовательской или операционной настройки + +## Проверка + +### TDD / таргетные проверки +- `GOCACHE=/tmp/go-build go test ./internal/database/ -run 'TestHasConfirmedPaymentTreatsConfirmedNotActivatedAsPaid|TestHasConfirmedPaymentSinceTreatsConfirmedNotActivatedAsPaid' -v` + - сначала `FAIL` + - после правки `PASS` +- `GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestSchedulerTrialNotKickedIfPaymentConfirmedNotActivated|TestSchedulerPaidDisableSkippedIfPaymentConfirmedNotActivated|TestSchedulerGraceKickSkippedIfPaymentConfirmedNotActivated|TestSchedulerPassDoesNotPunishConfirmedNotActivatedWhenRetryStillFails' -v` + - `PASS` + +### Обязательная полная проверка +- `make fmt` + - `PASS` +- `make tests` + - `PASS` diff --git a/internal/bot/scheduler.go b/internal/bot/scheduler.go index c5fdf59..4c5e7b8 100644 --- a/internal/bot/scheduler.go +++ b/internal/bot/scheduler.go @@ -134,7 +134,9 @@ func (b *Bot) processTrialUser(telegramID int64, dbUser database.User, expireAt, return } - // Защита: проверяем, не оплатил ли пользователь + // Защита: проверяем, не оплатил ли пользователь. + // confirmed_not_activated тоже защищает от кика: деньги уже подтверждены, + // даже если активация в панели ещё не завершилась. hasPaid, err := b.db.HasConfirmedPaymentSince(telegramID, expireAt) if err != nil { slog.Error("Scheduler: ошибка проверки оплаты при кике триала", "error", err, "telegram_id", telegramID) @@ -168,7 +170,9 @@ func (b *Bot) processPaidUser(telegramID int64, dbUser database.User, expireAt, // Подписка истекла — disable + начало grace period if !now.Before(expireAt) { - // Защита: проверяем, не оплатил ли пользователь после expireAt + // Защита: проверяем, не оплатил ли пользователь после expireAt. + // confirmed_not_activated тоже считается оплатой для scheduler: + // пользователя нельзя disable-ить как должника, пока retry активации продолжается. hasPaid, err := b.db.HasConfirmedPaymentSince(telegramID, expireAt) if err != nil { slog.Error("Scheduler: ошибка проверки оплаты", "error", err, "telegram_id", telegramID) @@ -197,7 +201,8 @@ func (b *Bot) processPaidUser(telegramID int64, dbUser database.User, expireAt, return } - // Защита: проверяем оплату за весь grace period + // Защита: проверяем оплату за весь grace period. + // confirmed_not_activated здесь тоже блокирует кик: деньги уже получены. hasPaid, err := b.db.HasConfirmedPaymentSince(telegramID, expireAt) if err != nil { slog.Error("Scheduler: ошибка проверки оплаты перед grace kick", "error", err, "telegram_id", telegramID) @@ -223,6 +228,8 @@ func (b *Bot) processPaidUser(telegramID int64, dbUser database.User, expireAt, // isTrialUser проверяет, находится ли пользователь на триале. // Триальный = приглашён модераторским инвайтом (expire_days != NULL) И ни разу не платил. +// confirmed_not_activated уже не считается "не платил": деньги подтверждены, просто +// активация доступа в панели временно отложена на retry. func (b *Bot) isTrialUser(telegramID int64) bool { invite, err := b.db.GetInviteByUsedBy(telegramID) if err != nil || invite == nil || invite.ExpireDays == nil { diff --git a/internal/bot/scheduler_test.go b/internal/bot/scheduler_test.go index ed808db..c6ddc63 100644 --- a/internal/bot/scheduler_test.go +++ b/internal/bot/scheduler_test.go @@ -263,6 +263,43 @@ func TestSchedulerTrialNotKickedIfPaid(t *testing.T) { assert.NotNil(t, dbUser, "оплативший пользователь не должен быть кикнут") } +func TestSchedulerTrialNotKickedIfPaymentConfirmedNotActivated(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(101) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(202) + price := 400 + _, err = db.CreateUser(userID, "user_retry_trial", "User", "uuid-202", &price, &modID) + require.NoError(t, err) + + expireDays := 3 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + require.NoError(t, db.UpdatePaymentStatus(id, "confirmed_not_activated")) + + yesterday := time.Now().UTC().AddDate(0, 0, -1) + b.processTrialUser(userID, database.User{TelegramID: userID, RemnawaveUUID: "uuid-202"}, yesterday, time.Now().UTC()) + + dbUser, err := db.GetUserByTelegramID(userID) + require.NoError(t, err) + assert.NotNil(t, dbUser, "пользователь с confirmed_not_activated не должен быть кикнут как неоплативший") +} + func TestSchedulerSkipsLegacyUserWithoutInvite(t *testing.T) { b, db := setupSchedulerTestBot(t) @@ -530,6 +567,123 @@ func TestSchedulerPaidDisableIgnoresPaymentsBeforeExpireAt(t *testing.T) { assert.True(t, disableCalled, "старый платёж до expireAt не должен блокировать disable после истечения подписки") } +func TestSchedulerPaidDisableSkippedIfPaymentConfirmedNotActivated(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(131) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(321) + price := 400 + _, err = db.CreateUser(userID, "paid_retry_disable", "Paid", "uuid-321", &price, &modID) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + require.NoError(t, db.UpdatePaymentStatus(id, "confirmed_not_activated")) + + var disableCalled bool + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodPatch { + disableCalled = true + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + } + return nil, fmt.Errorf("unexpected: %s %s", r.Method, r.URL.Path) + }), + }) + b.remnawave = client + + expireAt := time.Now().UTC().Add(-30 * time.Minute) + b.processPaidUser(userID, database.User{TelegramID: userID, RemnawaveUUID: "uuid-321"}, expireAt, time.Now().UTC()) + + assert.False(t, disableCalled, "confirmed_not_activated не должен приводить к disable как будто оплаты не было") +} + +func TestSchedulerGraceKickSkippedIfPaymentConfirmedNotActivated(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(132) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(322) + price := 400 + _, err = db.CreateUser(userID, "paid_retry_grace", "Paid", "uuid-322", &price, &modID) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + require.NoError(t, db.UpdatePaymentStatus(id, "confirmed_not_activated")) + + var deleteCalled bool + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/users/uuid-322") { + user := remnawave.User{ + UUID: "uuid-322", + Status: "DISABLED", + ExpireAt: time.Now().UTC().AddDate(0, 0, -5), + } + body, _ := json.Marshal(map[string]interface{}{"response": user}) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(body))), + Header: make(http.Header), + }, nil + } + if r.Method == http.MethodDelete { + deleteCalled = true + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + } + return nil, fmt.Errorf("unexpected: %s %s", r.Method, r.URL.Path) + }), + }) + b.remnawave = client + + expireAt := time.Now().UTC().Add(-96 * time.Hour) + b.processPaidUser(userID, database.User{TelegramID: userID, RemnawaveUUID: "uuid-322"}, expireAt, time.Now().UTC()) + + assert.False(t, deleteCalled, "confirmed_not_activated не должен приводить к grace kick как будто оплаты не было") +} + // TestSchedulerMaintenanceMode проверяет, что в maintenance mode кики и disable не выполняются func TestSchedulerMaintenanceMode(t *testing.T) { b, db := setupSchedulerTestBot(t) @@ -667,3 +821,120 @@ func TestSchedulerRetryConfirmedNotActivated(t *testing.T) { require.NotNil(t, p.ConfirmedAt) assert.True(t, p.ConfirmedAt.Equal(confirmedAt), "confirmed_at должен сохраниться после retry") } + +func TestSchedulerPassDoesNotPunishConfirmedNotActivatedWhenRetryStillFails(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(140) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(630) + price := 400 + _, err = db.CreateUser(userID, "retry_pass_user", "Retry", "uuid-630", &price, &modID) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + require.NoError(t, db.UpdatePaymentStatus(id, "confirmed_not_activated")) + + expireAt := time.Now().UTC().Add(-2 * time.Hour) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, time.Now().UTC(), id) + require.NoError(t, err) + + var retryPatchCalled bool + var disableCalled bool + var deleteCalled bool + + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/users/uuid-630"): + user := remnawave.User{ + UUID: "uuid-630", + Status: "EXPIRED", + ExpireAt: expireAt, + } + body, _ := json.Marshal(map[string]interface{}{"response": user}) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(body))), + Header: make(http.Header), + }, nil + case r.Method == http.MethodGet && r.URL.Path == "/api/users": + payload := fmt.Sprintf(`{"response":{"users":[{"uuid":"uuid-630","username":"retry_pass_user","status":"EXPIRED","telegramId":630,"expireAt":"%s"}],"total":1}}`, + expireAt.Format(time.RFC3339)) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodPatch && r.URL.Path == "/api/users": + bodyBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + + var req remnawave.UpdateUserRequest + require.NoError(t, json.Unmarshal(bodyBytes, &req)) + require.NotNil(t, req.Status) + + switch *req.Status { + case remnawave.StatusActive: + retryPatchCalled = true + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`{"error":"temporary failure"}`)), + Header: make(http.Header), + }, nil + case remnawave.StatusDisabled: + disableCalled = true + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + default: + return nil, fmt.Errorf("unexpected patch status: %s", *req.Status) + } + case r.Method == http.MethodDelete && r.URL.Path == "/api/users/uuid-630": + deleteCalled = true + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + default: + return nil, fmt.Errorf("unexpected: %s %s", r.Method, r.URL.Path) + } + }), + }) + b.remnawave = client + + b.runSubscriptionSchedulerPass() + + assert.True(t, retryPatchCalled, "scheduler должен попробовать retry активации confirmed_not_activated") + assert.False(t, disableCalled, "после неуспешного retry scheduler не должен disable-ить уже оплатившего пользователя") + assert.False(t, deleteCalled, "после неуспешного retry scheduler не должен кикать уже оплатившего пользователя") + + dbUser, err := db.GetUserByTelegramID(userID) + require.NoError(t, err) + assert.NotNil(t, dbUser, "пользователь должен остаться в БД после scheduler pass") + + storedPayment, err := db.GetPaymentByID(id) + require.NoError(t, err) + require.NotNil(t, storedPayment) + assert.Equal(t, "confirmed_not_activated", storedPayment.Status, "платёж должен остаться в retry-статусе после неуспешной активации") +} diff --git a/internal/database/payments.go b/internal/database/payments.go index 312f39d..581a093 100644 --- a/internal/database/payments.go +++ b/internal/database/payments.go @@ -227,22 +227,25 @@ func (db *DB) GetConfirmedNotActivated() ([]Payment, error) { return payments, rows.Err() } -// HasConfirmedPayment проверяет, была ли у пользователя хотя бы одна подтверждённая оплата +// HasConfirmedPayment проверяет, была ли у пользователя хотя бы одна подтверждённая оплата. +// Для защитных проверок scheduler считаем оплатой и confirmed_not_activated: +// деньги уже подтверждены, пользователя нельзя считать неоплатившим. func (db *DB) HasConfirmedPayment(telegramID int64) (bool, error) { var exists bool err := db.conn.QueryRow( - `SELECT EXISTS(SELECT 1 FROM payments WHERE telegram_id = ? AND status = 'confirmed')`, telegramID, + `SELECT EXISTS(SELECT 1 FROM payments WHERE telegram_id = ? AND status IN ('confirmed', 'confirmed_not_activated'))`, telegramID, ).Scan(&exists) return exists, err } // HasConfirmedPaymentSince проверяет, есть ли подтверждённый платёж после указанной даты. -// Используется scheduler для защиты от ложного кика/disable — если пользователь оплатил -// после expireAt, подписка уже активирована через callback. +// Используется scheduler для защиты от ложного кика/disable. Для этой проверки +// confirmed_not_activated тоже считается оплатой: callback уже подтвердил деньги, +// даже если активация в Remnawave ещё retry-ится. func (db *DB) HasConfirmedPaymentSince(telegramID int64, since time.Time) (bool, error) { var exists bool err := db.conn.QueryRow( - `SELECT EXISTS(SELECT 1 FROM payments WHERE telegram_id = ? AND status = 'confirmed' AND confirmed_at >= ?)`, + `SELECT EXISTS(SELECT 1 FROM payments WHERE telegram_id = ? AND status IN ('confirmed', 'confirmed_not_activated') AND confirmed_at >= ?)`, telegramID, since, ).Scan(&exists) return exists, err diff --git a/internal/database/payments_test.go b/internal/database/payments_test.go index 3d66c87..6d99354 100644 --- a/internal/database/payments_test.go +++ b/internal/database/payments_test.go @@ -252,3 +252,56 @@ func TestHasConfirmedPayment(t *testing.T) { require.NoError(t, err) assert.True(t, has) } + +func TestHasConfirmedPaymentTreatsConfirmedNotActivatedAsPaid(t *testing.T) { + dbFile := "test_payments_has_confirmed_not_activated.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + p := &Payment{ + TelegramID: 12345, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(p) + require.NoError(t, err) + + require.NoError(t, db.ConfirmPayment(id)) + require.NoError(t, db.UpdatePaymentStatus(id, "confirmed_not_activated")) + + has, err := db.HasConfirmedPayment(12345) + require.NoError(t, err) + assert.True(t, has, "confirmed_not_activated должен считаться подтверждённой оплатой для защитных проверок") +} + +func TestHasConfirmedPaymentSinceTreatsConfirmedNotActivatedAsPaid(t *testing.T) { + dbFile := "test_payments_has_since_confirmed_not_activated.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + p := &Payment{ + TelegramID: 12345, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(p) + require.NoError(t, err) + + require.NoError(t, db.ConfirmPayment(id)) + require.NoError(t, db.UpdatePaymentStatus(id, "confirmed_not_activated")) + + since := time.Now().UTC().Add(-1 * time.Hour) + has, err := db.HasConfirmedPaymentSince(12345, since) + require.NoError(t, err) + assert.True(t, has, "confirmed_not_activated должен защищать пользователя в проверках scheduler по времени") +} From 869c4f8f45e99feab8f39ca22e05d92f350de454 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 18:14:06 +0300 Subject: [PATCH 21/34] =?UTF-8?q?fix:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8E=20=D1=81=D1=83=D0=BC=D0=BC=D1=8B=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=B6=D0=B0=20>=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bot/payment.go | 22 ++-- internal/bot/payment_test.go | 194 +++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 10 deletions(-) diff --git a/internal/bot/payment.go b/internal/bot/payment.go index 03342c8..2d8e087 100644 --- a/internal/bot/payment.go +++ b/internal/bot/payment.go @@ -83,6 +83,9 @@ func (h *paymentCallbackHandler) handleConfirmed(payment *database.Payment) erro return fmt.Errorf("confirm payment: %w", err) } + // Финансовый учёт фиксируем в момент подтверждения денег, даже если активация ещё retry-ится. + h.createEarningRecord(payment) + // Пытаемся активировать подписку один раз. // Долгие retry выполняет scheduler, чтобы не держать callback/manual-check path открытым. if err := h.activateSubscription(payment); err != nil { @@ -108,9 +111,6 @@ func (h *paymentCallbackHandler) handleConfirmed(payment *database.Payment) erro } func (h *paymentCallbackHandler) finalizeActivatedPayment(payment *database.Payment) { - // Создаём запись в moderator_earnings (если есть модератор) - h.createEarningRecord(payment) - // Уведомляем пользователя remUser, _ := h.bot.remnawave.GetUserByTelegramID(payment.TelegramID) @@ -258,11 +258,8 @@ func (h *paymentCallbackHandler) createEarningRecord(payment *database.Payment) } moderatorID := *payment.ModeratorID - - // Проверяем, что модератор ещё активен - if !h.bot.isModerator(moderatorID) { - return - } + // payments.moderator_id хранит snapshot куратора на момент создания платежа, + // поэтому финансовая история не должна зависеть от последующего снятия роли. // Считаем количество платящих клиентов для определения доли payingCount, err := h.bot.db.CountPayingSubscribersByModerator(moderatorID) @@ -375,6 +372,11 @@ func (b *Bot) createPaymentForUser(telegramID int64, paymentMethodInt int) (*dat return nil, "", fmt.Errorf("subscription price not set") } + price := *user.SubscriptionPrice + if price <= 0 { + return nil, "", fmt.Errorf("некорректная сумма платежа: %d", price) + } + // Проверка лимита 90 дней: нельзя оплатить, если до конца подписки >= 90 дней remUser, err := b.remnawave.GetUserByTelegramID(telegramID) if err == nil && remUser != nil && remUser.Status == "ACTIVE" && remUser.ExpireAt.Year() < 2099 { @@ -411,7 +413,7 @@ func (b *Bot) createPaymentForUser(telegramID int64, paymentMethodInt int) (*dat resp, err := b.platega.CreatePayment(platega.CreateTransactionRequest{ PaymentMethod: paymentMethodInt, - Amount: *user.SubscriptionPrice, + Amount: price, Currency: "RUB", Description: "VPN подписка на 1 месяц", ReturnURL: fmt.Sprintf("https://t.me/%s", b.bot.Me.Username), @@ -434,7 +436,7 @@ func (b *Bot) createPaymentForUser(telegramID int64, paymentMethodInt int) (*dat payment := &database.Payment{ TelegramID: telegramID, ModeratorID: user.ModeratorID, - Amount: *user.SubscriptionPrice, + Amount: price, PaymentMethod: paymentMethodStr, Status: "pending", PlategaTransactionID: &resp.TransactionID, diff --git a/internal/bot/payment_test.go b/internal/bot/payment_test.go index 2d92379..4bfcac2 100644 --- a/internal/bot/payment_test.go +++ b/internal/bot/payment_test.go @@ -157,6 +157,200 @@ func TestHandleConfirmedReturnsQuicklyWhenActivationFails(t *testing.T) { require.NotNil(t, stored.ConfirmedAt) } +func TestHandleConfirmedCreatesModeratorEarningBeforeActivationRetry(t *testing.T) { + dbFile := "test_payment_confirm_retry_earning.db" + db, err := database.New(dbFile) + require.NoError(t, err) + t.Cleanup(func() { + db.Close() + os.Remove(dbFile) + }) + + adminID := int64(999) + modID := int64(550) + userID := int64(551) + price := 400 + + _, err = db.CreateUser(modID, "moderator", "Moderator", "uuid-550", nil, nil) + require.NoError(t, err) + require.NoError(t, db.AddModerator(modID, adminID)) + _, err = db.CreateUser(userID, "payer", "Payer", "uuid-551", &price, &modID) + require.NoError(t, err) + + txID := "tx-retry-earning" + payment := &database.Payment{ + TelegramID: userID, + ModeratorID: &modID, + Amount: price, + PaymentMethod: "sbp", + Status: "pending", + PlategaTransactionID: &txID, + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + payment.ID = id + + cfg := &config.Config{AdminID: adminID, PlategaFeeSBP: 11, PlategaFeeWithdrawal: 2} + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-551" { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`{"error":"boom"}`)), + Header: make(http.Header), + }, nil + } + return nil, assert.AnError + }), + }) + + b := &Bot{db: db, config: cfg, userStates: newStateMap(), remnawave: client} + handler := &paymentCallbackHandler{bot: b} + + err = handler.handleConfirmed(payment) + require.NoError(t, err) + + stored, err := db.GetPaymentByID(id) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, "confirmed_not_activated", stored.Status) + + var count int + err = db.Conn().QueryRow(`SELECT COUNT(*) FROM moderator_earnings WHERE payment_id = ?`, id).Scan(&count) + require.NoError(t, err) + assert.Equal(t, 1, count) + + var shareAmount int + err = db.Conn().QueryRow(`SELECT share_amount FROM moderator_earnings WHERE payment_id = ?`, id).Scan(&shareAmount) + require.NoError(t, err) + assert.Equal(t, 52, shareAmount) +} + +func TestRetryConfirmedPaymentActivationDoesNotDuplicateEarning(t *testing.T) { + dbFile := "test_payment_retry_duplicate_earning.db" + db, err := database.New(dbFile) + require.NoError(t, err) + t.Cleanup(func() { + db.Close() + os.Remove(dbFile) + }) + + adminID := int64(999) + modID := int64(560) + userID := int64(561) + price := 400 + + _, err = db.CreateUser(modID, "moderator", "Moderator", "uuid-560", nil, nil) + require.NoError(t, err) + require.NoError(t, db.AddModerator(modID, adminID)) + _, err = db.CreateUser(userID, "payer", "Payer", "uuid-561", &price, &modID) + require.NoError(t, err) + + txID := "tx-retry-no-duplicate" + payment := &database.Payment{ + TelegramID: userID, + ModeratorID: &modID, + Amount: price, + PaymentMethod: "sbp", + Status: "pending", + PlategaTransactionID: &txID, + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + payment.ID = id + + var getUserAttempts atomic.Int32 + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-561": + if getUserAttempts.Add(1) == 1 { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`{"error":"boom"}`)), + Header: make(http.Header), + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{"uuid":"uuid-561","status":"EXPIRED","expireAt":"2026-03-01T00:00:00Z"}}`)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodPatch && r.URL.Path == "/api/users": + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodGet && r.URL.Path == "/api/users/by-telegram-id/561": + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{"uuid":"uuid-561","status":"ACTIVE","expireAt":"2026-04-20T00:00:00Z"}}`)), + Header: make(http.Header), + }, nil + default: + return nil, assert.AnError + } + }), + }) + + cfg := &config.Config{AdminID: adminID, PlategaFeeSBP: 11, PlategaFeeWithdrawal: 2} + b := &Bot{db: db, config: cfg, userStates: newStateMap(), remnawave: client} + handler := &paymentCallbackHandler{bot: b} + + err = handler.handleConfirmed(payment) + require.NoError(t, err) + + ok := b.retryConfirmedPaymentActivation(id, "test") + require.True(t, ok) + + var count int + err = db.Conn().QueryRow(`SELECT COUNT(*) FROM moderator_earnings WHERE payment_id = ?`, id).Scan(&count) + require.NoError(t, err) + assert.Equal(t, 1, count) +} + +func TestCreatePaymentForUser_RejectsZeroOrNilPrice(t *testing.T) { + dbFile := "test_payment_zero_price.db" + db, err := database.New(dbFile) + require.NoError(t, err) + t.Cleanup(func() { + db.Close() + os.Remove(dbFile) + }) + + // Пользователь без цены подписки (NULL) + _, err = db.CreateUser(700, "user_no_price", "No Price", "uuid-700", nil, nil) + require.NoError(t, err) + + // Пользователь с нулевой ценой подписки + zeroPrice := 0 + _, err = db.CreateUser(701, "user_zero_price", "Zero Price", "uuid-701", &zeroPrice, nil) + require.NoError(t, err) + + cfg := &config.Config{AdminID: 999} + b := &Bot{ + db: db, + config: cfg, + userStates: newStateMap(), + remnawave: remnawave.NewClient("https://panel.example.com", "test-token", nil), + } + + t.Run("SubscriptionPrice is nil — ошибка", func(t *testing.T) { + _, _, createErr := b.createPaymentForUser(700, 1) + require.Error(t, createErr) + assert.Contains(t, createErr.Error(), "subscription price") + }) + + t.Run("SubscriptionPrice равен 0 — ошибка валидации", func(t *testing.T) { + _, _, createErr := b.createPaymentForUser(701, 1) + require.Error(t, createErr) + assert.Contains(t, createErr.Error(), "некорректная сумма платежа") + }) +} + func TestHandleConfirmedRetriesActivationInBackground(t *testing.T) { dbFile := "test_payment_background_retry.db" db, err := database.New(dbFile) From 65a92368087de95f6be3969beab80d29041808ea Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 18:25:02 +0300 Subject: [PATCH 22/34] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20earnings=20table?= =?UTF-8?q?=20=D0=B2=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B5=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D1=83=20=D0=BA=D0=BE=D0=BD=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bot/admin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/bot/admin.go b/internal/bot/admin.go index 286a660..5a32254 100644 --- a/internal/bot/admin.go +++ b/internal/bot/admin.go @@ -692,7 +692,7 @@ func (b *Bot) handleAdminStats(c tele.Context) error { conversion := 0 if trialsThisMonth > 0 { - conversion = (firstPayments*100 + trialsThisMonth/2) / trialsThisMonth + conversion = firstPayments * 100 / trialsThisMonth } ownerIncome := monthEarnings.TotalNetAmount - monthEarnings.TotalShareAmount From 3ce6c59360b20e1bf2cf9d888b8801bfc8a3b688 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 18:26:31 +0300 Subject: [PATCH 23/34] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D1=81=D1=87=D1=91?= =?UTF-8?q?=D1=82=20grace=20period=20=D0=B2=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B5=20=D0=B0=D0=B4=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bot/admin.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/bot/admin.go b/internal/bot/admin.go index 5a32254..b489106 100644 --- a/internal/bot/admin.go +++ b/internal/bot/admin.go @@ -671,6 +671,9 @@ func (b *Bot) handleAdminStats(c tele.Context) error { graceCount := 0 infiniteCount := 0 + // graceDeadline — граница 72 часа назад для определения grace period + graceDeadline := now.Add(-72 * time.Hour) + for _, user := range dbUsers { remUser, ok := byTelegramID[user.TelegramID] if !ok { @@ -680,7 +683,9 @@ func (b *Bot) handleAdminStats(c tele.Context) error { switch { case remUser.ExpireAt.Year() >= 2099: infiniteCount++ - case remUser.Status == remnawave.StatusDisabled && !remUser.ExpireAt.After(now): + case remUser.Status == remnawave.StatusDisabled && + !remUser.ExpireAt.After(now) && + remUser.ExpireAt.After(graceDeadline): graceCount++ case b.isTrialUser(user.TelegramID): trialCount++ From b9cfc8e74ad853fef7e8247245cffac2440f4017 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 18:36:09 +0300 Subject: [PATCH 24/34] =?UTF-8?q?fix:=20=D1=81=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BD=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D1=83=D1=8E=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D1=83=20=D0=BF=D0=BE=20=D0=B2=D1=81=D0=B5=D0=BC=20confir?= =?UTF-8?q?med-=D0=BF=D0=BB=D0=B0=D1=82=D0=B5=D0=B6=D0=B0=D0=BC=20=D0=B2?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=8F=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bot/admin.go | 29 +++++- internal/bot/admin_test.go | 182 +++++++++++++++++++++++++++++++++++-- 2 files changed, 201 insertions(+), 10 deletions(-) diff --git a/internal/bot/admin.go b/internal/bot/admin.go index b489106..cbb29d3 100644 --- a/internal/bot/admin.go +++ b/internal/bot/admin.go @@ -618,6 +618,18 @@ func strPtr(v string) *string { return &v } +// calculateMonthlyPaymentFinance считает комиссии платежа по текущим конфигурационным ставкам. +// Используется для расчёта финансовой статистики в отчёте админа. +func (b *Bot) calculateMonthlyPaymentFinance(payment database.MonthlyConfirmedPayment) (plategaFee, withdrawalFee, netAmount int) { + feePercent := b.getPlategaFeePercent(payment.PaymentMethod) + grossAmount := payment.Amount + plategaFee = grossAmount * feePercent / 100 + afterPlatega := grossAmount - plategaFee + withdrawalFee = afterPlatega * b.config.PlategaFeeWithdrawal / 100 + netAmount = afterPlatega - withdrawalFee + return +} + // handleAdminStats показывает общую финансовую и пользовательскую статистику за текущий месяц. func (b *Bot) handleAdminStats(c tele.Context) error { if !b.isAdmin(c) { @@ -628,12 +640,25 @@ func (b *Bot) handleAdminStats(c tele.Context) error { year := now.Year() month := int(now.Month()) - monthEarnings, err := b.db.GetAllEarningsByMonth(year, month) + // Источник финансовой статистики — все подтверждённые платежи месяца. + // Платежи без earnings (админские) включаются с share_amount = 0. + confirmedPayments, err := b.db.GetConfirmedPaymentsByMonth(year, month) if err != nil { - slog.Error("Failed to load monthly earnings for admin stats", "error", err) + slog.Error("Failed to load monthly confirmed payments for admin stats", "error", err) return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) } + monthEarnings := &database.MonthlyEarnings{} + for _, payment := range confirmedPayments { + plategaFee, withdrawalFee, netAmount := b.calculateMonthlyPaymentFinance(payment) + monthEarnings.TotalPayments++ + monthEarnings.GrossAmount += payment.Amount + monthEarnings.TotalPlategaFee += plategaFee + monthEarnings.TotalWithdrawal += withdrawalFee + monthEarnings.TotalNetAmount += netAmount + monthEarnings.TotalShareAmount += payment.ShareAmount + } + trialsThisMonth, err := b.db.CountTrialsByMonth(year, month) if err != nil { slog.Error("Failed to count trials for admin stats", "error", err) diff --git a/internal/bot/admin_test.go b/internal/bot/admin_test.go index d3dca81..85a1802 100644 --- a/internal/bot/admin_test.go +++ b/internal/bot/admin_test.go @@ -135,7 +135,7 @@ func TestProcessBanUser_PersistsBanAndKeepsInviteHistory(t *testing.T) { b := &Bot{ db: db, remnawave: client, - config: &config.Config{AdminID: adminID}, + config: &config.Config{AdminID: adminID, PlategaFeeCard: 10, PlategaFeeWithdrawal: 2}, userStates: newStateMap(), } @@ -214,8 +214,9 @@ func TestHandleAdminModStats(t *testing.T) { prevMonth := time.Now().UTC().AddDate(0, -1, 0) _, err = rawDB.Exec( - `UPDATE moderator_earnings SET created_at = ?`, + `UPDATE payments SET confirmed_at = ? WHERE id = ?`, time.Date(prevMonth.Year(), prevMonth.Month(), 15, 12, 0, 0, 0, time.UTC), + paymentID, ) require.NoError(t, err) @@ -250,7 +251,7 @@ func TestHandleAdminModStats(t *testing.T) { b := &Bot{ db: db, remnawave: client, - config: &config.Config{AdminID: adminID}, + config: &config.Config{AdminID: adminID, PlategaFeeCard: 10, PlategaFeeWithdrawal: 2}, userStates: newStateMap(), } @@ -415,7 +416,7 @@ func TestProcessSwitchSubscription_ConfirmFlow(t *testing.T) { b := &Bot{ db: db, remnawave: client, - config: &config.Config{AdminID: adminID}, + config: &config.Config{AdminID: adminID, PlategaFeeCard: 10, PlategaFeeWithdrawal: 2}, userStates: newStateMap(), } @@ -573,7 +574,7 @@ func TestHandleAdminStats_ShowsFinanceAndConversion(t *testing.T) { b := &Bot{ db: db, remnawave: client, - config: &config.Config{AdminID: adminID}, + config: &config.Config{AdminID: adminID, PlategaFeeCard: 10, PlategaFeeWithdrawal: 2}, userStates: newStateMap(), } @@ -586,11 +587,13 @@ func TestHandleAdminStats_ShowsFinanceAndConversion(t *testing.T) { assert.Contains(t, msg, "Общая статистика") assert.Contains(t, msg, "Платежей за месяц: 1") assert.Contains(t, msg, "Сумма платежей (грязная): 500 руб") + // Комиссии считаются через calculateMonthlyPaymentFinance (целочисленное деление): + // 500 card(10%): platega=50, afterPlatega=450, withdrawal=450*2/100=9, net=441, share=66 (из earnings), owner=375 assert.Contains(t, msg, "Комиссии Platega: -50 руб") - assert.Contains(t, msg, "Комиссия вывода (2%): -10 руб") - assert.Contains(t, msg, "Чистый доход: 440 руб") + assert.Contains(t, msg, "Комиссия вывода (2%): -9 руб") + assert.Contains(t, msg, "Чистый доход: 441 руб") assert.Contains(t, msg, "Выплаты модераторам: -66 руб") - assert.Contains(t, msg, "Доход владельца: 374 руб") + assert.Contains(t, msg, "Доход владельца: 375 руб") assert.Contains(t, msg, "Всего в системе: 4") assert.Contains(t, msg, "💳 Платящих: 1") assert.Contains(t, msg, "⏳ Триал: 1") @@ -599,6 +602,169 @@ func TestHandleAdminStats_ShowsFinanceAndConversion(t *testing.T) { assert.Contains(t, msg, "Конверсия триал → оплата: 33%") } +func TestHandleAdminStats_IncludesAdminPaymentsAndModeratorPayouts(t *testing.T) { + dbFile := "test_admin_stats_regression.db" + db, err := database.New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + adminID := int64(999999) + modID := int64(100) + moderatorPaymentUserID := int64(200) + adminPaymentUserID := int64(201) + previousMonthPaymentUserID := int64(202) + notActivatedPaymentUserID := int64(203) + + _, err = db.CreateUser(modID, "moderator", "Модератор", "uuid-mod", nil, nil) + require.NoError(t, err) + require.NoError(t, db.AddModerator(modID, adminID)) + + _, err = db.CreateUser(moderatorPaymentUserID, "paid-mod", "Paid Mod", "uuid-paid-mod", nil, &modID) + require.NoError(t, err) + _, err = db.CreateUser(adminPaymentUserID, "paid-admin", "Paid Admin", "uuid-paid-admin", nil, nil) + require.NoError(t, err) + _, err = db.CreateUser(previousMonthPaymentUserID, "paid-prev", "Paid Prev", "uuid-paid-prev", nil, &modID) + require.NoError(t, err) + _, err = db.CreateUser(notActivatedPaymentUserID, "paid-pending", "Paid Pending", "uuid-paid-pending", nil, &modID) + require.NoError(t, err) + + moderatorPaymentID, err := db.CreatePayment(&database.Payment{ + TelegramID: moderatorPaymentUserID, + ModeratorID: &modID, + Amount: 500, + PaymentMethod: "card", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(moderatorPaymentID)) + + _, err = db.CreateEarning(&database.ModeratorEarning{ + PaymentID: moderatorPaymentID, + ModeratorID: modID, + GrossAmount: 500, + PlategaFee: 60, + WithdrawalFee: 8, + NetAmount: 432, + SharePercent: 15, + ShareAmount: 66, + }) + require.NoError(t, err) + + adminPaymentID, err := db.CreatePayment(&database.Payment{ + TelegramID: adminPaymentUserID, + Amount: 1000, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(adminPaymentID)) + + previousMonthPaymentID, err := db.CreatePayment(&database.Payment{ + TelegramID: previousMonthPaymentUserID, + ModeratorID: &modID, + Amount: 700, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(previousMonthPaymentID)) + + notActivatedPaymentID, err := db.CreatePayment(&database.Payment{ + TelegramID: notActivatedPaymentUserID, + ModeratorID: &modID, + Amount: 800, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(notActivatedPaymentID)) + + now := time.Now().UTC() + previousMonth := time.Date(now.Year(), now.Month(), 1, 12, 0, 0, 0, time.UTC).AddDate(0, -1, 0) + currentMonthConfirmedAt := time.Date(now.Year(), now.Month(), 10, 12, 0, 0, 0, time.UTC) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, currentMonthConfirmedAt, moderatorPaymentID) + require.NoError(t, err) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, currentMonthConfirmedAt, adminPaymentID) + require.NoError(t, err) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, previousMonth, previousMonthPaymentID) + require.NoError(t, err) + _, err = db.Conn().Exec(`UPDATE payments SET status = 'confirmed_not_activated', confirmed_at = ? WHERE id = ?`, currentMonthConfirmedAt, notActivatedPaymentID) + require.NoError(t, err) + + _, err = db.CreateEarning(&database.ModeratorEarning{ + PaymentID: previousMonthPaymentID, + ModeratorID: modID, + GrossAmount: 700, + PlategaFee: 70, + WithdrawalFee: 12, + NetAmount: 618, + SharePercent: 15, + ShareAmount: 92, + }) + require.NoError(t, err) + _, err = db.CreateEarning(&database.ModeratorEarning{ + PaymentID: notActivatedPaymentID, + ModeratorID: modID, + GrossAmount: 800, + PlategaFee: 80, + WithdrawalFee: 14, + NetAmount: 706, + SharePercent: 15, + ShareAmount: 105, + }) + require.NoError(t, err) + + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/api/users" && r.URL.RawQuery == "size=1000" { + payload := `{"response":{"users":[],"total":0}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + } + return nil, assert.AnError + }), + }) + + b := &Bot{ + db: db, + remnawave: client, + config: &config.Config{AdminID: adminID, PlategaFeeSBP: 10, PlategaFeeCard: 12, PlategaFeeWithdrawal: 2}, + userStates: newStateMap(), + } + + ctx := &MockContext{ + sender: &tele.User{ID: adminID, Username: "admin"}, + message: &tele.Message{}, + } + + err = b.handleAdminStats(ctx) + require.NoError(t, err) + + msg, ok := ctx.sentMsg.(string) + require.True(t, ok) + // Финансовая статистика считается по всем confirmed-платежам месяца через GetConfirmedPaymentsByMonth. + // previousMonthPaymentID подтверждён в прошлом месяце — не входит. + // В текущем месяце: moderatorPayment (500 card) + adminPayment (1000 sbp) + notActivatedPayment (800 sbp) = 3 платежа. + // moderatorPayment (500, card 12%): platega=60, withdrawal=8, net=432, share=66 + // adminPayment (1000, sbp 10%): platega=100, withdrawal=18, net=882, share=0 (нет earnings) + // notActivatedPayment (800, sbp 10%): platega=80, withdrawal=14, net=706, share=105 + // Итого: gross=2300, platega=240, withdrawal=40, net=2020, share=171, owner=1849 + assert.Contains(t, msg, "Платежей за месяц: 3") + assert.Contains(t, msg, "Сумма платежей (грязная): 2300 руб") + assert.Contains(t, msg, "Комиссии Platega: -240 руб") + assert.Contains(t, msg, "Комиссия вывода (2%): -40 руб") + assert.Contains(t, msg, "Чистый доход: 2020 руб") + assert.Contains(t, msg, "Выплаты модераторам: -171 руб") + assert.Contains(t, msg, "Доход владельца: 1849 руб") +} + func TestProcessAdminUserInfo_ShowsFullCard(t *testing.T) { dbFile := "test_admin_user_info.db" db, err := database.New(dbFile) From 5490529e4ec5554027a984d52a57676290edf26e Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 18:36:49 +0300 Subject: [PATCH 25/34] =?UTF-8?q?docs:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=81=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BD=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...3-23-admin-financial-stats-fix-progress.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/progress/2026-03-23-admin-financial-stats-fix-progress.md diff --git a/docs/progress/2026-03-23-admin-financial-stats-fix-progress.md b/docs/progress/2026-03-23-admin-financial-stats-fix-progress.md new file mode 100644 index 0000000..9a6d963 --- /dev/null +++ b/docs/progress/2026-03-23-admin-financial-stats-fix-progress.md @@ -0,0 +1,79 @@ +# Исправление финансовой отчётности админа и модераторов + +**Дата:** 2026-03-23 +**План:** [2026-03-23-admin-financial-stats-fix-plan.md](../plans/2026-03-23-admin-financial-stats-fix-plan.md) +**Коммиты:** +- `869c4f8` — fix: добавить валидацию суммы платежа > 0 +- `65a9236` — fix: использовать earnings table в статистике админа и исправить формулу конверсии +- `3ce6c59` — fix: исправить подсчёт grace period в статистике админа +- `b9cfc8e` — fix: считать финансовую статистику по всем confirmed-платежам включая admin + +## Что сделано + +### `internal/database/earnings.go` +- Месячные выборки начислений модераторов переведены с `moderator_earnings.created_at` на `payments.confirmed_at` +- Актуальный `share_percent` теперь выбирается по дате подтверждения платежа +- `GetAllEarningsByMonth` считает начисления по историческому факту подтверждения, не переписывая прошлые месяцы из-за последующего `chargebacked` +- `CreateEarning` стал идемпотентным на уровне SQL-вставки по `payment_id` + +### `internal/database/payments.go` +- Добавлен `MonthlyConfirmedPayment` +- Добавлен `GetConfirmedPaymentsByMonth(year, month)`: + - берёт финансово подтверждённые платежи со статусами `confirmed` и `confirmed_not_activated` + - режет период по `payments.confirmed_at` + - не теряет админские платежи без `moderator_earnings` + - возвращает `share_amount = 0`, если выплаты модератору нет +- `CountFirstPaymentsByMonth` переведён на первую финансово подтверждённую оплату по `confirmed_at`, чтобы конверсия не расходилась с финансовым блоком + +### `internal/bot/admin.go` +- Финансовый блок `handleAdminStats` больше не строится из `moderator_earnings` как единственного источника +- Общая сводка теперь считает: + - количество платежей + - грязную сумму + - комиссии Platega + - комиссию вывода + - чистый доход + - выплаты модераторам + - доход владельца + по confirmed-платежам месяца +- Формула комиссий переиспользует ту же конфигурацию и ту же бизнес-логику, что и callback платежей + +### `internal/bot/payment.go` +- Убрана зависимость создания `moderator_earnings` от текущего статуса роли модератора +- Начисление модератору теперь фиксируется в момент подтверждения денег, даже если активация подписки уходит в retry +- Финансовая история теперь опирается на snapshot `payments.moderator_id`, зафиксированный при создании платежа + +## Какие сценарии закрыты + +- Админские платежи теперь попадают в общую месячную финансовую статистику +- Платежи пользователей бывшего модератора не теряют historical snapshot начисления +- Платёж, подтверждённый в конце месяца и доактивированный позже, относится к месяцу `payments.confirmed_at`, а не к месяцу фактической вставки earnings +- Платёж со статусом `confirmed_not_activated` больше не выпадает из финансовой отчётности, если деньги уже подтверждены +- Поздний `chargebacked` не стирает платёж из уже закрытого отчётного месяца + +## Тесты + +### Добавлены / обновлены + +- `internal/bot/admin_test.go` + - регрессия на админские платежи и выплаты модераторам + - обновлены ожидания финансового блока + - исправлен setup статистики модераторов под новую семантику месяца +- `internal/bot/payment_handler_test.go` + - регрессия на сохранение snapshot модератора после снятия роли +- `internal/bot/payment_test.go` + - начисление модератору создаётся до успешной активации + - retry активации не создаёт дубликат earnings +- `internal/database/earnings_test.go` + - регрессия на месяц по `confirmed_at` + - проверка выбора актуального `share_percent` по дате подтверждения + - историческое сохранение `chargebacked` в месяце подтверждения +- `internal/database/payments_test.go` + - выборка финансово подтверждённых платежей месяца с учётом admin/moderator/previous-month/confirmed_not_activated/chargebacked сценариев + +## Проверка + +- `GOCACHE=/tmp/go-build go test ./internal/database/... -count=1` +- `GOCACHE=/tmp/go-build go test ./internal/bot/... -count=1` +- `make fmt` +- `make tests` From 618dd2f930501f317c9af6e1640195b1086d6dd6 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 19:39:24 +0300 Subject: [PATCH 26/34] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20remediation-=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BB=D0=B0=D1=82=D0=B5=D0=B6=D0=B5?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-03-23-admin-financial-stats-fix-plan.md | 269 ++++++++++++ .../plans/2026-03-23-payment-bugs-fix-plan.md | 362 ++++++++++++++++ ...-23-payment-validation-remediation-plan.md | 396 ++++++++++++++++++ ...payment-validation-remediation-progress.md | 78 ++++ internal/bot/admin.go | 18 +- internal/bot/admin_test.go | 58 +++ internal/bot/handlers.go | 4 + internal/bot/keyboards.go | 2 +- internal/bot/messages.go | 1 + internal/bot/messages_test.go | 1 + internal/bot/moderator.go | 20 + internal/bot/moderator_test.go | 64 +++ internal/bot/payment.go | 104 ++++- internal/bot/payment_handler.go | 2 +- internal/bot/payment_handler_test.go | 176 ++++++++ internal/bot/payment_test.go | 230 ++++++++++ internal/bot/scheduler.go | 12 +- internal/bot/scheduler_test.go | 104 +++++ internal/database/earnings.go | 30 +- internal/database/earnings_test.go | 219 ++++++++++ internal/database/payments.go | 79 +++- internal/database/payments_test.go | 216 ++++++++++ 22 files changed, 2410 insertions(+), 35 deletions(-) create mode 100644 docs/plans/2026-03-23-admin-financial-stats-fix-plan.md create mode 100644 docs/plans/2026-03-23-payment-bugs-fix-plan.md create mode 100644 docs/plans/2026-03-23-payment-validation-remediation-plan.md create mode 100644 docs/progress/2026-03-23-payment-validation-remediation-progress.md diff --git a/docs/plans/2026-03-23-admin-financial-stats-fix-plan.md b/docs/plans/2026-03-23-admin-financial-stats-fix-plan.md new file mode 100644 index 0000000..d7e08f2 --- /dev/null +++ b/docs/plans/2026-03-23-admin-financial-stats-fix-plan.md @@ -0,0 +1,269 @@ +# Исправление финансовой отчётности админа и модераторов — план реализации + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Сделать месячную финансовую отчётность корректной: админская сводка должна учитывать все подтверждённые платежи месяца, а модераторские начисления и месячные периоды должны определяться по дате подтверждения платежа, а не по времени поздней доактивации. + +**Architecture:** Источником истины для общей финансовой статистики становится таблица `payments` с фильтром по `confirmed_at` и статусу `confirmed`. Таблица `moderator_earnings` остаётся снимком доли модератора на момент платежа, но месячные выборки для неё тоже должны фильтроваться через связанный платёж. Начисление модератору должно опираться на snapshot `payments.moderator_id`, а не на текущий статус роли модератора. + +**Tech Stack:** Go 1.25, SQLite, telebot.v3, testify + +--- + +### Task 1: Зафиксировать регрессии в тестах до изменения логики + +**Files:** +- Modify: `internal/bot/admin_test.go` +- Modify: `internal/database/earnings_test.go` +- Modify: `internal/bot/payment_handler_test.go` + +**Step 1: Добавить падающий тест на админскую сводку с админским платежом** + +В `internal/bot/admin_test.go` добавить сценарий: + +- есть один платёж клиента модератора; +- есть один платёж клиента админа (`moderator_id = NULL`); +- оба платежа подтверждены в текущем месяце; +- `handleAdminStats` должен показать: + - `Платежей за месяц: 2` + - суммарную грязную выручку по двум платежам; + - выплаты модераторам только по платежу модератора; + - доход владельца как `чистый доход - выплаты модераторам`. + +Тест должен падать на текущей реализации, потому что админский платёж не попадает в финансовый блок. + +**Step 2: Добавить падающий тест на “поздно доактивированный” платёж** + +В `internal/database/earnings_test.go` добавить тест, который моделирует: + +- платёж подтверждён 31 марта (`payments.confirmed_at = 2026-03-31 ...`); +- запись в `moderator_earnings` создана 1 апреля (`moderator_earnings.created_at = 2026-04-01 ...`); +- запрос `GetModeratorEarningsByMonth(..., 2026, 3)` должен вернуть этот платёж в март; +- запрос `GetModeratorEarningsByMonth(..., 2026, 4)` не должен считать его апрельским. + +Тест должен падать на текущей реализации, потому что сейчас период режется по `moderator_earnings.created_at`. + +**Step 3: Добавить падающий тест на снятого модератора** + +В `internal/bot/payment_handler_test.go` или рядом с уже существующими тестами callback-потока добавить сценарий: + +- у пользователя уже создан `pending` платёж с `payment.moderator_id = oldModeratorID`; +- до подтверждения модератора снимают; +- callback подтверждает платёж и активация проходит успешно; +- в `moderator_earnings` должна появиться запись с `moderator_id = oldModeratorID`. + +Ожидание: тест падает на текущей реализации из-за проверки текущей роли модератора. + +**Step 4: Запустить только новые регрессионные тесты** + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestHandleAdminStats.*|TestPaymentCallback.*RemovedModerator.*' -v +GOCACHE=/tmp/go-build go test ./internal/database/ -run 'TestGetModeratorEarningsByMonth.*ConfirmedAt.*' -v +``` + +Expected: `FAIL` на текущей логике. + +**Suggested commit name:** `fix: зафиксировать регрессии финансовой отчётности` + +--- + +### Task 2: Перевести месячные выборки начислений на период подтверждения платежа + +**Files:** +- Modify: `internal/database/earnings.go` +- Modify: `internal/database/earnings_test.go` + +**Step 1: Обновить `GetModeratorEarningsByMonth`** + +Изменить SQL так, чтобы метод агрегировал `moderator_earnings` через `JOIN payments ON payments.id = moderator_earnings.payment_id` и фильтровал месяц по: + +```sql +payments.status = 'confirmed' +AND payments.confirmed_at >= ? +AND payments.confirmed_at < ? +``` + +Суммы по-прежнему брать из `moderator_earnings`, но календарный период определять только по `payments.confirmed_at`. + +**Step 2: Обновить получение актуального `share_percent`** + +Если текущий helper всё ещё нужен, выбирать последний процент через связанный платёж, чтобы порядок был привязан к дате подтверждения, а не к времени позднего retry-вставления earnings. + +**Step 3: Удалить или переопределить `GetAllEarningsByMonth`** + +Если метод остаётся в кодовой базе, он должен либо: + +- стать private/internal helper только для выплат модераторам с фильтром по `payments.confirmed_at`, либо +- быть заменён на более честное имя, например `GetModeratorPayoutsByMonth`. + +Цель: убрать ложное ощущение, что это “общая” финансовая статистика бизнеса. + +**Step 4: Перезапустить только DB-тесты earnings** + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/database/ -run 'TestGetModeratorEarningsByMonth' -v +``` + +Expected: `PASS`, март/апрель определяются по `confirmed_at`. + +**Suggested commit name:** `fix: считать месячные начисления по confirmed_at` + +--- + +### Task 3: Вынести общую финансовую статистику бизнеса на confirmed-платежи + +**Files:** +- Modify: `internal/database/payments.go` +- Modify: `internal/database/payments_test.go` +- Modify: `internal/bot/admin.go` +- Modify: `internal/bot/admin_test.go` + +**Step 1: Добавить helper для списка или агрегата подтверждённых платежей месяца** + +В `internal/database/payments.go` добавить новый метод с прозрачной семантикой, например: + +- `ListConfirmedPaymentsByMonth(year, month int) ([]Payment, error)`, если расчёт комиссий удобнее делать в Go; +- или `ListConfirmedPaymentsWithModeratorShareByMonth(...)`, если нужен `LEFT JOIN moderator_earnings` по `payment_id`. + +Требования к helper: + +- фильтр только по `payments.status = 'confirmed'`; +- период только по `payments.confirmed_at`; +- платежи без `moderator_earnings` не теряются; +- `share_amount` для таких платежей трактуется как `0`. + +**Step 2: Написать unit-тест на helper** + +В `internal/database/payments_test.go` добавить тест, который создаёт: + +- подтверждённый админский платёж без earnings; +- подтверждённый модераторский платёж с earnings; +- подтверждённый платёж прошлого месяца; +- `confirmed_not_activated` платёж этого месяца. + +Ожидание: + +- в результат попадают только два `confirmed`-платежа целевого месяца; +- share по админскому платежу равен нулю; +- `confirmed_not_activated` не включается в финансовую сводку. + +**Step 3: Переписать `handleAdminStats`** + +В `internal/bot/admin.go` заменить использование “общих earnings” на расчёт по подтверждённым платежам месяца: + +- количество платежей = число `payments.status = 'confirmed'` за месяц; +- грязная сумма = сумма `payments.amount`; +- комиссия Platega и комиссия вывода считаются по каждому платежу через текущие конфиги и `payment_method`; +- чистый доход = сумма по всем платежам после комиссий; +- выплаты модераторам = сумма `share_amount` из earnings, привязанных к этим платежам; +- доход владельца = `чистый доход - выплаты модераторам`. + +Текущие helper-методы `getPlategaFeePercent` и конфиг `PlategaFeeWithdrawal` должны использоваться как единый источник формулы, чтобы не появилось расхождения между callback и отчётом. + +**Step 4: Перезапустить только тесты admin stats** + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestHandleAdminStats' -v +``` + +Expected: `PASS`, включая сценарий с админским клиентом. + +**Suggested commit name:** `fix: считать общую финансовую статистику по payments` + +--- + +### Task 4: Сохранить snapshot модератора при подтверждении платежа + +**Files:** +- Modify: `internal/bot/payment.go` +- Modify: `internal/bot/payment_handler_test.go` +- Check: `docs/plans/2026-03-21-payment-business-model-redesign.md` + +**Step 1: Упростить правило создания earnings** + +В `createEarningRecord` оставить только бизнес-условие: + +- если `payment.ModeratorID == nil`, earnings не создаётся; +- если `payment.ModeratorID != nil`, earnings создаётся всегда. + +Проверку “модератор ещё активен” удалить, потому что она ломает snapshot-историю уже созданного платежа. + +**Step 2: Зафиксировать допущение в комментарии** + +Рядом с логикой создания earnings добавить короткий комментарий на русском: + +- `payments.moderator_id` — это snapshot куратора на момент создания платежа; +- финансовая история не должна зависеть от последующего снятия роли. + +Комментарий должен объяснять, почему бывший модератор всё ещё может фигурировать в historical earnings конкретного платежа. + +**Step 3: Перезапустить callback/payment-тесты** + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestPaymentCallback|TestCheckPaymentStatus' -v +``` + +Expected: `PASS`, earnings для снятого модератора создаются, админский платёж по-прежнему не получает moderator share. + +**Suggested commit name:** `fix: сохранять snapshot модератора в earnings` + +--- + +### Task 5: Полная верификация и документация выполнения + +**Files:** +- Create: `docs/progress/2026-03-23-admin-financial-stats-fix-progress.md` +- Modify: `docs/progress/PROGRESS.md` (если в проекте принято индексировать выполненные работы) +- Check: `README.md` только если в процессе меняются пользовательские правила отчётности + +**Step 1: Описать факт выполнения плана** + +В новом progress-файле указать: + +- ссылку на этот план; +- какие сценарии были закрыты: + - админские платежи; + - платежи бывших модераторов; + - корректный месяц по `confirmed_at`; +- какие тесты добавлены; +- какие команды верификации были выполнены. + +**Step 2: Прогнать точечные тесты пакетов** + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/database/... -count=1 +GOCACHE=/tmp/go-build go test ./internal/bot/... -count=1 +``` + +Expected: `PASS`. + +**Step 3: Выполнить обязательную проектную верификацию** + +Run: + +```bash +make fmt +make tests +``` + +Expected: обе команды завершаются успешно. Нельзя считать задачу закрытой, если хотя бы одна из них падает. + +**Step 4: Подготовить итоговое описание** + +В финальном отчёте явно указать: + +- что теперь общая финансовая статистика считает все подтверждённые платежи месяца; +- что отчётный период определяется по `payments.confirmed_at`; +- что `moderator_earnings` больше не теряет snapshot платежа после снятия модератора. + +**Suggested commit name:** `docs: задокументировать исправление финансовой отчётности` diff --git a/docs/plans/2026-03-23-payment-bugs-fix-plan.md b/docs/plans/2026-03-23-payment-bugs-fix-plan.md new file mode 100644 index 0000000..3214cdb --- /dev/null +++ b/docs/plans/2026-03-23-payment-bugs-fix-plan.md @@ -0,0 +1,362 @@ +# Payment Bugs Fix Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Устранить 4 найденных проблемы в реализации платёжной системы. + +**Architecture:** Все правки изолированы — каждый таск независим. Таск 1 меняет `admin.go` (финансовая статистика). Таск 2 меняет `admin.go` (формула конверсии). Таск 3 и 4 — мелкие защитные правки в `payment.go` и `payment_handler.go`. + +**Tech Stack:** Go, SQLite, telebot.v3, testify + +--- + +## Проблемы для устранения + +| # | Проблема | Критичность | +|---|----------|-------------| +| 1 | `handleAdminStats` пересчитывает финансы из payments вместо earnings | Средняя | +| 2 | Формула конверсии `(firstPayments*100 + trialsThisMonth/2) / trialsThisMonth` — неверная | Средняя | +| 3 | Нет валидации `amount > 0` перед созданием платежа | Низкая | +| 4 | graceCount считает `!remUser.ExpireAt.After(now)` — это считает истёкших, а не grace | Низкая | + +--- + +## Task 1: Заменить пересчёт финансов в handleAdminStats на GetAllEarningsByMonth + +**Проблема:** `handleAdminStats` вручную суммирует комиссии по каждому payment, применяя **текущие** ставки комиссий. Но при изменении конфига `PLATEGA_FEE_*` статистика за прошлые месяцы будет врать. Правильный источник — таблица `moderator_earnings`, где всё уже посчитано в момент транзакции. + +**Что менять:** `internal/bot/admin.go`, функция `handleAdminStats`. + +**Текущий код (строки 645–660):** +```go +confirmedPayments, err := b.db.GetConfirmedPaymentsByMonth(year, month) +// ... +monthEarnings := &database.MonthlyEarnings{} +for _, payment := range confirmedPayments { + plategaFee, withdrawalFee, netAmount := b.calculateMonthlyPaymentFinance(payment) + monthEarnings.TotalPayments++ + monthEarnings.GrossAmount += payment.Amount + monthEarnings.TotalPlategaFee += plategaFee + monthEarnings.TotalWithdrawal += withdrawalFee + monthEarnings.TotalNetAmount += netAmount + monthEarnings.TotalShareAmount += payment.ShareAmount +} +``` + +**Правильный код:** +```go +monthEarnings, err := b.db.GetAllEarningsByMonth(year, month) +if err != nil { + slog.Error("Failed to load monthly earnings for admin stats", "error", err) + return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) +} +``` + +**Замечание:** После этой замены переменная `confirmedPayments` больше не нужна. Также функция `calculateMonthlyPaymentFinance` становится неиспользуемой — удалить её вместе с вызовом. + +**Тесты:** В `internal/bot/admin_test.go` найти или добавить тест `TestHandleAdminStats`. Проверить что: +1. Вызывается `GetAllEarningsByMonth`, а не `GetConfirmedPaymentsByMonth` (через mock или проверку результата) +2. Итог отображает правильные суммы из earnings + +**Файлы:** +- Modify: `internal/bot/admin.go:636-660` +- Test: `internal/bot/admin_test.go` + +### Шаги + +**Шаг 1: Написать тест (TDD)** + +В `internal/bot/admin_test.go` добавить тест, который создаёт платёж + earning и проверяет, что статистика берётся из earnings: + +```go +func TestHandleAdminStatsUsesEarningsTable(t *testing.T) { + b, db := newTestBot(t) + // создаём пользователя + telegramID := int64(1001) + _ = db.CreateUser(&database.User{TelegramID: telegramID, RemnawaveUUID: "uuid-1"}) + + // создаём payment + paymentID, _ := db.CreatePayment(&database.Payment{ + TelegramID: telegramID, + Amount: 1000, + PaymentMethod: "sbp", + Status: "confirmed", + }) + now := time.Now().UTC() + db.ConfirmPayment(paymentID, "tx-1") + + // создаём earning с заранее известными суммами + _, _ = db.CreateEarning(&database.ModeratorEarning{ + PaymentID: paymentID, + ModeratorID: 999, + GrossAmount: 1000, + PlategaFee: 110, + WithdrawalFee: 18, + NetAmount: 872, + SharePercent: 15, + ShareAmount: 130, + }) + + // вызываем handleAdminStats + resp := callAdminStats(b, t) // хелпер из testutil + + // проверяем что в ответе фигурируют правильные суммы из earnings + assert.Contains(t, resp, "1000") // GrossAmount + assert.Contains(t, resp, "110") // PlategaFee + assert.Contains(t, resp, "872") // NetAmount + assert.Contains(t, resp, "130") // ShareAmount + _ = now +} +``` + +Запустить: `make tests` — тест должен **падать** (если функциональность ещё не исправлена). + +**Шаг 2: Заменить реализацию** + +В `internal/bot/admin.go` строки 645–660 заменить пересчёт на: +```go +monthEarnings, err := b.db.GetAllEarningsByMonth(year, month) +if err != nil { + slog.Error("Failed to load monthly earnings for admin stats", "error", err) + return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) +} +``` + +Удалить больше неиспользуемые: +- Переменную `confirmedPayments` и весь цикл (строки 645–660) +- Импорт, который перестал использоваться (если есть) +- Функцию `calculateMonthlyPaymentFinance` (строки 621–633) — она больше нигде не нужна + +**Шаг 3: Проверить что компилируется и тесты проходят** + +```bash +make fmt +make tests +``` + +**Шаг 4: Коммит** + +```bash +git add internal/bot/admin.go internal/bot/admin_test.go +git commit -m "fix: использовать earnings table в статистике админа вместо пересчёта" +``` + +--- + +## Task 2: Исправить формулу конверсии + +**Проблема:** Текущая формула: +```go +conversion = (firstPayments*100 + trialsThisMonth/2) / trialsThisMonth +``` +Это «округление через добавление половины знаменателя» — но по сути дела неправильная метрика. По плану конверсия = `firstPayments * 100 / trialsThisMonth`. + +**Что менять:** `internal/bot/admin.go`, строка 720. + +**Правильный код:** +```go +conversion = firstPayments * 100 / trialsThisMonth +``` + +**Файлы:** +- Modify: `internal/bot/admin.go:718-721` +- Test: `internal/bot/admin_test.go` + +### Шаги + +**Шаг 1: Написать тест** + +В `internal/bot/admin_test.go`: +```go +func TestConversionCalculation(t *testing.T) { + // 3 триала, 1 первая оплата → конверсия 33% + trials := 3 + first := 1 + result := first * 100 / trials + assert.Equal(t, 33, result) + + // 10 триалов, 5 оплат → 50% + assert.Equal(t, 50, 5*100/10) + + // 0 триалов → деление не происходит (защита в коде) +} +``` + +Если в admin.go логика конверсии не выделена в функцию, достаточно просто убедиться что правило верное и тест проходит после правки. + +**Шаг 2: Исправить формулу** + +В `internal/bot/admin.go` строка 720: +```go +// Было: +conversion = (firstPayments*100 + trialsThisMonth/2) / trialsThisMonth +// Стало: +conversion = firstPayments * 100 / trialsThisMonth +``` + +**Шаг 3: Проверить** + +```bash +make fmt +make tests +``` + +**Шаг 4: Коммит** + +```bash +git add internal/bot/admin.go internal/bot/admin_test.go +git commit -m "fix: исправить формулу конверсии триал → оплата" +``` + +--- + +## Task 3: Добавить валидацию amount > 0 при создании платежа + +**Проблема:** `createPaymentForUser` не проверяет что `amount > 0` перед отправкой в Platega API. + +**Где это:** `internal/bot/payment.go`, функция `createPaymentForUser`. + +**Найти функцию:** +```bash +grep -n "createPaymentForUser\|func.*createPayment" internal/bot/payment.go +``` + +**Добавить валидацию в начало функции:** +```go +if price <= 0 { + return fmt.Errorf("некорректная сумма платежа: %d", price) +} +``` + +**Файлы:** +- Modify: `internal/bot/payment.go` +- Test: `internal/bot/payment_test.go` + +### Шаги + +**Шаг 1: Найти точку вставки** + +```bash +grep -n "createPaymentForUser" internal/bot/payment.go +``` + +Прочитать функцию и определить где берётся `price`/`amount`. + +**Шаг 2: Написать тест** + +В `internal/bot/payment_test.go`: +```go +func TestCreatePaymentForUserRejectsZeroAmount(t *testing.T) { + b, db := newTestBot(t) + telegramID := int64(2001) + _ = db.CreateUser(&database.User{ + TelegramID: telegramID, + RemnawaveUUID: "uuid-zero", + SubscriptionPrice: nil, // нет цены → должна вернуть ошибку + }) + err := b.createPaymentForUser(telegramID, 2 /*SBP*/) + assert.Error(t, err) +} +``` + +Запустить: `make tests` — тест должен **падать** (если функция не проверяет). + +**Шаг 3: Добавить валидацию** + +В функцию `createPaymentForUser` добавить в начало после получения `price`: +```go +if price <= 0 { + return fmt.Errorf("некорректная сумма платежа: %d", price) +} +``` + +**Шаг 4: Проверить** + +```bash +make fmt +make tests +``` + +**Шаг 5: Коммит** + +```bash +git add internal/bot/payment.go internal/bot/payment_test.go +git commit -m "fix: добавить валидацию суммы платежа > 0" +``` + +--- + +## Task 4: Исправить логику graceCount в handleAdminStats + +**Проблема:** Текущее условие: +```go +case remUser.Status == remnawave.StatusDisabled && !remUser.ExpireAt.After(now): + graceCount++ +``` +`!remUser.ExpireAt.After(now)` = `ExpireAt <= now` — это означает что подписка **уже истекла**. Но grace period — это пользователи у которых подписка истекла, но срок кика ещё не пришёл (kicked_at < now). При текущей логике в graceCount попадают пользователи, которых scheduler уже должен был выкинуть. + +Правильное условие для grace: `Status == DISABLED` && `ExpireAt <= now` && `ExpireAt > now - 72h` (не более 3 дней назад). + +**Что менять:** `internal/bot/admin.go`, строка 708. + +**Правильный код:** +```go +graceDeadline := now.Add(-72 * time.Hour) +// ... +case remUser.Status == remnawave.StatusDisabled && + !remUser.ExpireAt.After(now) && + remUser.ExpireAt.After(graceDeadline): + graceCount++ +``` + +**Файлы:** +- Modify: `internal/bot/admin.go:694-715` +- Test: `internal/bot/admin_test.go` + +### Шаги + +**Шаг 1: Написать тест** + +В `internal/bot/admin_test.go` написать тест, где есть пользователь с истёкшей подпиской (> 72ч назад) и пользователь в grace (< 72ч). Убедиться что счётчики правильные. + +**Шаг 2: Исправить условие** + +В `handleAdminStats` перед циклом добавить: +```go +graceDeadline := now.Add(-72 * time.Hour) +``` + +Обновить `case` для graceCount: +```go +case remUser.Status == remnawave.StatusDisabled && + !remUser.ExpireAt.After(now) && + remUser.ExpireAt.After(graceDeadline): + graceCount++ +``` + +**Шаг 3: Проверить** + +```bash +make fmt +make tests +``` + +**Шаг 4: Коммит** + +```bash +git add internal/bot/admin.go internal/bot/admin_test.go +git commit -m "fix: исправить подсчёт grace period в статистике админа" +``` + +--- + +## Финальная проверка + +После всех тасков: + +```bash +make fmt +make tests +``` + +Все тесты должны проходить, форматирование без ошибок. diff --git a/docs/plans/2026-03-23-payment-validation-remediation-plan.md b/docs/plans/2026-03-23-payment-validation-remediation-plan.md new file mode 100644 index 0000000..4e003f8 --- /dev/null +++ b/docs/plans/2026-03-23-payment-validation-remediation-plan.md @@ -0,0 +1,396 @@ +# План исправления замечаний по валидации платёжной системы + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Закрыть критичные баги и расхождения, найденные при параллельной валидации платёжной системы, без лишних рефакторингов и с упором на корректность платежей, scheduler и UI. + +**Architecture:** Сначала исправляем P0-проблемы с гонками, идемпотентностью и потерей консистентности между ботом и Remnawave. Затем выравниваем пользовательский, модераторский и админский UI с утверждёнными планами. Для исторической статистики отдельно фиксируем decision point: часть замечаний нельзя исправить честно без новых snapshot-данных, поэтому в плане есть отдельный этап с выбором стратегии. + +**Tech Stack:** Go 1.25, SQLite, telebot.v3, testify + +**Связанные документы:** +- `docs/plans/2026-03-21-user-ui-redesign.md` +- `docs/plans/2026-03-21-moderator-ui-redesign.md` +- `docs/plans/2026-03-21-admin-ui-redesign.md` +- `docs/plans/2026-03-22-payment-implementation-plan.md` +- `docs/plans/2026-03-23-payment-bugs-fix-plan.md` +- `docs/plans/2026-03-23-confirmed-not-activated-scheduler-guard-plan.md` +- `docs/plans/2026-03-23-admin-financial-stats-fix-plan.md` + +--- + +## Приоритеты + +### P0 +- Повторный concurrent callback может повторно продлить подписку +- Быстрое двойное создание платежа может создать два `pending` +- Grace kick удаляет пользователя локально даже при ошибке Remnawave +- `confirmed_not_activated` может застревать бесконечно + +### P1 +- Ручная проверка оплаты даёт лишнее/урезанное success-сообщение +- В grace-статусе пользователя нет строки про удаление аккаунта +- В UI модератора broken back-flow и слишком широкое право на смену цены +- В карточке пользователя админа статус всегда с `✅` + +### P2 +- Историческая статистика модераторов и админа смешивает snapshot-данные с текущим live-состоянием + +--- + +### Task 1: Зафиксировать идемпотентность callback и сериализовать создание платежа + +**Files:** +- Modify: `internal/bot/payment.go` +- Modify: `internal/bot/payment_test.go` +- Modify: `internal/bot/payment_handler_test.go` +- Modify: `internal/database/payments.go` + +**Step 1: Добавить регрессионный тест на повторный concurrent callback** + +В `internal/bot/payment_test.go` добавить сценарий: +- один `pending` платёж; +- два параллельных вызова `HandlePaymentCallback(CONFIRMED)`; +- ожидание: `moderator_earnings` остаётся один, `ExpireAt` продлевается ровно на один месяц, а не на два. + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestPaymentCallback.*Concurrent.*' -v +``` + +Expected: `FAIL` на текущей реализации. + +**Step 2: Добавить регрессионный тест на двойное быстрое создание платежа** + +В `internal/bot/payment_test.go` добавить сценарий: +- один пользователь; +- два параллельных вызова `createPaymentForUser` с разными методами; +- ожидание: в БД не больше одного живого `pending`, второй запрос либо переиспользует ссылку, либо делает схему `старый expired -> новый pending`. + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestCreatePaymentForUser.*Concurrent.*' -v +``` + +Expected: `FAIL` или flaky-поведение на текущей реализации. + +**Step 3: Исправить callback-path** + +В `internal/bot/payment.go`: +- после входа в mutex перечитывать платёж из БД по `payment.ID`; +- принимать решение по актуальному `status`, а не по stale-объекту, прочитанному до lock; +- пропускать повторную активацию, если под mutex уже виден `confirmed`. + +**Step 4: Исправить create-flow** + +В `internal/bot/payment.go`: +- взять тот же mutex по `telegram_id` внутри `createPaymentForUser`; +- проверять `pending` уже под lock; +- обновление `expired` и создание нового платежа выполнять в одной критической секции; +- комментарии в коде оставить на русском. + +**Step 5: Обновить helper-комментарии** + +В `internal/database/payments.go` и `internal/bot/payment.go` уточнить, какие статусы являются terminal, а какие допускают retry. + +**Step 6: Перезапустить таргетные тесты** + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestPaymentCallback|TestCreatePaymentForUser' -v +``` + +Expected: `PASS`. + +**Suggested commit name:** `fix: сериализовать платежи и закрыть гонку callback` + +--- + +### Task 2: Остановить потерю консистентности при grace kick и завершать безнадёжные retry + +**Files:** +- Modify: `internal/bot/scheduler.go` +- Modify: `internal/bot/payment.go` +- Modify: `internal/database/payments.go` +- Modify: `internal/bot/scheduler_test.go` +- Modify: `internal/bot/payment_test.go` + +**Step 1: Добавить тест на недоступный Remnawave во время grace kick** + +В `internal/bot/scheduler_test.go` добавить сценарий: +- grace period закончился; +- `GetUser`/`DeleteUser` в Remnawave возвращают ошибку; +- ожидание: бот НЕ удаляет пользователя из локальной БД, НЕ помечает инвайт `kicked`, а только логирует/алертит проблему. + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestScheduler.*GraceKick.*Remnawave.*' -v +``` + +Expected: `FAIL`. + +**Step 2: Добавить тест на unrecoverable `confirmed_not_activated`** + +В `internal/bot/payment_test.go` добавить сценарии: +- локальный пользователь удалён из БД; +- пользователь удалён из Remnawave; +- ожидание: retry не крутится бесконечно, статус переводится в terminal error-state, админу уходит алерт. + +**Step 3: Ввести terminal status для безнадёжной активации** + +В `internal/database/payments.go` и `internal/bot/payment.go`: +- добавить новый статус, например `confirmed_activation_failed`; +- использовать его только для случаев, когда retry уже не имеет смысла (`user not found` в локальной БД или в панели); +- не трогать финансовую часть: деньги уже подтверждены, меняется только технический статус активации. + +**Step 4: Исправить scheduler kick-path** + +В `internal/bot/scheduler.go`: +- если `GetUser`/`DeleteUser` падает, не удалять локальные данные; +- `handleAutoKick` должен быть атомарным по смыслу: сначала успешное действие в панели, только потом локальный cleanup; +- при ошибке отправлять алерт админу. + +**Step 5: Подчистить retry-path** + +В `internal/bot/payment.go`: +- различать временные ошибки Remnawave и terminal `not found`; +- для terminal-case переводить платёж в `confirmed_activation_failed` и не планировать новые retry; +- для временных ошибок сохранять текущую retry-логику `[30s, 1m, 5m] + scheduler`. + +**Step 6: Перезапустить таргетные тесты** + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestScheduler|TestRetryConfirmedPaymentActivation' -v +``` + +Expected: `PASS`. + +**Suggested commit name:** `fix: защитить kick и завершать безнадёжные payment retry` + +--- + +### Task 3: Выровнять пользовательский payment UX с планом + +**Files:** +- Modify: `internal/bot/payment_handler.go` +- Modify: `internal/bot/messages.go` +- Modify: `internal/bot/payment_handler_test.go` +- Modify: `internal/bot/messages_test.go` + +**Step 1: Добавить тест на полный grace-экран в `Мой статус`** + +В `internal/bot/messages_test.go` добавить проверку, что grace-экран содержит: +- дедлайн оплаты; +- строку про удаление аккаунта после дедлайна. + +**Step 2: Добавить тест на ручную проверку оплаты** + +В `internal/bot/payment_handler_test.go` зафиксировать поведение: +- `checkPaymentStatus()` при `confirmed` не должен приводить к второму урезанному success-сообщению; +- пользователь должен получить только одно финальное подтверждение с датой окончания подписки. + +**Step 3: Исправить `handleCheckPayment`** + +В `internal/bot/payment_handler.go` выбрать одну из двух реализаций и оставить её единственной: +- либо `checkPaymentStatus()` только подтверждает платёж, а `handleCheckPayment` сам формирует финальный подробный ответ; +- либо `handleConfirmed()` уже отправляет финальное сообщение, а `handleCheckPayment` после `confirmed` не шлёт второе success-сообщение. + +Рекомендация: выбрать второй вариант, чтобы не дублировать шаблон финального сообщения. + +**Step 4: Исправить grace-текст** + +В `internal/bot/messages.go` добавить строку: + +```text +Если не оплатить до {дата}, аккаунт будет удалён. +``` + +**Step 5: Перезапустить таргетные тесты** + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestFormatUserStatusGrace|TestHandleCheckPayment' -v +``` + +Expected: `PASS`. + +**Suggested commit name:** `fix: выровнять пользовательский payment ux` + +--- + +### Task 4: Исправить moderator UI flow и ужесточить смену цены до true-trial + +**Files:** +- Modify: `internal/bot/keyboards.go` +- Modify: `internal/bot/moderator.go` +- Modify: `internal/bot/moderator_test.go` +- Modify: `internal/bot/handlers.go` + +**Step 1: Добавить тест на back-flow из списка подписчиков** + +В `internal/bot/moderator_test.go` добавить сценарий: +- открыть `Мои подписчики`; +- нажать back; +- ожидание: возврат в модераторское меню/предыдущий экран, а не в пользовательское меню. + +**Step 2: Добавить тест на запрет смены цены для non-trial** + +Зафиксировать отдельно: +- paid пользователь; +- grace пользователь; +- expired пользователь; +- ожидание: смена цены запрещена во всех случаях, кроме настоящего `trial`. + +**Step 3: Исправить клавиатуру** + +В `internal/bot/keyboards.go`: +- для экрана подписчиков использовать `BtnBack`, а не `BtnModBack`; +- не ломать основной `В меню` для корневого раздела модератора. + +**Step 4: Исправить eligibility на смену цены** + +В `internal/bot/moderator.go`: +- уйти от правила “не было confirmed-оплаты”; +- проверять именно текущий тип подписки пользователя как `trial`; +- не разрешать смену цены для `grace` и `expired`, даже если исторически оплат ещё не было. + +**Step 5: Перезапустить таргетные тесты** + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestModerator.*ChangePrice|TestModerator.*Subscribers.*' -v +``` + +Expected: `PASS`. + +**Suggested commit name:** `fix: починить flow модератора и ограничить смену цены триалом` + +--- + +### Task 5: Исправить явные ошибки admin UI + +**Files:** +- Modify: `internal/bot/admin.go` +- Modify: `internal/bot/admin_test.go` + +**Step 1: Добавить тест на статус в карточке пользователя** + +В `internal/bot/admin_test.go` добавить сценарии: +- активный paid; +- grace; +- disabled/expired; +- ожидание: префикс и текст статуса соответствуют реальному состоянию, а не всегда `✅`. + +**Step 2: Исправить рендер карточки** + +В `internal/bot/admin.go`: +- перестать хардкодить `✅ Статус:`; +- вернуть из helper-а готовую пару `emoji + текст` или готовую строку статуса; +- сохранить остальные поля карточки без изменений. + +**Step 3: Перезапустить таргетные тесты** + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestAdmin.*UserInfo.*' -v +``` + +Expected: `PASS`. + +**Suggested commit name:** `fix: корректно отображать статус в карточке пользователя` + +--- + +### Task 6: Принять решение по исторической статистике и реализовать одну честную стратегию + +**Files:** +- Modify: `internal/bot/admin.go` +- Modify: `internal/bot/moderator.go` +- Modify: `internal/database/payments.go` +- Modify: `internal/database/earnings.go` +- Modify: `internal/bot/admin_test.go` +- Modify: `internal/bot/moderator_test.go` +- Optional migration: `internal/database/db.go` + +**Проблема:** Замечания по `Мой заработок`, `Общей статистике` и `Статистике модераторов за прошлый месяц` частично упираются в отсутствие исторических snapshot-данных. Текущая схема не умеет честно отвечать на вопросы вида: +- сколько было `paid / trial / grace` у модератора именно в прошлом месяце; +- сколько было платящих “на 01.MM”; +- какие комиссии применялись к админским платежам, если fee-конфиг потом менялся. + +**Step 1: Зафиксировать решение по одной из стратегий** + +Нужно выбрать один путь до реализации: + +**Вариант A, рекомендованный:** добавить snapshot-данные. +- хранить fee snapshot для каждого платежа; +- хранить месячный snapshot счётчиков модератора (`paid / trial / grace`) на конец или начало месяца; +- после этого приводить UI в точное соответствие планам. + +**Вариант B, минимальный:** честно переименовать/упростить UI. +- `Мой заработок` и статистику модераторов явно пометить как “текущее состояние + финансы за месяц”; +- убрать формулировки, которые обещают исторический snapshot, если данных для него нет. + +**Step 2: Если выбран вариант A, подготовить миграцию и тесты** + +Новые сущности зависят от точного решения, но минимум: +- snapshot комиссий на платеже; +- snapshot модераторских счётчиков на месяц. + +Сначала написать падающие тесты, потом миграцию, потом выборки. + +**Step 3: Если выбран вариант B, зафиксировать copy и тесты** + +В `internal/bot/moderator_test.go` и `internal/bot/admin_test.go`: +- ожидать только те метрики, которые код реально может доказать; +- убрать misleading-тексты про `на 01.MM` и “статистика прошлого месяца”, если там живые текущие состояния. + +**Step 4: Реализовать выбранную стратегию** + +Команды проверки зависят от варианта, но минимум: + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestHandleAdminStats|TestHandleAdminModStats|TestHandleModeratorEarnings' -v +GOCACHE=/tmp/go-build go test ./internal/database/ -run 'Test.*Earnings|Test.*Payments' -v +``` + +**Suggested commit name:** `refactor: привести историческую статистику к честной модели` + +--- + +### Task 7: Сквозная верификация и документация выполнения + +**Files:** +- Modify: `docs/progress/2026-03-23-payment-validation-remediation-progress.md` +- Optional modify: `README.md` + +**Step 1: Прогнать таргетные тесты по изменённым зонам** + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/... -count=1 +GOCACHE=/tmp/go-build go test ./internal/database/... -count=1 +``` + +**Step 2: Прогнать обязательные проектные проверки** + +```bash +make fmt +make tests +``` + +Ожидание: всё `PASS`. Нельзя завершать работу с красными проверками. + +**Step 3: Записать прогресс** + +Создать `docs/progress/2026-03-23-payment-validation-remediation-progress.md` и указать: +- ссылку на этот план; +- что именно реализовано; +- какие команды проверки запускались; +- какие пункты сознательно отложены и почему. + +**Step 4: Обновить README только если меняется публично наблюдаемое поведение** + +Обновлять README только при существенных внешних изменениях: +- новый terminal status; +- новая трактовка статистики; +- новые ограничения поведения scheduler. + +**Suggested commit name:** `docs: задокументировать исправления по валидации платежей` diff --git a/docs/progress/2026-03-23-payment-validation-remediation-progress.md b/docs/progress/2026-03-23-payment-validation-remediation-progress.md new file mode 100644 index 0000000..b75a39b --- /dev/null +++ b/docs/progress/2026-03-23-payment-validation-remediation-progress.md @@ -0,0 +1,78 @@ +# Прогресс по исправлению замечаний валидации платёжной системы + +**План:** [2026-03-23-payment-validation-remediation-plan.md](../plans/2026-03-23-payment-validation-remediation-plan.md) + +## Что выполнено + +### P0: гонки и консистентность платёжного ядра + +- Закрыта гонка повторного callback: + `handleConfirmed` теперь перечитывает актуальный `payment` из БД перед проверкой статуса и активацией подписки. +- Сериализован `createPaymentForUser` по `telegram_id`: + быстрые параллельные нажатия больше не создают две живые `pending`-ссылки. +- Исправлен `auto-kick`: + локальный cleanup больше не выполняется, если `DeleteUser` в Remnawave вернул non-404 ошибку. +- Перед `grace kick` scheduler теперь останавливается при ошибке свежей проверки статуса из панели и не удаляет пользователя локально. +- Добавлен terminal status `confirmed_activation_failed` для безнадёжных кейсов активации: + если пользователя уже нет в БД или в Remnawave, retry больше не крутится бесконечно. + +### P1: пользовательский UI + +- В grace-экране `Мой статус` добавлена строка про удаление аккаунта после дедлайна оплаты. +- Ручная кнопка `Проверить оплату` теперь возвращает подробное финальное success-сообщение с датой окончания подписки и снятием лимита трафика. +- Для ручной проверки оплаты убран отдельный push-path через callback-обработчик, чтобы не плодить второе success-сообщение. + +### P1: UI модератора + +- Для экрана `Мои подписчики` добавлен корректный back-flow: + `🔙 Назад` теперь возвращает в меню модератора, а не в пользовательский корень. +- Смена цены ужесточена: + локально оплаченные подписчики отсекаются без обращения к Remnawave; + для неоплаченных цена меняется только если пользователь действительно находится на текущем `trial`; + `grace` и `expired` больше не проходят как “можно менять цену”. + +### P1: UI админа + +- В карточке пользователя убран хардкод `✅ Статус:`. +- Строка статуса теперь рендерится с корректным эмодзи по реальному состоянию (`✅`, `⛔`, `⏰`, `⚠️`). + +## Что пока не выполнено + +### Историческая статистика админа и модераторов + +Остаётся открытым блок, связанный с: + +- `Мой заработок` как snapshot `на 01.MM`; +- `Статистика модераторов за прошлый месяц` по historical-состояниям `paid/trial/grace`; +- полной честностью финансовых historical-отчётов при изменяемых fee-конфигах. + +Причина остановки: +часть замечаний нельзя корректно закрыть без явного решения по модели данных. +Нужен выбор одной из стратегий: + +- добавить новые snapshot-данные и миграции; +- или честно упростить/переименовать UI под доступные live-данные. + +## Проверки + +Выполнено: + +```bash +gofmt -w internal/bot/payment.go internal/bot/payment_test.go internal/bot/payment_handler.go internal/bot/payment_handler_test.go internal/bot/messages.go internal/bot/messages_test.go internal/bot/moderator.go internal/bot/moderator_test.go internal/bot/keyboards.go internal/bot/handlers.go internal/bot/admin.go internal/bot/admin_test.go internal/bot/scheduler.go internal/bot/scheduler_test.go +GOCACHE=/tmp/go-build go test ./internal/bot/... -count=1 +GOCACHE=/tmp/go-build go test ./internal/database/... -count=1 +GOCACHE=/tmp/go-build go test ./... -count=1 +make fmt +make tests +``` + +Результат: + +- `make fmt` — PASS +- `make tests` — PASS + +## Примечание + +Изолированный worktree был подготовлен, но не использован: +в исходном рабочем дереве уже были незакоммиченные изменения, отсутствующие в свежем worktree от `HEAD`. +По решению пользователя работа продолжена в текущем workspace поверх существующих правок. diff --git a/internal/bot/admin.go b/internal/bot/admin.go index cbb29d3..791a231 100644 --- a/internal/bot/admin.go +++ b/internal/bot/admin.go @@ -275,6 +275,7 @@ func (b *Bot) processAdminUserInfo(c tele.Context, text string) error { } typeLabel, statusLabel := b.describeAdminUserSubscription(targetID, remUser) + statusEmoji := adminStatusEmoji(statusLabel) var msg strings.Builder msg.WriteString("🔍 Информация о пользователе\n\n") @@ -292,7 +293,7 @@ func (b *Bot) processAdminUserInfo(c tele.Context, text string) error { fmt.Fprintf(&msg, "📊 Трафик за месяц: %s\n", trafficLabel) fmt.Fprintf(&msg, "📡 Устройства: %s\n", devicesLabel) fmt.Fprintf(&msg, "🏷 Тип: %s\n", typeLabel) - fmt.Fprintf(&msg, "✅ Статус: %s", statusLabel) + fmt.Fprintf(&msg, "%s Статус: %s", statusEmoji, statusLabel) return c.Send(msg.String(), &tele.SendOptions{ ParseMode: tele.ModeHTML, @@ -1366,6 +1367,21 @@ func humanizeAdminStatus(status string) string { } } +func adminStatusEmoji(status string) string { + switch status { + case "Активен": + return "✅" + case "Grace period", "Отключён": + return "⛔" + case "Истёк": + return "⏰" + case "Лимит трафика": + return "⚠️" + default: + return "❌" + } +} + func formatAdminPriceValue(price *int) string { if price == nil { return "не установлена" diff --git a/internal/bot/admin_test.go b/internal/bot/admin_test.go index 85a1802..e690cd6 100644 --- a/internal/bot/admin_test.go +++ b/internal/bot/admin_test.go @@ -908,6 +908,64 @@ func TestAdminChangePriceFlow_UpdatesPaidUser(t *testing.T) { assert.Empty(t, b.userStates.Get(adminID)) } +func TestProcessAdminUserInfo_ShowsNonSuccessStatusForGraceUser(t *testing.T) { + dbFile := "test_admin_user_info_grace.db" + db, err := database.New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + adminID := int64(999999) + targetID := int64(22334) + price := 500 + + _, err = db.CreateUser(targetID, "grace", "Grace", "uuid-grace-user", &price, nil) + require.NoError(t, err) + + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-grace-user": + payload := `{"response":{"uuid":"uuid-grace-user","username":"grace","status":"DISABLED","expireAt":"2026-03-01T00:00:00Z","hwidDeviceLimit":2,"userTraffic":{"usedTrafficBytes":1073741824,"lifetimeUsedTrafficBytes":1073741824}}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodGet && r.URL.Path == "/api/hwid/devices/uuid-grace-user": + payload := `{"response":{"total":1,"devices":[{"hwid":"a"}]}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + default: + return nil, assert.AnError + } + }), + }) + + b := &Bot{ + db: db, + remnawave: client, + config: &config.Config{AdminID: adminID}, + userStates: newStateMap(), + } + + ctx := &MockContext{sender: &tele.User{ID: adminID}} + err = b.processAdminUserInfo(ctx, strconv.FormatInt(targetID, 10)) + require.NoError(t, err) + + msg, ok := ctx.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, msg, "Статус: Grace period") + assert.NotContains(t, msg, "✅ Статус: Grace period") + assert.Contains(t, msg, "⛔") +} + func intPtrAdmin(v int) *int { return &v } diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 1fe9f89..8d9c983 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -460,6 +460,10 @@ func (b *Bot) handleTextMessage(c tele.Context) error { return b.handleModeratorEarnings(c) case BtnModChangePrice: return b.handleModChangePriceRequest(c) + case BtnBack: + if b.userStates.Get(c.Sender().ID) == StateModSubscribers { + return b.handleModeratorMenu(c) + } case BtnModBack: return b.handleModeratorBack(c) } diff --git a/internal/bot/keyboards.go b/internal/bot/keyboards.go index a0c7b75..924bd61 100644 --- a/internal/bot/keyboards.go +++ b/internal/bot/keyboards.go @@ -168,7 +168,7 @@ func ModeratorMenuKeyboard() *tele.ReplyMarkup { func ModeratorSubscribersKeyboard() *tele.ReplyMarkup { menu := &tele.ReplyMarkup{ResizeKeyboard: true} menu.Reply( - menu.Row(menu.Text(BtnModChangePrice), menu.Text(BtnModBack)), + menu.Row(menu.Text(BtnModChangePrice), menu.Text(BtnBack)), ) return menu } diff --git a/internal/bot/messages.go b/internal/bot/messages.go index 459edac..9054fb7 100644 --- a/internal/bot/messages.go +++ b/internal/bot/messages.go @@ -240,6 +240,7 @@ func formatGraceStatus(remUser *remnawave.User, dbUser *database.User) string { } msg += "\nОплатите подписку, чтобы восстановить доступ." + msg += fmt.Sprintf("\nЕсли не оплатить до %s, аккаунт будет удалён.", graceDeadline.Format("02.01.2006")) return msg } diff --git a/internal/bot/messages_test.go b/internal/bot/messages_test.go index 7fb411a..4fc17eb 100644 --- a/internal/bot/messages_test.go +++ b/internal/bot/messages_test.go @@ -45,6 +45,7 @@ func TestFormatUserStatusGraceShowsPaymentWindow(t *testing.T) { assert.Contains(t, msg, "VPN деактивирован") assert.Contains(t, msg, "Цена подписки") assert.Contains(t, msg, "Осталось для оплаты") + assert.Contains(t, msg, "аккаунт будет удалён") } func TestMsgAccountCreatedHasNoTrafficLimitDetails(t *testing.T) { diff --git a/internal/bot/moderator.go b/internal/bot/moderator.go index 52ab994..7c648ce 100644 --- a/internal/bot/moderator.go +++ b/internal/bot/moderator.go @@ -19,6 +19,7 @@ const ( StateWaitModInvitePrice = "wait_mod_invite_price" // Ожидание цены нового инвайта StateWaitModChangePriceID = "wait_mod_change_price_id" // Ожидание telegram_id подписчика StateWaitModChangePriceValue = "wait_mod_change_price_value" // Ожидание новой цены подписки + StateModSubscribers = "mod_subscribers" // Открыт экран списка подписчиков ) type modChangePriceSession struct { @@ -39,6 +40,7 @@ func (b *Bot) isModerator(telegramID int64) bool { // handleModeratorMenu показывает подменю модератора. func (b *Bot) handleModeratorMenu(c tele.Context) error { + b.userStates.Delete(c.Sender().ID) return c.Send("🎟 Приглашения\n\nВыберите действие:", &tele.SendOptions{ ParseMode: tele.ModeHTML, ReplyMarkup: ModeratorMenuKeyboard(), @@ -217,6 +219,7 @@ func (b *Bot) handleModSubscribers(c tele.Context) error { expiredCount, deletedCount, ) + b.userStates.Set(telegramID, StateModSubscribers) return c.Send(msg.String(), &tele.SendOptions{ ParseMode: tele.ModeHTML, @@ -331,6 +334,23 @@ func (b *Bot) processModChangePriceID(c tele.Context, text string) error { ) } + remUser, err := b.remnawave.GetUser(dbUser.RemnawaveUUID) + if err != nil { + slog.Error("Failed to load subscriber from Remnawave", "error", err, "target_id", targetID) + return c.Send("Ошибка проверки подписчика", &tele.SendOptions{ReplyMarkup: CancelKeyboard()}) + } + + switch b.describeSubscriberStatus(targetID, *remUser, time.Now().UTC()) { + case "trial": + // Только trial может менять цену. + default: + b.userStates.Delete(moderatorID) + return c.Send( + "❌ Нельзя изменить цену — клиент уже не на пробном периоде. Обратитесь к администратору.", + &tele.SendOptions{ReplyMarkup: ModeratorSubscribersKeyboard()}, + ) + } + currentPrice := 0 if dbUser.SubscriptionPrice != nil { currentPrice = *dbUser.SubscriptionPrice diff --git a/internal/bot/moderator_test.go b/internal/bot/moderator_test.go index 07627f4..8f9f741 100644 --- a/internal/bot/moderator_test.go +++ b/internal/bot/moderator_test.go @@ -469,6 +469,21 @@ func TestHandleTextMessage_ModeratorButtons(t *testing.T) { assert.NoError(t, err) }) + t.Run("Кнопка_Назад_из_экрана_подписчиков", func(t *testing.T) { + b.userStates.Set(modID, StateModSubscribers) + ctx := &MockContext{ + sender: user, + message: &tele.Message{Text: BtnBack}, + } + err := b.handleTextMessage(ctx) + assert.NoError(t, err) + + sentStr, ok := ctx.sentMsg.(string) + assert.True(t, ok) + assert.Contains(t, sentStr, "Приглашения") + assert.Empty(t, b.userStates.Get(modID)) + }) + t.Run("Кнопка_Создать_запрашивает_цену", func(t *testing.T) { ctx := &MockContext{ sender: user, @@ -655,6 +670,20 @@ func TestProcessModChangePrice_UpdatesTrialSubscriber(t *testing.T) { require.NoError(t, err) require.NoError(t, db.ClaimInvite(code, 9001)) + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-9001" { + payload := `{"response":{"uuid":"uuid-9001","username":"trial","status":"ACTIVE","expireAt":"2026-04-20T00:00:00Z"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + } + return nil, assert.AnError + }), + }) + ctxID := &MockContext{sender: &tele.User{ID: modID}, message: &tele.Message{Text: "9001"}} require.NoError(t, b.processModChangePriceID(ctxID, "9001")) require.Equal(t, StateWaitModChangePriceValue, b.userStates.Get(modID)) @@ -779,6 +808,41 @@ func TestProcessModChangePriceID_RejectsPaidSubscriber(t *testing.T) { assert.Empty(t, b.userStates.Get(modID)) } +func TestProcessModChangePriceID_RejectsGraceSubscriber(t *testing.T) { + b, db, _, modID := setupModeratorTestBot(t) + price := 500 + _, err := db.CreateUser(401, "grace", "Grace", "uuid-401", &price, &modID) + require.NoError(t, err) + code, err := db.CreateInviteWithPrice(modID, 30, price) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(code, 401)) + + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-401" { + payload := `{"response":{"uuid":"uuid-401","username":"grace","status":"DISABLED","expireAt":"2026-03-01T00:00:00Z"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + } + return nil, assert.AnError + }), + }) + + ctx := &MockContext{ + sender: &tele.User{ID: modID}, + message: &tele.Message{Text: "401"}, + } + require.NoError(t, b.processModChangePriceID(ctx, "401")) + + msg, ok := ctx.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, msg, "уже не на пробном периоде") + assert.Empty(t, b.userStates.Get(modID)) +} + // --- Тесты handleStart с меню модератора --- func TestHandleStart_ModeratorGetsModeratorMenu(t *testing.T) { diff --git a/internal/bot/payment.go b/internal/bot/payment.go index 2d8e087..2fcdf47 100644 --- a/internal/bot/payment.go +++ b/internal/bot/payment.go @@ -4,6 +4,7 @@ import ( "fmt" "log/slog" "strconv" + "strings" "sync" "time" @@ -23,6 +24,8 @@ var defaultPaymentRetryDelays = []time.Duration{ 5 * time.Minute, } +const paymentStatusConfirmedActivationFailed = "confirmed_activation_failed" + func getPaymentMutex(telegramID int64) *sync.Mutex { mu, _ := paymentMu.LoadOrStore(telegramID, &sync.Mutex{}) return mu.(*sync.Mutex) @@ -68,8 +71,27 @@ func (h *paymentCallbackHandler) HandlePaymentCallback(payload platega.CallbackP } } -// handleConfirmed обрабатывает успешный платёж +// handleConfirmed обрабатывает успешный платёж и уведомляет пользователя. func (h *paymentCallbackHandler) handleConfirmed(payment *database.Payment) error { + return h.handleConfirmedWithNotification(payment, true) +} + +// handleConfirmedSilently обрабатывает успешный платёж без отдельного push-уведомления. +// Используется для ручной проверки оплаты, чтобы не дублировать финальное сообщение. +func (h *paymentCallbackHandler) handleConfirmedSilently(payment *database.Payment) error { + return h.handleConfirmedWithNotification(payment, false) +} + +func (h *paymentCallbackHandler) handleConfirmedWithNotification(payment *database.Payment, notifyUser bool) error { + freshPayment, err := h.bot.db.GetPaymentByID(payment.ID) + if err != nil { + return fmt.Errorf("reload payment before confirm: %w", err) + } + if freshPayment == nil { + return fmt.Errorf("payment not found: id=%d", payment.ID) + } + payment = freshPayment + // Идемпотентность: если платёж уже confirmed — пропускаем if payment.Status == "confirmed" { slog.Info("Платёж уже подтверждён, пропускаем", "payment_id", payment.ID) @@ -89,6 +111,19 @@ func (h *paymentCallbackHandler) handleConfirmed(payment *database.Payment) erro // Пытаемся активировать подписку один раз. // Долгие retry выполняет scheduler, чтобы не держать callback/manual-check path открытым. if err := h.activateSubscription(payment); err != nil { + if isTerminalActivationError(err) { + slog.Error("Активация подписки невозможна, переводим платёж в terminal-статус", + "error", err, "payment_id", payment.ID, "telegram_id", payment.TelegramID) + if updateErr := h.bot.db.UpdatePaymentStatus(payment.ID, paymentStatusConfirmedActivationFailed); updateErr != nil { + return fmt.Errorf("update status to %s: %w", paymentStatusConfirmedActivationFailed, updateErr) + } + h.bot.sendAdminAlert(fmt.Sprintf( + "⚠️ Платёж #%d подтверждён, но активация подписки невозможна для %d: %v", + payment.ID, payment.TelegramID, err, + )) + return nil + } + slog.Error("Не удалось активировать подписку после подтверждения, помечаем для scheduler", "error", err, "payment_id", payment.ID) if updateErr := h.bot.db.UpdatePaymentStatus(payment.ID, "confirmed_not_activated"); updateErr != nil { @@ -106,23 +141,24 @@ func (h *paymentCallbackHandler) handleConfirmed(payment *database.Payment) erro return nil // Не возвращаем ошибку — платёж уже сохранён } - h.finalizeActivatedPayment(payment) + h.finalizeActivatedPayment(payment, notifyUser) return nil } -func (h *paymentCallbackHandler) finalizeActivatedPayment(payment *database.Payment) { - // Уведомляем пользователя - remUser, _ := h.bot.remnawave.GetUserByTelegramID(payment.TelegramID) +func (b *Bot) paymentActivatedMessage(telegramID int64) string { + remUser, _ := b.remnawave.GetUserByTelegramID(telegramID) - var msg string if remUser != nil { expireDate := remUser.ExpireAt.Format("02.01.2006") - msg = fmt.Sprintf("✅ Оплата прошла! Ваша подписка активна до %s.\n\nЛимит трафика снят — пользуйтесь без ограничений.\n\nБлиже к концу подписки мы напомним о продлении.", expireDate) - } else { - msg = "✅ Оплата прошла! Подписка активирована." + return fmt.Sprintf("✅ Оплата прошла! Ваша подписка активна до %s.\n\nЛимит трафика снят — пользуйтесь без ограничений.\n\nБлиже к концу подписки мы напомним о продлении.", expireDate) } + return "✅ Оплата прошла! Подписка активирована." +} - _ = h.bot.sendSchedulerMessage(payment.TelegramID, msg) +func (h *paymentCallbackHandler) finalizeActivatedPayment(payment *database.Payment, notifyUser bool) { + if notifyUser { + _ = h.bot.sendSchedulerMessage(payment.TelegramID, h.bot.paymentActivatedMessage(payment.TelegramID)) + } // Очищаем уведомления (пользователь мог быть в grace period) h.bot.db.ClearNotifications(payment.TelegramID) @@ -172,6 +208,16 @@ func nextRetryDelay(delays []time.Duration, attempt int) string { return delays[attempt+1].String() } +func isTerminalActivationError(err error) bool { + if err == nil { + return false + } + + msg := err.Error() + return strings.Contains(msg, "user not found: telegram_id=") || + strings.Contains(msg, "API error 404") +} + func (b *Bot) retryConfirmedPaymentActivation(paymentID int64, source string) bool { payment, err := b.db.GetPaymentByID(paymentID) if err != nil { @@ -199,6 +245,25 @@ func (b *Bot) retryConfirmedPaymentActivation(paymentID int64, source string) bo handler := &paymentCallbackHandler{bot: b} if err := handler.activateSubscription(payment); err != nil { + if isTerminalActivationError(err) { + slog.Error("Retry активации упёрся в terminal-ошибку, останавливаем повторные попытки", + "error", err, + "payment_id", paymentID, + "telegram_id", payment.TelegramID, + "source", source, + ) + if updateErr := b.db.UpdatePaymentStatus(payment.ID, paymentStatusConfirmedActivationFailed); updateErr != nil { + slog.Error("Не удалось обновить статус после terminal-ошибки активации", + "error", updateErr, "payment_id", paymentID, "source", source) + return false + } + b.sendAdminAlert(fmt.Sprintf( + "⚠️ Retry активации остановлен: платёж #%d подтверждён, но подписку невозможно активировать для %d: %v", + payment.ID, payment.TelegramID, err, + )) + return true + } + slog.Warn("Не удалось активировать подписку при retry", "error", err, "payment_id", paymentID, @@ -214,7 +279,7 @@ func (b *Bot) retryConfirmedPaymentActivation(paymentID int64, source string) bo return false } - handler.finalizeActivatedPayment(payment) + handler.finalizeActivatedPayment(payment, true) slog.Info("Retry активации успешен", "payment_id", paymentID, "telegram_id", payment.TelegramID, @@ -388,6 +453,13 @@ func (b *Bot) createPaymentForUser(telegramID int64, paymentMethodInt int) (*dat paymentMethodStr := platega.PaymentMethodString(paymentMethodInt) + // Сериализуем весь create-flow по telegram_id. + // Иначе два быстрых нажатия могут одновременно пройти проверку pending + // и создать две живые ссылки на оплату. + mu := getPaymentMutex(telegramID) + mu.Lock() + defer mu.Unlock() + // Проверяем наличие активного PENDING платежа pending, err := b.db.GetPendingPayment(telegramID) if err != nil { @@ -404,7 +476,9 @@ func (b *Bot) createPaymentForUser(telegramID int64, paymentMethodInt int) (*dat return pending, url, nil } // Другой способ — помечаем старый как expired - b.db.UpdatePaymentStatus(pending.ID, "expired") + if err := b.db.UpdatePaymentStatus(pending.ID, "expired"); err != nil { + return nil, "", fmt.Errorf("expire previous pending: %w", err) + } } // Создаём платёж в Platega @@ -482,9 +556,9 @@ func (b *Bot) checkPaymentStatus(telegramID int64) (string, error) { } if status.Status == platega.StatusConfirmed { - // Платёж подтверждён — обрабатываем как callback (мьютекс уже взят) + // Платёж подтверждён — синхронизируем его без отдельного push-уведомления. handler := &paymentCallbackHandler{bot: b} - if err := handler.handleConfirmed(pending); err != nil { + if err := handler.handleConfirmedSilently(pending); err != nil { return "", err } @@ -501,7 +575,7 @@ func (b *Bot) checkPaymentStatus(telegramID int64) (string, error) { if status.Status == platega.StatusManualConfirmed { handler := &paymentCallbackHandler{bot: b} - if err := handler.handleConfirmed(pending); err != nil { + if err := handler.handleConfirmedSilently(pending); err != nil { return "", err } diff --git a/internal/bot/payment_handler.go b/internal/bot/payment_handler.go index 23cd23e..acf16ac 100644 --- a/internal/bot/payment_handler.go +++ b/internal/bot/payment_handler.go @@ -120,7 +120,7 @@ func (b *Bot) handleCheckPayment(c tele.Context) error { switch status { case "confirmed": b.userStates.Delete(telegramID) - return c.Send("✅ Оплата подтверждена! Подписка активирована.", &tele.SendOptions{ + return c.Send(b.paymentActivatedMessage(telegramID), &tele.SendOptions{ ParseMode: tele.ModeHTML, ReplyMarkup: b.userKeyboard(telegramID), }) diff --git a/internal/bot/payment_handler_test.go b/internal/bot/payment_handler_test.go index 9c89af8..e1d8808 100644 --- a/internal/bot/payment_handler_test.go +++ b/internal/bot/payment_handler_test.go @@ -272,3 +272,179 @@ func TestCheckPaymentStatusTreatsManualConfirmedAsConfirmed(t *testing.T) { assert.Equal(t, "confirmed", stored.Status) require.NotNil(t, stored.ConfirmedAt) } + +func TestHandleCheckPaymentReturnsDetailedSuccessMessage(t *testing.T) { + b, db := setupTestBot(t) + + userID := int64(815) + price := 500 + _, err := db.CreateUser(userID, "payer", "Payer", "uuid-815", &price, nil) + require.NoError(t, err) + + txID := "tx-815" + redirect := "https://pay.example/tx-815" + expiresAt := time.Now().UTC().Add(15 * time.Minute) + payment := &database.Payment{ + TelegramID: userID, + Amount: price, + PaymentMethod: "sbp", + Status: "pending", + PlategaTransactionID: &txID, + RedirectURL: &redirect, + ExpiresAt: &expiresAt, + } + paymentID, err := db.CreatePayment(payment) + require.NoError(t, err) + + b.platega = platega.NewClientWithBaseURL("merchant", "secret", "https://platega.test") + b.platega.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/transaction/"+txID, r.URL.Path) + + respBody, err := json.Marshal(map[string]any{ + "id": txID, + "paymentDetails": map[string]any{ + "amount": price, + "currency": "RUB", + }, + "status": platega.StatusConfirmed, + "paymentMethod": "SBPQR", + "expiresIn": "00:15:00", + "payload": "815", + }) + require.NoError(t, err) + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(respBody))), + Header: make(http.Header), + }, nil + }), + }) + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-815": + payload := `{"response":{"uuid":"uuid-815","username":"payer","status":"EXPIRED","expireAt":"2026-03-01T00:00:00Z"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodGet && r.URL.Path == "/api/users/by-telegram-id/815": + payload := `{"response":{"uuid":"uuid-815","username":"payer","status":"ACTIVE","expireAt":"2026-04-20T00:00:00Z"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodPatch && r.URL.Path == "/api/users": + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + default: + return nil, assert.AnError + } + }), + }) + + ctx := &MockContext{ + sender: &tele.User{ID: userID}, + message: &tele.Message{}, + } + + err = b.handleCheckPayment(ctx) + require.NoError(t, err) + + msg, ok := ctx.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, msg, "Ваша подписка активна до") + assert.Contains(t, msg, "20.04.2026") + assert.Contains(t, msg, "Лимит трафика снят") + assert.NotContains(t, msg, "Подписка активирована.") + assert.Len(t, ctx.sentMsgs, 1) + + stored, err := db.GetPaymentByID(paymentID) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, "confirmed", stored.Status) +} + +func TestHandlePaymentCallbackKeepsModeratorSnapshotAfterModeratorRemoval(t *testing.T) { + b, db := setupTestBot(t) + + adminID := int64(999999) + oldModeratorID := int64(813) + userID := int64(814) + + _, err := db.CreateUser(oldModeratorID, "oldmod", "Old Mod", "uuid-oldmod", nil, nil) + require.NoError(t, err) + require.NoError(t, db.AddModerator(oldModeratorID, adminID)) + + _, err = db.CreateUser(userID, "payer", "Payer", "uuid-payer", nil, &oldModeratorID) + require.NoError(t, err) + + txID := "tx-814" + payment := &database.Payment{ + TelegramID: userID, + ModeratorID: &oldModeratorID, + Amount: 500, + PaymentMethod: "card", + Status: "pending", + PlategaTransactionID: &txID, + } + paymentID, err := db.CreatePayment(payment) + require.NoError(t, err) + + require.NoError(t, db.RemoveModerator(oldModeratorID)) + + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-payer": + payload := `{"response":{"uuid":"uuid-payer","username":"payer","status":"EXPIRED","expireAt":"2026-03-01T00:00:00Z"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodPatch && r.URL.Path == "/api/users": + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodGet && r.URL.Path == "/api/users/by-telegram-id/814": + payload := `{"response":{"uuid":"uuid-payer","username":"payer","status":"ACTIVE","expireAt":"2026-04-20T00:00:00Z"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + default: + return nil, assert.AnError + } + }), + }) + + err = b.PaymentCallbackHandler().HandlePaymentCallback(platega.CallbackPayload{ + ID: txID, + Amount: 500, + Currency: "RUB", + Status: platega.StatusConfirmed, + PaymentMethod: platega.PaymentMethodCard, + Payload: "814", + }) + require.NoError(t, err) + + var storedModeratorID int64 + err = db.Conn().QueryRow( + `SELECT moderator_id FROM moderator_earnings WHERE payment_id = ?`, + paymentID, + ).Scan(&storedModeratorID) + require.NoError(t, err) + assert.Equal(t, oldModeratorID, storedModeratorID) +} diff --git a/internal/bot/payment_test.go b/internal/bot/payment_test.go index 4bfcac2..d64fe02 100644 --- a/internal/bot/payment_test.go +++ b/internal/bot/payment_test.go @@ -1,19 +1,23 @@ package bot import ( + "fmt" "io" "net/http" "os" "strings" + "sync" "sync/atomic" "testing" "time" "github.com/fus1ond/vpn_bot/internal/config" "github.com/fus1ond/vpn_bot/internal/database" + "github.com/fus1ond/vpn_bot/internal/platega" "github.com/fus1ond/vpn_bot/internal/remnawave" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + tele "gopkg.in/telebot.v3" ) func TestCalculateSharePercent(t *testing.T) { @@ -312,6 +316,97 @@ func TestRetryConfirmedPaymentActivationDoesNotDuplicateEarning(t *testing.T) { assert.Equal(t, 1, count) } +func TestHandleConfirmedDoesNotReapplyActivationFromStaleSnapshot(t *testing.T) { + dbFile := "test_payment_stale_snapshot.db" + db, err := database.New(dbFile) + require.NoError(t, err) + t.Cleanup(func() { + db.Close() + os.Remove(dbFile) + }) + + userID := int64(580) + _, err = db.CreateUser(userID, "payer", "Payer", "uuid-580", nil, nil) + require.NoError(t, err) + + txID := "tx-stale-snapshot" + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + PlategaTransactionID: &txID, + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + + firstSnapshot, err := db.GetPaymentByID(id) + require.NoError(t, err) + secondSnapshot, err := db.GetPaymentByID(id) + require.NoError(t, err) + + var getUserCalls atomic.Int32 + var patchCalls atomic.Int32 + + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-580": + if getUserCalls.Add(1) == 1 { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{"uuid":"uuid-580","status":"EXPIRED","expireAt":"2026-03-01T00:00:00Z"}}`)), + Header: make(http.Header), + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{"uuid":"uuid-580","status":"ACTIVE","expireAt":"2026-04-20T00:00:00Z"}}`)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodPatch && r.URL.Path == "/api/users": + patchCalls.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + case r.Method == http.MethodGet && r.URL.Path == "/api/users/by-telegram-id/580": + expireAt := "2026-04-20T00:00:00Z" + if patchCalls.Load() > 1 { + expireAt = "2026-05-20T00:00:00Z" + } + payload := fmt.Sprintf(`{"response":{"uuid":"uuid-580","username":"payer","status":"ACTIVE","expireAt":"%s"}}`, expireAt) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + default: + return nil, assert.AnError + } + }), + }) + + cfg := &config.Config{AdminID: 999} + b := &Bot{db: db, config: cfg, userStates: newStateMap(), remnawave: client} + handler := &paymentCallbackHandler{bot: b} + + err = handler.handleConfirmed(firstSnapshot) + require.NoError(t, err) + + err = handler.handleConfirmed(secondSnapshot) + require.NoError(t, err) + + assert.Equal(t, int32(1), patchCalls.Load(), "повторная обработка stale snapshot не должна повторно активировать подписку") + + stored, err := db.GetPaymentByID(id) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, "confirmed", stored.Status) +} + func TestCreatePaymentForUser_RejectsZeroOrNilPrice(t *testing.T) { dbFile := "test_payment_zero_price.db" db, err := database.New(dbFile) @@ -351,6 +446,87 @@ func TestCreatePaymentForUser_RejectsZeroOrNilPrice(t *testing.T) { }) } +func TestCreatePaymentForUserSerializesConcurrentRequests(t *testing.T) { + dbFile := "test_payment_concurrent_create.db" + db, err := database.New(dbFile) + require.NoError(t, err) + t.Cleanup(func() { + db.Close() + os.Remove(dbFile) + }) + + userID := int64(702) + price := 500 + _, err = db.CreateUser(userID, "payer", "Payer", "uuid-702", &price, nil) + require.NoError(t, err) + + plategaClient := platega.NewClientWithBaseURL("merchant", "secret", "https://platega.test") + var paymentRequests atomic.Int32 + plategaClient.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/transaction/process", r.URL.Path) + + requestNo := paymentRequests.Add(1) + time.Sleep(100 * time.Millisecond) + + resp := fmt.Sprintf( + `{"transactionId":"tx-%d","redirect":"https://pay.example/tx-%d","status":"PENDING","expiresIn":"00:15:00"}`, + requestNo, + requestNo, + ) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(resp)), + Header: make(http.Header), + }, nil + }), + }) + + remClient := remnawave.NewClient("https://panel.example.com", "test-token", nil) + remClient.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return nil, assert.AnError + }), + }) + + b := &Bot{ + db: db, + config: &config.Config{AdminID: 999, PlategaCallbackURL: "https://bot.example/callback"}, + userStates: newStateMap(), + remnawave: remClient, + platega: plategaClient, + bot: &tele.Bot{Me: &tele.User{Username: "testbot"}}, + } + + start := make(chan struct{}) + errCh := make(chan error, 2) + var wg sync.WaitGroup + + for _, method := range []int{platega.PaymentMethodSBP, platega.PaymentMethodCard} { + wg.Add(1) + go func(paymentMethod int) { + defer wg.Done() + <-start + _, _, createErr := b.createPaymentForUser(userID, paymentMethod) + errCh <- createErr + }(method) + } + + close(start) + wg.Wait() + close(errCh) + + for createErr := range errCh { + require.NoError(t, createErr) + } + + var pendingCount int + err = db.Conn().QueryRow(`SELECT COUNT(*) FROM payments WHERE telegram_id = ? AND status = 'pending'`, userID).Scan(&pendingCount) + require.NoError(t, err) + assert.Equal(t, 1, pendingCount, "должен остаться только один живой pending платёж") +} + func TestHandleConfirmedRetriesActivationInBackground(t *testing.T) { dbFile := "test_payment_background_retry.db" db, err := database.New(dbFile) @@ -443,3 +619,57 @@ func TestHandleConfirmedRetriesActivationInBackground(t *testing.T) { return stored != nil && stored.Status == "confirmed" }, time.Second, 20*time.Millisecond) } + +func TestRetryConfirmedPaymentActivationMarksTerminalFailureWhenUserMissingInRemnawave(t *testing.T) { + dbFile := "test_payment_terminal_retry.db" + db, err := database.New(dbFile) + require.NoError(t, err) + t.Cleanup(func() { + db.Close() + os.Remove(dbFile) + }) + + userID := int64(590) + _, err = db.CreateUser(userID, "payer", "Payer", "uuid-590", nil, nil) + require.NoError(t, err) + + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + require.NoError(t, db.UpdatePaymentStatus(id, "confirmed_not_activated")) + + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-590" { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)), + Header: make(http.Header), + }, nil + } + return nil, assert.AnError + }), + }) + + b := &Bot{ + db: db, + config: &config.Config{AdminID: 999}, + userStates: newStateMap(), + remnawave: client, + } + + ok := b.retryConfirmedPaymentActivation(id, "test") + require.True(t, ok, "terminal ошибка должна останавливать дальнейшие retry") + + stored, err := db.GetPaymentByID(id) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, "confirmed_activation_failed", stored.Status) +} diff --git a/internal/bot/scheduler.go b/internal/bot/scheduler.go index 4c5e7b8..8751a38 100644 --- a/internal/bot/scheduler.go +++ b/internal/bot/scheduler.go @@ -214,7 +214,12 @@ func (b *Bot) processPaidUser(telegramID int64, dbUser database.User, expireAt, // Перед киком проверяем свежий статус через API — вдруг callback прошёл freshUser, err := b.remnawave.GetUser(dbUser.RemnawaveUUID) - if err == nil && freshUser.Status == "ACTIVE" && freshUser.ExpireAt.After(now) { + if err != nil { + slog.Warn("Scheduler: не удалось проверить свежий статус перед grace kick", + "error", err, "telegram_id", telegramID) + return + } + if freshUser.Status == "ACTIVE" && freshUser.ExpireAt.After(now) { slog.Info("Scheduler: пользователь активен при проверке перед grace kick, пропускаем", "telegram_id", telegramID) return @@ -298,6 +303,11 @@ func (b *Bot) handleAutoKick(telegramID int64, userUUID string) { slog.Debug("Scheduler auto-kick: user already absent in Remnawave", "telegram_id", telegramID) } else { slog.Warn("Scheduler failed to delete user from Remnawave during auto-kick", "error", err, "telegram_id", telegramID) + b.sendAdminAlert(fmt.Sprintf( + "⚠️ Auto-kick не завершён: не удалось удалить пользователя %d из Remnawave: %v", + telegramID, err, + )) + return } } diff --git a/internal/bot/scheduler_test.go b/internal/bot/scheduler_test.go index c6ddc63..1831710 100644 --- a/internal/bot/scheduler_test.go +++ b/internal/bot/scheduler_test.go @@ -81,6 +81,46 @@ func TestHandleAutoKick_404IsNotFatalError(t *testing.T) { assert.NotNil(t, invite.KickedAt, "kicked_at должен быть проставлен после автокика") } +func TestHandleAutoKick_DoesNotCleanupOnRemnawaveDeleteError(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + _, err := db.CreateUser(701, "victim", "Victim", "uuid-701", nil, nil) + require.NoError(t, err) + modID := int64(51) + _, err = db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, 701)) + + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodDelete { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`{"error":"boom"}`)), + Header: make(http.Header), + }, nil + } + return nil, fmt.Errorf("unexpected: %s %s", r.Method, r.URL.Path) + }), + }) + b.remnawave = client + + b.handleAutoKick(701, "uuid-701") + + dbUser, err := db.GetUserByTelegramID(701) + require.NoError(t, err) + assert.NotNil(t, dbUser, "локальный cleanup нельзя делать, если DeleteUser в Remnawave завершился ошибкой") + + invite, err := db.GetInviteByCode(inv.Code) + require.NoError(t, err) + require.NotNil(t, invite) + assert.Nil(t, invite.KickedAt, "kicked_at нельзя ставить при неуспешном удалении в панели") +} + func TestHandleAutoKick_SkipsAlreadyDeletedInRemnawave(t *testing.T) { err := fmt.Errorf("API error 404: not found") assert.True(t, isAutoKickNotFoundError(err)) @@ -684,6 +724,70 @@ func TestSchedulerGraceKickSkippedIfPaymentConfirmedNotActivated(t *testing.T) { assert.False(t, deleteCalled, "confirmed_not_activated не должен приводить к grace kick как будто оплаты не было") } +func TestSchedulerGraceKickSkippedWhenFreshStatusCheckFails(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(133) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(323) + price := 400 + _, err = db.CreateUser(userID, "paid_grace_error", "Paid", "uuid-323", &price, &modID) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + + payment := &database.Payment{ + TelegramID: userID, + Amount: 400, + PaymentMethod: "sbp", + Status: "pending", + } + id, err := db.CreatePayment(payment) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(id)) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = datetime('now', '-60 days') WHERE id = ?`, id) + require.NoError(t, err) + + var deleteCalled bool + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/users/uuid-323") { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`{"error":"panel unavailable"}`)), + Header: make(http.Header), + }, nil + } + if r.Method == http.MethodDelete { + deleteCalled = true + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"response":{}}`)), + Header: make(http.Header), + }, nil + } + return nil, fmt.Errorf("unexpected: %s %s", r.Method, r.URL.Path) + }), + }) + b.remnawave = client + + expireAt := time.Now().UTC().Add(-96 * time.Hour) + b.processPaidUser(userID, database.User{TelegramID: userID, RemnawaveUUID: "uuid-323"}, expireAt, time.Now().UTC()) + + assert.False(t, deleteCalled, "при ошибке свежей проверки статуса scheduler не должен идти в auto-kick") + + dbUser, err := db.GetUserByTelegramID(userID) + require.NoError(t, err) + assert.NotNil(t, dbUser, "пользователь должен остаться в БД при ошибке проверки панели") +} + // TestSchedulerMaintenanceMode проверяет, что в maintenance mode кики и disable не выполняются func TestSchedulerMaintenanceMode(t *testing.T) { b, db := setupSchedulerTestBot(t) diff --git a/internal/database/earnings.go b/internal/database/earnings.go index 1d86fd8..45d548e 100644 --- a/internal/database/earnings.go +++ b/internal/database/earnings.go @@ -23,8 +23,9 @@ type ModeratorEarning struct { func (db *DB) CreateEarning(e *ModeratorEarning) (int64, error) { res, err := db.conn.Exec( `INSERT INTO moderator_earnings (payment_id, moderator_id, gross_amount, platega_fee, withdrawal_fee, net_amount, share_percent, share_amount) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - e.PaymentID, e.ModeratorID, e.GrossAmount, e.PlategaFee, e.WithdrawalFee, e.NetAmount, e.SharePercent, e.ShareAmount, + SELECT ?, ?, ?, ?, ?, ?, ?, ? + WHERE NOT EXISTS (SELECT 1 FROM moderator_earnings WHERE payment_id = ?)`, + e.PaymentID, e.ModeratorID, e.GrossAmount, e.PlategaFee, e.WithdrawalFee, e.NetAmount, e.SharePercent, e.ShareAmount, e.PaymentID, ) if err != nil { return 0, err @@ -50,9 +51,11 @@ func (db *DB) GetModeratorEarningsByMonth(moderatorID int64, year int, month int me := &MonthlyEarnings{} err := db.conn.QueryRow( - `SELECT COUNT(*), COALESCE(SUM(gross_amount), 0), COALESCE(SUM(platega_fee), 0), - COALESCE(SUM(withdrawal_fee), 0), COALESCE(SUM(net_amount), 0), COALESCE(SUM(share_amount), 0) - FROM moderator_earnings WHERE moderator_id = ? AND created_at >= ? AND created_at < ?`, + `SELECT COUNT(*), COALESCE(SUM(me.gross_amount), 0), COALESCE(SUM(me.platega_fee), 0), + COALESCE(SUM(me.withdrawal_fee), 0), COALESCE(SUM(me.net_amount), 0), COALESCE(SUM(me.share_amount), 0) + FROM moderator_earnings me + JOIN payments p ON p.id = me.payment_id + WHERE me.moderator_id = ? AND p.confirmed_at >= ? AND p.confirmed_at < ?`, moderatorID, start, end, ).Scan(&me.TotalPayments, &me.GrossAmount, &me.TotalPlategaFee, &me.TotalWithdrawal, &me.TotalNetAmount, &me.TotalShareAmount) if err != nil { @@ -62,7 +65,12 @@ func (db *DB) GetModeratorEarningsByMonth(moderatorID int64, year int, month int // Получаем актуальный процент (из последнего начисления модератора) var pct sql.NullInt64 db.conn.QueryRow( - `SELECT share_percent FROM moderator_earnings WHERE moderator_id = ? ORDER BY created_at DESC LIMIT 1`, + `SELECT me.share_percent + FROM moderator_earnings me + JOIN payments p ON p.id = me.payment_id + WHERE me.moderator_id = ? AND p.confirmed_at IS NOT NULL + ORDER BY p.confirmed_at DESC, me.id DESC + LIMIT 1`, moderatorID, ).Scan(&pct) if pct.Valid { @@ -85,16 +93,18 @@ func (db *DB) GetModeratorTotalEarnings(moderatorID int64) (int, error) { return 0, err } -// GetAllEarningsByMonth возвращает общую статистику за месяц (для админа) +// GetAllEarningsByMonth возвращает общую статистику начислений за месяц по дате подтверждения платежа func (db *DB) GetAllEarningsByMonth(year int, month int) (*MonthlyEarnings, error) { start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) end := start.AddDate(0, 1, 0) me := &MonthlyEarnings{} err := db.conn.QueryRow( - `SELECT COUNT(*), COALESCE(SUM(gross_amount), 0), COALESCE(SUM(platega_fee), 0), - COALESCE(SUM(withdrawal_fee), 0), COALESCE(SUM(net_amount), 0), COALESCE(SUM(share_amount), 0) - FROM moderator_earnings WHERE created_at >= ? AND created_at < ?`, + `SELECT COUNT(*), COALESCE(SUM(me.gross_amount), 0), COALESCE(SUM(me.platega_fee), 0), + COALESCE(SUM(me.withdrawal_fee), 0), COALESCE(SUM(me.net_amount), 0), COALESCE(SUM(me.share_amount), 0) + FROM moderator_earnings me + JOIN payments p ON p.id = me.payment_id + WHERE p.confirmed_at >= ? AND p.confirmed_at < ?`, start, end, ).Scan(&me.TotalPayments, &me.GrossAmount, &me.TotalPlategaFee, &me.TotalWithdrawal, &me.TotalNetAmount, &me.TotalShareAmount) return me, err diff --git a/internal/database/earnings_test.go b/internal/database/earnings_test.go index 30c897b..36ace04 100644 --- a/internal/database/earnings_test.go +++ b/internal/database/earnings_test.go @@ -3,6 +3,7 @@ package database import ( "os" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -72,6 +73,9 @@ func TestGetModeratorEarningsByMonth(t *testing.T) { } paymentID, err := db.CreatePayment(p) require.NoError(t, err) + confirmedAt := time.Date(2026, time.March, 10+i, 12, 0, 0, 0, time.UTC) + _, err = db.Conn().Exec(`UPDATE payments SET status = 'confirmed', confirmed_at = ? WHERE id = ?`, confirmedAt, paymentID) + require.NoError(t, err) e := &ModeratorEarning{ PaymentID: paymentID, @@ -101,6 +105,221 @@ func TestGetModeratorEarningsByMonth(t *testing.T) { assert.Equal(t, 0, me.TotalPayments) } +func TestGetModeratorEarningsByMonth_UsesPaymentConfirmationMonth(t *testing.T) { + dbFile := "test_earnings_month_boundary.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + modID := int64(778) + + paymentID, err := db.CreatePayment(&Payment{ + TelegramID: 9001, + Amount: 500, + PaymentMethod: "card", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(paymentID)) + + confirmedAt := time.Date(2026, time.March, 31, 23, 59, 59, 0, time.UTC) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, confirmedAt, paymentID) + require.NoError(t, err) + + earningID, err := db.CreateEarning(&ModeratorEarning{ + PaymentID: paymentID, + ModeratorID: modID, + GrossAmount: 500, + PlategaFee: 50, + WithdrawalFee: 10, + NetAmount: 440, + SharePercent: 15, + ShareAmount: 66, + }) + require.NoError(t, err) + + createdAt := time.Date(2026, time.April, 1, 0, 0, 0, 0, time.UTC) + _, err = db.Conn().Exec(`UPDATE moderator_earnings SET created_at = ? WHERE id = ?`, createdAt, earningID) + require.NoError(t, err) + + me, err := db.GetModeratorEarningsByMonth(modID, 2026, 3) + require.NoError(t, err) + require.NotNil(t, me) + assert.Equal(t, 1, me.TotalPayments) + assert.Equal(t, 500, me.GrossAmount) + assert.Equal(t, 66, me.TotalShareAmount) + + allMe, err := db.GetAllEarningsByMonth(2026, 3) + require.NoError(t, err) + require.NotNil(t, allMe) + assert.Equal(t, 1, allMe.TotalPayments) + assert.Equal(t, 500, allMe.GrossAmount) + + allMe, err = db.GetAllEarningsByMonth(2026, 4) + require.NoError(t, err) + require.NotNil(t, allMe) + assert.Equal(t, 0, allMe.TotalPayments) + assert.Equal(t, 0, allMe.GrossAmount) + + me, err = db.GetModeratorEarningsByMonth(modID, 2026, 4) + require.NoError(t, err) + require.NotNil(t, me) + assert.Equal(t, 0, me.TotalPayments) + assert.Equal(t, 0, me.GrossAmount) +} + +func TestGetModeratorEarningsByMonth_IncludesConfirmedNotActivated(t *testing.T) { + dbFile := "test_earnings_confirmed_not_activated.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + modID := int64(780) + paymentID, err := db.CreatePayment(&Payment{ + TelegramID: 9201, + Amount: 800, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(paymentID)) + + confirmedAt := time.Date(2026, time.March, 20, 12, 0, 0, 0, time.UTC) + _, err = db.Conn().Exec(`UPDATE payments SET status = 'confirmed_not_activated', confirmed_at = ? WHERE id = ?`, confirmedAt, paymentID) + require.NoError(t, err) + + _, err = db.CreateEarning(&ModeratorEarning{ + PaymentID: paymentID, + ModeratorID: modID, + GrossAmount: 800, + PlategaFee: 80, + WithdrawalFee: 14, + NetAmount: 706, + SharePercent: 15, + ShareAmount: 105, + }) + require.NoError(t, err) + + me, err := db.GetModeratorEarningsByMonth(modID, 2026, 3) + require.NoError(t, err) + require.NotNil(t, me) + assert.Equal(t, 1, me.TotalPayments) + assert.Equal(t, 800, me.GrossAmount) + assert.Equal(t, 105, me.TotalShareAmount) + assert.Equal(t, 15, me.SharePercent) +} + +func TestGetModeratorEarningsByMonth_KeepsChargebackedConfirmedPaymentInHistory(t *testing.T) { + dbFile := "test_earnings_chargebacked_history.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + modID := int64(781) + paymentID, err := db.CreatePayment(&Payment{ + TelegramID: 9301, + Amount: 600, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(paymentID)) + + confirmedAt := time.Date(2026, time.March, 21, 12, 0, 0, 0, time.UTC) + _, err = db.Conn().Exec(`UPDATE payments SET status = 'chargebacked', confirmed_at = ? WHERE id = ?`, confirmedAt, paymentID) + require.NoError(t, err) + + _, err = db.CreateEarning(&ModeratorEarning{ + PaymentID: paymentID, + ModeratorID: modID, + GrossAmount: 600, + PlategaFee: 60, + WithdrawalFee: 10, + NetAmount: 530, + SharePercent: 15, + ShareAmount: 79, + }) + require.NoError(t, err) + + me, err := db.GetModeratorEarningsByMonth(modID, 2026, 3) + require.NoError(t, err) + require.NotNil(t, me) + assert.Equal(t, 1, me.TotalPayments) + assert.Equal(t, 600, me.GrossAmount) + assert.Equal(t, 79, me.TotalShareAmount) +} + +func TestGetModeratorEarningsByMonth_UsesLatestPaymentConfirmationForSharePercent(t *testing.T) { + dbFile := "test_earnings_share_percent.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + modID := int64(779) + + firstPaymentID, err := db.CreatePayment(&Payment{ + TelegramID: 9101, + Amount: 500, + PaymentMethod: "card", + Status: "confirmed", + }) + require.NoError(t, err) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, time.Date(2026, time.March, 5, 10, 0, 0, 0, time.UTC), firstPaymentID) + require.NoError(t, err) + + secondPaymentID, err := db.CreatePayment(&Payment{ + TelegramID: 9102, + Amount: 500, + PaymentMethod: "card", + Status: "confirmed", + }) + require.NoError(t, err) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, time.Date(2026, time.March, 25, 10, 0, 0, 0, time.UTC), secondPaymentID) + require.NoError(t, err) + + _, err = db.CreateEarning(&ModeratorEarning{ + PaymentID: secondPaymentID, + ModeratorID: modID, + GrossAmount: 500, + PlategaFee: 15, + WithdrawalFee: 10, + NetAmount: 475, + SharePercent: 20, + ShareAmount: 95, + }) + require.NoError(t, err) + + _, err = db.CreateEarning(&ModeratorEarning{ + PaymentID: firstPaymentID, + ModeratorID: modID, + GrossAmount: 500, + PlategaFee: 15, + WithdrawalFee: 10, + NetAmount: 475, + SharePercent: 10, + ShareAmount: 47, + }) + require.NoError(t, err) + + me, err := db.GetModeratorEarningsByMonth(modID, 2026, 3) + require.NoError(t, err) + require.NotNil(t, me) + assert.Equal(t, 2, me.TotalPayments) + assert.Equal(t, 20, me.SharePercent) +} + func TestGetModeratorTotalEarnings(t *testing.T) { dbFile := "test_earnings_total.db" db, err := New(dbFile) diff --git a/internal/database/payments.go b/internal/database/payments.go index 581a093..29255ec 100644 --- a/internal/database/payments.go +++ b/internal/database/payments.go @@ -20,6 +20,12 @@ type Payment struct { ConfirmedAt *time.Time } +// MonthlyConfirmedPayment хранит подтверждённый платёж месяца и долю модератора. +type MonthlyConfirmedPayment struct { + Payment + ShareAmount int +} + // CreatePayment создаёт новый платёж func (db *DB) CreatePayment(p *Payment) (int64, error) { res, err := db.conn.Exec( @@ -251,25 +257,85 @@ func (db *DB) HasConfirmedPaymentSince(telegramID int64, since time.Time) (bool, return exists, err } -// CountConfirmedPaymentsByMonth считает платежи за месяц (для статистики) +// GetConfirmedPaymentsByMonth возвращает финансово подтверждённые платежи за месяц. +// Платежи без moderator_earnings остаются в выборке, а доля модератора для них равна нулю. +func (db *DB) GetConfirmedPaymentsByMonth(year int, month int) ([]MonthlyConfirmedPayment, error) { + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(0, 1, 0) + + rows, err := db.conn.Query( + `SELECT p.id, p.telegram_id, p.moderator_id, p.amount, p.payment_method, p.status, + p.platega_transaction_id, p.redirect_url, p.expires_at, p.created_at, p.confirmed_at, + COALESCE(me.share_amount, 0) + FROM payments p + LEFT JOIN ( + SELECT payment_id, COALESCE(SUM(share_amount), 0) AS share_amount + FROM moderator_earnings + GROUP BY payment_id + ) me ON me.payment_id = p.id + WHERE p.confirmed_at >= ? AND p.confirmed_at < ? + ORDER BY p.confirmed_at ASC, p.id ASC`, + start, end, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var payments []MonthlyConfirmedPayment + for rows.Next() { + var p MonthlyConfirmedPayment + var modID sql.NullInt64 + var txID sql.NullString + var redirectURL sql.NullString + var expiresAt sql.NullTime + var confirmedAt sql.NullTime + + if err := rows.Scan(&p.ID, &p.TelegramID, &modID, &p.Amount, &p.PaymentMethod, &p.Status, &txID, &redirectURL, &expiresAt, &p.CreatedAt, &confirmedAt, &p.ShareAmount); err != nil { + return nil, err + } + + if modID.Valid { + p.ModeratorID = &modID.Int64 + } + if txID.Valid { + p.PlategaTransactionID = &txID.String + } + if redirectURL.Valid { + p.RedirectURL = &redirectURL.String + } + if expiresAt.Valid { + p.ExpiresAt = &expiresAt.Time + } + if confirmedAt.Valid { + p.ConfirmedAt = &confirmedAt.Time + } + + payments = append(payments, p) + } + + return payments, rows.Err() +} + +// CountConfirmedPaymentsByMonth считает финансово подтверждённые платежи за месяц. func (db *DB) CountConfirmedPaymentsByMonth(year int, month int) (int, error) { var count int start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) end := start.AddDate(0, 1, 0) err := db.conn.QueryRow( - `SELECT COUNT(*) FROM payments WHERE status = 'confirmed' AND confirmed_at >= ? AND confirmed_at < ?`, + `SELECT COUNT(*) FROM payments WHERE confirmed_at >= ? AND confirmed_at < ?`, start, end, ).Scan(&count) return count, err } -// SumConfirmedPaymentsByMonth возвращает сумму платежей за месяц +// SumConfirmedPaymentsByMonth возвращает сумму финансово подтверждённых платежей за месяц. func (db *DB) SumConfirmedPaymentsByMonth(year int, month int) (int, error) { var sum int start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) end := start.AddDate(0, 1, 0) err := db.conn.QueryRow( - `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE status = 'confirmed' AND confirmed_at >= ? AND confirmed_at < ?`, + `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE confirmed_at >= ? AND confirmed_at < ?`, start, end, ).Scan(&sum) return sum, err @@ -292,11 +358,12 @@ func (db *DB) CountFirstPaymentsByMonth(year int, month int) (int, error) { var count int start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) end := start.AddDate(0, 1, 0) - // Считаем пользователей, у которых первый confirmed платёж попал в этот месяц + // Считаем пользователей, у которых первая финансово подтверждённая оплата попала в этот месяц. err := db.conn.QueryRow( `SELECT COUNT(*) FROM ( SELECT telegram_id, MIN(confirmed_at) as first_payment - FROM payments WHERE status = 'confirmed' + FROM payments + WHERE confirmed_at IS NOT NULL GROUP BY telegram_id HAVING first_payment >= ? AND first_payment < ? )`, start, end, diff --git a/internal/database/payments_test.go b/internal/database/payments_test.go index 6d99354..9b67faa 100644 --- a/internal/database/payments_test.go +++ b/internal/database/payments_test.go @@ -188,6 +188,222 @@ func TestConfirmPaymentPreservesExistingConfirmedAt(t *testing.T) { assert.True(t, got.ConfirmedAt.Equal(original), "confirmed_at не должен перезаписываться при retry") } +func TestGetConfirmedPaymentsByMonth(t *testing.T) { + dbFile := "test_payments_confirmed_month.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + targetMonth := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) + targetAdminConfirmedAt := targetMonth.Add(9 * 24 * time.Hour) + targetModeratorConfirmedAt := targetMonth.Add(10 * 24 * time.Hour) + targetNotActivatedConfirmedAt := targetMonth.Add(11 * 24 * time.Hour) + targetChargebackedConfirmedAt := targetMonth.Add(12 * 24 * time.Hour) + previousMonthConfirmedAt := targetMonth.AddDate(0, -1, 0).Add(20 * 24 * time.Hour) + + modID := int64(100) + + adminPaymentID, err := db.CreatePayment(&Payment{ + TelegramID: 200, + Amount: 1000, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(adminPaymentID)) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, targetAdminConfirmedAt, adminPaymentID) + require.NoError(t, err) + + moderatorPaymentID, err := db.CreatePayment(&Payment{ + TelegramID: 201, + ModeratorID: &modID, + Amount: 500, + PaymentMethod: "card", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(moderatorPaymentID)) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, targetModeratorConfirmedAt, moderatorPaymentID) + require.NoError(t, err) + _, err = db.CreateEarning(&ModeratorEarning{ + PaymentID: moderatorPaymentID, + ModeratorID: modID, + GrossAmount: 500, + PlategaFee: 60, + WithdrawalFee: 8, + NetAmount: 432, + SharePercent: 15, + ShareAmount: 66, + }) + require.NoError(t, err) + + previousMonthPaymentID, err := db.CreatePayment(&Payment{ + TelegramID: 202, + ModeratorID: &modID, + Amount: 700, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(previousMonthPaymentID)) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, previousMonthConfirmedAt, previousMonthPaymentID) + require.NoError(t, err) + _, err = db.CreateEarning(&ModeratorEarning{ + PaymentID: previousMonthPaymentID, + ModeratorID: modID, + GrossAmount: 700, + PlategaFee: 70, + WithdrawalFee: 12, + NetAmount: 618, + SharePercent: 15, + ShareAmount: 92, + }) + require.NoError(t, err) + + notActivatedPaymentID, err := db.CreatePayment(&Payment{ + TelegramID: 203, + ModeratorID: &modID, + Amount: 800, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(notActivatedPaymentID)) + _, err = db.Conn().Exec(`UPDATE payments SET status = 'confirmed_not_activated', confirmed_at = ? WHERE id = ?`, targetNotActivatedConfirmedAt, notActivatedPaymentID) + require.NoError(t, err) + _, err = db.CreateEarning(&ModeratorEarning{ + PaymentID: notActivatedPaymentID, + ModeratorID: modID, + GrossAmount: 800, + PlategaFee: 80, + WithdrawalFee: 14, + NetAmount: 706, + SharePercent: 15, + ShareAmount: 105, + }) + require.NoError(t, err) + + chargebackedPaymentID, err := db.CreatePayment(&Payment{ + TelegramID: 204, + Amount: 600, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(chargebackedPaymentID)) + _, err = db.Conn().Exec(`UPDATE payments SET status = 'chargebacked', confirmed_at = ? WHERE id = ?`, targetChargebackedConfirmedAt, chargebackedPaymentID) + require.NoError(t, err) + + payments, err := db.GetConfirmedPaymentsByMonth(2026, 3) + require.NoError(t, err) + require.Len(t, payments, 4) + + byTelegramID := make(map[int64]MonthlyConfirmedPayment, len(payments)) + for _, payment := range payments { + byTelegramID[payment.TelegramID] = payment + } + + adminPayment, ok := byTelegramID[200] + require.True(t, ok) + assert.Nil(t, adminPayment.ModeratorID) + assert.Equal(t, 1000, adminPayment.Amount) + assert.Equal(t, 0, adminPayment.ShareAmount) + + moderatorPayment, ok := byTelegramID[201] + require.True(t, ok) + require.NotNil(t, moderatorPayment.ModeratorID) + assert.Equal(t, modID, *moderatorPayment.ModeratorID) + assert.Equal(t, 500, moderatorPayment.Amount) + assert.Equal(t, 66, moderatorPayment.ShareAmount) + + _, ok = byTelegramID[202] + assert.False(t, ok, "платёж из предыдущего месяца не должен попадать в выборку") + + notActivatedPayment, ok := byTelegramID[203] + require.True(t, ok) + require.NotNil(t, notActivatedPayment.ModeratorID) + assert.Equal(t, modID, *notActivatedPayment.ModeratorID) + assert.Equal(t, 800, notActivatedPayment.Amount) + assert.Equal(t, 105, notActivatedPayment.ShareAmount) + + chargebackedPayment, ok := byTelegramID[204] + require.True(t, ok) + assert.Equal(t, 600, chargebackedPayment.Amount) + assert.Equal(t, 0, chargebackedPayment.ShareAmount) +} + +func TestCountFirstPaymentsByMonth_IncludesFinanciallyConfirmedStatuses(t *testing.T) { + dbFile := "test_payments_first_payments_month.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + firstConfirmedID, err := db.CreatePayment(&Payment{ + TelegramID: 301, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(firstConfirmedID)) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC), firstConfirmedID) + require.NoError(t, err) + + firstNotActivatedID, err := db.CreatePayment(&Payment{ + TelegramID: 302, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(firstNotActivatedID)) + _, err = db.Conn().Exec(`UPDATE payments SET status = 'confirmed_not_activated', confirmed_at = ? WHERE id = ?`, time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC), firstNotActivatedID) + require.NoError(t, err) + + chargebackedID, err := db.CreatePayment(&Payment{ + TelegramID: 303, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(chargebackedID)) + _, err = db.Conn().Exec(`UPDATE payments SET status = 'chargebacked', confirmed_at = ? WHERE id = ?`, time.Date(2026, time.March, 7, 12, 0, 0, 0, time.UTC), chargebackedID) + require.NoError(t, err) + + previousMonthID, err := db.CreatePayment(&Payment{ + TelegramID: 304, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(previousMonthID)) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, time.Date(2026, time.February, 20, 12, 0, 0, 0, time.UTC), previousMonthID) + require.NoError(t, err) + + secondMarchPaymentID, err := db.CreatePayment(&Payment{ + TelegramID: 304, + Amount: 500, + PaymentMethod: "sbp", + Status: "pending", + }) + require.NoError(t, err) + require.NoError(t, db.ConfirmPayment(secondMarchPaymentID)) + _, err = db.Conn().Exec(`UPDATE payments SET confirmed_at = ? WHERE id = ?`, time.Date(2026, time.March, 8, 12, 0, 0, 0, time.UTC), secondMarchPaymentID) + require.NoError(t, err) + + count, err := db.CountFirstPaymentsByMonth(2026, 3) + require.NoError(t, err) + assert.Equal(t, 3, count) +} + func TestExpireOldPendingPayments(t *testing.T) { dbFile := "test_payments_expire.db" db, err := New(dbFile) From 5d57c9a739f2f77c9a31ed0b61ae2b1f8fa50456 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 19:45:45 +0300 Subject: [PATCH 27/34] =?UTF-8?q?fix:=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D1=8C=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BD=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D1=83=D1=8E=20=D0=B8=20live-=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-03-21-admin-ui-redesign.md | 54 +++++++----- .../plans/2026-03-21-moderator-ui-redesign.md | 22 +++-- ...payment-validation-remediation-progress.md | 25 +++--- internal/bot/admin.go | 58 ++++++------- internal/bot/admin_test.go | 7 ++ internal/bot/moderator.go | 83 +++++++++++++++++-- internal/bot/moderator_test.go | 34 +++++++- 7 files changed, 202 insertions(+), 81 deletions(-) diff --git a/docs/plans/2026-03-21-admin-ui-redesign.md b/docs/plans/2026-03-21-admin-ui-redesign.md index 729ae97..5a79715 100644 --- a/docs/plans/2026-03-21-admin-ui-redesign.md +++ b/docs/plans/2026-03-21-admin-ui-redesign.md @@ -237,32 +237,42 @@ ### 📊 Статистика (модераторов) -Финансовая и операционная сводка по каждому модератору отдельно. Каждый модератор — **отдельное сообщение**, в конце — итоговое сообщение. Все данные — **live-подсчёт** из `moderator_earnings`, фильтр по календарному месяцу. По умолчанию показывается **прошлый завершённый месяц** (для определения сумм к выплате). +Финансовая и операционная сводка по каждому модератору отдельно. Каждый модератор — **отдельное сообщение**, в конце — итоговое сообщение. Финансы считаются **за прошлый завершённый календарный месяц** по `moderator_earnings` и `payments.confirmed_at`, а состояние клиентов показывается **только как текущее live-состояние**. Сообщение 1 (модератор): ``` 📊 Статистика: Иван (@ivan) — март 2026 -💳 Платящих: 8 │ ⏳ Триал: 2 │ ⚠️ Grace: 1 -📥 Платежи: 3 200 руб -📉 Комиссии Platega: -368 руб -📉 Комиссия вывода (2%): -57 руб -📊 Чистый доход: 2 775 руб -💰 Доля модератора (15%): 416 руб -💰 За всё время: 2 100 руб +💰 Финансы за март 2026 +├ Платежи: 3 200 руб +├ Комиссии Platega: -368 руб +├ Комиссия вывода (2%): -57 руб +├ Чистый доход: 2 775 руб +└ Доля модератора (15%): 416 руб + +💰 За всё время +└ Заработано: 2 100 руб + +👥 Текущее состояние клиентов +└ 💳 Платящих: 8 │ ⏳ Триал: 2 │ ⚠️ Grace: 1 ``` Сообщение 2 (модератор): ``` 📊 Статистика: Пётр (@petr) — март 2026 -💳 Платящих: 17 │ ⏳ Триал: 3 │ ⚠️ Grace: 0 -📥 Платежи: 8 500 руб -📉 Комиссии Platega: -935 руб -📉 Комиссия вывода (2%): -151 руб -📊 Чистый доход: 7 414 руб -💰 Доля модератора (20%): 1 483 руб -💰 За всё время: 5 400 руб +💰 Финансы за март 2026 +├ Платежи: 8 500 руб +├ Комиссии Platega: -935 руб +├ Комиссия вывода (2%): -151 руб +├ Чистый доход: 7 414 руб +└ Доля модератора (20%): 1 483 руб + +💰 За всё время +└ Заработано: 5 400 руб + +👥 Текущее состояние клиентов +└ 💳 Платящих: 17 │ ⏳ Триал: 3 │ ⚠️ Grace: 0 ``` Сообщение 3 (итого): @@ -294,12 +304,12 @@ ## 📊 Общая статистика -Финансовая и операционная сводка по всему бизнесу за текущий месяц. +Финансовая и операционная сводка по всему бизнесу. Финансы и конверсия считаются **за текущий календарный месяц**, а статусы пользователей показываются **как текущее состояние системы**. ``` 📊 Общая статистика — март 2026 -💰 Финансы +💰 Финансы за март 2026 ├ Платежей за месяц: 32 ├ Сумма платежей (грязная): 14 800 руб ├ Комиссии Platega: -1 672 руб @@ -308,18 +318,20 @@ ├ Выплаты модераторам: -1 899 руб └ Доход владельца: 10 966 руб -👥 Пользователи +📈 Воронка за март 2026 +└ Конверсия триал → оплата: 72% + +👥 Текущее состояние пользователей ├ Всего в системе: 45 ├ 💳 Платящих: 28 ├ ⏳ Триал: 5 ├ ⚠️ Grace period: 3 -├ ♾️ Бессрочных: 9 -└ 📈 Конверсия триал → оплата: 72% (за месяц) +└ ♾️ Бессрочных: 9 ``` ### Логика "Конверсия триал → оплата" -За текущий месяц: количество пользователей, которые оплатили первый раз / количество пользователей, которые активировали триал. Показывает эффективность привлечения. +За текущий месяц: количество пользователей, которые оплатили первый раз / количество пользователей, которые активировали триал. Это period-метрика, её нельзя смешивать с текущими статусами пользователей в том же блоке. --- diff --git a/docs/plans/2026-03-21-moderator-ui-redesign.md b/docs/plans/2026-03-21-moderator-ui-redesign.md index 4aa21cc..8a47de1 100644 --- a/docs/plans/2026-03-21-moderator-ui-redesign.md +++ b/docs/plans/2026-03-21-moderator-ui-redesign.md @@ -181,25 +181,35 @@ ``` 💰 Мой заработок -За март 2026: -├ Платящих клиентов (на 01.03): 12 -├ Ваша доля: 15% (до 15 клиентов) +💰 Финансы за март 2026 +├ Платежей: 12 +├ Ваша доля: 15% ├ Сумма платежей: 5 600 руб ├ Комиссии Platega: -648 руб ├ Комиссия вывода (2%): -99 руб ├ Чистый доход: 4 853 руб -└ Ваша доля: 728 руб +└ Заработок за период: 728 руб -За всё время: 4 250 руб +💰 За всё время +└ Заработано: 4 250 руб + +👥 Текущее состояние подписчиков +├ 💳 Платящих: 12 +├ ⏳ Триал: 3 +├ ⚠️ Grace: 1 +├ ⏰ Истекших: 2 +└ ❌ Удалённых: 1 ``` ### Логика расчёта Данные берутся из таблицы `moderator_earnings` (заполняется при каждом CONFIRMED-платеже, см. бизнес-план). -- **За текущий месяц** — live-подсчёт: `SELECT SUM(share_amount)` с фильтром по месяцу +- **Финансы за период** — агрегаты по `moderator_earnings` и `payments.confirmed_at` за календарный месяц - **За всё время** — `SELECT SUM(share_amount)` без фильтра +- **Текущее состояние подписчиков** — live-подсчёт по текущему списку подписчиков и их текущим статусам в Remnawave - Процент доли и все комиссии зафиксированы в каждой записи на момент платежа — пересчёта нет +- UI не должен обещать historical snapshot вида "на 01.MM" или "paid/trial/grace за прошлый месяц", потому что такие данные в БД не хранятся --- diff --git a/docs/progress/2026-03-23-payment-validation-remediation-progress.md b/docs/progress/2026-03-23-payment-validation-remediation-progress.md index b75a39b..99cce15 100644 --- a/docs/progress/2026-03-23-payment-validation-remediation-progress.md +++ b/docs/progress/2026-03-23-payment-validation-remediation-progress.md @@ -36,22 +36,21 @@ - В карточке пользователя убран хардкод `✅ Статус:`. - Строка статуса теперь рендерится с корректным эмодзи по реальному состоянию (`✅`, `⛔`, `⏰`, `⚠️`). -## Что пока не выполнено +### P2: честный статистический UI без snapshot-данных -### Историческая статистика админа и модераторов +- Для `docs/plans/2026-03-21-moderator-ui-redesign.md` и `docs/plans/2026-03-21-admin-ui-redesign.md` выбран и зафиксирован путь 2: + snapshot-таблицы и миграции не добавляются, UI больше не обещает historical state `на 01.MM`. +- Экран `💰 Мой заработок` у модератора разделён на три честных блока: + `Финансы за период`, `За всё время`, `Текущее состояние подписчиков`. +- В `📊 Статистика` модераторов у админа финансовые агрегаты за прошлый завершённый месяц больше не смешиваются с текущими live-статусами клиентов. +- В `📊 Общая статистика` у админа period-метрика `Конверсия триал → оплата` вынесена из пользовательского live-блока в отдельную секцию за текущий месяц. +- Тесты `internal/bot/moderator_test.go` и `internal/bot/admin_test.go` обновлены под новый контракт текстов. -Остаётся открытым блок, связанный с: +## Открытые пункты -- `Мой заработок` как snapshot `на 01.MM`; -- `Статистика модераторов за прошлый месяц` по historical-состояниям `paid/trial/grace`; -- полной честностью финансовых historical-отчётов при изменяемых fee-конфигах. - -Причина остановки: -часть замечаний нельзя корректно закрыть без явного решения по модели данных. -Нужен выбор одной из стратегий: - -- добавить новые snapshot-данные и миграции; -- или честно упростить/переименовать UI под доступные live-данные. +По remediation-плану path 2 открытых продуктовых пунктов не осталось. +Исторические snapshot-данные сознательно не добавлялись: +UI и документация приведены в соответствие с тем, что текущая модель данных умеет считать честно. ## Проверки diff --git a/internal/bot/admin.go b/internal/bot/admin.go index 791a231..9ae39d5 100644 --- a/internal/bot/admin.go +++ b/internal/bot/admin.go @@ -730,7 +730,7 @@ func (b *Bot) handleAdminStats(c tele.Context) error { msg := fmt.Sprintf( "📊 Общая статистика — %s %d\n\n"+ - "💰 Финансы\n"+ + "💰 Финансы за %s %d\n"+ "├ Платежей за месяц: %d\n"+ "├ Сумма платежей (грязная): %d руб\n"+ "├ Комиссии Platega: -%d руб\n"+ @@ -738,13 +738,16 @@ func (b *Bot) handleAdminStats(c tele.Context) error { "├ Чистый доход: %d руб\n"+ "├ Выплаты модераторам: -%d руб\n"+ "└ Доход владельца: %d руб\n\n"+ - "👥 Пользователи\n"+ + "📈 Воронка за %s %d\n"+ + "└ Конверсия триал → оплата: %d%%\n\n"+ + "👥 Текущее состояние пользователей\n"+ "├ Всего в системе: %d\n"+ "├ 💳 Платящих: %d\n"+ "├ ⏳ Триал: %d\n"+ "├ ⚠️ Grace period: %d\n"+ - "├ ♾️ Бессрочных: %d\n"+ - "└ 📈 Конверсия триал → оплата: %d%%", + "└ ♾️ Бессрочных: %d", + monthNameRu(now.Month()), + now.Year(), monthNameRu(now.Month()), now.Year(), monthEarnings.TotalPayments, @@ -754,12 +757,14 @@ func (b *Bot) handleAdminStats(c tele.Context) error { monthEarnings.TotalNetAmount, monthEarnings.TotalShareAmount, ownerIncome, + monthNameRu(now.Month()), + now.Year(), + conversion, totalUsers, payingCount, trialCount, graceCount, infiniteCount, - conversion, ) return c.Send(msg, &tele.SendOptions{ @@ -1179,23 +1184,7 @@ func (b *Bot) handleAdminModStats(c tele.Context) error { continue } - paying := 0 - trial := 0 - grace := 0 - for _, sub := range subs { - remUser, ok := byTelegramID[sub.TelegramID] - if !ok { - continue - } - switch b.describeSubscriberStatus(sub.TelegramID, remUser, now) { - case "paid": - paying++ - case "trial": - trial++ - case "grace": - grace++ - } - } + currentState := b.summarizeModeratorSubscriberStates(subs, byTelegramID, now) monthStats, err := b.db.GetModeratorEarningsByMonth(mod.TelegramID, reportYear, reportMonth) if err != nil { @@ -1215,19 +1204,21 @@ func (b *Bot) handleAdminModStats(c tele.Context) error { sharePercent := monthStats.SharePercent msg := fmt.Sprintf( "📊 Статистика: %s — %s %d\n\n"+ - "💳 Платящих: %d │ ⏳ Триал: %d │ ⚠️ Grace: %d\n"+ - "📥 Платежи: %d руб\n"+ - "📉 Комиссии Platega: -%d руб\n"+ - "📉 Комиссия вывода (2%%): -%d руб\n"+ - "📊 Чистый доход: %d руб\n"+ - "💰 Доля модератора (%d%%): %d руб\n"+ - "💰 За всё время: %d руб", + "💰 Финансы за %s %d\n"+ + "├ Платежи: %d руб\n"+ + "├ Комиссии Platega: -%d руб\n"+ + "├ Комиссия вывода (2%%): -%d руб\n"+ + "├ Чистый доход: %d руб\n"+ + "└ Доля модератора (%d%%): %d руб\n\n"+ + "💰 За всё время\n"+ + "└ Заработано: %d руб\n\n"+ + "👥 Текущее состояние клиентов\n"+ + "└ 💳 Платящих: %d │ ⏳ Триал: %d │ ⚠️ Grace: %d", formatAdminModeratorLabel(mod.FirstName, mod.Username, mod.TelegramID), monthNameRu(reportDate.Month()), reportYear, - paying, - trial, - grace, + monthNameRu(reportDate.Month()), + reportYear, monthStats.GrossAmount, monthStats.TotalPlategaFee, monthStats.TotalWithdrawal, @@ -1235,6 +1226,9 @@ func (b *Bot) handleAdminModStats(c tele.Context) error { sharePercent, monthStats.TotalShareAmount, totalEarnings, + currentState.Paying, + currentState.Trial, + currentState.Grace, ) if err := c.Send(msg, &tele.SendOptions{ParseMode: tele.ModeHTML}); err != nil { diff --git a/internal/bot/admin_test.go b/internal/bot/admin_test.go index e690cd6..62e0dee 100644 --- a/internal/bot/admin_test.go +++ b/internal/bot/admin_test.go @@ -269,6 +269,9 @@ func TestHandleAdminModStats(t *testing.T) { require.True(t, ok) assert.Contains(t, msg, "Статистика:") assert.Contains(t, msg, "@moderator") + assert.Contains(t, msg, "Финансы за") + assert.Contains(t, msg, "За всё время") + assert.Contains(t, msg, "Текущее состояние клиентов") assert.Contains(t, msg, "Платящих: 1") assert.Contains(t, msg, "Платежи: 500 руб") assert.Contains(t, msg, "Доля модератора (15%)") @@ -585,6 +588,9 @@ func TestHandleAdminStats_ShowsFinanceAndConversion(t *testing.T) { msg, ok := ctx.sentMsg.(string) require.True(t, ok) assert.Contains(t, msg, "Общая статистика") + assert.Contains(t, msg, "Финансы за") + assert.Contains(t, msg, "Воронка за") + assert.Contains(t, msg, "Текущее состояние пользователей") assert.Contains(t, msg, "Платежей за месяц: 1") assert.Contains(t, msg, "Сумма платежей (грязная): 500 руб") // Комиссии считаются через calculateMonthlyPaymentFinance (целочисленное деление): @@ -600,6 +606,7 @@ func TestHandleAdminStats_ShowsFinanceAndConversion(t *testing.T) { assert.Contains(t, msg, "⚠️ Grace period: 1") assert.Contains(t, msg, "♾️ Бессрочных: 1") assert.Contains(t, msg, "Конверсия триал → оплата: 33%") + assert.NotContains(t, msg, "👥 Пользователи") } func TestHandleAdminStats_IncludesAdminPaymentsAndModeratorPayouts(t *testing.T) { diff --git a/internal/bot/moderator.go b/internal/bot/moderator.go index 7c648ce..31ae090 100644 --- a/internal/bot/moderator.go +++ b/internal/bot/moderator.go @@ -28,6 +28,14 @@ type modChangePriceSession struct { CurrentPrice int } +type moderatorSubscriberStateSummary struct { + Paying int + Trial int + Grace int + Expired int + Deleted int +} + // isModerator проверяет, является ли пользователь модератором. func (b *Bot) isModerator(telegramID int64) bool { ok, err := b.db.IsModerator(telegramID) @@ -244,30 +252,50 @@ func (b *Bot) handleModeratorEarnings(c tele.Context) error { return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) } - payingCount, err := b.db.CountPayingSubscribersByModerator(moderatorID) + subscribers, err := b.db.GetSubscribersByModerator(moderatorID) if err != nil { - slog.Error("Failed to count paying subscribers", "error", err, "moderator_id", moderatorID) + slog.Error("Failed to load subscribers for moderator earnings", "error", err, "moderator_id", moderatorID) return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) } + remUsers, err := b.remnawave.GetAllUsers() + if err != nil { + slog.Error("Failed to load Remnawave users for moderator earnings", "error", err, "moderator_id", moderatorID) + return c.Send("Ошибка получения статистики из панели", &tele.SendOptions{ReplyMarkup: ModeratorMenuKeyboard()}) + } + + remByTelegramID := make(map[int64]remnawave.User, len(remUsers)) + for _, user := range remUsers { + if user.TelegramID == nil || *user.TelegramID == 0 { + continue + } + remByTelegramID[*user.TelegramID] = user + } + + currentState := b.summarizeModeratorSubscriberStates(subscribers, remByTelegramID, now) + sharePercent := monthStats.SharePercent if sharePercent == 0 { - sharePercent = calculateSharePercent(payingCount) + sharePercent = calculateSharePercent(currentState.Paying) } msg := fmt.Sprintf( - "💰 Мой заработок\n\nЗа %s %d:\n"+ - "├ Платящих клиентов: %d\n"+ + "💰 Мой заработок\n\n"+ + "💰 Финансы за %s %d\n"+ + "├ Платежей: %d\n"+ "├ Ваша доля: %d%%\n"+ "├ Сумма платежей: %d руб\n"+ "├ Комиссии Platega: -%d руб\n"+ "├ Комиссия вывода: -%d руб\n"+ "├ Чистый доход: %d руб\n"+ - "└ Ваша доля: %d руб\n\n"+ - "За всё время: %d руб", + "└ Заработок за период: %d руб\n\n"+ + "💰 За всё время\n"+ + "└ Заработано: %d руб\n\n"+ + "👥 Текущее состояние подписчиков\n"+ + "└ 💳 Платящих: %d │ ⏳ Триал: %d │ ⚠️ Grace: %d │ ⏰ Истекших: %d │ ❌ Удалённых: %d", monthNameRu(now.Month()), now.Year(), - payingCount, + monthStats.TotalPayments, sharePercent, monthStats.GrossAmount, monthStats.TotalPlategaFee, @@ -275,6 +303,11 @@ func (b *Bot) handleModeratorEarnings(c tele.Context) error { monthStats.TotalNetAmount, monthStats.TotalShareAmount, totalEarnings, + currentState.Paying, + currentState.Trial, + currentState.Grace, + currentState.Expired, + currentState.Deleted, ) return c.Send(msg, &tele.SendOptions{ @@ -535,6 +568,40 @@ func (b *Bot) describeSubscriberStatus(telegramID int64, remUser remnawave.User, return "paid" } +func (b *Bot) summarizeModeratorSubscriberStates( + subscribers []database.Subscriber, + byTelegramID map[int64]remnawave.User, + now time.Time, +) moderatorSubscriberStateSummary { + var summary moderatorSubscriberStateSummary + + for _, sub := range subscribers { + if sub.RemnawaveUUID == nil { + summary.Deleted++ + continue + } + + remUser, ok := byTelegramID[sub.TelegramID] + if !ok { + summary.Deleted++ + continue + } + + switch b.describeSubscriberStatus(sub.TelegramID, remUser, now) { + case "trial": + summary.Trial++ + case "grace": + summary.Grace++ + case "expired": + summary.Expired++ + default: + summary.Paying++ + } + } + + return summary +} + func formatPriceLabel(price *int) string { if price == nil { return "не установлена" diff --git a/internal/bot/moderator_test.go b/internal/bot/moderator_test.go index 8f9f741..2e1f46f 100644 --- a/internal/bot/moderator_test.go +++ b/internal/bot/moderator_test.go @@ -419,6 +419,20 @@ func TestHandleModeratorEarnings(t *testing.T) { }) require.NoError(t, err) + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/api/users" && r.URL.RawQuery == "size=1000" { + payload := `{"response":{"users":[{"uuid":"uuid-300","telegramId":300,"username":"paid","status":"ACTIVE","expireAt":"2026-04-20T00:00:00Z"}],"total":1}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + } + return nil, assert.AnError + }), + }) + ctx := &MockContext{ sender: &tele.User{ID: modID, Username: "moderator"}, message: &tele.Message{}, @@ -430,7 +444,11 @@ func TestHandleModeratorEarnings(t *testing.T) { sentStr, ok := ctx.sentMsg.(string) require.True(t, ok) assert.Contains(t, sentStr, "Мой заработок") - assert.Contains(t, sentStr, "Платящих клиентов") + assert.Contains(t, sentStr, "Финансы за") + assert.Contains(t, sentStr, "За всё время") + assert.Contains(t, sentStr, "Текущее состояние подписчиков") + assert.Contains(t, sentStr, "💳 Платящих: 1") + assert.NotContains(t, sentStr, "Платящих клиентов") assert.Contains(t, sentStr, "500 руб") assert.Contains(t, sentStr, "65 руб") } @@ -496,6 +514,20 @@ func TestHandleTextMessage_ModeratorButtons(t *testing.T) { }) t.Run("Кнопка_Заработок_открывает_сводку", func(t *testing.T) { + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/api/users" && r.URL.RawQuery == "size=1000" { + payload := `{"response":{"users":[],"total":0}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + } + return nil, assert.AnError + }), + }) + ctx := &MockContext{ sender: user, message: &tele.Message{Text: BtnModEarnings}, From 48fd50281baa692bf68a20e3e3d25568c7fc1e29 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 20:12:26 +0300 Subject: [PATCH 28/34] =?UTF-8?q?fix:=20=D0=B2=D0=BE=D1=81=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20startup=20reconcile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/bot/main.go | 17 ++++--- internal/bot/startup.go | 89 ++++++++++++++++++++++++++++++++ internal/bot/startup_test.go | 99 ++++++++++++++++++++++++++++++++++++ internal/database/invites.go | 56 ++++++++++++++++++++ 4 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 internal/bot/startup.go create mode 100644 internal/bot/startup_test.go diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 81865b4..2b95bf6 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -41,13 +41,6 @@ func main() { } defer db.Close() - // Откат инвайтов, зависших после краша (claimed но пользователь не создан) - if count, err := db.ReconcileOrphanedInvites(); err != nil { - slog.Error("Failed to reconcile orphaned invites", "error", err) - } else if count > 0 { - slog.Warn("Reconciled orphaned invites on startup", "count", count) - } - // Создание клиента Remnawave API remnawaveClient := remnawave.NewClient( cfg.RemnawaveURL, @@ -55,6 +48,16 @@ func main() { cfg.RemnawaveSquadUUIDs, ) + // Восстановление регистраций, застрявших после краша между Remnawave и локальной БД. + if stats, err := bot.ReconcileOrphanedRegistrations(db, remnawaveClient); err != nil { + slog.Error("Failed to reconcile orphaned registrations", "error", err) + } else if stats.RestoredUsers > 0 || stats.ReleasedInvites > 0 { + slog.Warn("Reconciled orphaned registrations on startup", + "restored_users", stats.RestoredUsers, + "released_invites", stats.ReleasedInvites, + ) + } + // Создание и запуск Telegram бота telegramBot, err := bot.New(cfg, db, remnawaveClient) if err != nil { diff --git a/internal/bot/startup.go b/internal/bot/startup.go new file mode 100644 index 0000000..9892778 --- /dev/null +++ b/internal/bot/startup.go @@ -0,0 +1,89 @@ +package bot + +import ( + "fmt" + "strings" + + "github.com/fus1ond/vpn_bot/internal/database" + "github.com/fus1ond/vpn_bot/internal/remnawave" +) + +// StartupReconcileStats содержит итоги восстановления регистрации после перезапуска. +type StartupReconcileStats struct { + RestoredUsers int + ReleasedInvites int +} + +// ReconcileOrphanedRegistrations чинит застрявшие регистрации после падения процесса. +// Если пользователь уже успел создаться в Remnawave, восстанавливает локальную запись users. +// Если пользователя в панели нет, освобождает инвайт как и раньше. +func ReconcileOrphanedRegistrations(db *database.DB, client *remnawave.Client) (StartupReconcileStats, error) { + var stats StartupReconcileStats + + invites, err := db.GetRecentOrphanedInvites() + if err != nil { + return stats, fmt.Errorf("load recent orphaned invites: %w", err) + } + + for _, invite := range invites { + if invite.UsedBy == nil { + continue + } + + telegramID := *invite.UsedBy + remoteUser, err := client.GetUserByTelegramID(telegramID) + if err != nil { + if isRemnawaveNotFound(err) { + if err := db.UnclaimInvite(invite.Code); err != nil { + return stats, fmt.Errorf("unclaim invite %s: %w", invite.Code, err) + } + stats.ReleasedInvites++ + continue + } + return stats, fmt.Errorf("get remote user by telegram_id=%d: %w", telegramID, err) + } + + username := remoteUser.Username + if username == "" { + username = fmt.Sprintf("tg_%d", telegramID) + } + + var moderatorID *int64 + if invite.ExpireDays != nil { + isModerator, err := db.IsModerator(invite.CreatedBy) + if err != nil { + return stats, fmt.Errorf("check moderator status for %d: %w", invite.CreatedBy, err) + } + if isModerator { + moderatorID = &invite.CreatedBy + } + } + + if _, err := db.CreateUser( + telegramID, + username, + "", + remoteUser.UUID, + invite.SubscriptionPrice, + moderatorID, + ); err != nil { + return stats, fmt.Errorf("restore local user for telegram_id=%d: %w", telegramID, err) + } + + stats.RestoredUsers++ + } + + return stats, nil +} + +func isRemnawaveNotFound(err error) bool { + if err == nil { + return false + } + + if strings.Contains(err.Error(), "API error 404") { + return true + } + + return strings.Contains(err.Error(), remnawave.ErrUserNotFound.Error()) +} diff --git a/internal/bot/startup_test.go b/internal/bot/startup_test.go new file mode 100644 index 0000000..bc46ff6 --- /dev/null +++ b/internal/bot/startup_test.go @@ -0,0 +1,99 @@ +package bot + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReconcileOrphanedRegistrationsRestoresLocalUserFromRemnawave(t *testing.T) { + b, db := setupTestBot(t) + + adminID := int64(999999) + moderatorID := int64(1001) + userID := int64(2001) + price := 650 + + _, err := db.CreateUser(moderatorID, "mod", "Mod", "uuid-mod", nil, nil) + require.NoError(t, err) + require.NoError(t, db.AddModerator(moderatorID, adminID)) + + code, err := db.CreateInviteWithPrice(moderatorID, 30, price) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(code, userID)) + + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/api/users/by-telegram-id/2001", r.URL.Path) + + payload := `{"response":{"uuid":"uuid-remote-2001","username":"restored_user","telegramId":2001,"status":"ACTIVE","expireAt":"2026-04-20T00:00:00Z"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + }), + }) + + stats, err := ReconcileOrphanedRegistrations(db, b.remnawave) + require.NoError(t, err) + assert.Equal(t, 1, stats.RestoredUsers) + assert.Equal(t, 0, stats.ReleasedInvites) + + user, err := db.GetUserByTelegramID(userID) + require.NoError(t, err) + require.NotNil(t, user) + assert.Equal(t, "uuid-remote-2001", user.RemnawaveUUID) + assert.Equal(t, "restored_user", user.Username) + require.NotNil(t, user.SubscriptionPrice) + assert.Equal(t, price, *user.SubscriptionPrice) + require.NotNil(t, user.ModeratorID) + assert.Equal(t, moderatorID, *user.ModeratorID) + + invite, err := db.GetInviteByCode(code) + require.NoError(t, err) + require.NotNil(t, invite) + require.NotNil(t, invite.UsedBy) + assert.Equal(t, userID, *invite.UsedBy) +} + +func TestReconcileOrphanedRegistrationsReleasesInviteWhenRemoteUserMissing(t *testing.T) { + b, db := setupTestBot(t) + + userID := int64(3001) + code, err := db.CreateInviteWithPrice(1001, 30, 700) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(code, userID)) + + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/api/users/by-telegram-id/3001", r.URL.Path) + + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader(`{"message":"not found"}`)), + Header: make(http.Header), + }, nil + }), + }) + + stats, err := ReconcileOrphanedRegistrations(db, b.remnawave) + require.NoError(t, err) + assert.Equal(t, 0, stats.RestoredUsers) + assert.Equal(t, 1, stats.ReleasedInvites) + + user, err := db.GetUserByTelegramID(userID) + require.NoError(t, err) + assert.Nil(t, user) + + invite, err := db.GetInviteByCode(code) + require.NoError(t, err) + require.NotNil(t, invite) + assert.Nil(t, invite.UsedBy) +} diff --git a/internal/database/invites.go b/internal/database/invites.go index c795b3c..ff03403 100644 --- a/internal/database/invites.go +++ b/internal/database/invites.go @@ -145,6 +145,62 @@ func (db *DB) ReconcileOrphanedInvites() (int, error) { return int(rows), nil } +// GetRecentOrphanedInvites возвращает свежие claimed-инвайты без локального пользователя. +// Используется startup-reconcile, который дополнительно сверяет состояние с Remnawave. +func (db *DB) GetRecentOrphanedInvites() ([]Invite, error) { + rows, err := db.conn.Query(` + SELECT code, created_by, used_by, used_at, expire_days, subscription_price, kicked_at, created_at + FROM invites + WHERE used_by IS NOT NULL + AND used_by NOT IN (SELECT telegram_id FROM users) + AND used_at >= datetime('now', '-1 hour') + `) + if err != nil { + return nil, fmt.Errorf("failed to query orphaned invites: %w", err) + } + defer rows.Close() + + var invites []Invite + for rows.Next() { + var invite Invite + var usedBy sql.NullInt64 + var usedAt sql.NullTime + var expireDays sql.NullInt64 + var subscriptionPrice sql.NullInt64 + var kickedAt sql.NullTime + + if err := rows.Scan(&invite.Code, &invite.CreatedBy, &usedBy, &usedAt, &expireDays, &subscriptionPrice, &kickedAt, &invite.CreatedAt); err != nil { + return nil, fmt.Errorf("failed to scan orphaned invite: %w", err) + } + + if usedBy.Valid { + invite.UsedBy = &usedBy.Int64 + } + if usedAt.Valid { + invite.UsedAt = &usedAt.Time + } + if expireDays.Valid { + v := int(expireDays.Int64) + invite.ExpireDays = &v + } + if subscriptionPrice.Valid { + v := int(subscriptionPrice.Int64) + invite.SubscriptionPrice = &v + } + if kickedAt.Valid { + invite.KickedAt = &kickedAt.Time + } + + invites = append(invites, invite) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("orphaned invites rows error: %w", err) + } + + return invites, nil +} + // UnclaimInvite откатывает claim инвайта (если создание пользователя не удалось) func (db *DB) UnclaimInvite(code string) error { _, err := db.conn.Exec( From 770437032762697cf3c82fa4161eb177e419bbbe Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 20:48:00 +0300 Subject: [PATCH 29/34] =?UTF-8?q?plan:=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B8=D0=BA=D1=81=20migration=20gap=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D1=8B=D1=85=20=D0=BE=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-03-23-legacy-paid-migration-fix-plan.md | 367 ++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 docs/plans/2026-03-23-legacy-paid-migration-fix-plan.md diff --git a/docs/plans/2026-03-23-legacy-paid-migration-fix-plan.md b/docs/plans/2026-03-23-legacy-paid-migration-fix-plan.md new file mode 100644 index 0000000..c8363bc --- /dev/null +++ b/docs/plans/2026-03-23-legacy-paid-migration-fix-plan.md @@ -0,0 +1,367 @@ +# Исправление migration-gap для старых оплаченных пользователей модераторов Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Убрать migration-gap, при котором старый пользователь модератора с уже оплаченным вручную активным периодом после установки `subscription_price` ошибочно считается `trial` вместо `paid`. + +**Architecture:** Источник истины о финансах и первой оплате остаётся в таблице `payments`; фейковые backfill-платежи не создаём. Для миграции добавляем в `users` отдельный boolean-флаг, который означает: текущий период уже был оплачен вне нового payment-flow и должен обрабатываться как `paid` до первой реальной оплаты через Platega. В админском flow смены цены показываем дополнительный вопрос только для migration-case: модераторский пользователь, активный finite `expireAt`, нет ни одного подтверждённого платежа. + +**Tech Stack:** Go 1.25, SQLite, telebot.v3, testify + +**Связанные документы:** +- `docs/plans/2026-03-21-payment-business-model-redesign.md` +- `docs/plans/2026-03-22-deployment-checklist.md` +- `docs/plans/2026-03-22-payment-implementation-plan.md` +- `docs/plans/2026-03-23-payment-validation-remediation-plan.md` + +--- + +## Решение + +- Добавить в `users` флаг `legacy_paid_migrated` (`INTEGER`, трактуется как bool, default `0`). +- При `✏️ Изменить цену` админу задавать дополнительный вопрос только если одновременно выполняются условия: + - у пользователя есть модераторский инвайт (`invite.ExpireDays != nil`); + - у пользователя нет подтверждённых платежей в `payments`; + - в Remnawave пользователь активен сейчас и `expireAt > now`; + - подписка не бессрочная (`expireAt.Year() < 2099`). +- Если админ отвечает `Да, считать оплаченной`, сохранять новую цену и `legacy_paid_migrated = true`. +- Если админ отвечает `Нет, оставить trial`, сохранять только цену и `legacy_paid_migrated = false`. +- Для новых пользователей после деплоя никакого дополнительного вопроса быть не должно: они остаются обычным `trial` до первой оплаты через Platega. + +--- + +### Task 1: Зафиксировать регрессии тестами до изменения логики + +**Files:** +- Create: `internal/database/users_test.go` +- Modify: `internal/bot/admin_test.go` +- Modify: `internal/bot/scheduler_test.go` +- Modify: `internal/bot/handlers_test.go` + +**Step 1: Добавить DB-тест на новый migration-флаг** + +В `internal/database/users_test.go` добавить сценарий: +- создать пользователя; +- убедиться, что `legacy_paid_migrated` по умолчанию `false`; +- установить флаг в `true`; +- перечитать пользователя и проверить, что флаг сохранился. + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/database/ -run 'TestUserLegacyPaidMigrated' -v +``` + +Expected: `FAIL`, потому что поля и accessor-ов ещё нет. + +**Step 2: Добавить тест на admin change price flow для migration-case** + +В `internal/bot/admin_test.go` добавить сценарий: +- пользователь пришёл по модераторскому инвайту; +- в Remnawave у него активный finite `expireAt` в будущем; +- подтверждённых платежей нет; +- админ запускает `✏️ Изменить цену`, вводит `telegram_id`, затем новую цену; +- ожидание: бот НЕ завершает flow сразу, а переводит админа в новый state подтверждения migration-case и показывает вопрос: + - "Текущий период уже оплачен вручную?" + - в сообщении есть дата `expireAt`. + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestAdminChangePrice.*Migration.*Prompt' -v +``` + +Expected: `FAIL`. + +**Step 3: Добавить тест, что новый пользователь после деплоя не видит migration-вопрос** + +В `internal/bot/admin_test.go` добавить сценарий: +- новый пользователь пришёл по модераторскому инвайту; +- у него обычный триал без старой ручной оплаты; +- админ меняет цену; +- ожидание: бот сохраняет цену без дополнительного вопроса и не переходит в migration-state. + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestAdminChangePrice.*NoMigrationPromptForFreshTrial' -v +``` + +Expected: `FAIL`. + +**Step 4: Добавить тест на статус migrated-user** + +В `internal/bot/handlers_test.go` и/или `internal/bot/scheduler_test.go` добавить сценарий: +- модераторский пользователь без записей в `payments`; +- `legacy_paid_migrated = true`; +- ожидание: + - `isTrialUser()` возвращает `false`; + - в `userKeyboard()` показывается `💳 Продлить подписку`, а не `💳 Оплатить подписку`. + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestIsTrialUser.*LegacyPaidMigrated|TestUserKeyboard.*LegacyPaidMigrated' -v +``` + +Expected: `FAIL`. + +**Step 5: Добавить тест на scheduler paid-ветку для migrated-user** + +В `internal/bot/scheduler_test.go` добавить сценарий: +- модераторский пользователь без `payments`, но с `legacy_paid_migrated = true`; +- до конца подписки остаётся 3 дня; +- ожидание: отправляется `paid`-уведомление за 3 дня, а не trial-уведомление за 24 часа. + +Run: + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestScheduler.*LegacyPaidMigrated.*PaidBranch' -v +``` + +Expected: `FAIL`. + +**Suggested commit name:** `test: зафиксировать migration gap старых оплат` + +--- + +### Task 2: Добавить в БД migration-флаг и helper-методы + +**Files:** +- Modify: `internal/database/db.go` +- Modify: `internal/database/users.go` +- Create: `internal/database/users_test.go` + +**Step 1: Расширить модель `User`** + +В `internal/database/db.go`: +- добавить в `type User struct` поле: + +```go +LegacyPaidMigrated bool +``` + +**Step 2: Добавить миграцию схемы** + +В `internal/database/db.go`: +- добавить `ALTER TABLE users ADD COLUMN legacy_paid_migrated INTEGER NOT NULL DEFAULT 0`. + +Проверить, что: +- существующие БД поднимутся без ручных миграций; +- default значение безопасно для всех текущих пользователей. + +**Step 3: Обновить чтение пользователей** + +В `internal/database/users.go`: +- расширить `SELECT`/`Scan` в `GetUserByTelegramID`, `GetUserByRemnawaveUUID`, `GetAllUsers`; +- читать `legacy_paid_migrated` как `INTEGER` и маппить в `bool`. + +**Step 4: Добавить setter** + +В `internal/database/users.go` добавить метод: + +```go +func (db *DB) SetLegacyPaidMigrated(telegramID int64, value bool) error +``` + +Реализация: +- писать `1` или `0` в `users.legacy_paid_migrated`. + +**Step 5: Запустить таргетные DB-тесты** + +```bash +GOCACHE=/tmp/go-build go test ./internal/database/ -run 'TestUserLegacyPaidMigrated' -v +``` + +Expected: `PASS`. + +**Suggested commit name:** `feat: добавить migration-флаг старой оплаты` + +--- + +### Task 3: Добавить условный migration-подтверждающий шаг в admin price flow + +**Files:** +- Modify: `internal/bot/admin.go` +- Modify: `internal/bot/handlers.go` +- Modify: `internal/bot/keyboards.go` +- Modify: `internal/bot/keyboards_test.go` +- Modify: `internal/bot/admin_test.go` + +**Step 1: Ввести новые state и session-поля** + +В `internal/bot/admin.go`: +- добавить новый state, например: + +```go +StateWaitAdminChangePriceMigrationConfirm = "wait_admin_change_price_migration_confirm" +``` + +- расширить `adminChangePriceSession` полями: + - `PendingPrice int` + - `HasPendingPrice bool` + - `ShouldAskMigrationConfirm bool` + - `CurrentExpireAt *time.Time` + +**Step 2: Добавить отдельную клавиатуру для migration-вопроса** + +В `internal/bot/keyboards.go`: +- добавить новые кнопки: + - `BtnAdminMigrationPaidYes = "✅ Да, считать оплаченной"` + - `BtnAdminMigrationPaidNo = "❌ Нет, оставить trial"` +- добавить `AdminMigrationPaidKeyboard()`. + +В `internal/bot/keyboards_test.go`: +- зафиксировать наличие этих кнопок. + +**Step 3: Вычислять eligibility заранее** + +В `processAdminChangePriceID()`: +- кроме существующих проверок, загрузить пользователя из Remnawave; +- вычислить `ShouldAskMigrationConfirm` только если: + - инвайт модераторский; + - `HasConfirmedPayment(targetID) == false`; + - `remUser.Status == ACTIVE`; + - `remUser.ExpireAt.After(time.Now().UTC())`; + - `remUser.ExpireAt.Year() < 2099`. + +Если хоть одно условие не выполнено: +- flow работает по старой схеме без дополнительного вопроса. + +**Step 4: Разделить применение цены и финальное подтверждение** + +В `processAdminChangePriceValue()`: +- если `ShouldAskMigrationConfirm == false`, применять цену сразу, как сейчас; +- если `true`, сохранять `PendingPrice`, переводить админа в новый state и отправлять вопрос: + +```text +Срок в панели: до DD.MM.YYYY. +Текущий период у пользователя уже оплачен вручную? +``` + +**Step 5: Реализовать новый обработчик ответа** + +Добавить метод вроде: + +```go +func (b *Bot) processAdminChangePriceMigrationConfirm(c tele.Context, text string) error +``` + +Ветви: +- `BtnAdminMigrationPaidYes`: + - `UpdateSubscriptionPrice` + - `UpdateInviteSubscriptionPrice` + - `SetLegacyPaidMigrated(..., true)` + - финальное сообщение: цена изменена, пользователь помечен как `paid`. +- `BtnAdminMigrationPaidNo`: + - те же update цены; + - `SetLegacyPaidMigrated(..., false)` + - финальное сообщение: цена изменена, пользователь остаётся `trial`. +- `BtnCancel`: + - чисто завершить flow без side effect. + +Вынести применение цены в helper, чтобы не дублировать код. + +**Step 6: Подключить новый state в роутинг** + +В `internal/bot/handlers.go`: +- добавить обработку `StateWaitAdminChangePriceMigrationConfirm`. + +**Step 7: Запустить таргетные UI-тесты** + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestAdminChangePrice|TestAdminMigrationPaidKeyboard' -v +``` + +Expected: `PASS`. + +**Suggested commit name:** `feat: добавить migration-вопрос в смену цены админа` + +--- + +### Task 4: Перевести migrated-user в paid-ветку без подделки платежей + +**Files:** +- Modify: `internal/bot/scheduler.go` +- Modify: `internal/bot/handlers.go` +- Modify: `internal/bot/handlers_test.go` +- Modify: `internal/bot/scheduler_test.go` + +**Step 1: Исправить `isTrialUser()`** + +В `internal/bot/scheduler.go`: +- до проверки `HasConfirmedPayment()` добавить ранний выход: + +```go +if dbUser.LegacyPaidMigrated { + return false +} +``` + +Не загружать legacy-флаг из побочных источников; использовать только поле в `users`. + +**Step 2: Сохранить поведение для новых пользователей** + +Убедиться тестами, что: +- обычный новый модераторский пользователь без оплат по-прежнему `trial`; +- админский бессрочный пользователь не меняет поведение; +- `legacy_paid_migrated = false` ничего не ломает. + +**Step 3: Проверить пользовательский UI** + +В `internal/bot/handlers.go` логика уже опирается на `isTrialUser()`, поэтому дополнительных веток не добавлять. +Нужно только тестами зафиксировать, что migrated-user: +- видит `💳 Продлить подписку`; +- попадает в paid-ветку scheduler с напоминаниями за 3 дня и 1 день; +- получает grace period, а не мгновенный kick при `expireAt`. + +**Step 4: Запустить таргетные scheduler/UI-тесты** + +```bash +GOCACHE=/tmp/go-build go test ./internal/bot/ -run 'TestIsTrialUser|TestUserKeyboard|TestScheduler.*LegacyPaidMigrated.*' -v +``` + +Expected: `PASS`. + +**Suggested commit name:** `fix: считать мигрированных пользователей paid` + +--- + +### Task 5: Обновить документацию и прогнать полную верификацию + +**Files:** +- Modify: `README.md` +- Create: `docs/progress/2026-03-23-legacy-paid-migration-fix-progress.md` + +**Step 1: Обновить README** + +В `README.md` уточнить: +- что старые пользователи с установленной ценой могут быть переведены на новую модель как `paid` через админский migration-вопрос; +- что этот вопрос показывается только для старых активных пользователей модераторов без платежей в `payments`; +- что новые пользователи после деплоя не затрагиваются и остаются обычным `trial`. + +**Step 2: Создать progress-файл после выполнения плана** + +В `docs/progress/2026-03-23-legacy-paid-migration-fix-progress.md` зафиксировать: +- ссылку на этот план; +- какие тесты добавлены; +- какие команды верификации выполнены; +- итоговое поведение migration-case и fresh-user-case. + +**Step 3: Прогнать обязательную верификацию** + +```bash +make fmt +make tests +``` + +Expected: оба прогона успешны. + +**Step 4: Финальный коммит** + +```bash +git add README.md docs/plans/2026-03-23-legacy-paid-migration-fix-plan.md docs/progress/2026-03-23-legacy-paid-migration-fix-progress.md internal/database/db.go internal/database/users.go internal/database/users_test.go internal/bot/admin.go internal/bot/handlers.go internal/bot/keyboards.go internal/bot/keyboards_test.go internal/bot/admin_test.go internal/bot/handlers_test.go internal/bot/scheduler.go internal/bot/scheduler_test.go +git commit -m "fix: закрыть migration gap старых оплат" +``` + +**Suggested commit name:** `docs: задокументировать миграцию старых оплат` From f6927b7f502a9fb2ec8c96abaf3366995bc13b45 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 21:28:13 +0300 Subject: [PATCH 30/34] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D1=80=D0=B5=D0=B3=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D0=B8=20pending=20=D0=B8=20trial-=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/bot/payment.go | 2 +- internal/database/db.go | 23 +++++++---- internal/database/invites.go | 13 ++++-- internal/database/payments.go | 6 +-- internal/database/payments_test.go | 65 ++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 14 deletions(-) diff --git a/internal/bot/payment.go b/internal/bot/payment.go index 2fcdf47..6b9ba08 100644 --- a/internal/bot/payment.go +++ b/internal/bot/payment.go @@ -502,7 +502,7 @@ func (b *Bot) createPaymentForUser(telegramID int64, paymentMethodInt int) (*dat // Вычисляем время жизни var expiresAt *time.Time if resp.ExpiresIn > 0 { - t := time.Now().Add(resp.ExpiresIn) + t := time.Now().UTC().Add(resp.ExpiresIn) expiresAt = &t } diff --git a/internal/database/db.go b/internal/database/db.go index f01225c..cc89124 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -82,13 +82,14 @@ func migrate(conn *sql.DB) error { // Таблица инвайтов `CREATE TABLE IF NOT EXISTS invites ( - code TEXT PRIMARY KEY, - created_by INTEGER NOT NULL, - used_by INTEGER, - used_at TIMESTAMP, - expire_days INTEGER, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )`, + code TEXT PRIMARY KEY, + created_by INTEGER NOT NULL, + used_by INTEGER, + used_at TIMESTAMP, + expire_days INTEGER, + is_trial INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, // Таблица модераторов `CREATE TABLE IF NOT EXISTS moderators ( @@ -174,6 +175,8 @@ func migrate(conn *sql.DB) error { `ALTER TABLE users ADD COLUMN moderator_id INTEGER`, // Миграция: цена подписки при создании инвайта `ALTER TABLE invites ADD COLUMN subscription_price INTEGER`, + // Миграция: неизменяемый исторический флаг trial-инвайта + `ALTER TABLE invites ADD COLUMN is_trial INTEGER NOT NULL DEFAULT 0`, } for _, m := range alterMigrations { @@ -181,6 +184,12 @@ func migrate(conn *sql.DB) error { conn.Exec(m) } + // Бэкофилл для старых записей: всё, что изначально было trial (expire_days IS NOT NULL), + // должно остаться trial в исторической статистике даже после последующих изменений expire_days. + if _, err := conn.Exec(`UPDATE invites SET is_trial = 1 WHERE is_trial = 0 AND expire_days IS NOT NULL`); err != nil { + return fmt.Errorf("failed to backfill invites.is_trial: %w", err) + } + return nil } diff --git a/internal/database/invites.go b/internal/database/invites.go index ff03403..2a5d55a 100644 --- a/internal/database/invites.go +++ b/internal/database/invites.go @@ -45,8 +45,8 @@ func (db *DB) CreateInviteWithExpiry(createdBy int64, expireDays *int) (*Invite, } _, err = db.conn.Exec( - `INSERT INTO invites (code, created_by, expire_days) VALUES (?, ?, ?)`, - code, createdBy, expireDays, + `INSERT INTO invites (code, created_by, expire_days, is_trial) VALUES (?, ?, ?, ?)`, + code, createdBy, expireDays, boolToSQLiteInt(expireDays != nil), ) if err != nil { return nil, fmt.Errorf("failed to create invite: %w", err) @@ -742,7 +742,7 @@ func (db *DB) CreateInviteWithPrice(createdBy int64, expireDays int, price int) return "", fmt.Errorf("failed to generate invite code: %w", err) } _, err = db.conn.Exec( - `INSERT INTO invites (code, created_by, expire_days, subscription_price) VALUES (?, ?, ?, ?)`, + `INSERT INTO invites (code, created_by, expire_days, subscription_price, is_trial) VALUES (?, ?, ?, ?, 1)`, code, createdBy, expireDays, price, ) if err != nil { @@ -759,3 +759,10 @@ func generateInviteCode() (string, error) { } return hex.EncodeToString(bytes), nil } + +func boolToSQLiteInt(v bool) int { + if v { + return 1 + } + return 0 +} diff --git a/internal/database/payments.go b/internal/database/payments.go index 29255ec..7ca9691 100644 --- a/internal/database/payments.go +++ b/internal/database/payments.go @@ -90,7 +90,7 @@ func (db *DB) GetPendingPayment(telegramID int64) (*Payment, error) { err := db.conn.QueryRow( `SELECT id, telegram_id, moderator_id, amount, payment_method, status, platega_transaction_id, redirect_url, expires_at, created_at, confirmed_at - FROM payments WHERE telegram_id = ? AND status = 'pending' AND (expires_at IS NULL OR expires_at > datetime('now')) + FROM payments WHERE telegram_id = ? AND status = 'pending' AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) ORDER BY created_at DESC LIMIT 1`, telegramID, ).Scan(&p.ID, &p.TelegramID, &modID, &p.Amount, &p.PaymentMethod, &p.Status, &txID, &redirectURL, &expiresAt, &p.CreatedAt, &confirmedAt) @@ -180,7 +180,7 @@ func (db *DB) ConfirmPayment(id int64) error { // ExpireOldPendingPayments помечает протухшие PENDING как expired func (db *DB) ExpireOldPendingPayments() (int64, error) { res, err := db.conn.Exec( - `UPDATE payments SET status = 'expired' WHERE status = 'pending' AND expires_at <= datetime('now')`, + `UPDATE payments SET status = 'expired' WHERE status = 'pending' AND datetime(expires_at) <= datetime('now')`, ) if err != nil { return 0, err @@ -347,7 +347,7 @@ func (db *DB) CountTrialsByMonth(year int, month int) (int, error) { start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) end := start.AddDate(0, 1, 0) err := db.conn.QueryRow( - `SELECT COUNT(*) FROM invites WHERE used_at >= ? AND used_at < ? AND expire_days IS NOT NULL`, + `SELECT COUNT(*) FROM invites WHERE used_at >= ? AND used_at < ? AND is_trial = 1`, start, end, ).Scan(&count) return count, err diff --git a/internal/database/payments_test.go b/internal/database/payments_test.go index 9b67faa..a15cf1e 100644 --- a/internal/database/payments_test.go +++ b/internal/database/payments_test.go @@ -91,6 +91,43 @@ func TestGetPendingPayment(t *testing.T) { assert.Nil(t, got) } +func TestPendingPaymentExpiryHandlesTimezoneAwareExpiresAt(t *testing.T) { + dbFile := "test_payments_pending_timezone.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + expiredAtLocal := time.Now().UTC(). + Add(-5 * time.Minute). + In(time.FixedZone("MSK", 3*60*60)). + Format("2006-01-02 15:04:05.999999999-07:00") + + res, err := db.Conn().Exec( + `INSERT INTO payments (telegram_id, amount, payment_method, status, expires_at) VALUES (?, ?, ?, 'pending', ?)`, + 54321, 500, "sbp", expiredAtLocal, + ) + require.NoError(t, err) + + id, err := res.LastInsertId() + require.NoError(t, err) + + got, err := db.GetPendingPayment(54321) + require.NoError(t, err) + assert.Nil(t, got, "просроченный платёж со смещением timezone не должен возвращаться как активный pending") + + expired, err := db.ExpireOldPendingPayments() + require.NoError(t, err) + assert.Equal(t, int64(1), expired, "просроченный платёж со смещением timezone должен протухать") + + stored, err := db.GetPaymentByID(id) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, "expired", stored.Status) +} + func TestGetPaymentByPlategaTxID(t *testing.T) { dbFile := "test_payments_tx.db" db, err := New(dbFile) @@ -404,6 +441,34 @@ func TestCountFirstPaymentsByMonth_IncludesFinanciallyConfirmedStatuses(t *testi assert.Equal(t, 3, count) } +func TestCountTrialsByMonthKeepsHistoricalTrialAfterSwitchToUnlimited(t *testing.T) { + dbFile := "test_payments_trials_history.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + code, err := db.CreateInviteWithPrice(100, 30, 500) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(code, 555)) + + usedAt := time.Date(2026, time.March, 5, 12, 0, 0, 0, time.UTC) + _, err = db.Conn().Exec(`UPDATE invites SET used_at = ? WHERE code = ?`, usedAt, code) + require.NoError(t, err) + + count, err := db.CountTrialsByMonth(2026, 3) + require.NoError(t, err) + assert.Equal(t, 1, count) + + require.NoError(t, db.UpdateInviteExpireDays(555, nil)) + + count, err = db.CountTrialsByMonth(2026, 3) + require.NoError(t, err) + assert.Equal(t, 1, count, "исторический trial не должен исчезать из статистики после перевода на бессрочный тариф") +} + func TestExpireOldPendingPayments(t *testing.T) { dbFile := "test_payments_expire.db" db, err := New(dbFile) From 5a1fc89e0d4193f52d8831c4457e39b779707893 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 21:31:28 +0300 Subject: [PATCH 31/34] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20migration=20gap=20=D1=81=D1=82=D0=B0=D1=80=D1=8B?= =?UTF-8?q?=D1=85=20=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=20=D0=B8=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D1=8C=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 + ...3-23-legacy-paid-migration-fix-progress.md | 62 +++ internal/bot/admin.go | 186 +++++++-- internal/bot/admin_test.go | 365 ++++++++++++++++++ internal/bot/handlers.go | 25 +- internal/bot/handlers_test.go | 55 ++- internal/bot/keyboards.go | 12 + internal/bot/keyboards_test.go | 15 + internal/bot/maintenance.go | 22 ++ internal/bot/maintenance_test.go | 24 ++ internal/bot/payment_handler.go | 2 +- internal/bot/scheduler.go | 14 +- internal/bot/scheduler_test.go | 108 +++++- internal/database/db.go | 17 +- internal/database/users.go | 65 +++- internal/database/users_test.go | 70 ++++ internal/monitoring/sync.go | 46 ++- internal/monitoring/sync_test.go | 32 +- 18 files changed, 1070 insertions(+), 60 deletions(-) create mode 100644 docs/progress/2026-03-23-legacy-paid-migration-fix-progress.md create mode 100644 internal/bot/maintenance.go create mode 100644 internal/bot/maintenance_test.go create mode 100644 internal/database/users_test.go diff --git a/README.md b/README.md index 265930f..96134fc 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 | `🗑 Удалить код` | Удаление неиспользованного кода (использованные защищены) | | `🚫 Забанить` | Перманентный бан: запись в ban-лист + удаление из БД бота и Remnawave | | `♾️ Сменить тариф` | Перевод пользователя с месячной подписки на бессрочную без смены куратора | +| `✏️ Изменить цену` | Смена цены месячной подписки; для legacy-пользователя без цены бот отдельно спросит, считать ли текущий период уже оплаченным | При активации нового инвайта админ получает уведомление с Telegram ID, username и именем. @@ -124,6 +125,8 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 5. после grace period пользователь удаляется, если свежая оплата не подтверждена; 6. в `maintenance mode` disable и автокики блокируются. 7. legacy-пользователи без инвайта и без `subscription_price` пропускаются новым payment-scheduler и продолжают жить по старой модели. +8. legacy-пользователь модератора с уже оплаченным вручную текущим периодом может быть переведён на новую модель через `✏️ Изменить цену`: + бот спросит, считать ли текущий период уже оплаченным; при ответе `Да` пользователь сразу пойдёт по paid-ветке с `💳 Продлить подписку`, напоминаниями за 3/1 день и grace period. **Flow оплаты**: 1. пользователь выбирает `💳 Оплатить подписку` или `💳 Продлить подписку`; @@ -133,6 +136,13 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 5. при успешной оплате подписка активируется автоматически, лимит трафика снимается; 6. если подтверждение получено, но Remnawave временно недоступен, пользователю не показывается ложное сообщение об активации: платёж остаётся в `confirmed_not_activated`, а scheduler повторяет активацию позже. +**Миграция старых пользователей модераторов**: +1. если у старого пользователя модератора ещё нет `subscription_price`, админ задаёт цену через `✏️ Изменить цену`; +2. для legacy-case бот дополнительно спрашивает, был ли текущий период уже оплачен вручную; +3. ответ `✅ Да, считать оплаченной` помечает пользователя как migrated-paid без поддельного платежа в `payments`; +4. ответ `❌ Нет, оставить trial` просто сохраняет цену и оставляет пользователя в trial до первой оплаты через Platega; +5. вопрос показывается только для legacy-case с модераторским инвайтом, пустой ценой, активной finite-подпиской и отсутствием confirmed-платежей; новые пользователи после деплоя этот шаг не видят. + **Бан пользователя** — перманентная операция: 1. Telegram ID заносится в `banned_users` diff --git a/docs/progress/2026-03-23-legacy-paid-migration-fix-progress.md b/docs/progress/2026-03-23-legacy-paid-migration-fix-progress.md new file mode 100644 index 0000000..c886c40 --- /dev/null +++ b/docs/progress/2026-03-23-legacy-paid-migration-fix-progress.md @@ -0,0 +1,62 @@ +# Прогресс: закрытие migration gap старых оплат + +**Дата:** 2026-03-23 +**План:** [2026-03-23-legacy-paid-migration-fix-plan.md](../plans/2026-03-23-legacy-paid-migration-fix-plan.md) + +## Что сделано + +### DB и модель пользователя +- В `users` добавлен флаг `legacy_paid_migrated`. +- Добавлена безопасная миграция `ALTER TABLE users ADD COLUMN legacy_paid_migrated INTEGER NOT NULL DEFAULT 0`. +- `GetUserByTelegramID`, `GetUserByRemnawaveUUID` и `GetAllUsers` читают новый флаг. +- Добавлены helper-методы: + - `SetLegacyPaidMigrated` + - `UpdateSubscriptionPriceAndLegacyPaidMigrated` + +### Admin flow смены цены +- Введён отдельный state `StateWaitAdminChangePriceMigrationConfirm`. +- После ввода новой цены бот показывает migration-вопрос только для legacy-case: + - модераторский инвайт; + - `subscription_price == nil`; + - нет confirmed-платежей; + - в Remnawave пользователь `ACTIVE`; + - `expireAt` конечный и в будущем. +- До ответа на migration-вопрос цена не пишется в БД. +- Варианты ответа: + - `✅ Да, считать оплаченной` -> цена сохраняется, `legacy_paid_migrated = true` + - `❌ Нет, оставить trial` -> цена сохраняется, `legacy_paid_migrated = false` + - `🚫 Отмена` -> flow завершается без side effect +- При ошибке загрузки пользователя из Remnawave для legacy-candidate flow теперь fail-closed и не продолжает смену цены молча. + +### Scheduler и пользовательский UI +- `isTrialUser()` теперь сначала проверяет `LegacyPaidMigrated`. +- Мигрированный legacy-paid пользователь: + - не считается `trial`, даже без confirmed-платежа; + - видит `💳 Продлить подписку`, а не `💳 Оплатить подписку`; + - идёт по paid-ветке scheduler; + - получает напоминания за 3 дня и 1 день; + - получает grace period вместо мгновенного кика. + +### Тесты +- Добавлены и зафиксированы регрессионные тесты на: + - persistence `legacy_paid_migrated` во всех scan-path DB; + - migration-prompt в admin change-price flow; + - ветки `Yes / No / Cancel`; + - fail-closed при ошибке lookup в Remnawave; + - `isTrialUser()` для migrated-user; + - `BtnRenew` для migrated-user; + - paid reminder scheduler для migrated-user. + +## Верификация + +Таргетные проверки: + +```bash +GOCACHE=/tmp/go-build go test ./internal/database -run 'TestUserLegacyPaidMigratedPersists|TestUpdateSubscriptionPriceAndLegacyPaidMigrated' -v +GOCACHE=/tmp/go-build go test ./internal/bot -run 'TestAdminChangePriceFlow|TestAdminChangePriceMigrationKeyboardContainsExpectedButtons|TestAdminChangePriceFlow_FailsClosedWhenMigrationLookupFails' -v +GOCACHE=/tmp/go-build go test ./internal/bot -run 'TestIsTrialUserTreatsLegacyPaidMigratedUserAsPaid|TestUserKeyboardShowsRenewForLegacyPaidMigratedUser|TestSchedulerPaidReminderForLegacyPaidMigratedUser' -v +``` + +Финальная обязательная верификация: +- `make fmt` +- `make tests` diff --git a/internal/bot/admin.go b/internal/bot/admin.go index 9ae39d5..f9b76dd 100644 --- a/internal/bot/admin.go +++ b/internal/bot/admin.go @@ -15,15 +15,16 @@ import ( // Состояния админа const ( - StateWaitBanUser = "wait_ban_user" // Ожидание telegram_id для бана - StateWaitDeleteInvite = "wait_delete_invite" // Ожидание кода для удаления - StateWaitAddModerator = "wait_add_moderator" // Ожидание telegram_id для назначения модератора - StateWaitRemoveModerator = "wait_remove_moderator" // Ожидание telegram_id для снятия модератора - StateWaitAdminUserInfo = "wait_admin_user_info" // Ожидание telegram_id для карточки пользователя - StateWaitAdminChangePriceID = "wait_admin_change_price_id" // Ожидание telegram_id для смены цены - StateWaitAdminChangePriceValue = "wait_admin_change_price_value" // Ожидание новой цены подписки - StateWaitSwitchSubscriptionID = "wait_switch_subscription_id" // Ожидание telegram_id для смены тарифа - StateWaitSwitchSubscriptionConfirm = "wait_switch_subscription_confirm" // Ожидание подтверждения смены тарифа + StateWaitBanUser = "wait_ban_user" // Ожидание telegram_id для бана + StateWaitDeleteInvite = "wait_delete_invite" // Ожидание кода для удаления + StateWaitAddModerator = "wait_add_moderator" // Ожидание telegram_id для назначения модератора + StateWaitRemoveModerator = "wait_remove_moderator" // Ожидание telegram_id для снятия модератора + StateWaitAdminUserInfo = "wait_admin_user_info" // Ожидание telegram_id для карточки пользователя + StateWaitAdminChangePriceID = "wait_admin_change_price_id" // Ожидание telegram_id для смены цены + StateWaitAdminChangePriceValue = "wait_admin_change_price_value" // Ожидание новой цены подписки + StateWaitAdminChangePriceMigrationConfirm = "wait_admin_change_price_migration_confirm" // Ожидание подтверждения migration-case + StateWaitSwitchSubscriptionID = "wait_switch_subscription_id" // Ожидание telegram_id для смены тарифа + StateWaitSwitchSubscriptionConfirm = "wait_switch_subscription_confirm" // Ожидание подтверждения смены тарифа ) type adminSwitchSession struct { @@ -33,10 +34,14 @@ type adminSwitchSession struct { } type adminChangePriceSession struct { - TargetTelegramID int64 - TargetLabel string - CurrentPrice int - HasCurrentPrice bool + TargetTelegramID int64 + TargetLabel string + CurrentPrice int + HasCurrentPrice bool + PendingPrice int + HasPendingPrice bool + ShouldAskMigrationConfirm bool + CurrentExpireAt *time.Time } // isAdmin проверяет, является ли пользователь админом @@ -48,7 +53,7 @@ func (b *Bot) isAdmin(c tele.Context) bool { func (b *Bot) handleAdminStart(c tele.Context) error { return c.Send(MsgAdminWelcome, &tele.SendOptions{ ParseMode: tele.ModeHTML, - ReplyMarkup: AdminKeyboard(b.maintenanceMode), + ReplyMarkup: AdminKeyboard(b.isMaintenanceMode()), }) } @@ -371,6 +376,21 @@ func (b *Bot) processAdminChangePriceID(c tele.Context, text string) error { session.CurrentPrice = *dbUser.SubscriptionPrice session.HasCurrentPrice = true } + if dbUser.SubscriptionPrice == nil { + remUser, err := b.remnawave.GetUser(dbUser.RemnawaveUUID) + if err != nil { + slog.Error("Failed to load Remnawave user before migration check", "error", err, "telegram_id", targetID) + b.userStates.Delete(adminID) + b.clearAdminChangePriceSession(adminID) + return c.Send("Ошибка проверки пользователя, попробуйте позже", &tele.SendOptions{ + ReplyMarkup: AdminManageKeyboard(), + }) + } else if b.shouldPromptAdminChangePriceMigration(dbUser, invite, remUser) { + session.ShouldAskMigrationConfirm = true + expireAt := remUser.ExpireAt + session.CurrentExpireAt = &expireAt + } + } b.setAdminChangePriceSession(adminID, session) b.userStates.Set(adminID, StateWaitAdminChangePriceValue) @@ -409,15 +429,36 @@ func (b *Bot) processAdminChangePriceValue(c tele.Context, text string) error { return c.Send("Сессия изменения цены потеряна. Начните заново.", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) } - if err := b.db.UpdateSubscriptionPrice(session.TargetTelegramID, newPrice); err != nil { + if session.ShouldAskMigrationConfirm { + session.PendingPrice = newPrice + session.HasPendingPrice = true + b.setAdminChangePriceSession(adminID, session) + b.userStates.Set(adminID, StateWaitAdminChangePriceMigrationConfirm) + + expireText := "неизвестна" + if session.CurrentExpireAt != nil { + expireText = session.CurrentExpireAt.Format("02.01.2006") + } + + return c.Send( + fmt.Sprintf( + "Срок в панели: до %s\n\nТекущий период уже оплачен вручную?\n\nНовая цена подписки: %d руб.", + expireText, + newPrice, + ), + &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: AdminChangePriceMigrationKeyboard(), + }, + ) + } + + if err := b.applyAdminChangePrice(session.TargetTelegramID, newPrice, nil); err != nil { slog.Error("Failed to update subscription price by admin", "error", err, "telegram_id", session.TargetTelegramID) b.userStates.Delete(adminID) b.clearAdminChangePriceSession(adminID) return c.Send("Ошибка изменения цены", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) } - if err := b.db.UpdateInviteSubscriptionPrice(session.TargetTelegramID, newPrice); err != nil { - slog.Error("Failed to update invite subscription price by admin", "error", err, "telegram_id", session.TargetTelegramID) - } b.userStates.Delete(adminID) b.clearAdminChangePriceSession(adminID) @@ -437,6 +478,69 @@ func (b *Bot) processAdminChangePriceValue(c tele.Context, text string) error { ) } +// processAdminChangePriceMigrationConfirm завершает смену цены после migration-question. +func (b *Bot) processAdminChangePriceMigrationConfirm(c tele.Context, text string) error { + adminID := c.Sender().ID + answer := strings.TrimSpace(text) + + session, ok := b.getAdminChangePriceSession(adminID) + if !ok { + b.userStates.Delete(adminID) + return c.Send("Сессия изменения цены потеряна. Начните заново.", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + if !session.HasPendingPrice { + b.userStates.Delete(adminID) + b.clearAdminChangePriceSession(adminID) + return c.Send("Сессия изменения цены потеряна. Начните заново.", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + + var legacyPaidMigrated *bool + var successSuffix string + switch answer { + case BtnAdminMigrationPaidYes: + value := true + legacyPaidMigrated = &value + successSuffix = "Пользователь помечен как уже оплаченный." + case BtnAdminMigrationPaidNo: + value := false + legacyPaidMigrated = &value + successSuffix = "Пользователь оставлен в trial до первой оплаты." + case BtnCancel: + b.userStates.Delete(adminID) + b.clearAdminChangePriceSession(adminID) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + default: + return c.Send("Выберите вариант ответа или нажмите «Отмена».", &tele.SendOptions{ + ReplyMarkup: AdminChangePriceMigrationKeyboard(), + }) + } + + if err := b.applyAdminChangePrice(session.TargetTelegramID, session.PendingPrice, legacyPaidMigrated); err != nil { + slog.Error("Failed to finalize admin price change", "error", err, "telegram_id", session.TargetTelegramID) + b.userStates.Delete(adminID) + b.clearAdminChangePriceSession(adminID) + return c.Send("Ошибка изменения цены", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + + b.userStates.Delete(adminID) + b.clearAdminChangePriceSession(adminID) + b.notifyUserAboutPriceChange(session.TargetTelegramID, session.PendingPrice) + + return c.Send( + fmt.Sprintf( + "✅ Цена подписки для %s изменена: %s → %d руб/мес\n%s", + session.TargetLabel, + formatAdminOldPrice(session), + session.PendingPrice, + successSuffix, + ), + &tele.SendOptions{ + ParseMode: tele.ModeHTML, + ReplyMarkup: AdminManageKeyboard(), + }, + ) +} + // processSwitchSubscriptionConfirm подтверждает перевод на бессрочный тариф. func (b *Bot) processSwitchSubscriptionConfirm(c tele.Context, text string) error { adminID := c.Sender().ID @@ -646,7 +750,7 @@ func (b *Bot) handleAdminStats(c tele.Context) error { confirmedPayments, err := b.db.GetConfirmedPaymentsByMonth(year, month) if err != nil { slog.Error("Failed to load monthly confirmed payments for admin stats", "error", err) - return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.isMaintenanceMode())}) } monthEarnings := &database.MonthlyEarnings{} @@ -663,25 +767,25 @@ func (b *Bot) handleAdminStats(c tele.Context) error { trialsThisMonth, err := b.db.CountTrialsByMonth(year, month) if err != nil { slog.Error("Failed to count trials for admin stats", "error", err) - return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.isMaintenanceMode())}) } firstPayments, err := b.db.CountFirstPaymentsByMonth(year, month) if err != nil { slog.Error("Failed to count first payments for admin stats", "error", err) - return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.isMaintenanceMode())}) } dbUsers, err := b.db.GetAllUsers() if err != nil { slog.Error("Failed to load DB users for admin stats", "error", err) - return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + return c.Send("Ошибка получения статистики", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.isMaintenanceMode())}) } remUsers, err := b.remnawave.GetAllUsers() if err != nil { slog.Error("Failed to load Remnawave users for admin stats", "error", err) - return c.Send("Ошибка получения статистики из панели", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + return c.Send("Ошибка получения статистики из панели", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.isMaintenanceMode())}) } byTelegramID := make(map[int64]remnawave.User, len(remUsers)) @@ -769,7 +873,7 @@ func (b *Bot) handleAdminStats(c tele.Context) error { return c.Send(msg, &tele.SendOptions{ ParseMode: tele.ModeHTML, - ReplyMarkup: AdminKeyboard(b.maintenanceMode), + ReplyMarkup: AdminKeyboard(b.isMaintenanceMode()), }) } @@ -779,15 +883,15 @@ func (b *Bot) handleAdminMaintenanceToggle(c tele.Context) error { return nil } - b.maintenanceMode = !b.maintenanceMode + enabled := b.toggleMaintenanceMode() msg := "🔧 Режим обслуживания включён. Оплата и кики приостановлены." - if !b.maintenanceMode { + if !enabled { msg = "▶️ Штатный режим восстановлен. Оплата и scheduler работают." } return c.Send(msg, &tele.SendOptions{ - ReplyMarkup: AdminKeyboard(b.maintenanceMode), + ReplyMarkup: AdminKeyboard(enabled), }) } @@ -1320,6 +1424,34 @@ func (b *Bot) notifyUserAboutPriceChange(telegramID int64, newPrice int) { } } +func (b *Bot) applyAdminChangePrice(telegramID int64, newPrice int, legacyPaidMigrated *bool) error { + if err := b.db.UpdateSubscriptionPriceAndLegacyPaidMigrated(telegramID, newPrice, legacyPaidMigrated); err != nil { + return err + } + if err := b.db.UpdateInviteSubscriptionPrice(telegramID, newPrice); err != nil { + slog.Error("Failed to update invite subscription price by admin", "error", err, "telegram_id", telegramID) + } + return nil +} + +func (b *Bot) shouldPromptAdminChangePriceMigration(dbUser *database.User, invite *database.Invite, remUser *remnawave.User) bool { + if dbUser == nil || invite == nil || invite.ExpireDays == nil || dbUser.SubscriptionPrice != nil || remUser == nil { + return false + } + if remUser.Status != remnawave.StatusActive { + return false + } + if remUser.ExpireAt.Year() >= 2099 || !remUser.ExpireAt.After(time.Now().UTC()) { + return false + } + hasPaid, err := b.db.HasConfirmedPayment(dbUser.TelegramID) + if err != nil { + slog.Error("Failed to check confirmed payments before migration prompt", "error", err, "telegram_id", dbUser.TelegramID) + return false + } + return !hasPaid +} + func (b *Bot) describeAdminUserSubscription(telegramID int64, remUser *remnawave.User) (string, string) { if remUser == nil { return "неизвестно", "неизвестно" diff --git a/internal/bot/admin_test.go b/internal/bot/admin_test.go index 62e0dee..0ab9084 100644 --- a/internal/bot/admin_test.go +++ b/internal/bot/admin_test.go @@ -3,6 +3,7 @@ package bot import ( "database/sql" "encoding/json" + "fmt" "io" "net/http" "os" @@ -915,6 +916,370 @@ func TestAdminChangePriceFlow_UpdatesPaidUser(t *testing.T) { assert.Empty(t, b.userStates.Get(adminID)) } +func TestAdminChangePriceFlow_PromptsForLegacyPaidMigration(t *testing.T) { + dbFile := "test_admin_change_price_migration.db" + db, err := database.New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + adminID := int64(999999) + modID := int64(54321) + targetID := int64(12346) + + _, err = db.CreateUser(modID, "moderator", "Moderator", "uuid-mod", nil, nil) + require.NoError(t, err) + require.NoError(t, db.AddModerator(modID, adminID)) + + _, err = db.CreateUser(targetID, "legacy", "Legacy", "uuid-target", nil, nil) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, targetID)) + + expireAt := time.Date(2026, time.April, 15, 0, 0, 0, 0, time.UTC) + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-target": + payload := fmt.Sprintf( + `{"response":{"uuid":"uuid-target","username":"legacy","status":"ACTIVE","expireAt":"%s"}}`, + expireAt.Format(time.RFC3339), + ) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + default: + return nil, fmt.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }), + }) + + b := &Bot{ + db: db, + remnawave: client, + config: &config.Config{AdminID: adminID, MinSubscriptionPrice: 400}, + userStates: newStateMap(), + } + + ctxID := &MockContext{sender: &tele.User{ID: adminID}} + require.NoError(t, b.processAdminChangePriceID(ctxID, strconv.FormatInt(targetID, 10))) + require.Equal(t, StateWaitAdminChangePriceValue, b.userStates.Get(adminID)) + + ctxValue := &MockContext{sender: &tele.User{ID: adminID}} + require.NoError(t, b.processAdminChangePriceValue(ctxValue, "650")) + + msgValue, ok := ctxValue.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, msgValue, "Текущий период уже оплачен вручную") + assert.Contains(t, msgValue, "15.04.2026") + assert.Equal(t, StateWaitAdminChangePriceMigrationConfirm, b.userStates.Get(adminID)) + + updatedUser, err := db.GetUserByTelegramID(targetID) + require.NoError(t, err) + assert.Nil(t, updatedUser.SubscriptionPrice, "цена не должна применяться до ответа на migration-вопрос") + + ctxConfirm := &MockContext{sender: &tele.User{ID: adminID}} + require.NoError(t, b.processAdminChangePriceMigrationConfirm(ctxConfirm, BtnAdminMigrationPaidYes)) + + updatedUser, err = db.GetUserByTelegramID(targetID) + require.NoError(t, err) + require.NotNil(t, updatedUser.SubscriptionPrice) + assert.Equal(t, 650, *updatedUser.SubscriptionPrice) + assert.True(t, updatedUser.LegacyPaidMigrated) + + updatedInvite, err := db.GetInviteByUsedBy(targetID) + require.NoError(t, err) + require.NotNil(t, updatedInvite.SubscriptionPrice) + assert.Equal(t, 650, *updatedInvite.SubscriptionPrice) + + assert.Empty(t, b.userStates.Get(adminID)) +} + +func TestAdminChangePriceFlow_FailsClosedWhenMigrationLookupFails(t *testing.T) { + dbFile := "test_admin_change_price_migration_lookup_fail.db" + db, err := database.New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + adminID := int64(999995) + modID := int64(54325) + targetID := int64(12350) + + _, err = db.CreateUser(modID, "moderator", "Moderator", "uuid-mod-fail", nil, nil) + require.NoError(t, err) + require.NoError(t, db.AddModerator(modID, adminID)) + + _, err = db.CreateUser(targetID, "legacy_fail", "Legacy Fail", "uuid-target-fail", nil, nil) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, targetID)) + + b := &Bot{ + db: db, + remnawave: remnawave.NewClient("https://panel.example.com", "test-token", nil), + config: &config.Config{AdminID: adminID, MinSubscriptionPrice: 400}, + userStates: newStateMap(), + } + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("network down") + }), + }) + + ctxID := &MockContext{sender: &tele.User{ID: adminID}} + err = b.processAdminChangePriceID(ctxID, strconv.FormatInt(targetID, 10)) + require.NoError(t, err) + + msg, ok := ctxID.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, msg, "Ошибка проверки пользователя, попробуйте позже") + assert.Empty(t, b.userStates.Get(adminID)) + + user, err := db.GetUserByTelegramID(targetID) + require.NoError(t, err) + require.NotNil(t, user) + assert.Nil(t, user.SubscriptionPrice) +} + +func TestAdminChangePriceFlow_MigrationNoLeavesTrial(t *testing.T) { + dbFile := "test_admin_change_price_migration_no.db" + db, err := database.New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + adminID := int64(999997) + modID := int64(54323) + targetID := int64(12348) + + _, err = db.CreateUser(modID, "moderator", "Moderator", "uuid-mod-no", nil, nil) + require.NoError(t, err) + require.NoError(t, db.AddModerator(modID, adminID)) + + _, err = db.CreateUser(targetID, "legacy_no", "Legacy No", "uuid-target-no", nil, nil) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, targetID)) + + expireAt := time.Date(2026, time.April, 20, 0, 0, 0, 0, time.UTC) + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-target-no": + payload := fmt.Sprintf( + `{"response":{"uuid":"uuid-target-no","username":"legacy_no","status":"ACTIVE","expireAt":"%s"}}`, + expireAt.Format(time.RFC3339), + ) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + default: + return nil, fmt.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }), + }) + + b := &Bot{ + db: db, + remnawave: client, + config: &config.Config{AdminID: adminID, MinSubscriptionPrice: 400}, + userStates: newStateMap(), + } + + ctxID := &MockContext{sender: &tele.User{ID: adminID}} + require.NoError(t, b.processAdminChangePriceID(ctxID, strconv.FormatInt(targetID, 10))) + require.Equal(t, StateWaitAdminChangePriceValue, b.userStates.Get(adminID)) + + ctxValue := &MockContext{sender: &tele.User{ID: adminID}} + require.NoError(t, b.processAdminChangePriceValue(ctxValue, "700")) + + msgValue, ok := ctxValue.sentMsg.(string) + require.True(t, ok) + assert.Contains(t, msgValue, "Текущий период уже оплачен вручную") + require.Equal(t, StateWaitAdminChangePriceMigrationConfirm, b.userStates.Get(adminID)) + + ctxConfirm := &MockContext{sender: &tele.User{ID: adminID}} + require.NoError(t, b.processAdminChangePriceMigrationConfirm(ctxConfirm, BtnAdminMigrationPaidNo)) + + updatedUser, err := db.GetUserByTelegramID(targetID) + require.NoError(t, err) + require.NotNil(t, updatedUser.SubscriptionPrice) + assert.Equal(t, 700, *updatedUser.SubscriptionPrice) + assert.False(t, updatedUser.LegacyPaidMigrated) + + assert.Empty(t, b.userStates.Get(adminID)) +} + +func TestAdminChangePriceFlow_MigrationCancelClearsSession(t *testing.T) { + dbFile := "test_admin_change_price_migration_cancel.db" + db, err := database.New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + adminID := int64(999996) + modID := int64(54324) + targetID := int64(12349) + + _, err = db.CreateUser(modID, "moderator", "Moderator", "uuid-mod-cancel", nil, nil) + require.NoError(t, err) + require.NoError(t, db.AddModerator(modID, adminID)) + + _, err = db.CreateUser(targetID, "legacy_cancel", "Legacy Cancel", "uuid-target-cancel", nil, nil) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, targetID)) + + expireAt := time.Date(2026, time.April, 25, 0, 0, 0, 0, time.UTC) + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-target-cancel": + payload := fmt.Sprintf( + `{"response":{"uuid":"uuid-target-cancel","username":"legacy_cancel","status":"ACTIVE","expireAt":"%s"}}`, + expireAt.Format(time.RFC3339), + ) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + default: + return nil, fmt.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }), + }) + + b := &Bot{ + db: db, + remnawave: client, + config: &config.Config{AdminID: adminID, MinSubscriptionPrice: 400}, + userStates: newStateMap(), + } + + ctxID := &MockContext{sender: &tele.User{ID: adminID}} + require.NoError(t, b.processAdminChangePriceID(ctxID, strconv.FormatInt(targetID, 10))) + require.Equal(t, StateWaitAdminChangePriceValue, b.userStates.Get(adminID)) + + ctxValue := &MockContext{sender: &tele.User{ID: adminID}} + require.NoError(t, b.processAdminChangePriceValue(ctxValue, "710")) + require.Equal(t, StateWaitAdminChangePriceMigrationConfirm, b.userStates.Get(adminID)) + + ctxCancel := &MockContext{sender: &tele.User{ID: adminID}} + require.NoError(t, b.processAdminChangePriceMigrationConfirm(ctxCancel, BtnCancel)) + + updatedUser, err := db.GetUserByTelegramID(targetID) + require.NoError(t, err) + assert.Nil(t, updatedUser.SubscriptionPrice) + assert.Empty(t, b.userStates.Get(adminID)) +} + +func TestAdminChangePriceFlow_DoesNotPromptForFreshTrial(t *testing.T) { + dbFile := "test_admin_change_price_fresh_trial.db" + db, err := database.New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + adminID := int64(999998) + modID := int64(54322) + targetID := int64(12347) + invitePrice := 500 + + _, err = db.CreateUser(modID, "moderator", "Moderator", "uuid-mod-2", nil, nil) + require.NoError(t, err) + require.NoError(t, db.AddModerator(modID, adminID)) + + _, err = db.CreateUser(targetID, "fresh", "Fresh", "uuid-target-2", &invitePrice, nil) + require.NoError(t, err) + + expireDays := 30 + inviteCode, err := db.CreateInviteWithPrice(modID, expireDays, invitePrice) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inviteCode, targetID)) + + initialUser, err := db.GetUserByTelegramID(targetID) + require.NoError(t, err) + require.NotNil(t, initialUser.SubscriptionPrice) + assert.Equal(t, invitePrice, *initialUser.SubscriptionPrice) + + expireAt := time.Date(2026, time.April, 15, 0, 0, 0, 0, time.UTC) + client := remnawave.NewClient("https://panel.example.com", "test-token", nil) + client.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/uuid-target-2": + payload := fmt.Sprintf( + `{"response":{"uuid":"uuid-target-2","username":"fresh","status":"ACTIVE","expireAt":"%s"}}`, + expireAt.Format(time.RFC3339), + ) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + default: + return nil, fmt.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }), + }) + + b := &Bot{ + db: db, + remnawave: client, + config: &config.Config{AdminID: adminID, MinSubscriptionPrice: 400}, + userStates: newStateMap(), + } + + ctxID := &MockContext{sender: &tele.User{ID: adminID}} + require.NoError(t, b.processAdminChangePriceID(ctxID, strconv.FormatInt(targetID, 10))) + require.Equal(t, StateWaitAdminChangePriceValue, b.userStates.Get(adminID)) + + ctxValue := &MockContext{sender: &tele.User{ID: adminID}} + require.NoError(t, b.processAdminChangePriceValue(ctxValue, "650")) + + msgValue, ok := ctxValue.sentMsg.(string) + require.True(t, ok) + assert.NotContains(t, msgValue, "Текущий период уже оплачен вручную") + assert.NotEqual(t, StateWaitAdminChangePriceMigrationConfirm, b.userStates.Get(adminID)) + + updatedUser, err := db.GetUserByTelegramID(targetID) + require.NoError(t, err) + require.NotNil(t, updatedUser.SubscriptionPrice) + assert.Equal(t, 650, *updatedUser.SubscriptionPrice) + assert.False(t, updatedUser.LegacyPaidMigrated) +} + func TestProcessAdminUserInfo_ShowsNonSuccessStatusForGraceUser(t *testing.T) { dbFile := "test_admin_user_info_grace.db" db, err := database.New(dbFile) diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 8d9c983..baa061b 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -5,6 +5,7 @@ import ( "log/slog" "strings" "sync" + "sync/atomic" "time" "github.com/fus1ond/vpn_bot/internal/config" @@ -35,7 +36,7 @@ type Bot struct { sdConfigsPath string // путь к sd_configs (для чтения targets) render *render.Client // клиент render-сервиса (nil если не настроен) platega *platega.Client // Platega API клиент (nil если не настроен) - maintenanceMode bool // Режим обслуживания (сбрасывается при перезапуске) + maintenanceMode atomic.Bool // Режим обслуживания (сбрасывается при перезапуске) paymentRetryDelays []time.Duration // Тестовые override-задержки для короткого background retry активации paymentRetryInFlight sync.Map // payment_id -> struct{}, чтобы не плодить дублирующие retry-воркеры modChangePriceMu sync.RWMutex @@ -251,7 +252,7 @@ func (b *Bot) handleTextMessage(c tele.Context) error { case StateWaitBroadcastActive: if text == BtnCancel { b.userStates.Delete(telegramID) - return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.isMaintenanceMode())}) } if b.isAdmin(c) { return b.processBroadcastMessage(c) @@ -260,7 +261,7 @@ func (b *Bot) handleTextMessage(c tele.Context) error { case StateWaitBanUser: if text == BtnCancel { b.userStates.Delete(telegramID) - return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.isMaintenanceMode())}) } if b.isAdmin(c) { return b.processBanUser(c, text) @@ -269,7 +270,7 @@ func (b *Bot) handleTextMessage(c tele.Context) error { case StateWaitDeleteInvite: if text == BtnCancel { b.userStates.Delete(telegramID) - return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.isMaintenanceMode())}) } if b.isAdmin(c) { return b.processDeleteInvite(c, text) @@ -324,6 +325,16 @@ func (b *Bot) handleTextMessage(c tele.Context) error { return b.processAdminChangePriceValue(c, text) } + case StateWaitAdminChangePriceMigrationConfirm: + if text == BtnCancel { + b.userStates.Delete(telegramID) + b.clearAdminChangePriceSession(telegramID) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminManageKeyboard()}) + } + if b.isAdmin(c) { + return b.processAdminChangePriceMigrationConfirm(c, text) + } + case StateWaitModDeleteInvite: if text == BtnCancel { b.userStates.Delete(telegramID) @@ -357,7 +368,7 @@ func (b *Bot) handleTextMessage(c tele.Context) error { case StateWaitAddModerator: if text == BtnCancel { b.userStates.Delete(telegramID) - return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.isMaintenanceMode())}) } if b.isAdmin(c) { return b.processAddModerator(c, text) @@ -366,7 +377,7 @@ func (b *Bot) handleTextMessage(c tele.Context) error { case StateWaitRemoveModerator: if text == BtnCancel { b.userStates.Delete(telegramID) - return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.maintenanceMode)}) + return c.Send("Отменено", &tele.SendOptions{ReplyMarkup: AdminKeyboard(b.isMaintenanceMode())}) } if b.isAdmin(c) { return b.processRemoveModerator(c, text) @@ -744,7 +755,7 @@ func (b *Bot) userKeyboard(telegramID int64) *tele.ReplyMarkup { } // В режиме обслуживания скрываем оплату для всех. - if b.maintenanceMode { + if b.isMaintenanceMode() { return UserMenuKeyboardDynamic("", false, isMod) } diff --git a/internal/bot/handlers_test.go b/internal/bot/handlers_test.go index be4765a..df5bc69 100644 --- a/internal/bot/handlers_test.go +++ b/internal/bot/handlers_test.go @@ -53,12 +53,13 @@ func (c *MockContext) Text() string { // setupTestBot создаёт бота с временной БД для тестов func setupTestBot(t *testing.T) (*Bot, *database.DB) { t.Helper() - dbFile := "test_handlers.db" + testName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + dbFile := t.TempDir() + "/test_handlers_" + testName + ".db" db, err := database.New(dbFile) require.NoError(t, err) t.Cleanup(func() { db.Close() - os.Remove(dbFile) + _ = os.Remove(dbFile) }) cfg := &config.Config{ @@ -234,7 +235,7 @@ func TestUserKeyboardHidesPaymentButtonInMaintenanceMode(t *testing.T) { } assert.Contains(t, normalButtons, BtnRenew) - b.maintenanceMode = true + b.setMaintenanceMode(true) kb = b.userKeyboard(userID) var maintenanceButtons []string @@ -267,6 +268,54 @@ func TestUserKeyboardHidesPaymentButtonWithoutPlatega(t *testing.T) { assert.NotContains(t, buttons, BtnRenew) } +func TestUserKeyboardShowsRenewForLegacyPaidMigratedUser(t *testing.T) { + b, db := setupTestBot(t) + + modID := int64(781) + userID := int64(780) + price := 500 + _, err := db.CreateUser(modID, "moderator", "Moderator", "uuid-mod-781", nil, nil) + require.NoError(t, err) + + _, err = db.CreateUser(userID, "legacy_paid", "Legacy Paid", "uuid-legacy-paid", &price, &modID) + require.NoError(t, err) + + expireDays := 30 + invite, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(invite.Code, userID)) + + require.NoError(t, db.SetLegacyPaidMigrated(userID, true)) + + b.platega = platega.NewClient("merchant", "secret") + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users/by-telegram-id/780": + payload := `{"response":{"uuid":"uuid-legacy-paid","username":"legacy_paid","status":"ACTIVE","expireAt":"2026-04-15T00:00:00Z"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + default: + return nil, assert.AnError + } + }), + }) + + kb := b.userKeyboard(userID) + var buttons []string + for _, row := range kb.ReplyKeyboard { + for _, btn := range row { + buttons = append(buttons, btn.Text) + } + } + + assert.Contains(t, buttons, BtnRenew) + assert.NotContains(t, buttons, BtnPay) +} + func TestHandleStatusShowsDevices(t *testing.T) { b, db := setupTestBot(t) diff --git a/internal/bot/keyboards.go b/internal/bot/keyboards.go index 924bd61..1327bfc 100644 --- a/internal/bot/keyboards.go +++ b/internal/bot/keyboards.go @@ -45,6 +45,8 @@ const ( BtnAdminSwitchSubscription = "♾️ Сменить тариф" BtnAdminSwitchInfinite = "♾️ Перевести на бессрочную" BtnAdminChangePrice = "✏️ Изменить цену" + BtnAdminMigrationPaidYes = "✅ Да, считать оплаченной" + BtnAdminMigrationPaidNo = "❌ Нет, оставить trial" // Кнопки подтверждения BtnConfirmYes = "Да" @@ -185,6 +187,16 @@ func AdminModeratorKeyboard() *tele.ReplyMarkup { return menu } +// AdminChangePriceMigrationKeyboard возвращает меню подтверждения migration-case. +func AdminChangePriceMigrationKeyboard() *tele.ReplyMarkup { + menu := &tele.ReplyMarkup{ResizeKeyboard: true} + menu.Reply( + menu.Row(menu.Text(BtnAdminMigrationPaidYes), menu.Text(BtnAdminMigrationPaidNo)), + menu.Row(menu.Text(BtnCancel)), + ) + return menu +} + // CancelKeyboard возвращает клавиатуру с кнопкой отмены func CancelKeyboard() *tele.ReplyMarkup { menu := &tele.ReplyMarkup{ResizeKeyboard: true} diff --git a/internal/bot/keyboards_test.go b/internal/bot/keyboards_test.go index 8e9d509..83e8ca8 100644 --- a/internal/bot/keyboards_test.go +++ b/internal/bot/keyboards_test.go @@ -101,6 +101,21 @@ func TestAdminModeratorKeyboardContainsStatsButton(t *testing.T) { assert.Contains(t, buttons, BtnAdminModStats) } +func TestAdminChangePriceMigrationKeyboardContainsExpectedButtons(t *testing.T) { + keyboard := AdminChangePriceMigrationKeyboard() + + var buttons []string + for _, row := range keyboard.ReplyKeyboard { + for _, btn := range row { + buttons = append(buttons, btn.Text) + } + } + + assert.Contains(t, buttons, BtnAdminMigrationPaidYes) + assert.Contains(t, buttons, BtnAdminMigrationPaidNo) + assert.Contains(t, buttons, BtnCancel) +} + func TestInstructionsKeyboardContainsUnifiedDesktopButton(t *testing.T) { keyboard := InstructionsKeyboard() diff --git a/internal/bot/maintenance.go b/internal/bot/maintenance.go new file mode 100644 index 0000000..8f4ed06 --- /dev/null +++ b/internal/bot/maintenance.go @@ -0,0 +1,22 @@ +package bot + +// isMaintenanceMode возвращает текущее состояние режима обслуживания. +func (b *Bot) isMaintenanceMode() bool { + return b.maintenanceMode.Load() +} + +// setMaintenanceMode явно устанавливает режим обслуживания. +func (b *Bot) setMaintenanceMode(enabled bool) { + b.maintenanceMode.Store(enabled) +} + +// toggleMaintenanceMode переключает режим обслуживания и возвращает новое состояние. +func (b *Bot) toggleMaintenanceMode() bool { + for { + current := b.maintenanceMode.Load() + next := !current + if b.maintenanceMode.CompareAndSwap(current, next) { + return next + } + } +} diff --git a/internal/bot/maintenance_test.go b/internal/bot/maintenance_test.go new file mode 100644 index 0000000..96d1026 --- /dev/null +++ b/internal/bot/maintenance_test.go @@ -0,0 +1,24 @@ +package bot + +import "testing" + +func TestMaintenanceModeHelpers(t *testing.T) { + b := &Bot{} + + if b.isMaintenanceMode() { + t.Fatal("режим обслуживания должен быть выключен по умолчанию") + } + + b.setMaintenanceMode(true) + if !b.isMaintenanceMode() { + t.Fatal("режим обслуживания должен включаться через helper") + } + + if enabled := b.toggleMaintenanceMode(); enabled { + t.Fatal("toggle должен выключить режим обслуживания") + } + + if b.isMaintenanceMode() { + t.Fatal("режим обслуживания должен быть выключен после toggle") + } +} diff --git a/internal/bot/payment_handler.go b/internal/bot/payment_handler.go index acf16ac..64cc4f5 100644 --- a/internal/bot/payment_handler.go +++ b/internal/bot/payment_handler.go @@ -20,7 +20,7 @@ func (b *Bot) handlePayButton(c tele.Context) error { telegramID := c.Sender().ID // Проверка режима обслуживания - if b.maintenanceMode { + if b.isMaintenanceMode() { return c.Send("⚙️ Платёжная система временно на обслуживании. Попробуйте позже.", &tele.SendOptions{ ReplyMarkup: b.userKeyboard(telegramID), }) diff --git a/internal/bot/scheduler.go b/internal/bot/scheduler.go index 8751a38..78e048a 100644 --- a/internal/bot/scheduler.go +++ b/internal/bot/scheduler.go @@ -129,7 +129,7 @@ func (b *Bot) processTrialUser(telegramID int64, dbUser database.User, expireAt, // Триал истёк — кик if !now.Before(expireAt) { - if b.maintenanceMode { + if b.isMaintenanceMode() { slog.Info("Scheduler: maintenance mode, пропускаем кик триального пользователя", "telegram_id", telegramID) return } @@ -182,7 +182,7 @@ func (b *Bot) processPaidUser(telegramID int64, dbUser database.User, expireAt, return // Оплатил — callback уже обработал } - if !b.maintenanceMode { + if !b.isMaintenanceMode() { // Disable в Remnawave (если ещё не disabled) if err := b.remnawave.DisableUser(dbUser.RemnawaveUUID); err != nil { slog.Warn("Scheduler: не удалось disable пользователя", "error", err, "telegram_id", telegramID) @@ -196,7 +196,7 @@ func (b *Bot) processPaidUser(telegramID int64, dbUser database.User, expireAt, // Grace period кик: expireAt + 72 часа graceDeadline := expireAt.Add(72 * time.Hour) if !now.Before(graceDeadline) { - if b.maintenanceMode { + if b.isMaintenanceMode() { slog.Info("Scheduler: maintenance mode, пропускаем grace kick", "telegram_id", telegramID) return } @@ -236,6 +236,14 @@ func (b *Bot) processPaidUser(telegramID int64, dbUser database.User, expireAt, // confirmed_not_activated уже не считается "не платил": деньги подтверждены, просто // активация доступа в панели временно отложена на retry. func (b *Bot) isTrialUser(telegramID int64) bool { + user, err := b.db.GetUserByTelegramID(telegramID) + if err != nil || user == nil { + return false + } + if user.LegacyPaidMigrated { + return false + } + invite, err := b.db.GetInviteByUsedBy(telegramID) if err != nil || invite == nil || invite.ExpireDays == nil { return false // Админский инвайт или нет инвайта — не триал diff --git a/internal/bot/scheduler_test.go b/internal/bot/scheduler_test.go index 1831710..5d779cc 100644 --- a/internal/bot/scheduler_test.go +++ b/internal/bot/scheduler_test.go @@ -21,12 +21,12 @@ import ( // setupSchedulerTestBot создаёт бота для тестов scheduler. func setupSchedulerTestBot(t *testing.T) (*Bot, *database.DB) { t.Helper() - dbFile := fmt.Sprintf("test_scheduler_%s.db", t.Name()) + dbFile := t.TempDir() + "/" + fmt.Sprintf("test_scheduler_%s.db", t.Name()) db, err := database.New(dbFile) require.NoError(t, err) t.Cleanup(func() { db.Close() - os.Remove(dbFile) + _ = os.Remove(dbFile) }) cfg := &config.Config{AdminID: 999} b := &Bot{ @@ -38,6 +38,22 @@ func setupSchedulerTestBot(t *testing.T) (*Bot, *database.DB) { return b, db } +func newOfflineTelegramBotForTest(t *testing.T, transport http.RoundTripper) *tele.Bot { + t.Helper() + + bot, err := tele.NewBot(tele.Settings{ + Token: "test-token", + URL: "https://api.telegram.org", + Offline: true, + Client: &http.Client{ + Transport: transport, + }, + }) + require.NoError(t, err) + + return bot +} + // TestHandleAutoKick_404IsNotFatalError проверяет, что при 404 от Remnawave (пользователь // уже удалён администратором) handleAutoKick всё равно выполняет очистку в БД. func TestHandleAutoKick_404IsNotFatalError(t *testing.T) { @@ -197,6 +213,28 @@ func TestIsTrialUser(t *testing.T) { }) } +func TestIsTrialUserTreatsLegacyPaidMigratedUserAsPaid(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(120) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod-120", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(220) + price := 500 + _, err = db.CreateUser(userID, "legacy_paid", "Legacy Paid", "uuid-220", &price, &modID) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + require.NoError(t, db.SetLegacyPaidMigrated(userID, true)) + + assert.False(t, b.isTrialUser(userID)) +} + // TestSchedulerTrialKick проверяет кик триального пользователя после expireAt func TestSchedulerTrialKick(t *testing.T) { b, db := setupSchedulerTestBot(t) @@ -263,6 +301,70 @@ func TestSchedulerTrialWaitsForExactExpireAt(t *testing.T) { assert.NotNil(t, dbUser, "триальный пользователь не должен кикаться раньше точного expireAt") } +func TestSchedulerPaidReminderForLegacyPaidMigratedUser(t *testing.T) { + b, db := setupSchedulerTestBot(t) + + modID := int64(130) + _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod-130", nil, nil) + require.NoError(t, err) + db.Conn().Exec(`INSERT INTO moderators (telegram_id, added_by) VALUES (?, ?)`, modID, 999) + + userID := int64(230) + price := 500 + _, err = db.CreateUser(userID, "legacy_paid", "Legacy Paid", "uuid-230", &price, &modID) + require.NoError(t, err) + + expireDays := 30 + inv, err := db.CreateInviteWithExpiry(modID, &expireDays) + require.NoError(t, err) + require.NoError(t, db.ClaimInvite(inv.Code, userID)) + require.NoError(t, db.SetLegacyPaidMigrated(userID, true)) + + expireAt := time.Now().UTC().Add(60 * time.Hour) + + b.remnawave = remnawave.NewClient("https://panel.example.com", "test-token", nil) + b.remnawave.SetHTTPClient(&http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/users" && r.URL.Query().Get("size") == "1000": + payload := fmt.Sprintf(`{"response":{"users":[{"uuid":"uuid-230","username":"legacy_paid","status":"ACTIVE","telegramId":230,"expireAt":"%s"}],"total":1}}`, + expireAt.Format(time.RFC3339)) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + default: + return nil, fmt.Errorf("unexpected remnawave request: %s %s", r.Method, r.URL.Path) + } + }), + }) + + b.bot = newOfflineTelegramBotForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/sendMessage"): + payload := `{"ok":true,"result":{"message_id":1,"date":1710000000,"chat":{"id":230,"type":"private"},"text":"ok"}}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payload)), + Header: make(http.Header), + }, nil + default: + return nil, fmt.Errorf("unexpected telegram request: %s %s", r.Method, r.URL.Path) + } + })) + + b.runSubscriptionSchedulerPass() + + sent, err := db.WasNotificationSent(userID, notificationExpire3d) + require.NoError(t, err) + assert.True(t, sent, "migrated-paid пользователь должен получить 3-day reminder") + + trialSent, err := db.WasNotificationSent(userID, notificationTrialExpire1d) + require.NoError(t, err) + assert.False(t, trialSent, "migrated-paid пользователь не должен идти по trial-ветке") +} + // TestSchedulerTrialNotKickedIfPaid проверяет, что оплативший триальный не кикается func TestSchedulerTrialNotKickedIfPaid(t *testing.T) { b, db := setupSchedulerTestBot(t) @@ -791,7 +893,7 @@ func TestSchedulerGraceKickSkippedWhenFreshStatusCheckFails(t *testing.T) { // TestSchedulerMaintenanceMode проверяет, что в maintenance mode кики и disable не выполняются func TestSchedulerMaintenanceMode(t *testing.T) { b, db := setupSchedulerTestBot(t) - b.maintenanceMode = true + b.setMaintenanceMode(true) modID := int64(100) _, err := db.CreateUser(modID, "mod", "Mod", "uuid-mod", nil, nil) diff --git a/internal/database/db.go b/internal/database/db.go index cc89124..f4533a1 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -17,13 +17,14 @@ type DB struct { // User представляет запись пользователя type User struct { - TelegramID int64 - Username string - FirstName string // Имя пользователя из Telegram - RemnawaveUUID string - SubscriptionPrice *int // Цена подписки руб/мес (NULL = не установлена) - ModeratorID *int64 // Telegram ID куратора (NULL = админский/снят) - CreatedAt time.Time + TelegramID int64 + Username string + FirstName string // Имя пользователя из Telegram + RemnawaveUUID string + SubscriptionPrice *int // Цена подписки руб/мес (NULL = не установлена) + ModeratorID *int64 // Telegram ID куратора (NULL = админский/снят) + LegacyPaidMigrated bool // Старый пользователь с ручной оплатой, переведённый на новую модель + CreatedAt time.Time } // Invite представляет запись инвайта @@ -173,6 +174,8 @@ func migrate(conn *sql.DB) error { `ALTER TABLE users ADD COLUMN subscription_price INTEGER`, // Миграция: telegram_id модератора-куратора (NULL = админский или снят модератор) `ALTER TABLE users ADD COLUMN moderator_id INTEGER`, + // Миграция: флаг старой ручной оплаты для перевода legacy-пользователей на новую модель + `ALTER TABLE users ADD COLUMN legacy_paid_migrated INTEGER NOT NULL DEFAULT 0`, // Миграция: цена подписки при создании инвайта `ALTER TABLE invites ADD COLUMN subscription_price INTEGER`, // Миграция: неизменяемый исторический флаг trial-инвайта diff --git a/internal/database/users.go b/internal/database/users.go index c4d625a..7c7059c 100644 --- a/internal/database/users.go +++ b/internal/database/users.go @@ -8,7 +8,7 @@ import ( // CreateUser создаёт нового пользователя func (db *DB) CreateUser(telegramID int64, username, firstName, remnawaveUUID string, subscriptionPrice *int, moderatorID *int64) (*User, error) { _, err := db.conn.Exec( - `INSERT INTO users (telegram_id, username, first_name, remnawave_uuid, subscription_price, moderator_id) VALUES (?, ?, ?, ?, ?, ?)`, + `INSERT INTO users (telegram_id, username, first_name, remnawave_uuid, subscription_price, moderator_id, legacy_paid_migrated) VALUES (?, ?, ?, ?, ?, ?, 0)`, telegramID, username, firstName, remnawaveUUID, subscriptionPrice, moderatorID, ) if err != nil { @@ -24,10 +24,11 @@ func (db *DB) GetUserByTelegramID(telegramID int64) (*User, error) { var firstName sql.NullString var subPrice sql.NullInt64 var modID sql.NullInt64 + var legacyPaidMigrated sql.NullInt64 err := db.conn.QueryRow( - `SELECT telegram_id, username, first_name, remnawave_uuid, subscription_price, moderator_id, created_at FROM users WHERE telegram_id = ?`, + `SELECT telegram_id, username, first_name, remnawave_uuid, subscription_price, moderator_id, legacy_paid_migrated, created_at FROM users WHERE telegram_id = ?`, telegramID, - ).Scan(&user.TelegramID, &user.Username, &firstName, &user.RemnawaveUUID, &subPrice, &modID, &user.CreatedAt) + ).Scan(&user.TelegramID, &user.Username, &firstName, &user.RemnawaveUUID, &subPrice, &modID, &legacyPaidMigrated, &user.CreatedAt) if err == sql.ErrNoRows { return nil, nil @@ -46,6 +47,7 @@ func (db *DB) GetUserByTelegramID(telegramID int64) (*User, error) { if modID.Valid { user.ModeratorID = &modID.Int64 } + user.LegacyPaidMigrated = legacyPaidMigrated.Valid && legacyPaidMigrated.Int64 != 0 return &user, nil } @@ -56,10 +58,11 @@ func (db *DB) GetUserByRemnawaveUUID(uuid string) (*User, error) { var firstName sql.NullString var subPrice sql.NullInt64 var modID sql.NullInt64 + var legacyPaidMigrated sql.NullInt64 err := db.conn.QueryRow( - `SELECT telegram_id, username, first_name, remnawave_uuid, subscription_price, moderator_id, created_at FROM users WHERE remnawave_uuid = ?`, + `SELECT telegram_id, username, first_name, remnawave_uuid, subscription_price, moderator_id, legacy_paid_migrated, created_at FROM users WHERE remnawave_uuid = ?`, uuid, - ).Scan(&user.TelegramID, &user.Username, &firstName, &user.RemnawaveUUID, &subPrice, &modID, &user.CreatedAt) + ).Scan(&user.TelegramID, &user.Username, &firstName, &user.RemnawaveUUID, &subPrice, &modID, &legacyPaidMigrated, &user.CreatedAt) if err == sql.ErrNoRows { return nil, nil @@ -78,6 +81,7 @@ func (db *DB) GetUserByRemnawaveUUID(uuid string) (*User, error) { if modID.Valid { user.ModeratorID = &modID.Int64 } + user.LegacyPaidMigrated = legacyPaidMigrated.Valid && legacyPaidMigrated.Int64 != 0 return &user, nil } @@ -85,7 +89,7 @@ func (db *DB) GetUserByRemnawaveUUID(uuid string) (*User, error) { // GetAllUsers получает всех пользователей func (db *DB) GetAllUsers() ([]User, error) { rows, err := db.conn.Query( - `SELECT telegram_id, username, first_name, remnawave_uuid, subscription_price, moderator_id, created_at FROM users ORDER BY created_at`, + `SELECT telegram_id, username, first_name, remnawave_uuid, subscription_price, moderator_id, legacy_paid_migrated, created_at FROM users ORDER BY created_at`, ) if err != nil { return nil, fmt.Errorf("failed to query users: %w", err) @@ -98,7 +102,8 @@ func (db *DB) GetAllUsers() ([]User, error) { var firstName sql.NullString var subPrice sql.NullInt64 var modID sql.NullInt64 - if err := rows.Scan(&user.TelegramID, &user.Username, &firstName, &user.RemnawaveUUID, &subPrice, &modID, &user.CreatedAt); err != nil { + var legacyPaidMigrated sql.NullInt64 + if err := rows.Scan(&user.TelegramID, &user.Username, &firstName, &user.RemnawaveUUID, &subPrice, &modID, &legacyPaidMigrated, &user.CreatedAt); err != nil { return nil, fmt.Errorf("failed to scan user: %w", err) } if firstName.Valid { @@ -111,6 +116,7 @@ func (db *DB) GetAllUsers() ([]User, error) { if modID.Valid { user.ModeratorID = &modID.Int64 } + user.LegacyPaidMigrated = legacyPaidMigrated.Valid && legacyPaidMigrated.Int64 != 0 users = append(users, user) } @@ -127,6 +133,51 @@ func (db *DB) UpdateSubscriptionPrice(telegramID int64, price int) error { return err } +// UpdateSubscriptionPriceAndLegacyPaidMigrated обновляет цену и флаг migration в одной транзакции. +func (db *DB) UpdateSubscriptionPriceAndLegacyPaidMigrated(telegramID int64, price int, legacyPaidMigrated *bool) error { + tx, err := db.conn.Begin() + if err != nil { + return err + } + + rollback := func(err error) error { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("%w: rollback failed: %v", err, rbErr) + } + return err + } + + if _, err := tx.Exec(`UPDATE users SET subscription_price = ? WHERE telegram_id = ?`, price, telegramID); err != nil { + return rollback(err) + } + + if legacyPaidMigrated != nil { + intValue := 0 + if *legacyPaidMigrated { + intValue = 1 + } + if _, err := tx.Exec(`UPDATE users SET legacy_paid_migrated = ? WHERE telegram_id = ?`, intValue, telegramID); err != nil { + return rollback(err) + } + } + + if err := tx.Commit(); err != nil { + return err + } + + return nil +} + +// SetLegacyPaidMigrated помечает пользователя как переведённого со старой ручной оплаты. +func (db *DB) SetLegacyPaidMigrated(telegramID int64, value bool) error { + var intValue int + if value { + intValue = 1 + } + _, err := db.conn.Exec(`UPDATE users SET legacy_paid_migrated = ? WHERE telegram_id = ?`, intValue, telegramID) + return err +} + // UpdateUsername обновляет username пользователя func (db *DB) UpdateUsername(telegramID int64, username string) error { _, err := db.conn.Exec( diff --git a/internal/database/users_test.go b/internal/database/users_test.go new file mode 100644 index 0000000..0191865 --- /dev/null +++ b/internal/database/users_test.go @@ -0,0 +1,70 @@ +package database + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserLegacyPaidMigratedPersists(t *testing.T) { + dbFile := "test_users_legacy_paid_migrated.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + price := 500 + _, err = db.CreateUser(1010, "legacy_paid", "Legacy Paid", "uuid-1010", &price, nil) + require.NoError(t, err) + + user, err := db.GetUserByTelegramID(1010) + require.NoError(t, err) + require.NotNil(t, user) + assert.False(t, user.LegacyPaidMigrated) + + err = db.SetLegacyPaidMigrated(1010, true) + require.NoError(t, err) + + user, err = db.GetUserByTelegramID(1010) + require.NoError(t, err) + require.NotNil(t, user) + assert.True(t, user.LegacyPaidMigrated) + + user, err = db.GetUserByRemnawaveUUID("uuid-1010") + require.NoError(t, err) + require.NotNil(t, user) + assert.True(t, user.LegacyPaidMigrated) + + users, err := db.GetAllUsers() + require.NoError(t, err) + require.Len(t, users, 1) + assert.True(t, users[0].LegacyPaidMigrated) +} + +func TestUpdateSubscriptionPriceAndLegacyPaidMigrated(t *testing.T) { + dbFile := "test_users_update_price_and_legacy_flag.db" + db, err := New(dbFile) + require.NoError(t, err) + defer func() { + db.Close() + os.Remove(dbFile) + }() + + price := 500 + _, err = db.CreateUser(2020, "legacy_paid", "Legacy Paid", "uuid-2020", &price, nil) + require.NoError(t, err) + + value := true + require.NoError(t, db.UpdateSubscriptionPriceAndLegacyPaidMigrated(2020, 650, &value)) + + user, err := db.GetUserByTelegramID(2020) + require.NoError(t, err) + require.NotNil(t, user) + require.NotNil(t, user.SubscriptionPrice) + assert.Equal(t, 650, *user.SubscriptionPrice) + assert.True(t, user.LegacyPaidMigrated) +} diff --git a/internal/monitoring/sync.go b/internal/monitoring/sync.go index bf73126..84f19de 100644 --- a/internal/monitoring/sync.go +++ b/internal/monitoring/sync.go @@ -93,7 +93,7 @@ func SyncNodes(client *remnawave.Client, sdConfigsPath string) (int, error) { } targetFile := filepath.Join(sdConfigsPath, "targets.json") - if err := os.WriteFile(targetFile, data, 0644); err != nil { + if err := writeFileAtomically(targetFile, data, 0644); err != nil { return 0, fmt.Errorf("не удалось записать %s: %w", targetFile, err) } @@ -116,3 +116,47 @@ func ReadTargets(sdConfigsPath string) ([]Target, error) { return targets, nil } + +// writeFileAtomically сначала пишет данные во временный файл рядом с целевым, +// а затем атомарно подменяет основной файл через rename. +func writeFileAtomically(path string, data []byte, perm os.FileMode) (err error) { + dir := filepath.Dir(path) + pattern := filepath.Base(path) + ".tmp-*" + + tmpFile, err := os.CreateTemp(dir, pattern) + if err != nil { + return fmt.Errorf("создание временного файла: %w", err) + } + + tmpPath := tmpFile.Name() + defer func() { + if err != nil { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + return fmt.Errorf("запись временного файла: %w", err) + } + + if err := tmpFile.Chmod(perm); err != nil { + tmpFile.Close() + return fmt.Errorf("chmod временного файла: %w", err) + } + + if err := tmpFile.Sync(); err != nil { + tmpFile.Close() + return fmt.Errorf("sync временного файла: %w", err) + } + + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("close временного файла: %w", err) + } + + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("rename временного файла: %w", err) + } + + return nil +} diff --git a/internal/monitoring/sync_test.go b/internal/monitoring/sync_test.go index 4581ad4..332863f 100644 --- a/internal/monitoring/sync_test.go +++ b/internal/monitoring/sync_test.go @@ -1,6 +1,10 @@ package monitoring -import "testing" +import ( + "os" + "path/filepath" + "testing" +) func TestParseBandwidthTag(t *testing.T) { tests := []struct { @@ -30,3 +34,29 @@ func TestParseBandwidthTag(t *testing.T) { }) } } + +func TestWriteFileAtomicallyReplacesContentAndCleansTempFile(t *testing.T) { + dir := t.TempDir() + targetFile := filepath.Join(dir, "targets.json") + + if err := os.WriteFile(targetFile, []byte(`old-content`), 0644); err != nil { + t.Fatalf("подготовка старого файла: %v", err) + } + + newContent := []byte(`[{"targets":["127.0.0.1:9100"]}]`) + if err := writeFileAtomically(targetFile, newContent, 0644); err != nil { + t.Fatalf("writeFileAtomically вернул ошибку: %v", err) + } + + got, err := os.ReadFile(targetFile) + if err != nil { + t.Fatalf("чтение итогового файла: %v", err) + } + if string(got) != string(newContent) { + t.Fatalf("итоговый файл = %q, want %q", string(got), string(newContent)) + } + + if _, err := os.Stat(targetFile + ".tmp"); !os.IsNotExist(err) { + t.Fatalf("временный файл не должен оставаться после успешной записи") + } +} From 62f74ca11ed94289f1d0e693d0dc94c4e80738b7 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 21:35:46 +0300 Subject: [PATCH 32/34] =?UTF-8?q?docs:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8=D1=8E=20=D0=BF=D0=BE=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=84=D0=B8=D0=B3=D1=83=20=D0=B8=20=D0=B0=D0=B4=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D1=81=D0=BA=D0=BE=D0=BC=D1=83=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + README.md | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/.env.example b/.env.example index 0593e67..ec2e941 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,7 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2 # опционально, UUID inte DB_PATH=/app/data/bot.db # Мониторинг +SD_CONFIGS_PATH=/app/sd_configs VICTORIA_METRICS_URL=http://victoriametrics:8428 # Субтитры (опционально) diff --git a/README.md b/README.md index 96134fc..8769a7f 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ make logs | `REMNAWAVE_API_TOKEN` | ✅ | JWT-токен из панели Remnawave | | `DB_PATH` | — | Путь к SQLite-базе (дефолт: `/app/data/bot.db`) | | `REMNAWAVE_DEFAULT_SQUAD_UUIDS` | — | Список UUID internal squads через запятую; новые пользователи добавляются во все перечисленные сквады | +| `SD_CONFIGS_PATH` | — | Путь к папке service discovery для мониторинга (дефолт: `/app/sd_configs`) | | `VICTORIA_METRICS_URL` | — | URL VictoriaMetrics (дефолт: `http://victoriametrics:8428`) | | `CALLBACK_PORT` | — | Порт встроенного callback-сервера Platega (дефолт: `8080`) | | `MIN_SUBSCRIPTION_PRICE` | — | Минимальная цена подписки для модераторов (дефолт: `400`) | @@ -53,6 +54,7 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 Для обратной совместимости бот также понимает legacy-переменную `REMNAWAVE_DEFAULT_SQUAD_UUID`, если новый список не задан. Если `PLATEGA_MERCHANT_ID` и `PLATEGA_SECRET` не заданы, бот запускается как раньше: callback-сервер не поднимается, кнопки оплаты не показываются. +Подробности по интеграции Platega и настройке callback-сервера: [docs/platega/README.md](docs/platega/README.md) и [docs/plans/2026-03-22-deployment-checklist.md](docs/plans/2026-03-22-deployment-checklist.md). ## Функциональность @@ -90,6 +92,8 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 | `📋 Управление` | Инвайты и действия с пользователями (см. ниже) | | `👥 Модераторы` | Управление ролями: назначить / список / статистика / снять модератора | | `📢 Рассылка` | Отправка сообщения всем активным пользователям (текст, фото, видео, документы) | +| `📊 Общая статистика` | Финансовая сводка за месяц, конверсия trial → оплата и текущее состояние базы | +| `🔧 Режим обслуживания` / `▶️ Штатный режим` | Временно скрывает оплату у пользователей и останавливает scheduler-кики/disable | | `👤 Режим пользователя` | Просмотр бота глазами пользователя | Кнопка `📋 Управление` открывает панель инвайтов и действий с пользователями: @@ -101,6 +105,7 @@ REMNAWAVE_DEFAULT_SQUAD_UUIDS=uuid-1,uuid-2,uuid-3 | `🗑 Удалить код` | Удаление неиспользованного кода (использованные защищены) | | `🚫 Забанить` | Перманентный бан: запись в ban-лист + удаление из БД бота и Remnawave | | `♾️ Сменить тариф` | Перевод пользователя с месячной подписки на бессрочную без смены куратора | +| `🔍 Инфо о пользователе` | Карточка пользователя: куратор, цена, срок, трафик, устройства, тип подписки и статус | | `✏️ Изменить цену` | Смена цены месячной подписки; для legacy-пользователя без цены бот отдельно спросит, считать ли текущий период уже оплаченным | При активации нового инвайта админ получает уведомление с Telegram ID, username и именем. @@ -217,4 +222,6 @@ vpn_bot/ ## Ссылки - [Remnawave](https://remnawave.com) +- [Документация Platega](docs/platega/README.md) +- [Чеклист деплоя платежей](docs/plans/2026-03-22-deployment-checklist.md) - [CLAUDE.md](CLAUDE.md) / [AGENTS.md](AGENTS.md) — инструкции для работы с репозиторием From 65a81543a633b2ddbe99e0ef6309950f66caa91a Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 21:38:08 +0300 Subject: [PATCH 33/34] =?UTF-8?q?docs:=20=D0=B0=D0=BA=D1=82=D1=83=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D1=87=D0=B5=D0=BA=D0=BB=D0=B8=D1=81=D1=82=20=D0=B4=D0=B5=D0=BF?= =?UTF-8?q?=D0=BB=D0=BE=D1=8F=20Platega?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-03-22-deployment-checklist.md | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/docs/plans/2026-03-22-deployment-checklist.md b/docs/plans/2026-03-22-deployment-checklist.md index 131e641..90eb585 100644 --- a/docs/plans/2026-03-22-deployment-checklist.md +++ b/docs/plans/2026-03-22-deployment-checklist.md @@ -1,6 +1,7 @@ # Чеклист развёртывания платёжной системы Platega **Дата:** 2026-03-22 +**Актуализировано под состояние ветки:** 2026-03-23 **Связан с:** [План реализации](./2026-03-22-payment-implementation-plan.md) --- @@ -63,6 +64,10 @@ PLATEGA_CALLBACK_URL=https://vpn.fus1ond.ru/platega/callback CALLBACK_PORT=8080 MIN_SUBSCRIPTION_PRICE=400 TRIAL_TRAFFIC_LIMIT_GB=1 +PLATEGA_FEE_SBP=11 +PLATEGA_FEE_CARD=12 +PLATEGA_FEE_CRYPTO=5 +PLATEGA_FEE_WITHDRAWAL=2 ``` --- @@ -256,7 +261,15 @@ dig +short vpn.fus1ond.ru # 3. Бэкап nginx-конфига cp /root/MyCVWEBsite/nginx.prod.conf /root/MyCVWEBsite/nginx.prod.conf.backup -# 4. Обновить .env файл vpn-bot (добавить PLATEGA_* переменные) +# 4. Обновить .env файл vpn-bot +# Добавить/проверить: +# - PLATEGA_MERCHANT_ID +# - PLATEGA_SECRET +# - PLATEGA_CALLBACK_URL +# - CALLBACK_PORT +# - MIN_SUBSCRIPTION_PRICE +# - TRIAL_TRAFFIC_LIMIT_GB +# - PLATEGA_FEE_SBP / CARD / CRYPTO / WITHDRAWAL nano /root/vpn_bot/.env # 5. Обновить docker-compose.yml vpn-bot (добавить сеть и порт, см. раздел 2.2) @@ -303,7 +316,10 @@ curl -s -o /dev/null -w "%{http_code}" -X POST https://vpn.fus1ond.ru/platega/ca # 13. Проверить логи на ошибки docker compose logs vpn-bot | grep -i "callback\|platega" -# Ожидаем: "Callback server starting", "Platega client initialized" +# Ожидаем как минимум: +# - "Platega client initialized" +# - "Callback server starting" +# - "Platega callback server started" ``` --- @@ -314,7 +330,8 @@ docker compose logs vpn-bot | grep -i "callback\|platega" При первом запуске нового кода: - Таблицы `payments` и `moderator_earnings` создаются автоматически -- Поля `subscription_price` и `moderator_id` добавляются в `users` (NULL для всех) +- Поля `subscription_price` и `moderator_id` добавляются в `users` (NULL для существующих) +- Поле `legacy_paid_migrated` добавляется в `users` со значением `0` для существующих записей - Поле `subscription_price` добавляется в `invites` (NULL для существующих) ### Ручная настройка (админ через бот) @@ -322,12 +339,17 @@ docker compose logs vpn-bot | grep -i "callback\|platega" Существующие пользователи с `subscription_price = NULL`: - Кнопка "Оплатить" **не показывается** (бот продолжает работать как раньше) - Подписки работают по старой модели (модератор продлевает вручную) +- Legacy-пользователи без инвайта и без `subscription_price` пропускаются payment-scheduler и продолжают жить по старой модели **Для перевода на новую модель:** 1. Админ заходит в бот → "Управление" → "Сменить тариф" → "Изменить цену" 2. Вводит telegram_id пользователя 3. Устанавливает цену подписки -4. После установки цены кнопка "Оплатить" появляется у пользователя +4. Если это обычный legacy-case, цена просто сохраняется и у пользователя появляется кнопка "Оплатить" +5. Если это legacy-пользователь модератора с уже активным ручным периодом, бот задаст дополнительный вопрос: + - `✅ Да, считать оплаченной` → сохраняется цена, выставляется `legacy_paid_migrated = true`, у пользователя сразу будет paid-ветка с кнопкой "Продлить подписку", напоминаниями за 3/1 день и grace period + - `❌ Нет, оставить trial` → сохраняется цена, пользователь остаётся в trial до первой оплаты через Platega +6. До ответа на migration-вопрос цена для такого legacy-case не применяется **Важно:** переводить пользователей можно постепенно, в своём темпе. Старая модель продолжает работать параллельно. @@ -373,12 +395,23 @@ curl -s -o /dev/null -w "%{http_code}" -X POST \ 3. Нажать "Оплатить подписку" → выбрать СБП → получить ссылку 4. Оплатить → подписка активирована на месяц -### Тест 5: Scheduler +### Тест 5: Legacy migration для старого платящего пользователя модератора + +1. Взять существующего legacy-пользователя модератора без `subscription_price`, но с ещё активным ручным периодом +2. Админ → "Управление" → "Сменить тариф" → ввести `telegram_id` → указать новую цену +3. Убедиться, что бот показывает migration-вопрос +4. Проверить оба сценария на тестовых данных: + - `✅ Да, считать оплаченной` → у пользователя появляется `Продлить подписку`, а не `Оплатить подписку` + - `❌ Нет, оставить trial` → у пользователя остаётся `Оплатить подписку` + +### Тест 6: Scheduler ```bash # Проверить в логах что scheduler запустился docker compose logs vpn-bot | grep -i scheduler -# Ожидаем: "Scheduler: running initial pass on startup" +# Ожидаем: +# - "Scheduler: running initial pass on startup" +# - "Subscription scheduler started" ``` --- @@ -438,5 +471,6 @@ docker compose -f docker-compose.prod.yml exec nginx nginx -s reload 1. **БД не откатывается автоматически.** Новые таблицы и колонки останутся после отката кода — это безопасно, SQLite игнорирует неиспользуемые колонки 2. **PENDING платежи протухают сами** — Platega отменяет их через ~15 минут 3. **Callback может прийти после отката** — nginx вернёт 502 (бот не слушает порт), Platega сделает retry до 3 раз. Если платёж прошёл, но callback не дошёл — пользователь может нажать "Проверить оплату" после восстановления -4. **Логи** — все платёжные события логируются (callback received, confirmed, errors). Для диагностики: `docker compose logs vpn-bot | grep -i "callback\|payment\|platega"` -5. **Порт 8080** — должен быть открыт только для localhost (127.0.0.1). Внешний доступ только через nginx (HTTPS) +4. **`confirmed_not_activated` — это защитный статус.** Деньги уже подтверждены, но активация в панели могла не завершиться; scheduler повторит активацию и не должен кикать/disable-ить такого пользователя как неоплатившего +5. **Логи** — все платёжные события логируются (callback, payment, scheduler, retry, errors). Для диагностики: `docker compose logs vpn-bot | grep -i "callback\|payment\|platega\|scheduler"` +6. **Порт 8080** — должен быть открыт только для localhost (127.0.0.1). Внешний доступ только через nginx (HTTPS) From 3ff17f2186f205736dc4f0b486f027bf124dfb60 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 23 Mar 2026 21:48:52 +0300 Subject: [PATCH 34/34] =?UTF-8?q?docs:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=BF=D0=BB=D0=BE=D1=8F=20=D0=BF=D0=BE=D0=B4=20fus1ond.ru?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-03-22-deployment-checklist.md | 123 +++++++----------- 1 file changed, 50 insertions(+), 73 deletions(-) diff --git a/docs/plans/2026-03-22-deployment-checklist.md b/docs/plans/2026-03-22-deployment-checklist.md index 90eb585..630a354 100644 --- a/docs/plans/2026-03-22-deployment-checklist.md +++ b/docs/plans/2026-03-22-deployment-checklist.md @@ -8,6 +8,9 @@ ## 1. Переменные окружения (.env) +> В этом чеклисте используется существующий домен `fus1ond.ru` с отдельным path-префиксом `/vpn-bot`. +> Если понадобится другой префикс, его нужно заменить единообразно в `PLATEGA_CALLBACK_URL`, nginx-конфиге и smoke test. + ### Обязательные (новые) ```env @@ -16,7 +19,7 @@ PLATEGA_MERCHANT_ID=ваш_merchant_id PLATEGA_SECRET=ваш_secret_key # Полный URL для callback (HTTPS обязательно!) -PLATEGA_CALLBACK_URL=https://vpn.fus1ond.ru/platega/callback +PLATEGA_CALLBACK_URL=https://fus1ond.ru/vpn-bot/platega/callback ``` ### Опциональные (с дефолтами) @@ -60,7 +63,7 @@ VICTORIA_METRICS_URL=http://victoriametrics:8428 # Platega (НОВОЕ) PLATEGA_MERCHANT_ID=your-merchant-id PLATEGA_SECRET=your-secret-key -PLATEGA_CALLBACK_URL=https://vpn.fus1ond.ru/platega/callback +PLATEGA_CALLBACK_URL=https://fus1ond.ru/vpn-bot/platega/callback CALLBACK_PORT=8080 MIN_SUBSCRIPTION_PRICE=400 TRIAL_TRAFFIC_LIMIT_GB=1 @@ -82,18 +85,22 @@ PLATEGA_FEE_WITHDRAWAL=2 - **Docker-compose nginx:** `/root/MyCVWEBsite/docker-compose.prod.yml` - **vpn-bot:** `/root/vpn_bot/docker-compose.yml`, контейнер `vpn-bot` - **Сети:** nginx в `mycvwebsite_pwp-network`, vpn-bot в `vpn_bot_vpn-network` — **разные сети!** -- **Домены:** `fus1ond.ru` (портфолио), `moto-23.ru` (магазин). Для VPN-бота домена пока нет +- **Домены:** `fus1ond.ru` (портфолио), `moto-23.ru` (магазин) - **SSL:** Let's Encrypt через certbot (контейнер `mycvwebsite-certbot-1`) ### Что нужно сделать -#### 2.1. Выделить домен/субдомен для callback +#### 2.1. Выделить path-префикс для callback на существующем домене + +Platega требует HTTPS. В текущем деплое отдельный домен или субдомен не нужен: callback и health публикуются через существующий `fus1ond.ru` с отдельным префиксом. -Platega требует HTTPS. Варианты: +Используем: -- **Вариант A (рекомендуется):** Субдомен `vpn.fus1ond.ru` — добавить A-запись в DNS, указывающую на `5.53.125.146` -- **Вариант B:** Отдельный домен -- **Вариант C:** Использовать `fus1ond.ru` с отдельным location — проще, но мешает основному сайту +- callback URL: `https://fus1ond.ru/vpn-bot/platega/callback` +- health URL: `https://fus1ond.ru/vpn-bot/health` +- внутренние роуты бота без изменений: + - `/platega/callback` + - `/health` #### 2.2. Подключить vpn-bot к сети nginx @@ -130,53 +137,30 @@ docker compose down && docker compose up -d Теперь nginx сможет обращаться к `vpn-bot:8080` по имени контейнера. -#### 2.3. Получить SSL-сертификат для субдомена +#### 2.3. Проверить существующий HTTPS для `fus1ond.ru` ```bash -# Добавить субдомен в certbot (из директории MyCVWEBsite) +# Проверить, что текущий домен уже отвечает по HTTPS cd /root/MyCVWEBsite -docker compose -f docker-compose.prod.yml exec certbot certbot certonly --webroot \ - --webroot-path=/var/www/certbot \ - -d vpn.fus1ond.ru \ - --agree-tos --no-eff-email +curl -I https://fus1ond.ru ``` -Или расширить существующий сертификат: +Дополнительно проверить, что nginx уже обслуживает `fus1ond.ru`: + ```bash -docker compose -f docker-compose.prod.yml exec certbot certbot certonly --webroot \ - --webroot-path=/var/www/certbot \ - -d fus1ond.ru -d vpn.fus1ond.ru \ - --agree-tos --no-eff-email --expand +docker compose -f docker-compose.prod.yml exec nginx nginx -T | grep -n "server_name fus1ond.ru" ``` -#### 2.4. Добавить server block в nginx.prod.conf +Если `fus1ond.ru` уже работает с валидным сертификатом, отдельный certbot-шаг для callback не нужен. + +#### 2.4. Добавить location-блоки в существующий server block `fus1ond.ru` -В файле `/root/MyCVWEBsite/nginx.prod.conf` добавить **перед** закрывающей `}` блока `http`: +В файле `/root/MyCVWEBsite/nginx.prod.conf` найти существующий `server` для `fus1ond.ru` и добавить в него: ```nginx - # VPN Bot Platega callback - server { - listen 80; - server_name vpn.fus1ond.ru; - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - location / { - return 301 https://$host$request_uri; - } - } - - server { - listen 443 ssl; - server_name vpn.fus1ond.ru; - ssl_certificate /etc/letsencrypt/live/vpn.fus1ond.ru/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/vpn.fus1ond.ru/privkey.pem; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - # Platega callback - location /platega/callback { - proxy_pass http://vpn-bot:8080; + # VPN Bot Platega callback через path-префикс + location = /vpn-bot/platega/callback { + proxy_pass http://vpn-bot:8080/platega/callback; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -190,19 +174,17 @@ docker compose -f docker-compose.prod.yml exec certbot certbot certonly --webroo proxy_send_timeout 60s; } - # Health check - location /health { - proxy_pass http://vpn-bot:8080; - } - - # Всё остальное — 404 - location / { - return 404; + # Health check бота через тот же path-префикс + location = /vpn-bot/health { + proxy_pass http://vpn-bot:8080/health; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } - } ``` -**Примечание:** `proxy_pass http://vpn-bot:8080` работает потому что vpn-bot подключён к сети `mycvwebsite_pwp-network` (шаг 2.2). Если вместо этого используется проброс порта на хост, заменить на `proxy_pass http://host.docker.internal:8080` или `proxy_pass http://172.17.0.1:8080` (IP хоста из Docker). +**Примечание:** `proxy_pass http://vpn-bot:8080/...` работает потому что vpn-bot подключён к сети `mycvwebsite_pwp-network` (шаг 2.2). Если вместо этого используется проброс порта на хост, заменить на `proxy_pass http://host.docker.internal:8080/...` или `proxy_pass http://172.17.0.1:8080/...` (IP хоста из Docker). #### 2.5. Перезагрузить nginx @@ -216,11 +198,11 @@ docker compose -f docker-compose.prod.yml exec nginx nginx -s reload ```bash # Health check через HTTPS -curl -s https://vpn.fus1ond.ru/health +curl -s https://fus1ond.ru/vpn-bot/health # Ответ: OK # Callback без заголовков — 401 -curl -s -o /dev/null -w "%{http_code}" -X POST https://vpn.fus1ond.ru/platega/callback +curl -s -o /dev/null -w "%{http_code}" -X POST https://fus1ond.ru/vpn-bot/platega/callback # Ответ: 401 ``` @@ -250,13 +232,9 @@ curl -s -o /dev/null -w "%{http_code}" -X POST https://vpn.fus1ond.ru/platega/ca cd /root/vpn_bot cp data/bot.db data/bot.db.backup-$(date +%Y%m%d) -# 2. Добавить DNS-запись vpn.fus1ond.ru → 5.53.125.146 -# (в панели управления DNS провайдера) - -# 2.1. Проверить DNS-пропагацию (certbot упадёт если DNS ещё не готов) -dig +short vpn.fus1ond.ru -# Ожидаемый ответ: 5.53.125.146 -# Если пусто — подождать (обычно 5-30 минут, иногда до 48 часов) +# 2. Проверить, что fus1ond.ru уже обслуживается текущим nginx по HTTPS +curl -I https://fus1ond.ru +# Ожидаемый ответ: HTTP 200/301/302, без TLS-ошибки # 3. Бэкап nginx-конфига cp /root/MyCVWEBsite/nginx.prod.conf /root/MyCVWEBsite/nginx.prod.conf.backup @@ -275,12 +253,11 @@ nano /root/vpn_bot/.env # 5. Обновить docker-compose.yml vpn-bot (добавить сеть и порт, см. раздел 2.2) nano /root/vpn_bot/docker-compose.yml -# 6. Получить SSL-сертификат для vpn.fus1ond.ru (см. раздел 2.3) +# 6. Проверить текущий HTTPS-конфиг для fus1ond.ru (см. раздел 2.3) cd /root/MyCVWEBsite -docker compose -f docker-compose.prod.yml exec certbot certbot certonly --webroot \ - --webroot-path=/var/www/certbot -d vpn.fus1ond.ru --agree-tos --no-eff-email +docker compose -f docker-compose.prod.yml exec nginx nginx -T | grep -n "server_name fus1ond.ru" -# 7. Добавить server block для vpn.fus1ond.ru в nginx (см. раздел 2.4) +# 7. Добавить location-блоки `/vpn-bot/...` в server block fus1ond.ru (см. раздел 2.4) nano /root/MyCVWEBsite/nginx.prod.conf # 8. Проверить и перезагрузить nginx @@ -307,11 +284,11 @@ curl -s http://127.0.0.1:8080/health # Ожидаемый ответ: OK # 11. Проверить через nginx (HTTPS) -curl -s https://vpn.fus1ond.ru/health +curl -s https://fus1ond.ru/vpn-bot/health # Ожидаемый ответ: OK # 12. Проверить что callback endpoint доступен -curl -s -o /dev/null -w "%{http_code}" -X POST https://vpn.fus1ond.ru/platega/callback +curl -s -o /dev/null -w "%{http_code}" -X POST https://fus1ond.ru/vpn-bot/platega/callback # Ожидаемый ответ: 401 (нет заголовков — это правильно) # 13. Проверить логи на ошибки @@ -364,7 +341,7 @@ docker compose logs vpn-bot | grep -i "callback\|platega" ### Тест 1: Health check ```bash -curl https://vpn.fus1ond.ru/health +curl https://fus1ond.ru/vpn-bot/health # Ответ: OK ``` @@ -372,14 +349,14 @@ curl https://vpn.fus1ond.ru/health ```bash # Без заголовков — должен быть 401 -curl -s -o /dev/null -w "%{http_code}" -X POST https://vpn.fus1ond.ru/platega/callback +curl -s -o /dev/null -w "%{http_code}" -X POST https://fus1ond.ru/vpn-bot/platega/callback # Ответ: 401 # С неверными заголовками — должен быть 401 curl -s -o /dev/null -w "%{http_code}" -X POST \ -H "X-MerchantId: wrong" \ -H "X-Secret: wrong" \ - https://vpn.fus1ond.ru/platega/callback + https://fus1ond.ru/vpn-bot/platega/callback # Ответ: 401 ``` @@ -457,7 +434,7 @@ make down && make up ### Откат nginx ```bash -# Удалить server block для vpn.fus1ond.ru из конфига +# Удалить location-блоки `/vpn-bot/platega/callback` и `/vpn-bot/health` из конфига nano /root/MyCVWEBsite/nginx.prod.conf cd /root/MyCVWEBsite docker compose -f docker-compose.prod.yml exec nginx nginx -t