Merge branch 'dev' into patch-3

This commit is contained in:
Machka Pasla
2025-09-07 23:05:49 +05:00
committed by GitHub
38 changed files with 2863 additions and 663 deletions
+2 -2
View File
@@ -126,8 +126,8 @@ LOG_PROMO_ACTIVATIONS=True # Lo
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
# Embedded mode thumbnails. Please don't touch this.
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png
INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png
INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png
+1
View File
@@ -16,3 +16,4 @@ __pycache__/
*.pid
locales/ru_backup.json
locales/en_backup.json
db/models_old.py
+9
View File
@@ -54,6 +54,15 @@ def build_core_services(
settings_obj=settings,
)
# Wire services that depend on each other
try:
# Attach YooKassa to subscription service for auto-renew charges
setattr(subscription_service, "yookassa_service", yookassa_service)
# Allow panel webhook to trigger renewals through subscription service
setattr(panel_webhook_service, "subscription_service", subscription_service)
except Exception:
pass
return {
"panel_service": panel_service,
"subscription_service": subscription_service,
+2
View File
@@ -8,6 +8,7 @@ from . import statistics
from . import sync_admin
from . import logs_admin
from . import payments
from . import ads
admin_router_aggregate = Router(name="admin_features_router")
@@ -19,5 +20,6 @@ admin_router_aggregate.include_router(statistics.router)
admin_router_aggregate.include_router(sync_admin.router)
admin_router_aggregate.include_router(logs_admin.router)
admin_router_aggregate.include_router(payments.router)
admin_router_aggregate.include_router(ads.router)
__all__ = ("admin_router_aggregate", )
+239
View File
@@ -0,0 +1,239 @@
import logging
from aiogram import Router, F, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from db.dal import ad_dal
from bot.states.admin_states import AdminStates
router = Router(name="admin_ads_router")
PAGE_SIZE = 5
@router.callback_query(F.data == "admin_action:ads")
async def show_ads_menu(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer("Language error.", show_alert=True)
return
totals = await ad_dal.get_totals(session)
total_cost = totals.get("cost", 0.0)
total_revenue = totals.get("revenue", 0.0)
overview = _("admin_ads_overview", revenue=f"{total_revenue:.2f}", cost=f"{total_cost:.2f}")
total_count = await ad_dal.count_campaigns(session)
if total_count == 0:
text = overview + "\n\n" + _("admin_ads_empty")
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
reply_markup = get_ads_menu_keyboard(i18n, current_lang)
else:
current_page = 0
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
campaigns = await ad_dal.list_campaigns_paged(session, page=current_page, page_size=PAGE_SIZE)
text = overview + "\n\n" + _("admin_ads_header")
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, current_page, total_pages)
await callback.message.edit_text(text, reply_markup=reply_markup)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("admin_ads:page:"))
async def ads_list_pagination(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer("Language error.", show_alert=True)
return
try:
page = int(callback.data.split(":")[2])
except Exception:
page = 0
totals = await ad_dal.get_totals(session)
overview = _("admin_ads_overview", revenue=f"{totals.get('revenue', 0.0):.2f}", cost=f"{totals.get('cost', 0.0):.2f}")
total_count = await ad_dal.count_campaigns(session)
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = max(0, min(page, total_pages - 1))
campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE)
text = overview + "\n\n" + _("admin_ads_header")
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages)
try:
await callback.message.edit_text(text, reply_markup=reply_markup)
await callback.answer()
except Exception as e:
logging.error(f"Failed to paginate ads list: {e}")
await callback.answer()
@router.callback_query(F.data.startswith("admin_ads:card:"))
async def show_ad_card(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer("Language error.", show_alert=True)
return
parts = callback.data.split(":")
camp_id = int(parts[2])
back_page = int(parts[3]) if len(parts) > 3 else 0
camp = await ad_dal.get_campaign_by_id(session, camp_id)
if not camp:
await callback.answer(_("admin_promo_not_found"), show_alert=True)
return
try:
stats = await ad_dal.get_campaign_stats(session, camp_id)
except Exception:
stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0}
text = _(
"admin_ads_card",
id=camp.ad_campaign_id,
source=camp.source,
start_param=camp.start_param,
cost=f"{camp.cost:.2f}",
active=_("csv_yes") if camp.is_active else _("csv_no"),
starts=stats["starts"],
trials=stats["trials"],
payers=stats["payers"],
revenue=f"{stats['revenue']:.2f}",
)
from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard
reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page)
try:
await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer()
except Exception as e:
logging.error(f"Failed to show ad card: {e}")
await callback.answer()
@router.callback_query(F.data == "admin_action:ads_create")
async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict):
from bot.states.admin_states import AdminStates
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer("Language error.", show_alert=True)
return
await state.set_state(AdminStates.waiting_for_ad_source)
await callback.message.edit_text(_("admin_ads_create_source_prompt"))
try:
await callback.answer()
except Exception:
pass
@router.message(
StateFilter(
AdminStates.waiting_for_ad_source,
AdminStates.waiting_for_ad_start_param,
AdminStates.waiting_for_ad_cost,
),
F.text,
)
async def ads_create_flow(message: types.Message, state: FSMContext, settings: Settings, i18n_data: dict, session: AsyncSession):
current_state = await state.get_state()
if current_state not in (
AdminStates.waiting_for_ad_source.state,
AdminStates.waiting_for_ad_start_param.state,
AdminStates.waiting_for_ad_cost.state,
):
return
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if current_state == AdminStates.waiting_for_ad_source.state:
source = message.text.strip()
if not source or len(source) > 64:
await message.answer(_("admin_ads_invalid_source"))
return
await state.update_data(ad_source=source)
await state.set_state(AdminStates.waiting_for_ad_start_param)
await message.answer(_("admin_ads_create_start_param_prompt"))
return
if current_state == AdminStates.waiting_for_ad_start_param.state:
start_param = message.text.strip()
# Allow alnum underscore dash only
import re as _re
if not _re.match(r"^[A-Za-z0-9_\-]{2,64}$", start_param):
await message.answer(_("admin_ads_invalid_start_param"))
return
await state.update_data(ad_start_param=start_param)
await state.set_state(AdminStates.waiting_for_ad_cost)
await message.answer(_("admin_ads_create_cost_prompt"))
return
if current_state == AdminStates.waiting_for_ad_cost.state:
text = message.text.replace(",", ".").strip()
try:
cost = float(text)
if cost < 0 or cost > 1e8:
raise ValueError()
except Exception:
await message.answer(_("admin_ads_invalid_cost"))
return
data = await state.get_data()
try:
campaign = await ad_dal.create_campaign(
session,
source=data.get("ad_source", "unknown"),
start_param=data.get("ad_start_param", "NA"),
cost=cost,
)
await session.commit()
except ValueError as ve:
await session.rollback()
if str(ve) == "ad_campaign_start_param_exists":
await message.answer(_("admin_ads_start_param_exists"))
else:
await message.answer(_("error_occurred_try_again"))
return
except Exception as e:
await session.rollback()
logging.error(f"Failed to create ad campaign: {e}", exc_info=True)
await message.answer(_("error_occurred_try_again"))
return
await state.clear()
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await message.answer(
_(
"admin_ads_created_success",
id=campaign.ad_campaign_id,
source=campaign.source,
start_param=campaign.start_param,
cost=f"{campaign.cost:.2f}",
)
)
# Offer back to ads menu
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
await message.answer(_("admin_ads_back_to_menu_hint"), reply_markup=get_ads_menu_keyboard(i18n, current_lang))
+6
View File
@@ -127,6 +127,12 @@ async def admin_panel_actions_callback_handler(
from . import payments as admin_payments_handlers
await admin_payments_handlers.view_payments_handler(
callback, i18n_data, settings, session)
elif action == "ads":
from . import ads as admin_ads_handlers
await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session)
elif action == "ads_create":
from . import ads as admin_ads_handlers
await admin_ads_handlers.ads_create_start(callback, state, settings, i18n_data)
elif action == "main":
try:
await callback.message.edit_text(
+4 -1
View File
@@ -144,7 +144,10 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
existing_user.first_name or "",
existing_user.last_name or "",
])
if description_text.strip():
# Update description only when it differs from the current one on panel
current_panel_description = (panel_user_dict.get("description") or "").strip()
desired_description = description_text.strip()
if desired_description and desired_description != current_panel_description:
await panel_service.update_user_details_on_panel(
panel_uuid, {"description": description_text}
)
+3 -2
View File
@@ -1,7 +1,8 @@
from aiogram import Router
from . import start
from . import subscription
# TODO: after splitting subscription into a package, replace this import
from .subscription import router as subscription_router
from . import referral
from . import promo_user
from . import trial_handler
@@ -11,5 +12,5 @@ user_router_aggregate = Router(name="user_router_aggregate")
user_router_aggregate.include_router(promo_user.router)
user_router_aggregate.include_router(trial_handler.router)
user_router_aggregate.include_router(start.router)
user_router_aggregate.include_router(subscription.router)
user_router_aggregate.include_router(subscription_router)
user_router_aggregate.include_router(referral.router)
+248 -43
View File
@@ -12,7 +12,7 @@ from sqlalchemy.orm import sessionmaker
from yookassa.domain.notification import WebhookNotification
from yookassa.domain.models.amount import Amount as YooKassaAmount
from db.dal import payment_dal, user_dal
from db.dal import payment_dal, user_dal, user_billing_dal
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
@@ -27,6 +27,7 @@ payment_processing_lock = asyncio.Lock()
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
async def process_successful_payment(session: AsyncSession, bot: Bot,
@@ -40,8 +41,13 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
subscription_months_str = metadata.get("subscription_months")
promo_code_id_str = metadata.get("promo_code_id")
payment_db_id_str = metadata.get("payment_db_id")
auto_renew_subscription_id_str = metadata.get(
"auto_renew_for_subscription_id")
if not user_id_str or not subscription_months_str or not payment_db_id_str:
# For auto-renew payments, payment_db_id may be absent. In that case,
# we will create/ensure a payment record idempotently using provider payment id.
if (not user_id_str or not subscription_months_str
or (not payment_db_id_str and not auto_renew_subscription_id_str)):
logging.error(
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
)
@@ -51,7 +57,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
try:
user_id = int(user_id_str)
subscription_months = int(subscription_months_str)
payment_db_id = int(payment_db_id_str)
payment_db_id = int(
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id)
promo_code_id = int(
promo_code_id_str
) if promo_code_id_str and promo_code_id_str.isdigit() else None
@@ -59,6 +67,44 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
amount_data = payment_info_from_webhook.get("amount", {})
payment_value = float(amount_data.get("value", 0.0))
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
if payment_db_id is None and auto_renew_subscription_id_str:
try:
# Create/ensure provider payment by YooKassa payment id for idempotency
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
from db.dal import payment_dal as _payment_dal
ensured_payment = await _payment_dal.ensure_payment_with_provider_id(
session,
user_id=user_id,
amount=payment_value,
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
months=subscription_months,
description=payment_info_from_webhook.get(
"description") or f"Auto-renewal for {subscription_months} months",
provider="yookassa",
provider_payment_id=yk_payment_id_from_hook,
)
payment_db_id = ensured_payment.payment_id
# Also persist yookassa_payment_id field if not set yet
try:
await _payment_dal.update_payment_status_by_db_id(
session,
payment_db_id,
payment_info_from_webhook.get("status", "succeeded"),
yk_payment_id_from_hook,
)
except Exception:
# Non-fatal; continue processing
logging.exception(
"Failed to backfill yookassa_payment_id for ensured auto-renew payment"
)
except Exception as e_ensure:
logging.error(
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
exc_info=True,
)
return
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error(
@@ -89,6 +135,42 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
try:
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
# Try to capture and save payment method for future charges if available
try:
payment_method = payment_info_from_webhook.get("payment_method")
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and isinstance(payment_method, dict) and payment_method.get("saved", False):
pm_id = payment_method.get("id")
pm_type = payment_method.get("type")
title = payment_method.get("title")
card = payment_method.get("card") or {}
account_number = payment_method.get("account_number") or payment_method.get("account")
display_network = None
display_last4 = None
# Build generic display for various instrument types
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
# Normalize wallet display name to avoid leaking full account from title
display_network = "YooMoney"
if isinstance(account_number, str) and len(account_number) >= 4:
display_last4 = account_number[-4:]
else:
display_last4 = None
else:
# Wallets, SBP, etc. — use provided title/type; no last4
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=pm_id,
card_last4=display_last4,
card_network=display_network,
)
except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook")
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
@@ -137,56 +219,66 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
applied_referee_bonus_days_from_referral = referral_bonus_info.get(
"referee_bonus_applied_days")
# Use user's DB language for all user-facing messages
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
config_link = activation_details.get("subscription_url") or _(
"config_link_not_available"
)
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(
session, db_user.referred_by_id)
if inviter and inviter.first_name:
inviter_name_display = inviter.first_name
elif inviter and inviter.username:
inviter_name_display = f"@{inviter.username}"
# For auto-renew charges, avoid re-sending config link; send concise message
if is_auto_renew and final_end_date_for_user:
details_message = _(
"payment_successful_with_referral_bonus_full",
months=subscription_months,
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
bonus_days=applied_referee_bonus_days_from_referral,
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display,
config_link=config_link,
)
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
details_message = _(
"payment_successful_with_promo_full",
months=subscription_months,
bonus_days=applied_promo_bonus_days,
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
config_link=config_link,
)
elif final_end_date_for_user:
details_message = _(
"payment_successful_full",
"yookassa_auto_renewal",
months=subscription_months,
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
config_link=config_link,
)
details_markup = None
else:
logging.error(
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic."
config_link = activation_details.get("subscription_url") or _(
"config_link_not_available"
)
details_message = _("payment_successful_error_details")
details_markup = get_connect_and_main_keyboard(
user_lang, i18n, settings, config_link
)
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(
session, db_user.referred_by_id)
if inviter and inviter.first_name:
inviter_name_display = inviter.first_name
elif inviter and inviter.username:
inviter_name_display = f"@{inviter.username}"
details_message = _(
"payment_successful_with_referral_bonus_full",
months=subscription_months,
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
bonus_days=applied_referee_bonus_days_from_referral,
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display,
config_link=config_link,
)
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
details_message = _(
"payment_successful_with_promo_full",
months=subscription_months,
bonus_days=applied_promo_bonus_days,
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
config_link=config_link,
)
elif final_end_date_for_user:
details_message = _(
"payment_successful_full",
months=subscription_months,
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
config_link=config_link,
)
else:
logging.error(
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic."
)
details_message = _("payment_successful_error_details")
details_markup = get_connect_and_main_keyboard(
user_lang, i18n, settings, config_link
)
try:
await bot.send_message(
user_id,
@@ -313,6 +405,40 @@ async def yookassa_webhook_route(request: web.Request):
)
return web.Response(status=200, text="ok_error_no_metadata")
# Safely extract payment_method details (SDK objects may not have to_dict)
pm_obj = getattr(payment_data_from_notification, 'payment_method', None)
pm_dict = None
if pm_obj is not None:
try:
card_obj = getattr(pm_obj, 'card', None)
pm_dict = {
"id": getattr(pm_obj, 'id', None),
"type": getattr(pm_obj, 'type', None),
"saved": bool(getattr(pm_obj, 'saved', False)),
"title": getattr(pm_obj, 'title', None),
"account_number": (
getattr(pm_obj, 'account_number', None)
if hasattr(pm_obj, 'account_number') else (
getattr(pm_obj, 'account', None)
if hasattr(pm_obj, 'account') else None
)
),
"card": (
{
"first6": getattr(card_obj, 'first6', None),
"last4": getattr(card_obj, 'last4', None),
"expiry_month": getattr(card_obj, 'expiry_month', None),
"expiry_year": getattr(card_obj, 'expiry_year', None),
"card_type": getattr(card_obj, 'card_type', None),
}
if card_obj is not None
else None
),
}
except Exception:
logging.exception("Failed to serialize YooKassa payment_method from webhook")
pm_dict = None
payment_dict_for_processing = {
"id":
str(payment_data_from_notification.id),
@@ -329,6 +455,7 @@ async def yookassa_webhook_route(request: web.Request):
"description":
str(payment_data_from_notification.description)
if payment_data_from_notification.description else None,
"payment_method": pm_dict,
}
async with payment_processing_lock:
@@ -354,6 +481,84 @@ async def yookassa_webhook_route(request: web.Request):
session, bot, payment_dict_for_processing,
i18n_instance, settings)
await session.commit()
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
# Bind-only flow: save method and cancel auth if metadata has bind_only
metadata = payment_dict_for_processing.get("metadata", {}) or {}
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and metadata.get("bind_only") == "1":
try:
user_id_str = metadata.get("user_id")
if user_id_str and user_id_str.isdigit():
user_id = int(user_id_str)
payment_method = payment_dict_for_processing.get("payment_method")
if isinstance(payment_method, dict) and payment_method.get("id"):
pm_type = payment_method.get("type")
title = payment_method.get("title")
card = payment_method.get("card") or {}
account_number = payment_method.get("account_number") or payment_method.get("account")
display_network = None
display_last4 = None
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
# Normalize wallet display name to avoid leaking full account from title
display_network = "YooMoney"
if isinstance(account_number, str) and len(account_number) >= 4:
display_last4 = account_number[-4:]
else:
display_last4 = None
else:
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=payment_method.get("id"),
card_last4=display_last4,
card_network=display_network,
)
await session.commit()
# Save multi-card entry and mark default if first
try:
from db.dal import user_billing_dal as ub
await ub.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=payment_method.get("id"),
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=True,
)
await session.commit()
except Exception:
await session.rollback()
# Notify user about successful binding with Back button
try:
# Use user's DB language for bind success notification
i18n_lang = settings.DEFAULT_LANGUAGE
from db.dal import user_dal
db_user = await user_dal.get_user_by_id(session, user_id)
if db_user and db_user.language_code:
i18n_lang = db_user.language_code
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
await bot.send_message(
chat_id=user_id,
text=_("payment_method_bound_success"),
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
)
except Exception:
pass
# Attempt to cancel the authorization to avoid charge hold
try:
yk: YooKassaService = request.app.get('yookassa_service')
if yk:
await yk.cancel_payment(payment_dict_for_processing.get("id"))
except Exception:
logging.exception("Failed to cancel bind-only payment auth")
except Exception:
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
except Exception as e_webhook_db_processing:
await session.rollback()
logging.error(
+22 -1
View File
@@ -118,6 +118,7 @@ async def send_main_menu(target_event: Union[types.Message,
@router.message(CommandStart())
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
async def start_command_handler(message: types.Message,
state: FSMContext,
settings: Settings,
@@ -125,7 +126,8 @@ async def start_command_handler(message: types.Message,
subscription_service: SubscriptionService,
session: AsyncSession,
ref_match: Optional[re.Match] = None,
promo_match: Optional[re.Match] = None):
promo_match: Optional[re.Match] = None,
ad_param_match: Optional[re.Match] = None):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -137,6 +139,7 @@ async def start_command_handler(message: types.Message,
referred_by_user_id: Optional[int] = None
promo_code_to_apply: Optional[str] = None
ad_start_param: Optional[str] = None
if ref_match:
potential_referrer_id = int(ref_match.group(1))
@@ -145,6 +148,9 @@ async def start_command_handler(message: types.Message,
elif promo_match:
promo_code_to_apply = promo_match.group(1)
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
elif ad_param_match:
ad_start_param = ad_param_match.group(1)
logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
@@ -217,6 +223,21 @@ async def start_command_handler(message: types.Message,
f"Failed to update existing user {user_id} in session: {e_update}",
exc_info=True)
# Attribute user to ad campaign if start param provided
if ad_start_param:
try:
from db.dal import ad_dal as _ad_dal
campaign = await _ad_dal.get_campaign_by_start_param(session, ad_start_param)
if campaign and campaign.is_active:
await _ad_dal.ensure_attribution(session, user_id=user_id, campaign_id=campaign.ad_campaign_id)
await session.commit()
except Exception as e_attr:
logging.error(f"Failed to attribute user {user_id} to ad '{ad_start_param}': {e_attr}")
try:
await session.rollback()
except Exception:
pass
# Send welcome message if not disabled
if not settings.DISABLE_WELCOME_MESSAGE:
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
-540
View File
@@ -1,540 +0,0 @@
import logging
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice
from typing import Optional, Dict, Any, Union
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import payment_dal
from bot.keyboards.inline.user_keyboards import (
get_subscription_options_keyboard, get_payment_method_keyboard,
get_payment_url_keyboard, get_back_to_main_menu_markup)
from bot.services.yookassa_service import YooKassaService
from bot.services.stars_service import StarsService
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.middlewares.i18n import JsonI18n
router = Router(name="user_subscription_router")
async def display_subscription_options(event: Union[types.Message,
types.CallbackQuery],
i18n_data: dict, settings: Settings,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
) if i18n else key
if not i18n:
err_msg = "Language service error."
if isinstance(event, types.CallbackQuery):
try:
await event.answer(err_msg, show_alert=True)
except Exception:
pass
elif isinstance(event, types.Message):
await event.answer(err_msg)
return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
text_content = get_text("select_subscription_period"
) if settings.subscription_options else get_text(
"no_subscription_options_available")
reply_markup = get_subscription_options_keyboard(
settings.subscription_options, currency_symbol_val, current_lang, i18n
) if settings.subscription_options else get_back_to_main_menu_markup(
current_lang, i18n)
target_message_obj = event.message if isinstance(
event, types.CallbackQuery) else event
if not target_message_obj:
if isinstance(event, types.CallbackQuery):
try:
await event.answer(get_text("error_occurred_try_again"),
show_alert=True)
except Exception:
pass
return
if isinstance(event, types.CallbackQuery):
try:
await target_message_obj.edit_text(text_content,
reply_markup=reply_markup)
except Exception:
await target_message_obj.answer(text_content,
reply_markup=reply_markup)
try:
await event.answer()
except Exception:
pass
else:
await target_message_obj.answer(text_content,
reply_markup=reply_markup)
@router.callback_query(F.data.startswith("subscribe_period:"))
async def select_subscription_period_callback_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"),
show_alert=True)
except Exception:
pass
return
try:
months = int(callback.data.split(":")[-1])
except (ValueError, IndexError):
logging.error(
f"Invalid subscription period in callback_data: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
price_rub = settings.subscription_options.get(months)
if price_rub is None:
logging.error(
f"Price not found for {months} months subscription period in settings.subscription_options."
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
text_content = get_text("choose_payment_method")
tribute_url = settings.tribute_payment_links.get(months)
stars_price = settings.stars_subscription_options.get(months)
reply_markup = get_payment_method_keyboard(
months,
price_rub,
tribute_url,
stars_price,
currency_symbol_val,
current_lang,
i18n,
settings,
)
try:
await callback.message.edit_text(text_content,
reply_markup=reply_markup)
except Exception as e_edit:
logging.warning(
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
)
await callback.message.answer(text_content,
reply_markup=reply_markup)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_stars:"))
async def pay_stars_callback_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
session: AsyncSession, bot: Bot, stars_service: StarsService):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
months_str, price_str = data_payload.split(":")
months = int(months_str)
stars_price = int(price_str)
except (ValueError, IndexError):
logging.error(f"Invalid pay_stars data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
payment_description = get_text("payment_description_subscription", months=months)
payment_id = await stars_service.create_invoice(
session, user_id, months, stars_price, payment_description)
if payment_id is None:
await callback.message.edit_text(get_text("error_payment_gateway"))
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
yookassa_service: YooKassaService, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"),
show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service is not configured or unavailable.")
target_msg_edit = callback.message
await target_msg_edit.edit_text(get_text("payment_service_unavailable")
)
try:
await callback.answer(get_text("payment_service_unavailable_alert"),
show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
months_str, price_str = data_payload.split(":")
months = int(months_str)
price_rub = float(price_str)
except (ValueError, IndexError):
logging.error(
f"Invalid pay_yk data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
payment_description = get_text("payment_description_subscription",
months=months)
currency_code_for_yk = "RUB"
payment_record_data = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code_for_yk,
"status": "pending_yookassa",
"description": payment_description,
"subscription_duration_months": months,
}
db_payment_record = None
try:
db_payment_record = await payment_dal.create_payment_record(
session, payment_record_data)
await session.commit()
logging.info(
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
)
except Exception as e_db_payment:
await session.rollback()
logging.error(
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
exc_info=True)
await callback.message.edit_text(
get_text("error_creating_payment_record"))
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
if not db_payment_record:
await callback.message.edit_text(
get_text("error_creating_payment_record"))
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
yookassa_metadata = {
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(db_payment_record.payment_id),
}
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
payment_response_yk = await yookassa_service.create_payment(
amount=price_rub,
currency=currency_code_for_yk,
description=payment_description,
metadata=yookassa_metadata,
receipt_email=receipt_email_for_yk)
if payment_response_yk and payment_response_yk.get("confirmation_url"):
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=db_payment_record.payment_id,
new_status=payment_response_yk.get("status", "pending"),
yk_payment_id=payment_response_yk.get("id"))
await session.commit()
except Exception as e_db_update_ykid:
await session.rollback()
logging.error(
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
exc_info=True)
await callback.message.edit_text(
get_text("error_payment_gateway_link_failed"))
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
await callback.message.edit_text(
get_text(key="payment_link_message", months=months),
reply_markup=get_payment_url_keyboard(
payment_response_yk["confirmation_url"], current_lang, i18n),
disable_web_page_preview=False)
else:
try:
await payment_dal.update_payment_status_by_db_id(
session, db_payment_record.payment_id, "failed_creation")
await session.commit()
except Exception as e_db_fail_create:
await session.rollback()
logging.error(
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
exc_info=True)
logging.error(
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
)
await callback.message.edit_text(get_text("error_payment_gateway"))
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_crypto:"))
async def pay_crypto_callback_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
cryptopay_service: CryptoPayService, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not cryptopay_service or not cryptopay_service.configured:
await callback.message.edit_text(get_text("payment_service_unavailable"))
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
months_str, amount_str = data_payload.split(":")
months = int(months_str)
amount_val = float(amount_str)
except (ValueError, IndexError):
logging.error(f"Invalid pay_crypto data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
description = get_text("payment_description_subscription", months=months)
invoice_url = await cryptopay_service.create_invoice(
session, user_id, months, amount_val, description)
if invoice_url:
await callback.message.edit_text(
get_text("payment_link_message", months=months),
reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n),
disable_web_page_preview=False,
)
else:
await callback.message.edit_text(get_text("error_payment_gateway"))
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data == "main_action:subscribe")
async def reshow_subscription_options_callback(callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
await display_subscription_options(callback, i18n_data, settings, session)
async def my_subscription_command_handler(
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
session: AsyncSession,
bot: Bot
):
target = event.message if isinstance(event, types.CallbackQuery) else event
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
if not i18n or not target:
if isinstance(event, types.Message):
await event.answer(get_text("error_occurred_try_again"))
return
if not panel_service or not subscription_service:
await target.answer(get_text("error_service_unavailable"))
return
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
if not active:
text = get_text("subscription_not_active")
buy_button = InlineKeyboardButton(
text=get_text("menu_subscribe_inline", default="Купить"),
callback_data="main_action:subscribe"
)
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
kb = InlineKeyboardMarkup(
inline_keyboard=[
[buy_button],
*back_markup.inline_keyboard
]
)
if isinstance(event, types.CallbackQuery):
try:
await event.answer()
except Exception:
pass
try:
await event.message.edit_text(text, reply_markup=kb)
except:
await event.message.answer(text, reply_markup=kb)
else:
await event.answer(text, reply_markup=kb)
return
end_date = active.get("end_date")
days_left = (
(end_date.date() - datetime.now().date()).days
if end_date else 0
)
text = get_text(
"my_subscription_details",
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
days_left=max(0, days_left),
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
config_link=active.get("config_link") or get_text("config_link_not_available"),
traffic_limit=(
f"{active['traffic_limit_bytes'] / 2**30:.2f} GB"
if active.get("traffic_limit_bytes")
else get_text("traffic_unlimited")
),
traffic_used=(
f"{active['traffic_used_bytes'] / 2**30:.2f} GB"
if active.get("traffic_used_bytes") is not None
else get_text("traffic_na")
)
)
markup = get_back_to_main_menu_markup(current_lang, i18n)
if isinstance(event, types.CallbackQuery):
try:
await event.answer()
except Exception:
pass
try:
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
except:
await bot.send_message(chat_id=target.chat.id, text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
else:
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
@router.pre_checkout_query()
async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery):
await pre_checkout_query.answer(ok=True)
@router.message(F.successful_payment)
async def stars_successful_payment_handler(
message: types.Message, settings: Settings, i18n_data: dict,
session: AsyncSession, stars_service: StarsService):
sp = message.successful_payment
if not sp or sp.currency != "XTR":
return
payload = sp.invoice_payload or ""
try:
payment_id_str, months_str = payload.split(":")
payment_db_id = int(payment_id_str)
months = int(months_str)
except (ValueError, IndexError):
logging.error(f"Invalid invoice payload for stars payment: {payload}")
return
stars_amount = sp.total_amount
await stars_service.process_successful_payment(
session, message, payment_db_id, months, stars_amount, i18n_data)
@router.message(Command("connect"))
async def connect_command_handler(message: types.Message, i18n_data: dict,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
session: AsyncSession, bot: Bot):
logging.info(f"User {message.from_user.id} used /connect command.")
await my_subscription_command_handler(message, i18n_data, settings,
panel_service, subscription_service,
session, bot)
@@ -0,0 +1,17 @@
from aiogram import Router
from . import core
from . import payments
from . import payment_methods
router = Router(name="user_subscription_router")
# Include sub-routers
router.include_router(core.router)
router.include_router(payments.router)
router.include_router(payment_methods.router)
# Re-export commonly used entrypoints for backward compatibility
from .core import display_subscription_options, my_subscription_command_handler # noqa: E402,F401
+354
View File
@@ -0,0 +1,354 @@
import logging
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
from typing import Optional, Union
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from config.settings import Settings
from bot.keyboards.inline.user_keyboards import (
get_subscription_options_keyboard,
get_back_to_main_menu_markup,
get_autorenew_confirm_keyboard,
)
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.middlewares.i18n import JsonI18n
from db.dal import subscription_dal
from db.models import Subscription
router = Router(name="user_subscription_core_router")
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n:
err_msg = "Language service error."
if isinstance(event, types.CallbackQuery):
try:
await event.answer(err_msg, show_alert=True)
except Exception:
pass
elif isinstance(event, types.Message):
await event.answer(err_msg)
return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available")
reply_markup = (
get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n)
if settings.subscription_options
else get_back_to_main_menu_markup(current_lang, i18n)
)
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
if not target_message_obj:
if isinstance(event, types.CallbackQuery):
try:
await event.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if isinstance(event, types.CallbackQuery):
try:
await target_message_obj.edit_text(text_content, reply_markup=reply_markup)
except Exception:
await target_message_obj.answer(text_content, reply_markup=reply_markup)
try:
await event.answer()
except Exception:
pass
else:
await target_message_obj.answer(text_content, reply_markup=reply_markup)
@router.callback_query(F.data == "main_action:subscribe")
async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
await display_subscription_options(callback, i18n_data, settings, session)
async def my_subscription_command_handler(
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
session: AsyncSession,
bot: Bot,
):
target = event.message if isinstance(event, types.CallbackQuery) else event
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
if not i18n or not target:
if isinstance(event, types.Message):
await event.answer(get_text("error_occurred_try_again"))
return
if not panel_service or not subscription_service:
await target.answer(get_text("error_service_unavailable"))
return
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
if not active:
text = get_text("subscription_not_active")
buy_button = InlineKeyboardButton(
text=get_text("menu_subscribe_inline", default="Купить"), callback_data="main_action:subscribe"
)
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
kb = InlineKeyboardMarkup(inline_keyboard=[[buy_button], *back_markup.inline_keyboard])
if isinstance(event, types.CallbackQuery):
try:
await event.answer()
except Exception:
pass
try:
await event.message.edit_text(text, reply_markup=kb)
except Exception:
await event.message.answer(text, reply_markup=kb)
else:
await event.answer(text, reply_markup=kb)
return
end_date = active.get("end_date")
days_left = (end_date.date() - datetime.now().date()).days if end_date else 0
tribute_hint = ""
if active.get("status_from_panel", "").lower() == "active":
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
if local_sub:
if local_sub.provider == "tribute":
link = None
link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None
tribute_hint = "\n\n" + (
get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice")
)
text = get_text(
"my_subscription_details",
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
days_left=max(0, days_left),
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
config_link=active.get("config_link") or get_text("config_link_not_available"),
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
traffic_used=(
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
),
)
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
kb = base_markup.inline_keyboard
try:
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
# Build rows to prepend above the base "back" markup
prepend_rows = []
# 1) Mini-app connect button on top if enabled
if settings.SUBSCRIPTION_MINI_APP_URL:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("connect_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
])
# 2) Auto-renew toggle (if supported and not tribute)
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
toggle_text = (
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
)
prepend_rows.append([
InlineKeyboardButton(
text=toggle_text,
callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}",
)
])
# 3) Payment methods management (when autopayments enabled)
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
prepend_rows.append([
InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")
])
if prepend_rows:
kb = prepend_rows + kb
except Exception:
pass
markup = InlineKeyboardMarkup(inline_keyboard=kb)
if isinstance(event, types.CallbackQuery):
try:
await event.answer()
except Exception:
pass
try:
await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
except Exception:
await bot.send_message(
chat_id=target.chat.id,
text=text + tribute_hint,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
else:
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
@router.callback_query(F.data.startswith("toggle_autorenew:"))
async def toggle_autorenew_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
subscription_service: SubscriptionService,
panel_service: PanelApiService,
bot: Bot,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
try:
_, payload = callback.data.split(":", 1)
sub_id_str, enable_str = payload.split(":")
sub_id = int(sub_id_str)
enable = bool(int(enable_str))
except Exception:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
sub = await session.get(Subscription, sub_id)
if not sub or sub.user_id != callback.from_user.id:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if sub.provider == "tribute":
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
return
# Show confirmation popup and inline buttons
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
kb = get_autorenew_confirm_keyboard(enable, sub.subscription_id, current_lang, i18n)
try:
await callback.message.edit_text(confirm_text, reply_markup=kb)
except Exception:
try:
await callback.message.answer(confirm_text, reply_markup=kb)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
@router.callback_query(F.data.startswith("autorenew:confirm:"))
async def confirm_autorenew_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
subscription_service: SubscriptionService,
panel_service: PanelApiService,
bot: Bot,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
try:
_, _, sub_id_str, enable_str = callback.data.split(":", 3)
sub_id = int(sub_id_str)
enable = bool(int(enable_str))
except Exception:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
sub = await session.get(Subscription, sub_id)
if not sub or sub.user_id != callback.from_user.id:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if sub.provider == "tribute":
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
return
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
await session.commit()
try:
await callback.answer(get_text("subscription_autorenew_updated"))
except Exception:
pass
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
@router.callback_query(F.data == "autorenew:cancel")
async def autorenew_cancel_from_webhook_button(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
subscription_service: SubscriptionService,
panel_service: PanelApiService,
bot: Bot,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
# Disable auto-renew on the active subscription (non-tribute)
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
if not sub:
try:
await callback.answer(get_text("subscription_not_active"), show_alert=True)
except Exception:
pass
return
if sub.provider == "tribute":
try:
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
except Exception:
pass
return
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": False})
await session.commit()
try:
await callback.answer(get_text("subscription_autorenew_updated"))
except Exception:
pass
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
@router.message(Command("connect"))
async def connect_command_handler(
message: types.Message,
i18n_data: dict,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
session: AsyncSession,
bot: Bot,
):
logging.info(f"User {message.from_user.id} used /connect command.")
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
@@ -0,0 +1,458 @@
from aiogram import Router, F, types
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.keyboards.inline.user_keyboards import (
get_payment_methods_list_keyboard,
get_payment_method_delete_confirm_keyboard,
get_payment_method_details_keyboard,
get_bind_url_keyboard,
)
from bot.services.yookassa_service import YooKassaService
from bot.middlewares.i18n import JsonI18n
from db.dal import user_billing_dal
from db.models import Payment
from sqlalchemy.future import select
router = Router(name="user_subscription_payment_methods_router")
@router.callback_query(F.data == "pm:manage")
async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal.user_billing_dal import list_user_payment_methods
get_text = _
methods = await list_user_payment_methods(session, callback.from_user.id)
cards: List[tuple] = []
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return get_text("payment_method_wallet_title", last4=l4)
return get_text("payment_method_wallet_title", last4="****")
if last4:
network_name = network or get_text("payment_network_card", default="Card")
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic", default="Payment method")
return get_text("payment_method_generic_title", network=network_name)
for m in methods:
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
text = get_text("payment_methods_title")
if not cards:
text += "\n\n" + get_text("payment_method_none")
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data == "pm:bind")
async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
resp = await yookassa_service.create_payment(
amount=1.00,
currency="RUB",
description="Bind card",
metadata=metadata,
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
save_payment_method=True,
capture=False,
bind_only=True,
)
if not resp or not resp.get("confirmation_url"):
await callback.answer(_("error_payment_gateway"), show_alert=True)
return
await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:delete_confirm"))
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2)
pm_id = parts[2] if len(parts) >= 3 else ""
await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:delete"))
async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2)
pm_id_raw = parts[2] if len(parts) >= 3 else ""
deleted = False
try:
from db.dal.user_billing_dal import (
delete_user_payment_method,
delete_user_payment_method_by_provider_id,
list_user_payment_methods,
)
if pm_id_raw:
if pm_id_raw.isdigit():
deleted = await delete_user_payment_method(session, callback.from_user.id, int(pm_id_raw))
else:
deleted = await delete_user_payment_method_by_provider_id(session, callback.from_user.id, pm_id_raw)
try:
legacy_deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id)
deleted = deleted or legacy_deleted
except Exception:
pass
await session.commit()
methods = await list_user_payment_methods(session, callback.from_user.id)
text = _("payment_methods_title")
cards = []
for m in methods:
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card", default="Card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic", default="Payment method")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
if not cards:
text += "\n\n" + _("payment_method_none")
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
await callback.message.edit_text(f"{msg}\n\n{text}", reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
return
except Exception:
await session.rollback()
try:
await callback.answer(_("error_try_again"), show_alert=True)
except Exception:
pass
@router.callback_query(F.data.startswith("pm:view"))
async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
if not billing or not billing.yookassa_payment_method_id:
from db.dal.user_billing_dal import list_user_payment_methods
methods = await list_user_payment_methods(session, callback.from_user.id)
if not methods:
await callback.answer(_("payment_method_none"), show_alert=True)
return
parts = callback.data.split(":", 2)
pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id)
sel = next((m for m in methods if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id), methods[0])
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card", default="Card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic", default="Payment method")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(sel.card_network, sel.card_last4)
added_at = sel.created_at.strftime('%Y-%m-%d') if getattr(sel, 'created_at', None) else ""
last_tx = ""
try:
stmt = (
select(Payment)
.where(
Payment.user_id == callback.from_user.id,
Payment.status == 'succeeded',
Payment.provider == 'yookassa',
)
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
lp = result.scalar_one_or_none()
if lp and lp.created_at:
last_tx = lp.created_at.strftime('%Y-%m-%d')
except Exception:
pass
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}"
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(str(sel.method_id), current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
return
added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else ""
last_tx = ""
try:
stmt = (
select(Payment)
.where(
Payment.user_id == callback.from_user.id,
Payment.status == 'succeeded',
Payment.provider == 'yookassa',
)
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
last_payment = result.scalar_one_or_none()
if last_payment and last_payment.created_at:
last_tx = last_payment.created_at.strftime('%Y-%m-%d')
except Exception:
pass
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card", default="Card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic", default="Payment method")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(billing.card_network, billing.card_last4)
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}"
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(billing.yookassa_payment_method_id, current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:history"))
async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal import payment_dal
payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0)
user_payments = [p for p in payments if p.user_id == callback.from_user.id]
selected_pm_provider_id: Optional[str] = None
pm_filter_requested: bool = False
try:
split_a, split_b, split_pm_id = callback.data.split(":", 2)
if split_pm_id:
pm_filter_requested = True
if split_pm_id.isdigit():
from db.dal.user_billing_dal import list_user_payment_methods
methods = await list_user_payment_methods(session, callback.from_user.id)
sel = next((m for m in methods if str(m.method_id) == split_pm_id), None)
if sel and sel.provider_payment_method_id:
selected_pm_provider_id = sel.provider_payment_method_id
else:
selected_pm_provider_id = split_pm_id
except Exception:
selected_pm_provider_id = None
pm_filter_requested = False
if pm_filter_requested and not selected_pm_provider_id:
user_payments = []
if selected_pm_provider_id:
filtered: List[Payment] = []
for p in user_payments:
if p.provider != 'yookassa':
continue
if p.yookassa_payment_id and yookassa_service:
try:
info = await yookassa_service.get_payment_info(p.yookassa_payment_id)
pm = (info or {}).get("payment_method") or {}
if pm.get("id") == selected_pm_provider_id:
filtered.append(p)
continue
except Exception:
pass
user_payments = filtered
if not user_payments:
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
back_pm_id = ""
try:
split_a, split_b, back_pm_id = callback.data.split(":", 2)
except Exception:
back_pm_id = ""
back_markup = (
get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n)
if back_pm_id
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
)
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
return
def _format_item(p: Payment) -> str:
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A"
return f"{date_str}{title}{p.amount:.2f} {p.currency}"
lines = [_format_item(p) for p in user_payments]
text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines)
try:
split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2)
except Exception:
split_pm_id_for_back = ""
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
back_markup = (
get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n)
if split_pm_id_for_back
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
)
await callback.message.edit_text(text, reply_markup=back_markup)
@router.callback_query(F.data.startswith("pm:list:"))
async def payment_methods_list(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal.user_billing_dal import list_user_payment_methods
cards: List[tuple] = []
methods = await list_user_payment_methods(session, callback.from_user.id)
for m in methods:
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return get_text("payment_method_wallet_title", last4=l4)
return get_text("payment_method_wallet_title", last4="****")
if last4:
network_name = network or get_text("payment_network_card", default="Card")
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic", default="Payment method")
return get_text("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
try:
_, _, page_str = callback.data.split(":", 2)
page = int(page_str)
except Exception:
page = 0
text = get_text("payment_methods_title")
if not cards:
text += "\n\n" + get_text("payment_method_none")
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
+438
View File
@@ -0,0 +1,438 @@
import logging
from aiogram import Router, F, types
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard, get_payment_url_keyboard
from bot.services.yookassa_service import YooKassaService
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.stars_service import StarsService
from bot.middlewares.i18n import JsonI18n
from db.dal import payment_dal, user_billing_dal
router = Router(name="user_subscription_payments_router")
@router.callback_query(F.data.startswith("subscribe_period:"))
async def select_subscription_period_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
try:
months = int(callback.data.split(":")[-1])
except (ValueError, IndexError):
logging.error(f"Invalid subscription period in callback_data: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
price_rub = settings.subscription_options.get(months)
if price_rub is None:
logging.error(
f"Price not found for {months} months subscription period in settings.subscription_options."
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
text_content = get_text("choose_payment_method")
tribute_url = settings.tribute_payment_links.get(months)
stars_price = settings.stars_subscription_options.get(months)
reply_markup = get_payment_method_keyboard(
months,
price_rub,
tribute_url,
stars_price,
currency_symbol_val,
current_lang,
i18n,
settings,
)
try:
await callback.message.edit_text(text_content, reply_markup=reply_markup)
except Exception as e_edit:
logging.warning(
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
)
await callback.message.answer(text_content, reply_markup=reply_markup)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service is not configured or unavailable.")
target_msg_edit = callback.message
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
months_str, price_str = data_payload.split(":")
months = int(months_str)
price_rub = float(price_str)
except (ValueError, IndexError):
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
payment_description = get_text("payment_description_subscription", months=months)
currency_code_for_yk = "RUB"
payment_record_data = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code_for_yk,
"status": "pending_yookassa",
"description": payment_description,
"subscription_duration_months": months,
}
db_payment_record = None
try:
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
await session.commit()
logging.info(
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
)
except Exception as e_db_payment:
await session.rollback()
logging.error(
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
exc_info=True,
)
await callback.message.edit_text(get_text("error_creating_payment_record"))
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
if not db_payment_record:
await callback.message.edit_text(get_text("error_creating_payment_record"))
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
yookassa_metadata = {
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(db_payment_record.payment_id),
}
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
payment_response_yk = await yookassa_service.create_payment(
amount=price_rub,
currency=currency_code_for_yk,
description=payment_description,
metadata=yookassa_metadata,
receipt_email=receipt_email_for_yk,
# Save method only when autopayments are enabled
save_payment_method=bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)),
)
if payment_response_yk and payment_response_yk.get("confirmation_url"):
pm = payment_response_yk.get("payment_method")
try:
if pm and pm.get("id"):
pm_type = pm.get("type")
title = pm.get("title")
card = pm.get("card") or {}
account_number = pm.get("account_number") or pm.get("account")
if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
display_network = "YooMoney"
display_last4 = (
account_number[-4:]
if isinstance(account_number, str) and len(account_number) >= 4
else None
)
else:
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=pm["id"],
card_last4=display_last4,
card_network=display_network,
)
try:
await user_billing_dal.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=pm["id"],
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=True,
)
except Exception:
pass
await session.commit()
except Exception:
await session.rollback()
logging.exception("Failed to save YooKassa payment method preliminarily")
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=db_payment_record.payment_id,
new_status=payment_response_yk.get("status", "pending"),
yk_payment_id=payment_response_yk.get("id"),
)
await session.commit()
except Exception as e_db_update_ykid:
await session.rollback()
logging.error(
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
exc_info=True,
)
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
await callback.message.edit_text(
get_text(key="payment_link_message", months=months),
reply_markup=get_payment_url_keyboard(payment_response_yk["confirmation_url"], current_lang, i18n),
disable_web_page_preview=False,
)
else:
try:
await payment_dal.update_payment_status_by_db_id(session, db_payment_record.payment_id, "failed_creation")
await session.commit()
except Exception as e_db_fail_create:
await session.rollback()
logging.error(
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
exc_info=True,
)
logging.error(
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
)
await callback.message.edit_text(get_text("error_payment_gateway"))
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_crypto:"))
async def pay_crypto_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
cryptopay_service: CryptoPayService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
months_str, price_str = data_payload.split(":")
months = int(months_str)
price_amount = float(price_str)
except (ValueError, IndexError):
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
payment_description = get_text("payment_description_subscription", months=months)
invoice_url = await cryptopay_service.create_invoice(
session=session,
user_id=user_id,
months=months,
amount=price_amount,
description=payment_description,
)
if invoice_url:
try:
await callback.message.edit_text(
get_text(key="payment_link_message", months=months),
reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n),
disable_web_page_preview=False,
)
except Exception:
try:
await callback.message.answer(
get_text(key="payment_link_message", months=months),
reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@router.callback_query(F.data.startswith("pay_stars:"))
async def pay_stars_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = (lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key)
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not settings.STARS_ENABLED:
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
months_str, stars_price_str = data_payload.split(":")
months = int(months_str)
stars_price = int(stars_price_str)
except (ValueError, IndexError):
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
payment_description = get_text("payment_description_subscription", months=months)
payment_db_id = await stars_service.create_invoice(
session=session,
user_id=user_id,
months=months,
stars_price=stars_price,
description=payment_description,
)
if payment_db_id:
try:
await callback.answer()
except Exception:
pass
return
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@router.pre_checkout_query()
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
await query.answer(ok=True)
except Exception:
# Nothing else to do here; Telegram will show an error if not answered
pass
@router.message(F.successful_payment)
async def handle_successful_stars_payment(
message: types.Message,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
):
payload = (message.successful_payment.invoice_payload
if message and message.successful_payment else "")
try:
payment_db_id_str, months_str = (payload or "").split(":", 1)
payment_db_id = int(payment_db_id_str)
months = int(months_str)
except Exception:
return
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
await stars_service.process_successful_payment(
session=session,
message=message,
payment_db_id=payment_db_id,
months=months,
stars_amount=stars_amount,
i18n_data=i18n_data,
)
+15
View File
@@ -113,6 +113,14 @@ async def request_trial_confirmation_handler(
# Send notification to admin about new trial
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
# Mark ad attribution trial if exists
try:
from db.dal import ad_dal as _ad_dal
await _ad_dal.mark_trial_activated(session, user_id)
await session.commit()
except Exception as e_mark:
await session.rollback()
logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}")
else:
message_key_from_service = (
activation_result.get("message_key", "trial_activation_failed")
@@ -298,6 +306,13 @@ async def confirm_activate_trial_handler(
if activation_result and activation_result.get("activated") and end_date_obj:
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
try:
from db.dal import ad_dal as _ad_dal
await _ad_dal.mark_trial_activated(session, user_id)
await session.commit()
except Exception as e_mark:
await session.rollback()
logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}")
@router.callback_query(F.data == "main_action:cancel_trial")
+77
View File
@@ -25,6 +25,10 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
builder.button(text=_(key="admin_promo_marketing_section"),
callback_data="admin_section:promo_marketing")
# Реклама
builder.button(text=_(key="admin_ads_section", default="📈 Реклама"),
callback_data="admin_action:ads")
# Системные функции
builder.button(text=_(key="admin_system_functions_section"),
callback_data="admin_section:system_functions")
@@ -116,6 +120,79 @@ def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
return builder.as_markup()
def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="admin_ads_create_button", default=" Создать кампанию"),
callback_data="admin_action:ads_create")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
builder.adjust(1, 1)
return builder.as_markup()
def get_ads_list_keyboard(
i18n_instance,
lang: str,
campaigns: list,
current_page: int,
total_pages: int,
) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
for c in campaigns:
title = f"{c.source}"
builder.button(
text=title,
callback_data=f"admin_ads:card:{c.ad_campaign_id}:{current_page}",
)
# Pagination row (only when needed)
if total_pages > 1:
row = []
if current_page > 0:
row.append(
InlineKeyboardButton(
text="⬅️ " + _("prev_page_button", default="Prev"),
callback_data=f"admin_ads:page:{current_page - 1}",
)
)
row.append(
InlineKeyboardButton(
text=f"{current_page + 1}/{total_pages}",
callback_data="ads_page_display",
)
)
if current_page < total_pages - 1:
row.append(
InlineKeyboardButton(
text=_("next_page_button", default="Next") + " ➡️",
callback_data=f"admin_ads:page:{current_page + 1}",
)
)
if row:
builder.row(*row)
builder.button(text=_(key="admin_ads_create_button", default=" Создать кампанию"),
callback_data="admin_action:ads_create")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
builder.adjust(1)
return builder.as_markup()
def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page: int) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"),
callback_data=f"admin_ads:page:{back_page}")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
builder.adjust(1)
return builder.as_markup()
def get_logs_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
+127 -14
View File
@@ -1,6 +1,6 @@
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
from typing import Dict, Optional, List
from typing import Dict, Optional, List, Tuple
from config.settings import Settings
@@ -21,20 +21,12 @@ def get_main_menu_inline_keyboard(
builder.row(
InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
callback_data="main_action:subscribe"))
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
InlineKeyboardButton(
text=_(key="menu_my_subscription_inline"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
)
else:
builder.row(
InlineKeyboardButton(
text=_(key="menu_my_subscription_inline"),
callback_data="main_action:my_subscription",
)
builder.row(
InlineKeyboardButton(
text=_(key="menu_my_subscription_inline"),
callback_data="main_action:my_subscription",
)
)
referral_button = InlineKeyboardButton(
text=_(key="menu_referral_inline"),
@@ -229,3 +221,124 @@ def get_connect_and_main_keyboard(
)
return builder.as_markup()
def get_payment_methods_manage_keyboard(lang: str, i18n_instance, has_card: bool) -> InlineKeyboardMarkup:
"""Deprecated in favor of get_payment_methods_list_keyboard. Kept for backward compatibility."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=_(key="payment_method_bind_button"), callback_data="pm:bind")
)
builder.row(
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main")
)
return builder.as_markup()
def get_payment_methods_list_keyboard(
cards: List[Tuple[str, str]],
page: int,
lang: str,
i18n_instance,
) -> InlineKeyboardMarkup:
"""
Build a paginated list of saved payment methods.
cards: list of tuples (payment_method_id, display_title)
page: 0-based page index
"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
per_page = 5
total = len(cards)
start = page * per_page
end = start + per_page
for pm_id, title in cards[start:end]:
builder.row(
InlineKeyboardButton(text=title, callback_data=f"pm:view:{pm_id}")
)
# Pagination controls if needed
nav_buttons: List[InlineKeyboardButton] = []
if start > 0:
nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"pm:list:{page-1}"))
if end < total:
nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"pm:list:{page+1}"))
if nav_buttons:
builder.row(*nav_buttons)
# Bind new card and back
builder.row(InlineKeyboardButton(text=_(key="payment_method_bind_button"), callback_data="pm:bind"))
builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main"))
return builder.as_markup()
def get_payment_method_delete_confirm_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=_(key="yes_button"), callback_data=f"pm:delete:{pm_id}"),
InlineKeyboardButton(text=_(key="cancel_button"), callback_data=f"pm:view:{pm_id}"),
)
return builder.as_markup()
def get_payment_method_details_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=_(key="payment_method_tx_history_title"), callback_data=f"pm:history:{pm_id}")
)
builder.row(
InlineKeyboardButton(text=_(key="payment_method_delete_button"), callback_data=f"pm:delete_confirm:{pm_id}")
)
builder.row(
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:list:0")
)
return builder.as_markup()
def get_bind_url_keyboard(bind_url: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="payment_method_bind_button"), url=bind_url)
builder.button(text=_(key="back_to_main_menu_button"), callback_data="pm:manage")
builder.adjust(1)
return builder.as_markup()
def get_back_to_payment_methods_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:list:0"))
return builder.as_markup()
def get_back_to_payment_method_details_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
# Back one step: return to specific payment method details
builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=f"pm:view:{pm_id}"))
return builder.as_markup()
def get_autorenew_cancel_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=_(key="autorenew_disable_button"), callback_data="autorenew:cancel")
)
builder.row(
InlineKeyboardButton(text=_(key="menu_my_subscription_inline"), callback_data="main_action:my_subscription")
)
return builder.as_markup()
def get_autorenew_confirm_keyboard(enable: bool, sub_id: int, lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=_(key="yes_button"), callback_data=f"autorenew:confirm:{sub_id}:{1 if enable else 0}"),
InlineKeyboardButton(text=_(key="no_button"), callback_data="main_action:my_subscription"),
)
return builder.as_markup()
+2
View File
@@ -293,6 +293,8 @@ async def run_bot(settings_param: Settings):
main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask"))
# Recurring billing moved to panel webhook (24h before expiry). No periodic task needed here.
logging.info("Starting bot in Webhook mode with AIOHTTP server...")
logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}")
+18 -1
View File
@@ -45,10 +45,27 @@ class JsonI18n:
exc_info=True)
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
effective_lang_code = lang_code if lang_code and lang_code in self.locales_data else self.default_lang
# Determine effective language with robust fallback
if lang_code and lang_code in self.locales_data:
effective_lang_code = lang_code
elif self.default_lang in self.locales_data:
effective_lang_code = self.default_lang
elif 'en' in self.locales_data:
effective_lang_code = 'en'
else:
effective_lang_code = lang_code or self.default_lang
lang_data = self.locales_data.get(effective_lang_code)
if lang_data is None:
# Try explicit fallback to English if available
fallback_data = self.locales_data.get('en')
if fallback_data is not None:
text = fallback_data.get(key)
if text is not None:
try:
return text.format(**kwargs) if kwargs else text
except Exception:
return text
logging.warning(
f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is."
)
+38 -18
View File
@@ -67,18 +67,28 @@ class CryptoPayService:
logging.error("CryptoPayService not configured")
return None
payment_record = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": float(amount),
"currency": self.settings.CRYPTOPAY_ASSET,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": months,
"provider": "cryptopay",
},
)
# Create pending payment in DB and commit to persist
try:
payment_record = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": float(amount),
"currency": self.settings.CRYPTOPAY_ASSET,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": months,
"provider": "cryptopay",
},
)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"Failed to create cryptopay payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
return None
payload = json.dumps({
"user_id": str(user_id),
"subscription_months": str(months),
@@ -93,12 +103,21 @@ class CryptoPayService:
description=description,
payload=payload,
)
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(invoice.invoice_id),
str(invoice.status),
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(invoice.invoice_id),
str(invoice.status),
)
await session.commit()
except Exception as e_db_update:
await session.rollback()
logging.error(
f"Failed to update cryptopay payment record {payment_record.payment_id}: {e_db_update}",
exc_info=True,
)
return None
return invoice.bot_invoice_url
except Exception as e:
logging.error(f"CryptoPay invoice creation failed: {e}", exc_info=True)
@@ -155,6 +174,7 @@ class CryptoPayService:
return
db_user = await user_dal.get_user_by_id(session, user_id)
# Use DB language for user-facing messages
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
+45 -1
View File
@@ -10,7 +10,7 @@ from typing import Optional
from config.settings import Settings
from .panel_api_service import PanelApiService
from bot.middlewares.i18n import JsonI18n
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
from db.dal import user_dal
from bot.utils.date_utils import add_months
@@ -185,7 +185,51 @@ class PanelWebhookService:
if event_name in EVENT_MAP:
days_left, msg_key = EVENT_MAP[event_name]
if days_left == 1:
# Trigger auto-renew via SubscriptionService (wired in at factory)
try:
subscription_service = getattr(self, "subscription_service", None)
if subscription_service:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
try:
ok = await subscription_service.charge_subscription_renewal(session, sub)
# If initiation succeeded, suppress the 24h reminder by returning early
if ok:
await session.commit()
return
else:
await session.rollback()
except Exception:
await session.rollback()
logging.exception("Auto-renew attempt (24h) failed")
except Exception:
logging.exception("Auto-renew trigger (24h) failed pre-check")
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
# For 48h event, if auto-renew is enabled and not tribute, show special notice with cancel button
if days_left == 2:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
logging.info(
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
user_id,
bool(sub),
getattr(sub, 'auto_renew_enabled', None) if sub else None,
getattr(sub, 'provider', None) if sub else None,
)
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
await self._send_message(
user_id,
lang,
"autorenew_48h_charge_tomorrow_notice",
reply_markup=cancel_kb,
user_name=first_name,
)
return
await self._send_message(
user_id,
lang,
+3 -2
View File
@@ -109,8 +109,9 @@ class StarsService:
if not final_end:
final_end = activation_details["end_date"]
current_lang = i18n_data.get("current_language",
self.settings.DEFAULT_LANGUAGE)
# Always use user's language from DB for user-facing messages
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
current_lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
i18n: JsonI18n = i18n_data.get("i18n_instance")
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
+58 -1
View File
@@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple
from aiogram import Bot
from bot.middlewares.i18n import JsonI18n
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal
from bot.utils.date_utils import add_months
from db.models import User, Subscription
@@ -509,6 +509,7 @@ class SubscriptionService:
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
"provider": provider,
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
"auto_renew_enabled": True,
}
try:
new_or_updated_sub = await subscription_dal.upsert_subscription(
@@ -780,6 +781,62 @@ class SubscriptionService:
)
return results
async def charge_subscription_renewal(
self,
session: AsyncSession,
sub: Subscription,
) -> bool:
"""Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure."""
if not sub.auto_renew_enabled:
return True
# If autopayments are disabled globally, skip charging attempts
if not getattr(self.settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
return True
if sub.provider == "tribute":
# Tribute is paid externally; we do not auto-charge here
return True
from db.dal.user_billing_dal import get_user_default_payment_method
default_pm = await get_user_default_payment_method(session, sub.user_id)
if not default_pm:
logging.info(f"Auto-renew skipped: no saved payment method for user {sub.user_id}")
return False
try:
from .yookassa_service import YooKassaService # local import to avoid cycles
yk: YooKassaService = self.yookassa_service # type: ignore[attr-defined]
except Exception:
yk = None # type: ignore
if not yk or not getattr(yk, 'configured', False):
logging.warning("YooKassa unavailable for auto-renew")
return False
months = sub.duration_months or 1
amount = self.settings.subscription_options.get(months)
if not amount:
logging.error(f"Auto-renew price missing for {months} months")
return False
metadata = {
"user_id": str(sub.user_id),
"auto_renew_for_subscription_id": str(sub.subscription_id),
"subscription_months": str(months),
}
resp = await yk.create_payment(
amount=float(amount),
currency="RUB",
description=f"Auto-renewal for {months} months",
metadata=metadata,
payment_method_id=default_pm.provider_payment_method_id,
save_payment_method=False,
capture=True,
)
if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}:
logging.error(f"Auto-renew create_payment failed: {resp}")
return False
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
return True
async def update_last_notification_sent(
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
):
+1
View File
@@ -209,6 +209,7 @@ class TributeService:
)
try:
# Use user's DB language in success messages prepared above
await bot.send_message(
int(user_id),
success_msg,
+66 -35
View File
@@ -61,7 +61,11 @@ class YooKassaService:
description: str,
metadata: Dict[str, Any],
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
receipt_phone: Optional[str] = None,
save_payment_method: bool = False,
payment_method_id: Optional[str] = None,
capture: bool = True,
bind_only: bool = False) -> Optional[Dict[str, Any]]:
if not self.configured:
logging.error("YooKassa is not configured. Cannot create payment.")
return None
@@ -102,13 +106,23 @@ class YooKassaService:
"value": str(round(amount, 2)),
"currency": currency.upper()
})
builder.set_capture(True)
# For binding cards only, do not capture and set minimal amount
if bind_only:
capture = False
amount = max(amount, 1.00)
builder.set_capture(capture)
builder.set_confirmation({
"type": ConfirmationType.REDIRECT,
"return_url": self.return_url
})
builder.set_description(description)
builder.set_metadata(metadata)
if save_payment_method:
# Ask YooKassa to save method for off-session charges
builder.set_save_payment_method(True)
if payment_method_id:
# Use a previously saved payment method for merchant-initiated payments
builder.set_payment_method_id(payment_method_id)
receipt_items_list: List[Dict[str, Any]] = [{
"description":
@@ -122,9 +136,9 @@ class YooKassaService:
"vat_code":
str(self.settings.YOOKASSA_VAT_CODE),
"payment_mode":
self.settings.YOOKASSA_PAYMENT_MODE,
getattr(self.settings, 'yk_receipt_payment_mode', self.settings.YOOKASSA_PAYMENT_MODE),
"payment_subject":
self.settings.YOOKASSA_PAYMENT_SUBJECT
getattr(self.settings, 'yk_receipt_payment_subject', self.settings.YOOKASSA_PAYMENT_SUBJECT)
}]
receipt_data_dict: Dict[str, Any] = {
@@ -178,7 +192,8 @@ class YooKassaService:
"description_from_yk":
response.description,
"test_mode":
response.test if hasattr(response, 'test') else None
response.test if hasattr(response, 'test') else None,
"payment_method": getattr(response, 'payment_method', None),
}
except Exception as e:
logging.error(f"YooKassa payment creation failed: {e}",
@@ -204,37 +219,40 @@ class YooKassaService:
logging.info(
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}"
)
pm = getattr(payment_info_yk, 'payment_method', None)
pm_payload: Dict[str, Any] = {}
if pm:
# Collect common fields, including id and hints for last4
pm_id = getattr(pm, 'id', None)
pm_type = getattr(pm, 'type', None)
pm_title = getattr(pm, 'title', None)
account_number = getattr(pm, 'account_number', None) or getattr(pm, 'account', None)
card_obj = getattr(pm, 'card', None)
last4_val = None
if card_obj and hasattr(card_obj, 'last4'):
last4_val = getattr(card_obj, 'last4')
elif isinstance(account_number, str) and len(account_number) >= 4:
last4_val = account_number[-4:]
pm_payload = {
"id": pm_id,
"type": pm_type,
"title": pm_title,
"card_last4": last4_val,
}
return {
"id":
payment_info_yk.id,
"status":
payment_info_yk.status,
"paid":
payment_info_yk.paid,
"amount_value":
float(payment_info_yk.amount.value),
"amount_currency":
payment_info_yk.amount.currency,
"metadata":
payment_info_yk.metadata,
"description":
payment_info_yk.description,
"refundable":
payment_info_yk.refundable,
"created_at":
payment_info_yk.created_at.isoformat() if hasattr(
payment_info_yk.created_at, 'isoformat') else str(
payment_info_yk.created_at),
"captured_at":
payment_info_yk.captured_at.isoformat()
if payment_info_yk.captured_at and hasattr(
payment_info_yk.captured_at, 'isoformat') else None,
"payment_method_type":
payment_info_yk.payment_method.type
if payment_info_yk.payment_method else None,
"test_mode":
payment_info_yk.test
if hasattr(payment_info_yk, 'test') else None
"id": payment_info_yk.id,
"status": payment_info_yk.status,
"paid": payment_info_yk.paid,
"amount_value": float(payment_info_yk.amount.value),
"amount_currency": payment_info_yk.amount.currency,
"metadata": payment_info_yk.metadata,
"description": payment_info_yk.description,
"refundable": payment_info_yk.refundable,
"created_at": payment_info_yk.created_at.isoformat() if hasattr(
payment_info_yk.created_at, 'isoformat') else str(payment_info_yk.created_at),
"captured_at": payment_info_yk.captured_at.isoformat() if getattr(payment_info_yk, 'captured_at', None) and hasattr(payment_info_yk.captured_at, 'isoformat') else None,
"payment_method": pm_payload,
"test_mode": getattr(payment_info_yk, 'test', None),
}
else:
logging.warning(
@@ -246,3 +264,16 @@ class YooKassaService:
f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}",
exc_info=True)
return None
async def cancel_payment(self, payment_id_in_yookassa: str) -> bool:
if not self.configured:
logging.error("YooKassa is not configured. Cannot cancel payment.")
return False
try:
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa))
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
return True
except Exception as e:
logging.error(f"Failed to cancel YooKassa payment {payment_id_in_yookassa}: {e}")
return False
+5
View File
@@ -28,3 +28,8 @@ class AdminStates(StatesGroup):
waiting_for_user_search = State()
waiting_for_subscription_days_to_add = State()
waiting_for_direct_message_to_user = State()
# Ads campaigns
waiting_for_ad_source = State()
waiting_for_ad_start_param = State()
waiting_for_ad_cost = State()
+16
View File
@@ -30,8 +30,11 @@ class Settings(BaseSettings):
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None)
YOOKASSA_VAT_CODE: int = Field(default=1)
# Deprecated: explicit receipt fields are now derived from YOOKASSA_AUTOPAYMENTS_ENABLED
YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment")
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
WEBHOOK_BASE_URL: Optional[str] = None
@@ -233,6 +236,19 @@ class Settings(BaseSettings):
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
return None
# Computed YooKassa receipt fields based on recurring toggle
@computed_field
@property
def yk_receipt_payment_mode(self) -> str:
# If autopayments are enabled, use service; otherwise full prepayment
return "service" if self.YOOKASSA_AUTOPAYMENTS_ENABLED else "full_prepayment"
@computed_field
@property
def yk_receipt_payment_subject(self) -> str:
# If autopayments are enabled, use full_payment; otherwise payment
return "full_payment" if self.YOOKASSA_AUTOPAYMENTS_ENABLED else "payment"
@computed_field
@property
def subscription_options(self) -> Dict[int, float]:
+21
View File
@@ -0,0 +1,21 @@
from . import user_dal
from . import payment_dal
from . import subscription_dal
from . import promo_code_dal
from . import panel_sync_dal
from . import message_log_dal
from . import user_billing_dal
from . import ad_dal
__all__ = (
"user_dal",
"payment_dal",
"subscription_dal",
"promo_code_dal",
"panel_sync_dal",
"message_log_dal",
"user_billing_dal",
"ad_dal",
)
+164
View File
@@ -0,0 +1,164 @@
import logging
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sqlalchemy import update, delete, func, and_
from ..models import AdCampaign, AdAttribution, Payment
async def create_campaign(
session: AsyncSession, *, source: str, start_param: str, cost: float
) -> AdCampaign:
existing = await get_campaign_by_start_param(session, start_param)
if existing:
raise ValueError("ad_campaign_start_param_exists")
campaign = AdCampaign(source=source, start_param=start_param, cost=float(cost))
session.add(campaign)
await session.flush()
await session.refresh(campaign)
logging.info(
f"AdCampaign created id={campaign.ad_campaign_id}, source={source}, start={start_param}, cost={cost}"
)
return campaign
async def get_campaign_by_id(session: AsyncSession, campaign_id: int) -> Optional[AdCampaign]:
stmt = select(AdCampaign).where(AdCampaign.ad_campaign_id == campaign_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_campaign_by_start_param(session: AsyncSession, start_param: str) -> Optional[AdCampaign]:
clean = start_param.strip()
stmt = select(AdCampaign).where(AdCampaign.start_param == clean)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def list_campaigns(session: AsyncSession, *, only_active: bool = False) -> List[AdCampaign]:
stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc())
if only_active:
stmt = stmt.where(AdCampaign.is_active == True)
result = await session.execute(stmt)
return result.scalars().all()
async def toggle_campaign_active(session: AsyncSession, campaign_id: int, is_active: bool) -> bool:
stmt = (
update(AdCampaign)
.where(AdCampaign.ad_campaign_id == campaign_id)
.values(is_active=is_active)
)
result = await session.execute(stmt)
return result.rowcount > 0
async def ensure_attribution(session: AsyncSession, *, user_id: int, campaign_id: int) -> AdAttribution:
existing = await get_attribution_for_user(session, user_id)
if existing:
return existing
attrib = AdAttribution(user_id=user_id, ad_campaign_id=campaign_id)
session.add(attrib)
await session.flush()
await session.refresh(attrib)
logging.info(f"AdAttribution created for user {user_id} -> campaign {campaign_id}")
return attrib
async def get_attribution_for_user(session: AsyncSession, user_id: int) -> Optional[AdAttribution]:
stmt = select(AdAttribution).where(AdAttribution.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def mark_trial_activated(session: AsyncSession, user_id: int) -> bool:
stmt = (
update(AdAttribution)
.where(and_(AdAttribution.user_id == user_id, AdAttribution.trial_activated_at.is_(None)))
.values(trial_activated_at=func.now())
)
result = await session.execute(stmt)
return result.rowcount > 0
async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[str, Any]:
# Starts (attributed users)
starts_stmt = select(func.count(AdAttribution.user_id)).where(
AdAttribution.ad_campaign_id == campaign_id
)
starts = (await session.execute(starts_stmt)).scalar() or 0
# Trials
trials_stmt = select(func.count(AdAttribution.user_id)).where(
and_(AdAttribution.ad_campaign_id == campaign_id, AdAttribution.trial_activated_at.is_not(None))
)
trials = (await session.execute(trials_stmt)).scalar() or 0
# Payers (unique users with succeeded payments)
payers_stmt = select(func.count(func.distinct(Payment.user_id))).select_from(Payment).where(
and_(
Payment.status == "succeeded",
Payment.user_id.in_(
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
),
)
)
payers = (await session.execute(payers_stmt)).scalar() or 0
# Revenue sum
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
and_(
Payment.status == "succeeded",
Payment.user_id.in_(
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
),
)
)
revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
return {
"starts": int(starts),
"trials": int(trials),
"payers": int(payers),
"revenue": revenue,
}
async def count_campaigns(session: AsyncSession, *, only_active: bool = False) -> int:
stmt = select(func.count(AdCampaign.ad_campaign_id))
if only_active:
stmt = stmt.where(AdCampaign.is_active == True)
return int((await session.execute(stmt)).scalar() or 0)
async def list_campaigns_paged(
session: AsyncSession, *, page: int, page_size: int, only_active: bool = False
) -> List[AdCampaign]:
offset = max(0, page) * max(1, page_size)
stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc()).offset(offset).limit(page_size)
if only_active:
stmt = stmt.where(AdCampaign.is_active == True)
result = await session.execute(stmt)
return result.scalars().all()
async def get_totals(session: AsyncSession) -> Dict[str, float]:
# Total cost across all campaigns
total_cost_stmt = select(func.coalesce(func.sum(AdCampaign.cost), 0.0))
total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0)
# Total revenue from all attributed users (unique users counted across all campaigns)
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
and_(
Payment.status == "succeeded",
Payment.user_id.in_(select(AdAttribution.user_id)),
)
)
total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
return {"cost": total_cost, "revenue": total_revenue}
+5
View File
@@ -53,6 +53,11 @@ async def update_subscription(
return sub
async def set_auto_renew(session: AsyncSession, subscription_id: int, enabled: bool) -> Optional[Subscription]:
"""Toggle auto_renew_enabled for a subscription."""
return await update_subscription(session, subscription_id, {"auto_renew_enabled": enabled})
async def set_user_subscriptions_cancelled_with_grace(
session: AsyncSession, user_id: int, grace_days: int = 1) -> int:
"""Mark all active user subscriptions as cancelled with a short grace period.
+164
View File
@@ -0,0 +1,164 @@
from typing import Optional, Dict, Any, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from sqlalchemy.sql import func
from db.models import UserBilling, UserPaymentMethod
async def get_user_billing(session: AsyncSession, user_id: int) -> Optional[UserBilling]:
stmt = select(UserBilling).where(UserBilling.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def upsert_yk_payment_method(
session: AsyncSession,
*,
user_id: int,
payment_method_id: str,
card_last4: Optional[str] = None,
card_network: Optional[str] = None,
) -> UserBilling:
existing = await get_user_billing(session, user_id)
if existing:
existing.yookassa_payment_method_id = payment_method_id
existing.card_last4 = card_last4
existing.card_network = card_network
existing.updated_at = func.now()
await session.flush()
await session.refresh(existing)
return existing
record = UserBilling(
user_id=user_id,
yookassa_payment_method_id=payment_method_id,
card_last4=card_last4,
card_network=card_network,
)
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def delete_yk_payment_method(session: AsyncSession, user_id: int) -> bool:
existing = await get_user_billing(session, user_id)
if not existing:
return False
existing.yookassa_payment_method_id = None
existing.card_last4 = None
existing.card_network = None
existing.updated_at = func.now()
await session.flush()
await session.refresh(existing)
return True
# Multi-card support API
async def upsert_user_payment_method(
session: AsyncSession,
*,
user_id: int,
provider_payment_method_id: str,
provider: str = "yookassa",
card_last4: Optional[str] = None,
card_network: Optional[str] = None,
set_default: bool = False,
) -> UserPaymentMethod:
existing_stmt = select(UserPaymentMethod).where(UserPaymentMethod.provider_payment_method_id == provider_payment_method_id)
result = await session.execute(existing_stmt)
existing: Optional[UserPaymentMethod] = result.scalar_one_or_none()
if existing:
existing.card_last4 = card_last4
existing.card_network = card_network
if set_default:
# unset previous defaults
await session.execute(
update(UserPaymentMethod)
.where(UserPaymentMethod.user_id == user_id)
.values(is_default=False)
)
existing.is_default = True
existing.updated_at = func.now()
await session.flush()
await session.refresh(existing)
return existing
if set_default:
await session.execute(
update(UserPaymentMethod)
.where(UserPaymentMethod.user_id == user_id)
.values(is_default=False)
)
record = UserPaymentMethod(
user_id=user_id,
provider=provider,
provider_payment_method_id=provider_payment_method_id,
card_last4=card_last4,
card_network=card_network,
is_default=set_default,
)
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def list_user_payment_methods(session: AsyncSession, user_id: int, provider: Optional[str] = None) -> List[UserPaymentMethod]:
stmt = select(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
if provider:
stmt = stmt.where(UserPaymentMethod.provider == provider)
stmt = stmt.order_by(UserPaymentMethod.is_default.desc(), UserPaymentMethod.created_at.desc())
result = await session.execute(stmt)
return result.scalars().all()
async def get_user_default_payment_method(session: AsyncSession, user_id: int, provider: str = "yookassa") -> Optional[UserPaymentMethod]:
stmt = select(UserPaymentMethod).where(
UserPaymentMethod.user_id == user_id,
UserPaymentMethod.provider == provider,
UserPaymentMethod.is_default == True,
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def set_user_default_payment_method(session: AsyncSession, user_id: int, method_id: int) -> bool:
methods = await list_user_payment_methods(session, user_id)
if not any(m.method_id == method_id for m in methods):
return False
await session.execute(update(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id).values(is_default=False))
await session.execute(update(UserPaymentMethod).where(UserPaymentMethod.method_id == method_id).values(is_default=True))
return True
async def delete_user_payment_method(session: AsyncSession, user_id: int, method_id: int) -> bool:
stmt = select(UserPaymentMethod).where(UserPaymentMethod.method_id == method_id, UserPaymentMethod.user_id == user_id)
result = await session.execute(stmt)
method = result.scalar_one_or_none()
if not method:
return False
await session.delete(method)
await session.flush()
return True
async def delete_user_payment_method_by_provider_id(
session: AsyncSession,
user_id: int,
provider_payment_method_id: str,
) -> bool:
"""Delete a saved payment method by its provider payment_method.id for a specific user.
Useful when callbacks pass the provider id (e.g., YooKassa pm_...) instead of our internal method_id.
"""
stmt = select(UserPaymentMethod).where(
UserPaymentMethod.user_id == user_id,
UserPaymentMethod.provider_payment_method_id == provider_payment_method_id,
)
result = await session.execute(stmt)
method: Optional[UserPaymentMethod] = result.scalar_one_or_none()
if not method:
return False
await session.delete(method)
await session.flush()
return True
+3
View File
@@ -4,6 +4,7 @@ from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from .models import Base
from .migrator import run_simple_migrations
async_engine = None
@@ -62,6 +63,8 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Run lightweight, idempotent migrations to add any missing columns
await conn.run_sync(run_simple_migrations)
logging.info(
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
)
+66
View File
@@ -0,0 +1,66 @@
import logging
from typing import Set
from sqlalchemy import inspect, text
from sqlalchemy.engine import Connection
from .models import Base
def _add_missing_columns(connection: Connection) -> None:
inspector = inspect(connection)
metadata = Base.metadata
existing_tables: Set[str] = set(inspector.get_table_names())
for table in metadata.tables.values():
table_name = table.name
if table_name not in existing_tables:
# Tables are created elsewhere via create_all; skip here.
continue
existing_columns = {col_info["name"] for col_info in inspector.get_columns(table_name)}
for desired_column in table.columns:
if desired_column.name in existing_columns:
continue
# Build ADD COLUMN DDL
preparer = connection.dialect.identifier_preparer
table_quoted = preparer.format_table(table)
column_name_quoted = preparer.quote(desired_column.name)
column_type_sql = desired_column.type.compile(dialect=connection.dialect)
default_clause = ""
server_default = getattr(desired_column, "server_default", None)
if server_default is not None and getattr(server_default, "arg", None) is not None:
try:
compiled_default = str(
server_default.arg.compile(dialect=connection.dialect)
)
default_clause = f" DEFAULT {compiled_default}"
except Exception: # best-effort
pass
# For safety, add new columns as NULLable to avoid failures on existing rows
# If strict NOT NULL is needed, it can be enforced manually later.
ddl = f"ALTER TABLE {table_quoted} ADD COLUMN {column_name_quoted} {column_type_sql}{default_clause}"
logging.info(
f"Migrator: adding missing column {desired_column.name} to table {table_name}"
)
connection.execute(text(ddl))
def run_simple_migrations(connection: Connection) -> None:
"""
Run lightweight, idempotent migrations:
- Ensure missing columns are added to existing tables to match models in db/models.py
Note: Table creation is handled separately via Base.metadata.create_all.
"""
try:
_add_missing_columns(connection)
logging.info("Migrator: schema synchronized (columns added as needed).")
except Exception as e:
logging.error(f"Migrator: failed to run simple migrations: {e}", exc_info=True)
raise
+64
View File
@@ -72,6 +72,7 @@ class Subscription(Base):
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
provider = Column(String, nullable=True)
skip_notifications = Column(Boolean, default=False)
auto_renew_enabled = Column(Boolean, default=True, index=True)
user = relationship("User", back_populates="subscriptions")
@@ -112,6 +113,37 @@ class Payment(Base):
back_populates="payments_where_used")
class UserBilling(Base):
__tablename__ = "user_billing"
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True)
# Saved payment method for off-session recurring charges (YooKassa)
yookassa_payment_method_id = Column(String, nullable=True, unique=True)
card_last4 = Column(String, nullable=True)
card_network = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
user = relationship("User")
class UserPaymentMethod(Base):
__tablename__ = "user_payment_methods"
method_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
provider = Column(String, nullable=False, default="yookassa", index=True)
provider_payment_method_id = Column(String, nullable=False, unique=True, index=True)
card_last4 = Column(String, nullable=True)
card_network = Column(String, nullable=True)
is_default = Column(Boolean, default=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
user = relationship("User")
__table_args__ = (
UniqueConstraint('user_id', 'provider_payment_method_id', name='uq_user_provider_method'),
)
class PromoCode(Base):
__tablename__ = "promo_codes"
@@ -195,3 +227,35 @@ class PanelSyncStatus(Base):
subscriptions_synced = Column(Integer, default=0)
__table_args__ = (UniqueConstraint('id'), )
class AdCampaign(Base):
__tablename__ = "ad_campaigns"
ad_campaign_id = Column(Integer, primary_key=True, autoincrement=True)
source = Column(String, nullable=False, index=True)
start_param = Column(String, nullable=False, unique=True, index=True)
cost = Column(Float, nullable=False, default=0.0)
is_active = Column(Boolean, default=True, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
attributions = relationship(
"AdAttribution",
back_populates="campaign",
cascade="all, delete-orphan",
)
def __repr__(self):
return f"<AdCampaign(id={self.ad_campaign_id}, source='{self.source}', start_param='{self.start_param}', cost={self.cost})>"
class AdAttribution(Base):
__tablename__ = "ad_attributions"
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True, index=True)
ad_campaign_id = Column(Integer, ForeignKey("ad_campaigns.ad_campaign_id"), nullable=False, index=True)
first_start_at = Column(DateTime(timezone=True), server_default=func.now())
trial_activated_at = Column(DateTime(timezone=True), nullable=True)
user = relationship("User")
campaign = relationship("AdCampaign", back_populates="attributions")
+8
View File
@@ -11,6 +11,9 @@ services:
volumes:
- ./locales:/app/locales
restart: unless-stopped
depends_on:
remnawave-tg-shop-db:
condition: service_healthy
remnawave-tg-shop-db:
image: postgres:17
@@ -23,6 +26,11 @@ services:
networks:
- remnawave-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
networks:
remnawave-network:
+47 -1
View File
@@ -209,8 +209,12 @@
"subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.",
"subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.",
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
"tribute_subscription_cancelled": "🚨 <b>Subscription Cancelled</b>\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.",
"tribute_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}",
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
"admin_user_subscription_info": "Subscription Information:",
"admin_user_reset_trial_button": "🔄 Reset Trial",
@@ -371,6 +375,31 @@
"admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}",
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
"autorenew_enable_button": "🔄 Enable auto-renew",
"autorenew_disable_button": "🛑 Disable auto-renew",
"subscription_autorenew_updated": "Auto-renew settings updated.",
"payment_methods_manage_button": "💳 Payment Methods",
"payment_methods_title": "💳 <b>Payment Methods</b>",
"payment_method_bind_button": " Add method",
"payment_method_delete_button": "🗑 Remove",
"payment_method_view_button": "️ Details",
"payment_method_none": "You don't have a saved payment method yet.",
"payment_method_bound_success": "✅ Payment method added.",
"payment_method_deleted_success": "✅ Payment method removed.",
"payment_method_delete_confirm": "Remove saved payment method?",
"payment_method_card_title": "💳 {network} ••••{last4}",
"payment_method_generic_title": "💳 {network}",
"payment_method_wallet_title": "💼 YooMoney wallet ••••{last4}",
"payment_network_card": "Card",
"payment_network_generic": "Payment method",
"payment_method_added_at": "Added: {date}",
"payment_method_last_tx": "Last transaction: {date}",
"payment_method_tx_history_title": "📜 Transactions history",
"payment_method_no_history": "No transactions history.",
"subscription_purchase_title": "Subscription purchase for {months} mo.",
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
"subscription_not_active": "You don't have an active subscription.",
"error_service_unavailable": "Service unavailable. Please try again later.",
"error_payment_gateway": "Payment service error. Please try again later.",
@@ -380,5 +409,22 @@
"error_creating_payment_record": "Error creating payment record. Please try again later.",
"error_payment_gateway_link_failed": "Error creating payment link. Please try again later.",
"status_active": "Active",
"status_inactive": "Inactive"
"status_inactive": "Inactive",
"admin_ads_section": "📈 Ads",
"admin_ads_header": "📈 Ad Campaigns:",
"admin_ads_empty": "📭 No ad campaigns. Click \"Create\" to add one.",
"admin_ads_item": "ID: {id}\nSource: <b>{source}</b>\nstart={start_param}\nCost: {cost} RUB\nActive: {active}\n— Starts: {starts}\n— Trials: {trials}\n— Payers: {payers}\n— Revenue: {revenue} RUB",
"admin_ads_create_button": " Create campaign",
"admin_ads_create_source_prompt": "Enter source (e.g., AEZA, VK, TG-channel):",
"admin_ads_create_start_param_prompt": "Enter start link parameter (e.g., AEZA). Will be used as start=AEZA",
"admin_ads_create_cost_prompt": "Enter campaign cost (RUB):",
"admin_ads_invalid_source": "❌ Invalid source. Enter up to 64 characters.",
"admin_ads_invalid_start_param": "❌ Invalid parameter. Allowed letters/digits/underscore/dash (2-64).",
"admin_ads_invalid_cost": "❌ Invalid amount. Enter a non-negative number.",
"admin_ads_start_param_exists": "❌ A campaign with this start parameter already exists.",
"admin_ads_created_success": "✅ Campaign created!\nID: {id}\nSource: {source}\nParam: {start_param}\nCost: {cost} RUB",
"admin_ads_back_to_menu_hint": "Done. Back to Ads section:",
"admin_ads_overview": "📈 <b>Ads</b>\n💰 Revenue: <b>{revenue} RUB</b>\n💸 Spent: <b>{cost} RUB</b>",
"back_to_ads_list_button": "⬅️ Back to list",
"admin_ads_card": "📈 <b>Campaign #{id}</b>\nSource: <b>{source}</b>\nstart=<code>{start_param}</code>\nCost: <b>{cost} RUB</b>\nActive: {active}\n\n👥 Starts: <b>{starts}</b>\n🆓 Trials: <b>{trials}</b>\n💳 Payers: <b>{payers}</b>\n💵 Revenue: <b>{revenue} RUB</b>"
}
+47 -1
View File
@@ -139,7 +139,11 @@
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.",
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
"tribute_subscription_cancelled": "🚨 <b>Подписка отменена</b>\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.",
"yookassa_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
"admin_promo_set_validity_days": "⏰ Установить срок (дни)",
"admin_back_to_panel": "⬅️ В панель",
"admin_promo_unlimited": "♾️ Неограниченно",
@@ -370,6 +374,31 @@
"admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}",
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
"autorenew_enable_button": "🔄 Включить автопродление",
"autorenew_disable_button": "🛑 Отключить автопродление",
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
"payment_methods_manage_button": "💳 Способы оплаты",
"payment_methods_title": "💳 <b>Способы оплаты</b>",
"payment_method_bind_button": "➕ Добавить способ",
"payment_method_delete_button": "🗑 Удалить",
"payment_method_view_button": "️ Детали",
"payment_method_none": "У вас пока нет сохранённого способа оплаты.",
"payment_method_bound_success": "✅ Способ оплаты добавлен.",
"payment_method_deleted_success": "✅ Способ оплаты удалён.",
"payment_method_delete_confirm": "Удалить сохранённый способ оплаты?",
"payment_method_card_title": "💳 {network} ••••{last4}",
"payment_method_generic_title": "💳 {network}",
"payment_method_wallet_title": "💼 Кошелёк YooMoney ••••{last4}",
"payment_network_card": "Карта",
"payment_network_generic": "Способ оплаты",
"payment_method_added_at": "Добавлена: {date}",
"payment_method_last_tx": "Последняя операция: {date}",
"payment_method_tx_history_title": "📜 История операций",
"payment_method_no_history": "История операций отсутствует.",
"subscription_purchase_title": "Покупка подписки на {months} мес.",
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
"subscription_not_active": "У вас нет активной подписки.",
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
@@ -379,5 +408,22 @@
"error_creating_payment_record": "Ошибка создания записи платежа. Попробуйте позже.",
"error_payment_gateway_link_failed": "Ошибка создания платежной ссылки. Попробуйте позже.",
"status_active": "Активна",
"status_inactive": "Неактивна"
"status_inactive": "Неактивна",
"admin_ads_section": "📈 Реклама",
"admin_ads_header": "📈 Рекламные кампании:",
"admin_ads_empty": "📭 Рекламные кампании отсутствуют. Нажмите \"Создать\" чтобы добавить новую.",
"admin_ads_item": "ID: {id}\nИсточник: <b>{source}</b>\nstart={start_param}\nСтоимость: {cost} RUB\nАктивна: {active}\n— Запустили: {starts}\n— Взяли триал: {trials}\n— Оплатили: {payers}\n— Доход: {revenue} RUB",
"admin_ads_create_button": "➕ Создать кампанию",
"admin_ads_create_source_prompt": "Введите источник (например: AEZA, VK, TG-канал):",
"admin_ads_create_start_param_prompt": "Введите параметр старт-ссылки (например: AEZA). Будет использован как start=AEZA",
"admin_ads_create_cost_prompt": "Введите сумму затрат на кампанию (в RUB):",
"admin_ads_invalid_source": "❌ Неверный источник. Введите до 64 символов.",
"admin_ads_invalid_start_param": "❌ Неверный параметр. Допустимы буквы/цифры/подчёркивания/дефисы (2-64).",
"admin_ads_invalid_cost": "❌ Неверная сумма. Введите неотрицательное число.",
"admin_ads_start_param_exists": "❌ Кампания с таким start-параметром уже существует.",
"admin_ads_created_success": "✅ Кампания создана!\nID: {id}\nИсточник: {source}\nПараметр: {start_param}\nЗатраты: {cost} RUB",
"admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:",
"admin_ads_overview": "📈 <b>Реклама</b>\n💰 Пришло: <b>{revenue} RUB</b>\n💸 Потрачено: <b>{cost} RUB</b>",
"back_to_ads_list_button": "⬅️ К списку",
"admin_ads_card": "📈 <b>Кампания #{id}</b>\nИсточник: <b>{source}</b>\nstart=<code>{start_param}</code>\nСтоимость: <b>{cost} RUB</b>\nАктивна: {active}\n\n👥 Запустили: <b>{starts}</b>\n🆓 Взяли триал: <b>{trials}</b>\n💳 Оплатили: <b>{payers}</b>\n💵 Доход: <b>{revenue} RUB</b>"
}