Merge branch 'dev' into patch-3
This commit is contained in:
+2
-2
@@ -126,8 +126,8 @@ LOG_PROMO_ACTIVATIONS=True # Lo
|
|||||||
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
|
||||||
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
|
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_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_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_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
|
||||||
@@ -16,3 +16,4 @@ __pycache__/
|
|||||||
*.pid
|
*.pid
|
||||||
locales/ru_backup.json
|
locales/ru_backup.json
|
||||||
locales/en_backup.json
|
locales/en_backup.json
|
||||||
|
db/models_old.py
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ def build_core_services(
|
|||||||
settings_obj=settings,
|
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 {
|
return {
|
||||||
"panel_service": panel_service,
|
"panel_service": panel_service,
|
||||||
"subscription_service": subscription_service,
|
"subscription_service": subscription_service,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from . import statistics
|
|||||||
from . import sync_admin
|
from . import sync_admin
|
||||||
from . import logs_admin
|
from . import logs_admin
|
||||||
from . import payments
|
from . import payments
|
||||||
|
from . import ads
|
||||||
|
|
||||||
admin_router_aggregate = Router(name="admin_features_router")
|
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(sync_admin.router)
|
||||||
admin_router_aggregate.include_router(logs_admin.router)
|
admin_router_aggregate.include_router(logs_admin.router)
|
||||||
admin_router_aggregate.include_router(payments.router)
|
admin_router_aggregate.include_router(payments.router)
|
||||||
|
admin_router_aggregate.include_router(ads.router)
|
||||||
|
|
||||||
__all__ = ("admin_router_aggregate", )
|
__all__ = ("admin_router_aggregate", )
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +127,12 @@ async def admin_panel_actions_callback_handler(
|
|||||||
from . import payments as admin_payments_handlers
|
from . import payments as admin_payments_handlers
|
||||||
await admin_payments_handlers.view_payments_handler(
|
await admin_payments_handlers.view_payments_handler(
|
||||||
callback, i18n_data, settings, session)
|
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":
|
elif action == "main":
|
||||||
try:
|
try:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
|
|||||||
@@ -144,7 +144,10 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
existing_user.first_name or "",
|
existing_user.first_name or "",
|
||||||
existing_user.last_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(
|
await panel_service.update_user_details_on_panel(
|
||||||
panel_uuid, {"description": description_text}
|
panel_uuid, {"description": description_text}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from aiogram import Router
|
from aiogram import Router
|
||||||
|
|
||||||
from . import start
|
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 referral
|
||||||
from . import promo_user
|
from . import promo_user
|
||||||
from . import trial_handler
|
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(promo_user.router)
|
||||||
user_router_aggregate.include_router(trial_handler.router)
|
user_router_aggregate.include_router(trial_handler.router)
|
||||||
user_router_aggregate.include_router(start.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)
|
user_router_aggregate.include_router(referral.router)
|
||||||
|
|||||||
+248
-43
@@ -12,7 +12,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
from yookassa.domain.notification import WebhookNotification
|
from yookassa.domain.notification import WebhookNotification
|
||||||
from yookassa.domain.models.amount import Amount as YooKassaAmount
|
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.subscription_service import SubscriptionService
|
||||||
from bot.services.referral_service import ReferralService
|
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_SUCCEEDED = 'payment.succeeded'
|
||||||
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
|
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,
|
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")
|
subscription_months_str = metadata.get("subscription_months")
|
||||||
promo_code_id_str = metadata.get("promo_code_id")
|
promo_code_id_str = metadata.get("promo_code_id")
|
||||||
payment_db_id_str = metadata.get("payment_db_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(
|
logging.error(
|
||||||
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
|
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:
|
try:
|
||||||
user_id = int(user_id_str)
|
user_id = int(user_id_str)
|
||||||
subscription_months = int(subscription_months_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 = int(
|
||||||
promo_code_id_str
|
promo_code_id_str
|
||||||
) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
) 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", {})
|
amount_data = payment_info_from_webhook.get("amount", {})
|
||||||
payment_value = float(amount_data.get("value", 0.0))
|
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)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
logging.error(
|
logging.error(
|
||||||
@@ -89,6 +135,42 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
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(
|
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
||||||
session,
|
session,
|
||||||
payment_db_id=payment_db_id,
|
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(
|
applied_referee_bonus_days_from_referral = referral_bonus_info.get(
|
||||||
"referee_bonus_applied_days")
|
"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
|
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)
|
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||||
|
|
||||||
config_link = activation_details.get("subscription_url") or _(
|
# For auto-renew charges, avoid re-sending config link; send concise message
|
||||||
"config_link_not_available"
|
if is_auto_renew and final_end_date_for_user:
|
||||||
)
|
|
||||||
|
|
||||||
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 = _(
|
details_message = _(
|
||||||
"payment_successful_with_referral_bonus_full",
|
"yookassa_auto_renewal",
|
||||||
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,
|
months=subscription_months,
|
||||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||||
config_link=config_link,
|
|
||||||
)
|
)
|
||||||
|
details_markup = None
|
||||||
else:
|
else:
|
||||||
logging.error(
|
config_link = activation_details.get("subscription_url") or _(
|
||||||
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic."
|
"config_link_not_available"
|
||||||
)
|
)
|
||||||
details_message = _("payment_successful_error_details")
|
|
||||||
|
|
||||||
details_markup = get_connect_and_main_keyboard(
|
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
||||||
user_lang, i18n, settings, config_link
|
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:
|
try:
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
@@ -313,6 +405,40 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
)
|
)
|
||||||
return web.Response(status=200, text="ok_error_no_metadata")
|
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 = {
|
payment_dict_for_processing = {
|
||||||
"id":
|
"id":
|
||||||
str(payment_data_from_notification.id),
|
str(payment_data_from_notification.id),
|
||||||
@@ -329,6 +455,7 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
"description":
|
"description":
|
||||||
str(payment_data_from_notification.description)
|
str(payment_data_from_notification.description)
|
||||||
if payment_data_from_notification.description else None,
|
if payment_data_from_notification.description else None,
|
||||||
|
"payment_method": pm_dict,
|
||||||
}
|
}
|
||||||
|
|
||||||
async with payment_processing_lock:
|
async with payment_processing_lock:
|
||||||
@@ -354,6 +481,84 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
session, bot, payment_dict_for_processing,
|
session, bot, payment_dict_for_processing,
|
||||||
i18n_instance, settings)
|
i18n_instance, settings)
|
||||||
await session.commit()
|
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:
|
except Exception as e_webhook_db_processing:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logging.error(
|
logging.error(
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ async def send_main_menu(target_event: Union[types.Message,
|
|||||||
@router.message(CommandStart())
|
@router.message(CommandStart())
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match")))
|
@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"^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,
|
async def start_command_handler(message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
@@ -125,7 +126,8 @@ async def start_command_handler(message: types.Message,
|
|||||||
subscription_service: SubscriptionService,
|
subscription_service: SubscriptionService,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
ref_match: Optional[re.Match] = None,
|
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()
|
await state.clear()
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
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
|
referred_by_user_id: Optional[int] = None
|
||||||
promo_code_to_apply: Optional[str] = None
|
promo_code_to_apply: Optional[str] = None
|
||||||
|
ad_start_param: Optional[str] = None
|
||||||
|
|
||||||
if ref_match:
|
if ref_match:
|
||||||
potential_referrer_id = int(ref_match.group(1))
|
potential_referrer_id = int(ref_match.group(1))
|
||||||
@@ -145,6 +148,9 @@ async def start_command_handler(message: types.Message,
|
|||||||
elif promo_match:
|
elif promo_match:
|
||||||
promo_code_to_apply = promo_match.group(1)
|
promo_code_to_apply = promo_match.group(1)
|
||||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
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)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not db_user:
|
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}",
|
f"Failed to update existing user {user_id} in session: {e_update}",
|
||||||
exc_info=True)
|
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
|
# Send welcome message if not disabled
|
||||||
if not settings.DISABLE_WELCOME_MESSAGE:
|
if not settings.DISABLE_WELCOME_MESSAGE:
|
||||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||||
|
|||||||
@@ -1,540 +0,0 @@
|
|||||||
import logging
|
|
||||||
from aiogram import Router, F, types, Bot
|
|
||||||
from aiogram.filters import Command
|
|
||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice
|
|
||||||
from typing import Optional, Dict, Any, Union
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from config.settings import Settings
|
|
||||||
from db.dal import payment_dal
|
|
||||||
from bot.keyboards.inline.user_keyboards import (
|
|
||||||
get_subscription_options_keyboard, get_payment_method_keyboard,
|
|
||||||
get_payment_url_keyboard, get_back_to_main_menu_markup)
|
|
||||||
from bot.services.yookassa_service import YooKassaService
|
|
||||||
from bot.services.stars_service import StarsService
|
|
||||||
from bot.services.crypto_pay_service import CryptoPayService
|
|
||||||
from bot.services.subscription_service import SubscriptionService
|
|
||||||
from bot.services.panel_api_service import PanelApiService
|
|
||||||
from bot.services.referral_service import ReferralService
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
|
||||||
|
|
||||||
router = Router(name="user_subscription_router")
|
|
||||||
|
|
||||||
|
|
||||||
async def display_subscription_options(event: Union[types.Message,
|
|
||||||
types.CallbackQuery],
|
|
||||||
i18n_data: dict, settings: Settings,
|
|
||||||
session: AsyncSession):
|
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
||||||
|
|
||||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
|
||||||
) if i18n else key
|
|
||||||
|
|
||||||
if not i18n:
|
|
||||||
err_msg = "Language service error."
|
|
||||||
if isinstance(event, types.CallbackQuery):
|
|
||||||
try:
|
|
||||||
await event.answer(err_msg, show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
elif isinstance(event, types.Message):
|
|
||||||
await event.answer(err_msg)
|
|
||||||
return
|
|
||||||
|
|
||||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
|
||||||
text_content = get_text("select_subscription_period"
|
|
||||||
) if settings.subscription_options else get_text(
|
|
||||||
"no_subscription_options_available")
|
|
||||||
|
|
||||||
reply_markup = get_subscription_options_keyboard(
|
|
||||||
settings.subscription_options, currency_symbol_val, current_lang, i18n
|
|
||||||
) if settings.subscription_options else get_back_to_main_menu_markup(
|
|
||||||
current_lang, i18n)
|
|
||||||
|
|
||||||
target_message_obj = event.message if isinstance(
|
|
||||||
event, types.CallbackQuery) else event
|
|
||||||
if not target_message_obj:
|
|
||||||
if isinstance(event, types.CallbackQuery):
|
|
||||||
try:
|
|
||||||
await event.answer(get_text("error_occurred_try_again"),
|
|
||||||
show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
if isinstance(event, types.CallbackQuery):
|
|
||||||
try:
|
|
||||||
await target_message_obj.edit_text(text_content,
|
|
||||||
reply_markup=reply_markup)
|
|
||||||
except Exception:
|
|
||||||
await target_message_obj.answer(text_content,
|
|
||||||
reply_markup=reply_markup)
|
|
||||||
try:
|
|
||||||
await event.answer()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
await target_message_obj.answer(text_content,
|
|
||||||
reply_markup=reply_markup)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("subscribe_period:"))
|
|
||||||
async def select_subscription_period_callback_handler(
|
|
||||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
|
||||||
session: AsyncSession):
|
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
||||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
|
||||||
) if i18n else key
|
|
||||||
|
|
||||||
if not i18n or not callback.message:
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_occurred_try_again"),
|
|
||||||
show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
months = int(callback.data.split(":")[-1])
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
logging.error(
|
|
||||||
f"Invalid subscription period in callback_data: {callback.data}")
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
price_rub = settings.subscription_options.get(months)
|
|
||||||
if price_rub is None:
|
|
||||||
logging.error(
|
|
||||||
f"Price not found for {months} months subscription period in settings.subscription_options."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
|
||||||
text_content = get_text("choose_payment_method")
|
|
||||||
tribute_url = settings.tribute_payment_links.get(months)
|
|
||||||
stars_price = settings.stars_subscription_options.get(months)
|
|
||||||
reply_markup = get_payment_method_keyboard(
|
|
||||||
months,
|
|
||||||
price_rub,
|
|
||||||
tribute_url,
|
|
||||||
stars_price,
|
|
||||||
currency_symbol_val,
|
|
||||||
current_lang,
|
|
||||||
i18n,
|
|
||||||
settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await callback.message.edit_text(text_content,
|
|
||||||
reply_markup=reply_markup)
|
|
||||||
except Exception as e_edit:
|
|
||||||
logging.warning(
|
|
||||||
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
|
|
||||||
)
|
|
||||||
await callback.message.answer(text_content,
|
|
||||||
reply_markup=reply_markup)
|
|
||||||
try:
|
|
||||||
await callback.answer()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("pay_stars:"))
|
|
||||||
async def pay_stars_callback_handler(
|
|
||||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
|
||||||
session: AsyncSession, bot: Bot, stars_service: StarsService):
|
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
||||||
|
|
||||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
||||||
|
|
||||||
if not i18n or not callback.message:
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
_, data_payload = callback.data.split(":", 1)
|
|
||||||
months_str, price_str = data_payload.split(":")
|
|
||||||
months = int(months_str)
|
|
||||||
stars_price = int(price_str)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
logging.error(f"Invalid pay_stars data in callback: {callback.data}")
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
user_id = callback.from_user.id
|
|
||||||
payment_description = get_text("payment_description_subscription", months=months)
|
|
||||||
|
|
||||||
payment_id = await stars_service.create_invoice(
|
|
||||||
session, user_id, months, stars_price, payment_description)
|
|
||||||
if payment_id is None:
|
|
||||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
await callback.answer()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("pay_yk:"))
|
|
||||||
async def pay_yk_callback_handler(
|
|
||||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
|
||||||
yookassa_service: YooKassaService, session: AsyncSession):
|
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
||||||
|
|
||||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
|
||||||
) if i18n else key
|
|
||||||
|
|
||||||
if not i18n or not callback.message:
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_occurred_try_again"),
|
|
||||||
show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
if not yookassa_service or not yookassa_service.configured:
|
|
||||||
logging.error("YooKassa service is not configured or unavailable.")
|
|
||||||
target_msg_edit = callback.message
|
|
||||||
await target_msg_edit.edit_text(get_text("payment_service_unavailable")
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("payment_service_unavailable_alert"),
|
|
||||||
show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
_, data_payload = callback.data.split(":", 1)
|
|
||||||
months_str, price_str = data_payload.split(":")
|
|
||||||
months = int(months_str)
|
|
||||||
price_rub = float(price_str)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
logging.error(
|
|
||||||
f"Invalid pay_yk data in callback: {callback.data}")
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
user_id = callback.from_user.id
|
|
||||||
|
|
||||||
payment_description = get_text("payment_description_subscription",
|
|
||||||
months=months)
|
|
||||||
currency_code_for_yk = "RUB"
|
|
||||||
|
|
||||||
payment_record_data = {
|
|
||||||
"user_id": user_id,
|
|
||||||
"amount": price_rub,
|
|
||||||
"currency": currency_code_for_yk,
|
|
||||||
"status": "pending_yookassa",
|
|
||||||
"description": payment_description,
|
|
||||||
"subscription_duration_months": months,
|
|
||||||
}
|
|
||||||
db_payment_record = None
|
|
||||||
try:
|
|
||||||
db_payment_record = await payment_dal.create_payment_record(
|
|
||||||
session, payment_record_data)
|
|
||||||
await session.commit()
|
|
||||||
logging.info(
|
|
||||||
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
|
|
||||||
)
|
|
||||||
except Exception as e_db_payment:
|
|
||||||
await session.rollback()
|
|
||||||
logging.error(
|
|
||||||
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
|
||||||
exc_info=True)
|
|
||||||
await callback.message.edit_text(
|
|
||||||
get_text("error_creating_payment_record"))
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
if not db_payment_record:
|
|
||||||
await callback.message.edit_text(
|
|
||||||
get_text("error_creating_payment_record"))
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
yookassa_metadata = {
|
|
||||||
"user_id": str(user_id),
|
|
||||||
"subscription_months": str(months),
|
|
||||||
"payment_db_id": str(db_payment_record.payment_id),
|
|
||||||
}
|
|
||||||
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
|
||||||
|
|
||||||
payment_response_yk = await yookassa_service.create_payment(
|
|
||||||
amount=price_rub,
|
|
||||||
currency=currency_code_for_yk,
|
|
||||||
description=payment_description,
|
|
||||||
metadata=yookassa_metadata,
|
|
||||||
receipt_email=receipt_email_for_yk)
|
|
||||||
|
|
||||||
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
|
||||||
try:
|
|
||||||
await payment_dal.update_payment_status_by_db_id(
|
|
||||||
session,
|
|
||||||
payment_db_id=db_payment_record.payment_id,
|
|
||||||
new_status=payment_response_yk.get("status", "pending"),
|
|
||||||
yk_payment_id=payment_response_yk.get("id"))
|
|
||||||
await session.commit()
|
|
||||||
except Exception as e_db_update_ykid:
|
|
||||||
await session.rollback()
|
|
||||||
logging.error(
|
|
||||||
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
|
|
||||||
exc_info=True)
|
|
||||||
await callback.message.edit_text(
|
|
||||||
get_text("error_payment_gateway_link_failed"))
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
|
||||||
get_text(key="payment_link_message", months=months),
|
|
||||||
reply_markup=get_payment_url_keyboard(
|
|
||||||
payment_response_yk["confirmation_url"], current_lang, i18n),
|
|
||||||
disable_web_page_preview=False)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
await payment_dal.update_payment_status_by_db_id(
|
|
||||||
session, db_payment_record.payment_id, "failed_creation")
|
|
||||||
await session.commit()
|
|
||||||
except Exception as e_db_fail_create:
|
|
||||||
await session.rollback()
|
|
||||||
logging.error(
|
|
||||||
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
|
|
||||||
exc_info=True)
|
|
||||||
|
|
||||||
logging.error(
|
|
||||||
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
|
|
||||||
)
|
|
||||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
|
||||||
|
|
||||||
try:
|
|
||||||
await callback.answer()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("pay_crypto:"))
|
|
||||||
async def pay_crypto_callback_handler(
|
|
||||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
|
||||||
cryptopay_service: CryptoPayService, session: AsyncSession):
|
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
||||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
||||||
|
|
||||||
if not i18n or not callback.message:
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
if not cryptopay_service or not cryptopay_service.configured:
|
|
||||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
_, data_payload = callback.data.split(":", 1)
|
|
||||||
months_str, amount_str = data_payload.split(":")
|
|
||||||
months = int(months_str)
|
|
||||||
amount_val = float(amount_str)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
logging.error(f"Invalid pay_crypto data in callback: {callback.data}")
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
user_id = callback.from_user.id
|
|
||||||
description = get_text("payment_description_subscription", months=months)
|
|
||||||
|
|
||||||
invoice_url = await cryptopay_service.create_invoice(
|
|
||||||
session, user_id, months, amount_val, description)
|
|
||||||
if invoice_url:
|
|
||||||
await callback.message.edit_text(
|
|
||||||
get_text("payment_link_message", months=months),
|
|
||||||
reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n),
|
|
||||||
disable_web_page_preview=False,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
|
||||||
try:
|
|
||||||
await callback.answer()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "main_action:subscribe")
|
|
||||||
async def reshow_subscription_options_callback(callback: types.CallbackQuery,
|
|
||||||
i18n_data: dict,
|
|
||||||
settings: Settings,
|
|
||||||
session: AsyncSession):
|
|
||||||
await display_subscription_options(callback, i18n_data, settings, session)
|
|
||||||
|
|
||||||
|
|
||||||
async def my_subscription_command_handler(
|
|
||||||
event: Union[types.Message, types.CallbackQuery],
|
|
||||||
i18n_data: dict,
|
|
||||||
settings: Settings,
|
|
||||||
panel_service: PanelApiService,
|
|
||||||
subscription_service: SubscriptionService,
|
|
||||||
session: AsyncSession,
|
|
||||||
bot: Bot
|
|
||||||
):
|
|
||||||
target = event.message if isinstance(event, types.CallbackQuery) else event
|
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
||||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
|
||||||
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
|
||||||
|
|
||||||
if not i18n or not target:
|
|
||||||
if isinstance(event, types.Message):
|
|
||||||
await event.answer(get_text("error_occurred_try_again"))
|
|
||||||
return
|
|
||||||
|
|
||||||
if not panel_service or not subscription_service:
|
|
||||||
await target.answer(get_text("error_service_unavailable"))
|
|
||||||
return
|
|
||||||
|
|
||||||
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
|
||||||
|
|
||||||
if not active:
|
|
||||||
text = get_text("subscription_not_active")
|
|
||||||
|
|
||||||
buy_button = InlineKeyboardButton(
|
|
||||||
text=get_text("menu_subscribe_inline", default="Купить"),
|
|
||||||
callback_data="main_action:subscribe"
|
|
||||||
)
|
|
||||||
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
|
||||||
|
|
||||||
kb = InlineKeyboardMarkup(
|
|
||||||
inline_keyboard=[
|
|
||||||
[buy_button],
|
|
||||||
*back_markup.inline_keyboard
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(event, types.CallbackQuery):
|
|
||||||
try:
|
|
||||||
await event.answer()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
await event.message.edit_text(text, reply_markup=kb)
|
|
||||||
except:
|
|
||||||
await event.message.answer(text, reply_markup=kb)
|
|
||||||
else:
|
|
||||||
await event.answer(text, reply_markup=kb)
|
|
||||||
return
|
|
||||||
|
|
||||||
end_date = active.get("end_date")
|
|
||||||
days_left = (
|
|
||||||
(end_date.date() - datetime.now().date()).days
|
|
||||||
if end_date else 0
|
|
||||||
)
|
|
||||||
text = get_text(
|
|
||||||
"my_subscription_details",
|
|
||||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
|
||||||
days_left=max(0, days_left),
|
|
||||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
|
||||||
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
|
||||||
traffic_limit=(
|
|
||||||
f"{active['traffic_limit_bytes'] / 2**30:.2f} GB"
|
|
||||||
if active.get("traffic_limit_bytes")
|
|
||||||
else get_text("traffic_unlimited")
|
|
||||||
),
|
|
||||||
traffic_used=(
|
|
||||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB"
|
|
||||||
if active.get("traffic_used_bytes") is not None
|
|
||||||
else get_text("traffic_na")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
markup = get_back_to_main_menu_markup(current_lang, i18n)
|
|
||||||
|
|
||||||
if isinstance(event, types.CallbackQuery):
|
|
||||||
try:
|
|
||||||
await event.answer()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
|
||||||
except:
|
|
||||||
await bot.send_message(chat_id=target.chat.id, text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
|
||||||
else:
|
|
||||||
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.pre_checkout_query()
|
|
||||||
async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery):
|
|
||||||
await pre_checkout_query.answer(ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.successful_payment)
|
|
||||||
async def stars_successful_payment_handler(
|
|
||||||
message: types.Message, settings: Settings, i18n_data: dict,
|
|
||||||
session: AsyncSession, stars_service: StarsService):
|
|
||||||
sp = message.successful_payment
|
|
||||||
if not sp or sp.currency != "XTR":
|
|
||||||
return
|
|
||||||
|
|
||||||
payload = sp.invoice_payload or ""
|
|
||||||
try:
|
|
||||||
payment_id_str, months_str = payload.split(":")
|
|
||||||
payment_db_id = int(payment_id_str)
|
|
||||||
months = int(months_str)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
logging.error(f"Invalid invoice payload for stars payment: {payload}")
|
|
||||||
return
|
|
||||||
|
|
||||||
stars_amount = sp.total_amount
|
|
||||||
await stars_service.process_successful_payment(
|
|
||||||
session, message, payment_db_id, months, stars_amount, i18n_data)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("connect"))
|
|
||||||
async def connect_command_handler(message: types.Message, i18n_data: dict,
|
|
||||||
settings: Settings,
|
|
||||||
panel_service: PanelApiService,
|
|
||||||
subscription_service: SubscriptionService,
|
|
||||||
session: AsyncSession, bot: Bot):
|
|
||||||
logging.info(f"User {message.from_user.id} used /connect command.")
|
|
||||||
await my_subscription_command_handler(message, i18n_data, settings,
|
|
||||||
panel_service, subscription_service,
|
|
||||||
session, bot)
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from aiogram import Router
|
||||||
|
|
||||||
|
from . import core
|
||||||
|
from . import payments
|
||||||
|
from . import payment_methods
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_router")
|
||||||
|
|
||||||
|
# Include sub-routers
|
||||||
|
router.include_router(core.router)
|
||||||
|
router.include_router(payments.router)
|
||||||
|
router.include_router(payment_methods.router)
|
||||||
|
|
||||||
|
# Re-export commonly used entrypoints for backward compatibility
|
||||||
|
from .core import display_subscription_options, my_subscription_command_handler # noqa: E402,F401
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import logging
|
||||||
|
from aiogram import Router, F, types, Bot
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||||
|
from typing import Optional, Union
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
|
get_subscription_options_keyboard,
|
||||||
|
get_back_to_main_menu_markup,
|
||||||
|
get_autorenew_confirm_keyboard,
|
||||||
|
)
|
||||||
|
from bot.services.subscription_service import SubscriptionService
|
||||||
|
from bot.services.panel_api_service import PanelApiService
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from db.dal import subscription_dal
|
||||||
|
from db.models import Subscription
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_core_router")
|
||||||
|
|
||||||
|
|
||||||
|
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not i18n:
|
||||||
|
err_msg = "Language service error."
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(err_msg, show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif isinstance(event, types.Message):
|
||||||
|
await event.answer(err_msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||||
|
text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available")
|
||||||
|
|
||||||
|
reply_markup = (
|
||||||
|
get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n)
|
||||||
|
if settings.subscription_options
|
||||||
|
else get_back_to_main_menu_markup(current_lang, i18n)
|
||||||
|
)
|
||||||
|
|
||||||
|
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
|
||||||
|
if not target_message_obj:
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await target_message_obj.edit_text(text_content, reply_markup=reply_markup)
|
||||||
|
except Exception:
|
||||||
|
await target_message_obj.answer(text_content, reply_markup=reply_markup)
|
||||||
|
try:
|
||||||
|
await event.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await target_message_obj.answer(text_content, reply_markup=reply_markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "main_action:subscribe")
|
||||||
|
async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||||
|
await display_subscription_options(callback, i18n_data, settings, session)
|
||||||
|
|
||||||
|
|
||||||
|
async def my_subscription_command_handler(
|
||||||
|
event: Union[types.Message, types.CallbackQuery],
|
||||||
|
i18n_data: dict,
|
||||||
|
settings: Settings,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
session: AsyncSession,
|
||||||
|
bot: Bot,
|
||||||
|
):
|
||||||
|
target = event.message if isinstance(event, types.CallbackQuery) else event
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
||||||
|
|
||||||
|
if not i18n or not target:
|
||||||
|
if isinstance(event, types.Message):
|
||||||
|
await event.answer(get_text("error_occurred_try_again"))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not panel_service or not subscription_service:
|
||||||
|
await target.answer(get_text("error_service_unavailable"))
|
||||||
|
return
|
||||||
|
|
||||||
|
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
text = get_text("subscription_not_active")
|
||||||
|
|
||||||
|
buy_button = InlineKeyboardButton(
|
||||||
|
text=get_text("menu_subscribe_inline", default="Купить"), callback_data="main_action:subscribe"
|
||||||
|
)
|
||||||
|
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||||
|
|
||||||
|
kb = InlineKeyboardMarkup(inline_keyboard=[[buy_button], *back_markup.inline_keyboard])
|
||||||
|
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await event.message.edit_text(text, reply_markup=kb)
|
||||||
|
except Exception:
|
||||||
|
await event.message.answer(text, reply_markup=kb)
|
||||||
|
else:
|
||||||
|
await event.answer(text, reply_markup=kb)
|
||||||
|
return
|
||||||
|
|
||||||
|
end_date = active.get("end_date")
|
||||||
|
days_left = (end_date.date() - datetime.now().date()).days if end_date else 0
|
||||||
|
tribute_hint = ""
|
||||||
|
if active.get("status_from_panel", "").lower() == "active":
|
||||||
|
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
|
||||||
|
if local_sub:
|
||||||
|
if local_sub.provider == "tribute":
|
||||||
|
link = None
|
||||||
|
link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None
|
||||||
|
tribute_hint = "\n\n" + (
|
||||||
|
get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice")
|
||||||
|
)
|
||||||
|
|
||||||
|
text = get_text(
|
||||||
|
"my_subscription_details",
|
||||||
|
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||||
|
days_left=max(0, days_left),
|
||||||
|
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||||
|
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
||||||
|
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
|
||||||
|
traffic_used=(
|
||||||
|
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||||
|
kb = base_markup.inline_keyboard
|
||||||
|
try:
|
||||||
|
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
|
||||||
|
# Build rows to prepend above the base "back" markup
|
||||||
|
prepend_rows = []
|
||||||
|
|
||||||
|
# 1) Mini-app connect button on top if enabled
|
||||||
|
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||||
|
prepend_rows.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=get_text("connect_button"),
|
||||||
|
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
# 2) Auto-renew toggle (if supported and not tribute)
|
||||||
|
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||||
|
toggle_text = (
|
||||||
|
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
|
||||||
|
)
|
||||||
|
prepend_rows.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=toggle_text,
|
||||||
|
callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}",
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
# 3) Payment methods management (when autopayments enabled)
|
||||||
|
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||||
|
prepend_rows.append([
|
||||||
|
InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")
|
||||||
|
])
|
||||||
|
|
||||||
|
if prepend_rows:
|
||||||
|
kb = prepend_rows + kb
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||||
|
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||||
|
except Exception:
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=target.chat.id,
|
||||||
|
text=text + tribute_hint,
|
||||||
|
reply_markup=markup,
|
||||||
|
parse_mode="HTML",
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("toggle_autorenew:"))
|
||||||
|
async def toggle_autorenew_handler(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
bot: Bot,
|
||||||
|
):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, payload = callback.data.split(":", 1)
|
||||||
|
sub_id_str, enable_str = payload.split(":")
|
||||||
|
sub_id = int(sub_id_str)
|
||||||
|
enable = bool(int(enable_str))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
sub = await session.get(Subscription, sub_id)
|
||||||
|
if not sub or sub.user_id != callback.from_user.id:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
return
|
||||||
|
if sub.provider == "tribute":
|
||||||
|
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show confirmation popup and inline buttons
|
||||||
|
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
|
||||||
|
kb = get_autorenew_confirm_keyboard(enable, sub.subscription_id, current_lang, i18n)
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(confirm_text, reply_markup=kb)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await callback.message.answer(confirm_text, reply_markup=kb)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("autorenew:confirm:"))
|
||||||
|
async def confirm_autorenew_handler(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
bot: Bot,
|
||||||
|
):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, _, sub_id_str, enable_str = callback.data.split(":", 3)
|
||||||
|
sub_id = int(sub_id_str)
|
||||||
|
enable = bool(int(enable_str))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
sub = await session.get(Subscription, sub_id)
|
||||||
|
if not sub or sub.user_id != callback.from_user.id:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
return
|
||||||
|
if sub.provider == "tribute":
|
||||||
|
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
|
||||||
|
await session.commit()
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("subscription_autorenew_updated"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "autorenew:cancel")
|
||||||
|
async def autorenew_cancel_from_webhook_button(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
bot: Bot,
|
||||||
|
):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
# Disable auto-renew on the active subscription (non-tribute)
|
||||||
|
from db.dal import subscription_dal
|
||||||
|
sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
|
||||||
|
if not sub:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("subscription_not_active"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
if sub.provider == "tribute":
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": False})
|
||||||
|
await session.commit()
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("subscription_autorenew_updated"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("connect"))
|
||||||
|
async def connect_command_handler(
|
||||||
|
message: types.Message,
|
||||||
|
i18n_data: dict,
|
||||||
|
settings: Settings,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
session: AsyncSession,
|
||||||
|
bot: Bot,
|
||||||
|
):
|
||||||
|
logging.info(f"User {message.from_user.id} used /connect command.")
|
||||||
|
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
from aiogram import Router, F, types
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
|
get_payment_methods_list_keyboard,
|
||||||
|
get_payment_method_delete_confirm_keyboard,
|
||||||
|
get_payment_method_details_keyboard,
|
||||||
|
get_bind_url_keyboard,
|
||||||
|
)
|
||||||
|
from bot.services.yookassa_service import YooKassaService
|
||||||
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
from db.dal import user_billing_dal
|
||||||
|
from db.models import Payment
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
router = Router(name="user_subscription_payment_methods_router")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "pm:manage")
|
||||||
|
async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||||
|
try:
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
from db.dal.user_billing_dal import list_user_payment_methods
|
||||||
|
get_text = _
|
||||||
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||||
|
cards: List[tuple] = []
|
||||||
|
|
||||||
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||||
|
s = (network or "").lower()
|
||||||
|
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||||
|
|
||||||
|
def _extract_last4(text: str) -> Optional[str]:
|
||||||
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
|
return digits[-4:] if len(digits) >= 4 else None
|
||||||
|
|
||||||
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||||
|
if _is_yoomoney_network(network):
|
||||||
|
l4 = last4 or _extract_last4(network or "")
|
||||||
|
if l4:
|
||||||
|
return get_text("payment_method_wallet_title", last4=l4)
|
||||||
|
return get_text("payment_method_wallet_title", last4="****")
|
||||||
|
if last4:
|
||||||
|
network_name = network or get_text("payment_network_card", default="Card")
|
||||||
|
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||||
|
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||||
|
return get_text("payment_method_generic_title", network=network_name)
|
||||||
|
|
||||||
|
for m in methods:
|
||||||
|
title = _format_pm_title(m.card_network, m.card_last4)
|
||||||
|
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||||
|
|
||||||
|
text = get_text("payment_methods_title")
|
||||||
|
if not cards:
|
||||||
|
text += "\n\n" + get_text("payment_method_none")
|
||||||
|
|
||||||
|
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "pm:bind")
|
||||||
|
async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||||
|
try:
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
|
||||||
|
resp = await yookassa_service.create_payment(
|
||||||
|
amount=1.00,
|
||||||
|
currency="RUB",
|
||||||
|
description="Bind card",
|
||||||
|
metadata=metadata,
|
||||||
|
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
|
||||||
|
save_payment_method=True,
|
||||||
|
capture=False,
|
||||||
|
bind_only=True,
|
||||||
|
)
|
||||||
|
if not resp or not resp.get("confirmation_url"):
|
||||||
|
await callback.answer(_("error_payment_gateway"), show_alert=True)
|
||||||
|
return
|
||||||
|
await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n))
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pm:delete_confirm"))
|
||||||
|
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||||
|
try:
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
parts = callback.data.split(":", 2)
|
||||||
|
pm_id = parts[2] if len(parts) >= 3 else ""
|
||||||
|
await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n))
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pm:delete"))
|
||||||
|
async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||||
|
try:
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
parts = callback.data.split(":", 2)
|
||||||
|
pm_id_raw = parts[2] if len(parts) >= 3 else ""
|
||||||
|
deleted = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from db.dal.user_billing_dal import (
|
||||||
|
delete_user_payment_method,
|
||||||
|
delete_user_payment_method_by_provider_id,
|
||||||
|
list_user_payment_methods,
|
||||||
|
)
|
||||||
|
if pm_id_raw:
|
||||||
|
if pm_id_raw.isdigit():
|
||||||
|
deleted = await delete_user_payment_method(session, callback.from_user.id, int(pm_id_raw))
|
||||||
|
else:
|
||||||
|
deleted = await delete_user_payment_method_by_provider_id(session, callback.from_user.id, pm_id_raw)
|
||||||
|
try:
|
||||||
|
legacy_deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id)
|
||||||
|
deleted = deleted or legacy_deleted
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||||
|
text = _("payment_methods_title")
|
||||||
|
cards = []
|
||||||
|
for m in methods:
|
||||||
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||||
|
s = (network or "").lower()
|
||||||
|
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||||
|
def _extract_last4(text: str) -> Optional[str]:
|
||||||
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
|
return digits[-4:] if len(digits) >= 4 else None
|
||||||
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||||
|
if _is_yoomoney_network(network):
|
||||||
|
l4 = last4 or _extract_last4(network or "")
|
||||||
|
if l4:
|
||||||
|
return _("payment_method_wallet_title", last4=l4)
|
||||||
|
return _("payment_method_wallet_title", last4="****")
|
||||||
|
if last4:
|
||||||
|
network_name = network or _("payment_network_card", default="Card")
|
||||||
|
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||||
|
network_name = network or _("payment_network_generic", default="Payment method")
|
||||||
|
return _("payment_method_generic_title", network=network_name)
|
||||||
|
title = _format_pm_title(m.card_network, m.card_last4)
|
||||||
|
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||||
|
if not cards:
|
||||||
|
text += "\n\n" + _("payment_method_none")
|
||||||
|
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
|
||||||
|
await callback.message.edit_text(f"{msg}\n\n{text}", reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
try:
|
||||||
|
await callback.answer(_("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pm:view"))
|
||||||
|
async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||||
|
try:
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
|
||||||
|
if not billing or not billing.yookassa_payment_method_id:
|
||||||
|
from db.dal.user_billing_dal import list_user_payment_methods
|
||||||
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||||
|
if not methods:
|
||||||
|
await callback.answer(_("payment_method_none"), show_alert=True)
|
||||||
|
return
|
||||||
|
parts = callback.data.split(":", 2)
|
||||||
|
pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id)
|
||||||
|
sel = next((m for m in methods if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id), methods[0])
|
||||||
|
|
||||||
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||||
|
s = (network or "").lower()
|
||||||
|
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||||
|
|
||||||
|
def _extract_last4(text: str) -> Optional[str]:
|
||||||
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
|
return digits[-4:] if len(digits) >= 4 else None
|
||||||
|
|
||||||
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||||
|
if _is_yoomoney_network(network):
|
||||||
|
l4 = last4 or _extract_last4(network or "")
|
||||||
|
if l4:
|
||||||
|
return _("payment_method_wallet_title", last4=l4)
|
||||||
|
return _("payment_method_wallet_title", last4="****")
|
||||||
|
if last4:
|
||||||
|
network_name = network or _("payment_network_card", default="Card")
|
||||||
|
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||||
|
network_name = network or _("payment_network_generic", default="Payment method")
|
||||||
|
return _("payment_method_generic_title", network=network_name)
|
||||||
|
|
||||||
|
title = _format_pm_title(sel.card_network, sel.card_last4)
|
||||||
|
added_at = sel.created_at.strftime('%Y-%m-%d') if getattr(sel, 'created_at', None) else "—"
|
||||||
|
last_tx = "—"
|
||||||
|
try:
|
||||||
|
stmt = (
|
||||||
|
select(Payment)
|
||||||
|
.where(
|
||||||
|
Payment.user_id == callback.from_user.id,
|
||||||
|
Payment.status == 'succeeded',
|
||||||
|
Payment.provider == 'yookassa',
|
||||||
|
)
|
||||||
|
.order_by(Payment.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
lp = result.scalar_one_or_none()
|
||||||
|
if lp and lp.created_at:
|
||||||
|
last_tx = lp.created_at.strftime('%Y-%m-%d')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}"
|
||||||
|
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(str(sel.method_id), current_lang, i18n))
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else "—"
|
||||||
|
last_tx = "—"
|
||||||
|
try:
|
||||||
|
stmt = (
|
||||||
|
select(Payment)
|
||||||
|
.where(
|
||||||
|
Payment.user_id == callback.from_user.id,
|
||||||
|
Payment.status == 'succeeded',
|
||||||
|
Payment.provider == 'yookassa',
|
||||||
|
)
|
||||||
|
.order_by(Payment.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
last_payment = result.scalar_one_or_none()
|
||||||
|
if last_payment and last_payment.created_at:
|
||||||
|
last_tx = last_payment.created_at.strftime('%Y-%m-%d')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||||
|
s = (network or "").lower()
|
||||||
|
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||||
|
|
||||||
|
def _extract_last4(text: str) -> Optional[str]:
|
||||||
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
|
return digits[-4:] if len(digits) >= 4 else None
|
||||||
|
|
||||||
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||||
|
if _is_yoomoney_network(network):
|
||||||
|
l4 = last4 or _extract_last4(network or "")
|
||||||
|
if l4:
|
||||||
|
return _("payment_method_wallet_title", last4=l4)
|
||||||
|
return _("payment_method_wallet_title", last4="****")
|
||||||
|
if last4:
|
||||||
|
network_name = network or _("payment_network_card", default="Card")
|
||||||
|
return _("payment_method_card_title", network=network_name, last4=last4)
|
||||||
|
network_name = network or _("payment_network_generic", default="Payment method")
|
||||||
|
return _("payment_method_generic_title", network=network_name)
|
||||||
|
|
||||||
|
title = _format_pm_title(billing.card_network, billing.card_last4)
|
||||||
|
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}"
|
||||||
|
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(billing.yookassa_payment_method_id, current_lang, i18n))
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pm:history"))
|
||||||
|
async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||||
|
try:
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
from db.dal import payment_dal
|
||||||
|
payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0)
|
||||||
|
user_payments = [p for p in payments if p.user_id == callback.from_user.id]
|
||||||
|
|
||||||
|
selected_pm_provider_id: Optional[str] = None
|
||||||
|
pm_filter_requested: bool = False
|
||||||
|
try:
|
||||||
|
split_a, split_b, split_pm_id = callback.data.split(":", 2)
|
||||||
|
if split_pm_id:
|
||||||
|
pm_filter_requested = True
|
||||||
|
if split_pm_id.isdigit():
|
||||||
|
from db.dal.user_billing_dal import list_user_payment_methods
|
||||||
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||||
|
sel = next((m for m in methods if str(m.method_id) == split_pm_id), None)
|
||||||
|
if sel and sel.provider_payment_method_id:
|
||||||
|
selected_pm_provider_id = sel.provider_payment_method_id
|
||||||
|
else:
|
||||||
|
selected_pm_provider_id = split_pm_id
|
||||||
|
except Exception:
|
||||||
|
selected_pm_provider_id = None
|
||||||
|
pm_filter_requested = False
|
||||||
|
|
||||||
|
if pm_filter_requested and not selected_pm_provider_id:
|
||||||
|
user_payments = []
|
||||||
|
|
||||||
|
if selected_pm_provider_id:
|
||||||
|
filtered: List[Payment] = []
|
||||||
|
for p in user_payments:
|
||||||
|
if p.provider != 'yookassa':
|
||||||
|
continue
|
||||||
|
if p.yookassa_payment_id and yookassa_service:
|
||||||
|
try:
|
||||||
|
info = await yookassa_service.get_payment_info(p.yookassa_payment_id)
|
||||||
|
pm = (info or {}).get("payment_method") or {}
|
||||||
|
if pm.get("id") == selected_pm_provider_id:
|
||||||
|
filtered.append(p)
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
user_payments = filtered
|
||||||
|
|
||||||
|
if not user_payments:
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
|
||||||
|
back_pm_id = ""
|
||||||
|
try:
|
||||||
|
split_a, split_b, back_pm_id = callback.data.split(":", 2)
|
||||||
|
except Exception:
|
||||||
|
back_pm_id = ""
|
||||||
|
back_markup = (
|
||||||
|
get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n)
|
||||||
|
if back_pm_id
|
||||||
|
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
||||||
|
)
|
||||||
|
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _format_item(p: Payment) -> str:
|
||||||
|
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
|
||||||
|
date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A"
|
||||||
|
return f"{date_str} — {title} — {p.amount:.2f} {p.currency}"
|
||||||
|
|
||||||
|
lines = [_format_item(p) for p in user_payments]
|
||||||
|
text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines)
|
||||||
|
try:
|
||||||
|
split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2)
|
||||||
|
except Exception:
|
||||||
|
split_pm_id_for_back = ""
|
||||||
|
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
|
||||||
|
back_markup = (
|
||||||
|
get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n)
|
||||||
|
if split_pm_id_for_back
|
||||||
|
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
||||||
|
)
|
||||||
|
await callback.message.edit_text(text, reply_markup=back_markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pm:list:"))
|
||||||
|
async def payment_methods_list(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
from db.dal.user_billing_dal import list_user_payment_methods
|
||||||
|
cards: List[tuple] = []
|
||||||
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||||
|
for m in methods:
|
||||||
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||||
|
s = (network or "").lower()
|
||||||
|
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||||
|
def _extract_last4(text: str) -> Optional[str]:
|
||||||
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
|
return digits[-4:] if len(digits) >= 4 else None
|
||||||
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||||
|
if _is_yoomoney_network(network):
|
||||||
|
l4 = last4 or _extract_last4(network or "")
|
||||||
|
if l4:
|
||||||
|
return get_text("payment_method_wallet_title", last4=l4)
|
||||||
|
return get_text("payment_method_wallet_title", last4="****")
|
||||||
|
if last4:
|
||||||
|
network_name = network or get_text("payment_network_card", default="Card")
|
||||||
|
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||||
|
network_name = network or get_text("payment_network_generic", default="Payment method")
|
||||||
|
return get_text("payment_method_generic_title", network=network_name)
|
||||||
|
title = _format_pm_title(m.card_network, m.card_last4)
|
||||||
|
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, _, page_str = callback.data.split(":", 2)
|
||||||
|
page = int(page_str)
|
||||||
|
except Exception:
|
||||||
|
page = 0
|
||||||
|
|
||||||
|
text = get_text("payment_methods_title")
|
||||||
|
if not cards:
|
||||||
|
text += "\n\n" + get_text("payment_method_none")
|
||||||
|
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n))
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -113,6 +113,14 @@ async def request_trial_confirmation_handler(
|
|||||||
# Send notification to admin about new trial
|
# Send notification to admin about new trial
|
||||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
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:
|
else:
|
||||||
message_key_from_service = (
|
message_key_from_service = (
|
||||||
activation_result.get("message_key", "trial_activation_failed")
|
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:
|
if activation_result and activation_result.get("activated") and end_date_obj:
|
||||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
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")
|
@router.callback_query(F.data == "main_action:cancel_trial")
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
|
|||||||
builder.button(text=_(key="admin_promo_marketing_section"),
|
builder.button(text=_(key="admin_promo_marketing_section"),
|
||||||
callback_data="admin_section:promo_marketing")
|
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"),
|
builder.button(text=_(key="admin_system_functions_section"),
|
||||||
callback_data="admin_section:system_functions")
|
callback_data="admin_section:system_functions")
|
||||||
@@ -116,6 +120,79 @@ def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
|
|||||||
return builder.as_markup()
|
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:
|
def get_logs_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||||
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||||
from typing import Dict, Optional, List
|
from typing import Dict, Optional, List, Tuple
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
@@ -21,20 +21,12 @@ def get_main_menu_inline_keyboard(
|
|||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
|
InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
|
||||||
callback_data="main_action:subscribe"))
|
callback_data="main_action:subscribe"))
|
||||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
builder.row(
|
||||||
builder.row(
|
InlineKeyboardButton(
|
||||||
InlineKeyboardButton(
|
text=_(key="menu_my_subscription_inline"),
|
||||||
text=_(key="menu_my_subscription_inline"),
|
callback_data="main_action:my_subscription",
|
||||||
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",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
referral_button = InlineKeyboardButton(
|
referral_button = InlineKeyboardButton(
|
||||||
text=_(key="menu_referral_inline"),
|
text=_(key="menu_referral_inline"),
|
||||||
@@ -229,3 +221,124 @@ def get_connect_and_main_keyboard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return builder.as_markup()
|
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()
|
||||||
|
|||||||
@@ -293,6 +293,8 @@ async def run_bot(settings_param: Settings):
|
|||||||
|
|
||||||
main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask"))
|
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("Starting bot in Webhook mode with AIOHTTP server...")
|
||||||
logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}")
|
logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}")
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -45,10 +45,27 @@ class JsonI18n:
|
|||||||
exc_info=True)
|
exc_info=True)
|
||||||
|
|
||||||
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
|
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)
|
lang_data = self.locales_data.get(effective_lang_code)
|
||||||
if lang_data is None:
|
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(
|
logging.warning(
|
||||||
f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is."
|
f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,18 +67,28 @@ class CryptoPayService:
|
|||||||
logging.error("CryptoPayService not configured")
|
logging.error("CryptoPayService not configured")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
payment_record = await payment_dal.create_payment_record(
|
# Create pending payment in DB and commit to persist
|
||||||
session,
|
try:
|
||||||
{
|
payment_record = await payment_dal.create_payment_record(
|
||||||
"user_id": user_id,
|
session,
|
||||||
"amount": float(amount),
|
{
|
||||||
"currency": self.settings.CRYPTOPAY_ASSET,
|
"user_id": user_id,
|
||||||
"status": "pending_cryptopay",
|
"amount": float(amount),
|
||||||
"description": description,
|
"currency": self.settings.CRYPTOPAY_ASSET,
|
||||||
"subscription_duration_months": months,
|
"status": "pending_cryptopay",
|
||||||
"provider": "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({
|
payload = json.dumps({
|
||||||
"user_id": str(user_id),
|
"user_id": str(user_id),
|
||||||
"subscription_months": str(months),
|
"subscription_months": str(months),
|
||||||
@@ -93,12 +103,21 @@ class CryptoPayService:
|
|||||||
description=description,
|
description=description,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
await payment_dal.update_provider_payment_and_status(
|
try:
|
||||||
session,
|
await payment_dal.update_provider_payment_and_status(
|
||||||
payment_record.payment_id,
|
session,
|
||||||
str(invoice.invoice_id),
|
payment_record.payment_id,
|
||||||
str(invoice.status),
|
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
|
return invoice.bot_invoice_url
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"CryptoPay invoice creation failed: {e}", exc_info=True)
|
logging.error(f"CryptoPay invoice creation failed: {e}", exc_info=True)
|
||||||
@@ -155,6 +174,7 @@ class CryptoPayService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
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
|
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)
|
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import Optional
|
|||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from .panel_api_service import PanelApiService
|
from .panel_api_service import PanelApiService
|
||||||
from bot.middlewares.i18n import JsonI18n
|
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 db.dal import user_dal
|
||||||
from bot.utils.date_utils import add_months
|
from bot.utils.date_utils import add_months
|
||||||
|
|
||||||
@@ -185,7 +185,51 @@ class PanelWebhookService:
|
|||||||
|
|
||||||
if event_name in EVENT_MAP:
|
if event_name in EVENT_MAP:
|
||||||
days_left, msg_key = EVENT_MAP[event_name]
|
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:
|
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(
|
await self._send_message(
|
||||||
user_id,
|
user_id,
|
||||||
lang,
|
lang,
|
||||||
|
|||||||
@@ -109,8 +109,9 @@ class StarsService:
|
|||||||
if not final_end:
|
if not final_end:
|
||||||
final_end = activation_details["end_date"]
|
final_end = activation_details["end_date"]
|
||||||
|
|
||||||
current_lang = i18n_data.get("current_language",
|
# Always use user's language from DB for user-facing messages
|
||||||
self.settings.DEFAULT_LANGUAGE)
|
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")
|
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||||
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple
|
|||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from bot.middlewares.i18n import JsonI18n
|
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 bot.utils.date_utils import add_months
|
||||||
from db.models import User, Subscription
|
from db.models import User, Subscription
|
||||||
|
|
||||||
@@ -509,6 +509,7 @@ class SubscriptionService:
|
|||||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
|
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
|
||||||
|
"auto_renew_enabled": True,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
||||||
@@ -780,6 +781,62 @@ class SubscriptionService:
|
|||||||
)
|
)
|
||||||
return results
|
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(
|
async def update_last_notification_sent(
|
||||||
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
|
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ class TributeService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Use user's DB language in success messages prepared above
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
int(user_id),
|
int(user_id),
|
||||||
success_msg,
|
success_msg,
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ class YooKassaService:
|
|||||||
description: str,
|
description: str,
|
||||||
metadata: Dict[str, Any],
|
metadata: Dict[str, Any],
|
||||||
receipt_email: Optional[str] = None,
|
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:
|
if not self.configured:
|
||||||
logging.error("YooKassa is not configured. Cannot create payment.")
|
logging.error("YooKassa is not configured. Cannot create payment.")
|
||||||
return None
|
return None
|
||||||
@@ -102,13 +106,23 @@ class YooKassaService:
|
|||||||
"value": str(round(amount, 2)),
|
"value": str(round(amount, 2)),
|
||||||
"currency": currency.upper()
|
"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({
|
builder.set_confirmation({
|
||||||
"type": ConfirmationType.REDIRECT,
|
"type": ConfirmationType.REDIRECT,
|
||||||
"return_url": self.return_url
|
"return_url": self.return_url
|
||||||
})
|
})
|
||||||
builder.set_description(description)
|
builder.set_description(description)
|
||||||
builder.set_metadata(metadata)
|
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]] = [{
|
receipt_items_list: List[Dict[str, Any]] = [{
|
||||||
"description":
|
"description":
|
||||||
@@ -122,9 +136,9 @@ class YooKassaService:
|
|||||||
"vat_code":
|
"vat_code":
|
||||||
str(self.settings.YOOKASSA_VAT_CODE),
|
str(self.settings.YOOKASSA_VAT_CODE),
|
||||||
"payment_mode":
|
"payment_mode":
|
||||||
self.settings.YOOKASSA_PAYMENT_MODE,
|
getattr(self.settings, 'yk_receipt_payment_mode', self.settings.YOOKASSA_PAYMENT_MODE),
|
||||||
"payment_subject":
|
"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] = {
|
receipt_data_dict: Dict[str, Any] = {
|
||||||
@@ -178,7 +192,8 @@ class YooKassaService:
|
|||||||
"description_from_yk":
|
"description_from_yk":
|
||||||
response.description,
|
response.description,
|
||||||
"test_mode":
|
"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:
|
except Exception as e:
|
||||||
logging.error(f"YooKassa payment creation failed: {e}",
|
logging.error(f"YooKassa payment creation failed: {e}",
|
||||||
@@ -204,37 +219,40 @@ class YooKassaService:
|
|||||||
logging.info(
|
logging.info(
|
||||||
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}"
|
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 {
|
return {
|
||||||
"id":
|
"id": payment_info_yk.id,
|
||||||
payment_info_yk.id,
|
"status": payment_info_yk.status,
|
||||||
"status":
|
"paid": payment_info_yk.paid,
|
||||||
payment_info_yk.status,
|
"amount_value": float(payment_info_yk.amount.value),
|
||||||
"paid":
|
"amount_currency": payment_info_yk.amount.currency,
|
||||||
payment_info_yk.paid,
|
"metadata": payment_info_yk.metadata,
|
||||||
"amount_value":
|
"description": payment_info_yk.description,
|
||||||
float(payment_info_yk.amount.value),
|
"refundable": payment_info_yk.refundable,
|
||||||
"amount_currency":
|
"created_at": payment_info_yk.created_at.isoformat() if hasattr(
|
||||||
payment_info_yk.amount.currency,
|
payment_info_yk.created_at, 'isoformat') else str(payment_info_yk.created_at),
|
||||||
"metadata":
|
"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_info_yk.metadata,
|
"payment_method": pm_payload,
|
||||||
"description":
|
"test_mode": getattr(payment_info_yk, 'test', None),
|
||||||
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
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
@@ -246,3 +264,16 @@ class YooKassaService:
|
|||||||
f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}",
|
f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}",
|
||||||
exc_info=True)
|
exc_info=True)
|
||||||
return None
|
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
|
||||||
|
|||||||
@@ -28,3 +28,8 @@ class AdminStates(StatesGroup):
|
|||||||
waiting_for_user_search = State()
|
waiting_for_user_search = State()
|
||||||
waiting_for_subscription_days_to_add = State()
|
waiting_for_subscription_days_to_add = State()
|
||||||
waiting_for_direct_message_to_user = 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()
|
||||||
|
|||||||
@@ -30,8 +30,11 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None)
|
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None)
|
||||||
YOOKASSA_VAT_CODE: int = Field(default=1)
|
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_MODE: str = Field(default="full_prepayment")
|
||||||
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
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
|
WEBHOOK_BASE_URL: Optional[str] = None
|
||||||
|
|
||||||
@@ -233,6 +236,19 @@ class Settings(BaseSettings):
|
|||||||
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
|
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
|
||||||
return None
|
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
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def subscription_options(self) -> Dict[int, float]:
|
def subscription_options(self) -> Dict[int, float]:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +53,11 @@ async def update_subscription(
|
|||||||
return sub
|
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(
|
async def set_user_subscriptions_cancelled_with_grace(
|
||||||
session: AsyncSession, user_id: int, grace_days: int = 1) -> int:
|
session: AsyncSession, user_id: int, grace_days: int = 1) -> int:
|
||||||
"""Mark all active user subscriptions as cancelled with a short grace period.
|
"""Mark all active user subscriptions as cancelled with a short grace period.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,6 +4,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from .models import Base
|
from .models import Base
|
||||||
|
from .migrator import run_simple_migrations
|
||||||
|
|
||||||
async_engine = None
|
async_engine = None
|
||||||
|
|
||||||
@@ -62,6 +63,8 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
|
|||||||
|
|
||||||
async with async_engine.begin() as conn:
|
async with async_engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
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(
|
logging.info(
|
||||||
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
|
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -72,6 +72,7 @@ class Subscription(Base):
|
|||||||
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
||||||
provider = Column(String, nullable=True)
|
provider = Column(String, nullable=True)
|
||||||
skip_notifications = Column(Boolean, default=False)
|
skip_notifications = Column(Boolean, default=False)
|
||||||
|
auto_renew_enabled = Column(Boolean, default=True, index=True)
|
||||||
|
|
||||||
user = relationship("User", back_populates="subscriptions")
|
user = relationship("User", back_populates="subscriptions")
|
||||||
|
|
||||||
@@ -112,6 +113,37 @@ class Payment(Base):
|
|||||||
back_populates="payments_where_used")
|
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):
|
class PromoCode(Base):
|
||||||
__tablename__ = "promo_codes"
|
__tablename__ = "promo_codes"
|
||||||
|
|
||||||
@@ -195,3 +227,35 @@ class PanelSyncStatus(Base):
|
|||||||
subscriptions_synced = Column(Integer, default=0)
|
subscriptions_synced = Column(Integer, default=0)
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint('id'), )
|
__table_args__ = (UniqueConstraint('id'), )
|
||||||
|
|
||||||
|
|
||||||
|
class AdCampaign(Base):
|
||||||
|
__tablename__ = "ad_campaigns"
|
||||||
|
|
||||||
|
ad_campaign_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
source = Column(String, nullable=False, index=True)
|
||||||
|
start_param = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
cost = Column(Float, nullable=False, default=0.0)
|
||||||
|
is_active = Column(Boolean, default=True, index=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
attributions = relationship(
|
||||||
|
"AdAttribution",
|
||||||
|
back_populates="campaign",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<AdCampaign(id={self.ad_campaign_id}, source='{self.source}', start_param='{self.start_param}', cost={self.cost})>"
|
||||||
|
|
||||||
|
|
||||||
|
class AdAttribution(Base):
|
||||||
|
__tablename__ = "ad_attributions"
|
||||||
|
|
||||||
|
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True, index=True)
|
||||||
|
ad_campaign_id = Column(Integer, ForeignKey("ad_campaigns.ad_campaign_id"), nullable=False, index=True)
|
||||||
|
first_start_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
trial_activated_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
user = relationship("User")
|
||||||
|
campaign = relationship("AdCampaign", back_populates="attributions")
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./locales:/app/locales
|
- ./locales:/app/locales
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
remnawave-tg-shop-db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
remnawave-tg-shop-db:
|
remnawave-tg-shop-db:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
@@ -23,6 +26,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- remnawave-network
|
- remnawave-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
remnawave-network:
|
remnawave-network:
|
||||||
|
|||||||
+47
-1
@@ -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_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_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.",
|
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
|
||||||
|
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
|
||||||
|
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
|
||||||
|
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
|
||||||
"tribute_subscription_cancelled": "🚨 <b>Subscription Cancelled</b>\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.",
|
"tribute_subscription_cancelled": "🚨 <b>Subscription Cancelled</b>\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.",
|
||||||
"tribute_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}",
|
"tribute_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}",
|
||||||
|
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
|
||||||
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
|
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
|
||||||
"admin_user_subscription_info": "Subscription Information:",
|
"admin_user_subscription_info": "Subscription Information:",
|
||||||
"admin_user_reset_trial_button": "🔄 Reset Trial",
|
"admin_user_reset_trial_button": "🔄 Reset Trial",
|
||||||
@@ -371,6 +375,31 @@
|
|||||||
"admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}",
|
"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})",
|
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
|
||||||
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
|
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
|
||||||
|
"autorenew_enable_button": "🔄 Enable auto-renew",
|
||||||
|
"autorenew_disable_button": "🛑 Disable auto-renew",
|
||||||
|
"subscription_autorenew_updated": "Auto-renew settings updated.",
|
||||||
|
"payment_methods_manage_button": "💳 Payment Methods",
|
||||||
|
"payment_methods_title": "💳 <b>Payment Methods</b>",
|
||||||
|
"payment_method_bind_button": "➕ Add method",
|
||||||
|
"payment_method_delete_button": "🗑 Remove",
|
||||||
|
"payment_method_view_button": "ℹ️ Details",
|
||||||
|
"payment_method_none": "You don't have a saved payment method yet.",
|
||||||
|
"payment_method_bound_success": "✅ Payment method added.",
|
||||||
|
"payment_method_deleted_success": "✅ Payment method removed.",
|
||||||
|
"payment_method_delete_confirm": "Remove saved payment method?",
|
||||||
|
"payment_method_card_title": "💳 {network} ••••{last4}",
|
||||||
|
"payment_method_generic_title": "💳 {network}",
|
||||||
|
"payment_method_wallet_title": "💼 YooMoney wallet ••••{last4}",
|
||||||
|
"payment_network_card": "Card",
|
||||||
|
"payment_network_generic": "Payment method",
|
||||||
|
"payment_method_added_at": "Added: {date}",
|
||||||
|
"payment_method_last_tx": "Last transaction: {date}",
|
||||||
|
"payment_method_tx_history_title": "📜 Transactions history",
|
||||||
|
"payment_method_no_history": "No transactions history.",
|
||||||
|
"subscription_purchase_title": "Subscription purchase for {months} mo.",
|
||||||
|
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
|
||||||
|
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
|
||||||
|
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
|
||||||
"subscription_not_active": "You don't have an active subscription.",
|
"subscription_not_active": "You don't have an active subscription.",
|
||||||
"error_service_unavailable": "Service unavailable. Please try again later.",
|
"error_service_unavailable": "Service unavailable. Please try again later.",
|
||||||
"error_payment_gateway": "Payment service error. 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_creating_payment_record": "Error creating payment record. Please try again later.",
|
||||||
"error_payment_gateway_link_failed": "Error creating payment link. Please try again later.",
|
"error_payment_gateway_link_failed": "Error creating payment link. Please try again later.",
|
||||||
"status_active": "Active",
|
"status_active": "Active",
|
||||||
"status_inactive": "Inactive"
|
"status_inactive": "Inactive",
|
||||||
|
"admin_ads_section": "📈 Ads",
|
||||||
|
"admin_ads_header": "📈 Ad Campaigns:",
|
||||||
|
"admin_ads_empty": "📭 No ad campaigns. Click \"Create\" to add one.",
|
||||||
|
"admin_ads_item": "ID: {id}\nSource: <b>{source}</b>\nstart={start_param}\nCost: {cost} RUB\nActive: {active}\n— Starts: {starts}\n— Trials: {trials}\n— Payers: {payers}\n— Revenue: {revenue} RUB",
|
||||||
|
"admin_ads_create_button": "➕ Create campaign",
|
||||||
|
"admin_ads_create_source_prompt": "Enter source (e.g., AEZA, VK, TG-channel):",
|
||||||
|
"admin_ads_create_start_param_prompt": "Enter start link parameter (e.g., AEZA). Will be used as start=AEZA",
|
||||||
|
"admin_ads_create_cost_prompt": "Enter campaign cost (RUB):",
|
||||||
|
"admin_ads_invalid_source": "❌ Invalid source. Enter up to 64 characters.",
|
||||||
|
"admin_ads_invalid_start_param": "❌ Invalid parameter. Allowed letters/digits/underscore/dash (2-64).",
|
||||||
|
"admin_ads_invalid_cost": "❌ Invalid amount. Enter a non-negative number.",
|
||||||
|
"admin_ads_start_param_exists": "❌ A campaign with this start parameter already exists.",
|
||||||
|
"admin_ads_created_success": "✅ Campaign created!\nID: {id}\nSource: {source}\nParam: {start_param}\nCost: {cost} RUB",
|
||||||
|
"admin_ads_back_to_menu_hint": "Done. Back to Ads section:",
|
||||||
|
"admin_ads_overview": "📈 <b>Ads</b>\n💰 Revenue: <b>{revenue} RUB</b>\n💸 Spent: <b>{cost} RUB</b>",
|
||||||
|
"back_to_ads_list_button": "⬅️ Back to list",
|
||||||
|
"admin_ads_card": "📈 <b>Campaign #{id}</b>\nSource: <b>{source}</b>\nstart=<code>{start_param}</code>\nCost: <b>{cost} RUB</b>\nActive: {active}\n\n👥 Starts: <b>{starts}</b>\n🆓 Trials: <b>{trials}</b>\n💳 Payers: <b>{payers}</b>\n💵 Revenue: <b>{revenue} RUB</b>"
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-1
@@ -139,7 +139,11 @@
|
|||||||
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
|
"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_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||||
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
|
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||||
|
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
|
||||||
|
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
|
||||||
|
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
|
||||||
"tribute_subscription_cancelled": "🚨 <b>Подписка отменена</b>\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.",
|
"tribute_subscription_cancelled": "🚨 <b>Подписка отменена</b>\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.",
|
||||||
|
"yookassa_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
|
||||||
"admin_promo_set_validity_days": "⏰ Установить срок (дни)",
|
"admin_promo_set_validity_days": "⏰ Установить срок (дни)",
|
||||||
"admin_back_to_panel": "⬅️ В панель",
|
"admin_back_to_panel": "⬅️ В панель",
|
||||||
"admin_promo_unlimited": "♾️ Неограниченно",
|
"admin_promo_unlimited": "♾️ Неограниченно",
|
||||||
@@ -370,6 +374,31 @@
|
|||||||
"admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}",
|
"admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}",
|
||||||
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
|
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
|
||||||
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
|
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
|
||||||
|
"autorenew_enable_button": "🔄 Включить автопродление",
|
||||||
|
"autorenew_disable_button": "🛑 Отключить автопродление",
|
||||||
|
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
|
||||||
|
"payment_methods_manage_button": "💳 Способы оплаты",
|
||||||
|
"payment_methods_title": "💳 <b>Способы оплаты</b>",
|
||||||
|
"payment_method_bind_button": "➕ Добавить способ",
|
||||||
|
"payment_method_delete_button": "🗑 Удалить",
|
||||||
|
"payment_method_view_button": "ℹ️ Детали",
|
||||||
|
"payment_method_none": "У вас пока нет сохранённого способа оплаты.",
|
||||||
|
"payment_method_bound_success": "✅ Способ оплаты добавлен.",
|
||||||
|
"payment_method_deleted_success": "✅ Способ оплаты удалён.",
|
||||||
|
"payment_method_delete_confirm": "Удалить сохранённый способ оплаты?",
|
||||||
|
"payment_method_card_title": "💳 {network} ••••{last4}",
|
||||||
|
"payment_method_generic_title": "💳 {network}",
|
||||||
|
"payment_method_wallet_title": "💼 Кошелёк YooMoney ••••{last4}",
|
||||||
|
"payment_network_card": "Карта",
|
||||||
|
"payment_network_generic": "Способ оплаты",
|
||||||
|
"payment_method_added_at": "Добавлена: {date}",
|
||||||
|
"payment_method_last_tx": "Последняя операция: {date}",
|
||||||
|
"payment_method_tx_history_title": "📜 История операций",
|
||||||
|
"payment_method_no_history": "История операций отсутствует.",
|
||||||
|
"subscription_purchase_title": "Покупка подписки на {months} мес.",
|
||||||
|
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
|
||||||
|
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
|
||||||
|
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
|
||||||
"subscription_not_active": "У вас нет активной подписки.",
|
"subscription_not_active": "У вас нет активной подписки.",
|
||||||
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
|
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
|
||||||
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
|
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
|
||||||
@@ -379,5 +408,22 @@
|
|||||||
"error_creating_payment_record": "Ошибка создания записи платежа. Попробуйте позже.",
|
"error_creating_payment_record": "Ошибка создания записи платежа. Попробуйте позже.",
|
||||||
"error_payment_gateway_link_failed": "Ошибка создания платежной ссылки. Попробуйте позже.",
|
"error_payment_gateway_link_failed": "Ошибка создания платежной ссылки. Попробуйте позже.",
|
||||||
"status_active": "Активна",
|
"status_active": "Активна",
|
||||||
"status_inactive": "Неактивна"
|
"status_inactive": "Неактивна",
|
||||||
|
"admin_ads_section": "📈 Реклама",
|
||||||
|
"admin_ads_header": "📈 Рекламные кампании:",
|
||||||
|
"admin_ads_empty": "📭 Рекламные кампании отсутствуют. Нажмите \"Создать\" чтобы добавить новую.",
|
||||||
|
"admin_ads_item": "ID: {id}\nИсточник: <b>{source}</b>\nstart={start_param}\nСтоимость: {cost} RUB\nАктивна: {active}\n— Запустили: {starts}\n— Взяли триал: {trials}\n— Оплатили: {payers}\n— Доход: {revenue} RUB",
|
||||||
|
"admin_ads_create_button": "➕ Создать кампанию",
|
||||||
|
"admin_ads_create_source_prompt": "Введите источник (например: AEZA, VK, TG-канал):",
|
||||||
|
"admin_ads_create_start_param_prompt": "Введите параметр старт-ссылки (например: AEZA). Будет использован как start=AEZA",
|
||||||
|
"admin_ads_create_cost_prompt": "Введите сумму затрат на кампанию (в RUB):",
|
||||||
|
"admin_ads_invalid_source": "❌ Неверный источник. Введите до 64 символов.",
|
||||||
|
"admin_ads_invalid_start_param": "❌ Неверный параметр. Допустимы буквы/цифры/подчёркивания/дефисы (2-64).",
|
||||||
|
"admin_ads_invalid_cost": "❌ Неверная сумма. Введите неотрицательное число.",
|
||||||
|
"admin_ads_start_param_exists": "❌ Кампания с таким start-параметром уже существует.",
|
||||||
|
"admin_ads_created_success": "✅ Кампания создана!\nID: {id}\nИсточник: {source}\nПараметр: {start_param}\nЗатраты: {cost} RUB",
|
||||||
|
"admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:",
|
||||||
|
"admin_ads_overview": "📈 <b>Реклама</b>\n💰 Пришло: <b>{revenue} RUB</b>\n💸 Потрачено: <b>{cost} RUB</b>",
|
||||||
|
"back_to_ads_list_button": "⬅️ К списку",
|
||||||
|
"admin_ads_card": "📈 <b>Кампания #{id}</b>\nИсточник: <b>{source}</b>\nstart=<code>{start_param}</code>\nСтоимость: <b>{cost} RUB</b>\nАктивна: {active}\n\n👥 Запустили: <b>{starts}</b>\n🆓 Взяли триал: <b>{trials}</b>\n💳 Оплатили: <b>{payers}</b>\n💵 Доход: <b>{revenue} RUB</b>"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user