From db3ac254ba21a7371cf284cf42eadecf62274c2b Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 21 Mar 2026 03:03:06 +0300 Subject: [PATCH] Revert "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, 135 insertions(+), 25 deletions(-) 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_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', 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, 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() diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py index de489a82..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), @@ -926,9 +934,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 +946,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), 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', diff --git a/app/cabinet/services/email_templates.py b/app/cabinet/services/email_templates.py index a650ddc7..59f07a20 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,66 @@ 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 +1451,7 @@ class EmailNotificationTemplates:

Тариф: {tariff_name}

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

+ {creds_block_ru}

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

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

""", @@ -1398,6 +1461,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 +1471,7 @@ class EmailNotificationTemplates:

套餐: {tariff_name}

期限: {period_days} 天

+ {creds_block_zh}

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

前往个人中心

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

Тариф: {tariff_name}

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

+ {creds_block_ua}

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

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

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

طرح: {tariff_name}

مدت: {period_days} روز

+ {creds_block_fa}

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

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

""", diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 0338f9ce..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, @@ -278,11 +283,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 +348,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 +368,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 +396,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), 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)