diff --git a/.env.example b/.env.example index 5be10d1..fc19fbc 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index fd0a62f..0a8d3fa 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ __pycache__/ *.pid locales/ru_backup.json locales/en_backup.json +db/models_old.py diff --git a/bot/app/factories/build_services.py b/bot/app/factories/build_services.py index 8e84968..68b770b 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -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, diff --git a/bot/handlers/admin/__init__.py b/bot/handlers/admin/__init__.py index ecf8b9f..6f5dc6d 100644 --- a/bot/handlers/admin/__init__.py +++ b/bot/handlers/admin/__init__.py @@ -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", ) diff --git a/bot/handlers/admin/ads.py b/bot/handlers/admin/ads.py new file mode 100644 index 0000000..a16e4ac --- /dev/null +++ b/bot/handlers/admin/ads.py @@ -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)) + + diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py index 8204404..996cd83 100644 --- a/bot/handlers/admin/common.py +++ b/bot/handlers/admin/common.py @@ -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( diff --git a/bot/handlers/admin/sync_admin.py b/bot/handlers/admin/sync_admin.py index 1428fe2..39426cb 100644 --- a/bot/handlers/admin/sync_admin.py +++ b/bot/handlers/admin/sync_admin.py @@ -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} ) diff --git a/bot/handlers/user/__init__.py b/bot/handlers/user/__init__.py index 2aa7de7..6a37b5b 100644 --- a/bot/handlers/user/__init__.py +++ b/bot/handlers/user/__init__.py @@ -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) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 050ace8..2efcfbc 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -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( diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index 0c25f17..294b055 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -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))) diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py deleted file mode 100644 index 433169c..0000000 --- a/bot/handlers/user/subscription.py +++ /dev/null @@ -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) diff --git a/bot/handlers/user/subscription/__init__.py b/bot/handlers/user/subscription/__init__.py new file mode 100644 index 0000000..a4582aa --- /dev/null +++ b/bot/handlers/user/subscription/__init__.py @@ -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 + + diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py new file mode 100644 index 0000000..3b883e7 --- /dev/null +++ b/bot/handlers/user/subscription/core.py @@ -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) + + diff --git a/bot/handlers/user/subscription/payment_methods.py b/bot/handlers/user/subscription/payment_methods.py new file mode 100644 index 0000000..5dec974 --- /dev/null +++ b/bot/handlers/user/subscription/payment_methods.py @@ -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 + + diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py new file mode 100644 index 0000000..3222a35 --- /dev/null +++ b/bot/handlers/user/subscription/payments.py @@ -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, + ) + diff --git a/bot/handlers/user/trial_handler.py b/bot/handlers/user/trial_handler.py index b03966c..c3ad911 100644 --- a/bot/handlers/user/trial_handler.py +++ b/bot/handlers/user/trial_handler.py @@ -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") diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index 3fa2ccc..644cfa6 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -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() diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 6a0898f..456eae4 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -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() diff --git a/bot/main_bot.py b/bot/main_bot.py index 86981b8..e25a0dc 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -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]}") diff --git a/bot/middlewares/i18n.py b/bot/middlewares/i18n.py index 80bb2b8..7140bb5 100644 --- a/bot/middlewares/i18n.py +++ b/bot/middlewares/i18n.py @@ -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." ) diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index 718f0c2..41261b3 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -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) diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index cee7ada..1910676 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -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, diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index eaf36cc..b48b4b5 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -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 diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 78cf15a..fdbfc72 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -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 ): diff --git a/bot/services/tribute_service.py b/bot/services/tribute_service.py index a7d4403..06d1d25 100644 --- a/bot/services/tribute_service.py +++ b/bot/services/tribute_service.py @@ -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, diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index 43aaeae..6df92af 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -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 diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py index 330e53e..6db5421 100644 --- a/bot/states/admin_states.py +++ b/bot/states/admin_states.py @@ -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() diff --git a/config/settings.py b/config/settings.py index e65cdea..21878b4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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]: diff --git a/db/dal/__init__.py b/db/dal/__init__.py new file mode 100644 index 0000000..8df65b5 --- /dev/null +++ b/db/dal/__init__.py @@ -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", +) + + diff --git a/db/dal/ad_dal.py b/db/dal/ad_dal.py new file mode 100644 index 0000000..6a9bd91 --- /dev/null +++ b/db/dal/ad_dal.py @@ -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} + + diff --git a/db/dal/subscription_dal.py b/db/dal/subscription_dal.py index 6a0b0e6..1785c82 100644 --- a/db/dal/subscription_dal.py +++ b/db/dal/subscription_dal.py @@ -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. diff --git a/db/dal/user_billing_dal.py b/db/dal/user_billing_dal.py new file mode 100644 index 0000000..a6c1039 --- /dev/null +++ b/db/dal/user_billing_dal.py @@ -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 diff --git a/db/database_setup.py b/db/database_setup.py index 0b48c3f..4c1fbf8 100644 --- a/db/database_setup.py +++ b/db/database_setup.py @@ -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." ) diff --git a/db/migrator.py b/db/migrator.py new file mode 100644 index 0000000..3f37cc0 --- /dev/null +++ b/db/migrator.py @@ -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 diff --git a/db/models.py b/db/models.py index c8e2c4a..d33dfdf 100644 --- a/db/models.py +++ b/db/models.py @@ -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"" + + +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") diff --git a/docker-compose.yml b/docker-compose.yml index 4282534..4fbdbaf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/locales/en.json b/locales/en.json index 9e511fe..d4a73e8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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": "🚨 Subscription Cancelled\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": "🔄 Subscription Auto-Renewed\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}", + "yookassa_auto_renewal": "🔄 Subscription Auto-Renewed\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": "🔐 My Subscription\n\n⏰ Status: {status}\n📅 Active until: {end_date}\n📆 Days left: {days_left}\n\n🔗 Configuration link:\n{config_link}\n\n📊 Traffic:\nLimit: {traffic_limit}\nUsed: {traffic_used}", + "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": "💳 Payment Methods", + "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: {source}\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": "📈 Ads\n💰 Revenue: {revenue} RUB\n💸 Spent: {cost} RUB", + "back_to_ads_list_button": "⬅️ Back to list", + "admin_ads_card": "📈 Campaign #{id}\nSource: {source}\nstart={start_param}\nCost: {cost} RUB\nActive: {active}\n\n👥 Starts: {starts}\n🆓 Trials: {trials}\n💳 Payers: {payers}\n💵 Revenue: {revenue} RUB" } diff --git a/locales/ru.json b/locales/ru.json index b58b394..d28f4b8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "🚨 Подписка отменена\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.", + "yookassa_auto_renewal": "🔄 Подписка автоматически продлена\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": "🔐 Моя подписка\n\n⏰ Статус: {status}\n📅 Действует до: {end_date}\n📆 Осталось дней: {days_left}\n\n🔗 Ссылка на конфигурацию:\n{config_link}\n\n📊 Трафик:\nЛимит: {traffic_limit}\nИспользовано: {traffic_used}", + "autorenew_enable_button": "🔄 Включить автопродление", + "autorenew_disable_button": "🛑 Отключить автопродление", + "subscription_autorenew_updated": "Настройки автопродления обновлены.", + "payment_methods_manage_button": "💳 Способы оплаты", + "payment_methods_title": "💳 Способы оплаты", + "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Источник: {source}\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": "📈 Реклама\n💰 Пришло: {revenue} RUB\n💸 Потрачено: {cost} RUB", + "back_to_ads_list_button": "⬅️ К списку", + "admin_ads_card": "📈 Кампания #{id}\nИсточник: {source}\nstart={start_param}\nСтоимость: {cost} RUB\nАктивна: {active}\n\n👥 Запустили: {starts}\n🆓 Взяли триал: {trials}\n💳 Оплатили: {payers}\n💵 Доход: {revenue} RUB" }