Revert "Revert "fix: include landing page revenue in dashboard statistics""

This commit is contained in:
Egor
2026-03-21 03:03:06 +03:00
committed by Fringg
parent 39ae095d9b
commit 5175cccab6
9 changed files with 135 additions and 25 deletions
+7
View File
@@ -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,
+3 -1
View File
@@ -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',
+21 -1
View File
@@ -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,
+5 -5
View File
@@ -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()
+14 -6
View File
@@ -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),
+1 -1
View File
@@ -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',
+67
View File
@@ -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"""
<div class="highlight">
<p><strong>Данные для входа в личный кабинет:</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
creds_block_en = (
f"""
<div class="highlight">
<p><strong>Your cabinet login credentials:</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Password:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
creds_block_zh = (
f"""
<div class="highlight">
<p><strong>个人中心登录信息:</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>密码:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
creds_block_ua = (
f"""
<div class="highlight">
<p><strong>Дані для входу в особистий кабінет:</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
creds_block_fa = (
f"""
<div class="highlight">
<p><strong>اطلاعات ورود به پنل کاربری:</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>رمز عبور:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
bodies = {
'ru': f"""
<h2>Ваша VPN подписка готова!</h2>
@@ -1389,6 +1451,7 @@ class EmailNotificationTemplates:
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
{creds_block_ru}
<p>Подписка активирована в вашем личном кабинете.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
""",
@@ -1398,6 +1461,7 @@ class EmailNotificationTemplates:
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
{creds_block_en}
<p>Your subscription has been activated in your cabinet.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
""",
@@ -1407,6 +1471,7 @@ class EmailNotificationTemplates:
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} 天</strong></p>
</div>
{creds_block_zh}
<p>订阅已在您的个人中心激活。</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
""",
@@ -1416,6 +1481,7 @@ class EmailNotificationTemplates:
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
{creds_block_ua}
<p>Підписка активована у вашому особистому кабінеті.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
""",
@@ -1425,6 +1491,7 @@ class EmailNotificationTemplates:
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
{creds_block_fa}
<p>اشتراک شما در پنل کاربری فعال شده است.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
""",
+15 -10
View File
@@ -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),
@@ -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)