Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1e015fb6e | |||
| 0730173e5b | |||
| 968f18b6e4 | |||
| 7eea35f111 | |||
| 6920e3a0fb | |||
| fddf8ef5eb | |||
| ad268329be | |||
| 1804c28f05 | |||
| f967c29bd7 | |||
| 06a00e367c | |||
| abaf279533 | |||
| 6d4430c639 | |||
| 911df7a05c | |||
| f106ce8216 | |||
| dcff6947dd | |||
| 4966e39eb9 | |||
| 4abb8cb1a3 | |||
| 04f4e6bf6e | |||
| 3d1fbc70f8 | |||
| 3089c1704b | |||
| 20eff6170f | |||
| 038c34e52a | |||
| 77f1a764d5 | |||
| 641da949a9 | |||
| 3f0b24c1ec | |||
| c34fdd10a0 | |||
| 72b5305b87 | |||
| 099391eb5f | |||
| 322d457652 | |||
| 5b722c5210 | |||
| a80a85c2a4 | |||
| f84885cc8a | |||
| 12898b7eab | |||
| 20a6fa1bcf | |||
| 94199413c2 | |||
| 826accba51 | |||
| 1cc687ac15 | |||
| e4bb0430fb | |||
| 603b9a1f46 | |||
| 557af5994d | |||
| 808818ca2b | |||
| b563796091 | |||
| 6a3e9d92b5 | |||
| cda2392411 | |||
| 04419fdff7 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.32.4"
|
||||
".": "3.34.0"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ============
|
||||
|
||||
@@ -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}'"
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -25,6 +25,8 @@ REAL_PAYMENT_METHODS = [
|
||||
PaymentMethod.CLOUDPAYMENTS.value,
|
||||
PaymentMethod.FREEKASSA.value,
|
||||
PaymentMethod.KASSA_AI.value,
|
||||
PaymentMethod.RIOPAY.value,
|
||||
PaymentMethod.SEVERPAY.value,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
|
||||
Vendored
+6
-12
@@ -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
|
||||
|
||||
Vendored
+13
-4
@@ -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"}',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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_'))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ===')
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user