From e16eba10d936a2817a6cf5e1496fa0b852ea2e54 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 05:48:16 +0000 Subject: [PATCH 01/12] chore(main): release 3.36.1 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 8 ++++++++ Dockerfile | 2 +- pyproject.toml | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 53b29bc9..7508771b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.36.0" + ".": "3.36.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b93fa5e..8db0c168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [3.36.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.36.0...v3.36.1) (2026-03-20) + + +### Bug Fixes + +* handle duplicate admin roles in RBAC bootstrap ([877b1cd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/877b1cde11c462c0cf5692119ca09944476a2fb6)) +* make migration 0042 idempotent for retry_count column ([5faf701](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5faf7015ac043f44366fd2a5e00be19eab76b945)) + ## [3.36.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.35.0...v3.36.0) (2026-03-20) diff --git a/Dockerfile b/Dockerfile index f1bfa181..ae566410 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ FROM python:3.13-slim -ARG VERSION="v3.36.0" # x-release-please-version +ARG VERSION="v3.36.1" # x-release-please-version ARG BUILD_DATE ARG VCS_REF diff --git a/pyproject.toml b/pyproject.toml index 6c98fbee..ab02d4cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'remnawave-bedolaga-telegram-bot' -version = "3.36.0" +version = "3.36.1" description = 'Telegram bot for RemnaWave VPN service' readme = 'README.md' license = { text = 'MIT' } From 13dba5a303604f8d39e85d1382a4d36225f957ac Mon Sep 17 00:00:00 2001 From: "sMedia.tech" <81699471+smediainfo@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:10:56 +0300 Subject: [PATCH 02/12] fix: include SUBSCRIPTION_PAYMENT in dashboard revenue calculations Guest/landing purchases create SUBSCRIPTION_PAYMENT transactions (not DEPOSIT), so they were excluded from income_today, total_income, revenue_by_period, and payment_methods breakdown. Also use func.abs() for SUBSCRIPTION_PAYMENT amounts since they are stored as negative values. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/database/crud/transaction.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 0338f9ce..9192dfed 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -278,11 +278,11 @@ async def get_transactions_statistics( if not end_date: end_date = datetime.now(UTC) - # Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения) + # Доход считаем по реальным платежам + прямые покупки подписок (лендинги) income_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.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.created_at <= end_date, @@ -343,7 +343,7 @@ async def get_transactions_statistics( ) .where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.created_at <= end_date, @@ -363,11 +363,11 @@ async def get_transactions_statistics( ) transactions_today = today_result.scalar() - # Доход за сегодня - только реальные платежи + # Доход за сегодня — реальные платежи + прямые покупки подписок (лендинги) today_income_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.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= today, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), @@ -391,17 +391,17 @@ async def get_transactions_statistics( async def get_revenue_by_period(db: AsyncSession, days: int = 30) -> list[dict]: - """Доход по дням - только реальные платежи.""" + """Доход по дням — реальные платежи + прямые покупки подписок (лендинги).""" start_date = datetime.now(UTC) - timedelta(days=days) result = await db.execute( select( func.date(Transaction.created_at).label('date'), - func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'), + func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'), ) .where( and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), From 27ef75214e73211a68d541f5375ca14275ea86e9 Mon Sep 17 00:00:00 2001 From: "sMedia.tech" <81699471+smediainfo@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:28:47 +0300 Subject: [PATCH 03/12] fix: include SUBSCRIPTION_PAYMENT in sales summary and deposits stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as transaction.py — sales dashboard summary and deposits breakdown were only counting DEPOSIT transactions, missing all landing page purchases (SUBSCRIPTION_PAYMENT). Co-Authored-By: Claude Opus 4.6 (1M context) --- app/cabinet/routes/admin_sales_stats.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/cabinet/routes/admin_sales_stats.py b/app/cabinet/routes/admin_sales_stats.py index 1efa91ec..71595d83 100644 --- a/app/cabinet/routes/admin_sales_stats.py +++ b/app/cabinet/routes/admin_sales_stats.py @@ -112,11 +112,11 @@ async def get_sales_summary( try: period_start, period_end = _parse_period(days, start_date, end_date) - # Total revenue (deposits with real payment methods) + # Total revenue (deposits + direct subscription payments with real payment methods) 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.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), Transaction.created_at >= period_start, @@ -1079,7 +1079,7 @@ async def get_deposits_stats( methods_with_manual = [*REAL_PAYMENT_METHODS, PaymentMethod.MANUAL.value] base_filter = and_( - Transaction.type == TransactionType.DEPOSIT.value, + Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.payment_method.in_(methods_with_manual), Transaction.created_at >= period_start, @@ -1089,7 +1089,7 @@ async def get_deposits_stats( totals_result = await db.execute( select( 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) ) totals = totals_result.one() From 6982d27378d0dbe5c7b37d1491e6c9d0461dffc8 Mon Sep 17 00:00:00 2001 From: "sMedia.tech" <81699471+smediainfo@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:34:00 +0300 Subject: [PATCH 04/12] fix: include SUBSCRIPTION_PAYMENT in recent payments today/week totals Co-Authored-By: Claude Opus 4.6 (1M context) --- app/cabinet/routes/admin_stats.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py index de489a82..f29467ba 100644 --- a/app/cabinet/routes/admin_stats.py +++ b/app/cabinet/routes/admin_stats.py @@ -926,9 +926,9 @@ async def get_recent_payments( total_count = total_count_result.scalar() or 0 today_total_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.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= today_start, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), @@ -938,9 +938,9 @@ async def get_recent_payments( total_today = today_total_result.scalar() or 0 week_total_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.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), Transaction.is_completed == True, Transaction.created_at >= week_ago, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), From 226d3f2766bb0842e5a4aefb1458a9e40389108e Mon Sep 17 00:00:00 2001 From: "sMedia.tech" <81699471+smediainfo@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:15:54 +0300 Subject: [PATCH 05/12] fix: default payment_method to BALANCE for bot subscription payments Prevents double-counting in revenue: bot users create DEPOSIT (real money) + SUBSCRIPTION_PAYMENT (balance debit). Without explicit payment_method, subscription_payment had NULL which was patched to kassa_ai, causing both to count as real revenue. Now create_transaction defaults to BALANCE for SUBSCRIPTION_PAYMENT when no payment_method is specified. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/database/crud/transaction.py | 5 +++++ app/services/subscription_purchase_service.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 9192dfed..128ea3d8 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -51,6 +51,11 @@ async def create_transaction( else amount_kopeks ) + # Default payment_method to BALANCE for subscription/gift payments from bot (not landing) + # to avoid double-counting with DEPOSIT in revenue calculations + if payment_method is None and type in (TransactionType.SUBSCRIPTION_PAYMENT, TransactionType.GIFT_PAYMENT): + payment_method = PaymentMethod.BALANCE + transaction = Transaction( user_id=user_id, type=type.value, diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index c06b989a..74d02139 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -22,7 +22,7 @@ from app.database.crud.subscription_conversion import ( ) from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance -from app.database.models import ServerSquad, Subscription, SubscriptionStatus, TransactionType, User +from app.database.models import PaymentMethod, ServerSquad, Subscription, SubscriptionStatus, TransactionType, User from app.localization.texts import get_texts from app.services.subscription_service import SubscriptionService from app.utils.pricing_utils import ( @@ -1109,6 +1109,7 @@ class MiniAppSubscriptionPurchaseService: type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=pricing.final_total, description=f'Подписка на {pricing.selection.period.days} дней ({pricing.months} мес)', + payment_method=PaymentMethod.BALANCE, ) await db.refresh(user) From 1882909b3e60f4959921ae085e909bc91c0756b3 Mon Sep 17 00:00:00 2001 From: "sMedia.tech" <81699471+smediainfo@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:48:05 +0300 Subject: [PATCH 06/12] fix: derive income_today from revenue_chart to ensure consistency Co-Authored-By: Claude Opus 4.6 (1M context) --- app/cabinet/routes/admin_stats.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py index f29467ba..30ac67d5 100644 --- a/app/cabinet/routes/admin_stats.py +++ b/app/cabinet/routes/admin_stats.py @@ -275,6 +275,14 @@ async def get_dashboard_stats( # Get tariff statistics tariff_stats = await _get_tariff_stats(db) + # Derive income_today from revenue_chart to ensure consistency with chart + today_str = now.date().isoformat() + income_today_from_chart = sum( + item.get('amount_kopeks', 0) for item in revenue_data if str(item.get('date', '')) == today_str + ) + # Use chart-derived value if available, otherwise fall back to trans_stats + income_today_kopeks = income_today_from_chart or trans_stats.get('today', {}).get('income_kopeks', 0) + # Build response return DashboardStats( nodes=nodes_data, @@ -290,8 +298,8 @@ async def get_dashboard_stats( trial_to_paid_conversion=sub_stats.get('trial_to_paid_conversion', 0.0), ), financial=FinancialStats( - income_today_kopeks=trans_stats.get('today', {}).get('income_kopeks', 0), - income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 100, + income_today_kopeks=income_today_kopeks, + income_today_rubles=income_today_kopeks / 100, income_month_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0), income_month_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100, income_total_kopeks=all_time_stats.get('totals', {}).get('income_kopeks', 0), From fedcf2569a153ac40699dd5b402208ac86db0fc3 Mon Sep 17 00:00:00 2001 From: "sMedia.tech" <81699471+smediainfo@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:22:22 +0300 Subject: [PATCH 07/12] feat: include cabinet credentials in subscription delivered email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add login/password block to the "subscription ready" email template so users receive their cabinet credentials in the first email. The credentials block is only shown when cabinet_password is present (new accounts). All 5 locales updated (ru, en, zh, ua, fa). The separate credentials email (GUEST_CABINET_CREDENTIALS) is still sent as before — this provides redundancy in case one email doesn't arrive (e.g. due to SMTP quota limits). Co-Authored-By: Claude Opus 4.6 (1M context) --- app/cabinet/services/email_templates.py | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/app/cabinet/services/email_templates.py b/app/cabinet/services/email_templates.py index a650ddc7..476e15fd 100644 --- a/app/cabinet/services/email_templates.py +++ b/app/cabinet/services/email_templates.py @@ -1373,6 +1373,8 @@ class EmailNotificationTemplates: tariff_name = html.escape(context.get('tariff_name', '')) period_days = context.get('period_days', 0) cabinet_url = html.escape(context.get('cabinet_url', '')) + cabinet_email = html.escape(context.get('cabinet_email', '')) + cabinet_password = context.get('cabinet_password', '') subjects = { 'ru': 'Ваша VPN подписка готова', @@ -1382,6 +1384,46 @@ class EmailNotificationTemplates: 'fa': 'اشتراک VPN شما آماده است', } + creds_block_ru = f""" +
+

