Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df9985802b | |||
| c66849db10 | |||
| 8a1da85f3e | |||
| 0335f40b47 | |||
| 8ac1183670 | |||
| bcc761f9d3 | |||
| 1eb4e18c17 | |||
| 056c13bc23 | |||
| 2bdb7643f8 | |||
| 8b8f1b91f3 | |||
| 5ed2f0c958 | |||
| 454dc9321b | |||
| de91d3282f | |||
| e0bedc8e78 | |||
| 4a48818bc3 | |||
| ff7388766c | |||
| 6b36dc4df1 | |||
| fe847e35f0 | |||
| 4c2cb63cf9 | |||
| d3c994083e | |||
| cce3b0c13b | |||
| 6c208581d9 | |||
| c307278231 | |||
| 9eab802000 | |||
| 13ea3768b5 | |||
| ed5a92ab96 | |||
| 48265f1cd4 | |||
| 3b9568fcc1 | |||
| 79cfcbcece | |||
| 6d5aceb4ca | |||
| 905dbcc779 | |||
| f33dfdf031 | |||
| 59cd74d307 | |||
| 90209ebef1 | |||
| ab43e74ab7 | |||
| 4244962337 | |||
| ba79d03e38 | |||
| 8e4e2ddd1a | |||
| 38853cdd5a | |||
| f837c0c244 | |||
| 8a7b9cc651 | |||
| 3bf31055e7 | |||
| 3c5bf4fa22 | |||
| 4990ddf9e4 | |||
| de00612965 |
@@ -522,6 +522,7 @@ NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токен
|
||||
NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
|
||||
# NALOGO_PROXY_URL=socks5://127.0.0.1:1080 # SOCKS прокси для nalog.ru (если не задан — используется PROXY_URL)
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.37.0"
|
||||
".": "3.41.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,75 @@
|
||||
# Changelog
|
||||
|
||||
## [3.41.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.40.0...v3.41.0) (2026-03-22)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add subscription status to referral network graph nodes ([de91d32](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de91d3282ffa15c0cec60c0d62871d39e7ee4c05))
|
||||
* add total subscription revenue to referral network stats ([2bdb764](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2bdb7643f8fd142e99caee0fe989348161377348))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add abs() to all remaining subscription payment sum queries ([1eb4e18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1eb4e18c1776b2265a48e0b923a0ca4ee057d912))
|
||||
* add missing total_subscription_revenue_kopeks in scoped graph early return ([bcc761f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bcc761f9d3f673bd2b404adf817762058d8e0df4))
|
||||
* consider subscription status field in network graph ([454dc93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454dc9321bb9405c5ff0ff559ae4ced15533f3af))
|
||||
* superadmin role managed exclusively via env config ([e0bedc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0bedc8e780a2f91509517110639773e90bb6125))
|
||||
* treat expired and limited subscription statuses as inactive in referral network graph ([5ed2f0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ed2f0c95842a43ab57220dc05ca346748bd6adb))
|
||||
* use abs() for subscription payment amounts in referral network ([056c13b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/056c13bc23e6737f44bbcb802a66b643349f75a9))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* extract _compute_subscription_status shared helper ([8b8f1b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b8f1b91f37f829528f785a40e3a9cb98c85e043))
|
||||
|
||||
## [3.40.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.39.0...v3.40.0) (2026-03-22)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* allow inactive tariffs for trial subscription activation ([cce3b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cce3b0c13bcbf0b567bd4dcf2670973382e7cab0))
|
||||
* custom broadcast buttons and fix home button to use bot menu ([13ea376](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/13ea3768b516337c4e0320120bc60a9acb27a16b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* accept stale Telegram initData to prevent MiniApp auth failures ([4c2cb63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2cb63cf9f71fb392c3723a99e88ca3d02b127d))
|
||||
* daily subscription pause not persisting in cabinet and miniapp ([d3c9940](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c994083e3b054d02d4911172968c914724d051))
|
||||
* handle spurious user.deleted webhooks — preserve active subscriptions and prevent orphaned panel users ([9eab802](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9eab80200006e576967204b52f90bf9866875917))
|
||||
* prevent MESSAGE_TOO_LONG in promo groups list ([c307278](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30727823169159b4b6b61f54897b209ced8dfd2))
|
||||
* referral system — self-referral protection, race condition fix, deleted user re-registration ([ed5a92a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed5a92ab966dac54c15217050eae87f4b05eed62))
|
||||
* sanitize email dots in RemnaWave username generation ([6c20858](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c208581d936f5ab7d6b978baafd50881b8ce9f1))
|
||||
* send DISABLED instead of EXPIRED status to RemnaWave API ([79cfcbc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79cfcbcece3938f2daa83206f96ec1bffd0857e0))
|
||||
|
||||
## [3.39.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.38.0...v3.39.0) (2026-03-21)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add NaloGO fiscal receipts for code-only gift purchases ([90209eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90209ebef1a872665e622124a1898d52eff398e7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add NaloGO fiscal receipt creation for landing page purchases ([4244962](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/424496233773b4cee4e389a1172e95208b3afeaf))
|
||||
* manual admin top-ups missing from sales statistics ([ab43e74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ab43e74ab7484f8d3517f91e366ea395e1944b99))
|
||||
* skip non-JSON payload rows in cryptobot payment index and query ([ba79d03](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba79d03e389afed972296fe2bc05104aa6b883f3))
|
||||
|
||||
## [3.38.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.37.0...v3.38.0) (2026-03-21)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add SOCKS proxy support for nalogo (tax service) module ([3c5bf4f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3c5bf4fa22d1cdf144269f4e6ab32a4523c8f1f3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add diagnostic payload logging in create_user error path ([4990ddf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4990ddf9e46495b65fc3638ea8d6bed0cbe6b857))
|
||||
* retry Remnawave API calls without externalSquadUuid on A039 FK violation ([de00612](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de006129657ce3dac2b1f2fc0ab1b91e23e44241))
|
||||
* sanitize proxy credentials in all nalogo error paths ([3bf3105](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3bf31055e71ff64e6a6d94486bb7f7775ac7dc91))
|
||||
|
||||
## [3.37.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.36.1...v3.37.0) (2026-03-21)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.37.0" # x-release-please-version
|
||||
ARG VERSION="v3.41.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -41,21 +41,21 @@ Bedolaga — полнофункциональная платформа для п
|
||||
|
||||
### 📦 Подписки и тарифы
|
||||
|
||||
- 🎯 Гибкие тарифные планы (от 14 дней до года)
|
||||
- 📊 Трафик: безлимит, фиксированный лимит или пакеты
|
||||
- 📱 Управление устройствами (1–20 на подписку)
|
||||
- 🌍 Автовыбор сервера или ручной выбор
|
||||
- 🆓 Пробный период с конвертацией в платный
|
||||
- 🎯 Гибкие тарифные планы (от X дней до X дней)
|
||||
- 📊 Трафик: безлимит, фиксированный лимит или пакеты с возможностью докупки
|
||||
- 📱 Управление устройствами (1–20 на подписку) или отключение лимитов
|
||||
- 🌍 Автовыбор сервера(Тарифы) или ручной выбор(Конфигуратор подписки - с возможностью докупки)
|
||||
- 🆓 Пробный период(Возможен платный) с конвертацией в платный
|
||||
- 🛒 Умная корзина — сохраняет выбор при недостатке баланса
|
||||
- 🔄 Автопродление за 3 дня до окончания
|
||||
- 🎁 Подарочные подписки
|
||||
- 🎁 Подарочные подписки и конфигурируемые лендинги для быстрой продажи в вебе без авторизации
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### 💳 Платежи
|
||||
|
||||
- 🏦 **14 платёжных провайдеров** одновременно
|
||||
- 🏦 **15 платёжных провайдеров** одновременно
|
||||
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
|
||||
- ⚡ Автопокупка подписки после пополнения
|
||||
- 💾 Рекуррентные платежи (сохранённые карты)
|
||||
@@ -72,11 +72,13 @@ Bedolaga — полнофункциональная платформа для п
|
||||
|
||||
- 🏷 Промокоды (деньги, дни подписки, триалы)
|
||||
- 👥 Реферальная программа с выводом средств
|
||||
- 👥 Партнерская система
|
||||
- 📨 Рассылки по сегментам пользователей
|
||||
- 🌐 Кастомные лендинги с аналитикой
|
||||
- 🎮 Конкурсы и ежедневные игры с призами
|
||||
- 🎯 Персональные предложения и скидки
|
||||
- 📈 Маркетинговые кампании с трекингом
|
||||
- 🌐 Обязательная мультиподписка на каналы с возможностью автоотключения подписки - при отписки от канала
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
@@ -91,6 +93,9 @@ Bedolaga — полнофункциональная платформа для п
|
||||
- 📡 Мониторинг трафика и аномалий
|
||||
- 🤝 Партнёрская программа
|
||||
- 🔐 RBAC: роли и гранулярные права доступа
|
||||
- 📈 Детальная отчетность с возможностью визуализации Реф сети
|
||||
- 🔐 Блокировка юзеров из общего черного списка
|
||||
И многое др...
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+9
-5
@@ -101,12 +101,16 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
bot = create_bot()
|
||||
|
||||
proxy_url = settings.get_proxy_url()
|
||||
if proxy_url:
|
||||
from urllib.parse import urlparse
|
||||
nalogo_proxy_url = settings.get_nalogo_proxy_url()
|
||||
|
||||
parsed = urlparse(proxy_url)
|
||||
masked = f'{parsed.scheme}://***@{parsed.hostname}:{parsed.port}' if parsed.username else proxy_url
|
||||
logger.info('Proxy configured', proxy_url=masked)
|
||||
if proxy_url or nalogo_proxy_url:
|
||||
from app.utils.proxy import mask_proxy_url
|
||||
|
||||
if proxy_url:
|
||||
logger.info('Proxy configured', proxy_url=mask_proxy_url(proxy_url))
|
||||
if nalogo_proxy_url:
|
||||
source = 'NALOGO_PROXY_URL' if settings.NALOGO_PROXY_URL else 'PROXY_URL (fallback)'
|
||||
logger.info('Nalogo proxy configured', proxy_url=mask_proxy_url(nalogo_proxy_url), source=source)
|
||||
|
||||
maintenance_service.set_bot(bot)
|
||||
logger.info('Бот установлен в maintenance_service')
|
||||
|
||||
@@ -49,7 +49,17 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
|
||||
logger.warning(
|
||||
'Telegram widget auth rejected: too old',
|
||||
age_hours=round(age / 3600, 1),
|
||||
max_age_hours=round(max_age_seconds / 3600, 1),
|
||||
)
|
||||
return False
|
||||
if age > 86400:
|
||||
logger.info(
|
||||
'Telegram widget auth accepted with stale auth_date',
|
||||
age_hours=round(age / 3600, 1),
|
||||
)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return False
|
||||
|
||||
@@ -96,7 +106,17 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
|
||||
logger.warning(
|
||||
'Telegram initData rejected: too old',
|
||||
age_hours=round(age / 3600, 1),
|
||||
max_age_hours=round(max_age_seconds / 3600, 1),
|
||||
)
|
||||
return None
|
||||
if age > 86400:
|
||||
logger.info(
|
||||
'Telegram initData accepted with stale auth_date (Telegram caching bug)',
|
||||
age_hours=round(age / 3600, 1),
|
||||
)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -487,7 +487,8 @@ async def link_telegram(
|
||||
|
||||
if request.init_data:
|
||||
# Mini App flow: validate initData
|
||||
user_data = validate_telegram_init_data(request.init_data)
|
||||
# Generous max_age: Telegram Desktop/iOS cache initData with stale auth_date
|
||||
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
|
||||
if not user_data or not user_data.get('id'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -560,7 +561,8 @@ async def link_telegram(
|
||||
if request.photo_url is not None:
|
||||
widget_data['photo_url'] = request.photo_url
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
# Generous max_age: Telegram caches auth data with stale auth_date
|
||||
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired Telegram Login Widget data',
|
||||
|
||||
@@ -453,6 +453,7 @@ async def create_broadcast(
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin.username or f'Admin #{admin.id}',
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
)
|
||||
|
||||
# Start broadcast
|
||||
@@ -651,6 +652,7 @@ async def create_combined_broadcast(
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin_name,
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
)
|
||||
|
||||
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -16,6 +17,7 @@ from app.database.models import (
|
||||
PartnerStatus,
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
Tariff,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
@@ -78,6 +80,7 @@ class NetworkUserNode(BaseModel):
|
||||
personal_spent_kopeks: int
|
||||
subscription_name: str | None
|
||||
subscription_end: str | None
|
||||
subscription_status: str | None
|
||||
registered_at: str | None
|
||||
|
||||
|
||||
@@ -114,6 +117,7 @@ class NetworkGraphResponse(BaseModel):
|
||||
total_referrers: int
|
||||
total_campaigns: int
|
||||
total_earnings_kopeks: int
|
||||
total_subscription_revenue_kopeks: int
|
||||
|
||||
|
||||
class NetworkUserDetail(BaseModel):
|
||||
@@ -134,6 +138,7 @@ class NetworkUserDetail(BaseModel):
|
||||
personal_spent_kopeks: int
|
||||
subscription_name: str | None
|
||||
subscription_end: str | None
|
||||
subscription_status: str | None
|
||||
registered_at: str | None
|
||||
|
||||
|
||||
@@ -215,6 +220,7 @@ def _build_user_node(
|
||||
campaign_id: int | None,
|
||||
subscription_name: str | None,
|
||||
subscription_end_str: str | None,
|
||||
subscription_status: str | None,
|
||||
) -> NetworkUserNode:
|
||||
return NetworkUserNode(
|
||||
id=user.id,
|
||||
@@ -232,6 +238,7 @@ def _build_user_node(
|
||||
personal_spent_kopeks=personal_spent,
|
||||
subscription_name=subscription_name,
|
||||
subscription_end=subscription_end_str,
|
||||
subscription_status=subscription_status,
|
||||
registered_at=_format_datetime(user.created_at),
|
||||
)
|
||||
|
||||
@@ -312,7 +319,7 @@ async def _fetch_branch_revenue(db: AsyncSession, user_ids: set[int]) -> dict[in
|
||||
stmt = (
|
||||
select(
|
||||
referred_user.c.referred_by_id,
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0),
|
||||
)
|
||||
.join(referred_user, Transaction.user_id == referred_user.c.id)
|
||||
.where(
|
||||
@@ -333,7 +340,7 @@ async def _fetch_personal_spent(db: AsyncSession, user_ids: set[int]) -> dict[in
|
||||
return {}
|
||||
|
||||
stmt = (
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
@@ -376,18 +383,81 @@ async def _fetch_campaign_registrations(db: AsyncSession, user_ids: set[int] | N
|
||||
return {row[0]: row[1] for row in result}
|
||||
|
||||
|
||||
async def _fetch_subscription_info(db: AsyncSession, user_ids: set[int]) -> dict[int, tuple[str | None, str | None]]:
|
||||
"""Return {user_id: (tariff_name, end_date_iso)} for given users."""
|
||||
def _compute_subscription_status(
|
||||
is_trial: bool | None,
|
||||
db_status: str | None,
|
||||
end_date: datetime | None,
|
||||
now: datetime,
|
||||
) -> str | None:
|
||||
"""Map subscription fields to a frontend status label.
|
||||
|
||||
Returns one of: 'trial_active', 'trial_expired', 'paid_active', 'paid_expired', or None.
|
||||
Statuses DISABLED, PENDING, EXPIRED, LIMITED are treated as inactive regardless of end_date.
|
||||
ACTIVE and TRIAL fall through to a date-based check.
|
||||
"""
|
||||
if is_trial is None:
|
||||
return None
|
||||
if db_status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.PENDING.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
):
|
||||
return 'trial_expired' if is_trial else 'paid_expired'
|
||||
if is_trial:
|
||||
return 'trial_active' if (end_date and end_date > now) else 'trial_expired'
|
||||
return 'paid_active' if (end_date and end_date > now) else 'paid_expired'
|
||||
|
||||
|
||||
async def _fetch_subscription_info(
|
||||
db: AsyncSession,
|
||||
user_ids: set[int],
|
||||
) -> dict[int, tuple[str | None, str | None, str | None]]:
|
||||
"""Return {user_id: (tariff_name, end_date_iso, subscription_status)} for given users."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
stmt = (
|
||||
select(Subscription.user_id, Tariff.name, Subscription.end_date)
|
||||
row_num = (
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=Subscription.user_id,
|
||||
order_by=Subscription.end_date.desc().nullslast(),
|
||||
)
|
||||
.label('rn')
|
||||
)
|
||||
|
||||
inner = (
|
||||
select(
|
||||
Subscription.user_id,
|
||||
Tariff.name,
|
||||
Subscription.end_date,
|
||||
Subscription.is_trial,
|
||||
Subscription.status,
|
||||
row_num,
|
||||
)
|
||||
.outerjoin(Tariff, Subscription.tariff_id == Tariff.id)
|
||||
.where(Subscription.user_id.in_(user_ids))
|
||||
)
|
||||
subq = inner.subquery()
|
||||
|
||||
stmt = select(
|
||||
subq.c.user_id,
|
||||
subq.c.name,
|
||||
subq.c.end_date,
|
||||
subq.c.is_trial,
|
||||
subq.c.status,
|
||||
).where(subq.c.rn == 1)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return {row[0]: (row[1], _format_datetime(row[2]) if row[2] else None) for row in result}
|
||||
now = datetime.now(UTC)
|
||||
out: dict[int, tuple[str | None, str | None, str | None]] = {}
|
||||
for row in result:
|
||||
user_id, tariff_name, end_date, is_trial, db_status = row
|
||||
end_date_iso = _format_datetime(end_date) if end_date else None
|
||||
sub_status = _compute_subscription_status(is_trial, db_status, end_date, now)
|
||||
out[user_id] = (tariff_name, end_date_iso, sub_status)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
async def _fetch_campaign_stats(
|
||||
@@ -428,7 +498,7 @@ async def _fetch_campaign_stats(
|
||||
user_spent: dict[int, int] = {}
|
||||
if all_campaign_users:
|
||||
spent_stmt = (
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(all_campaign_users),
|
||||
@@ -540,6 +610,7 @@ async def get_referral_network(
|
||||
total_referrers=0,
|
||||
total_campaigns=0,
|
||||
total_earnings_kopeks=0,
|
||||
total_subscription_revenue_kopeks=0,
|
||||
)
|
||||
|
||||
# Cap to prevent excessive response sizes (deterministic: keep lowest IDs for stability)
|
||||
@@ -567,7 +638,7 @@ async def get_referral_network(
|
||||
# Build user nodes
|
||||
user_nodes: list[NetworkUserNode] = []
|
||||
for user in users:
|
||||
sub = sub_info.get(user.id, (None, None))
|
||||
sub = sub_info.get(user.id, (None, None, None))
|
||||
user_nodes.append(
|
||||
_build_user_node(
|
||||
user,
|
||||
@@ -578,6 +649,7 @@ async def get_referral_network(
|
||||
campaign_id=campaign_regs.get(user.id),
|
||||
subscription_name=sub[0],
|
||||
subscription_end_str=sub[1],
|
||||
subscription_status=sub[2],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -629,6 +701,7 @@ async def get_referral_network(
|
||||
total_referrers = len([u for u in user_nodes if u.direct_referrals > 0])
|
||||
|
||||
total_earnings = sum(personal_revenue.values())
|
||||
total_subscription_revenue = sum(personal_spent.values())
|
||||
|
||||
return NetworkGraphResponse(
|
||||
users=user_nodes,
|
||||
@@ -638,6 +711,7 @@ async def get_referral_network(
|
||||
total_referrers=total_referrers,
|
||||
total_campaigns=len(campaign_nodes),
|
||||
total_earnings_kopeks=total_earnings,
|
||||
total_subscription_revenue_kopeks=total_subscription_revenue,
|
||||
)
|
||||
|
||||
|
||||
@@ -784,6 +858,7 @@ async def _build_scoped_graph(
|
||||
total_referrers=0,
|
||||
total_campaigns=len(campaign_nodes),
|
||||
total_earnings_kopeks=0,
|
||||
total_subscription_revenue_kopeks=0,
|
||||
)
|
||||
return NetworkGraphResponse(
|
||||
users=[],
|
||||
@@ -793,6 +868,7 @@ async def _build_scoped_graph(
|
||||
total_referrers=0,
|
||||
total_campaigns=0,
|
||||
total_earnings_kopeks=0,
|
||||
total_subscription_revenue_kopeks=0,
|
||||
)
|
||||
|
||||
# Cap to prevent excessive response sizes
|
||||
@@ -816,7 +892,7 @@ async def _build_scoped_graph(
|
||||
|
||||
user_nodes: list[NetworkUserNode] = []
|
||||
for user in users:
|
||||
sub = sub_info.get(user.id, (None, None))
|
||||
sub = sub_info.get(user.id, (None, None, None))
|
||||
user_nodes.append(
|
||||
_build_user_node(
|
||||
user,
|
||||
@@ -827,6 +903,7 @@ async def _build_scoped_graph(
|
||||
campaign_id=campaign_regs.get(user.id),
|
||||
subscription_name=sub[0],
|
||||
subscription_end_str=sub[1],
|
||||
subscription_status=sub[2],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -878,6 +955,7 @@ async def _build_scoped_graph(
|
||||
|
||||
total_referrers = len([u for u in user_nodes if u.direct_referrals > 0])
|
||||
total_earnings = sum(personal_revenue.values())
|
||||
total_subscription_revenue = sum(personal_spent.values())
|
||||
|
||||
return NetworkGraphResponse(
|
||||
users=user_nodes,
|
||||
@@ -887,6 +965,7 @@ async def _build_scoped_graph(
|
||||
total_referrers=total_referrers,
|
||||
total_campaigns=len(campaign_nodes),
|
||||
total_earnings_kopeks=total_earnings,
|
||||
total_subscription_revenue_kopeks=total_subscription_revenue,
|
||||
)
|
||||
|
||||
|
||||
@@ -1057,7 +1136,7 @@ async def get_network_user_detail(
|
||||
branch_revenue = 0
|
||||
|
||||
# Personal spent
|
||||
spent_stmt = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
spent_stmt = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.type.in_(SPENT_TRANSACTION_TYPES),
|
||||
@@ -1102,7 +1181,7 @@ async def get_network_user_detail(
|
||||
|
||||
# Branch revenue: total spent by all users in the branch
|
||||
branch_user_ids_stmt = select(branch_cte.c.id)
|
||||
branch_rev_stmt = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
branch_rev_stmt = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id.in_(branch_user_ids_stmt),
|
||||
Transaction.type.in_(SPENT_TRANSACTION_TYPES),
|
||||
@@ -1124,10 +1203,17 @@ async def get_network_user_detail(
|
||||
# Subscription info
|
||||
subscription_name: str | None = None
|
||||
subscription_end: str | None = None
|
||||
subscription_status: str | None = None
|
||||
if user.subscription is not None:
|
||||
if user.subscription.tariff is not None:
|
||||
subscription_name = user.subscription.tariff.name
|
||||
subscription_end = _format_datetime(user.subscription.end_date)
|
||||
subscription_status = _compute_subscription_status(
|
||||
user.subscription.is_trial,
|
||||
user.subscription.status,
|
||||
user.subscription.end_date,
|
||||
datetime.now(UTC),
|
||||
)
|
||||
|
||||
return NetworkUserDetail(
|
||||
id=user.id,
|
||||
@@ -1147,6 +1233,7 @@ async def get_network_user_detail(
|
||||
personal_spent_kopeks=personal_spent,
|
||||
subscription_name=subscription_name,
|
||||
subscription_end=subscription_end,
|
||||
subscription_status=subscription_status,
|
||||
registered_at=_format_datetime(user.created_at),
|
||||
)
|
||||
|
||||
@@ -1215,7 +1302,7 @@ async def get_network_campaign_detail(
|
||||
total_spent = 0
|
||||
if campaign_user_ids:
|
||||
spent_stmt = (
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(campaign_user_ids),
|
||||
@@ -1336,7 +1423,7 @@ async def search_referral_network(
|
||||
sub_info = await _fetch_subscription_info(db, matched_ids)
|
||||
|
||||
for user in matched_users:
|
||||
sub = sub_info.get(user.id, (None, None))
|
||||
sub = sub_info.get(user.id, (None, None, None))
|
||||
user_nodes.append(
|
||||
_build_user_node(
|
||||
user,
|
||||
@@ -1347,6 +1434,7 @@ async def search_referral_network(
|
||||
campaign_id=campaign_regs.get(user.id),
|
||||
subscription_name=sub[0],
|
||||
subscription_end_str=sub[1],
|
||||
subscription_status=sub[2],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1400,7 +1488,7 @@ async def search_referral_network(
|
||||
campaign_user_spent: dict[int, int] = {}
|
||||
if all_campaign_user_ids:
|
||||
spent_stmt = (
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(all_campaign_user_ids),
|
||||
|
||||
@@ -441,6 +441,14 @@ async def assign_role(
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
# Superadmin role is managed exclusively via ADMIN_IDS/ADMIN_EMAILS env config
|
||||
if role.level >= SUPERADMIN_LEVEL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
|
||||
'Add the user there and restart the bot.',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot assign a role with level >= own level
|
||||
@@ -450,13 +458,6 @@ async def assign_role(
|
||||
detail='Cannot assign a role with level >= your own role level',
|
||||
)
|
||||
|
||||
# Superadmin assignments must be permanent — expiry would cause silent lockout
|
||||
if role.level == SUPERADMIN_LEVEL and payload.expires_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Superadmin role assignments cannot be time-limited',
|
||||
)
|
||||
|
||||
# Verify target user exists
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
@@ -505,9 +506,7 @@ async def revoke_role(
|
||||
admin: User = Depends(require_permission('roles:assign')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke a role assignment. Cannot remove the last superadmin."""
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
"""Revoke a role assignment. Superadmin roles are managed via env config."""
|
||||
from app.database.models import UserRole
|
||||
|
||||
# Lock the assignment row (FOR UPDATE held until commit)
|
||||
@@ -526,6 +525,14 @@ async def revoke_role(
|
||||
detail='Associated role not found',
|
||||
)
|
||||
|
||||
# Superadmin role is managed exclusively via env config
|
||||
if role.level >= SUPERADMIN_LEVEL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
|
||||
'Remove the user from env and restart the bot.',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot revoke a role at or above own level
|
||||
@@ -535,33 +542,6 @@ async def revoke_role(
|
||||
detail='Cannot revoke a role at or above your own level',
|
||||
)
|
||||
|
||||
# Block self-revocation of superadmin role
|
||||
if role.level == SUPERADMIN_LEVEL and user_role.user_id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot revoke your own superadmin role',
|
||||
)
|
||||
|
||||
# Protect last superadmin (level 999).
|
||||
# Advisory lock serializes concurrent superadmin revocations so two requests
|
||||
# cannot both read count=2 and then both proceed to revoke.
|
||||
if role.level == SUPERADMIN_LEVEL:
|
||||
if not settings.is_sqlite():
|
||||
await db.execute(sa.text('SELECT pg_advisory_xact_lock(736453)'))
|
||||
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
|
||||
if superadmin_count <= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot remove the last superadmin',
|
||||
)
|
||||
|
||||
# Warn if target user is a legacy admin — RBAC revocation won't actually block access
|
||||
target_user = await get_user_by_id(db, user_role.user_id)
|
||||
is_target_legacy = target_user and settings.is_admin(
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email if target_user.email_verified else None,
|
||||
)
|
||||
|
||||
# Revoke directly on the locked object (avoid CRUD re-fetch without FOR UPDATE)
|
||||
user_role.is_active = False
|
||||
await db.flush()
|
||||
@@ -575,10 +555,4 @@ async def revoke_role(
|
||||
role_name=role.name,
|
||||
)
|
||||
|
||||
result_msg = {'message': 'Role revoked', 'assignment_id': assignment_id}
|
||||
if is_target_legacy:
|
||||
result_msg['warning'] = (
|
||||
'This user is still listed in ADMIN_IDS/ADMIN_EMAILS env config. '
|
||||
'They retain full access until removed from those settings and the bot is restarted.'
|
||||
)
|
||||
return result_msg
|
||||
return {'message': 'Role revoked', 'assignment_id': assignment_id}
|
||||
|
||||
@@ -128,7 +128,7 @@ async def get_sales_summary(
|
||||
|
||||
# Manual top-ups by admins
|
||||
manual_topup_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
@@ -246,7 +246,7 @@ async def get_sales_summary(
|
||||
|
||||
# Add-on revenue
|
||||
addon_revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed == True,
|
||||
@@ -256,7 +256,7 @@ async def get_sales_summary(
|
||||
)
|
||||
)
|
||||
)
|
||||
addon_revenue = abs(addon_revenue_result.scalar() or 0)
|
||||
addon_revenue = addon_revenue_result.scalar() or 0
|
||||
|
||||
return SalesSummary(
|
||||
total_revenue_kopeks=total_revenue + manual_topup,
|
||||
@@ -1101,11 +1101,11 @@ async def get_deposits_stats(
|
||||
select(
|
||||
Transaction.payment_method.label('method'),
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(Transaction.payment_method)
|
||||
.order_by(func.sum(Transaction.amount_kopeks).desc())
|
||||
.order_by(func.sum(func.abs(Transaction.amount_kopeks)).desc())
|
||||
)
|
||||
by_method = [
|
||||
DepositByMethodItem(method=row.method or 'unknown', count=row.count, amount_kopeks=row.amount)
|
||||
@@ -1116,7 +1116,7 @@ async def get_deposits_stats(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(func.date(Transaction.created_at))
|
||||
@@ -1137,7 +1137,7 @@ async def get_deposits_stats(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
Transaction.payment_method.label('method'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(func.date(Transaction.created_at), Transaction.payment_method)
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.database.crud.user import (
|
||||
from app.database.crud.user_promo_group import sync_user_primary_promo_group
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
PaymentMethod,
|
||||
PromoGroup,
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
@@ -897,6 +898,7 @@ async def update_user_balance(
|
||||
description=request.description,
|
||||
create_transaction=request.create_transaction,
|
||||
transaction_type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
)
|
||||
else:
|
||||
# Subtract balance
|
||||
@@ -912,6 +914,7 @@ async def update_user_balance(
|
||||
amount_kopeks=amount_to_subtract,
|
||||
description=request.description,
|
||||
create_transaction=request.create_transaction,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
)
|
||||
|
||||
if not success:
|
||||
|
||||
+83
-16
@@ -239,11 +239,39 @@ async def _process_referral_code(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
referral_code: str | None,
|
||||
*,
|
||||
is_new_user: bool = False,
|
||||
) -> None:
|
||||
"""Set referred_by_id for user if referral_code is valid. Never raises."""
|
||||
if not referral_code or user.referred_by_id:
|
||||
"""Process referral for a newly created user. Never raises.
|
||||
|
||||
Only applies to new users (is_new_user=True). Existing users cannot be
|
||||
assigned a referrer — same logic as the bot /start handler.
|
||||
|
||||
Handles two cases:
|
||||
- referred_by_id already set by create_user() → fire registration event
|
||||
- referred_by_id not set (resolution failed earlier) → resolve, set, fire
|
||||
"""
|
||||
if not referral_code or not is_new_user:
|
||||
return
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
# Lock user row to prevent concurrent referral application (TOCTOU race)
|
||||
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
await db.refresh(user)
|
||||
|
||||
# Case 1: referred_by_id already set by create_user() — just fire the event
|
||||
if user.referred_by_id:
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, user.referred_by_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral registration processed for pre-set referrer',
|
||||
user_id=user.id,
|
||||
referrer_id=user.referred_by_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Case 2: referred_by_id not set — resolve referral code and set it
|
||||
referrer = await get_user_by_referral_code(db, referral_code)
|
||||
if not referrer:
|
||||
return
|
||||
@@ -254,8 +282,6 @@ async def _process_referral_code(
|
||||
user.referred_by_id = referrer.id
|
||||
await db.flush()
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=bot)
|
||||
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
|
||||
@@ -405,7 +431,11 @@ async def auth_telegram(
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
user_data = validate_telegram_init_data(request.init_data)
|
||||
# Telegram Desktop/iOS cache initData with stale auth_date (known Telegram bug:
|
||||
# https://github.com/telegramdesktop/tdesktop/issues/28303).
|
||||
# Use generous max_age: HMAC signature proves authenticity,
|
||||
# JWT tokens handle actual session expiration after login.
|
||||
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
|
||||
|
||||
if not user_data:
|
||||
raise HTTPException(
|
||||
@@ -434,10 +464,19 @@ async def auth_telegram(
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
|
||||
if referrer.telegram_id and referrer.telegram_id == telegram_id:
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via telegram_id',
|
||||
telegram_id=telegram_id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
is_new_user = not user
|
||||
if not user:
|
||||
# Create new user from Telegram initData
|
||||
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
|
||||
@@ -481,8 +520,8 @@ async def auth_telegram(
|
||||
# Store refresh token
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
@@ -515,7 +554,8 @@ async def auth_telegram_widget(
|
||||
|
||||
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
# Generous max_age: Telegram caches auth data with stale auth_date
|
||||
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid or expired Telegram authentication data',
|
||||
@@ -529,10 +569,19 @@ async def auth_telegram_widget(
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
|
||||
if referrer.telegram_id and referrer.telegram_id == request.id:
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via telegram_id',
|
||||
telegram_id=request.id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
is_new_user = not user
|
||||
if not user:
|
||||
# Create new user from Telegram data
|
||||
logger.info(
|
||||
@@ -569,8 +618,8 @@ async def auth_telegram_widget(
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
@@ -661,10 +710,19 @@ async def auth_telegram_oidc(
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
except (ValueError, LookupError) as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=str(e))
|
||||
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
|
||||
if referrer.telegram_id and referrer.telegram_id == telegram_id:
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via telegram_id',
|
||||
telegram_id=telegram_id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
is_new_user = not user
|
||||
if not user:
|
||||
logger.info('Creating new user from cabinet OIDC', telegram_id=telegram_id, username=username)
|
||||
user = await create_user(
|
||||
@@ -698,7 +756,8 @@ async def auth_telegram_oidc(
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
|
||||
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
@@ -1793,6 +1852,14 @@ async def poll_deep_link_token(
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token, device_info='deep_link')
|
||||
|
||||
# Deep link auth is always for existing users — referral code not applicable
|
||||
# (kept for campaign bonus processing only)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
logger.info('Deep link auth successful', user_id=user.id, telegram_id=user.telegram_id)
|
||||
|
||||
return response
|
||||
|
||||
@@ -40,6 +40,8 @@ async def _finalize_oauth_login(
|
||||
provider: str,
|
||||
campaign_slug: str | None = None,
|
||||
referral_code: str | None = None,
|
||||
*,
|
||||
is_new_user: bool = False,
|
||||
) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
@@ -47,10 +49,10 @@ async def _finalize_oauth_login(
|
||||
auth_response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
from .auth import _process_referral_code, _user_to_response
|
||||
|
||||
await _process_referral_code(db, user, referral_code)
|
||||
await _process_referral_code(db, user, referral_code, is_new_user=is_new_user)
|
||||
|
||||
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
|
||||
if auth_response.campaign_bonus:
|
||||
@@ -232,4 +234,6 @@ async def oauth_callback(
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
logger.info('New OAuth user created', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
|
||||
return await _finalize_oauth_login(
|
||||
db, user, provider, request.campaign_slug, request.referral_code, is_new_user=True
|
||||
)
|
||||
|
||||
@@ -1145,6 +1145,7 @@ async def get_trial_info(
|
||||
price_kopeks = settings.TRIAL_ACTIVATION_PRICE if requires_payment else 0
|
||||
|
||||
# Get trial parameters from tariff if configured (same logic as activate_trial)
|
||||
# Триальный тариф может быть неактивным — используется для отдельных лимитов
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
|
||||
|
||||
@@ -1154,8 +1155,6 @@ async def get_trial_info(
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
traffic_limit_gb = trial_tariff.traffic_limit_gb
|
||||
@@ -1288,6 +1287,7 @@ async def activate_trial(
|
||||
|
||||
# First check for tariff with is_trial_available flag in DB (set via admin panel)
|
||||
# Then fallback to TRIAL_TARIFF_ID from settings
|
||||
# Триальный тариф может быть неактивным — используется для отдельных лимитов
|
||||
trial_tariff = None
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
|
||||
@@ -1298,8 +1298,6 @@ async def activate_trial(
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
@@ -4255,6 +4253,7 @@ async def toggle_subscription_pause(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Toggle pause/resume for daily subscription."""
|
||||
logger.debug('toggle_subscription_pause called', user_id=user.id)
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
if not user.subscription:
|
||||
@@ -4277,7 +4276,15 @@ async def toggle_subscription_pause(
|
||||
detail='Pause is only available for daily tariffs',
|
||||
)
|
||||
|
||||
# Determine current state
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
|
||||
# Lock user BEFORE reading state and mutating to prevent TOCTOU on promo group
|
||||
# and to ensure is_daily_paused mutation is not overwritten by populate_existing
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Determine current state from the LOCKED instance
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
|
||||
@@ -4295,13 +4302,6 @@ async def toggle_subscription_pause(
|
||||
new_paused_state = not is_currently_paused
|
||||
user.subscription.is_daily_paused = new_paused_state
|
||||
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
|
||||
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Apply group discount to daily price (consistent with DailySubscriptionService and miniapp resume)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
@@ -4311,6 +4311,8 @@ async def toggle_subscription_pause(
|
||||
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
|
||||
)
|
||||
|
||||
resume_transaction = None
|
||||
|
||||
# If resuming, check balance and charge
|
||||
if not new_paused_state:
|
||||
if daily_price > 0 and user.balance_kopeks < daily_price:
|
||||
@@ -4335,6 +4337,7 @@ async def toggle_subscription_pause(
|
||||
daily_price,
|
||||
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
mark_as_paid_subscription=True,
|
||||
commit=False,
|
||||
)
|
||||
if not deducted:
|
||||
raise HTTPException(
|
||||
@@ -4350,26 +4353,45 @@ async def toggle_subscription_pause(
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
|
||||
try:
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to create resume transaction', error=exc)
|
||||
resume_transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Balance deducted successfully — now activate
|
||||
now = datetime.now(UTC)
|
||||
user.subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
user.subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
user.subscription.last_daily_charge_at = now
|
||||
user.subscription.end_date = now + timedelta(days=1)
|
||||
|
||||
# Re-apply is_daily_paused on the current identity-mapped instance
|
||||
# (subtract_user_balance with populate_existing=True may have reloaded it from DB)
|
||||
user.subscription.is_daily_paused = new_paused_state
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user.subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred transaction side effects after commit
|
||||
if not new_paused_state and was_disabled and daily_price > 0 and resume_transaction is not None:
|
||||
try:
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db=db,
|
||||
transaction=resume_transaction,
|
||||
amount_kopeks=daily_price,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to emit resume transaction side effects', error=exc)
|
||||
|
||||
# Sync with RemnaWave only when resuming from DISABLED state
|
||||
if not new_paused_state and was_disabled:
|
||||
try:
|
||||
|
||||
@@ -198,6 +198,17 @@ class DeepLinkTokenResponse(BaseModel):
|
||||
|
||||
|
||||
class DeepLinkPollRequest(BaseModel):
|
||||
"""Request to poll deep link auth status."""
|
||||
"""Request to poll deep link auth status.
|
||||
|
||||
Deep link auth is always for existing bot users — referral codes are not applicable here.
|
||||
Only campaign_slug is supported (campaign bonus can apply to existing users).
|
||||
"""
|
||||
|
||||
token: str = Field(..., min_length=16, max_length=128, description='Deep link auth token')
|
||||
campaign_slug: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
pattern=r'^[a-zA-Z0-9_-]+$',
|
||||
description='Campaign slug captured from cabinet URL',
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# ============ Channel Types ============
|
||||
@@ -75,6 +75,27 @@ class BroadcastButtonsResponse(BaseModel):
|
||||
buttons: list[BroadcastButton]
|
||||
|
||||
|
||||
class CustomBroadcastButton(BaseModel):
|
||||
"""Custom button for broadcast message."""
|
||||
|
||||
label: str = Field(..., min_length=1, max_length=64)
|
||||
action_type: Literal['callback', 'url'] = 'callback'
|
||||
action_value: str = Field(..., min_length=1, max_length=256)
|
||||
|
||||
@field_validator('action_value')
|
||||
@classmethod
|
||||
def validate_action_value(cls, v: str, info) -> str:
|
||||
action_type = info.data.get('action_type', 'callback')
|
||||
if action_type == 'url':
|
||||
if not v.startswith(('https://', 'tg://')):
|
||||
raise ValueError('URL must start with https:// or tg://')
|
||||
elif action_type == 'callback':
|
||||
# Telegram API limits callback_data to 64 bytes
|
||||
if len(v.encode('utf-8')) > 64:
|
||||
raise ValueError('Callback data must be at most 64 bytes')
|
||||
return v
|
||||
|
||||
|
||||
# ============ Media ============
|
||||
|
||||
|
||||
@@ -95,6 +116,7 @@ class BroadcastCreateRequest(BaseModel):
|
||||
target: str
|
||||
message_text: str = Field(..., min_length=1, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
|
||||
media: BroadcastMediaRequest | None = None
|
||||
|
||||
|
||||
@@ -187,6 +209,7 @@ class CombinedBroadcastCreateRequest(BaseModel):
|
||||
# Telegram-specific fields
|
||||
message_text: str | None = Field(default=None, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
|
||||
media: BroadcastMediaRequest | None = None
|
||||
|
||||
# Email-specific fields
|
||||
|
||||
+24
-13
@@ -376,6 +376,7 @@ class Settings(BaseSettings):
|
||||
NALOGO_PASSWORD: str | None = None
|
||||
NALOGO_DEVICE_ID: str | None = None
|
||||
NALOGO_STORAGE_PATH: str = './nalogo_tokens.json'
|
||||
NALOGO_PROXY_URL: str | None = None # SOCKS proxy for nalog.ru; falls back to PROXY_URL if not set
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
@@ -807,7 +808,7 @@ class Settings(BaseSettings):
|
||||
# Format: socks5://user:password@host:port or socks5://host:port
|
||||
PROXY_URL: str | None = None
|
||||
|
||||
@field_validator('PROXY_URL', mode='before')
|
||||
@field_validator('PROXY_URL', 'NALOGO_PROXY_URL', mode='before')
|
||||
@classmethod
|
||||
def validate_proxy_url(cls, value: str | None) -> str | None:
|
||||
if not value:
|
||||
@@ -815,13 +816,13 @@ class Settings(BaseSettings):
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme not in ('socks5', 'socks4'):
|
||||
if parsed.scheme not in ('socks5', 'socks5h', 'socks4'):
|
||||
raise ValueError(
|
||||
f'PROXY_URL must use socks5:// or socks4:// scheme, got: {parsed.scheme!r}. '
|
||||
'HTTP proxies are not supported for security reasons (bot token would be exposed).'
|
||||
f'Proxy URL must use socks5://, socks5h://, or socks4:// scheme, got: {parsed.scheme!r}. '
|
||||
'HTTP proxies are not supported for security reasons.'
|
||||
)
|
||||
if not parsed.hostname:
|
||||
raise ValueError('PROXY_URL must contain a hostname')
|
||||
raise ValueError('Proxy URL must contain a hostname')
|
||||
return value
|
||||
|
||||
@field_validator('MAIN_MENU_MODE', mode='before')
|
||||
@@ -954,6 +955,13 @@ class Settings(BaseSettings):
|
||||
"""Return SOCKS5 proxy URL or None."""
|
||||
return self.PROXY_URL if self.PROXY_URL else None
|
||||
|
||||
def get_nalogo_proxy_url(self) -> str | None:
|
||||
"""Return SOCKS proxy URL for nalogo or None.
|
||||
|
||||
Uses NALOGO_PROXY_URL if set, otherwise falls back to PROXY_URL.
|
||||
"""
|
||||
return self.NALOGO_PROXY_URL or self.PROXY_URL
|
||||
|
||||
def is_admin(self, telegram_id: int | None = None, email: str | None = None) -> bool:
|
||||
"""
|
||||
Check if user is admin by telegram_id or email.
|
||||
@@ -1125,12 +1133,17 @@ class Settings(BaseSettings):
|
||||
username_clean = (username or '').lstrip('@')
|
||||
full_name_value = full_name or ''
|
||||
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
def _sanitize(value: str) -> str:
|
||||
result = re.sub(r'[^0-9A-Za-z_-]+', '_', value)
|
||||
return re.sub(r'_+', '_', result).strip('_-')
|
||||
|
||||
# Для email-пользователей формируем уникальный identifier
|
||||
if telegram_id:
|
||||
identifier = str(telegram_id)
|
||||
elif email:
|
||||
email_prefix = email.split('@')[0][:10]
|
||||
identifier = f'email_{email_prefix}_{user_id}' if user_id else f'email_{email_prefix}'
|
||||
email_prefix = _sanitize(email.split('@')[0][:10])
|
||||
identifier = _sanitize(f'email_{email_prefix}_{user_id}' if user_id else f'email_{email_prefix}')
|
||||
elif user_id:
|
||||
identifier = f'id_{user_id}'
|
||||
else:
|
||||
@@ -1144,20 +1157,18 @@ class Settings(BaseSettings):
|
||||
'username_clean': username_clean,
|
||||
'telegram_id': str(telegram_id) if telegram_id else identifier,
|
||||
'identifier': identifier,
|
||||
'email': email.split('@')[0] if email else '',
|
||||
'email': _sanitize(email.split('@')[0]) if email else '',
|
||||
'user_id': str(user_id) if user_id else '',
|
||||
},
|
||||
)
|
||||
|
||||
raw_username = template.format_map(values).strip()
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z_-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('_-')
|
||||
sanitized_username = _sanitize(raw_username)
|
||||
|
||||
if not sanitized_username:
|
||||
sanitized_username = f'user_{identifier}'
|
||||
sanitized_username = _sanitize(f'user_{identifier}')
|
||||
|
||||
return sanitized_username[:36]
|
||||
return sanitized_username[:36].strip('_-') or 'user'
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: str | None) -> list[time]:
|
||||
|
||||
@@ -1442,6 +1442,7 @@ async def create_pending_subscription(
|
||||
payment_method: str = 'pending',
|
||||
total_price_kopeks: int = 0,
|
||||
is_trial: bool = False,
|
||||
tariff_id: int | None = None,
|
||||
) -> Subscription:
|
||||
"""Creates a pending subscription that will be activated after payment.
|
||||
|
||||
@@ -1475,6 +1476,8 @@ async def create_pending_subscription(
|
||||
existing_subscription.connected_squads = connected_squads or []
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
existing_subscription.updated_at = current_time
|
||||
if tariff_id is not None:
|
||||
existing_subscription.tariff_id = tariff_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
@@ -1497,6 +1500,7 @@ async def create_pending_subscription(
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or [],
|
||||
tariff_id=tariff_id,
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
@@ -1526,6 +1530,7 @@ async def create_pending_trial_subscription(
|
||||
connected_squads: list[str] = None,
|
||||
payment_method: str = 'pending',
|
||||
total_price_kopeks: int = 0,
|
||||
tariff_id: int | None = None,
|
||||
) -> Subscription:
|
||||
"""Creates a pending trial subscription. Wrapper for create_pending_subscription with is_trial=True."""
|
||||
return await create_pending_subscription(
|
||||
@@ -1538,6 +1543,7 @@ async def create_pending_trial_subscription(
|
||||
payment_method=payment_method,
|
||||
total_price_kopeks=total_price_kopeks,
|
||||
is_trial=True,
|
||||
tariff_id=tariff_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -83,13 +83,16 @@ async def count_tariffs(db: AsyncSession, *, include_inactive: bool = False) ->
|
||||
async def get_trial_tariff(db: AsyncSession) -> Tariff | None:
|
||||
"""Получает тариф, доступный для триала (is_trial_available=True).
|
||||
|
||||
Триальный тариф может быть неактивным — это сделано специально,
|
||||
чтобы он не отображался в списке покупки, но использовался для триала
|
||||
со своими лимитами (трафик, устройства, серверы).
|
||||
|
||||
Сортируется по updated_at DESC, чтобы вернуть последний установленный
|
||||
триальный тариф (на случай если их несколько).
|
||||
"""
|
||||
query = (
|
||||
select(Tariff)
|
||||
.where(Tariff.is_trial_available.is_(True))
|
||||
.where(Tariff.is_active.is_(True))
|
||||
.options(selectinload(Tariff.allowed_promo_groups))
|
||||
.order_by(Tariff.updated_at.desc().nullslast(), Tariff.id.desc())
|
||||
.limit(1)
|
||||
|
||||
@@ -344,7 +344,7 @@ async def get_transactions_statistics(
|
||||
select(
|
||||
Transaction.payment_method,
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('total_amount'),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
|
||||
@@ -3288,6 +3288,8 @@ class GuestPurchase(Base):
|
||||
auto_login_token = Column(Text, nullable=True)
|
||||
recipient_warning = Column(String(50), nullable=True)
|
||||
retry_count = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
receipt_uuid = Column(String(255), nullable=True, index=True)
|
||||
receipt_created_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
|
||||
tariff = relationship('Tariff', lazy='selectin')
|
||||
|
||||
Vendored
+30
-5
@@ -469,7 +469,22 @@ class RemnaWaveAPI:
|
||||
hwidDeviceLimit=data.get('hwidDeviceLimit'),
|
||||
status=data.get('status'),
|
||||
)
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
try:
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
except RemnaWaveAPIError as e:
|
||||
# A039 = FK violation on externalSquadUuid — retry without it
|
||||
error_code = (e.response_data or {}).get('errorCode', '')
|
||||
if error_code == 'A039' and 'externalSquadUuid' in data:
|
||||
stale_uuid = data.pop('externalSquadUuid')
|
||||
logger.warning(
|
||||
'A039 FK violation on externalSquadUuid, retrying without it',
|
||||
stale_uuid=stale_uuid,
|
||||
username=data.get('username'),
|
||||
)
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
else:
|
||||
logger.error('POST /api/users FAILED — full payload', payload=data)
|
||||
raise
|
||||
user = self._parse_user(response['response'])
|
||||
logger.info(
|
||||
'POST /api/users response',
|
||||
@@ -570,10 +585,20 @@ class RemnaWaveAPI:
|
||||
|
||||
try:
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
except Exception:
|
||||
# Логируем полный payload при ошибке для диагностики A039
|
||||
logger.error('PATCH /api/users FAILED — full payload', payload=data)
|
||||
raise
|
||||
except RemnaWaveAPIError as e:
|
||||
# A039 = FK violation on externalSquadUuid — retry without it
|
||||
error_code = (e.response_data or {}).get('errorCode', '')
|
||||
if error_code == 'A039' and 'externalSquadUuid' in data:
|
||||
stale_uuid = data.pop('externalSquadUuid')
|
||||
logger.warning(
|
||||
'A039 FK violation on externalSquadUuid, retrying without it',
|
||||
stale_uuid=stale_uuid,
|
||||
uuid=uuid,
|
||||
)
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
else:
|
||||
logger.error('PATCH /api/users FAILED — full payload', payload=data)
|
||||
raise
|
||||
user = self._parse_user(response['response'])
|
||||
logger.info(
|
||||
'PATCH /api/users response',
|
||||
|
||||
@@ -83,7 +83,6 @@ CABINET_MINIAPP_BUTTON_KEYS = {
|
||||
'connect',
|
||||
'subscription',
|
||||
'support',
|
||||
'home',
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +96,11 @@ def get_updated_message_buttons_selector_keyboard(
|
||||
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
|
||||
|
||||
|
||||
def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> types.InlineKeyboardMarkup | None:
|
||||
def create_broadcast_keyboard(
|
||||
selected_buttons: list,
|
||||
language: str = 'ru',
|
||||
custom_buttons: list[dict] | None = None,
|
||||
) -> types.InlineKeyboardMarkup | None:
|
||||
selected_buttons = selected_buttons or []
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
button_config_map = get_broadcast_button_config(language)
|
||||
@@ -123,6 +126,20 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
|
||||
if row_buttons:
|
||||
keyboard.append(row_buttons)
|
||||
|
||||
# Append custom buttons (each on its own row)
|
||||
if custom_buttons:
|
||||
for btn in custom_buttons:
|
||||
label = btn.get('label', '')
|
||||
action_type = btn.get('action_type', 'callback')
|
||||
action_value = btn.get('action_value', '')
|
||||
if not label or not action_value:
|
||||
continue
|
||||
if action_type == 'url':
|
||||
keyboard.append([types.InlineKeyboardButton(text=label, url=action_value)])
|
||||
else:
|
||||
# callback type
|
||||
keyboard.append([types.InlineKeyboardButton(text=label, callback_data=action_value)])
|
||||
|
||||
if not keyboard:
|
||||
return None
|
||||
|
||||
|
||||
@@ -462,28 +462,17 @@ async def show_promo_groups_menu(
|
||||
|
||||
keyboard_rows = []
|
||||
for group, member_count in groups:
|
||||
icon = '⭐' if group.is_default else '🎯'
|
||||
default_suffix = texts.t('ADMIN_PROMO_GROUPS_DEFAULT_LABEL', ' (базовая)') if group.is_default else ''
|
||||
group_lines = [
|
||||
f'{"⭐" if group.is_default else "🎯"} <b>{group.name}</b>{default_suffix}',
|
||||
]
|
||||
group_lines.extend(_format_discount_lines(texts, group))
|
||||
group_lines.append(_format_auto_assign_line(texts, group))
|
||||
group_lines.append(
|
||||
texts.t(
|
||||
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
|
||||
'Участников: {count}',
|
||||
).format(count=member_count)
|
||||
)
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
|
||||
group_lines.extend(period_lines)
|
||||
group_lines.append('')
|
||||
|
||||
lines.extend(group_lines)
|
||||
members_label = texts.t(
|
||||
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
|
||||
'Участников: {count}',
|
||||
).format(count=member_count)
|
||||
lines.append(f'{icon} <b>{group.name}</b>{default_suffix} — {members_label}')
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'{"⭐" if group.is_default else "🎯"} {group.name}',
|
||||
text=f'{icon} {group.name}',
|
||||
callback_data=f'promo_group_manage_{group.id}',
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1019,7 +1019,7 @@ async def delete_user_account(callback: types.CallbackQuery, db_user: User, db:
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user_service = UserService()
|
||||
delete_result = await user_service.delete_user_account(db, user_id, db_user.id)
|
||||
delete_result = await user_service.delete_user_account(db, user_id, db_user.id, force_panel_delete=True)
|
||||
|
||||
if delete_result.bot_deleted:
|
||||
await callback.message.edit_text(
|
||||
@@ -4571,7 +4571,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
update_kwargs = dict(
|
||||
uuid=target_user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
|
||||
if subscription.traffic_limit_gb > 0
|
||||
@@ -4608,7 +4608,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
create_kwargs = dict(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
|
||||
if subscription.traffic_limit_gb > 0
|
||||
else 0,
|
||||
|
||||
@@ -888,7 +888,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
)
|
||||
|
||||
user.status = UserStatus.ACTIVE.value
|
||||
# Keep status=DELETED so complete_registration properly handles
|
||||
# referral assignment and status change (not the "already active" branch)
|
||||
user.balance_kopeks = 0
|
||||
user.remnawave_uuid = None
|
||||
user.has_had_paid_subscription = False
|
||||
@@ -1191,7 +1192,7 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext,
|
||||
reply_markup=get_rules_keyboard(language),
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1302,7 +1303,7 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS
|
||||
reply_markup=get_privacy_policy_keyboard(language),
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1392,7 +1393,7 @@ async def process_referral_code_skip(callback: types.CallbackQuery, state: FSMCo
|
||||
await callback.message.edit_text(
|
||||
texts.t('REGISTRATION_COMPLETING', '✅ Завершаем регистрацию...'), reply_markup=None
|
||||
)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
@@ -619,8 +619,6 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
trial_traffic = trial_tariff.traffic_limit_gb
|
||||
@@ -811,14 +809,36 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) or 0
|
||||
can_pay_from_balance = user_balance_kopeks >= trial_price_kopeks
|
||||
|
||||
traffic_label = 'Безлимит' if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f'{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ'
|
||||
# Берём параметры из триального тарифа если доступен
|
||||
paid_trial_days = settings.TRIAL_DURATION_DAYS
|
||||
paid_trial_traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
paid_trial_devices = settings.TRIAL_DEVICE_LIMIT
|
||||
if settings.is_tariffs_mode():
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id as get_tariff, get_trial_tariff
|
||||
|
||||
paid_trial_tariff = await get_trial_tariff(db)
|
||||
if not paid_trial_tariff:
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
paid_trial_tariff = await get_tariff(db, trial_tariff_id)
|
||||
if paid_trial_tariff:
|
||||
paid_trial_traffic = paid_trial_tariff.traffic_limit_gb
|
||||
paid_trial_devices = paid_trial_tariff.device_limit
|
||||
tariff_trial_days = getattr(paid_trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
paid_trial_days = tariff_trial_days
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
|
||||
|
||||
traffic_label = 'Безлимит' if paid_trial_traffic == 0 else f'{paid_trial_traffic} ГБ'
|
||||
|
||||
message_lines = [
|
||||
texts.t('PAID_TRIAL_HEADER', '⚡ <b>Пробная подписка</b>'),
|
||||
'',
|
||||
f'📅 {texts.t("PERIOD", "Период")}: {settings.TRIAL_DURATION_DAYS} {texts.t("DAYS", "дней")}',
|
||||
f'📅 {texts.t("PERIOD", "Период")}: {paid_trial_days} {texts.t("DAYS", "дней")}',
|
||||
f'📊 {texts.t("TRAFFIC", "Трафик")}: {traffic_label}',
|
||||
f'📱 {texts.t("DEVICES", "Устройства")}: {settings.TRIAL_DEVICE_LIMIT}',
|
||||
f'📱 {texts.t("DEVICES", "Устройства")}: {paid_trial_devices}',
|
||||
'',
|
||||
f'💰 {texts.t("PRICE", "Стоимость")}: {settings.format_price(trial_price_kopeks)}',
|
||||
f'💳 {texts.t("YOUR_BALANCE", "Ваш баланс")}: {settings.format_price(user_balance_kopeks)}',
|
||||
@@ -865,6 +885,7 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
|
||||
|
||||
# Сначала проверяем тариф из БД с флагом is_trial_available
|
||||
# Триальный тариф может быть неактивным — используется для отдельных лимитов
|
||||
trial_tariff = await get_trial_tariff(db)
|
||||
|
||||
# Если не найден в БД, проверяем настройку TRIAL_TARIFF_ID
|
||||
@@ -872,8 +893,6 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
@@ -3044,10 +3063,47 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
# Получаем параметры из триального тарифа (аналогично бесплатному триалу)
|
||||
trial_tariff = None
|
||||
trial_traffic_limit = None
|
||||
trial_device_limit = forced_devices
|
||||
trial_squads = None
|
||||
tariff_id_for_trial = None
|
||||
trial_duration = None
|
||||
|
||||
if settings.is_tariffs_mode():
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id as _get_tariff, get_trial_tariff
|
||||
|
||||
trial_tariff = await get_trial_tariff(db)
|
||||
if not trial_tariff:
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await _get_tariff(db, trial_tariff_id)
|
||||
if trial_tariff:
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
trial_device_limit = trial_tariff.device_limit
|
||||
trial_squads = trial_tariff.allowed_squads or []
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
trial_duration = tariff_trial_days
|
||||
logger.info(
|
||||
'Платный триал с баланса: используем тариф',
|
||||
trial_tariff_name=trial_tariff.name,
|
||||
trial_tariff_id=trial_tariff.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
device_limit=forced_devices,
|
||||
duration_days=trial_duration,
|
||||
device_limit=trial_device_limit,
|
||||
traffic_limit_gb=trial_traffic_limit,
|
||||
connected_squads=trial_squads,
|
||||
tariff_id=tariff_id_for_trial,
|
||||
)
|
||||
|
||||
await db.refresh(db_user)
|
||||
@@ -3365,28 +3421,63 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
try:
|
||||
payment_service = PaymentService(callback.bot)
|
||||
|
||||
# Получаем случайный сквад для триала
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
# Получаем параметры из триального тарифа
|
||||
trial_duration = settings.TRIAL_DURATION_DAYS
|
||||
trial_traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
trial_devices = settings.TRIAL_DEVICE_LIMIT
|
||||
trial_squads_list = []
|
||||
tariff_id_for_trial = None
|
||||
|
||||
trial_squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
if settings.is_tariffs_mode():
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id as _get_tariff, get_trial_tariff
|
||||
|
||||
trial_tariff = await get_trial_tariff(db)
|
||||
if not trial_tariff:
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await _get_tariff(db, trial_tariff_id)
|
||||
if trial_tariff:
|
||||
trial_traffic = trial_tariff.traffic_limit_gb
|
||||
trial_devices = trial_tariff.device_limit
|
||||
trial_squads_list = trial_tariff.allowed_squads or []
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
trial_duration = tariff_trial_days
|
||||
logger.info(
|
||||
'Платный триал через платёжку: используем тариф',
|
||||
trial_tariff_name=trial_tariff.name,
|
||||
trial_tariff_id=trial_tariff.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
|
||||
|
||||
# Если тариф не задал серверы, получаем случайный сквад
|
||||
if not trial_squads_list:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
trial_squads_list = [trial_squad_uuid] if trial_squad_uuid else []
|
||||
|
||||
# Создаем pending триальную подписку
|
||||
pending_subscription = await create_pending_trial_subscription(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
duration_days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.TRIAL_DEVICE_LIMIT,
|
||||
connected_squads=[trial_squad_uuid] if trial_squad_uuid else [],
|
||||
duration_days=trial_duration,
|
||||
traffic_limit_gb=trial_traffic,
|
||||
device_limit=trial_devices,
|
||||
connected_squads=trial_squads_list,
|
||||
payment_method=f'trial_{payment_method}',
|
||||
total_price_kopeks=trial_price_kopeks,
|
||||
tariff_id=tariff_id_for_trial,
|
||||
)
|
||||
|
||||
if not pending_subscription:
|
||||
await callback.answer('❌ Не удалось подготовить заказ. Попробуйте позже.', show_alert=True)
|
||||
return
|
||||
|
||||
traffic_label = 'Безлимит' if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f'{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ'
|
||||
traffic_label = 'Безлимит' if trial_traffic == 0 else f'{trial_traffic} ГБ'
|
||||
|
||||
if payment_method == 'stars':
|
||||
# Оплата через Telegram Stars
|
||||
@@ -3395,11 +3486,11 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
await callback.bot.send_invoice(
|
||||
chat_id=callback.from_user.id,
|
||||
title=texts.t('PAID_TRIAL_INVOICE_TITLE', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
description=(
|
||||
f'{texts.t("PERIOD", "Период")}: {settings.TRIAL_DURATION_DAYS} {texts.t("DAYS", "дней")}\n'
|
||||
f'{texts.t("DEVICES", "Устройства")}: {settings.TRIAL_DEVICE_LIMIT}\n'
|
||||
f'{texts.t("PERIOD", "Период")}: {trial_duration} {texts.t("DAYS", "дней")}\n'
|
||||
f'{texts.t("DEVICES", "Устройства")}: {trial_devices}\n'
|
||||
f'{texts.t("TRAFFIC", "Трафик")}: {traffic_label}'
|
||||
),
|
||||
payload=f'trial_{pending_subscription.id}',
|
||||
@@ -3426,7 +3517,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
db=db,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
@@ -3465,7 +3556,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
metadata={
|
||||
'type': 'trial',
|
||||
@@ -3514,7 +3605,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
payload=f'trial_{pending_subscription.id}_{db_user.id}',
|
||||
)
|
||||
@@ -3562,7 +3653,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
@@ -3600,7 +3691,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
@@ -3637,7 +3728,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
@@ -3675,7 +3766,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
)
|
||||
@@ -3719,7 +3810,7 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
days=trial_duration
|
||||
),
|
||||
language=db_user.language,
|
||||
payment_method_code=method_code,
|
||||
|
||||
@@ -41,11 +41,13 @@ class AsyncHTTPClient:
|
||||
auth_provider: AuthProvider,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
timeout: float = 10.0,
|
||||
proxy_url: str | None = None,
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.auth_provider = auth_provider
|
||||
self.default_headers = default_headers or {}
|
||||
self.timeout = timeout
|
||||
self.proxy_url = proxy_url
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
self.max_retries = 2 # Same as PHP AuthenticationPlugin::RETRY_LIMIT
|
||||
|
||||
@@ -124,7 +126,7 @@ class AsyncHTTPClient:
|
||||
if json_data is not None:
|
||||
request_kwargs['json'] = json_data
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
# Initial request
|
||||
response = await client.request(**request_kwargs)
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
base_url: str = 'https://lknpd.nalog.ru/api',
|
||||
storage_path: str | None = None,
|
||||
device_id: str | None = None,
|
||||
proxy_url: str | None = None,
|
||||
):
|
||||
self.base_url_v1 = f'{base_url}/v1'
|
||||
self.base_url_v2 = f'{base_url}/v2'
|
||||
@@ -47,6 +48,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
self.device_id = device_id or generate_device_id()
|
||||
self.device_info = DeviceInfo(sourceDeviceId=self.device_id)
|
||||
self._token_data: dict[str, Any] | None = None
|
||||
self.proxy_url = proxy_url
|
||||
|
||||
# Default headers similar to PHP Authenticator
|
||||
self.default_headers = {
|
||||
@@ -130,7 +132,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
'deviceInfo': self.device_info.model_dump(),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
response = await client.post(
|
||||
f'{self.base_url_v1}/auth/lkfl',
|
||||
json=request_data,
|
||||
@@ -165,7 +167,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
'requireTpToBeActive': True,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
response = await client.post(
|
||||
f'{self.base_url_v2}/auth/challenge/sms/start',
|
||||
json=request_data,
|
||||
@@ -200,7 +202,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
'deviceInfo': self.device_info.model_dump(),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
response = await client.post(
|
||||
f'{self.base_url_v1}/auth/challenge/sms/verify',
|
||||
json=request_data,
|
||||
@@ -233,7 +235,7 @@ class AuthProviderImpl(AuthProvider):
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(proxy=self.proxy_url) as client:
|
||||
response = await client.post(
|
||||
f'{self.base_url_v1}/auth/token',
|
||||
json=request_data,
|
||||
|
||||
@@ -36,6 +36,7 @@ class Client:
|
||||
storage_path: str | None = None,
|
||||
device_id: str | None = None,
|
||||
timeout: float = 10.0,
|
||||
proxy_url: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize Moy Nalog API client.
|
||||
@@ -45,6 +46,7 @@ class Client:
|
||||
storage_path: Optional file path for token storage
|
||||
device_id: Optional device ID (auto-generated if not provided)
|
||||
timeout: HTTP request timeout in seconds
|
||||
proxy_url: Optional SOCKS proxy URL for routing traffic
|
||||
"""
|
||||
self.base_url = base_url
|
||||
self.timeout = timeout
|
||||
@@ -54,6 +56,7 @@ class Client:
|
||||
base_url=base_url,
|
||||
storage_path=storage_path,
|
||||
device_id=device_id,
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
|
||||
# Initialize HTTP client with auth middleware
|
||||
@@ -67,6 +70,7 @@ class Client:
|
||||
'Referrer': 'https://lknpd.nalog.ru/auth/login',
|
||||
},
|
||||
timeout=timeout,
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
|
||||
# User profile data (for receipt operations)
|
||||
|
||||
@@ -61,6 +61,7 @@ class BroadcastConfig:
|
||||
selected_buttons: list[str]
|
||||
media: BroadcastMediaConfig | None = None
|
||||
initiator_name: str | None = None
|
||||
custom_buttons: list[dict] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -179,7 +180,7 @@ class BroadcastService:
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, blocked_count, cancelled=False)
|
||||
return
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
keyboard = self._build_keyboard(config.selected_buttons, config.custom_buttons)
|
||||
|
||||
logger.info(
|
||||
'Рассылка : начинаем отправку получателям (batch delay=s)',
|
||||
@@ -358,10 +359,14 @@ class BroadcastService:
|
||||
|
||||
return sent_count, failed_count, blocked_count, False
|
||||
|
||||
def _build_keyboard(self, selected_buttons: list[str] | None) -> InlineKeyboardMarkup | None:
|
||||
def _build_keyboard(
|
||||
self,
|
||||
selected_buttons: list[str] | None,
|
||||
custom_buttons: list[dict] | None = None,
|
||||
) -> InlineKeyboardMarkup | None:
|
||||
if selected_buttons is None:
|
||||
selected_buttons = []
|
||||
return create_broadcast_keyboard(selected_buttons)
|
||||
return create_broadcast_keyboard(selected_buttons, custom_buttons=custom_buttons)
|
||||
|
||||
async def _deliver_message(
|
||||
self,
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.database.models import (
|
||||
LandingPage,
|
||||
PaymentMethod,
|
||||
Tariff,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
@@ -176,6 +177,97 @@ async def create_purchase(
|
||||
return purchase
|
||||
|
||||
|
||||
async def _create_nalogo_receipt_for_purchase(
|
||||
db: AsyncSession,
|
||||
purchase: GuestPurchase,
|
||||
user: User,
|
||||
transaction: Transaction | None = None,
|
||||
) -> None:
|
||||
"""Create NaloGO fiscal receipt for a guest purchase (best-effort)."""
|
||||
if not settings.is_nalogo_enabled():
|
||||
return
|
||||
|
||||
# Без payment_id нет dedup-ключа в Redis — нельзя гарантировать идемпотентность
|
||||
if not purchase.payment_id:
|
||||
logger.warning(
|
||||
'Cannot create NaloGO receipt: purchase has no payment_id',
|
||||
purchase_id=purchase.id,
|
||||
)
|
||||
return
|
||||
|
||||
# Нулевые/отрицательные суммы не фискализируем
|
||||
if purchase.amount_kopeks <= 0:
|
||||
return
|
||||
|
||||
# Защита от дублей: если у транзакции или покупки уже есть чек — не создаём новый
|
||||
if transaction and transaction.receipt_uuid:
|
||||
logger.info(
|
||||
'NaloGO receipt already exists for guest purchase (transaction)',
|
||||
purchase_id=purchase.id,
|
||||
receipt_uuid=transaction.receipt_uuid,
|
||||
)
|
||||
return
|
||||
|
||||
if purchase.receipt_uuid:
|
||||
logger.info(
|
||||
'NaloGO receipt already exists for guest purchase (purchase)',
|
||||
purchase_id=purchase.id,
|
||||
receipt_uuid=purchase.receipt_uuid,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.nalogo_service import NaloGoService
|
||||
|
||||
nalogo_service = NaloGoService()
|
||||
if not nalogo_service.configured:
|
||||
return
|
||||
|
||||
amount_rubles = purchase.amount_kopeks / 100
|
||||
# Не передаём telegram_user_id в описание чека — privacy (VPN-сервис)
|
||||
receipt_name = settings.get_balance_payment_description(purchase.amount_kopeks)
|
||||
|
||||
receipt_uuid = await nalogo_service.create_receipt(
|
||||
name=receipt_name,
|
||||
amount=amount_rubles,
|
||||
quantity=1,
|
||||
payment_id=purchase.payment_id,
|
||||
telegram_user_id=user.telegram_id,
|
||||
amount_kopeks=purchase.amount_kopeks,
|
||||
)
|
||||
|
||||
if receipt_uuid:
|
||||
logger.info(
|
||||
'NaloGO receipt created for guest purchase',
|
||||
purchase_id=purchase.id,
|
||||
receipt_uuid=receipt_uuid,
|
||||
saved_to_transaction=transaction is not None,
|
||||
)
|
||||
# Всегда сохраняем receipt_uuid на purchase (persistent dedup)
|
||||
try:
|
||||
purchase.receipt_uuid = receipt_uuid
|
||||
purchase.receipt_created_at = datetime.now(UTC)
|
||||
if transaction:
|
||||
transaction.receipt_uuid = receipt_uuid
|
||||
transaction.receipt_created_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.warning(
|
||||
'Failed to save receipt_uuid to purchase/transaction',
|
||||
purchase_id=purchase.id,
|
||||
receipt_uuid=receipt_uuid,
|
||||
)
|
||||
except Exception as exc:
|
||||
from app.utils.proxy import sanitize_proxy_error
|
||||
|
||||
logger.error(
|
||||
'Failed to create nalogo receipt for guest purchase',
|
||||
purchase_id=purchase.id,
|
||||
error=sanitize_proxy_error(exc),
|
||||
)
|
||||
|
||||
|
||||
async def fulfill_purchase(
|
||||
db: AsyncSession,
|
||||
purchase_token: str,
|
||||
@@ -271,6 +363,10 @@ async def fulfill_purchase(
|
||||
|
||||
await _send_admin_notification(purchase, notification_tariff_name, is_pending_activation=True)
|
||||
|
||||
# Создаем чек через NaloGO (деньги получены, чек нужен)
|
||||
await _create_nalogo_receipt_for_purchase(db, purchase, user)
|
||||
await db.refresh(purchase) # guard: inner rollback may expire the object
|
||||
|
||||
# Clear plaintext password after email delivery
|
||||
if purchase.cabinet_password:
|
||||
purchase.cabinet_password = None
|
||||
@@ -335,9 +431,10 @@ async def fulfill_purchase(
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
|
||||
# Create transaction so promo group auto-assignment and contest tracking work
|
||||
transaction = None
|
||||
try:
|
||||
payment_method_enum = _resolve_payment_method(purchase.payment_method)
|
||||
await create_transaction(
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
@@ -363,6 +460,12 @@ async def fulfill_purchase(
|
||||
|
||||
await _send_admin_notification(purchase, notification_tariff_name, is_pending_activation=False)
|
||||
|
||||
# Создаем чек через NaloGO
|
||||
await _create_nalogo_receipt_for_purchase(db, purchase, user, transaction)
|
||||
|
||||
# Refresh purchase: если внутри nalogo helper был rollback, объект expired
|
||||
await db.refresh(purchase)
|
||||
|
||||
# Clear plaintext password after email delivery — no longer needed in DB
|
||||
if purchase.cabinet_password:
|
||||
purchase.cabinet_password = None
|
||||
@@ -1344,6 +1447,7 @@ async def _find_succeeded_provider_payment(
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment).where(
|
||||
CryptoBotPayment.status == 'paid',
|
||||
CryptoBotPayment.payload.like('{%'),
|
||||
cast(CryptoBotPayment.payload, SA_JSON)['purchase_token'].as_string() == purchase_token,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -377,8 +377,10 @@ class MonitoringService:
|
||||
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=RemnaWaveUserStatus.ACTIVE if is_active else RemnaWaveUserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
status=RemnaWaveUserStatus.ACTIVE if is_active else RemnaWaveUserStatus.DISABLED,
|
||||
expire_at=subscription.end_date
|
||||
if is_active
|
||||
else max(subscription.end_date, current_time + timedelta(minutes=1)),
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
description=settings.format_remnawave_user_description(
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.config import settings
|
||||
from app.lib.nalogo import Client
|
||||
from app.lib.nalogo.dto.income import IncomeClient, IncomeType
|
||||
from app.utils.cache import cache
|
||||
from app.utils.proxy import mask_proxy_url, sanitize_proxy_error
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -41,18 +42,25 @@ class NaloGoService:
|
||||
try:
|
||||
# Таймаут 30 секунд — nalog.ru иногда отвечает медленно
|
||||
timeout = getattr(settings, 'NALOGO_TIMEOUT', 30.0)
|
||||
proxy_url = settings.get_nalogo_proxy_url()
|
||||
self.client = Client(
|
||||
base_url='https://lknpd.nalog.ru/api',
|
||||
storage_path=storage_path,
|
||||
device_id=device_id or 'bot-device-123',
|
||||
timeout=timeout,
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
self.inn = inn
|
||||
self.password = password
|
||||
self.configured = True
|
||||
logger.info('NaloGO клиент инициализирован для ИНН: ...', inn=inn[:5])
|
||||
if proxy_url:
|
||||
logger.info(
|
||||
'NaloGO клиент инициализирован с прокси', inn=inn[:5], proxy_url=mask_proxy_url(proxy_url)
|
||||
)
|
||||
else:
|
||||
logger.info('NaloGO клиент инициализирован для ИНН: ...', inn=inn[:5])
|
||||
except Exception as error:
|
||||
logger.error('Ошибка инициализации NaloGO клиента', error=error, exc_info=True)
|
||||
logger.error('Ошибка инициализации NaloGO клиента', error=sanitize_proxy_error(error))
|
||||
self.configured = False
|
||||
|
||||
@staticmethod
|
||||
@@ -281,9 +289,9 @@ class NaloGoService:
|
||||
return True
|
||||
except Exception as error:
|
||||
if self._is_service_unavailable(error):
|
||||
logger.warning('NaloGO временно недоступен (техработы)', error=str(error)[:200])
|
||||
logger.warning('NaloGO временно недоступен (техработы)', error=sanitize_proxy_error(error))
|
||||
else:
|
||||
logger.error('Ошибка аутентификации в NaloGO', error=error, exc_info=True)
|
||||
logger.error('Ошибка аутентификации в NaloGO', error=sanitize_proxy_error(error))
|
||||
return False
|
||||
|
||||
async def create_receipt(
|
||||
@@ -355,7 +363,7 @@ class NaloGoService:
|
||||
name, amount, quantity, client_info, payment_id, telegram_user_id, amount_kopeks
|
||||
)
|
||||
else:
|
||||
logger.error('Ошибка аутентификации NaloGO', auth_error=auth_error, exc_info=True)
|
||||
logger.error('Ошибка аутентификации NaloGO', auth_error=sanitize_proxy_error(auth_error))
|
||||
return None
|
||||
|
||||
# ЭТАП 2: Создание чека
|
||||
@@ -400,9 +408,9 @@ class NaloGoService:
|
||||
# ВАЖНО: Аутентификация была успешной, запрос на создание чека УШЁЛ
|
||||
# При таймауте чек МОГ быть создан на сервере — НЕ добавляем в очередь!
|
||||
if self._is_service_unavailable(error):
|
||||
error_msg = str(error)[:200]
|
||||
error_msg = sanitize_proxy_error(error)[:200]
|
||||
logger.error(
|
||||
'⚠️ ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан! (payment_id=, сумма=₽). Сохраняем в очередь проверки. Проверьте lknpd.nalog.ru',
|
||||
'ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан!',
|
||||
payment_id=payment_id,
|
||||
amount=amount,
|
||||
)
|
||||
@@ -418,7 +426,7 @@ class NaloGoService:
|
||||
error_message=error_msg,
|
||||
)
|
||||
else:
|
||||
logger.error('Ошибка создания чека в NaloGO', error=error, exc_info=True)
|
||||
logger.error('Ошибка создания чека в NaloGO', error=sanitize_proxy_error(error))
|
||||
return None
|
||||
|
||||
async def get_queue_length(self) -> int:
|
||||
@@ -511,7 +519,7 @@ class NaloGoService:
|
||||
return None
|
||||
|
||||
except Exception as error:
|
||||
logger.warning('Ошибка проверки дубликата чека', error=error)
|
||||
logger.warning('Ошибка проверки дубликата чека', error=sanitize_proxy_error(error))
|
||||
return None
|
||||
|
||||
async def get_incomes(
|
||||
@@ -555,7 +563,7 @@ class NaloGoService:
|
||||
|
||||
except Exception as error:
|
||||
if self._is_service_unavailable(error):
|
||||
logger.warning('NaloGO временно недоступен', error=error)
|
||||
logger.warning('NaloGO временно недоступен', error=sanitize_proxy_error(error))
|
||||
else:
|
||||
logger.error('Ошибка получения списка доходов', error=error, exc_info=True)
|
||||
logger.error('Ошибка получения списка доходов', error=sanitize_proxy_error(error))
|
||||
return None # None = ошибка, [] = нет чеков
|
||||
|
||||
@@ -483,12 +483,14 @@ async def try_fulfill_guest_purchase(
|
||||
if purchase_token is None:
|
||||
return None
|
||||
|
||||
from app.database.crud.landing import get_purchase_by_token, update_purchase_status
|
||||
from app.database.crud.landing import update_purchase_status
|
||||
from app.database.models import GuestPurchase, GuestPurchaseStatus
|
||||
from app.services.guest_purchase_service import fulfill_purchase
|
||||
|
||||
try:
|
||||
existing = await get_purchase_by_token(db, purchase_token)
|
||||
# FOR UPDATE prevents concurrent webhooks from double-processing the same purchase
|
||||
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update())
|
||||
existing = result.scalars().first()
|
||||
|
||||
# Verify amount (skip for providers with currency conversion imprecision)
|
||||
if existing and not skip_amount_check and payment_amount_kopeks != existing.amount_kopeks:
|
||||
@@ -502,11 +504,20 @@ async def try_fulfill_guest_purchase(
|
||||
await update_purchase_status(db, purchase_token, GuestPurchaseStatus.FAILED)
|
||||
return True # consumed, even though failed
|
||||
|
||||
# Idempotency: skip terminal states
|
||||
if existing and existing.status in (
|
||||
GuestPurchaseStatus.DELIVERED.value,
|
||||
GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
GuestPurchaseStatus.FAILED.value,
|
||||
# Idempotency: skip terminal states (and code-only gifts already in PAID)
|
||||
if (
|
||||
existing
|
||||
and existing.status
|
||||
in (
|
||||
GuestPurchaseStatus.DELIVERED.value,
|
||||
GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
GuestPurchaseStatus.FAILED.value,
|
||||
)
|
||||
) or (
|
||||
existing
|
||||
and existing.status == GuestPurchaseStatus.PAID.value
|
||||
and existing.is_gift
|
||||
and not existing.gift_recipient_type
|
||||
):
|
||||
logger.info(
|
||||
'Guest purchase already in terminal state, skipping',
|
||||
@@ -536,6 +547,24 @@ async def try_fulfill_guest_purchase(
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
provider=provider_name,
|
||||
)
|
||||
# NaloGO receipt: payment received, fulfillment deferred until code activation
|
||||
try:
|
||||
await db.refresh(existing)
|
||||
if existing.buyer:
|
||||
from app.services.guest_purchase_service import _create_nalogo_receipt_for_purchase
|
||||
|
||||
await _create_nalogo_receipt_for_purchase(db, existing, existing.buyer)
|
||||
else:
|
||||
logger.warning(
|
||||
'Code-only gift has no buyer, skipping NaloGO receipt',
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
buyer_user_id=existing.buyer_user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'Failed to create NaloGO receipt for code-only gift',
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
)
|
||||
return True
|
||||
|
||||
# Fulfill: create user, subscription, deliver (commits on success)
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Final
|
||||
import structlog
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.rbac import SUPERADMIN_LEVEL, UserRoleCRUD
|
||||
@@ -215,19 +216,28 @@ async def bootstrap_superadmins(db: AsyncSession) -> None:
|
||||
if assigned:
|
||||
assigned_count += 1
|
||||
|
||||
# ── 4. Commit all changes ──────────────────────────────────────
|
||||
# ── 4. Revoke superadmin from users NOT in env ───────────────
|
||||
revoked_count = await _revoke_stale_superadmins(
|
||||
db,
|
||||
role_id=role_id,
|
||||
admin_ids=admin_ids,
|
||||
admin_emails=admin_emails,
|
||||
)
|
||||
|
||||
# ── 5. Commit all changes ──────────────────────────────────────
|
||||
await db.commit()
|
||||
|
||||
if assigned_count > 0:
|
||||
if assigned_count > 0 or revoked_count > 0:
|
||||
logger.info(
|
||||
'Superadmin bootstrap completed',
|
||||
assigned_count=assigned_count,
|
||||
revoked_count=revoked_count,
|
||||
role_id=role_id,
|
||||
)
|
||||
else:
|
||||
logger.debug('Superadmin bootstrap: no new assignments needed')
|
||||
logger.debug('Superadmin bootstrap: no changes needed')
|
||||
|
||||
# ── 5. Safety: warn if no active superadmins exist ────────────
|
||||
# ── 6. Safety: warn if no active superadmins exist ────────────
|
||||
await _warn_if_no_superadmins(db, admin_ids, admin_emails)
|
||||
|
||||
except Exception:
|
||||
@@ -235,6 +245,60 @@ async def bootstrap_superadmins(db: AsyncSession) -> None:
|
||||
logger.exception('Failed to bootstrap superadmins, continuing startup')
|
||||
|
||||
|
||||
async def _revoke_stale_superadmins(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
role_id: int,
|
||||
admin_ids: list[int],
|
||||
admin_emails: list[str],
|
||||
) -> int:
|
||||
"""Revoke superadmin from users who are no longer in env config.
|
||||
|
||||
Env config (ADMIN_IDS / ADMIN_EMAILS) is the single source of truth.
|
||||
If a user was removed from env, their superadmin DB role is deactivated
|
||||
on the next bot restart.
|
||||
|
||||
Returns the number of revoked assignments.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(UserRole)
|
||||
.options(selectinload(UserRole.user))
|
||||
.where(
|
||||
UserRole.role_id == role_id,
|
||||
UserRole.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
active_assignments = result.scalars().all()
|
||||
|
||||
admin_ids_set = set(admin_ids)
|
||||
admin_emails_set = {e.lower() for e in admin_emails}
|
||||
|
||||
revoked = 0
|
||||
for assignment in active_assignments:
|
||||
user = assignment.user
|
||||
if user is None:
|
||||
continue
|
||||
|
||||
# Check if user is still in env config.
|
||||
# email_verified is required — symmetric with _ensure_role_by_email.
|
||||
in_env_by_id = user.telegram_id is not None and user.telegram_id in admin_ids_set
|
||||
in_env_by_email = user.email is not None and user.email_verified and user.email.lower() in admin_emails_set
|
||||
|
||||
if not in_env_by_id and not in_env_by_email:
|
||||
assignment.is_active = False
|
||||
await db.flush()
|
||||
revoked += 1
|
||||
logger.warning(
|
||||
'Revoked Superadmin role: user removed from env config',
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_role_id=assignment.id,
|
||||
)
|
||||
|
||||
return revoked
|
||||
|
||||
|
||||
async def _warn_if_no_superadmins(
|
||||
db: AsyncSession,
|
||||
admin_ids: list[int],
|
||||
@@ -281,13 +345,18 @@ async def _ensure_role_by_email(
|
||||
email: str,
|
||||
role_id: int,
|
||||
) -> bool:
|
||||
"""Assign Superadmin role to user found by email (case-insensitive). Returns True if assigned."""
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email.lower()))
|
||||
"""Assign Superadmin role to user found by verified email (case-insensitive). Returns True if assigned."""
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
func.lower(User.email) == email.lower(),
|
||||
User.email_verified.is_(True),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
logger.debug(
|
||||
'Admin user (email) not yet registered, skipping',
|
||||
'Admin user (email) not yet registered or not verified, skipping',
|
||||
email=email,
|
||||
)
|
||||
return False
|
||||
@@ -302,13 +371,13 @@ async def _assign_if_missing(
|
||||
role_id: int,
|
||||
identifier: str,
|
||||
) -> bool:
|
||||
"""Create a UserRole row if none exists for this user/role pair.
|
||||
"""Create or reactivate a UserRole row for this user/role pair.
|
||||
|
||||
If an assignment already exists (active or revoked), it is left as-is.
|
||||
This ensures that an admin-revoked role is NOT silently reactivated
|
||||
on every bot restart.
|
||||
Env config (ADMIN_IDS / ADMIN_EMAILS) is the source of truth for
|
||||
Superadmin assignments. If a previously revoked assignment exists,
|
||||
it is reactivated — the env config always wins.
|
||||
|
||||
Returns True only if a brand-new assignment was created.
|
||||
Returns True if a new assignment was created or an inactive one was reactivated.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(UserRole).where(
|
||||
@@ -325,16 +394,18 @@ async def _assign_if_missing(
|
||||
user_id=user_id,
|
||||
identifier=identifier,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
'Superadmin role was previously revoked, not reactivating '
|
||||
'(remove user from ADMIN_IDS to stop this warning, '
|
||||
'or re-assign via cabinet)',
|
||||
user_id=user_id,
|
||||
identifier=identifier,
|
||||
user_role_id=existing.id,
|
||||
)
|
||||
return False
|
||||
return False
|
||||
|
||||
# Reactivate: env config is the source of truth
|
||||
existing.is_active = True
|
||||
await db.flush()
|
||||
logger.info(
|
||||
'Reactivated Superadmin role (user is in env config)',
|
||||
user_id=user_id,
|
||||
identifier=identifier,
|
||||
user_role_id=existing.id,
|
||||
)
|
||||
return True
|
||||
|
||||
user_role = UserRole(
|
||||
user_id=user_id,
|
||||
|
||||
@@ -120,6 +120,12 @@ _ADMIN_NODE_CONNECTION_EVENTS = frozenset({'node.connection_lost', 'node.connect
|
||||
class RemnaWaveWebhookService:
|
||||
"""Processes incoming webhooks from RemnaWave backend."""
|
||||
|
||||
# In-memory guard: tracks recent panel recreations per subscription_id.
|
||||
# Prevents unbounded user.deleted → recreate → user.deleted loops.
|
||||
# Key: subscription_id, Value: datetime of last recreation attempt.
|
||||
_recent_recreations: dict[int, datetime] = {}
|
||||
_RECREATION_GUARD_SECONDS: int = 120 # 2-minute cooldown
|
||||
|
||||
def __init__(self, bot: Bot) -> None:
|
||||
self.bot = bot
|
||||
self._admin_service = AdminNotificationService(bot)
|
||||
@@ -687,6 +693,36 @@ class RemnaWaveWebhookService:
|
||||
user_id = user.id
|
||||
sub_id = subscription.id if subscription else None
|
||||
|
||||
# Evict stale entries from the recreation loop guard to prevent unbounded growth
|
||||
if self._recent_recreations:
|
||||
now = datetime.now(UTC)
|
||||
expired_keys = [
|
||||
k
|
||||
for k, v in self._recent_recreations.items()
|
||||
if (now - v).total_seconds() >= self._RECREATION_GUARD_SECONDS
|
||||
]
|
||||
for k in expired_keys:
|
||||
del self._recent_recreations[k]
|
||||
|
||||
# Guard against webhook loop: if we recently attempted panel recreation for this
|
||||
# subscription (from a previous user.deleted), skip to prevent unbounded
|
||||
# recreate→delete→recreate cycles. Uses an in-memory guard (not the generic
|
||||
# last_webhook_update_at stamp which fires on ANY webhook event).
|
||||
if sub_id and sub_id in self._recent_recreations:
|
||||
elapsed = (datetime.now(UTC) - self._recent_recreations[sub_id]).total_seconds()
|
||||
if elapsed < self._RECREATION_GUARD_SECONDS:
|
||||
logger.warning(
|
||||
'Webhook user.deleted: skipping — panel recreation was attempted recently (recreation loop guard)',
|
||||
sub_id=sub_id,
|
||||
user_id=user_id,
|
||||
elapsed=round(elapsed, 1),
|
||||
)
|
||||
return
|
||||
|
||||
# Stamp immediately (before any await) so concurrent coroutines see the guard
|
||||
if sub_id:
|
||||
self._recent_recreations[sub_id] = datetime.now(UTC)
|
||||
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
|
||||
@@ -718,23 +754,46 @@ class RemnaWaveWebhookService:
|
||||
logger.error('Webhook: user not found after rollback', user_id=user_id)
|
||||
return
|
||||
|
||||
# Check if subscription has a future end_date — likely a spurious user.deleted
|
||||
# (e.g., RemnaWave sends user.deleted during panel resync when modifying another user)
|
||||
subscription_still_valid = (
|
||||
subscription is not None and subscription.end_date is not None and subscription.end_date > datetime.now(UTC)
|
||||
)
|
||||
|
||||
if subscription:
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
logger.info(
|
||||
'Webhook: subscription marked expired (user deleted in panel) for user',
|
||||
if subscription_still_valid:
|
||||
# Subscription is still valid — don't mark as expired.
|
||||
# Clear only panel linkage fields (URLs, UUID) but keep status and squads
|
||||
# so that re-creation can restore VPN access.
|
||||
logger.warning(
|
||||
'Webhook user.deleted: subscription has future end_date, '
|
||||
'keeping active status and attempting panel re-creation',
|
||||
sub_id=sub_id,
|
||||
user_id=user_id,
|
||||
end_date=subscription.end_date,
|
||||
status=subscription.status,
|
||||
)
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
# Keep connected_squads — needed for panel re-creation
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
# Subscription expired or has no end_date — safe to mark as expired
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
logger.info(
|
||||
'Webhook: subscription marked expired (user deleted in panel) for user',
|
||||
sub_id=sub_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.connected_squads = []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
# Clear subscription data — panel user no longer exists
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.connected_squads = []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
# Remove SubscriptionServer link rows
|
||||
# Remove SubscriptionServer link rows (panel user no longer exists)
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
|
||||
|
||||
# Clear remnawave linkage
|
||||
@@ -743,7 +802,69 @@ class RemnaWaveWebhookService:
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
|
||||
if subscription_still_valid:
|
||||
# Attempt to re-create user in panel to restore VPN access.
|
||||
# If recreation fails, fall back to expiring the subscription
|
||||
# so it doesn't stay in ACTIVE-but-no-panel limbo.
|
||||
recreated = await self._attempt_panel_recreation(db, user, subscription)
|
||||
if not recreated:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.connected_squads = []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
|
||||
else:
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _attempt_panel_recreation(self, db: AsyncSession, user: User, subscription: Subscription) -> bool:
|
||||
"""Re-create user in RemnaWave panel after spurious user.deleted webhook.
|
||||
|
||||
Called when a user.deleted webhook arrives but the subscription still has a
|
||||
future end_date, indicating the deletion was likely spurious (e.g., RemnaWave
|
||||
resync when modifying another user). Attempts to restore VPN access by
|
||||
creating/updating the user in the panel.
|
||||
|
||||
Returns True if recreation succeeded, False otherwise.
|
||||
"""
|
||||
# Update the recreation guard timestamp to the actual recreation start time
|
||||
if subscription.id is not None:
|
||||
self._recent_recreations[subscription.id] = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
service = SubscriptionService()
|
||||
if not service.is_configured:
|
||||
logger.warning(
|
||||
'RemnaWave not configured, cannot re-create panel user after user.deleted',
|
||||
user_id=user.id,
|
||||
)
|
||||
return False
|
||||
|
||||
remnawave_user = await service.create_remnawave_user(db, subscription)
|
||||
if remnawave_user:
|
||||
logger.info(
|
||||
'Webhook user.deleted: successfully re-created user in panel',
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
new_uuid=remnawave_user.uuid,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.error(
|
||||
'Webhook user.deleted: failed to re-create user in panel',
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Webhook user.deleted: error re-creating user in panel',
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
error=e,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _handle_user_revoked(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
|
||||
@@ -476,7 +476,7 @@ class ReportingService:
|
||||
"""
|
||||
return select(
|
||||
func.count(Transaction.id),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0),
|
||||
).where(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == true(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
@@ -348,13 +348,17 @@ class SubscriptionService:
|
||||
# Определяем актуальный статус для отправки в RemnaWave
|
||||
# НЕ меняем статус подписки здесь - это задача scheduled job
|
||||
is_actually_active = (
|
||||
subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > current_time
|
||||
subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
|
||||
and subscription.end_date > current_time
|
||||
)
|
||||
|
||||
# Логируем если статус и end_date не согласованы (для отладки)
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time:
|
||||
if (
|
||||
subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
|
||||
and subscription.end_date <= current_time
|
||||
):
|
||||
logger.warning(
|
||||
'⚠️ update_remnawave_user: подписка имеет статус ACTIVE, но end_date <= now . Отправляем в RemnaWave как EXPIRED, но НЕ меняем статус в БД.',
|
||||
'⚠️ update_remnawave_user: подписка имеет статус ACTIVE, но end_date <= now. Отправляем в RemnaWave как DISABLED, но НЕ меняем статус в БД.',
|
||||
subscription_id=subscription.id,
|
||||
end_date=subscription.end_date,
|
||||
current_time=current_time,
|
||||
@@ -370,8 +374,10 @@ class SubscriptionService:
|
||||
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.DISABLED,
|
||||
expire_at=subscription.end_date
|
||||
if is_actually_active
|
||||
else max(subscription.end_date, current_time + timedelta(minutes=1)),
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
|
||||
telegram_id=user.telegram_id,
|
||||
@@ -843,7 +849,8 @@ class SubscriptionService:
|
||||
|
||||
current_time = datetime.now(UTC)
|
||||
is_actually_active = (
|
||||
sub.status == SubscriptionStatus.ACTIVE.value and sub.end_date > current_time
|
||||
sub.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
|
||||
and sub.end_date > current_time
|
||||
)
|
||||
|
||||
user_tag = self._resolve_user_tag(sub)
|
||||
@@ -852,8 +859,10 @@ class SubscriptionService:
|
||||
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
expire_at=sub.end_date,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.DISABLED,
|
||||
expire_at=sub.end_date
|
||||
if is_actually_active
|
||||
else max(sub.end_date, current_time + timedelta(minutes=1)),
|
||||
traffic_limit_bytes=self._gb_to_bytes(sub.traffic_limit_gb),
|
||||
traffic_limit_strategy=traffic_strategy,
|
||||
telegram_id=user.telegram_id,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Proxy URL utilities for safe logging and error handling."""
|
||||
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def mask_proxy_url(proxy_url: str) -> str:
|
||||
"""Mask credentials in a proxy URL for safe logging.
|
||||
|
||||
Handles edge cases:
|
||||
- No credentials: returns URL as-is
|
||||
- Username + password: masks both with ***
|
||||
- Password-only: masks as well
|
||||
- No explicit port: omits :port part
|
||||
"""
|
||||
parsed = urlparse(proxy_url)
|
||||
if not parsed.username and not parsed.password:
|
||||
return proxy_url
|
||||
host = parsed.hostname or 'unknown'
|
||||
port_part = f':{parsed.port}' if parsed.port else ''
|
||||
return f'{parsed.scheme}://***@{host}{port_part}'
|
||||
|
||||
|
||||
_PROXY_CRED_RE = re.compile(r'(socks[45h]*://)([^@\s]+@)', re.IGNORECASE)
|
||||
|
||||
|
||||
def sanitize_proxy_error(error: Exception) -> str:
|
||||
"""Strip proxy credentials from exception messages.
|
||||
|
||||
httpx/socksio may include the full proxy URL (with credentials)
|
||||
in connection error messages and tracebacks. This function removes
|
||||
credentials from the error string while preserving the original scheme.
|
||||
"""
|
||||
msg = str(error)
|
||||
return _PROXY_CRED_RE.sub(r'\1***@', msg)
|
||||
@@ -3776,14 +3776,13 @@ async def activate_subscription_trial_endpoint(
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
|
||||
|
||||
# Триальный тариф может быть неактивным — используется для отдельных лимитов
|
||||
trial_tariff = await get_trial_tariff(db)
|
||||
|
||||
if not trial_tariff:
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id > 0:
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
if trial_tariff and not trial_tariff.is_active:
|
||||
trial_tariff = None
|
||||
|
||||
if trial_tariff:
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
@@ -7145,7 +7144,16 @@ async def toggle_daily_subscription_pause_endpoint(
|
||||
detail={'code': 'not_daily_tariff', 'message': 'Subscription is not on a daily tariff'},
|
||||
)
|
||||
|
||||
# Определяем состояние
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
|
||||
# Lock user BEFORE reading state and mutating to prevent TOCTOU on promo group
|
||||
# and to ensure is_daily_paused mutation is not overwritten by populate_existing
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
subscription = user.subscription
|
||||
|
||||
# Определяем состояние из LOCKED экземпляра
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
is_currently_paused = getattr(subscription, 'is_daily_paused', False)
|
||||
@@ -7162,13 +7170,6 @@ async def toggle_daily_subscription_pause_endpoint(
|
||||
new_paused_state = not is_currently_paused
|
||||
subscription.is_daily_paused = new_paused_state
|
||||
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo discount
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Apply group discount to daily price (consistent with DailySubscriptionService and resume-after-topup)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
@@ -7178,6 +7179,8 @@ async def toggle_daily_subscription_pause_endpoint(
|
||||
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
|
||||
)
|
||||
|
||||
resume_transaction = None
|
||||
|
||||
# Если снимаем с паузы, проверяем баланс и списываем оплату
|
||||
if not new_paused_state:
|
||||
if daily_price > 0 and user.balance_kopeks < daily_price:
|
||||
@@ -7202,6 +7205,7 @@ async def toggle_daily_subscription_pause_endpoint(
|
||||
daily_price,
|
||||
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
mark_as_paid_subscription=True,
|
||||
commit=False,
|
||||
)
|
||||
if not deducted:
|
||||
raise HTTPException(
|
||||
@@ -7217,32 +7221,51 @@ async def toggle_daily_subscription_pause_endpoint(
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
|
||||
try:
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to create resume transaction in miniapp', error=exc)
|
||||
resume_transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Баланс списан — теперь активируем
|
||||
now = datetime.now(UTC)
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
subscription.last_daily_charge_at = now
|
||||
subscription.end_date = now + timedelta(days=1)
|
||||
|
||||
logger.info(
|
||||
'✅ Суточная подписка восстановлена в ACTIVE (miniapp)',
|
||||
'Суточная подписка восстановлена в ACTIVE (miniapp)',
|
||||
subscription_id=subscription.id,
|
||||
previous_status='disabled/expired',
|
||||
)
|
||||
|
||||
# Re-apply is_daily_paused on the current identity-mapped instance
|
||||
# (subtract_user_balance with populate_existing=True may have reloaded it from DB)
|
||||
subscription.is_daily_paused = new_paused_state
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
# Emit deferred transaction side effects after commit
|
||||
if not new_paused_state and was_disabled and daily_price > 0 and resume_transaction is not None:
|
||||
try:
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db=db,
|
||||
transaction=resume_transaction,
|
||||
amount_kopeks=daily_price,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to emit resume transaction side effects (miniapp)', error=exc)
|
||||
|
||||
# Синхронизация с RemnaWave только при возобновлении из DISABLED/EXPIRED
|
||||
if not new_paused_state and was_disabled:
|
||||
try:
|
||||
|
||||
@@ -76,7 +76,7 @@ async def _get_overview(db: AsyncSession) -> dict[str, object]:
|
||||
today = datetime.now(UTC).date()
|
||||
today_transactions = (
|
||||
await db.scalar(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
func.date(Transaction.created_at) == today,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
|
||||
@@ -24,7 +24,7 @@ from app.database.crud.user import (
|
||||
get_user_by_telegram_id,
|
||||
update_user,
|
||||
)
|
||||
from app.database.models import PromoGroup, Subscription, User, UserStatus
|
||||
from app.database.models import PaymentMethod, PromoGroup, Subscription, User, UserStatus
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
@@ -317,6 +317,7 @@ async def update_balance(
|
||||
amount_kopeks=payload.amount_kopeks,
|
||||
description=payload.description or 'Корректировка через веб-API',
|
||||
create_transaction=payload.create_transaction,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
)
|
||||
|
||||
if not success:
|
||||
|
||||
@@ -73,12 +73,13 @@ def upgrade() -> None:
|
||||
)
|
||||
)
|
||||
|
||||
# CryptoBot: payload (text) column with JSON inside, no metadata_json
|
||||
# CryptoBot: payload (text) column with JSON inside, no metadata_json.
|
||||
# Filter payload LIKE '{%' to skip non-JSON values (e.g. "balance_2_10000").
|
||||
op.execute(
|
||||
sa.text(
|
||||
'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_cryptobot_payments_payload_purchase_token '
|
||||
"ON cryptobot_payments ((CAST(payload AS json) ->> 'purchase_token')) "
|
||||
"WHERE status = 'paid'"
|
||||
"WHERE status = 'paid' AND payload LIKE '{%'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""fix payment_method=NULL for admin manual top-ups
|
||||
|
||||
Revision ID: 0044
|
||||
Revises: 0043
|
||||
Create Date: 2026-03-21
|
||||
|
||||
Data-only migration: sets payment_method='manual' on deposit transactions
|
||||
that were created by admin top-ups (Cabinet API, WebAPI, Telegram bot)
|
||||
but stored with payment_method=NULL due to a bug.
|
||||
|
||||
Strategy: exclude all known non-admin deposit patterns that legitimately
|
||||
have payment_method=NULL (wheel prizes, campaigns, promo codes, referral
|
||||
purchase commissions, legacy webhook duplicates). Everything remaining
|
||||
with type='deposit' AND payment_method IS NULL is an admin manual top-up.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0044'
|
||||
down_revision: Union[str, None] = '0043'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
UPDATE transactions
|
||||
SET payment_method = 'manual'
|
||||
WHERE type = 'deposit'
|
||||
AND payment_method IS NULL
|
||||
AND is_completed = TRUE
|
||||
AND (description IS NULL OR (
|
||||
description NOT LIKE 'Выигрыш в колесе удачи:%'
|
||||
AND description NOT LIKE 'Бонус за регистрацию по кампании%'
|
||||
AND description NOT LIKE 'Бонус по промокоду%'
|
||||
AND description NOT LIKE 'Комиссия %'
|
||||
AND description NOT LIKE 'Бонус за первое пополнение%'
|
||||
AND description NOT LIKE 'Бонус за реферала%'
|
||||
AND description NOT LIKE 'Восстановленный бонус%'
|
||||
AND description NOT LIKE 'Пополнение через Tribute%'
|
||||
AND description NOT LIKE 'Пополнение через Telegram Stars%'
|
||||
))
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("""
|
||||
UPDATE transactions
|
||||
SET payment_method = NULL
|
||||
WHERE type = 'deposit'
|
||||
AND payment_method = 'manual'
|
||||
AND is_completed = TRUE
|
||||
AND (description IS NULL OR (
|
||||
description NOT LIKE 'Выигрыш в колесе удачи:%'
|
||||
AND description NOT LIKE 'Бонус за регистрацию по кампании%'
|
||||
AND description NOT LIKE 'Бонус по промокоду%'
|
||||
AND description NOT LIKE 'Комиссия %'
|
||||
AND description NOT LIKE 'Бонус за первое пополнение%'
|
||||
AND description NOT LIKE 'Бонус за реферала%'
|
||||
AND description NOT LIKE 'Восстановленный бонус%'
|
||||
AND description NOT LIKE 'Пополнение через Tribute%'
|
||||
AND description NOT LIKE 'Пополнение через Telegram Stars%'
|
||||
))
|
||||
""")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""add receipt_uuid and receipt_created_at to guest_purchases
|
||||
|
||||
Revision ID: 0045
|
||||
Revises: 0044
|
||||
Create Date: 2026-03-21
|
||||
|
||||
Adds receipt_uuid and receipt_created_at columns to guest_purchases table
|
||||
so that NaloGO fiscal receipt UUIDs are persisted on the purchase record
|
||||
itself (not only on transaction or in Redis). This provides a persistent
|
||||
DB-level dedup guard and audit trail for receipts created in the
|
||||
PENDING_ACTIVATION path and code-only gift path where no Transaction
|
||||
exists at receipt creation time.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0045'
|
||||
down_revision: str | None = '0044'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('guest_purchases', sa.Column('receipt_uuid', sa.String(255), nullable=True))
|
||||
op.add_column('guest_purchases', sa.Column('receipt_created_at', sa.DateTime(timezone=True), nullable=True))
|
||||
op.create_index('ix_guest_purchases_receipt_uuid', 'guest_purchases', ['receipt_uuid'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_guest_purchases_receipt_uuid', table_name='guest_purchases')
|
||||
op.drop_column('guest_purchases', 'receipt_created_at')
|
||||
op.drop_column('guest_purchases', 'receipt_uuid')
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.37.0"
|
||||
version = "3.41.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
@@ -25,6 +25,7 @@ dependencies = [
|
||||
'structlog>=25.1.0,<26',
|
||||
'rich>=14.0',
|
||||
'aiohttp-socks>=0.10.1',
|
||||
'httpx[socks]>=0.27.0',
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -623,6 +623,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
socks = [
|
||||
{ name = "socksio" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@@ -1137,7 +1142,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remnawave-bedolaga-telegram-bot"
|
||||
version = "3.36.0"
|
||||
version = "3.38.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiogram" },
|
||||
@@ -1148,6 +1153,7 @@ dependencies = [
|
||||
{ name = "bcrypt" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "httpx", extra = ["socks"] },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-dateutil" },
|
||||
@@ -1179,6 +1185,7 @@ requires-dist = [
|
||||
{ name = "bcrypt", specifier = ">=5.0.0" },
|
||||
{ name = "cryptography", specifier = ">=44.0.1" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" },
|
||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.27.0" },
|
||||
{ name = "packaging", specifier = ">=26.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.11.0" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||
@@ -1322,6 +1329,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socksio"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.46"
|
||||
|
||||
Reference in New Issue
Block a user