Compare commits

...

45 Commits

Author SHA1 Message Date
Egor c1e015fb6e Merge pull request #2756 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.34.0
2026-03-18 05:58:02 +03:00
github-actions[bot] 0730173e5b chore(main): release 3.34.0 2026-03-18 02:56:31 +00:00
Egor 968f18b6e4 Merge pull request #2755 from BEDOLAGA-DEV/dev
Dev
2026-03-18 05:56:06 +03:00
Egor 7eea35f111 Merge pull request #2754 from BEDOLAGA-DEV/main
w
2026-03-18 05:54:06 +03:00
Fringg 6920e3a0fb style: ruff format 2026-03-18 05:53:31 +03:00
Fringg fddf8ef5eb fix: remove contains_eager conflicting with selectinload on user relationship
Loader strategies for the same ORM path cannot coexist. The
_apply_user_join_filter helper added contains_eager(model.user) on top
of the selectinload(Model.user) already present in each query, causing
InvalidRequestError at runtime. Removed contains_eager — selectinload
handles user loading correctly on its own.
2026-03-18 05:50:00 +03:00
Fringg ad268329be fix: добавлен импорт MAX_ALL_TIME_DAYS в admin_payments routes 2026-03-18 05:43:22 +03:00
Fringg 1804c28f05 feat: поиск платежей в админ-панели с фильтрами и статистикой
Новый сервис поиска по 13 платёжным провайдерам с ILIKE (escape от инъекций),
фильтрами по статусу/периоду/методу, кастомным диапазоном дат, пагинацией.
Эндпоинты: GET /search, GET /search/stats с валидацией входных данных.
2026-03-18 05:40:49 +03:00
Fringg f967c29bd7 fix: добавлены RioPay и SeverPay в REAL_PAYMENT_METHODS
Без этого платежи через RioPay и SeverPay не учитывались
в статистике доходов, разбивке по методам и отчётах
2026-03-18 03:59:33 +03:00
Fringg 06a00e367c feat: добавлен SeverPay в админ-панель и настройки кабинета
- Категория SEVERPAY в настройках бота
- Кнопка тестового платежа
- Конфигурация метода в кабинете
- DEFAULT_METHOD_ORDER обновлён
2026-03-18 03:55:24 +03:00
Fringg abaf279533 feat: добавлена интеграция SeverPay для пополнения баланса
- API клиент (HMAC-SHA256 подпись, создание/получение платежа)
- CRUD операции с FOR UPDATE блокировкой
- Payment mixin с обработкой webhook и финализацией
- Хендлеры бота для пополнения через SeverPay
- Миграция 0040: таблица severpay_payments
- Webhook endpoint (всегда 200 для предотвращения ретраев)
- Интеграция с payment_verification_service
- Поддержка гостевых покупок (лендинги, подарки)
2026-03-18 03:49:19 +03:00
Egor 6d4430c639 Merge pull request #2753 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.33.0
2026-03-18 02:02:09 +03:00
github-actions[bot] 911df7a05c chore(main): release 3.33.0 2026-03-17 23:01:41 +00:00
Egor f106ce8216 Merge pull request #2752 from BEDOLAGA-DEV/dev
Dev
2026-03-18 02:00:59 +03:00
Fringg dcff6947dd style: ruff format 2026-03-18 01:59:12 +03:00
Fringg 4966e39eb9 fix: скрыть плашку верификации email при выключенной верификации
- Добавлен verification_enabled в ответ /cabinet/branding/email-auth
- Фронтенд использует его для скрытия баннера и бейджа
2026-03-18 01:52:54 +03:00
Fringg 4abb8cb1a3 fix: исправлены проблемы RioPay интеграции после ревью
- Модель: user_id nullable=True + ondelete='SET NULL' (не применилось ранее)
- order_id для гостей: 'rpguest_xxx' вместо 'rpNone_xxx'
- Миграция: добавлено пересоздание FK с ON DELETE SET NULL
- get_latest_payment_by_method: добавлен RioPayPayment в model_map
2026-03-18 00:18:15 +03:00
Fringg 04f4e6bf6e feat: добавлена поддержка RioPay для лендингов и подарков
- Добавлен RioPay в create_guest_payment (landing/gift покупки)
- user_id в RioPayPayment теперь nullable (для гостевых платежей)
- Добавлен guest purchase flow в _finalize_riopay_payment
- Миграция 0039: riopay_payments.user_id nullable
2026-03-18 00:10:51 +03:00
Fringg 3d1fbc70f8 feat: добавлена поддержка RioPay в кабинете
- Добавлен RioPay в create_topup endpoint (cabinet balance)
- Добавлен маппинг статусов RioPay в _get_status_info
- Добавлена поддержка ручной проверки RioPay платежей
- Добавлена автопроверка RioPay в payment_verification_service
- Добавлены success_url/fail_url параметры в create_riopay_payment mixin
2026-03-18 00:05:07 +03:00
Fringg 3089c1704b fix: исправлен расчёт конверсии в статистике продаж
- Добавлен fallback через has_had_paid_subscription для подсчёта конверсий
- Исправлен знаменатель: total_trial_starters = new_trials + conversions
- Ограничение conversion_rate до 100% максимум
- Исправлен .is_(True) вместо == True в subscription_conversion.py
2026-03-17 23:46:15 +03:00
Fringg 20eff6170f fix: add back button to payment amount validation errors
All min/max amount error messages in payment handlers now include
a back button keyboard, so users aren't stuck without navigation.
Fixed 30 message.answer() calls across 12 payment handler files.
2026-03-17 23:34:05 +03:00
Fringg 038c34e52a fix: swap Caddy auth headers — api_key to Authorization, caddy_token to X-Api-Key
Caddy Security expects the caddy token in X-Api-Key and the Remnawave
API key in Authorization: Bearer. The headers were swapped, causing
401 errors for users with Caddy auth type.
2026-03-17 23:28:44 +03:00
Fringg 77f1a764d5 fix: merge phantom users into active accounts on /start
When a user purchases on a landing page by username and Bot.get_chat()
fails, a phantom user (telegram_id=NULL) is created. If that user
already has an active bot account, the phantom was never merged,
creating duplicate user records.

Now cmd_start checks for phantom users matching the active user's
username and merges them: transfers GuestPurchase records, balance,
and subscription (if active user has none). Phantom is soft-deleted
(status=DELETED, username=NULL) to preserve payment/transaction audit
trail and avoid CASCADE FK issues.
2026-03-17 23:06:02 +03:00
Fringg 641da949a9 fix: enforce promo group authorization on country/server selection
Previously, users could retain access to servers removed from their
promo group by re-submitting already-connected UUIDs in country
selection requests. The validation allowed any UUID present in
current connected_squads, bypassing promo group checks.

Now all selected server UUIDs must be in the user's allowed promo
group set. Unauthorized servers are rejected (cabinet/bot) or
filtered out (miniapp). Fixes authorization bypass across all 3
surfaces: cabinet, Telegram bot, and miniapp.
2026-03-17 22:30:57 +03:00
Fringg 3f0b24c1ec fix: add sync_squads=True to admin tariff change handler
Missed in the previous fix — admin tariff change at
handlers/admin/users.py sets connected_squads from tariff but
did not pass sync_squads=True to update_remnawave_user.
2026-03-17 22:25:01 +03:00
Fringg c34fdd10a0 fix: sync squads to Remnawave panel on tariff purchase/switch
When sync_squads parameter was introduced (4aaf0ddd) to prevent FK
violations from stale squad UUIDs, all update_remnawave_user calls
defaulted to sync_squads=False. This broke squad synchronization for
purchase/tariff-change flows where squads are freshly assigned and
must be sent to the panel.

Adds sync_squads=True to all purchase, tariff switch, and country
selection call sites across cabinet, bot handlers, miniapp, and
auto-purchase service.
2026-03-17 22:17:35 +03:00
Fringg 72b5305b87 fix: review findings — db.commit, isinstance guard, constants, ACTIVE check
- Explicit db.commit() for cabinet_last_login before _store_refresh_token
- isinstance(callback.message, types.Message) guard in process_webauth_confirm
- Check UserStatus.ACTIVE (not just DELETED) in bot callback handler
- isinstance guard in consume_web_auth_token for type safety
- Named constants: WEB_AUTH_LINKED_TTL, WEB_AUTH_TOKEN_MIN_LENGTH
- Use str.removeprefix() instead of hardcoded slice
- Move link_web_auth_token import to module level
2026-03-17 21:56:52 +03:00
Fringg 099391eb5f fix: deep link auth security and reliability fixes
- Atomic GETDEL in link_web_auth_token to prevent TOCTOU race
- Session fixation protection: inline keyboard confirmation before linking
- Poll rate limit 30→60/min to support 2.5s polling interval
- Fix double commit in poll endpoint (cabinet_last_login before _store_refresh_token)
- Replace magic string 'active' with UserStatus.ACTIVE.value
- Add response_model=AuthResponse to poll endpoint
- Validate bot_username is set (503 if empty)
- Move web_auth imports to module level
2026-03-17 21:46:09 +03:00
Fringg 322d457652 feat: deep link авторизация в кабинете при блокировке oauth.telegram.org
Когда скрипт Telegram Login Widget не загружается (заблокирован),
фронтенд автоматически переключается на deep link авторизацию:
- POST /cabinet/auth/deeplink/request — генерирует одноразовый токен
- Пользователь открывает t.me/bot?start=webauth_TOKEN
- Бот связывает токен с Telegram-аккаунтом
- POST /cabinet/auth/deeplink/poll — фронтенд получает JWT токены

Новый сервис: app/services/web_auth_service.py (Redis, TTL 5 мин)
2026-03-17 21:29:18 +03:00
Egor 5b722c5210 Merge pull request #2746 from smediainfo/pr/kassa-ai-sbp-card
feat: add SBP and Card sub-options for KassaAI payment method
2026-03-17 20:29:03 +03:00
Egor a80a85c2a4 Merge pull request #2748 from smediainfo/fix/missing-greenlet-purchase
fix: MissingGreenlet crash after subscription purchase in cabinet
2026-03-17 20:27:40 +03:00
Egor f84885cc8a Merge pull request #2747 from smediainfo/pr/fix-external-squad-sync
fix: защита внешних сквадов от удаления при синхронизации серверов
2026-03-17 20:26:58 +03:00
Egor 12898b7eab Merge pull request #2751 from SayonaraQ/fix/extend-period-nameerror
Fix/extend period nameerror
2026-03-17 20:25:51 +03:00
Codex Bot 20a6fa1bcf fix(subscription): remove stale extend promo state fields causing NameError 2026-03-17 17:55:14 +03:00
c0mrade 94199413c2 fix: миграция Tribute webhook с deprecated user_id на trb_user_id
- Убран fallback на deprecated поле user_id (удаляется 14 апреля 2026)
- Добавлен парсинг trb_user_id во всех ветках обработки webhook
- trb_user_id прокинут в результат и логи всех хендлеров
2026-03-17 12:43:04 +03:00
Fringg 826accba51 fix: MissingGreenlet при изменении количества устройств на CLASSIC подписках
lock_user_for_pricing не загружал User.subscription eagerly,
что вызывало lazy load в async контексте при обращении к db_user.subscription
в execute_change_devices.
2026-03-16 11:25:33 +03:00
sMedia.tech 1cc687ac15 fix: MissingGreenlet crash after subscription purchase in cabinet
`_subscription_to_response()` is a sync function that accesses
lazy-loaded relationship attributes (e.g. `subscription.tariff`).
When `send_subscription_purchase_notification()` is called before
building the response, `_record_subscription_event()` internally
calls `create_subscription_event()` which does `db.commit()`.
This expires all ORM objects in the session.

When the sync `_subscription_to_response()` then tries to access
`subscription.tariff`, SQLAlchemy cannot perform the lazy load
outside of an async greenlet context, raising:

  MissingGreenlet: greenlet_spawn has not been called;
  can't call await_only() here.

The fix adds `await db.refresh(subscription)` (and `user` where
accessed) after the admin notification block and before
`_subscription_to_response()` in three purchase endpoints:
- `submit_purchase` (classic mode)
- `purchase_tariff` (tariffs mode)
- `switch_tariff`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:16:53 +03:00
sMedia.tech e4bb0430fb refactor: deduplicate KassaAI handlers with config dict and shared helpers
Extract _KASSA_AI_METHOD_CONFIG dict, _check_topup_restriction() helper,
and generic _start_kassa_ai_sub_topup / _process_kassa_ai_sub_quick_amount
implementations. Public handlers become thin wrappers.