Данные для входа в личный кабинет:

+

Email: {cabinet_email}

+

Пароль: {cabinet_password}

+
+ """ if cabinet_password else '' + + creds_block_en = f""" +
+

Your cabinet login credentials:

+

Email: {cabinet_email}

+

Password: {cabinet_password}

+
+ """ if cabinet_password else '' + + creds_block_zh = f""" +
+

个人中心登录信息:

+

Email: {cabinet_email}

+

密码: {cabinet_password}

+
+ """ if cabinet_password else '' + + creds_block_ua = f""" +
+

Дані для входу в особистий кабінет:

+

Email: {cabinet_email}

+

Пароль: {cabinet_password}

+
+ """ if cabinet_password else '' + + creds_block_fa = f""" +
+

اطلاعات ورود به پنل کاربری:

+

Email: {cabinet_email}

+

رمز عبور: {cabinet_password}

+
+ """ if cabinet_password else '' + bodies = { 'ru': f"""

Ваша VPN подписка готова!

@@ -1389,6 +1431,7 @@ class EmailNotificationTemplates:

Тариф: {tariff_name}

Период: {period_days} дней

+ {creds_block_ru}

Подписка активирована в вашем личном кабинете.

Перейти в личный кабинет

""", @@ -1398,6 +1441,7 @@ class EmailNotificationTemplates:

Plan: {tariff_name}

Period: {period_days} days

+ {creds_block_en}

Your subscription has been activated in your cabinet.

Go to Cabinet

""", @@ -1407,6 +1451,7 @@ class EmailNotificationTemplates:

套餐: {tariff_name}

期限: {period_days} 天

+ {creds_block_zh}

订阅已在您的个人中心激活。

前往个人中心

""", @@ -1416,6 +1461,7 @@ class EmailNotificationTemplates:

Тариф: {tariff_name}

Період: {period_days} днів

+ {creds_block_ua}

Підписка активована у вашому особистому кабінеті.

Перейти до кабінету

""", @@ -1425,6 +1471,7 @@ class EmailNotificationTemplates:

طرح: {tariff_name}

مدت: {period_days} روز

+ {creds_block_fa}

اشتراک شما در پنل کاربری فعال شده است.

رفتن به پنل کاربری

""", From f82a713110c90494e41f2de9910f5dc60a06962e Mon Sep 17 00:00:00 2001 From: "sMedia.tech" <81699471+smediainfo@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:28:18 +0300 Subject: [PATCH 08/12] feat: expose cabinet_email/password vars in subscription delivered template admin UI Add cabinet_email and cabinet_password to context_vars and sample_contexts for guest_subscription_delivered template type so they appear in the admin email template editor. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/cabinet/routes/admin_email_templates.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/cabinet/routes/admin_email_templates.py b/app/cabinet/routes/admin_email_templates.py index 099411c1..b1c133e3 100644 --- a/app/cabinet/routes/admin_email_templates.py +++ b/app/cabinet/routes/admin_email_templates.py @@ -312,7 +312,7 @@ TEMPLATE_TYPES = [ 'zh': '通过落地页成功付款后发送给买家的邮件', 'ua': 'Лист покупцю після успішної оплати через лендінг', }, - 'context_vars': ['tariff_name', 'period_days', 'cabinet_url'], + 'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'], }, { 'type': 'guest_activation_required', @@ -425,6 +425,8 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = { 'tariff_name': 'Premium', 'period_days': 30, 'cabinet_url': 'https://example.com/cabinet', + 'cabinet_email': 'user@example.com', + 'cabinet_password': 'SecurePass123', }, 'guest_activation_required': { 'tariff_name': 'Premium', From bf0ba22790a5564d0e0fce39900edd7cec583b2b Mon Sep 17 00:00:00 2001 From: "sMedia.tech" <81699471+smediainfo@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:30:47 +0300 Subject: [PATCH 09/12] style: ruff format email_templates.py Co-Authored-By: Claude Opus 4.6 (1M context) --- app/cabinet/services/email_templates.py | 40 ++++++++++++++++++------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/app/cabinet/services/email_templates.py b/app/cabinet/services/email_templates.py index 476e15fd..59f07a20 100644 --- a/app/cabinet/services/email_templates.py +++ b/app/cabinet/services/email_templates.py @@ -1384,45 +1384,65 @@ class EmailNotificationTemplates: 'fa': 'اشتراک VPN شما آماده است', } - creds_block_ru = f""" + creds_block_ru = ( + f"""

Данные для входа в личный кабинет:

Email: {cabinet_email}

Пароль: {cabinet_password}

- """ if cabinet_password else '' + """ + if cabinet_password + else '' + ) - creds_block_en = f""" + creds_block_en = ( + f"""

Your cabinet login credentials:

Email: {cabinet_email}

Password: {cabinet_password}

- """ if cabinet_password else '' + """ + if cabinet_password + else '' + ) - creds_block_zh = f""" + creds_block_zh = ( + f"""

个人中心登录信息:

Email: {cabinet_email}

密码: {cabinet_password}

- """ if cabinet_password else '' + """ + if cabinet_password + else '' + ) - creds_block_ua = f""" + creds_block_ua = ( + f"""

Дані для входу в особистий кабінет:

Email: {cabinet_email}

Пароль: {cabinet_password}

- """ if cabinet_password else '' + """ + if cabinet_password + else '' + ) - creds_block_fa = f""" + creds_block_fa = ( + f"""

اطلاعات ورود به پنل کاربری:

Email: {cabinet_email}

رمز عبور: {cabinet_password}

- """ if cabinet_password else '' + """ + if cabinet_password + else '' + ) bodies = { 'ru': f""" From d400cd7b49cb18edf8545a5af54009561610218a Mon Sep 17 00:00:00 2001 From: "sMedia.tech" <81699471+smediainfo@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:14:27 +0300 Subject: [PATCH 10/12] feat: broadcast caption validation + landing daily created stats - Validate message length for media broadcasts (1024 char Telegram limit) - Add created count per day to landing stats API (separate from successful) - Fix total_purchases to show total_created instead of total_successful Co-Authored-By: Claude Opus 4.6 (1M context) --- app/cabinet/routes/admin_broadcasts.py | 7 +++++++ app/cabinet/routes/admin_landings.py | 22 +++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/cabinet/routes/admin_broadcasts.py b/app/cabinet/routes/admin_broadcasts.py index cebc4068..b72c6b8f 100644 --- a/app/cabinet/routes/admin_broadcasts.py +++ b/app/cabinet/routes/admin_broadcasts.py @@ -411,6 +411,13 @@ async def create_broadcast( media_payload = request.media + # Validate caption length for media messages (Telegram limit: 1024 chars) + if media_payload and len(message_text) > 1024: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Текст слишком длинный для сообщения с медиа. Максимум 1024 символов, сейчас {len(message_text)}. Сократите текст или уберите медиафайл.', + ) + # Create broadcast record broadcast = BroadcastHistory( target_type=request.target, diff --git a/app/cabinet/routes/admin_landings.py b/app/cabinet/routes/admin_landings.py index af8a4e70..73bb94fc 100644 --- a/app/cabinet/routes/admin_landings.py +++ b/app/cabinet/routes/admin_landings.py @@ -483,6 +483,7 @@ class OrderRequest(BaseModel): class LandingDailyStat(BaseModel): date: str # YYYY-MM-DD + created: int = 0 purchases: int revenue_kopeks: int gifts: int @@ -844,17 +845,35 @@ async def get_landing_stats( ) daily_rows = {str(r.day): r for r in daily_result.all()} + # Created per day (all statuses, by created_at) + day_created_utc = func.date(func.timezone('UTC', GuestPurchase.created_at)) + created_result = await db.execute( + select( + day_created_utc.label('day'), + func.count(GuestPurchase.id).label('created'), + ) + .where( + GuestPurchase.landing_id == landing_id, + GuestPurchase.created_at >= cutoff, + ) + .group_by(day_created_utc) + .order_by(day_created_utc) + ) + created_rows = {str(r.day): r.created for r in created_result.all()} + # Fill missing days with zeros today = now.date() daily_stats: list[LandingDailyStat] = [] for i in range(_STATS_PERIOD_DAYS, -1, -1): day = today - timedelta(days=i) day_str = day.isoformat() + day_created = created_rows.get(day_str, 0) if day_str in daily_rows: r = daily_rows[day_str] daily_stats.append( LandingDailyStat( date=day_str, + created=day_created, purchases=r.purchases, revenue_kopeks=r.revenue_kopeks, gifts=r.gifts, @@ -864,6 +883,7 @@ async def get_landing_stats( daily_stats.append( LandingDailyStat( date=day_str, + created=day_created, purchases=0, revenue_kopeks=0, gifts=0, @@ -897,7 +917,7 @@ async def get_landing_stats( ] return LandingStatsResponse( - total_purchases=total_successful, + total_purchases=total_created, total_revenue_kopeks=total_revenue_kopeks, total_gifts=total_gifts, total_regular=total_regular, From 801921ff74107daccc17e49a0335c7b133268280 Mon Sep 17 00:00:00 2001 From: "sMedia.tech" <81699471+smediainfo@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:48:02 +0300 Subject: [PATCH 11/12] fix: increase landing purchase rate limit from 5 to 30 req/min Users hitting 429 Too Many Requests when trying to purchase on landing. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/cabinet/routes/landing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/cabinet/routes/landing.py b/app/cabinet/routes/landing.py index 8a93ec8b..b0fd4a5d 100644 --- a/app/cabinet/routes/landing.py +++ b/app/cabinet/routes/landing.py @@ -550,7 +550,7 @@ async def create_landing_purchase( No authentication required. """ client_ip = get_client_ip(raw_request) - if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=5, window=60, fail_closed=True): + if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=30, window=60, fail_closed=True): raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many purchase attempts, please try again later', From 42ddadec5bd5f363f4eb323ff069349469194043 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 21 Mar 2026 03:02:36 +0300 Subject: [PATCH 12/12] Revert "fix: include landing page revenue in dashboard statistics" --- app/cabinet/routes/admin_broadcasts.py | 7 -- app/cabinet/routes/admin_email_templates.py | 4 +- app/cabinet/routes/admin_landings.py | 22 +----- app/cabinet/routes/admin_sales_stats.py | 10 +-- app/cabinet/routes/admin_stats.py | 20 ++---- app/cabinet/routes/landing.py | 2 +- app/cabinet/services/email_templates.py | 67 ------------------- app/database/crud/transaction.py | 25 +++---- app/services/subscription_purchase_service.py | 3 +- 9 files changed, 25 insertions(+), 135 deletions(-) diff --git a/app/cabinet/routes/admin_broadcasts.py b/app/cabinet/routes/admin_broadcasts.py index b72c6b8f..cebc4068 100644 --- a/app/cabinet/routes/admin_broadcasts.py +++ b/app/cabinet/routes/admin_broadcasts.py @@ -411,13 +411,6 @@ async def create_broadcast( media_payload = request.media - # Validate caption length for media messages (Telegram limit: 1024 chars) - if media_payload and len(message_text) > 1024: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Текст слишком длинный для сообщения с медиа. Максимум 1024 символов, сейчас {len(message_text)}. Сократите текст или уберите медиафайл.', - ) - # Create broadcast record broadcast = BroadcastHistory( target_type=request.target, diff --git a/app/cabinet/routes/admin_email_templates.py b/app/cabinet/routes/admin_email_templates.py index b1c133e3..099411c1 100644 --- a/app/cabinet/routes/admin_email_templates.py +++ b/app/cabinet/routes/admin_email_templates.py @@ -312,7 +312,7 @@ TEMPLATE_TYPES = [ 'zh': '通过落地页成功付款后发送给买家的邮件', 'ua': 'Лист покупцю після успішної оплати через лендінг', }, - 'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'], + 'context_vars': ['tariff_name', 'period_days', 'cabinet_url'], }, { 'type': 'guest_activation_required', @@ -425,8 +425,6 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = { 'tariff_name': 'Premium', 'period_days': 30, 'cabinet_url': 'https://example.com/cabinet', - 'cabinet_email': 'user@example.com', - 'cabinet_password': 'SecurePass123', }, 'guest_activation_required': { 'tariff_name': 'Premium', diff --git a/app/cabinet/routes/admin_landings.py b/app/cabinet/routes/admin_landings.py index 73bb94fc..af8a4e70 100644 --- a/app/cabinet/routes/admin_landings.py +++ b/app/cabinet/routes/admin_landings.py @@ -483,7 +483,6 @@ class OrderRequest(BaseModel): class LandingDailyStat(BaseModel): date: str # YYYY-MM-DD - created: int = 0 purchases: int revenue_kopeks: int gifts: int @@ -845,35 +844,17 @@ async def get_landing_stats( ) daily_rows = {str(r.day): r for r in daily_result.all()} - # Created per day (all statuses, by created_at) - day_created_utc = func.date(func.timezone('UTC', GuestPurchase.created_at)) - created_result = await db.execute( - select( - day_created_utc.label('day'), - func.count(GuestPurchase.id).label('created'), - ) - .where( - GuestPurchase.landing_id == landing_id, - GuestPurchase.created_at >= cutoff, - ) - .group_by(day_created_utc) - .order_by(day_created_utc) - ) - created_rows = {str(r.day): r.created for r in created_result.all()} - # Fill missing days with zeros today = now.date() daily_stats: list[LandingDailyStat] = [] for i in range(_STATS_PERIOD_DAYS, -1, -1): day = today - timedelta(days=i) day_str = day.isoformat() - day_created = created_rows.get(day_str, 0) if day_str in daily_rows: r = daily_rows[day_str] daily_stats.append( LandingDailyStat( date=day_str, - created=day_created, purchases=r.purchases, revenue_kopeks=r.revenue_kopeks, gifts=r.gifts, @@ -883,7 +864,6 @@ async def get_landing_stats( daily_stats.append( LandingDailyStat( date=day_str, - created=day_created, purchases=0, revenue_kopeks=0, gifts=0, @@ -917,7 +897,7 @@ async def get_landing_stats( ] return LandingStatsResponse( - total_purchases=total_created, + total_purchases=total_successful, total_revenue_kopeks=total_revenue_kopeks, total_gifts=total_gifts, total_regular=total_regular, diff --git a/app/cabinet/routes/admin_sales_stats.py b/app/cabinet/routes/admin_sales_stats.py index 71595d83..1efa91ec 100644 --- a/app/cabinet/routes/admin_sales_stats.py +++ b/app/cabinet/routes/admin_sales_stats.py @@ -112,11 +112,11 @@ async def get_sales_summary( try: period_start, period_end = _parse_period(days, start_date, end_date) - # Total revenue (deposits + direct subscription payments with real payment methods) + # Total revenue (deposits with real payment methods) revenue_result = await db.execute( - select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( and_( - Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), + Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), Transaction.created_at >= period_start, @@ -1079,7 +1079,7 @@ async def get_deposits_stats( methods_with_manual = [*REAL_PAYMENT_METHODS, PaymentMethod.MANUAL.value] base_filter = and_( - Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), + Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.payment_method.in_(methods_with_manual), Transaction.created_at >= period_start, @@ -1089,7 +1089,7 @@ async def get_deposits_stats( totals_result = await db.execute( select( func.count(Transaction.id).label('count'), - func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'), + func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'), ).where(base_filter) ) totals = totals_result.one() diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py index 30ac67d5..de489a82 100644 --- a/app/cabinet/routes/admin_stats.py +++ b/app/cabinet/routes/admin_stats.py @@ -275,14 +275,6 @@ async def get_dashboard_stats( # Get tariff statistics tariff_stats = await _get_tariff_stats(db) - # Derive income_today from revenue_chart to ensure consistency with chart - today_str = now.date().isoformat() - income_today_from_chart = sum( - item.get('amount_kopeks', 0) for item in revenue_data if str(item.get('date', '')) == today_str - ) - # Use chart-derived value if available, otherwise fall back to trans_stats - income_today_kopeks = income_today_from_chart or trans_stats.get('today', {}).get('income_kopeks', 0) - # Build response return DashboardStats( nodes=nodes_data, @@ -298,8 +290,8 @@ async def get_dashboard_stats( trial_to_paid_conversion=sub_stats.get('trial_to_paid_conversion', 0.0), ), financial=FinancialStats( - income_today_kopeks=income_today_kopeks, - income_today_rubles=income_today_kopeks / 100, + income_today_kopeks=trans_stats.get('today', {}).get('income_kopeks', 0), + income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 100, income_month_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0), income_month_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100, income_total_kopeks=all_time_stats.get('totals', {}).get('income_kopeks', 0), @@ -934,9 +926,9 @@ async def get_recent_payments( total_count = total_count_result.scalar() or 0 today_total_result = await db.execute( - select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( and_( - Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), + Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.created_at >= today_start, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), @@ -946,9 +938,9 @@ async def get_recent_payments( total_today = today_total_result.scalar() or 0 week_total_result = await db.execute( - select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( and_( - Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), + Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.created_at >= week_ago, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), diff --git a/app/cabinet/routes/landing.py b/app/cabinet/routes/landing.py index b0fd4a5d..8a93ec8b 100644 --- a/app/cabinet/routes/landing.py +++ b/app/cabinet/routes/landing.py @@ -550,7 +550,7 @@ async def create_landing_purchase( No authentication required. """ client_ip = get_client_ip(raw_request) - if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=30, window=60, fail_closed=True): + if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=5, window=60, fail_closed=True): raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many purchase attempts, please try again later', diff --git a/app/cabinet/services/email_templates.py b/app/cabinet/services/email_templates.py index 59f07a20..a650ddc7 100644 --- a/app/cabinet/services/email_templates.py +++ b/app/cabinet/services/email_templates.py @@ -1373,8 +1373,6 @@ class EmailNotificationTemplates: tariff_name = html.escape(context.get('tariff_name', '')) period_days = context.get('period_days', 0) cabinet_url = html.escape(context.get('cabinet_url', '')) - cabinet_email = html.escape(context.get('cabinet_email', '')) - cabinet_password = context.get('cabinet_password', '') subjects = { 'ru': 'Ваша VPN подписка готова', @@ -1384,66 +1382,6 @@ class EmailNotificationTemplates: 'fa': 'اشتراک VPN شما آماده است', } - creds_block_ru = ( - f""" -
-

Данные для входа в личный кабинет:

-

Email: {cabinet_email}

-

Пароль: {cabinet_password}

-
- """ - if cabinet_password - else '' - ) - - creds_block_en = ( - f""" -
-

Your cabinet login credentials:

-

Email: {cabinet_email}

-

Password: {cabinet_password}

-
- """ - if cabinet_password - else '' - ) - - creds_block_zh = ( - f""" -
-

个人中心登录信息:

-

Email: {cabinet_email}

-

密码: {cabinet_password}

-
- """ - if cabinet_password - else '' - ) - - creds_block_ua = ( - f""" -
-

Дані для входу в особистий кабінет:

-

Email: {cabinet_email}

-

Пароль: {cabinet_password}

-
- """ - if cabinet_password - else '' - ) - - creds_block_fa = ( - f""" -
-

اطلاعات ورود به پنل کاربری:

-

Email: {cabinet_email}

-

رمز عبور: {cabinet_password}

-
- """ - if cabinet_password - else '' - ) - bodies = { 'ru': f"""

Ваша VPN подписка готова!

@@ -1451,7 +1389,6 @@ class EmailNotificationTemplates:

Тариф: {tariff_name}

Период: {period_days} дней

- {creds_block_ru}

Подписка активирована в вашем личном кабинете.

Перейти в личный кабинет

""", @@ -1461,7 +1398,6 @@ class EmailNotificationTemplates:

Plan: {tariff_name}

Period: {period_days} days

- {creds_block_en}

Your subscription has been activated in your cabinet.

Go to Cabinet

""", @@ -1471,7 +1407,6 @@ class EmailNotificationTemplates:

套餐: {tariff_name}

期限: {period_days} 天

- {creds_block_zh}

订阅已在您的个人中心激活。

前往个人中心

""", @@ -1481,7 +1416,6 @@ class EmailNotificationTemplates:

Тариф: {tariff_name}

Період: {period_days} днів

- {creds_block_ua}

Підписка активована у вашому особистому кабінеті.

Перейти до кабінету

""", @@ -1491,7 +1425,6 @@ class EmailNotificationTemplates:

طرح: {tariff_name}

مدت: {period_days} روز

- {creds_block_fa}

اشتراک شما در پنل کاربری فعال شده است.

رفتن به پنل کاربری

""", diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 128ea3d8..0338f9ce 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -51,11 +51,6 @@ async def create_transaction( else amount_kopeks ) - # Default payment_method to BALANCE for subscription/gift payments from bot (not landing) - # to avoid double-counting with DEPOSIT in revenue calculations - if payment_method is None and type in (TransactionType.SUBSCRIPTION_PAYMENT, TransactionType.GIFT_PAYMENT): - payment_method = PaymentMethod.BALANCE - transaction = Transaction( user_id=user_id, type=type.value, @@ -283,11 +278,11 @@ async def get_transactions_statistics( if not end_date: end_date = datetime.now(UTC) - # Доход считаем по реальным платежам + прямые покупки подписок (лендинги) + # Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения) income_result = await db.execute( - select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( and_( - Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), + Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.created_at <= end_date, @@ -348,7 +343,7 @@ async def get_transactions_statistics( ) .where( and_( - Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), + Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.created_at <= end_date, @@ -368,11 +363,11 @@ async def get_transactions_statistics( ) transactions_today = today_result.scalar() - # Доход за сегодня — реальные платежи + прямые покупки подписок (лендинги) + # Доход за сегодня - только реальные платежи today_income_result = await db.execute( - select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( and_( - Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), + Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.created_at >= today, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), @@ -396,17 +391,17 @@ async def get_transactions_statistics( async def get_revenue_by_period(db: AsyncSession, days: int = 30) -> list[dict]: - """Доход по дням — реальные платежи + прямые покупки подписок (лендинги).""" + """Доход по дням - только реальные платежи.""" start_date = datetime.now(UTC) - timedelta(days=days) result = await db.execute( select( func.date(Transaction.created_at).label('date'), - func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'), + func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'), ) .where( and_( - Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]), + Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.payment_method.in_(REAL_PAYMENT_METHODS), diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index 74d02139..c06b989a 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -22,7 +22,7 @@ from app.database.crud.subscription_conversion import ( ) from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance -from app.database.models import PaymentMethod, ServerSquad, Subscription, SubscriptionStatus, TransactionType, User +from app.database.models import ServerSquad, Subscription, SubscriptionStatus, TransactionType, User from app.localization.texts import get_texts from app.services.subscription_service import SubscriptionService from app.utils.pricing_utils import ( @@ -1109,7 +1109,6 @@ class MiniAppSubscriptionPurchaseService: type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=pricing.final_total, description=f'Подписка на {pricing.selection.period.days} дней ({pricing.months} мес)', - payment_method=PaymentMethod.BALANCE, ) await db.refresh(user)