608 → 429 lines (-30%), eliminates 5 copies of restriction check block
and 3 pairs of nearly-identical start/quick-amount handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 08:53:34 +03:00
root 603b9a1f46 fix: sub-method enabled check, guest payment provider, silent FSM return 2026-03-16 04:40:36 +00:00
root 557af5994d style: ruff format kassa_ai files 2026-03-16 04:31:04 +00:00
root 808818ca2b style: ruff format server_squad.py 2026-03-16 04:30:32 +00:00
root b563796091 fix: protect external squads from deletion during server sync 2026-03-16 04:27:59 +00:00
sMedia.tech 6a3e9d92b5 style: ruff format kassa_ai_service.py 2026-03-16 04:12:41 +00:00
root cda2392411 refactor: move KASSA_AI_SUB_METHODS to service layer, add early enabled checks
- Move KASSA_AI_SUB_METHODS from handler to kassa_ai_service.py (fixes service→handler import violation)
- Remove KASSA_AI_PAYMENT_METHODS set (was defined but unused)
- Import KASSA_AI_SUB_METHODS in payment_service.py from service layer
- Add is_kassa_ai_sbp/card_enabled() checks at start of entry handler functions
2026-03-16 04:12:41 +00:00
root 04419fdff7 feat: add SBP and Card sub-options to kassa_ai payment method
- kassa_ai shows single admin entry with СБП/Карта sub-option checkboxes
- SBP routes to payment_system_id=44, Card to payment_system_id=36
- Bot: added kassa_ai_sbp/card handlers and FSM flow (mirrors freekassa pattern)
- Cabinet: KASSA_AI_OPTION_MAP reads payment_option to select correct ps_id
- Config: KASSA_AI_SBP_ENABLED / KASSA_AI_CARD_ENABLED env vars + helpers
- Guest payments: kassa_ai_sbp/card supported in landing page checkout
- payment_method_config_service: kassa_ai has available_sub_options=[sbp,card]
2026-03-16 04:12:41 +00:00
61 changed files with 3756 additions and 209 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.32.4"
".": "3.34.0"
}
+56
View File
@@ -1,5 +1,61 @@
# Changelog
## [3.34.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.33.0...v3.34.0) (2026-03-18)
### New Features
* добавлен SeverPay в админ-панель и настройки кабинета ([06a00e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06a00e367c303b5426008f38a20393ec4f2e07cd))
* добавлена интеграция SeverPay для пополнения баланса ([abaf279](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/abaf279533d31994a8a70346627c962b43000c64))
* поиск платежей в админ-панели с фильтрами и статистикой ([1804c28](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1804c28f0551fbe52883fc36fc4cfcd60d2d6bd6))
### Bug Fixes
* remove contains_eager conflicting with selectinload on user relationship ([fddf8ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fddf8ef5ebc8e5176a75a10e92d59165bdabf2e1))
* добавлен импорт MAX_ALL_TIME_DAYS в admin_payments routes ([ad26832](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad268329be45bdd665ce4a6f142e9762a78e55b5))
* добавлены RioPay и SeverPay в REAL_PAYMENT_METHODS ([f967c29](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f967c29bd7cfe8a66e7e0f492573bb7521fcda22))
## [3.33.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.4...v3.33.0) (2026-03-17)
### New Features
* add SBP and Card sub-options for KassaAI payment method ([5b722c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b722c521036befcbbaf6192215f651c2ec9c4fb))
* add SBP and Card sub-options to kassa_ai payment method ([04419fd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04419fdff7dc244eb2c9553ea1501e2de454010b))
* deep link авторизация в кабинете при блокировке oauth.telegram.org ([322d457](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/322d45765220c854e36ae0b4b862a96d36ae3be8))
* добавлена поддержка RioPay в кабинете ([3d1fbc7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d1fbc70f8add81d4a3561d63dcd46f385bcc6f1))
* добавлена поддержка RioPay для лендингов и подарков ([04f4e6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04f4e6bf6e9031ef8512a07ab36355111c524319))
### Bug Fixes
* add back button to payment amount validation errors ([20eff61](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20eff6170fb752051022a14fa9d927d59ff1d602))
* add sync_squads=True to admin tariff change handler ([3f0b24c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3f0b24c1ec82ebfb6fdbc801ed6b493ea09c9a2f))
* deep link auth security and reliability fixes ([099391e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/099391eb5f24319703f12ae2cb72d93b26164d99))
* enforce promo group authorization on country/server selection ([641da94](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/641da949a907ade7b870e1a5385fdb71f9e524af))
* merge phantom users into active accounts on /start ([77f1a76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/77f1a764d59d68236899ec59c884907d3666d3dd))
* MissingGreenlet crash after subscription purchase in cabinet ([a80a85c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a80a85c2a489f397efb4ffb5bd77655420a49adb))
* MissingGreenlet crash after subscription purchase in cabinet ([1cc687a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cc687ac15ecdf12928e5ce448514c09b6628f65))
* MissingGreenlet при изменении количества устройств на CLASSIC подписках ([826accb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/826accba519f23a687fcc3d387f727fd31d2a88c))
* protect external squads from deletion during server sync ([b563796](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b563796091e83edcc8dfbd6222648b5346f39a9c))
* review findings — db.commit, isinstance guard, constants, ACTIVE check ([72b5305](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/72b5305b870ae9ecdb2672549b9c153dd8b3f7bc))
* sub-method enabled check, guest payment provider, silent FSM return ([603b9a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/603b9a1f4610a5b288d6fba78c98474c439529aa))
* **subscription:** remove stale extend promo state fields causing NameError ([20a6fa1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20a6fa1bcf362455623f43838f4ffaaf98b33e76))
* swap Caddy auth headers — api_key to Authorization, caddy_token to X-Api-Key ([038c34e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/038c34e52a863d0c5c6993ea785587ba7e0bc61d))
* sync squads to Remnawave panel on tariff purchase/switch ([c34fdd1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c34fdd10a0a22a85a4e29cbf44da2ac5d4a643b3))
* защита внешних сквадов от удаления при синхронизации серверов ([f84885c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f84885cc8aa0c9a70f916076e987284f1f9c3479))
* исправлен расчёт конверсии в статистике продаж ([3089c17](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3089c1704b54323b4c2a151d40a5b395a427c658))
* исправлены проблемы RioPay интеграции после ревью ([4abb8cb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4abb8cb1a3f21089697199261bcc37e6f6a5c623))
* миграция Tribute webhook с deprecated user_id на trb_user_id ([9419941](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94199413c283167edd829b337cf9bec0a5414a54))
* скрыть плашку верификации email при выключенной верификации ([4966e39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4966e39eb9b92967ef92c92ae434ca6cdea80c84))
### Refactoring
* deduplicate KassaAI handlers with config dict and shared helpers ([e4bb043](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4bb0430fb9cc4f55013767ceda6d1c214bd80a6))
* move KASSA_AI_SUB_METHODS to service layer, add early enabled checks ([cda2392](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cda239241122ae1dd02a252b3eadc47453c0c48b))
## [3.32.4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.3...v3.32.4) (2026-03-16)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.32.4" # x-release-please-version
ARG VERSION="v3.34.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+157 -3
View File
@@ -1,7 +1,7 @@
"""Admin routes for payment verification in cabinet."""
import math
from datetime import datetime
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Bot
@@ -13,6 +13,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, User
from app.services.payment_search_service import (
MAX_ALL_TIME_DAYS,
PeriodPreset,
SearchParams,
StatusFilter,
search_payments,
search_payments_stats,
)
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
SUPPORTED_MANUAL_CHECK_METHODS,
@@ -87,6 +95,16 @@ class PaymentsStatsResponse(BaseModel):
by_method: dict
class SearchStatsResponse(BaseModel):
"""Statistics for payment search results."""
total: int
pending: int
paid: int
cancelled: int
by_method: dict
# ============ Helper functions ============
@@ -241,6 +259,8 @@ def _get_payment_url(record: PendingPayment) -> str | None:
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
payment_url = getattr(payment, 'payment_url', None) or payment_url
if payment_url and not payment_url.startswith(('https://', 'http://')):
return None
return payment_url
@@ -329,6 +349,140 @@ async def get_payments_stats(
)
@router.get('/search', response_model=PendingPaymentListResponse)
async def search_payments_endpoint(
search: str | None = Query(
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
),
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
method_filter: str | None = Query(None, description='Filter by payment method'),
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Search payments across all providers with filters."""
try:
parsed_status = StatusFilter(status_filter)
except ValueError:
parsed_status = StatusFilter.ALL
try:
parsed_period = PeriodPreset(period)
except ValueError:
parsed_period = PeriodPreset.H24
parsed_method: PaymentMethod | None = None
if method_filter:
try:
parsed_method = PaymentMethod(method_filter)
except ValueError:
pass
# Ensure custom dates are timezone-aware
if date_from is not None and date_from.tzinfo is None:
date_from = date_from.replace(tzinfo=UTC)
if date_to is not None and date_to.tzinfo is None:
date_to = date_to.replace(tzinfo=UTC)
# Clamp custom dates to safety limit
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
if date_from is not None and date_from < min_allowed:
date_from = min_allowed
if date_from is not None and date_to is not None and date_from > date_to:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
params = SearchParams(
search=search.strip() if search else None,
status_filter=parsed_status,
method_filter=parsed_method,
period=parsed_period,
date_from=date_from,
date_to=date_to,
page=page,
per_page=per_page,
)
page_items, total = await search_payments(db, params)
pages = math.ceil(total / per_page) if total > 0 else 1
items = [_record_to_response(p) for p in page_items]
return PendingPaymentListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get('/search/stats', response_model=SearchStatsResponse)
async def search_payments_stats_endpoint(
search: str | None = Query(
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
),
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
method_filter: str | None = Query(None, description='Filter by payment method'),
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
admin: User = Depends(require_permission('payments:read')),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get aggregated statistics for payment search results."""
try:
parsed_status = StatusFilter(status_filter)
except ValueError:
parsed_status = StatusFilter.ALL
try:
parsed_period = PeriodPreset(period)
except ValueError:
parsed_period = PeriodPreset.H24
parsed_method: PaymentMethod | None = None
if method_filter:
try:
parsed_method = PaymentMethod(method_filter)
except ValueError:
pass
# Ensure custom dates are timezone-aware
if date_from is not None and date_from.tzinfo is None:
date_from = date_from.replace(tzinfo=UTC)
if date_to is not None and date_to.tzinfo is None:
date_to = date_to.replace(tzinfo=UTC)
# Clamp custom dates to safety limit
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
if date_from is not None and date_from < min_allowed:
date_from = min_allowed
if date_from is not None and date_to is not None and date_from > date_to:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
params = SearchParams(
search=search.strip() if search else None,
status_filter=parsed_status,
method_filter=parsed_method,
period=parsed_period,
date_from=date_from,
date_to=date_to,
)
stats = await search_payments_stats(db, params)
return SearchStatsResponse(
total=stats.total,
pending=stats.pending,
paid=stats.paid,
cancelled=stats.cancelled,
by_method=stats.by_method or {},
)
@router.get('/{method}/{payment_id}', response_model=PendingPaymentResponse)
async def get_pending_payment_details(
method: str,
@@ -342,7 +496,7 @@ async def get_pending_payment_details(
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid payment method: {method}',
detail='Invalid payment method',
)
record = await get_payment_record(db, payment_method, payment_id)
@@ -369,7 +523,7 @@ async def check_payment_status(
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid payment method: {method}',
detail='Invalid payment method',
)
# Get current record
+44 -6
View File
@@ -170,6 +170,7 @@ async def get_sales_summary(
new_trials = row.new_trials or 0
# Trial-to-paid conversion in period
# Method 1: SubscriptionConversion records (only created by some purchase flows)
conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id)).where(
and_(
@@ -178,9 +179,29 @@ async def get_sales_summary(
)
)
)
conversions = conversions_result.scalar() or 0
# Cap at 100%: conversions from previous periods can exceed current new_trials
conversion_rate = min(round((conversions / new_trials * 100), 1), 100.0) if new_trials > 0 else 0.0
conversion_records = conversions_result.scalar() or 0
# Method 2: Users registered in period who have paid (catches all purchase flows)
converted_users_result = await db.execute(
select(func.count(User.id)).where(
and_(
User.created_at >= period_start,
User.created_at <= period_end,
User.has_had_paid_subscription.is_(True),
)
)
)
converted_users = converted_users_result.scalar() or 0
# Use the higher count to catch conversions from all purchase flows
conversions = max(conversion_records, converted_users)
# new_trials only counts REMAINING trials (is_trial=True), but converted users
# had is_trial flipped to False. Add conversions back to get total trial starters.
total_trial_starters = new_trials + conversions
conversion_rate = (
min(round((conversions / total_trial_starters * 100), 1), 100.0) if total_trial_starters > 0 else 0.0
)
# Renewals count
renewals_subquery = (
@@ -290,6 +311,7 @@ async def get_trials_stats(
)
total_trials = total_result.scalar() or 0
# Conversion: SubscriptionConversion records + fallback to has_had_paid_subscription
conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id)).where(
and_(
@@ -298,9 +320,25 @@ async def get_trials_stats(
)
)
)
conversions = conversions_result.scalar() or 0
# Cap at 100%: conversions from previous periods can exceed current period trials
conversion_rate = min(round((conversions / total_trials * 100), 1), 100.0) if total_trials > 0 else 0.0
conversion_records = conversions_result.scalar() or 0
converted_users_result = await db.execute(
select(func.count(User.id)).where(
and_(
User.created_at >= period_start,
User.created_at <= period_end,
User.has_had_paid_subscription.is_(True),
)
)
)
converted_users = converted_users_result.scalar() or 0
conversions = max(conversion_records, converted_users)
# total_trials only counts remaining is_trial=True; add conversions for total starters
total_trial_starters = total_trials + conversions
conversion_rate = (
min(round((conversions / total_trial_starters * 100), 1), 100.0) if total_trial_starters > 0 else 0.0
)
avg_duration_result = await db.execute(
select(func.avg(SubscriptionConversion.trial_duration_days)).where(
+133 -6
View File
@@ -28,10 +28,16 @@ from app.database.crud.user import (
set_email_change_pending,
verify_and_apply_email_change,
)
from app.database.models import CabinetRefreshToken, User
from app.database.models import CabinetRefreshToken, User, UserStatus
from app.services.campaign_service import AdvertisingCampaignService
from app.services.disposable_email_service import disposable_email_service
from app.services.referral_service import process_referral_registration
from app.services.web_auth_service import (
WEB_AUTH_TOKEN_TTL,
consume_web_auth_token,
create_web_auth_token,
poll_web_auth_token,
)
from app.utils.cache import RateLimitCache, TokenReplayCache
from app.utils.timezone import panel_datetime_to_utc
@@ -61,6 +67,8 @@ from ..schemas.auth import (
AuthResponse,
AutoLoginRequest,
CampaignBonusInfo,
DeepLinkPollRequest,
DeepLinkTokenResponse,
EmailChangeRequest,
EmailChangeResponse,
EmailChangeVerifyRequest,
@@ -461,7 +469,7 @@ async def auth_telegram(
if updated:
logger.info('User profile updated from initData', user_id=user.id)
if user.status != 'active':
if user.status != UserStatus.ACTIVE.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User account is not active',
@@ -544,7 +552,7 @@ async def auth_telegram_widget(
)
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
if user.status != 'active':
if user.status != UserStatus.ACTIVE.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User account is not active',
@@ -673,7 +681,7 @@ async def auth_telegram_oidc(
)
logger.info('User created successfully', user_id=user.id, telegram_id=user.telegram_id)
if user.status != 'active':
if user.status != UserStatus.ACTIVE.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User account is not active',
@@ -1143,7 +1151,7 @@ async def login_email(
detail='Please verify your email first',
)
if user.status != 'active':
if user.status != UserStatus.ACTIVE.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User account is not active',
@@ -1292,7 +1300,7 @@ async def auto_login(
detail='User not found',
)
if user.status != 'active':
if user.status != UserStatus.ACTIVE.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is deactivated',
@@ -1674,3 +1682,122 @@ async def get_email_change_status(
'new_email': user.email_change_new,
'expires_at': user.email_change_expires.isoformat() if user.email_change_expires else None,
}
# --- Deep link auth (fallback when oauth.telegram.org is blocked) ---
@router.post('/deeplink/request', response_model=DeepLinkTokenResponse)
async def request_deep_link_token(
raw_request: Request,
):
"""Generate a one-time deep link auth token.
Frontend shows t.me/{bot}?start=webauth_{token} to the user.
No auth required (user is not logged in yet).
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'deeplink_request', limit=10, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
try:
token = await create_web_auth_token()
except RuntimeError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Service temporarily unavailable',
)
bot_username = settings.get_bot_username()
if not bot_username:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Bot not configured',
)
return DeepLinkTokenResponse(
token=token,
bot_username=bot_username,
expires_in=WEB_AUTH_TOKEN_TTL,
)
@router.post('/deeplink/poll', response_model=AuthResponse)
async def poll_deep_link_token(
request: DeepLinkPollRequest,
raw_request: Request,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Poll for deep link auth completion.
Returns 202 if still pending, AuthResponse if completed, 410 if expired.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'deeplink_poll', limit=60, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many requests',
headers={'Retry-After': '60'},
)
data = await poll_web_auth_token(request.token)
if data is None:
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail='Token expired or not found',
)
if data.get('status') == 'pending':
raise HTTPException(
status_code=status.HTTP_202_ACCEPTED,
detail='Waiting for confirmation',
)
if data.get('status') != 'linked':
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail='Invalid token state',
)
# Token is linked - consume it atomically
consumed = await consume_web_auth_token(request.token)
if not consumed:
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail='Token already consumed',
)
user_id = consumed.get('user_id')
if not user_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Invalid token data',
)
user = await get_user_by_id(db, int(user_id))
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not found',
)
if user.status != UserStatus.ACTIVE.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is deactivated',
)
user.cabinet_last_login = datetime.now(UTC)
await db.commit()
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token, device_info='deep_link')
logger.info('Deep link auth successful', user_id=user.id, telegram_id=user.telegram_id)
return response
+54 -1
View File
@@ -701,6 +701,11 @@ async def create_topup(
detail='KassaAI payment method is unavailable',
)
# Use payment_option to select sbp or card
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36}
option = (request.payment_option or '').strip().lower()
ps_id = KASSA_AI_OPTION_MAP.get(option) # None = use env default
payment_service = PaymentService()
result = await payment_service.create_kassa_ai_payment(
db=db,
@@ -709,6 +714,7 @@ async def create_topup(
description=settings.get_balance_payment_description(request.amount_kopeks),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_system_id=ps_id,
)
if result and result.get('payment_url'):
@@ -720,6 +726,33 @@ async def create_topup(
detail='Failed to create KassaAI payment',
)
elif request.payment_method == 'riopay':
if not settings.is_riopay_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='RioPay payment method is unavailable',
)
payment_service = PaymentService()
result = await payment_service.create_riopay_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
success_url=cabinet_success_url,
fail_url=cabinet_failed_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('riopay_order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create RioPay payment',
)
elif request.payment_method == 'tribute':
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
raise HTTPException(
@@ -871,6 +904,17 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.RIOPAY:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'success': ('', 'Оплачено'),
'failed': ('', 'Ошибка'),
'canceled': ('', 'Отменено'),
'expired': ('', 'Истёк'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
return '', 'Неизвестно'
@@ -901,6 +945,8 @@ def _is_checkable(record: PendingPayment) -> bool:
return status in {'pending', 'created', 'processing'}
if record.method == PaymentMethod.KASSA_AI:
return status in {'pending', 'created', 'processing'}
if record.method == PaymentMethod.RIOPAY:
return status in {'pending'}
return False
@@ -924,7 +970,12 @@ def _get_payment_url(record: PendingPayment) -> str | None:
)
elif record.method == PaymentMethod.PLATEGA:
payment_url = getattr(payment, 'redirect_url', None) or payment_url
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
elif record.method in (
PaymentMethod.CLOUDPAYMENTS,
PaymentMethod.FREEKASSA,
PaymentMethod.KASSA_AI,
PaymentMethod.RIOPAY,
):
payment_url = getattr(payment, 'payment_url', None) or payment_url
return payment_url
@@ -1013,6 +1064,7 @@ async def get_latest_payment_by_method(
MulenPayPayment,
Pal24Payment,
PlategaPayment,
RioPayPayment,
WataPayment,
YooKassaPayment,
)
@@ -1028,6 +1080,7 @@ async def get_latest_payment_by_method(
PaymentMethod.CLOUDPAYMENTS: CloudPaymentsPayment,
PaymentMethod.FREEKASSA: FreekassaPayment,
PaymentMethod.KASSA_AI: KassaAiPayment,
PaymentMethod.RIOPAY: RioPayPayment,
}
model = model_map.get(payment_method)
+13 -3
View File
@@ -244,6 +244,7 @@ class EmailAuthEnabledResponse(BaseModel):
"""Email auth enabled setting."""
enabled: bool = True
verification_enabled: bool = True
class EmailAuthEnabledUpdate(BaseModel):
@@ -838,10 +839,16 @@ async def get_email_auth_enabled(
if email_auth_value is not None:
enabled = email_auth_value.lower() == 'true'
return EmailAuthEnabledResponse(enabled=enabled)
return EmailAuthEnabledResponse(
enabled=enabled,
verification_enabled=settings.is_cabinet_email_verification_enabled(),
)
# Default: check config setting
return EmailAuthEnabledResponse(enabled=settings.is_cabinet_email_auth_enabled())
return EmailAuthEnabledResponse(
enabled=settings.is_cabinet_email_auth_enabled(),
verification_enabled=settings.is_cabinet_email_verification_enabled(),
)
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
@@ -855,7 +862,10 @@ async def update_email_auth_enabled(
logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return EmailAuthEnabledResponse(enabled=payload.enabled)
return EmailAuthEnabledResponse(
enabled=payload.enabled,
verification_enabled=settings.is_cabinet_email_verification_enabled(),
)
# ============ Telegram Widget Config Routes ============
+12 -2
View File
@@ -1787,6 +1787,9 @@ async def submit_purchase(
except Exception as e:
logger.error('Failed to send admin notification for subscription purchase', error=e)
# Refresh expired objects after db.commit() in _record_subscription_event
await db.refresh(subscription)
return {
'success': True,
'message': result['message'],
@@ -2068,6 +2071,7 @@ async def purchase_tariff(
subscription,
reset_traffic=True,
reset_reason='покупка тарифа (cabinet)',
sync_squads=True,
)
else:
await service.create_remnawave_user(
@@ -2096,6 +2100,7 @@ async def purchase_tariff(
logger.error('Error saving tariff cart (cabinet)', error=e)
await db.refresh(user)
await db.refresh(subscription)
response = {
'success': True,
@@ -3043,7 +3048,7 @@ async def update_countries(
# Validate selected countries
for country_uuid in selected_countries:
if country_uuid not in allowed_country_ids and country_uuid not in current_countries:
if country_uuid not in allowed_country_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Country {country_uuid} is not available',
@@ -3140,7 +3145,7 @@ async def update_countries(
try:
subscription_service = SubscriptionService()
if getattr(user, 'remnawave_uuid', None):
await subscription_service.update_remnawave_user(db, user.subscription)
await subscription_service.update_remnawave_user(db, user.subscription, sync_squads=True)
else:
await subscription_service.create_remnawave_user(db, user.subscription)
except Exception as e:
@@ -4170,6 +4175,7 @@ async def switch_tariff(
subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа',
sync_squads=True,
)
else:
await subscription_service.create_remnawave_user(
@@ -4221,6 +4227,10 @@ async def switch_tariff(
except Exception as e:
logger.error('Failed to send admin notification for tariff switch', error=e)
# Refresh expired objects after db.commit() in _record_subscription_event
await db.refresh(subscription)
await db.refresh(user)
response = {
'success': True,
'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'"
+14
View File
@@ -187,3 +187,17 @@ class EmailChangeResponse(BaseModel):
message: str = Field(..., description='Success message')
new_email: str = Field(..., description='New email address pending verification')
expires_in_minutes: int = Field(..., description='Code expiration time in minutes')
class DeepLinkTokenResponse(BaseModel):
"""Response with deep link auth token."""
token: str = Field(..., description='One-time auth token')
bot_username: str = Field(..., description='Bot username for deep link')
expires_in: int = Field(..., description='Token TTL in seconds')
class DeepLinkPollRequest(BaseModel):
"""Request to poll deep link auth status."""
token: str = Field(..., min_length=16, max_length=128, description='Deep link auth token')
+47
View File
@@ -535,6 +535,11 @@ class Settings(BaseSettings):
KASSA_AI_WEBHOOK_PORT: int = 8089
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
# Раздельные методы оплаты KassaAI (отображаются как отдельные кнопки)
KASSA_AI_SBP_ENABLED: bool = False # СБП — payment_system_id=44
KASSA_AI_SBP_DISPLAY_NAME: str = 'СБП (KassaAI)'
KASSA_AI_CARD_ENABLED: bool = False # Карты РФ — payment_system_id=36
KASSA_AI_CARD_DISPLAY_NAME: str = 'Карта (KassaAI)'
# RioPay (api.riopay.online) v2.0.1
RIOPAY_ENABLED: bool = False
@@ -548,6 +553,18 @@ class Settings(BaseSettings):
RIOPAY_SUCCESS_URL: str | None = None
RIOPAY_FAIL_URL: str | None = None
# SeverPay (severpay.io)
SEVERPAY_ENABLED: bool = False
SEVERPAY_MID: int | None = None # Merchant ID
SEVERPAY_TOKEN: str | None = None # Secret token for HMAC-SHA256
SEVERPAY_DISPLAY_NAME: str = 'SeverPay'
SEVERPAY_CURRENCY: str = 'RUB'
SEVERPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
SEVERPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
SEVERPAY_WEBHOOK_PATH: str = '/severpay-webhook'
SEVERPAY_RETURN_URL: str | None = None
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
CABINET_BUTTON_STYLE: str = ''
@@ -1850,6 +1867,36 @@ class Settings(BaseSettings):
def get_riopay_display_name_html(self) -> str:
return html.escape(self.get_riopay_display_name())
def is_severpay_enabled(self) -> bool:
return self.SEVERPAY_ENABLED and self.SEVERPAY_MID is not None and self.SEVERPAY_TOKEN is not None
def get_severpay_display_name(self) -> str:
name = (self.SEVERPAY_DISPLAY_NAME or '').strip()
return name if name else 'SeverPay'
def get_severpay_display_name_html(self) -> str:
return html.escape(self.get_severpay_display_name())
def is_kassa_ai_sbp_enabled(self) -> bool:
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
def get_kassa_ai_sbp_display_name(self) -> str:
name = (self.KASSA_AI_SBP_DISPLAY_NAME or '').strip()
return name if name else 'СБП (KassaAI)'
def get_kassa_ai_sbp_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_sbp_display_name())
def is_kassa_ai_card_enabled(self) -> bool:
return self.KASSA_AI_CARD_ENABLED and self.is_kassa_ai_enabled()
def get_kassa_ai_card_display_name(self) -> str:
name = (self.KASSA_AI_CARD_DISPLAY_NAME or '').strip()
return name if name else 'Карта (KassaAI)'
def get_kassa_ai_card_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_card_display_name())
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
+1 -1
View File
@@ -15,7 +15,7 @@ logger = structlog.get_logger(__name__)
async def create_riopay_payment(
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
+11 -1
View File
@@ -317,7 +317,17 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
)
created += 1
removed_servers = [server for uuid, server in existing_servers.items() if uuid not in remnawave_uuids]
# Protect external squads referenced by tariffs from being removed during sync
tariff_ext_uuids_result = await db.execute(
select(Tariff.external_squad_uuid).where(Tariff.external_squad_uuid.isnot(None))
)
protected_uuids = {row[0] for row in tariff_ext_uuids_result.fetchall()}
removed_servers = [
server
for uuid, server in existing_servers.items()
if uuid not in remnawave_uuids and uuid not in protected_uuids
]
if removed_servers:
removed_ids = [server.id for server in removed_servers]
+157
View File
@@ -0,0 +1,157 @@
"""CRUD операции для платежей SeverPay."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SeverPayPayment
logger = structlog.get_logger(__name__)
async def create_severpay_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
severpay_id: str | None = None,
severpay_uid: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> SeverPayPayment:
"""Создает запись о платеже SeverPay."""
payment = SeverPayPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
severpay_id=severpay_id,
severpay_uid=severpay_uid,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж SeverPay', order_id=order_id, user_id=user_id)
return payment
async def get_severpay_payment_by_order_id(db: AsyncSession, order_id: str) -> SeverPayPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_severpay_payment_by_severpay_id(db: AsyncSession, severpay_id: str) -> SeverPayPayment | None:
"""Получает платеж по ID от SeverPay."""
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.severpay_id == severpay_id))
return result.scalar_one_or_none()
async def get_severpay_payment_by_id(db: AsyncSession, payment_id: int) -> SeverPayPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_severpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> SeverPayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.id == payment_id).with_for_update())
return result.scalar_one_or_none()
async def update_severpay_payment_status(
db: AsyncSession,
payment: SeverPayPayment,
*,
status: str,
is_paid: bool | None = None,
severpay_id: str | None = None,
severpay_uid: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> SeverPayPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if severpay_id is not None:
payment.severpay_id = severpay_id
if severpay_uid is not None:
payment.severpay_uid = severpay_uid
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен статус платежа SeverPay',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_severpay_payments(db: AsyncSession, user_id: int) -> list[SeverPayPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(SeverPayPayment).where(
SeverPayPayment.user_id == user_id,
SeverPayPayment.status == 'pending',
SeverPayPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_severpay_payments(
db: AsyncSession,
) -> list[SeverPayPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(SeverPayPayment).where(
SeverPayPayment.status == 'pending',
SeverPayPayment.is_paid == False,
SeverPayPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_severpay_payment_to_transaction(
db: AsyncSession,
*,
payment: SeverPayPayment,
transaction_id: int,
) -> SeverPayPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+4 -3
View File
@@ -61,16 +61,17 @@ async def get_conversion_statistics(db: AsyncSession) -> dict:
total_conversions = total_conversions_result.scalar() or 0
# Подсчитываем пользователей с платными подписками
users_with_paid_result = await db.execute(select(func.count(User.id)).where(User.has_had_paid_subscription == True))
users_with_paid_result = await db.execute(
select(func.count(User.id)).where(User.has_had_paid_subscription.is_(True))
)
users_with_paid = users_with_paid_result.scalar() or 0
# Подсчитываем всех пользователей с подписками (использовавших триал)
# Считаем что все новые пользователи начинают с триала
total_users_with_subscriptions_result = await db.execute(select(func.count(func.distinct(Subscription.user_id))))
total_users_with_subscriptions = total_users_with_subscriptions_result.scalar() or 0
# Расчёт конверсии: (оплатившие) / (всего с подписками) * 100
# Это показывает какой % пользователей, получивших подписку, в итоге оплатили
# Знаменатель = все юзеры с подписками (включая уже конвертированных)
if total_users_with_subscriptions > 0:
conversion_rate = round((users_with_paid / total_users_with_subscriptions) * 100, 1)
else:
+2
View File
@@ -25,6 +25,8 @@ REAL_PAYMENT_METHODS = [
PaymentMethod.CLOUDPAYMENTS.value,
PaymentMethod.FREEKASSA.value,
PaymentMethod.KASSA_AI.value,
PaymentMethod.RIOPAY.value,
PaymentMethod.SEVERPAY.value,
]
+1
View File
@@ -541,6 +541,7 @@ async def lock_user_for_pricing(db: AsyncSession, user_id: int) -> User:
.options(
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group),
selectinload(User.subscription).selectinload(Subscription.tariff),
)
.with_for_update()
.execution_options(populate_existing=True)
+65 -1
View File
@@ -159,6 +159,7 @@ class PaymentMethod(Enum):
FREEKASSA = 'freekassa'
KASSA_AI = 'kassa_ai'
RIOPAY = 'riopay'
SEVERPAY = 'severpay'
MANUAL = 'manual'
BALANCE = 'balance'
@@ -756,7 +757,7 @@ class RioPayPayment(Base):
__tablename__ = 'riopay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
@@ -812,6 +813,69 @@ class RioPayPayment(Base):
return f'<RioPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class SeverPayPayment(Base):
"""Платежи через SeverPay (severpay.io)."""
__tablename__ = 'severpay_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
severpay_id = Column(String(64), unique=True, nullable=True, index=True) # ID от SeverPay
severpay_uid = Column(String(64), unique=True, nullable=True, index=True) # UID от SeverPay
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_method = Column(String(32), nullable=True)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='severpay_payments')
transaction = relationship('Transaction', backref='severpay_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'declined', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<SeverPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class PromoGroup(Base):
__tablename__ = 'promo_groups'
+6 -12
View File
@@ -280,12 +280,6 @@ class RemnaWaveAPI:
'X-Real-IP': '127.0.0.1',
}
# Caddy авторизация — добавляется поверх основной
if self.caddy_token:
# Caddy Security: готовый base64 токен используется как есть
headers['Authorization'] = f'Basic {self.caddy_token}'
logger.debug('Используем Caddy Basic Auth')
# Основная авторизация RemnaWave API
if self.auth_type == 'basic' and self.username and self.password:
credentials = f'{self.username}:{self.password}'
@@ -293,16 +287,16 @@ class RemnaWaveAPI:
headers['X-Api-Key'] = f'Basic {encoded_credentials}'
logger.debug('Используем Basic Auth в X-Api-Key заголовке')
elif self.auth_type == 'caddy':
# Для caddy auth_type основная авторизация уже в Authorization header
# Но API ключ всё равно нужен для RemnaWave
# Caddy Security: caddy_token → X-Api-Key, api_key → Authorization: Bearer
if self.api_key:
headers['X-Api-Key'] = self.api_key
logger.debug('Используем API ключ для RemnaWave + Caddy авторизацию')
headers['Authorization'] = f'Bearer {self.api_key}'
if self.caddy_token:
headers['X-Api-Key'] = self.caddy_token
logger.debug('Используем Caddy авторизацию')
else:
# api_key или bearer — стандартный режим
headers['X-Api-Key'] = self.api_key
if not self.caddy_token:
headers['Authorization'] = f'Bearer {self.api_key}'
headers['Authorization'] = f'Bearer {self.api_key}'
logger.debug('Используем API ключ в X-Api-Key заголовке')
return headers
+13 -4
View File
@@ -72,18 +72,21 @@ class TributeService:
status = None
amount_kopeks = 0
telegram_user_id = None
trb_user_id = None
payment_id = webhook_data.get('id') or webhook_data.get('payment_id')
status = webhook_data.get('status')
amount_kopeks = webhook_data.get('amount', 0)
telegram_user_id = webhook_data.get('telegram_user_id') or webhook_data.get('user_id')
telegram_user_id = webhook_data.get('telegram_user_id')
trb_user_id = webhook_data.get('trb_user_id')
if not payment_id and 'payload' in webhook_data:
data = webhook_data['payload']
payment_id = data.get('id') or data.get('payment_id')
status = data.get('status')
amount_kopeks = data.get('amount', 0)
telegram_user_id = data.get('telegram_user_id') or data.get('user_id')
telegram_user_id = data.get('telegram_user_id')
trb_user_id = data.get('trb_user_id')
if not payment_id and 'name' in webhook_data:
event_name = webhook_data.get('name')
@@ -91,6 +94,7 @@ class TributeService:
payment_id = str(data.get('donation_request_id'))
amount_kopeks = data.get('amount', 0)
telegram_user_id = data.get('telegram_user_id')
trb_user_id = data.get('trb_user_id')
if event_name in ('new_donation', 'recurrent_donation'):
status = 'paid'
@@ -100,15 +104,19 @@ class TributeService:
status = 'unknown'
logger.info(
'📝 Извлеченные данные: payment_id=, status=, amount_kopeks=, user_id',
'📝 Извлеченные данные: payment_id=, status=, amount_kopeks=, telegram_user_id=, trb_user_id=',
payment_id=payment_id,
status=status,
amount_kopeks=amount_kopeks,
telegram_user_id=telegram_user_id,
trb_user_id=trb_user_id,
)
if not telegram_user_id:
logger.error('❌ Не найден telegram_user_id в webhook данных')
logger.error(
'❌ Не найден telegram_user_id в webhook данных',
trb_user_id=trb_user_id,
)
logger.error(
'🔍 Полные данные для отладки', dumps=json.dumps(webhook_data, ensure_ascii=False, indent=2)
)
@@ -124,6 +132,7 @@ class TributeService:
'event_type': 'payment',
'payment_id': payment_id or f'tribute_{telegram_user_id}_{amount_kopeks}',
'user_id': telegram_user_id,
'trb_user_id': trb_user_id,
'amount_kopeks': int(amount_kopeks) if amount_kopeks else 0,
'status': status or 'paid',
'external_id': f'donation_{payment_id or "unknown"}',
+5 -1
View File
@@ -63,7 +63,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
},
'payments': {
'title': '💳 Платежные системы',
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay и Telegram Stars.',
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay, SeverPay и Telegram Stars.',
'icon': '💳',
'categories': (
'PAYMENT',
@@ -75,6 +75,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
'FREEKASSA',
'KASSA_AI',
'RIOPAY',
'SEVERPAY',
'MULENPAY',
'PAL24',
'WATA',
@@ -1256,6 +1257,9 @@ def _build_settings_keyboard(
elif category_key == 'RIOPAY':
label = texts.t('PAYMENT_RIOPAY', f'💳 {settings.get_riopay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'riopay')])
elif category_key == 'SEVERPAY':
label = texts.t('PAYMENT_SEVERPAY', f'💳 {settings.get_severpay_display_name()}')
test_payment_buttons.append([_test_button(f'{label} · тест', 'severpay')])
if test_payment_buttons:
rows.extend(test_payment_buttons)
+1
View File
@@ -5417,6 +5417,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='смена тарифа (админ)',
sync_squads=True,
)
logger.info(
+4
View File
@@ -167,6 +167,7 @@ async def process_cloudpayments_payment_amount(
'AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount:.0f}',
).format(min_amount=min_rub),
reply_markup=get_back_keyboard(db_user.language),
)
return
@@ -177,6 +178,7 @@ async def process_cloudpayments_payment_amount(
'AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount:,.0f}',
).format(max_amount=max_rub),
reply_markup=get_back_keyboard(db_user.language),
)
return
@@ -290,6 +292,7 @@ async def process_cloudpayments_amount(
'AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount:.0f}',
).format(min_amount=min_rub),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
@@ -301,6 +304,7 @@ async def process_cloudpayments_amount(
'AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount:,.0f}',
).format(max_amount=max_rub),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
+10 -4
View File
@@ -133,11 +133,13 @@ async def process_cryptobot_payment_amount(
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer('Минимальная сумма пополнения: 100 ₽')
await message.answer('Минимальная сумма пополнения: 100 ₽', reply_markup=get_back_keyboard(db_user.language))
return
if amount_rubles > 100000:
await message.answer('Максимальная сумма пополнения: 100,000 ₽')
await message.answer(
'Максимальная сумма пополнения: 100,000 ₽', reply_markup=get_back_keyboard(db_user.language)
)
return
try:
@@ -154,11 +156,15 @@ async def process_cryptobot_payment_amount(
amount_usd = round(amount_usd, 2)
if amount_usd < 1:
await message.answer('❌ Минимальная сумма для оплаты в USD: 1.00 USD')
await message.answer(
'❌ Минимальная сумма для оплаты в USD: 1.00 USD', reply_markup=get_back_keyboard(db_user.language)
)
return
if amount_usd > 1000:
await message.answer('❌ Максимальная сумма для оплаты в USD: 1,000 USD')
await message.answer(
'❌ Максимальная сумма для оплаты в USD: 1,000 USD', reply_markup=get_back_keyboard(db_user.language)
)
return
payment_service = PaymentService(message.bot)
+2
View File
@@ -186,6 +186,7 @@ async def process_freekassa_payment_amount(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
@@ -196,6 +197,7 @@ async def process_freekassa_payment_amount(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
+4 -2
View File
@@ -129,11 +129,13 @@ async def process_heleket_payment_amount(
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer('Минимальная сумма пополнения: 100 ₽')
await message.answer('Минимальная сумма пополнения: 100 ₽', reply_markup=get_back_keyboard(db_user.language))
return
if amount_rubles > 100000:
await message.answer('Максимальная сумма пополнения: 100,000 ₽')
await message.answer(
'Максимальная сумма пополнения: 100,000 ₽', reply_markup=get_back_keyboard(db_user.language)
)
return
payment_service = PaymentService(message.bot)
+172 -107
View File
@@ -10,6 +10,7 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.kassa_ai_service import KASSA_AI_SUB_METHODS
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
@@ -18,23 +19,55 @@ from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
# --- Enabled check + display name lookup by payment method ---
_KASSA_AI_METHOD_CONFIG = {
'kassa_ai': {
'is_enabled': settings.is_kassa_ai_enabled,
'display_name': settings.get_kassa_ai_display_name,
'unavailable_text': 'KassaAI временно недоступен',
},
'kassa_ai_sbp': {
'is_enabled': settings.is_kassa_ai_sbp_enabled,
'display_name': settings.get_kassa_ai_sbp_display_name,
'unavailable_text': 'KassaAI СБП временно недоступен',
},
'kassa_ai_card': {
'is_enabled': settings.is_kassa_ai_card_enabled,
'display_name': settings.get_kassa_ai_card_display_name,
'unavailable_text': 'KassaAI Карта временно недоступна',
},
}
async def _check_topup_restriction(callback: types.CallbackQuery, db_user: User) -> bool:
"""Check if user has topup restriction. Returns True if restricted (handler should abort)."""
if not getattr(db_user, 'restriction_topup', False):
return False
texts = get_texts(db_user.language)
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
return True
async def _create_kassa_ai_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method: str = 'kassa_ai',
):
"""
Common logic for creating KassaAI payment and sending response.
Args:
message_or_callback: Either a Message or CallbackQuery object
db_user: User object
db: Database session
amount_kopeks: Amount in kopeks
edit_message: Whether to edit existing message or send new one
"""
"""Common logic for creating KassaAI payment and sending response."""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
@@ -46,6 +79,9 @@ async def _create_kassa_ai_payment_and_respond(
description='Пополнение баланса',
)
sub = KASSA_AI_SUB_METHODS.get(payment_method)
payment_system_id = sub['payment_system_id'] if sub else settings.KASSA_AI_PAYMENT_SYSTEM_ID
result = await payment_service.create_kassa_ai_payment(
db=db,
user_id=db_user.id,
@@ -53,6 +89,7 @@ async def _create_kassa_ai_payment_and_respond(
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_system_id=payment_system_id,
)
if not result:
@@ -74,7 +111,8 @@ async def _create_kassa_ai_payment_and_respond(
return
payment_url = result.get('payment_url')
display_name = settings.get_kassa_ai_display_name()
cfg = _KASSA_AI_METHOD_CONFIG.get(payment_method, _KASSA_AI_METHOD_CONFIG['kassa_ai'])
display_name = cfg['display_name']()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
@@ -128,10 +166,9 @@ async def process_kassa_ai_payment_amount(
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
payment_method: str = 'kassa_ai',
):
"""
Process payment amount directly (called from quick_amount handlers).
"""
"""Process payment amount directly (called from custom_amount and quick_amount handlers)."""
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
@@ -161,6 +198,7 @@ async def process_kassa_ai_payment_amount(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
@@ -171,6 +209,7 @@ async def process_kassa_ai_payment_amount(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
@@ -183,53 +222,40 @@ async def process_kassa_ai_payment_amount(
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method=payment_method,
)
@error_handler
async def start_kassa_ai_topup(
# --- Generic start/quick-amount implementations ---
async def _start_kassa_ai_sub_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
payment_method: str,
):
"""
Start KassaAI top-up process - ask for amount.
"""
"""Generic start topup handler for any KassaAI sub-method."""
cfg = _KASSA_AI_METHOD_CONFIG[payment_method]
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
if not cfg['is_enabled']():
await callback.answer(texts.t('KASSA_AI_NOT_AVAILABLE', cfg['unavailable_text']), show_alert=True)
return
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
if await _check_topup_restriction(callback, db_user):
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='kassa_ai')
await state.update_data(payment_method=payment_method)
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_kassa_ai_display_name()
display_name = cfg['display_name']()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
]
inline_keyboard=[[InlineKeyboardButton(text=texts.t('BACK_BUTTON', '◀️ Назад'), callback_data='menu_balance')]]
)
await callback.message.edit_text(
@@ -249,6 +275,68 @@ async def start_kassa_ai_topup(
)
async def _process_kassa_ai_sub_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
payment_method: str,
):
"""Generic quick amount handler for any KassaAI sub-method."""
cfg = _KASSA_AI_METHOD_CONFIG[payment_method]
texts = get_texts(db_user.language)
if not cfg['is_enabled']():
await callback.answer(texts.t('KASSA_AI_NOT_AVAILABLE', cfg['unavailable_text']), show_alert=True)
return
try:
parts = callback.data.split('|')
amount_kopeks = int(parts[2]) if len(parts) >= 3 else None
if amount_kopeks is None:
raise ValueError
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
if await _check_topup_restriction(callback, db_user):
return
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'), show_alert=True)
return
if amount_kopeks > max_amount:
await callback.answer(texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'), show_alert=True)
return
await callback.answer()
await state.clear()
await _create_kassa_ai_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
payment_method=payment_method,
)
# --- Public handler functions (registered in main.py) ---
@error_handler
async def start_kassa_ai_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Start KassaAI top-up process - ask for amount."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai')
@error_handler
async def process_kassa_ai_custom_amount(
message: types.Message,
@@ -256,11 +344,10 @@ async def process_kassa_ai_custom_amount(
db: AsyncSession,
state: FSMContext,
):
"""
Process custom amount input for KassaAI payment.
"""
"""Process custom amount input for KassaAI payment."""
data = await state.get_data()
if data.get('payment_method') != 'kassa_ai':
pm = data.get('payment_method', 'kassa_ai')
if pm not in _KASSA_AI_METHOD_CONFIG:
return
texts = get_texts(db_user.language)
@@ -285,6 +372,7 @@ async def process_kassa_ai_custom_amount(
db=db,
amount_kopeks=amount_kopeks,
state=state,
payment_method=pm,
)
@@ -295,72 +383,49 @@ async def process_kassa_ai_quick_amount(
db: AsyncSession,
state: FSMContext,
):
"""
Process quick amount selection for KassaAI payment.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
"""Process quick amount selection for KassaAI payment."""
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai')
if not settings.is_kassa_ai_enabled():
await callback.answer(
texts.t('KASSA_AI_NOT_AVAILABLE', 'KassaAI временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|kassa_ai|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer('Invalid callback data', show_alert=True)
return
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
@error_handler
async def start_kassa_ai_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Start KassaAI SBP top-up process."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_sbp')
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
return
@error_handler
async def process_kassa_ai_sbp_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Process quick amount for KassaAI SBP."""
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai_sbp')
# Validate amount
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
show_alert=True,
)
return
@error_handler
async def start_kassa_ai_card_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Start KassaAI Card top-up process."""
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_card')
if amount_kopeks > max_amount:
await callback.answer(
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
show_alert=True,
)
return
await callback.answer()
await state.clear()
await _create_kassa_ai_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
)
@error_handler
async def process_kassa_ai_card_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Process quick amount for KassaAI Card."""
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai_card')
+38 -7
View File
@@ -131,11 +131,20 @@ async def route_payment_by_method(
)
return True
if payment_method == 'kassa_ai':
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'):
from .kassa_ai import process_kassa_ai_payment_amount
async with AsyncSessionLocal() as db:
await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state)
await process_kassa_ai_payment_amount(
message, db_user, db, amount_kopeks, state, payment_method=payment_method
)
return True
if payment_method == 'severpay':
from .severpay import process_severpay_payment_amount
async with AsyncSessionLocal() as db:
await process_severpay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'riopay':
@@ -542,11 +551,13 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
amount_rubles = float(amount_text.replace(',', '.'))
if amount_rubles < 1:
await message.answer('Минимальная сумма пополнения: 1 ₽')
await message.answer('Минимальная сумма пополнения: 1 ₽', reply_markup=get_back_keyboard(db_user.language))
return
if amount_rubles > 50000:
await message.answer('Максимальная сумма пополнения: 50,000 ₽')
await message.answer(
'Максимальная сумма пополнения: 50,000 ₽', reply_markup=get_back_keyboard(db_user.language)
)
return
amount_kopeks = int(amount_rubles * 100)
@@ -556,13 +567,17 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
if payment_method in ['yookassa', 'yookassa_sbp']:
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f}')
await message.answer(
f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f}',
reply_markup=get_back_keyboard(db_user.language),
)
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f}'.replace(',', ' ')
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f}'.replace(',', ' '),
reply_markup=get_back_keyboard(db_user.language),
)
return
@@ -790,16 +805,32 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_freekassa_card_topup, F.data == 'topup_freekassa_card')
dp.callback_query.register(process_freekassa_card_quick_amount, F.data.startswith('topup_amount|freekassa_card|'))
from .kassa_ai import process_kassa_ai_quick_amount, start_kassa_ai_topup
from .kassa_ai import (
process_kassa_ai_card_quick_amount,
process_kassa_ai_quick_amount,
process_kassa_ai_sbp_quick_amount,
start_kassa_ai_card_topup,
start_kassa_ai_sbp_topup,
start_kassa_ai_topup,
)
dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai')
dp.callback_query.register(process_kassa_ai_quick_amount, F.data.startswith('topup_amount|kassa_ai|'))
dp.callback_query.register(start_kassa_ai_sbp_topup, F.data == 'topup_kassa_ai_sbp')
dp.callback_query.register(process_kassa_ai_sbp_quick_amount, F.data.startswith('topup_amount|kassa_ai_sbp|'))
dp.callback_query.register(start_kassa_ai_card_topup, F.data == 'topup_kassa_ai_card')
dp.callback_query.register(process_kassa_ai_card_quick_amount, F.data.startswith('topup_amount|kassa_ai_card|'))
from .riopay import process_riopay_quick_amount, start_riopay_topup
dp.callback_query.register(start_riopay_topup, F.data == 'topup_riopay')
dp.callback_query.register(process_riopay_quick_amount, F.data.startswith('topup_amount|riopay|'))
from .severpay import process_severpay_quick_amount, start_severpay_topup
dp.callback_query.register(start_severpay_topup, F.data == 'topup_severpay')
dp.callback_query.register(process_severpay_quick_amount, F.data.startswith('topup_amount|severpay|'))
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
+4 -2
View File
@@ -124,13 +124,15 @@ async def process_mulenpay_payment_amount(
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
await message.answer(
f'Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}'
f'Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}',
reply_markup=get_back_keyboard(db_user.language),
)
return
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
await message.answer(
f'Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}'
f'Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}',
reply_markup=get_back_keyboard(db_user.language),
)
return
+8 -2
View File
@@ -359,12 +359,18 @@ async def process_pal24_payment_amount(
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f}')
await message.answer(
f'❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f}',
reply_markup=get_back_keyboard(db_user.language),
)
return
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f}'.replace(',', ' '))
await message.answer(
f'❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f}'.replace(',', ' '),
reply_markup=get_back_keyboard(db_user.language),
)
return
available_methods = _get_available_pal24_methods()
+4 -2
View File
@@ -250,7 +250,8 @@ async def process_platega_payment_amount(
texts.t(
'PLATEGA_AMOUNT_TOO_LOW',
'Минимальная сумма для оплаты через Platega: {amount}',
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)),
reply_markup=get_back_keyboard(db_user.language),
)
return
@@ -259,7 +260,8 @@ async def process_platega_payment_amount(
texts.t(
'PLATEGA_AMOUNT_TOO_HIGH',
'Максимальная сумма для оплаты через Platega: {amount}',
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS)),
reply_markup=get_back_keyboard(db_user.language),
)
return
+2
View File
@@ -161,6 +161,7 @@ async def process_riopay_payment_amount(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
@@ -171,6 +172,7 @@ async def process_riopay_payment_amount(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
+317
View File
@@ -0,0 +1,317 @@
"""Handler for SeverPay balance top-up."""
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_severpay_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating SeverPay payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_severpay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(
error_text,
parse_mode='HTML',
)
return
payment_url = result.get('payment_url')
display_name = settings.get_severpay_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'💳 Оплатить {amount}',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'SEVERPAY_PAYMENT_CREATED',
'💳 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже для оплаты.\n'
'После успешной оплаты баланс будет пополнен автоматически.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('SeverPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_severpay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly (called from quick_amount handlers).
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await message.answer(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
# Validate amount
min_amount = settings.SEVERPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.SEVERPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
await state.clear()
await _create_severpay_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
)
@error_handler
async def start_severpay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start SeverPay top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method='severpay')
min_amount = settings.SEVERPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.SEVERPAY_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_severpay_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'SEVERPAY_ENTER_AMOUNT',
'💳 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\n'
'Максимум: {max_amount}',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
@error_handler
async def process_severpay_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process quick amount selection for SeverPay payment.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
if not settings.is_severpay_enabled():
await callback.answer(
texts.t('SEVERPAY_NOT_AVAILABLE', 'SeverPay временно недоступен'),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|severpay|{amount_kopeks}
try:
parts = callback.data.split('|')
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer('Invalid callback data', show_alert=True)
return
except (ValueError, IndexError):
await callback.answer('Invalid amount', show_alert=True)
return
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
# Validate amount
min_amount = settings.SEVERPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.SEVERPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
show_alert=True,
)
return
if amount_kopeks > max_amount:
await callback.answer(
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
show_alert=True,
)
return
await callback.answer()
await state.clear()
await _create_severpay_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
)
+4 -2
View File
@@ -120,7 +120,8 @@ async def process_wata_payment_amount(
texts.t(
'WATA_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {amount}',
).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS))
).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS)),
reply_markup=get_back_keyboard(db_user.language),
)
return
@@ -129,7 +130,8 @@ async def process_wata_payment_amount(
texts.t(
'WATA_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {amount}',
).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS))
).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS)),
reply_markup=get_back_keyboard(db_user.language),
)
return
+16 -4
View File
@@ -178,12 +178,18 @@ async def process_yookassa_payment_amount(
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Минимальная сумма для оплаты картой: {min_rubles:.0f}')
await message.answer(
f'❌ Минимальная сумма для оплаты картой: {min_rubles:.0f}',
reply_markup=get_back_keyboard(db_user.language),
)
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f}'.replace(',', ' '))
await message.answer(
f'❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f}'.replace(',', ' '),
reply_markup=get_back_keyboard(db_user.language),
)
return
try:
@@ -327,12 +333,18 @@ async def process_yookassa_sbp_payment_amount(
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f}')
await message.answer(
f'❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f}',
reply_markup=get_back_keyboard(db_user.language),
)
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(f'❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f}'.replace(',', ' '))
await message.answer(
f'❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f}'.replace(',', ' '),
reply_markup=get_back_keyboard(db_user.language),
)
return
try:
+167
View File
@@ -51,6 +51,7 @@ from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.referral_service import process_referral_registration
from app.services.subscription_service import SubscriptionService
from app.services.support_settings_service import SupportSettingsService
from app.services.web_auth_service import WEB_AUTH_TOKEN_MIN_LENGTH, link_web_auth_token
from app.states import RegistrationStates
from app.utils.promo_offer import (
build_promo_offer_hint,
@@ -197,6 +198,80 @@ async def _claim_phantom_user(
return True, phantom
async def _merge_phantom_into_active_user(
db: AsyncSession,
phantom: 'User',
active_user: 'User',
) -> None:
"""Merge a phantom user (created by guest landing purchase) into an existing active user.
Transfers GuestPurchase records and handles subscription conflict.
The phantom is soft-deleted (status=DELETED, username cleared) to preserve
audit trail and avoid CASCADE deletion of payment/transaction records.
"""
from sqlalchemy import update
logger.info(
'Merging phantom user into active user',
phantom_id=phantom.id,
active_user_id=active_user.id,
phantom_username=phantom.username,
)
# Transfer GuestPurchase.user_id references
await db.execute(update(GuestPurchase).where(GuestPurchase.user_id == phantom.id).values(user_id=active_user.id))
# Transfer GuestPurchase.buyer_user_id references
await db.execute(
update(GuestPurchase).where(GuestPurchase.buyer_user_id == phantom.id).values(buyer_user_id=active_user.id)
)
# Transfer balance
if phantom.balance_kopeks and phantom.balance_kopeks > 0:
active_user.balance_kopeks = (active_user.balance_kopeks or 0) + phantom.balance_kopeks
logger.info('Transferred balance from phantom', amount_kopeks=phantom.balance_kopeks)
# Handle subscription
await db.refresh(phantom, ['subscription'])
await db.refresh(active_user, ['subscription'])
if phantom.subscription and not active_user.subscription:
# Transfer subscription from phantom to active user
phantom.subscription.user_id = active_user.id
# Transfer remnawave_uuid
if phantom.remnawave_uuid and not active_user.remnawave_uuid:
active_user.remnawave_uuid = phantom.remnawave_uuid
phantom.remnawave_uuid = None
await db.flush()
logger.info(
'Transferred subscription from phantom to active user',
subscription_id=phantom.subscription.id,
)
elif phantom.subscription:
# Both have subscriptions — disable phantom's Remnawave user and free server slots
logger.warning(
'Both phantom and active user have subscriptions, disabling phantom',
phantom_subscription_id=phantom.subscription.id,
active_subscription_id=active_user.subscription.id,
)
if phantom.remnawave_uuid:
try:
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(phantom.remnawave_uuid)
except Exception as exc:
logger.warning('Failed to disable phantom Remnawave user', error=str(exc))
await decrement_subscription_server_counts(db, phantom.subscription)
# Soft-delete phantom: clear identifiers to prevent future matches,
# preserve record for audit trail and avoid CASCADE deletion of payments/transactions
phantom.status = UserStatus.DELETED.value
phantom.username = None
phantom.remnawave_uuid = None
await db.flush()
logger.info('Phantom user merged and soft-deleted', phantom_id=phantom.id, active_user_id=active_user.id)
def _calculate_subscription_flags(subscription):
if not subscription:
return False, False
@@ -533,6 +608,40 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
await state.update_data(pending_gift_token=gift_token)
start_parameter = None # Don't treat as campaign or referral
# Handle web auth deep links: /start webauth_{token}
if start_parameter and start_parameter.startswith('webauth_'):
web_auth_token = start_parameter.removeprefix('webauth_')
if len(web_auth_token) >= WEB_AUTH_TOKEN_MIN_LENGTH:
user = db_user or await get_user_by_telegram_id(db, message.from_user.id)
if user and user.status != UserStatus.DELETED.value:
texts = get_texts(user.language)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t('WEB_AUTH_CONFIRM_YES', '✅ Да, войти'),
callback_data=f'webauth_confirm:{web_auth_token}',
),
types.InlineKeyboardButton(
text=texts.t('WEB_AUTH_CONFIRM_NO', '❌ Нет'),
callback_data='webauth_deny',
),
],
]
)
await message.answer(
texts.t(
'WEB_AUTH_CONFIRM_PROMPT',
'🔐 Подтвердите вход в личный кабинет. Если вы не запрашивали вход — нажмите «Нет».',
),
reply_markup=keyboard,
)
else:
logger.warning('Web auth attempt from unregistered user', telegram_id=message.from_user.id)
await message.answer('❌ Сначала зарегистрируйтесь в боте, затем попробуйте войти в кабинет.')
return
start_parameter = None # Invalid token, ignore
if start_parameter:
campaign = await get_campaign_by_start_parameter(
db,
@@ -589,6 +698,21 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
if user and user.status != UserStatus.DELETED.value:
logger.info('✅ Активный пользователь найден', telegram_id=user.telegram_id)
# Check for phantom user created by guest landing purchase and merge
if message.from_user.username:
phantom = await find_phantom_user_by_username(db, message.from_user.username)
if phantom and phantom.id != user.id:
try:
await _merge_phantom_into_active_user(db, phantom, user)
await db.refresh(user, ['subscription'])
except Exception:
await db.rollback()
logger.exception(
'Failed to merge phantom user',
phantom_id=phantom.id,
active_user_id=user.id,
)
profile_updated = False
if user.username != message.from_user.username:
@@ -2473,6 +2597,43 @@ async def required_sub_channel_check(
pass
async def process_webauth_confirm(
callback: types.CallbackQuery,
db: AsyncSession,
):
"""Handle web auth confirmation or denial."""
await callback.answer()
if not isinstance(callback.message, types.Message):
return
if callback.data == 'webauth_deny':
await callback.message.edit_text('❌ Вход отменён.')
return
# Extract token from callback_data: "webauth_confirm:{token}"
token = callback.data.split(':', 1)[1] if ':' in callback.data else ''
if len(token) < WEB_AUTH_TOKEN_MIN_LENGTH:
await callback.message.edit_text('❌ Ошибка: неверный токен.')
return
user = await get_user_by_telegram_id(db, callback.from_user.id)
if not user or user.status != UserStatus.ACTIVE.value:
await callback.message.edit_text('❌ Учётная запись неактивна.')
return
linked = await link_web_auth_token(token, callback.from_user.id, user.id)
texts = get_texts(user.language)
if linked:
await callback.message.edit_text(
texts.t('WEB_AUTH_SUCCESS', '✅ Авторизация в кабинете подтверждена! Вернитесь в браузер.'),
)
else:
await callback.message.edit_text(
texts.t('WEB_AUTH_EXPIRED', '❌ Ссылка для входа истекла. Попробуйте снова.'),
)
def register_handlers(dp: Dispatcher):
logger.debug('=== НАЧАЛО регистрации обработчиков start.py ===')
@@ -2517,4 +2678,10 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(required_sub_channel_check, F.data.in_(['sub_channel_check']))
logger.debug('Зарегистрирован required_sub_channel_check')
dp.callback_query.register(
process_webauth_confirm,
F.data.startswith('webauth_confirm:') | F.data.in_(['webauth_deny']),
)
logger.debug('Зарегистрирован process_webauth_confirm')
logger.debug('=== КОНЕЦ регистрации обработчиков start.py ===')
+3 -11
View File
@@ -172,7 +172,7 @@ async def handle_manage_country(callback: types.CallbackQuery, db_user: User, db
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
if country_uuid not in allowed_country_ids and country_uuid not in current_selected:
if country_uuid not in allowed_country_ids:
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
@@ -236,11 +236,7 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
selected_countries = [
country_uuid
for country_uuid in selected_countries
if country_uuid in allowed_country_ids or country_uuid in current_countries
]
selected_countries = [country_uuid for country_uuid in selected_countries if country_uuid in allowed_country_ids]
added = [c for c in selected_countries if c not in current_countries]
removed = [c for c in current_countries if c not in selected_countries]
@@ -788,11 +784,7 @@ async def confirm_add_countries_to_subscription(
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
selected_countries = [
country_uuid
for country_uuid in selected_countries
if country_uuid in allowed_country_ids or country_uuid in current_countries
]
selected_countries = [country_uuid for country_uuid in selected_countries if country_uuid in allowed_country_ids]
new_countries = [c for c in selected_countries if c not in current_countries]
removed_countries = [c for c in current_countries if c not in selected_countries]
+1
View File
@@ -2448,6 +2448,7 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
subscription,
reset_traffic=True,
reset_reason='покупка подписки',
sync_squads=True,
)
else:
remnawave_user = await subscription_service.create_remnawave_user(
@@ -1859,8 +1859,6 @@ async def select_tariff_extend_period(
extend_tariff_id=tariff_id,
extend_period=period,
extend_discount_percent=discount_percent,
extend_group_pct=group_pct,
extend_offer_pct=offer_pct,
)
await callback.answer()
+41 -1
View File
@@ -1695,7 +1695,35 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_kassa_ai_enabled():
if settings.is_kassa_ai_sbp_enabled():
sbp_name = settings.get_kassa_ai_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_KASSA_AI_SBP', f'📱 {sbp_name}'),
callback_data=_build_callback('kassa_ai_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_kassa_ai_card_enabled():
card_name = settings.get_kassa_ai_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_KASSA_AI_CARD', f'💳 {card_name}'),
callback_data=_build_callback('kassa_ai_card'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_kassa_ai_enabled()
and not settings.is_kassa_ai_sbp_enabled()
and not settings.is_kassa_ai_card_enabled()
):
kassa_ai_name = settings.get_kassa_ai_display_name()
keyboard.append(
[
@@ -1718,6 +1746,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_severpay_enabled():
severpay_name = settings.get_severpay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_SEVERPAY', f'💳 Банковская карта ({severpay_name})'),
callback_data=_build_callback('severpay'),
)
]
)
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append(
[
+6
View File
@@ -14,6 +14,12 @@ from app.config import settings
logger = structlog.get_logger(__name__)
# Sub-method to payment_system_id mapping
KASSA_AI_SUB_METHODS = {
'kassa_ai_sbp': {'payment_system_id': 44},
'kassa_ai_card': {'payment_system_id': 36},
}
# Кэш для публичного IP
_cached_public_ip: str | None = None
_ip_fetch_lock = asyncio.Lock()
+2
View File
@@ -14,6 +14,7 @@ from .mulenpay import MulenPayPaymentMixin
from .pal24 import Pal24PaymentMixin
from .platega import PlategaPaymentMixin
from .riopay import RioPayPaymentMixin
from .severpay import SeverPayPaymentMixin
from .stars import TelegramStarsMixin
from .tribute import TributePaymentMixin
from .wata import WataPaymentMixin
@@ -31,6 +32,7 @@ __all__ = [
'PaymentCommonMixin',
'PlategaPaymentMixin',
'RioPayPaymentMixin',
'SeverPayPaymentMixin',
'TelegramStarsMixin',
'TributePaymentMixin',
'WataPaymentMixin',
+7 -2
View File
@@ -28,6 +28,7 @@ class KassaAiPaymentMixin:
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_system_id: int | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж KassaAI.
@@ -96,7 +97,9 @@ class KassaAiPaymentMixin:
amount=amount_rubles,
currency=currency,
email=email,
payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID,
payment_system_id=payment_system_id
if payment_system_id is not None
else settings.KASSA_AI_PAYMENT_SYSTEM_ID,
)
payment_url = result.get('location')
@@ -118,7 +121,9 @@ class KassaAiPaymentMixin:
currency=currency,
description=description,
payment_url=payment_url,
payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID,
payment_system_id=payment_system_id
if payment_system_id is not None
else settings.KASSA_AI_PAYMENT_SYSTEM_ID,
expires_at=expires_at,
metadata_json=metadata,
)
+24 -5
View File
@@ -42,11 +42,13 @@ class RioPayPaymentMixin:
self,
db: AsyncSession,
*,
user_id: int,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
success_url: str | None = None,
fail_url: str | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж RioPay.
@@ -76,8 +78,11 @@ class RioPayPaymentMixin:
return None
# Получаем telegram_id пользователя для order_id
user = await get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
if user_id is not None:
user = await get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
tg_id = 'guest'
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'rp{tg_id}_{uuid.uuid4().hex[:6]}'
@@ -103,8 +108,8 @@ class RioPayPaymentMixin:
currency=currency,
external_id=order_id,
purpose=description,
success_url=settings.RIOPAY_SUCCESS_URL,
fail_url=settings.RIOPAY_FAIL_URL,
success_url=success_url or settings.RIOPAY_SUCCESS_URL,
fail_url=fail_url or settings.RIOPAY_FAIL_URL,
)
payment_url = result.get('paymentLink')
@@ -275,6 +280,20 @@ class RioPayPaymentMixin:
logger.info('RioPay платеж уже привязан к транзакции', order_id=payment.order_id, trigger=trigger)
return True
# --- Guest purchase flow (landing page / gift) ---
riopay_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=riopay_metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=str(riopay_order_id) if riopay_order_id else payment.order_id,
provider_name='riopay',
)
if guest_result is not None:
return True
# Получаем пользователя
user = await get_user_by_id(db, payment.user_id)
if not user:
+603
View File
@@ -0,0 +1,603 @@
"""Mixin для интеграции с SeverPay (severpay.io)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.severpay_service import severpay_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов SeverPay -> internal
SEVERPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
'new': ('pending', False),
'process': ('processing', False),
'success': ('success', True),
'decline': ('declined', False),
'fail': ('failed', False),
}
class SeverPayPaymentMixin:
"""Mixin для работы с платежами SeverPay."""
async def create_severpay_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
return_url: str | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж SeverPay.
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_severpay_enabled():
logger.error('SeverPay не настроен')
return None
# Валидация лимитов
if amount_kopeks < settings.SEVERPAY_MIN_AMOUNT_KOPEKS:
logger.warning(
'SeverPay: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
SEVERPAY_MIN_AMOUNT_KOPEKS=settings.SEVERPAY_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.SEVERPAY_MAX_AMOUNT_KOPEKS:
logger.warning(
'SeverPay: сумма больше максимальной',
amount_kopeks=amount_kopeks,
SEVERPAY_MAX_AMOUNT_KOPEKS=settings.SEVERPAY_MAX_AMOUNT_KOPEKS,
)
return None
# Получаем telegram_id пользователя для order_id
payment_module = import_module('app.services.payment_service')
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
else:
tg_id = 'guest'
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'sp{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.SEVERPAY_CURRENCY
# Срок действия платежа
lifetime = settings.SEVERPAY_LIFETIME
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
# Метаданные
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
}
try:
# Используем API для создания платежа
result = await severpay_service.create_payment(
order_id=order_id,
amount=amount_rubles,
currency=currency,
client_email=email or '',
client_id=str(tg_id),
url_return=return_url or settings.SEVERPAY_RETURN_URL,
lifetime=lifetime,
)
payment_url = result.get('url')
severpay_id = str(result.get('id', '')) if result.get('id') else None
severpay_uid = result.get('uid')
if not payment_url:
logger.error('SeverPay API не вернул URL платежа', result=result)
return None
logger.info(
'SeverPay API: создан платеж',
order_id=order_id,
severpay_id=severpay_id,
payment_url=payment_url,
)
# Вычисляем expires_at из ответа API если доступен
expire_at_raw = result.get('expire_at')
if expire_at_raw:
try:
expires_at = datetime.fromtimestamp(int(expire_at_raw), tz=UTC)
except (ValueError, TypeError):
pass
# Сохраняем в БД
severpay_crud = import_module('app.database.crud.severpay')
local_payment = await severpay_crud.create_severpay_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
severpay_id=severpay_id,
severpay_uid=severpay_uid,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'SeverPay: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_id,
'severpay_id': severpay_id,
'severpay_uid': severpay_uid,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_rubles,
'currency': currency,
'payment_url': payment_url,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('SeverPay: ошибка создания платежа', error=e)
return None
async def process_severpay_webhook(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""
Обрабатывает webhook от SeverPay.
Подпись проверяется в webserver/payments.py до вызова этого метода.
Args:
db: Сессия БД
payload: JSON тело webhook (sign проверен в webserver, тело передаётся как есть)
Returns:
True если платеж успешно обработан
"""
try:
webhook_type = payload.get('type')
if webhook_type != 'payin':
logger.info('SeverPay webhook: пропускаем тип', webhook_type=webhook_type)
return True
data = payload.get('data', {})
severpay_id = str(data.get('id', '')) if data.get('id') else None
order_id = data.get('order_id')
severpay_status = data.get('status')
amount = data.get('amount')
if not severpay_id or not severpay_status:
logger.warning('SeverPay webhook: отсутствуют обязательные поля', payload=payload)
return False
# Ищем платеж по order_id (наш) или severpay_id
severpay_crud = import_module('app.database.crud.severpay')
payment = None
if order_id:
payment = await severpay_crud.get_severpay_payment_by_order_id(db, order_id)
if not payment and severpay_id:
payment = await severpay_crud.get_severpay_payment_by_severpay_id(db, severpay_id)
if not payment:
logger.warning(
'SeverPay webhook: платеж не найден',
order_id=order_id,
severpay_id=severpay_id,
)
return False
# Проверка дублирования
if payment.is_paid:
logger.info('SeverPay webhook: платеж уже обработан', order_id=payment.order_id)
return True
# Маппинг статуса
status_info = SEVERPAY_STATUS_MAP.get(severpay_status, ('pending', False))
internal_status, is_paid = status_info
callback_payload = {
'severpay_id': severpay_id,
'order_id': order_id,
'status': severpay_status,
'amount': amount,
'currency': data.get('currency'),
}
# Проверка суммы ДО обновления статуса
if is_paid and amount is not None:
received_kopeks = round(float(amount) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'SeverPay amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await severpay_crud.update_severpay_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
severpay_id=severpay_id,
callback_payload=callback_payload,
)
return False
# Финализируем платеж если оплачен — без промежуточного commit
if is_paid:
# Inline field assignments to keep FOR UPDATE lock intact
payment.status = internal_status
payment.is_paid = True
payment.severpay_id = severpay_id or payment.severpay_id
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
return await self._finalize_severpay_payment(db, payment, severpay_id=severpay_id, trigger='webhook')
# Для не-success статусов можно безопасно коммитить
payment = await severpay_crud.update_severpay_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
severpay_id=severpay_id,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('SeverPay webhook: ошибка обработки', error=e)
return False
async def _finalize_severpay_payment(
self,
db: AsyncSession,
payment: Any,
*,
severpay_id: str | None,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
Использует FOR UPDATE lock для защиты от race condition.
"""
payment_module = import_module('app.services.payment_service')
severpay_crud = import_module('app.database.crud.severpay')
# Lock FIRST, then read fresh state
locked = await severpay_crud.get_severpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('SeverPay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
if payment.transaction_id:
logger.info(
'SeverPay платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
# Read fresh metadata AFTER lock to avoid stale data
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
# --- Guest purchase flow ---
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=str(severpay_id) if severpay_id else payment.order_id,
provider_name='severpay',
)
if guest_result is not None:
return True
# Inline field assignments to keep FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден для SeverPay', user_id=payment.user_id)
return False
# Загружаем промогруппы в асинхронном контексте
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
transaction_external_id = str(severpay_id) if severpay_id else payment.order_id
# Проверяем дупликат транзакции
existing_transaction = None
if transaction_external_id:
existing_transaction = await payment_module.get_transaction_by_external_id(
db,
transaction_external_id,
PaymentMethod.SEVERPAY,
)
display_name = settings.get_severpay_display_name()
description = f'Пополнение через {display_name}'
transaction = existing_transaction
created_transaction = False
if not transaction:
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.SEVERPAY,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await severpay_crud.link_severpay_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
should_credit_balance = created_transaction or not balance_already_credited
if not should_credit_balance:
logger.info('SeverPay платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
# Emit deferred side-effects after atomic commit
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.SEVERPAY,
external_id=transaction_external_id,
)
topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение'
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения SeverPay', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
if getattr(self, 'bot', None):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления SeverPay', error=error)
if getattr(self, 'bot', None) and user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
'✅ <b>Пополнение успешно!</b>\n\n'
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
f'💳 Способ: {display_name}\n'
f'🆔 Транзакция: {transaction.id}\n\n'
'Баланс пополнен автоматически!'
),
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.error('Ошибка отправки уведомления пользователю SeverPay', error=error)
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
except Exception as error:
logger.error(
'Ошибка при работе с сохраненной корзиной для пользователя',
user_id=payment.user_id,
error=error,
exc_info=True,
)
metadata['balance_change'] = {
'old_balance': old_balance,
'new_balance': user.balance_kopeks,
'credited_at': datetime.now(UTC).isoformat(),
}
metadata['balance_credited'] = True
payment.metadata_json = metadata
await db.commit()
logger.info(
'Обработан SeverPay платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
async def check_severpay_payment_status(
self,
db: AsyncSession,
order_id: str,
) -> dict[str, Any] | None:
"""Проверяет статус платежа через API."""
try:
severpay_crud = import_module('app.database.crud.severpay')
payment = await severpay_crud.get_severpay_payment_by_order_id(db, order_id)
if not payment:
logger.warning('SeverPay payment not found', order_id=order_id)
return None
if payment.is_paid:
return {
'payment': payment,
'status': 'success',
'is_paid': True,
}
# Проверяем через API по severpay_id
if payment.severpay_id:
try:
order_data = await severpay_service.get_payment(payment.severpay_id)
severpay_status = order_data.get('status')
if severpay_status:
status_info = SEVERPAY_STATUS_MAP.get(severpay_status, ('pending', False))
internal_status, is_paid = status_info
if is_paid:
# Проверка суммы
api_amount = order_data.get('amount')
if api_amount is not None:
received_kopeks = round(float(api_amount) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'SeverPay amount mismatch (API check)',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await severpay_crud.update_severpay_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
severpay_id=payment.severpay_id,
callback_payload={
'check_source': 'api',
'severpay_order_data': order_data,
},
)
return {
'payment': payment,
'status': 'amount_mismatch',
'is_paid': False,
}
logger.info('SeverPay payment confirmed via API', order_id=payment.order_id)
callback_payload = {
'check_source': 'api',
'severpay_order_data': order_data,
}
payment = await severpay_crud.update_severpay_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
severpay_id=payment.severpay_id,
callback_payload=callback_payload,
)
await self._finalize_severpay_payment(
db,
payment,
severpay_id=payment.severpay_id,
trigger='api_check',
)
elif internal_status != payment.status:
# Обновляем статус если изменился
payment = await severpay_crud.update_severpay_payment_status(
db=db,
payment=payment,
status=internal_status,
)
except Exception as e:
logger.error('Error checking SeverPay payment status via API', error=e)
return {
'payment': payment,
'status': payment.status or 'pending',
'is_paid': payment.is_paid,
}
except Exception as e:
logger.exception('SeverPay: ошибка проверки статуса', error=e)
return None
+12 -1
View File
@@ -126,7 +126,10 @@ def _get_method_defaults() -> dict:
'is_configured': settings.is_kassa_ai_enabled(),
'default_min': settings.KASSA_AI_MIN_AMOUNT_KOPEKS,
'default_max': settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
'available_sub_options': None,
'available_sub_options': [
{'id': 'sbp', 'name': 'СБП'},
{'id': 'card', 'name': 'Карта'},
],
},
'riopay': {
'default_display_name': settings.get_riopay_display_name(),
@@ -135,6 +138,13 @@ def _get_method_defaults() -> dict:
'default_max': settings.RIOPAY_MAX_AMOUNT_KOPEKS,
'available_sub_options': None,
},
'severpay': {
'default_display_name': settings.get_severpay_display_name(),
'is_configured': settings.is_severpay_enabled(),
'default_min': settings.SEVERPAY_MIN_AMOUNT_KOPEKS,
'default_max': settings.SEVERPAY_MAX_AMOUNT_KOPEKS,
'available_sub_options': None,
},
}
@@ -176,6 +186,7 @@ DEFAULT_METHOD_ORDER = [
'cloudpayments',
'kassa_ai',
'riopay',
'severpay',
]
+811
View File
@@ -0,0 +1,811 @@
"""Search service for querying payments across all provider tables."""
from __future__ import annotations
import enum
from collections import Counter
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
from sqlalchemy import cast, desc, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.types import String as SAString
from app.database.models import (
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PaymentMethod,
PlategaPayment,
RioPayPayment,
SeverPayPayment,
Transaction,
TransactionType,
User,
WataPayment,
YooKassaPayment,
)
from app.services.payment_verification_service import (
PendingPayment,
_build_record,
_metadata_is_balance,
_parse_cryptobot_amount_kopeks,
)
logger = structlog.get_logger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MAX_ALL_TIME_DAYS: int = 365
"""Safety limit for 'all time' queries to prevent unbounded scans."""
MAX_RECORDS_PER_PROVIDER: int = 5000
"""Hard limit on rows fetched from each provider table to prevent memory exhaustion."""
DEFAULT_PER_PAGE: int = 20
MAX_PER_PAGE: int = 100
def _escape_like(value: str) -> str:
"""Escape LIKE/ILIKE wildcard characters to prevent pattern injection."""
return value.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
class StatusFilter(str, enum.Enum):
"""Supported status filter values."""
ALL = 'all'
PENDING = 'pending'
PAID = 'paid'
CANCELLED = 'cancelled'
class PeriodPreset(str, enum.Enum):
"""Predefined period presets."""
H24 = '24h'
D7 = '7d'
D30 = '30d'
ALL = 'all'
_PERIOD_DELTAS: dict[PeriodPreset, timedelta] = {
PeriodPreset.H24: timedelta(hours=24),
PeriodPreset.D7: timedelta(days=7),
PeriodPreset.D30: timedelta(days=30),
PeriodPreset.ALL: timedelta(days=MAX_ALL_TIME_DAYS),
}
# Sets of provider-specific statuses used for classification.
_CANCELLED_STATUSES: frozenset[str] = frozenset(
{
'cancel',
'canceled',
'cancelled',
'declined',
'error',
'expired',
'fail',
'failed',
'amount_mismatch',
}
)
# ---------------------------------------------------------------------------
# Search params
# ---------------------------------------------------------------------------
@dataclass(slots=True)
class SearchParams:
"""Encapsulates validated search parameters."""
search: str | None = None
status_filter: StatusFilter = StatusFilter.ALL
method_filter: PaymentMethod | None = None
period: PeriodPreset = PeriodPreset.H24
date_from: datetime | None = None
date_to: datetime | None = None
page: int = 1
per_page: int = DEFAULT_PER_PAGE
@property
def cutoff(self) -> datetime:
"""Calculate the earliest datetime to consider."""
if self.date_from is not None:
return self.date_from
return datetime.now(UTC) - _PERIOD_DELTAS.get(self.period, _PERIOD_DELTAS[PeriodPreset.H24])
@property
def upper_bound(self) -> datetime | None:
"""Upper datetime bound (only set for custom ranges)."""
return self.date_to
@dataclass(slots=True)
class SearchStats:
"""Aggregated search statistics."""
total: int = 0
pending: int = 0
paid: int = 0
cancelled: int = 0
by_method: dict[str, int] | None = None
# ---------------------------------------------------------------------------
# Status classification
# ---------------------------------------------------------------------------
_PAID_STATUSES: frozenset[str] = frozenset(
{
'completed',
'confirmed',
'paid',
'paid_over',
'succeeded',
'success',
}
)
def _classify_status(record: PendingPayment) -> StatusFilter:
"""Classify a payment record into one of the three buckets."""
if record.is_paid:
return StatusFilter.PAID
status_lower = (record.status or '').lower()
if status_lower in _PAID_STATUSES:
return StatusFilter.PAID
if status_lower in _CANCELLED_STATUSES:
return StatusFilter.CANCELLED
return StatusFilter.PENDING
# ---------------------------------------------------------------------------
# User search type detection
# ---------------------------------------------------------------------------
class _UserSearchKind(enum.Enum):
USERNAME = 'username'
TELEGRAM_ID = 'telegram_id'
EMAIL = 'email'
INVOICE = 'invoice'
def _detect_user_search_kind(query: str) -> _UserSearchKind:
"""Auto-detect the type of user search query."""
stripped = query.strip()
if stripped.startswith('@'):
return _UserSearchKind.USERNAME
if stripped.isdigit():
return _UserSearchKind.TELEGRAM_ID
if '@' in stripped:
return _UserSearchKind.EMAIL
return _UserSearchKind.INVOICE
# ---------------------------------------------------------------------------
# Per-provider search functions
# ---------------------------------------------------------------------------
def _apply_date_filter(
stmt: Any,
created_at_col: Any,
cutoff: datetime,
upper_bound: datetime | None,
) -> Any:
"""Apply date range filters to a select statement."""
stmt = stmt.where(created_at_col >= cutoff)
if upper_bound is not None:
stmt = stmt.where(created_at_col <= upper_bound)
return stmt
def _apply_user_join_filter(
stmt: Any,
model: type,
search_kind: _UserSearchKind,
search_value: str,
) -> Any:
"""Apply user-based search filters by joining the User table."""
stmt = stmt.join(User, model.user_id == User.id)
if search_kind == _UserSearchKind.USERNAME:
username = search_value.lstrip('@')
stmt = stmt.where(User.username.ilike(f'%{_escape_like(username)}%'))
elif search_kind == _UserSearchKind.TELEGRAM_ID:
stmt = stmt.where(User.telegram_id == int(search_value))
elif search_kind == _UserSearchKind.EMAIL:
stmt = stmt.where(User.email.ilike(f'%{_escape_like(search_value)}%'))
return stmt
async def _search_yookassa(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(YooKassaPayment).options(selectinload(YooKassaPayment.user)).order_by(desc(YooKassaPayment.created_at))
)
stmt = _apply_date_filter(stmt, YooKassaPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
stmt = stmt.where(YooKassaPayment.yookassa_payment_id.ilike(f'%{_escape_like(params.search)}%'))
else:
stmt = _apply_user_join_filter(stmt, YooKassaPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _metadata_is_balance(payment):
continue
record = _build_record(
PaymentMethod.YOOKASSA,
payment,
identifier=payment.yookassa_payment_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(getattr(payment, 'is_paid', False)),
)
if record:
records.append(record)
return records
async def _search_cryptobot(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.order_by(desc(CryptoBotPayment.created_at))
)
stmt = _apply_date_filter(stmt, CryptoBotPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
stmt = stmt.where(CryptoBotPayment.invoice_id.ilike(f'%{_escape_like(params.search)}%'))
else:
stmt = _apply_user_join_filter(stmt, CryptoBotPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
amount_kopeks = _parse_cryptobot_amount_kopeks(payment)
record = _build_record(
PaymentMethod.CRYPTOBOT,
payment,
identifier=payment.invoice_id,
amount_kopeks=amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _search_heleket(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(HeleketPayment).options(selectinload(HeleketPayment.user)).order_by(desc(HeleketPayment.created_at))
stmt = _apply_date_filter(stmt, HeleketPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
stmt = stmt.where(
or_(
HeleketPayment.uuid.ilike(f'%{_escape_like(params.search)}%'),
HeleketPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
)
)
else:
stmt = _apply_user_join_filter(stmt, HeleketPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.HELEKET,
payment,
identifier=payment.uuid,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_mulenpay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(MulenPayPayment).options(selectinload(MulenPayPayment.user)).order_by(desc(MulenPayPayment.created_at))
)
stmt = _apply_date_filter(stmt, MulenPayPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [MulenPayPayment.uuid.ilike(f'%{_escape_like(params.search)}%')]
# mulen_payment_id is Integer -- cast for ILIKE
if params.search.isdigit():
conditions.append(MulenPayPayment.mulen_payment_id == int(params.search))
else:
conditions.append(
cast(MulenPayPayment.mulen_payment_id, SAString).ilike(f'%{_escape_like(params.search)}%')
)
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, MulenPayPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.MULENPAY,
payment,
identifier=payment.uuid,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _search_pal24(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(Pal24Payment).options(selectinload(Pal24Payment.user)).order_by(desc(Pal24Payment.created_at))
stmt = _apply_date_filter(stmt, Pal24Payment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
Pal24Payment.bill_id.ilike(f'%{_escape_like(params.search)}%'),
Pal24Payment.order_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, Pal24Payment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.PAL24,
payment,
identifier=payment.bill_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_wata(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(WataPayment).options(selectinload(WataPayment.user)).order_by(desc(WataPayment.created_at))
stmt = _apply_date_filter(stmt, WataPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
WataPayment.payment_link_id.ilike(f'%{_escape_like(params.search)}%'),
WataPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, WataPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.WATA,
payment,
identifier=payment.payment_link_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_platega(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(PlategaPayment).options(selectinload(PlategaPayment.user)).order_by(desc(PlategaPayment.created_at))
stmt = _apply_date_filter(stmt, PlategaPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
PlategaPayment.correlation_id.ilike(f'%{_escape_like(params.search)}%'),
PlategaPayment.platega_transaction_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, PlategaPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
record = _build_record(
PaymentMethod.PLATEGA,
payment,
identifier=identifier,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_cloudpayments(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(CloudPaymentsPayment)
.options(selectinload(CloudPaymentsPayment.user))
.order_by(desc(CloudPaymentsPayment.created_at))
)
stmt = _apply_date_filter(stmt, CloudPaymentsPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [CloudPaymentsPayment.invoice_id.ilike(f'%{_escape_like(params.search)}%')]
# transaction_id_cp is BigInteger -- cast for ILIKE
if params.search.isdigit():
conditions.append(CloudPaymentsPayment.transaction_id_cp == int(params.search))
else:
conditions.append(
cast(CloudPaymentsPayment.transaction_id_cp, SAString).ilike(f'%{_escape_like(params.search)}%')
)
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, CloudPaymentsPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.CLOUDPAYMENTS,
payment,
identifier=payment.invoice_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _search_freekassa(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(FreekassaPayment)
.options(selectinload(FreekassaPayment.user))
.order_by(desc(FreekassaPayment.created_at))
)
stmt = _apply_date_filter(stmt, FreekassaPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
FreekassaPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
FreekassaPayment.freekassa_order_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, FreekassaPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.FREEKASSA,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _search_kassa_ai(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(KassaAiPayment).options(selectinload(KassaAiPayment.user)).order_by(desc(KassaAiPayment.created_at))
stmt = _apply_date_filter(stmt, KassaAiPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
KassaAiPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
KassaAiPayment.kassa_ai_order_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, KassaAiPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.KASSA_AI,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
)
if record:
records.append(record)
return records
async def _search_riopay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = select(RioPayPayment).options(selectinload(RioPayPayment.user)).order_by(desc(RioPayPayment.created_at))
stmt = _apply_date_filter(stmt, RioPayPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
RioPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
RioPayPayment.riopay_order_id.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, RioPayPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.RIOPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_severpay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(SeverPayPayment).options(selectinload(SeverPayPayment.user)).order_by(desc(SeverPayPayment.created_at))
)
stmt = _apply_date_filter(stmt, SeverPayPayment.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
conditions = [
SeverPayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
SeverPayPayment.severpay_id.ilike(f'%{_escape_like(params.search)}%'),
SeverPayPayment.severpay_uid.ilike(f'%{_escape_like(params.search)}%'),
]
stmt = stmt.where(or_(*conditions))
else:
stmt = _apply_user_join_filter(stmt, SeverPayPayment, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
record = _build_record(
PaymentMethod.SEVERPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _search_stars(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
stmt = (
select(Transaction)
.options(selectinload(Transaction.user))
.where(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method == PaymentMethod.TELEGRAM_STARS.value,
)
.order_by(desc(Transaction.created_at))
)
stmt = _apply_date_filter(stmt, Transaction.created_at, params.cutoff, params.upper_bound)
if params.search:
kind = _detect_user_search_kind(params.search)
if kind == _UserSearchKind.INVOICE:
stmt = stmt.where(Transaction.external_id.ilike(f'%{_escape_like(params.search)}%'))
else:
stmt = _apply_user_join_filter(stmt, Transaction, kind, params.search)
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for transaction in result.scalars().all():
record = _build_record(
PaymentMethod.TELEGRAM_STARS,
transaction,
identifier=transaction.external_id or str(transaction.id),
amount_kopeks=transaction.amount_kopeks,
status='paid' if transaction.is_completed else 'pending',
is_paid=bool(transaction.is_completed),
)
if record:
records.append(record)
return records
# ---------------------------------------------------------------------------
# Provider -> search function mapping
# ---------------------------------------------------------------------------
_PROVIDER_SEARCH_MAP: dict[PaymentMethod, Any] = {
PaymentMethod.YOOKASSA: _search_yookassa,
PaymentMethod.CRYPTOBOT: _search_cryptobot,
PaymentMethod.HELEKET: _search_heleket,
PaymentMethod.MULENPAY: _search_mulenpay,
PaymentMethod.PAL24: _search_pal24,
PaymentMethod.WATA: _search_wata,
PaymentMethod.PLATEGA: _search_platega,
PaymentMethod.CLOUDPAYMENTS: _search_cloudpayments,
PaymentMethod.FREEKASSA: _search_freekassa,
PaymentMethod.KASSA_AI: _search_kassa_ai,
PaymentMethod.RIOPAY: _search_riopay,
PaymentMethod.SEVERPAY: _search_severpay,
PaymentMethod.TELEGRAM_STARS: _search_stars,
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def search_payments(
db: AsyncSession,
params: SearchParams,
) -> tuple[list[PendingPayment], int]:
"""Search payments across all (or filtered) providers.
Returns:
Tuple of ``(page_items, total_count)`` where *page_items* is
a slice according to ``params.page`` / ``params.per_page``.
"""
# Determine which providers to query
if params.method_filter is not None:
search_fn = _PROVIDER_SEARCH_MAP.get(params.method_filter)
if search_fn is None:
return [], 0
provider_results: list[list[PendingPayment]] = [await search_fn(db, params)]
else:
provider_results = []
for search_fn in _PROVIDER_SEARCH_MAP.values():
provider_results.append(await search_fn(db, params))
# Flatten
all_records: list[PendingPayment] = []
for batch in provider_results:
all_records.extend(batch)
# Apply status filter in Python (status classification depends on provider logic)
if params.status_filter != StatusFilter.ALL:
all_records = [r for r in all_records if _classify_status(r) == params.status_filter]
# Sort globally by created_at desc
all_records.sort(key=lambda r: r.created_at, reverse=True)
total = len(all_records)
# Paginate
start_idx = (params.page - 1) * params.per_page
page_items = all_records[start_idx : start_idx + params.per_page]
return page_items, total
async def search_payments_stats(
db: AsyncSession,
params: SearchParams,
) -> SearchStats:
"""Compute aggregated statistics for the given search filters.
Pagination params are ignored -- stats cover the full result set.
"""
# Reuse the same search logic but force ALL statuses for counting
stats_params = SearchParams(
search=params.search,
status_filter=StatusFilter.ALL,
method_filter=params.method_filter,
period=params.period,
date_from=params.date_from,
date_to=params.date_to,
page=1,
per_page=MAX_PER_PAGE,
)
# Query all providers
if stats_params.method_filter is not None:
search_fn = _PROVIDER_SEARCH_MAP.get(stats_params.method_filter)
if search_fn is None:
return SearchStats()
all_records: list[PendingPayment] = await search_fn(db, stats_params)
else:
all_records = []
for search_fn in _PROVIDER_SEARCH_MAP.values():
all_records.extend(await search_fn(db, stats_params))
# Classify
pending_count = 0
paid_count = 0
cancelled_count = 0
method_counter: Counter[str] = Counter()
for record in all_records:
status = _classify_status(record)
if status == StatusFilter.PAID:
paid_count += 1
elif status == StatusFilter.CANCELLED:
cancelled_count += 1
else:
pending_count += 1
method_counter[record.method.value] += 1
return SearchStats(
total=len(all_records),
pending=pending_count,
paid=paid_count,
cancelled=cancelled_count,
by_method=dict(method_counter),
)
+91 -3
View File
@@ -34,6 +34,7 @@ from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
from app.services.payment.freekassa import FreekassaPaymentMixin
from app.services.payment.kassa_ai import KassaAiPaymentMixin
from app.services.payment.riopay import RioPayPaymentMixin
from app.services.payment.severpay import SeverPayPaymentMixin
from app.services.platega_service import PlategaService
from app.services.wata_service import WataService
from app.services.yookassa_service import YooKassaService
@@ -296,6 +297,41 @@ async def update_cloudpayments_payment(*args, **kwargs):
return await cloudpayments_crud.update_cloudpayments_payment(*args, **kwargs)
async def create_severpay_payment(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.create_severpay_payment(*args, **kwargs)
async def get_severpay_payment_by_order_id(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.get_severpay_payment_by_order_id(*args, **kwargs)
async def get_severpay_payment_by_severpay_id(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.get_severpay_payment_by_severpay_id(*args, **kwargs)
async def get_severpay_payment_by_id(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.get_severpay_payment_by_id(*args, **kwargs)
async def get_severpay_payment_by_id_for_update(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.get_severpay_payment_by_id_for_update(*args, **kwargs)
async def update_severpay_payment_status(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.update_severpay_payment_status(*args, **kwargs)
async def link_severpay_payment_to_transaction(*args, **kwargs):
severpay_crud = import_module('app.database.crud.severpay')
return await severpay_crud.link_severpay_payment_to_transaction(*args, **kwargs)
# Mapping from model_name to getter function name for providers
# where it differs from the standard get_{model_name}_payment_by_id pattern.
_GETTER_OVERRIDES: dict[str, str] = {
@@ -318,6 +354,7 @@ class PaymentService(
FreekassaPaymentMixin,
KassaAiPaymentMixin,
RioPayPaymentMixin,
SeverPayPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -666,23 +703,74 @@ class PaymentService(
return None
# --- KassaAI ----------------------------------------------------------
if payment_method == 'kassa_ai':
if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'):
if not settings.is_kassa_ai_enabled():
logger.warning('KassaAI is not enabled, cannot create guest payment')
return None
from app.services.kassa_ai_service import KASSA_AI_SUB_METHODS
sub = KASSA_AI_SUB_METHODS.get(payment_method)
ps_id = sub['payment_system_id'] if sub else None
result = await self.create_kassa_ai_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
payment_system_id=ps_id,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'kassa_ai')
await _patch_guest_metadata(result['local_payment_id'], payment_method)
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'kassa_ai',
'provider': payment_method,
}
return None
# --- RioPay -----------------------------------------------------------
if payment_method == 'riopay':
if not settings.is_riopay_enabled():
logger.warning('RioPay is not enabled, cannot create guest payment')
return None
result = await self.create_riopay_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
success_url=return_url,
fail_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'riopay')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('riopay_order_id') or result.get('order_id'),
'provider': 'riopay',
}
return None
# --- SeverPay ---------------------------------------------------------
if payment_method == 'severpay':
if not settings.is_severpay_enabled():
logger.warning('SeverPay is not enabled, cannot create guest payment')
return None
result = await self.create_severpay_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'severpay')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('severpay_id') or result.get('order_id'),
'provider': 'severpay',
}
return None
@@ -27,6 +27,8 @@ from app.database.models import (
Pal24Payment,
PaymentMethod,
PlategaPayment,
RioPayPayment,
SeverPayPayment,
Transaction,
TransactionType,
User,
@@ -72,6 +74,8 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.CLOUDPAYMENTS,
PaymentMethod.FREEKASSA,
PaymentMethod.KASSA_AI,
PaymentMethod.RIOPAY,
PaymentMethod.SEVERPAY,
}
)
@@ -90,6 +94,8 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
# Payments are processed via webhook (wata_webhook.py).
PaymentMethod.FREEKASSA,
PaymentMethod.KASSA_AI,
PaymentMethod.RIOPAY,
PaymentMethod.SEVERPAY,
}
)
@@ -115,6 +121,10 @@ def method_display_name(method: PaymentMethod) -> str:
return 'Freekassa'
if method == PaymentMethod.KASSA_AI:
return settings.get_kassa_ai_display_name()
if method == PaymentMethod.RIOPAY:
return settings.get_riopay_display_name()
if method == PaymentMethod.SEVERPAY:
return settings.get_severpay_display_name()
if method == PaymentMethod.TELEGRAM_STARS:
return 'Telegram Stars'
return method.value
@@ -141,6 +151,10 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
return settings.is_freekassa_enabled()
if method == PaymentMethod.KASSA_AI:
return settings.is_kassa_ai_enabled()
if method == PaymentMethod.RIOPAY:
return settings.is_riopay_enabled()
if method == PaymentMethod.SEVERPAY:
return settings.is_severpay_enabled()
return False
@@ -376,6 +390,20 @@ def _is_kassa_ai_pending(payment: KassaAiPayment) -> bool:
return status in {'pending', 'created', 'processing'}
def _is_severpay_pending(payment: SeverPayPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending', 'processing'}
def _is_riopay_pending(payment: RioPayPayment) -> bool:
if payment.is_paid:
return False
status = (payment.status or '').lower()
return status in {'pending'}
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
payload = payment.payload or ''
match = re.search(r'_(\d+)$', payload)
@@ -693,6 +721,58 @@ async def _fetch_kassa_ai_payments(db: AsyncSession, cutoff: datetime) -> list[P
return records
async def _fetch_riopay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(RioPayPayment)
.options(selectinload(RioPayPayment.user))
.where(RioPayPayment.created_at >= cutoff)
.order_by(desc(RioPayPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_riopay_pending(payment):
continue
record = _build_record(
PaymentMethod.RIOPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _fetch_severpay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(SeverPayPayment)
.options(selectinload(SeverPayPayment.user))
.where(SeverPayPayment.created_at >= cutoff)
.order_by(desc(SeverPayPayment.created_at))
)
result = await db.execute(stmt)
records: list[PendingPayment] = []
for payment in result.scalars().all():
if not _is_severpay_pending(payment):
continue
record = _build_record(
PaymentMethod.SEVERPAY,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if record:
records.append(record)
return records
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
stmt = (
select(Transaction)
@@ -740,6 +820,8 @@ async def list_recent_pending_payments(
await _fetch_cloudpayments_payments(db, cutoff),
await _fetch_freekassa_payments(db, cutoff),
await _fetch_kassa_ai_payments(db, cutoff),
await _fetch_riopay_payments(db, cutoff),
await _fetch_severpay_payments(db, cutoff),
await _fetch_stars_transactions(db, cutoff),
)
@@ -908,6 +990,36 @@ async def get_payment_record(
is_paid=bool(payment.is_paid),
)
if method == PaymentMethod.RIOPAY:
payment = await db.get(RioPayPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if method == PaymentMethod.SEVERPAY:
payment = await db.get(SeverPayPayment, local_payment_id)
if not payment:
return None
await db.refresh(payment, attribute_names=['user'])
return _build_record(
method,
payment,
identifier=payment.order_id,
amount_kopeks=payment.amount_kopeks,
status=payment.status or '',
is_paid=bool(payment.is_paid),
expires_at=getattr(payment, 'expires_at', None),
)
if method == PaymentMethod.TELEGRAM_STARS:
transaction = await db.get(Transaction, local_payment_id)
if not transaction:
@@ -966,6 +1078,20 @@ async def run_manual_check(
elif method == PaymentMethod.KASSA_AI:
result = await payment_service.get_kassa_ai_payment_status(db, local_payment_id)
payment = result.get('payment') if result else None
elif method == PaymentMethod.SEVERPAY:
severpay_payment = await db.get(SeverPayPayment, local_payment_id)
if severpay_payment:
result = await payment_service.check_severpay_payment_status(db, severpay_payment.order_id)
payment = result.get('payment') if result else None
else:
payment = None
elif method == PaymentMethod.RIOPAY:
riopay_payment = await db.get(RioPayPayment, local_payment_id)
if riopay_payment:
result = await payment_service.check_riopay_payment_status(db, riopay_payment.order_id)
payment = result.get('payment') if result else None
else:
payment = None
else:
logger.warning('Manual check requested for unsupported method', method=method)
return None
+202
View File
@@ -0,0 +1,202 @@
"""Сервис для работы с API SeverPay (severpay.io)."""
import hashlib
import hmac
import json
import uuid
from typing import Any
import aiohttp
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
API_BASE_URL = 'https://severpay.io/api/merchant'
class SeverPayAPIError(Exception):
"""Ошибка API SeverPay."""
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f'SeverPay API error ({status_code}): {message}')
class SeverPayService:
"""Сервис для работы с API SeverPay."""
def __init__(self):
self._session: aiohttp.ClientSession | None = None
@property
def mid(self) -> int | None:
return settings.SEVERPAY_MID
@property
def token(self) -> str:
return settings.SEVERPAY_TOKEN or ''
async def _get_session(self) -> aiohttp.ClientSession:
"""Возвращает переиспользуемую HTTP-сессию."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
)
return self._session
async def close(self) -> None:
"""Закрывает HTTP-сессию."""
if self._session and not self._session.closed:
await self._session.close()
self._session = None
def _sign_request(self, body: dict[str, Any]) -> dict[str, Any]:
"""Генерирует salt, сортирует, подписывает и возвращает body с sign."""
body['mid'] = self.mid
body['salt'] = uuid.uuid4().hex
# Убираем sign если он есть (для пересчёта)
body.pop('sign', None)
sorted_body = dict(sorted(body.items()))
sign = hmac.new(
self.token.encode('utf-8'),
json.dumps(sorted_body, ensure_ascii=False, separators=(',', ':')).encode('utf-8'),
hashlib.sha256,
).hexdigest()
body['sign'] = sign
return body
async def create_payment(
self,
*,
order_id: str,
amount: float,
currency: str = 'RUB',
client_email: str = '',
client_id: str = '',
url_return: str | None = None,
lifetime: int | None = None,
) -> dict[str, Any]:
"""
Создает платеж через API SeverPay.
POST /payin/create
"""
payload: dict[str, Any] = {
'order_id': order_id,
'amount': amount,
'currency': currency,
'client_email': client_email,
'client_id': client_id,
}
if url_return:
payload['url_return'] = url_return
if lifetime is not None:
payload['lifetime'] = lifetime
payload = self._sign_request(payload)
logger.info(
'SeverPay API create_payment',
order_id=order_id,
amount=amount,
currency=currency,
)
try:
session = await self._get_session()
async with session.post(
f'{API_BASE_URL}/payin/create',
json=payload,
headers={'Content-Type': 'application/json'},
) as response:
data = await response.json(content_type=None)
if response.status == 200 and data.get('status') is True:
logger.info(
'SeverPay API payment created',
order_id=order_id,
severpay_id=data.get('data', {}).get('id'),
)
return data.get('data', {})
error_msg = data.get('msg') or str(data)
logger.error(
'SeverPay create_payment error',
status_code=response.status,
error_msg=error_msg,
)
raise SeverPayAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('SeverPay API connection error', error=e)
raise
async def get_payment(self, payment_id: str) -> dict[str, Any]:
"""
Получает информацию о платеже.
POST /payin/get
"""
payload: dict[str, Any] = {
'id': payment_id,
}
payload = self._sign_request(payload)
logger.info('SeverPay get_payment', payment_id=payment_id)
try:
session = await self._get_session()
async with session.post(
f'{API_BASE_URL}/payin/get',
json=payload,
headers={'Content-Type': 'application/json'},
) as response:
data = await response.json(content_type=None)
if response.status == 200 and data.get('status') is True:
return data.get('data', {})
error_msg = data.get('msg') or str(data)
logger.error(
'SeverPay get_payment error',
status_code=response.status,
error_msg=error_msg,
)
raise SeverPayAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('SeverPay API connection error', error=e)
raise
def verify_webhook_signature(self, raw_body: bytes) -> bool:
"""Верификация подписи webhook SeverPay.
Из body убираем sign, сортируем оставшиеся ключи, считаем HMAC-SHA256.
"""
try:
payload = json.loads(raw_body)
received_sign = payload.get('sign')
if not received_sign:
logger.warning('SeverPay webhook: отсутствует sign в теле')
return False
sorted_body = dict(sorted((k, v) for k, v in payload.items() if k != 'sign'))
expected = hmac.new(
self.token.encode('utf-8'),
json.dumps(sorted_body, ensure_ascii=False, separators=(',', ':')).encode('utf-8'),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, received_sign)
except Exception as e:
logger.error('SeverPay webhook verify error', error=e)
return False
# Singleton instance
severpay_service = SeverPayService()
@@ -460,6 +460,7 @@ async def _auto_extend_subscription(
updated_subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа' if is_tariff_change else 'продление подписки',
sync_squads=is_tariff_change,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
@@ -1091,6 +1091,7 @@ class MiniAppSubscriptionPurchaseService:
subscription,
reset_traffic=True,
reset_reason='miniapp purchase',
sync_squads=True,
)
else:
await subscription_service.create_remnawave_user(
+2
View File
@@ -90,6 +90,7 @@ class BotConfigurationService:
'FREEKASSA': '💳 Freekassa',
'KASSA_AI': '💳 KassaAI',
'RIOPAY': '💳 RioPay',
'SEVERPAY': '💳 SeverPay',
'YOOKASSA': '🟣 YooKassa',
'PLATEGA': '💳 {platega_name}',
'TRIBUTE': '🎁 Tribute',
@@ -350,6 +351,7 @@ class BotConfigurationService:
'FREEKASSA_': 'FREEKASSA',
'KASSA_AI_': 'KASSA_AI',
'RIOPAY_': 'RIOPAY',
'SEVERPAY_': 'SEVERPAY',
'PLATEGA_': 'PLATEGA',
'MULENPAY_': 'MULENPAY',
'PAL24_': 'PAL24',
+14 -3
View File
@@ -96,12 +96,14 @@ class TributeService:
user_telegram_id = payment_data['user_id']
amount_kopeks = payment_data['amount_kopeks']
payment_id = payment_data['payment_id']
trb_user_id = payment_data.get('trb_user_id')
logger.info(
'Обрабатываем успешный Tribute платеж: user_telegram_id=, amount=, payment_id',
'Обрабатываем успешный Tribute платеж: user_telegram_id=, amount=, payment_id=, trb_user_id=',
user_telegram_id=user_telegram_id,
amount_kopeks=amount_kopeks,
payment_id=payment_id,
trb_user_id=trb_user_id,
)
async for session in get_db():
@@ -218,6 +220,7 @@ class TributeService:
try:
user_id = payment_data['user_id']
payment_id = payment_data['payment_id']
trb_user_id = payment_data.get('trb_user_id')
async for session in get_db():
transaction = await get_transaction_by_external_id(
@@ -230,7 +233,11 @@ class TributeService:
await self._send_failure_notification(user_id)
logger.info('Обработан неудачный Tribute платеж для пользователя', user_id=user_id)
logger.info(
'Обработан неудачный Tribute платеж для пользователя',
user_id=user_id,
trb_user_id=trb_user_id,
)
break
except Exception as e:
@@ -241,6 +248,7 @@ class TributeService:
user_id = refund_data['user_id']
amount_kopeks = refund_data['amount_kopeks']
payment_id = refund_data['payment_id']
trb_user_id = refund_data.get('trb_user_id')
async for session in get_db():
await create_transaction(
@@ -269,7 +277,10 @@ class TributeService:
await self._send_refund_notification(user_id, amount_kopeks)
logger.info(
'Обработан возврат Tribute: ₽ для пользователя', amount_kopeks=amount_kopeks / 100, user_id=user_id
'Обработан возврат Tribute: ₽ для пользователя',
amount_kopeks=amount_kopeks / 100,
user_id=user_id,
trb_user_id=trb_user_id,
)
break
+108
View File
@@ -0,0 +1,108 @@
"""Web auth deep-link token service.
Allows cabinet frontend to authenticate users via Telegram bot deep link
when oauth.telegram.org is blocked/unreachable.
Flow:
1. Frontend requests token: POST /cabinet/auth/deeplink/request
2. User clicks t.me/bot?start=webauth_TOKEN
3. Bot receives /start, links token to Telegram user
4. Frontend polls: POST /cabinet/auth/deeplink/poll -> gets JWT tokens
"""
import secrets
from datetime import UTC, datetime
from typing import Any
import structlog
from app.utils.cache import cache, cache_key
logger = structlog.get_logger(__name__)
WEB_AUTH_TOKEN_TTL = 300 # 5 minutes
WEB_AUTH_LINKED_TTL = 120 # seconds — poll window after token is linked
WEB_AUTH_TOKEN_MIN_LENGTH = 16
WEB_AUTH_PREFIX = 'web_auth'
async def create_web_auth_token() -> str:
"""Generate a web auth token and store it in Redis (pending state).
Returns the raw token string (URL-safe, 24 bytes of entropy).
"""
token = secrets.token_urlsafe(24)
key = cache_key(WEB_AUTH_PREFIX, token)
value: dict[str, Any] = {
'status': 'pending',
'created_at': datetime.now(UTC).isoformat(),
}
stored = await cache.set(key, value, expire=WEB_AUTH_TOKEN_TTL)
if not stored:
logger.error('Failed to store web auth token in Redis')
raise RuntimeError('Failed to create web auth token')
logger.debug('Web auth token created', token_prefix=token[:8])
return token
async def link_web_auth_token(token: str, telegram_id: int, user_id: int) -> bool:
"""Link a web auth token to a Telegram user (called by bot on /start).
Atomically takes the token (GETDEL) so only one caller can win the race.
Returns True if token was found and linked, False if expired/invalid.
"""
key = cache_key(WEB_AUTH_PREFIX, token)
# Atomically take the token — only one concurrent caller can succeed
data: Any = await cache.getdel(key)
if not data or not isinstance(data, dict):
logger.warning('Web auth token not found or expired', token_prefix=token[:8])
return False
if data.get('status') != 'pending':
logger.warning('Web auth token already used', token_prefix=token[:8])
return False
# Update token with user info
data['status'] = 'linked'
data['telegram_id'] = telegram_id
data['user_id'] = user_id
data['linked_at'] = datetime.now(UTC).isoformat()
# Re-store with reduced TTL (only needs to survive the poll window)
await cache.set(key, data, expire=WEB_AUTH_LINKED_TTL)
logger.info('Web auth token linked', token_prefix=token[:8], telegram_id=telegram_id)
return True
async def poll_web_auth_token(token: str) -> dict[str, Any] | None:
"""Poll for web auth token status (non-destructive).
Returns:
- None if token doesn't exist or is expired
- dict with status='pending' if not yet linked
- dict with status='linked' and user_id/telegram_id if linked
"""
key = cache_key(WEB_AUTH_PREFIX, token)
data: Any = await cache.get(key)
if not data or not isinstance(data, dict):
return None
return data
async def consume_web_auth_token(token: str) -> dict[str, Any] | None:
"""Atomically get and delete a web auth token.
Used after successful poll to prevent token reuse.
Returns the token data or None.
"""
key = cache_key(WEB_AUTH_PREFIX, token)
data = await cache.getdel(key)
if not data or not isinstance(data, dict):
return None
return data
+27 -1
View File
@@ -5573,6 +5573,30 @@ async def update_subscription_servers_endpoint(
servers_discount,
)
# Enforce promo group authorization: drop any UUID not in the user's allowed set.
# Prevents users from retaining servers removed from their promo group.
authorized_servers = await get_available_server_squads(db, promo_group_id=getattr(user, 'promo_group_id', None))
authorized_uuids = {s.squad_uuid for s in authorized_servers}
selected_order = [uuid for uuid in selected_order if uuid in authorized_uuids]
if not selected_order:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail={
'code': 'validation_error',
'message': 'At least one authorized server must be selected',
},
)
# Recompute added/removed after authorization filter
selected_set = set(selected_order)
added = [uuid for uuid in selected_order if uuid not in current_set]
removed = [uuid for uuid in current_squads if uuid not in selected_set]
if not added and not removed:
return MiniAppSubscriptionUpdateResponse(
success=True,
message='No changes',
)
invalid_servers = [uuid for uuid in selected_order if uuid not in catalog]
if invalid_servers:
raise HTTPException(
@@ -5691,7 +5715,7 @@ async def update_subscription_servers_endpoint(
pass
service = SubscriptionService()
await service.update_remnawave_user(db, subscription)
await service.update_remnawave_user(db, subscription, sync_squads=True)
await with_admin_notification_service(
lambda service: service.send_subscription_update_notification(
@@ -6500,6 +6524,7 @@ async def purchase_tariff_endpoint(
subscription,
reset_traffic=True,
reset_reason='покупка тарифа (miniapp)',
sync_squads=True,
)
# Сохраняем корзину для автопродления
@@ -6873,6 +6898,7 @@ async def switch_tariff_endpoint(
subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа',
sync_squads=True,
)
except Exception as e:
logger.error('Ошибка синхронизации с RemnaWave при смене тарифа', error=e)
+47
View File
@@ -1175,6 +1175,52 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True
# SeverPay webhook
if settings.is_severpay_enabled():
@router.get(settings.SEVERPAY_WEBHOOK_PATH)
async def severpay_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'severpay_webhook',
'enabled': settings.is_severpay_enabled(),
}
)
@router.post(settings.SEVERPAY_WEBHOOK_PATH)
async def severpay_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('SeverPay webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
from app.services.severpay_service import severpay_service
if not severpay_service.verify_webhook_signature(raw_body):
logger.warning('SeverPay webhook: invalid signature')
return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN)
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_severpay_webhook',
)
if not success:
logger.error(
'SeverPay webhook processing failed',
data=payload.get('data'),
)
except Exception as e:
logger.exception('SeverPay webhook processing error', error=e)
# Always return 200 {"status": true} — SeverPay retries on any non-200
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
routes_registered = True
if routes_registered:
@router.get('/health/payment-webhooks')
@@ -1194,6 +1240,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
'freekassa_enabled': settings.is_freekassa_enabled(),
'kassa_ai_enabled': settings.is_kassa_ai_enabled(),
'riopay_enabled': settings.is_riopay_enabled(),
'severpay_enabled': settings.is_severpay_enabled(),
}
)
@@ -0,0 +1,29 @@
"""make riopay_payments.user_id nullable for guest purchases
Revision ID: 0039
Revises: 0038
Create Date: 2026-03-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0039'
down_revision: Union[str, None] = '0038'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.alter_column('riopay_payments', 'user_id', existing_type=sa.Integer(), nullable=True)
op.drop_constraint('riopay_payments_user_id_fkey', 'riopay_payments', type_='foreignkey')
op.create_foreign_key(None, 'riopay_payments', 'users', ['user_id'], ['id'], ondelete='SET NULL')
def downgrade() -> None:
op.drop_constraint(None, 'riopay_payments', type_='foreignkey')
op.create_foreign_key('riopay_payments_user_id_fkey', 'riopay_payments', 'users', ['user_id'], ['id'])
op.alter_column('riopay_payments', 'user_id', existing_type=sa.Integer(), nullable=False)
@@ -0,0 +1,46 @@
"""add severpay_payments table
Revision ID: 0040
Revises: 0039
Create Date: 2026-03-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0040'
down_revision: Union[str, None] = '0039'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'severpay_payments',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
sa.Column('severpay_id', sa.String(64), unique=True, nullable=True, index=True),
sa.Column('severpay_uid', sa.String(64), unique=True, nullable=True, index=True),
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false')),
sa.Column('payment_url', sa.Text(), nullable=True),
sa.Column('payment_method', sa.String(32), nullable=True),
sa.Column('metadata_json', sa.JSON(), nullable=True),
sa.Column('callback_payload', sa.JSON(), nullable=True),
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True),
)
def downgrade() -> None:
op.drop_table('severpay_payments')
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.32.4"
version = "3.34.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
Generated
+1 -1
View File
@@ -1115,7 +1115,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.32.2"
version = "3.32.4"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },