chore: run lint and prettifier

This commit is contained in:
3252a8
2026-05-12 21:54:12 +03:00
parent f31540afdb
commit 11187487b4
174 changed files with 12383 additions and 6688 deletions
+2 -4
View File
@@ -1,11 +1,9 @@
from aiogram import Router
from . import start
from . import promo_user, referral, start, trial_handler
# TODO: after splitting subscription into a package, replace this import
from .subscription import router as subscription_router
from . import referral
from . import promo_user
from . import trial_handler
user_router_aggregate = Router(name="user_router_aggregate")
+250 -168
View File
@@ -1,37 +1,34 @@
import logging
import json
import asyncio
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any
import json
import logging
from datetime import datetime, timezone
from typing import Optional
from aiohttp import web
from aiogram import Bot
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from yookassa.domain.notification import WebhookNotification
from yookassa.domain.models.amount import Amount as YooKassaAmount
from db.dal import payment_dal, user_dal, user_billing_dal
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.panel_api_service import PanelApiService
from bot.services.yookassa_service import YooKassaService
from bot.services.lknpd_service import LknpdService
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from bot.middlewares.i18n import JsonI18n
from bot.services.lknpd_service import LknpdService
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_billing_dal, user_dal
payment_processing_lock = asyncio.Lock()
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = "payment.succeeded"
YOOKASSA_EVENT_PAYMENT_CANCELED = "payment.canceled"
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = "payment.waiting_for_capture"
YOOKASSA_WEBHOOK_ALLOWED_IPS = [
"185.71.76.0/27",
"185.71.77.0/27",
@@ -43,23 +40,28 @@ YOOKASSA_WEBHOOK_ALLOWED_IPS = [
]
async def process_successful_payment(session: AsyncSession, bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n, settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService,
lknpd_service: Optional[LknpdService] = None):
async def process_successful_payment(
session: AsyncSession,
bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService,
lknpd_service: Optional[LknpdService] = None,
):
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
subscription_months_str = metadata.get("subscription_months")
traffic_gb_str = metadata.get("traffic_gb")
sale_mode = metadata.get("sale_mode") or ("traffic" if settings.traffic_sale_mode else "subscription")
sale_mode = metadata.get("sale_mode") or (
"traffic" if settings.traffic_sale_mode else "subscription"
)
sale_mode_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
promo_code_id_str = metadata.get("promo_code_id")
payment_db_id_str = metadata.get("payment_db_id")
auto_renew_subscription_id_str = metadata.get(
"auto_renew_for_subscription_id")
auto_renew_subscription_id_str = metadata.get("auto_renew_for_subscription_id")
# 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.
@@ -78,12 +80,17 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
user_id = int(user_id_str)
subscription_months = float(subscription_months_str or 0)
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
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 and sale_mode_base == "subscription")
promo_code_id = int(
promo_code_id_str
) if promo_code_id_str and promo_code_id_str.isdigit() else None
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
and sale_mode_base == "subscription"
)
promo_code_id = (
int(promo_code_id_str) if promo_code_id_str and promo_code_id_str.isdigit() else None
)
amount_data = payment_info_from_webhook.get("amount", {})
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
@@ -100,6 +107,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
)
return
from db.dal import payment_dal as _payment_dal
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
session, yk_payment_id_from_hook
)
@@ -110,8 +118,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
amount=payment_value,
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
months=months_for_record or 1,
description=payment_info_from_webhook.get(
"description") or f"Auto-renewal for {months_for_record or subscription_months} months",
description=payment_info_from_webhook.get("description")
or f"Auto-renewal for {months_for_record or subscription_months} months",
provider="yookassa",
provider_payment_id=yk_payment_id_from_hook,
)
@@ -143,25 +151,24 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
)
await payment_dal.update_payment_status_by_db_id(
session, payment_db_id, "failed_user_not_found",
payment_info_from_webhook.get("id"))
session, payment_db_id, "failed_user_not_found", payment_info_from_webhook.get("id")
)
return
except (TypeError, ValueError) as e:
logging.error(
f"Invalid metadata format for payment processing: {metadata} - {e}"
)
logging.error(f"Invalid metadata format for payment processing: {metadata} - {e}")
if payment_db_id_str and payment_db_id_str.isdigit():
try:
await payment_dal.update_payment_status_by_db_id(
session, int(payment_db_id_str), "failed_metadata_error",
payment_info_from_webhook.get("id"))
except Exception as e_upd:
logging.error(
f"Failed to update payment status after metadata error: {e_upd}"
session,
int(payment_db_id_str),
"failed_metadata_error",
payment_info_from_webhook.get("id"),
)
except Exception as e_upd:
logging.error(f"Failed to update payment status after metadata error: {e_upd}")
return
try:
@@ -183,12 +190,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
# Try to capture and save payment method for future charges if available
try:
payment_method = payment_info_from_webhook.get("payment_method")
if settings.yookassa_autopayments_active and isinstance(payment_method, dict) and payment_method.get("saved", False):
if (
settings.yookassa_autopayments_active
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")
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
@@ -228,7 +241,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
logging.exception("Failed to persist multi-card YooKassa method from webhook")
except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook")
months_for_activation = int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb)
months_for_activation = (
int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb)
)
activation_details = await subscription_service.activate_subscription(
session,
user_id,
@@ -238,32 +253,32 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
promo_code_id_from_payment=promo_code_id,
provider="yookassa",
sale_mode=sale_mode,
traffic_gb=traffic_amount_gb if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=traffic_amount_gb
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
if not activation_details or not activation_details.get('end_date'):
if not activation_details or not activation_details.get("end_date"):
logging.error(
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}"
)
raise Exception(
f"Subscription Error: Failed to activate for user {user_id}")
raise Exception(f"Subscription Error: Failed to activate for user {user_id}")
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
new_status=payment_info_from_webhook.get("status", "succeeded"),
yk_payment_id=yk_payment_id_from_hook)
yk_payment_id=yk_payment_id_from_hook,
)
if not updated_payment_record:
logging.error(
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}"
)
raise Exception(
f"DB Error: Could not update payment record {payment_db_id}")
raise Exception(f"DB Error: Could not update payment record {payment_db_id}")
base_subscription_end_date = activation_details['end_date']
base_subscription_end_date = activation_details["end_date"]
final_end_date_for_user = base_subscription_end_date
applied_promo_bonus_days = activation_details.get(
"applied_promo_bonus_days", 0)
applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0)
referral_bonus_info = None
if sale_mode_base == "subscription":
@@ -275,19 +290,24 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
skip_if_active_before_payment=False,
)
applied_referee_bonus_days_from_referral: Optional[int] = None
if referral_bonus_info and referral_bonus_info.get(
"referee_new_end_date"):
final_end_date_for_user = referral_bonus_info[
"referee_new_end_date"]
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
final_end_date_for_user = referral_bonus_info["referee_new_end_date"]
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)
traffic_label = (
str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}"
str(int(traffic_amount_gb))
if float(traffic_amount_gb).is_integer()
else f"{traffic_amount_gb:g}"
)
if should_send_lknpd_receipt:
receipt_item_name = payment_info_from_webhook.get("description")
@@ -295,7 +315,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
else:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(months=int(subscription_months))
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(
months=int(subscription_months)
)
try:
await lknpd_service.create_income_receipt(
item_name=receipt_item_name,
@@ -317,14 +339,16 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
details_message = _(
"yookassa_auto_renewal",
months=int(subscription_months),
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
)
details_markup = None
elif sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
details_message = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "",
end_date=final_end_date_for_user.strftime("%Y-%m-%d")
if final_end_date_for_user
else "",
config_link=config_link_text,
)
details_markup = get_connect_and_main_keyboard(
@@ -339,21 +363,26 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
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)
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
safe_name = (
sanitize_display_name(inviter.first_name)
if inviter.first_name
else None
)
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
inviter_name_display = username_for_display(
inviter.username, with_at=False
)
details_message = _(
"payment_successful_with_referral_bonus_full",
months=int(subscription_months),
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
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'),
final_end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
inviter_name=inviter_name_display,
config_link=config_link_text,
)
@@ -362,14 +391,14 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
"payment_successful_with_promo_full",
months=int(subscription_months),
bonus_days=applied_promo_bonus_days,
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_text,
)
elif final_end_date_for_user:
details_message = _(
"payment_successful_full",
months=int(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_text,
)
else:
@@ -395,9 +424,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
disable_web_page_preview=True,
)
except Exception as e_notify:
logging.error(
f"Failed to send payment details message to user {user_id}: {e_notify}"
)
logging.error(f"Failed to send payment details message to user {user_id}: {e_notify}")
# Send notification about payment
try:
@@ -417,7 +444,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
months=int(subscription_months) if sale_mode_base == "subscription" else 0,
payment_provider="yookassa", # This is specifically for YooKassa webhook
username=user.username if user else None,
traffic_gb=traffic_amount_gb if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
traffic_gb=traffic_amount_gb
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
traffic_is_premium=sale_mode_base == "premium_topup",
tariff_key=tariff_for_log,
)
@@ -427,14 +456,19 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
except Exception as e_process:
logging.error(
f"Error during process_successful_payment main try block for user {user_id}: {e_process}",
exc_info=True)
exc_info=True,
)
raise
async def process_cancelled_payment(session: AsyncSession, bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n, settings: Settings):
async def process_cancelled_payment(
session: AsyncSession,
bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n,
settings: Settings,
):
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
@@ -449,8 +483,7 @@ async def process_cancelled_payment(session: AsyncSession, bot: Bot,
user_id = int(user_id_str)
payment_db_id = int(payment_db_id_str)
except ValueError:
logging.error(
f"Invalid metadata in cancelled payment webhook: {metadata}")
logging.error(f"Invalid metadata in cancelled payment webhook: {metadata}")
return
try:
@@ -458,7 +491,8 @@ async def process_cancelled_payment(session: AsyncSession, bot: Bot,
session,
payment_db_id=payment_db_id,
new_status=payment_info_from_webhook.get("status", "canceled"),
yk_payment_id=payment_info_from_webhook.get("id"))
yk_payment_id=payment_info_from_webhook.get("id"),
)
if updated_payment:
logging.info(
@@ -471,7 +505,8 @@ async def process_cancelled_payment(session: AsyncSession, bot: Bot,
db_user = await user_dal.get_user_by_id(session, user_id)
user_lang = settings.DEFAULT_LANGUAGE
if db_user and db_user.language_code: user_lang = db_user.language_code
if db_user and db_user.language_code:
user_lang = db_user.language_code
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
await bot.send_message(user_id, _("payment_failed"))
@@ -479,29 +514,25 @@ async def process_cancelled_payment(session: AsyncSession, bot: Bot,
except Exception as e_process_cancel:
logging.error(
f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}",
exc_info=True)
exc_info=True,
)
raise
async def yookassa_webhook_route(request: web.Request):
try:
bot: Bot = request.app['bot']
i18n_instance: JsonI18n = request.app['i18n']
settings: Settings = request.app['settings']
panel_service: PanelApiService = request.app['panel_service']
subscription_service: SubscriptionService = request.app[
'subscription_service']
referral_service: ReferralService = request.app['referral_service']
lknpd_service: Optional[LknpdService] = request.app.get('lknpd_service')
async_session_factory: sessionmaker = request.app[
'async_session_factory']
bot: Bot = request.app["bot"]
i18n_instance: JsonI18n = request.app["i18n"]
settings: Settings = request.app["settings"]
panel_service: PanelApiService = request.app["panel_service"]
subscription_service: SubscriptionService = request.app["subscription_service"]
referral_service: ReferralService = request.app["referral_service"]
lknpd_service: Optional[LknpdService] = request.app.get("lknpd_service")
async_session_factory: sessionmaker = request.app["async_session_factory"]
except KeyError:
logging.exception(
"KeyError accessing app context in yookassa_webhook_route.")
return web.Response(
status=500,
text="Internal Server Error: Missing app context component")
logging.exception("KeyError accessing app context in yookassa_webhook_route.")
return web.Response(status=500, text="Internal Server Error: Missing app context component")
client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies)
if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS):
@@ -519,39 +550,41 @@ async def yookassa_webhook_route(request: web.Request):
f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'"
)
if not payment_data_from_notification or not hasattr(
payment_data_from_notification,
'metadata') or payment_data_from_notification.metadata is None:
if (
not payment_data_from_notification
or not hasattr(payment_data_from_notification, "metadata")
or payment_data_from_notification.metadata is None
):
logging.error(
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process."
)
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_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)
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),
"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
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),
"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
@@ -562,21 +595,19 @@ async def yookassa_webhook_route(request: web.Request):
pm_dict = None
payment_dict_for_processing = {
"id":
str(payment_data_from_notification.id),
"status":
str(payment_data_from_notification.status),
"paid":
bool(payment_data_from_notification.paid),
"id": str(payment_data_from_notification.id),
"status": str(payment_data_from_notification.status),
"paid": bool(payment_data_from_notification.paid),
"amount": {
"value": str(payment_data_from_notification.amount.value),
"currency": str(payment_data_from_notification.amount.currency)
} if payment_data_from_notification.amount else {},
"metadata":
dict(payment_data_from_notification.metadata),
"description":
str(payment_data_from_notification.description)
if payment_data_from_notification.description else None,
"currency": str(payment_data_from_notification.amount.currency),
}
if payment_data_from_notification.amount
else {},
"metadata": dict(payment_data_from_notification.metadata),
"description": str(payment_data_from_notification.description)
if payment_data_from_notification.description
else None,
"payment_method": pm_dict,
}
@@ -584,14 +615,21 @@ async def yookassa_webhook_route(request: web.Request):
async with async_session_factory() as session:
try:
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
if payment_dict_for_processing.get(
"paid") and payment_dict_for_processing.get(
"status") == "succeeded":
if (
payment_dict_for_processing.get("paid")
and payment_dict_for_processing.get("status") == "succeeded"
):
await process_successful_payment(
session, bot, payment_dict_for_processing,
i18n_instance, settings, panel_service,
subscription_service, referral_service,
lknpd_service)
session,
bot,
payment_dict_for_processing,
i18n_instance,
settings,
panel_service,
subscription_service,
referral_service,
lknpd_service,
)
await session.commit()
else:
logging.warning(
@@ -601,37 +639,62 @@ async def yookassa_webhook_route(request: web.Request):
)
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
await process_cancelled_payment(
session, bot, payment_dict_for_processing,
i18n_instance, settings)
session, bot, payment_dict_for_processing, i18n_instance, settings
)
await session.commit()
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
# Bind-only flow: save method and cancel auth if metadata has bind_only
metadata = payment_dict_for_processing.get("metadata", {}) or {}
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
if (
settings.yookassa_autopayments_active
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"):
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")
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"
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"}:
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:
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_network = title or (
pm_type.upper() if pm_type else "Payment method"
)
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
@@ -644,6 +707,7 @@ async def yookassa_webhook_route(request: web.Request):
# 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,
@@ -661,35 +725,53 @@ async def yookassa_webhook_route(request: web.Request):
# 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)
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
_ = 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)
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')
yk: YooKassaService = request.app.get(
"yookassa_service"
)
if yk:
await yk.cancel_payment(payment_dict_for_processing.get("id"))
await yk.cancel_payment(
payment_dict_for_processing.get("id")
)
except Exception:
logging.exception("Failed to cancel bind-only payment auth")
logging.exception(
"Failed to cancel bind-only payment auth"
)
except Exception:
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
logging.exception(
"Failed to handle bind-only waiting_for_capture webhook"
)
except Exception:
await session.rollback()
logging.exception(
"Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.",
notification_object.event,
payment_dict_for_processing.get('id'))
return web.Response(
status=500, text="internal_processing_error")
payment_dict_for_processing.get("id"),
)
return web.Response(status=500, text="internal_processing_error")
return web.Response(status=200, text="ok")
+59 -53
View File
@@ -1,21 +1,22 @@
import logging
import re
from aiogram import Router, F, types, Bot
from aiogram.fsm.context import FSMContext
from datetime import datetime
from typing import Optional
from aiogram import Bot, F, Router, types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.states.user_states import UserPromoStates
from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService
from bot.keyboards.inline.user_keyboards import (
get_back_to_main_menu_markup,
get_connect_and_main_keyboard,
)
from datetime import datetime
from bot.middlewares.i18n import JsonI18n
from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService
from bot.states.user_states import UserPromoStates
from bot.utils.callback_answer import safe_answer_callback
from config.settings import Settings
from .start import send_main_menu
@@ -24,15 +25,20 @@ router = Router(name="user_promo_router")
SUSPICIOUS_SQL_KEYWORDS_REGEX = re.compile(
r"\b(DROP\s*TABLE|DELETE\s*FROM|ALTER\s*TABLE|TRUNCATE\s*TABLE|UNION\s*SELECT|"
r";\s*SELECT|;\s*INSERT|;\s*UPDATE|;\s*DELETE|xp_cmdshell|sysdatabases|sysobjects|INFORMATION_SCHEMA)\b",
re.IGNORECASE)
re.IGNORECASE,
)
SUSPICIOUS_CHARS_REGEX = re.compile(r"(--|#\s|;|\*\/|\/\*)")
MAX_PROMO_CODE_INPUT_LENGTH = 100
async def prompt_promo_code_input(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings, session: AsyncSession,
back_callback: str = "main_action:back_to_main"):
async def prompt_promo_code_input(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
back_callback: str = "main_action:back_to_main",
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -41,8 +47,7 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not callback.message:
logging.error(
"CallbackQuery has no message in prompt_promo_code_input")
logging.error("CallbackQuery has no message in prompt_promo_code_input")
await safe_answer_callback(
callback,
_("error_occurred_processing_request"),
@@ -57,32 +62,38 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
current_lang,
i18n,
callback_data=back_callback,
))
except Exception as e_edit:
logging.warning(
f"Failed to edit message for promo prompt: {e_edit}. Sending new one."
),
)
except Exception as e_edit:
logging.warning(f"Failed to edit message for promo prompt: {e_edit}. Sending new one.")
await callback.message.answer(
text=_(key="promo_code_prompt"),
reply_markup=get_back_to_main_menu_markup(
current_lang,
i18n,
callback_data=back_callback,
))
),
)
await safe_answer_callback(callback)
await state.set_state(UserPromoStates.waiting_for_promo_code)
logging.info(
f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. "
f"FSM state: {await state.get_state()}")
f"FSM state: {await state.get_state()}"
)
@router.message(UserPromoStates.waiting_for_promo_code, F.text)
async def process_promo_code_input(message: types.Message, state: FSMContext,
settings: Settings, i18n_data: dict,
promo_code_service: PromoCodeService,
subscription_service: SubscriptionService,
bot: Bot, session: AsyncSession):
async def process_promo_code_input(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
promo_code_service: PromoCodeService,
subscription_service: SubscriptionService,
bot: Bot,
session: AsyncSession,
):
logging.info(
f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'"
)
@@ -91,9 +102,7 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not promo_code_service:
logging.error(
"Dependencies (i18n or PromoCodeService) missing in process_promo_code_input"
)
logging.error("Dependencies (i18n or PromoCodeService) missing in process_promo_code_input")
await message.reply("Service error. Please try again later.")
await state.clear()
return
@@ -106,10 +115,11 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
if not code_input:
is_suspicious = True
logging.warning(f"Empty promo code input by user {user.id}.")
elif len(
code_input
) > MAX_PROMO_CODE_INPUT_LENGTH or SUSPICIOUS_SQL_KEYWORDS_REGEX.search(
code_input) or SUSPICIOUS_CHARS_REGEX.search(code_input):
elif (
len(code_input) > MAX_PROMO_CODE_INPUT_LENGTH
or SUSPICIOUS_SQL_KEYWORDS_REGEX.search(code_input)
or SUSPICIOUS_CHARS_REGEX.search(code_input)
):
is_suspicious = True
logging.warning(
f"Suspicious input for promo code by user {user.id} (len: {len(code_input)}): '{code_input}'"
@@ -121,23 +131,23 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
if settings.LOG_SUSPICIOUS_ACTIVITY:
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_suspicious_promo_attempt(
user_id=user.id,
username=user.username,
first_name=user.first_name,
suspicious_input=code_input
suspicious_input=code_input,
)
except Exception as e:
logging.error(f"Failed to send suspicious promo notification: {e}")
success, result = await promo_code_service.apply_promo_code(
session, user.id, code_input, current_lang)
session, user.id, code_input, current_lang
)
if success:
await session.commit()
logging.info(
f"Promo code '{code_input}' successfully applied for user {user.id}."
)
logging.info(f"Promo code '{code_input}' successfully applied for user {user.id}.")
new_end_date = result if isinstance(result, datetime) else None
active = await subscription_service.get_active_subscription_details(session, user.id)
@@ -163,9 +173,7 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
)
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
await message.answer(
response_to_user_text,
@@ -178,12 +186,15 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
)
@router.callback_query(F.data == "main_action:back_to_main",
UserPromoStates.waiting_for_promo_code)
@router.callback_query(F.data == "main_action:back_to_main", UserPromoStates.waiting_for_promo_code)
async def cancel_promo_input_via_button(
callback: types.CallbackQuery, state: FSMContext, settings: Settings,
i18n_data: dict, subscription_service: SubscriptionService,
session: AsyncSession):
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -197,15 +208,10 @@ async def cancel_promo_input_via_button(
await state.clear()
if callback.message:
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
else:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await safe_answer_callback(
callback,
+80 -65
View File
@@ -1,45 +1,45 @@
import logging
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
from typing import Optional, Union
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiogram import Bot, F, Router, types
from aiogram.filters import Command
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from config.settings import Settings
from db.dal import user_dal
from bot.services.referral_service import ReferralService
from bot.middlewares.i18n import JsonI18n
router = Router(name="user_referral_router")
async def referral_command_handler(event: Union[types.Message, types.CallbackQuery],
settings: Settings, i18n_data: dict,
referral_service: ReferralService, bot: Bot,
session: AsyncSession,
back_callback: str = "main_action:back_to_main"):
async def referral_command_handler(
event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
back_callback: str = "main_action:back_to_main",
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
target_message_obj = event.message if isinstance(
event, types.CallbackQuery) else event
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
if not target_message_obj:
logging.error(
"Target message is None in referral_command_handler (possibly from callback without message)."
)
if isinstance(event, types.CallbackQuery):
await event.answer("Error displaying referral info.",
show_alert=True)
await event.answer("Error displaying referral info.", show_alert=True)
return
if not i18n or not referral_service:
logging.error(
"Dependencies (i18n or ReferralService) missing in referral_command_handler"
)
await target_message_obj.answer(
"Service error. Please try again later.")
if isinstance(event, types.CallbackQuery): await event.answer()
logging.error("Dependencies (i18n or ReferralService) missing in referral_command_handler")
await target_message_obj.answer("Service error. Please try again later.")
if isinstance(event, types.CallbackQuery):
await event.answer()
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
@@ -48,21 +48,23 @@ async def referral_command_handler(event: Union[types.Message, types.CallbackQue
bot_info = await bot.get_me()
bot_username = bot_info.username
except Exception as e_bot_info:
logging.error(
f"Failed to get bot info for referral link: {e_bot_info}")
logging.error(f"Failed to get bot info for referral link: {e_bot_info}")
await target_message_obj.answer(_("error_generating_referral_link"))
if isinstance(event, types.CallbackQuery): await event.answer()
if isinstance(event, types.CallbackQuery):
await event.answer()
return
if not bot_username:
logging.error("Bot username is None, cannot generate referral link.")
await target_message_obj.answer(_("error_generating_referral_link"))
if isinstance(event, types.CallbackQuery): await event.answer()
if isinstance(event, types.CallbackQuery):
await event.answer()
return
inviter_user_id = event.from_user.id
referral_link = await referral_service.generate_referral_link(
session, bot_username, inviter_user_id)
session, bot_username, inviter_user_id
)
if not referral_link:
logging.error(
@@ -79,22 +81,26 @@ async def referral_command_handler(event: Union[types.Message, types.CallbackQue
bonus_details_str = _("referral_not_available_for_traffic")
else:
if settings.subscription_options:
for months_period_key, _price in sorted(
settings.subscription_options.items()):
for months_period_key, _price in sorted(settings.subscription_options.items()):
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
if inv_bonus is not None or ref_bonus is not None:
bonus_info_parts.append(
_("referral_bonus_per_period",
months=months_period_key,
inviter_bonus_days=inv_bonus
if inv_bonus is not None else _("no_bonus_placeholder"),
referee_bonus_days=ref_bonus
if ref_bonus is not None else _("no_bonus_placeholder")))
_(
"referral_bonus_per_period",
months=months_period_key,
inviter_bonus_days=inv_bonus
if inv_bonus is not None
else _("no_bonus_placeholder"),
referee_bonus_days=ref_bonus
if ref_bonus is not None
else _("no_bonus_placeholder"),
)
)
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
"referral_no_bonuses_configured")
bonus_details_str = (
"\n".join(bonus_info_parts) if bonus_info_parts else _("referral_no_bonuses_configured")
)
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
@@ -112,14 +118,17 @@ async def referral_command_handler(event: Union[types.Message, types.CallbackQue
else ""
)
text = _("referral_program_info_new",
referral_link=referral_link,
webapp_link_section=webapp_link_section,
bonus_details=bonus_details_str,
invited_count=referral_stats["invited_count"],
purchased_count=referral_stats["purchased_count"])
text = _(
"referral_program_info_new",
referral_link=referral_link,
webapp_link_section=webapp_link_section,
bonus_details=bonus_details_str,
invited_count=referral_stats["invited_count"],
purchased_count=referral_stats["purchased_count"],
)
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
reply_markup_val = get_referral_link_keyboard(
current_lang,
i18n,
@@ -127,28 +136,29 @@ async def referral_command_handler(event: Union[types.Message, types.CallbackQue
)
if isinstance(event, types.Message):
await event.answer(text,
reply_markup=reply_markup_val,
disable_web_page_preview=True)
await event.answer(text, reply_markup=reply_markup_val, disable_web_page_preview=True)
elif isinstance(event, types.CallbackQuery) and event.message:
try:
await event.message.edit_text(text,
reply_markup=reply_markup_val,
disable_web_page_preview=True)
await event.message.edit_text(
text, reply_markup=reply_markup_val, disable_web_page_preview=True
)
except Exception as e_edit:
logging.warning(
f"Failed to edit message for referral info: {e_edit}. Sending new one."
logging.warning(f"Failed to edit message for referral info: {e_edit}. Sending new one.")
await event.message.answer(
text, reply_markup=reply_markup_val, disable_web_page_preview=True
)
await event.message.answer(text,
reply_markup=reply_markup_val,
disable_web_page_preview=True)
await event.answer()
@router.callback_query(F.data.startswith("referral_action:"))
async def referral_action_handler(callback: types.CallbackQuery, settings: Settings,
i18n_data: dict, referral_service: ReferralService,
bot: Bot, session: AsyncSession):
async def referral_action_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
):
action = callback.data.split(":")[1]
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n = i18n_data.get("i18n_instance")
@@ -164,7 +174,8 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
inviter_user_id = callback.from_user.id
referral_link = await referral_service.generate_referral_link(
session, bot_username, inviter_user_id)
session, bot_username, inviter_user_id
)
if not referral_link:
logging.error(
@@ -188,10 +199,7 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
else:
friend_message = _("referral_friend_message", referral_link=referral_link)
await callback.message.answer(
friend_message,
disable_web_page_preview=True
)
await callback.message.answer(friend_message, disable_web_page_preview=True)
except Exception as e:
logging.error(f"Error in referral share message: {e}")
@@ -200,7 +208,9 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
await callback.answer()
def _build_webapp_referral_link(base_url: Optional[str], referral_code: Optional[str]) -> Optional[str]:
def _build_webapp_referral_link(
base_url: Optional[str], referral_code: Optional[str]
) -> Optional[str]:
if not base_url or not referral_code:
return None
parts = urlsplit(base_url)
@@ -233,7 +243,12 @@ async def _generate_webapp_referral_link(
@router.message(Command("referral"))
async def referral_command_message_handler(message: types.Message, settings: Settings,
i18n_data: dict, referral_service: ReferralService,
bot: Bot, session: AsyncSession):
async def referral_command_message_handler(
message: types.Message,
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
):
await referral_command_handler(message, settings, i18n_data, referral_service, bot, session)
+239 -245
View File
@@ -1,61 +1,62 @@
import logging
import re
from aiogram import Router, F, types, Bot
from aiogram.utils.text_decorations import html_decoration as hd
from aiogram.filters import CommandStart, Command
from aiogram.fsm.context import FSMContext
from typing import Optional, Union
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timezone
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
from typing import Optional, Union
from aiogram import Bot, F, Router, types
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import Command, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.utils.text_decorations import html_decoration as hd
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import (
get_bot_interface_inline_keyboard,
get_channel_subscription_keyboard,
get_information_links_keyboard,
get_language_selection_keyboard,
get_main_menu_inline_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.callback_answer import safe_answer_callback
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import user_dal
from db.models import User
from bot.keyboards.inline.user_keyboards import (
get_main_menu_inline_keyboard,
get_bot_interface_inline_keyboard,
get_language_selection_keyboard,
get_channel_subscription_keyboard,
get_information_links_keyboard,
)
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
from bot.utils.callback_answer import safe_answer_callback
router = Router(name="user_start_router")
async def should_show_trial_button(
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
user_id: int) -> bool:
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
user_id: int,
) -> bool:
if not settings.TRIAL_ENABLED:
return False
if hasattr(subscription_service, 'has_had_any_subscription') and callable(
getattr(subscription_service, 'has_had_any_subscription')):
return not await subscription_service.has_had_any_subscription(
session, user_id)
if hasattr(subscription_service, "has_had_any_subscription") and callable(
getattr(subscription_service, "has_had_any_subscription")
):
return not await subscription_service.has_had_any_subscription(session, user_id)
logging.error(
"Method has_had_any_subscription is missing in SubscriptionService!"
)
logging.error("Method has_had_any_subscription is missing in SubscriptionService!")
return False
async def send_main_menu(target_event: Union[types.Message,
types.CallbackQuery],
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
is_edit: bool = False):
async def send_main_menu(
target_event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
is_edit: bool = False,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -63,8 +64,7 @@ async def send_main_menu(target_event: Union[types.Message,
user_full_name = hd.quote(target_event.from_user.full_name)
if not i18n:
logging.error(
f"i18n_instance missing in send_main_menu for user {user_id}")
logging.error(f"i18n_instance missing in send_main_menu for user {user_id}")
err_msg_fallback = "Error: Language service unavailable. Please try again later."
if isinstance(target_event, types.CallbackQuery):
try:
@@ -78,27 +78,25 @@ async def send_main_menu(target_event: Union[types.Message,
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
show_trial_button_in_menu = await should_show_trial_button(
settings, subscription_service, session, user_id)
settings, subscription_service, session, user_id
)
text = _(key="main_menu_greeting", user_name=user_full_name)
reply_markup = get_main_menu_inline_keyboard(current_lang, i18n, settings,
show_trial_button_in_menu)
reply_markup = get_main_menu_inline_keyboard(
current_lang, i18n, settings, show_trial_button_in_menu
)
target_message_obj: Optional[types.Message] = None
if isinstance(target_event, types.Message):
target_message_obj = target_event
elif isinstance(target_event,
types.CallbackQuery) and target_event.message:
elif isinstance(target_event, types.CallbackQuery) and target_event.message:
target_message_obj = target_event.message
if not target_message_obj:
logging.error(
f"send_main_menu: target_message_obj is None for event from user {user_id}."
)
logging.error(f"send_main_menu: target_message_obj is None for event from user {user_id}.")
if isinstance(target_event, types.CallbackQuery):
await safe_answer_callback(
target_event,
@@ -134,12 +132,13 @@ async def send_main_menu(target_event: Union[types.Message,
async def send_bot_interface_menu(
target_event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
is_edit: bool = False):
target_event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
is_edit: bool = False,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -149,16 +148,15 @@ async def send_bot_interface_menu(
user_id = target_event.from_user.id
show_trial_button_in_menu = await should_show_trial_button(
settings, subscription_service, session, user_id)
settings, subscription_service, session, user_id
)
text = i18n.gettext(current_lang, "bot_interface_menu_title")
if settings.SUBSCRIPTION_MINI_APP_URL:
text = (
f"{text}\n\n"
f"{i18n.gettext(current_lang, 'bot_interface_menu_webapp_hint')}"
)
text = f"{text}\n\n{i18n.gettext(current_lang, 'bot_interface_menu_webapp_hint')}"
reply_markup = get_bot_interface_inline_keyboard(
current_lang, i18n, settings, show_trial_button_in_menu)
current_lang, i18n, settings, show_trial_button_in_menu
)
target_message_obj: Optional[types.Message] = None
if isinstance(target_event, types.Message):
@@ -201,12 +199,13 @@ async def send_bot_interface_menu(
async def ensure_required_channel_subscription(
event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n: Optional[JsonI18n],
current_lang: str,
session: AsyncSession,
db_user: Optional[User] = None) -> bool:
event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n: Optional[JsonI18n],
current_lang: str,
session: AsyncSession,
db_user: Optional[User] = None,
) -> bool:
"""
Verify that the user is a member of the required channel (if configured).
Returns True when access can proceed, False when user must subscribe first.
@@ -227,9 +226,7 @@ async def ensure_required_channel_subscription(
message_obj = event
if bot_instance is None:
logging.error(
"Channel subscription check: bot instance missing for user %s.", user_id
)
logging.error("Channel subscription check: bot instance missing for user %s.", user_id)
return False
if user_id in settings.ADMIN_IDS:
@@ -254,9 +251,10 @@ async def ensure_required_channel_subscription(
)
return True
if (db_user.channel_subscription_verified
and db_user.channel_subscription_verified_for
== required_channel_id):
if (
db_user.channel_subscription_verified
and db_user.channel_subscription_verified_for == required_channel_id
):
return True
def translate(key: str, **kwargs) -> str:
@@ -346,10 +344,11 @@ async def ensure_required_channel_subscription(
)
return True
keyboard = (get_channel_subscription_keyboard(
current_lang, i18n, settings.REQUIRED_CHANNEL_LINK
keyboard = (
get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK)
if i18n
else None
)
if i18n else None)
prompt_text = translate("channel_subscription_required")
@@ -379,28 +378,41 @@ async def ensure_required_channel_subscription(
@router.message(CommandStart())
@router.message(CommandStart(magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_("ref_match")))
@router.message(
CommandStart(
magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\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"^admin_user_(\d+)$").as_("admin_user_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
async def start_command_handler(message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
referral_service: ReferralService,
session: AsyncSession,
ref_match: Optional[re.Match] = None,
promo_match: Optional[re.Match] = None,
page_ref_match: Optional[re.Match] = None,
ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None):
@router.message(
CommandStart(
magic=F.args.regexp(
r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
).as_("ad_param_match")
)
)
async def start_command_handler(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
referral_service: ReferralService,
session: AsyncSession,
ref_match: Optional[re.Match] = None,
promo_match: Optional[re.Match] = None,
page_ref_match: Optional[re.Match] = None,
ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None,
):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
) if i18n else key
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
user = message.from_user
user_id = user.id
@@ -409,21 +421,17 @@ async def start_command_handler(message: types.Message,
target_user_id = int(admin_user_match.group(1))
target_user = await user_dal.get_user_by_id(session, target_user_id)
if not target_user:
await message.answer(
_("admin_user_not_found", input=hd.quote(str(target_user_id)))
)
await message.answer(_("admin_user_not_found", input=hd.quote(str(target_user_id))))
return
try:
from bot.handlers.admin.user_management import (
_send_with_profile_link_fallback,
format_user_card,
get_user_card_keyboard,
_send_with_profile_link_fallback,
)
referral_service = ReferralService(
settings, subscription_service, message.bot, i18n
)
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
user_card_text = await format_user_card(
target_user,
session,
@@ -468,7 +476,8 @@ async def start_command_handler(message: types.Message,
if settings.LEGACY_REFS:
potential_referrer_id = int(raw_ref_value)
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
session, potential_referrer_id):
session, potential_referrer_id
):
referred_by_user_id = potential_referrer_id
else:
normalized_code = raw_ref_value.strip()
@@ -476,8 +485,7 @@ async def start_command_handler(message: types.Message,
normalized_code = normalized_code[1:]
ref_user = None
if normalized_code:
ref_user = await user_dal.get_user_by_referral_code(
session, normalized_code)
ref_user = await user_dal.get_user_by_referral_code(session, normalized_code)
if ref_user and ref_user.user_id != user_id:
referred_by_user_id = ref_user.user_id
elif promo_match:
@@ -504,7 +512,7 @@ async def start_command_handler(message: types.Message,
"last_name": sanitized_last_name,
"language_code": current_lang,
"referred_by_id": referred_by_user_id,
"registration_date": datetime.now(timezone.utc)
"registration_date": datetime.now(timezone.utc),
}
try:
db_user, created = await user_dal.create_user(session, user_data_to_create)
@@ -531,11 +539,13 @@ async def start_command_handler(message: types.Message,
)
if referred_by_user_id and referral_welcome_days > 0:
try:
referral_bonus_end_date = await subscription_service.extend_active_subscription_days(
session,
user_id,
referral_welcome_days,
reason="referral_welcome_bonus",
referral_bonus_end_date = (
await subscription_service.extend_active_subscription_days(
session,
user_id,
referral_welcome_days,
reason="referral_welcome_bonus",
)
)
if referral_bonus_end_date:
await session.commit()
@@ -572,20 +582,18 @@ async def start_command_handler(message: types.Message,
# Send notification about new user registration
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(message.bot, settings, i18n)
await notification_service.notify_new_user_registration(
user_id=user_id,
username=sanitized_username,
first_name=sanitized_first_name,
referred_by_id=referred_by_user_id
referred_by_id=referred_by_user_id,
)
except Exception as e:
logging.error(f"Failed to send new user notification: {e}")
except Exception as e_create:
logging.error(
f"Failed to add new user {user_id} to session: {e_create}",
exc_info=True)
logging.error(f"Failed to add new user {user_id} to session: {e_create}", exc_info=True)
await message.answer(_("error_occurred_processing_request"))
return
else:
@@ -612,22 +620,23 @@ async def start_command_handler(message: types.Message,
try:
await user_dal.update_user(session, user_id, update_payload)
logging.info(
f"Updated existing user {user_id} in session: {update_payload}"
)
logging.info(f"Updated existing user {user_id} in session: {update_payload}")
except Exception as e_update:
logging.error(
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 _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}")
@@ -636,14 +645,12 @@ async def start_command_handler(message: types.Message,
except Exception:
pass
if not await ensure_required_channel_subscription(message, settings, i18n,
current_lang, session,
db_user):
if not await ensure_required_channel_subscription(
message, settings, i18n, current_lang, session, db_user
):
return
open_referral_page_for_existing_user = (
should_open_referral_from_start and is_existing_user
)
open_referral_page_for_existing_user = should_open_referral_from_start and is_existing_user
# Send welcome message if not disabled
if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user:
@@ -653,6 +660,7 @@ async def start_command_handler(message: types.Message,
if promo_code_to_apply:
try:
from bot.services.promo_code_service import PromoCodeService
promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n)
success, result = await promo_code_service.apply_promo_code(
@@ -664,7 +672,9 @@ async def start_command_handler(message: types.Message,
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
# Get updated subscription details
active = await subscription_service.get_active_subscription_details(session, user_id)
active = await subscription_service.get_active_subscription_details(
session, user_id
)
config_link_display = active.get("config_link") if active else None
connect_button_url = active.get("connect_button_url") if active else None
config_link_text = config_link_display or _("config_link_not_available")
@@ -673,11 +683,14 @@ async def start_command_handler(message: types.Message,
promo_success_text = _(
"promo_code_applied_success_full",
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
end_date=(
new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"
),
config_link=config_link_text,
)
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
await message.answer(
promo_success_text,
reply_markup=get_connect_and_main_keyboard(
@@ -687,75 +700,76 @@ async def start_command_handler(message: types.Message,
config_link_display,
connect_button_url=connect_button_url,
),
parse_mode="HTML"
parse_mode="HTML",
)
# Don't show main menu if promo was successfully applied
return
else:
await session.commit()
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
logging.warning(
f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}"
)
await message.answer(str(result), parse_mode="HTML")
# Continue to show main menu if promo failed
except Exception as e:
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
logging.error(
f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}"
)
await session.rollback()
if open_referral_page_for_existing_user:
from . import referral as user_referral_handlers
await user_referral_handlers.referral_command_handler(
message, settings, i18n_data, referral_service, message.bot, session
)
return
await send_main_menu(message,
settings,
i18n_data,
subscription_service,
session,
is_edit=False)
await send_main_menu(message, settings, i18n_data, subscription_service, session, is_edit=False)
@router.message(Command("tg"))
async def tg_interface_command_handler(message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession):
async def tg_interface_command_handler(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
if not await ensure_required_channel_subscription(message, settings, i18n,
current_lang, session,
db_user):
if not await ensure_required_channel_subscription(
message, settings, i18n, current_lang, session, db_user
):
return
await send_bot_interface_menu(message,
settings,
i18n_data,
subscription_service,
session,
is_edit=False)
await send_bot_interface_menu(
message, settings, i18n_data, subscription_service, session, is_edit=False
)
@router.callback_query(F.data == "channel_subscription:verify")
async def verify_channel_subscription_callback(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession):
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
db_user = await user_dal.get_user_by_id(session, callback.from_user.id)
verified = await ensure_required_channel_subscription(
callback, settings, i18n, current_lang, session, db_user)
callback, settings, i18n, current_lang, session, db_user
)
if not verified:
return
@@ -769,15 +783,13 @@ async def verify_channel_subscription_callback(
_ = lambda key, **kwargs: key
if not settings.DISABLE_WELCOME_MESSAGE:
welcome_text = _(key="welcome",
user_name=hd.quote(callback.from_user.full_name))
welcome_text = _(key="welcome", user_name=hd.quote(callback.from_user.full_name))
if callback.message:
await callback.message.answer(welcome_text)
else:
fallback_bot: Optional[Bot] = getattr(callback, "bot", None)
if fallback_bot:
await fallback_bot.send_message(callback.from_user.id,
welcome_text)
await fallback_bot.send_message(callback.from_user.id, welcome_text)
try:
await safe_answer_callback(
@@ -788,12 +800,9 @@ async def verify_channel_subscription_callback(
except Exception:
pass
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=bool(callback.message))
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=bool(callback.message)
)
@router.message(Command("language"))
@@ -806,8 +815,7 @@ async def language_command_handler(
):
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
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
text_to_send = _(key="choose_language")
reply_markup = get_language_selection_keyboard(
@@ -816,8 +824,7 @@ async def language_command_handler(
back_callback=back_callback,
)
target_message_obj = event.message if isinstance(
event, types.CallbackQuery) else event
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
if not target_message_obj:
if isinstance(event, types.CallbackQuery):
await safe_answer_callback(
@@ -830,21 +837,22 @@ async def language_command_handler(
if isinstance(event, types.CallbackQuery):
if event.message:
try:
await event.message.edit_text(text_to_send,
reply_markup=reply_markup)
await event.message.edit_text(text_to_send, reply_markup=reply_markup)
except Exception:
await target_message_obj.answer(text_to_send,
reply_markup=reply_markup)
await target_message_obj.answer(text_to_send, reply_markup=reply_markup)
await safe_answer_callback(event)
else:
await target_message_obj.answer(text_to_send,
reply_markup=reply_markup)
await target_message_obj.answer(text_to_send, reply_markup=reply_markup)
@router.callback_query(F.data.startswith("set_lang_"))
async def select_language_callback_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings,
subscription_service: SubscriptionService, session: AsyncSession):
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await safe_answer_callback(
@@ -867,15 +875,12 @@ async def select_language_callback_handler(
user_id = callback.from_user.id
try:
updated = await user_dal.update_user_language(session, user_id,
lang_code)
updated = await user_dal.update_user_language(session, user_id, lang_code)
if updated:
i18n_data["current_language"] = lang_code
_ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs)
await safe_answer_callback(callback, _(key="language_set_alert"))
logging.info(
f"User {user_id} language updated to {lang_code} in session.")
logging.info(f"User {user_id} language updated to {lang_code} in session.")
else:
await safe_answer_callback(
callback,
@@ -884,43 +889,41 @@ async def select_language_callback_handler(
)
return
except Exception as e_lang_update:
logging.error(
f"Error updating lang for user {user_id}: {e_lang_update}",
exc_info=True)
logging.error(f"Error updating lang for user {user_id}: {e_lang_update}", exc_info=True)
await safe_answer_callback(callback, "Error setting language.", show_alert=True)
return
if return_target == "bot":
await send_bot_interface_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
await send_bot_interface_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
else:
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
@router.callback_query(F.data.startswith("main_action:"))
async def main_action_callback_handler(
callback: types.CallbackQuery, state: FSMContext, settings: Settings,
i18n_data: dict, bot: Bot, subscription_service: SubscriptionService,
referral_service: ReferralService, panel_service: PanelApiService,
promo_code_service: PromoCodeService, session: AsyncSession):
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
bot: Bot,
subscription_service: SubscriptionService,
referral_service: ReferralService,
panel_service: PanelApiService,
promo_code_service: PromoCodeService,
session: AsyncSession,
):
action = callback.data.split(":")[1]
user_id = callback.from_user.id
if action in {"back_to_main", "back_to_main_keep", "bot_interface"}:
await state.clear()
from . import subscription as user_subscription_handlers
from . import referral as user_referral_handlers
from . import promo_user as user_promo_handlers
from . import referral as user_referral_handlers
from . import subscription as user_subscription_handlers
from . import trial_handler as user_trial_handlers
if not callback.message:
@@ -933,7 +936,8 @@ async def main_action_callback_handler(
if action == "subscribe":
await user_subscription_handlers.display_subscription_options(
callback, i18n_data, settings, session)
callback, i18n_data, settings, session
)
elif action == "bot_subscribe":
await user_subscription_handlers.display_subscription_options(
callback,
@@ -944,8 +948,8 @@ async def main_action_callback_handler(
)
elif action == "my_subscription":
await user_subscription_handlers.my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service,
session, bot)
callback, i18n_data, settings, panel_service, subscription_service, session, bot
)
elif action == "bot_my_subscription":
await user_subscription_handlers.my_subscription_command_handler(
callback,
@@ -959,11 +963,12 @@ async def main_action_callback_handler(
)
elif action == "my_devices":
await user_subscription_handlers.my_devices_command_handler(
callback, i18n_data, settings, panel_service, subscription_service,
session, bot)
callback, i18n_data, settings, panel_service, subscription_service, session, bot
)
elif action == "referral":
await user_referral_handlers.referral_command_handler(
callback, settings, i18n_data, referral_service, bot, session)
callback, settings, i18n_data, referral_service, bot, session
)
elif action == "bot_referral":
await user_referral_handlers.referral_command_handler(
callback,
@@ -976,7 +981,8 @@ async def main_action_callback_handler(
)
elif action == "apply_promo":
await user_promo_handlers.prompt_promo_code_input(
callback, state, i18n_data, settings, session)
callback, state, i18n_data, settings, session
)
elif action == "bot_apply_promo":
await user_promo_handlers.prompt_promo_code_input(
callback,
@@ -988,9 +994,9 @@ async def main_action_callback_handler(
)
elif action == "request_trial":
await user_trial_handlers.request_trial_confirmation_handler(
callback, settings, i18n_data, subscription_service, session)
callback, settings, i18n_data, subscription_service, session
)
elif action == "language":
await language_command_handler(callback, i18n_data, settings)
elif action == "bot_language":
await language_command_handler(
@@ -1000,16 +1006,12 @@ async def main_action_callback_handler(
back_callback="main_action:bot_interface",
)
elif action == "bot_interface":
await send_bot_interface_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
await send_bot_interface_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
elif action in {"info", "bot_info"}:
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language",
settings.DEFAULT_LANGUAGE)
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n:
await safe_answer_callback(
callback,
@@ -1017,8 +1019,7 @@ async def main_action_callback_handler(
show_alert=True,
)
return
_ = lambda key, **kwargs: i18n.gettext(
current_lang, key, **kwargs) if i18n else key
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
privacy_url = settings.PRIVACY_POLICY_URL
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
@@ -1043,30 +1044,23 @@ async def main_action_callback_handler(
),
)
try:
await callback.message.edit_text(_(key="info_links_message"),
reply_markup=reply_markup)
await callback.message.edit_text(_(key="info_links_message"), reply_markup=reply_markup)
except Exception:
await callback.message.answer(_(key="info_links_message"),
reply_markup=reply_markup)
await callback.message.answer(_(key="info_links_message"), reply_markup=reply_markup)
await safe_answer_callback(callback)
elif action == "back_to_main":
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
elif action == "back_to_main_keep":
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=False)
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=False
)
else:
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(
i18n_data.get("current_language"), key, **kwargs) if i18n else key
_ = lambda key, **kwargs: (
i18n.gettext(i18n_data.get("current_language"), key, **kwargs) if i18n else key
)
await safe_answer_callback(
callback,
_("main_menu_unknown_action"),
+6 -6
View File
@@ -1,8 +1,6 @@
from aiogram import Router
from . import core
from . import payments
from . import payment_methods
from . import core, payment_methods, payments
router = Router(name="user_subscription_router")
@@ -12,6 +10,8 @@ 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, my_devices_command_handler # noqa: E402,F401
from .core import ( # noqa: E402,F401
display_subscription_options,
my_devices_command_handler,
my_subscription_command_handler,
)
+418 -134
View File
@@ -1,29 +1,29 @@
import hashlib
import html
import logging
from aiogram import Router, F, types, Bot
from datetime import datetime
from typing import Optional, Union
from aiogram import Bot, F, Router, types
from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
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,
get_tariff_catalog_keyboard,
get_tariff_periods_keyboard,
get_tariff_packages_keyboard,
get_payment_method_keyboard,
get_back_to_main_menu_markup,
get_hwid_device_packages_keyboard,
get_payment_method_keyboard,
get_subscription_options_keyboard,
get_tariff_catalog_keyboard,
get_tariff_packages_keyboard,
get_tariff_periods_keyboard,
)
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from db.dal import subscription_dal, user_billing_dal
from db.models import Subscription
@@ -55,7 +55,9 @@ def _has_multiple_enabled_tariffs(settings: Settings) -> bool:
return len(_enabled_tariffs(settings)) > 1
def _tariff_purchase_markup(tariff, current_lang: str, i18n: JsonI18n, settings: Settings) -> InlineKeyboardMarkup:
def _tariff_purchase_markup(
tariff, current_lang: str, i18n: JsonI18n, settings: Settings
) -> InlineKeyboardMarkup:
if tariff.billing_model == "period":
return get_tariff_periods_keyboard(tariff, current_lang, i18n, settings)
return get_tariff_packages_keyboard(tariff, tariff.traffic_packages.rub, current_lang, i18n)
@@ -130,7 +132,11 @@ async def display_subscription_options(
options = settings.subscription_options
if options:
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
text_content = (
get_text("select_traffic_package")
if traffic_mode
else get_text("select_subscription_period")
)
reply_markup = get_subscription_options_keyboard(
options,
currency_symbol_val,
@@ -170,12 +176,16 @@ async def display_subscription_options(
@router.callback_query(F.data == "main_action:subscribe")
async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
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)
@router.callback_query(F.data.startswith("tariff:select:"))
async def select_tariff_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def select_tariff_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
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)
@@ -196,7 +206,9 @@ async def select_tariff_callback(callback: types.CallbackQuery, i18n_data: dict,
@router.callback_query(F.data.startswith("tariff:period:"))
async def select_tariff_period_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def select_tariff_period_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
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)
@@ -227,7 +239,9 @@ async def select_tariff_period_callback(callback: types.CallbackQuery, i18n_data
@router.callback_query(F.data.startswith("tariff:package:"))
async def select_tariff_package_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def select_tariff_package_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
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)
@@ -238,12 +252,18 @@ async def select_tariff_package_callback(callback: types.CallbackQuery, i18n_dat
_, _, tariff_key, gb_raw = callback.data.split(":", 3)
tariff = config.require(tariff_key)
gb = float(gb_raw)
packages = tariff.traffic_packages.rub if tariff.billing_model == "traffic" else (config.topup_packages_for(tariff).rub if config.topup_packages_for(tariff) else [])
packages = (
tariff.traffic_packages.rub
if tariff.billing_model == "traffic"
else (config.topup_packages_for(tariff).rub if config.topup_packages_for(tariff) else [])
)
package = next((pkg for pkg in packages if float(pkg.gb) == gb), None)
if not package:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
sale_mode = f"{'traffic_package' if tariff.billing_model == 'traffic' else 'topup'}@{tariff.key}"
sale_mode = (
f"{'traffic_package' if tariff.billing_model == 'traffic' else 'topup'}@{tariff.key}"
)
markup = get_payment_method_keyboard(
gb,
package.price,
@@ -259,12 +279,20 @@ async def select_tariff_package_callback(callback: types.CallbackQuery, i18n_dat
@router.callback_query(F.data == "tariff_topup:list")
async def tariff_topup_list_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession):
async def tariff_topup_list_callback(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
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)
config = settings.tariffs_config
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
active = await subscription_service.get_active_subscription_details(
session, callback.from_user.id
)
if not config or not active or not active.get("tariff_key") or not callback.message:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
@@ -291,14 +319,24 @@ async def tariff_topup_list_callback(callback: types.CallbackQuery, i18n_data: d
callback_data=f"tariff:premium_package:{tariff.key}:{package.gb:g}",
)
)
builder.row(InlineKeyboardButton(text=get_text("back_to_main_menu_button"), callback_data="main_action:my_subscription"))
builder.row(
InlineKeyboardButton(
text=get_text("back_to_main_menu_button"), callback_data="main_action:my_subscription"
)
)
premium_lines = []
carryover_lines = []
if rub_packages or premium_packages:
carryover_lines.append("Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток.")
carryover_lines.append(
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток."
)
if int(active.get("premium_limit_bytes") or 0) > 0:
premium_left = max(0, int(active.get("premium_limit_bytes") or 0) - int(active.get("premium_used_bytes") or 0))
premium_left = max(
0,
int(active.get("premium_limit_bytes") or 0)
- int(active.get("premium_used_bytes") or 0),
)
labels = active.get("premium_node_labels") or active.get("premium_squad_labels") or []
if labels:
visible = [str(label) for label in labels[:8]]
@@ -319,7 +357,9 @@ async def tariff_topup_list_callback(callback: types.CallbackQuery, i18n_data: d
@router.callback_query(F.data.startswith("tariff:premium_package:"))
async def select_tariff_premium_package_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def select_tariff_premium_package_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
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)
@@ -350,12 +390,20 @@ async def select_tariff_premium_package_callback(callback: types.CallbackQuery,
@router.callback_query(F.data == "hwid_devices:list")
async def hwid_devices_list_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession):
async def hwid_devices_list_callback(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
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)
config = settings.tariffs_config
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
active = await subscription_service.get_active_subscription_details(
session, callback.from_user.id
)
if not config or not active or not active.get("tariff_key") or not callback.message:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
@@ -381,7 +429,9 @@ async def hwid_devices_list_callback(callback: types.CallbackQuery, i18n_data: d
@router.callback_query(F.data.startswith("hwid_devices:package:"))
async def hwid_devices_package_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def hwid_devices_package_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
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)
@@ -393,7 +443,11 @@ async def hwid_devices_package_callback(callback: types.CallbackQuery, i18n_data
tariff = config.require(tariff_key)
count = int(count_raw)
package = next(
(pkg for pkg in (tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []) if int(pkg.count) == count),
(
pkg
for pkg in (tariff.hwid_device_packages.rub if tariff.hwid_device_packages else [])
if int(pkg.count) == count
),
None,
)
if not package:
@@ -409,34 +463,68 @@ async def hwid_devices_package_callback(callback: types.CallbackQuery, i18n_data
settings,
sale_mode=f"hwid_devices@{tariff.key}",
)
await callback.message.edit_text(get_text("choose_payment_method_hwid_devices"), reply_markup=markup)
await callback.message.edit_text(
get_text("choose_payment_method_hwid_devices"), reply_markup=markup
)
await callback.answer()
@router.callback_query(F.data == "tariff_change:list")
async def tariff_change_list_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession):
async def tariff_change_list_callback(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
config = settings.tariffs_config
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
active = await subscription_service.get_active_subscription_details(
session, callback.from_user.id
)
if not config or not active or not callback.message:
await callback.answer("Error", show_alert=True)
return
if len(config.enabled_tariffs) <= 1:
await callback.answer("Смена тарифа недоступна: сейчас включен только один тариф.", show_alert=True)
await callback.answer(
"Смена тарифа недоступна: сейчас включен только один тариф.", show_alert=True
)
return
rows = []
for tariff in config.enabled_tariffs:
if tariff.key == active.get("tariff_key"):
continue
rows.append([InlineKeyboardButton(text=tariff.name(current_lang), callback_data=f"tariff_change:select:{tariff.key}")])
rows.append([InlineKeyboardButton(text=i18n.gettext(current_lang, "back_to_main_menu_button"), callback_data="main_action:my_subscription")])
await callback.message.edit_text("Выберите тариф", reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
rows.append(
[
InlineKeyboardButton(
text=tariff.name(current_lang),
callback_data=f"tariff_change:select:{tariff.key}",
)
]
)
rows.append(
[
InlineKeyboardButton(
text=i18n.gettext(current_lang, "back_to_main_menu_button"),
callback_data="main_action:my_subscription",
)
]
)
await callback.message.edit_text(
"Выберите тариф", reply_markup=InlineKeyboardMarkup(inline_keyboard=rows)
)
await callback.answer()
@router.callback_query(F.data.startswith("tariff_change:select:"))
async def tariff_change_select_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession):
async def tariff_change_select_callback(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
config = settings.tariffs_config
@@ -445,32 +533,85 @@ async def tariff_change_select_callback(callback: types.CallbackQuery, i18n_data
return
tariff_key = callback.data.split(":", 2)[2]
target = config.require(tariff_key)
db_sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
db_sub = await subscription_dal.get_active_subscription_by_user_id(
session, callback.from_user.id
)
if not db_sub:
await callback.answer("Error", show_alert=True)
return
options = subscription_service.calculate_tariff_switch_options(db_sub, target)
rows = []
if options["mode"] == "period_to_period":
rows.append([InlineKeyboardButton(text=f"Без доплаты, дней станет {options['recalc_days']}", callback_data=f"tariff_change:confirm_apply:{target.key}:recalc_days")])
rows.append(
[
InlineKeyboardButton(
text=f"Без доплаты, дней станет {options['recalc_days']}",
callback_data=f"tariff_change:confirm_apply:{target.key}:recalc_days",
)
]
)
if options.get("paid_diff_rub", 0) > 0:
rows.append([InlineKeyboardButton(text=f"Доплатить {options['paid_diff_rub']} RUB", callback_data=f"tariff_change:confirm_pay:{target.key}:{options['paid_diff_rub']}")])
rows.append(
[
InlineKeyboardButton(
text=f"Доплатить {options['paid_diff_rub']} RUB",
callback_data=f"tariff_change:confirm_pay:{target.key}:{options['paid_diff_rub']}",
)
]
)
elif options["mode"] == "period_to_traffic":
rows.append([InlineKeyboardButton(text=f"Перейти без доплаты, получить {options['converted_gb']} GB", callback_data=f"tariff_change:confirm_apply:{target.key}:convert_days_to_gb")])
rows.append(
[
InlineKeyboardButton(
text=f"Перейти без доплаты, получить {options['converted_gb']} GB",
callback_data=f"tariff_change:confirm_apply:{target.key}:convert_days_to_gb",
)
]
)
for package in target.traffic_packages.rub:
rows.append([InlineKeyboardButton(text=f"+ {package.gb:g} GB за {package.price:g} RUB", callback_data=f"tariff:package:{target.key}:{package.gb:g}")])
rows.append(
[
InlineKeyboardButton(
text=f"+ {package.gb:g} GB за {package.price:g} RUB",
callback_data=f"tariff:package:{target.key}:{package.gb:g}",
)
]
)
else:
for months in target.enabled_periods:
price = target.period_price(months, "rub")
if price:
rows.append([InlineKeyboardButton(text=f"{months} мес. за {price:g} RUB", callback_data=f"tariff:period:{target.key}:{months}")])
rows.append([InlineKeyboardButton(text=i18n.gettext(current_lang, "back_to_main_menu_button"), callback_data="tariff_change:list")])
await callback.message.edit_text(f"{target.name(current_lang)}\n{target.description(current_lang)}".strip(), reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
rows.append(
[
InlineKeyboardButton(
text=f"{months} мес. за {price:g} RUB",
callback_data=f"tariff:period:{target.key}:{months}",
)
]
)
rows.append(
[
InlineKeyboardButton(
text=i18n.gettext(current_lang, "back_to_main_menu_button"),
callback_data="tariff_change:list",
)
]
)
await callback.message.edit_text(
f"{target.name(current_lang)}\n{target.description(current_lang)}".strip(),
reply_markup=InlineKeyboardMarkup(inline_keyboard=rows),
)
await callback.answer()
@router.callback_query(F.data.startswith("tariff_change:confirm_apply:"))
async def tariff_change_confirm_apply_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession):
async def tariff_change_confirm_apply_callback(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
config = settings.tariffs_config
@@ -479,7 +620,9 @@ async def tariff_change_confirm_apply_callback(callback: types.CallbackQuery, i1
return
_, _, tariff_key, mode = callback.data.split(":", 3)
target = config.require(tariff_key)
db_sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
db_sub = await subscription_dal.get_active_subscription_by_user_id(
session, callback.from_user.id
)
if not db_sub:
await callback.answer("Error", show_alert=True)
return
@@ -491,8 +634,17 @@ async def tariff_change_confirm_apply_callback(callback: types.CallbackQuery, i1
else:
action_text = "тариф будет изменен без доплаты"
rows = [
[InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"tariff_change:apply:{target.key}:{mode}")],
[InlineKeyboardButton(text=i18n.gettext(current_lang, "back_to_main_menu_button"), callback_data=f"tariff_change:select:{target.key}")],
[
InlineKeyboardButton(
text="✅ Подтвердить", callback_data=f"tariff_change:apply:{target.key}:{mode}"
)
],
[
InlineKeyboardButton(
text=i18n.gettext(current_lang, "back_to_main_menu_button"),
callback_data=f"tariff_change:select:{target.key}",
)
],
]
await callback.message.edit_text(
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nИзменение: {action_text}",
@@ -502,7 +654,9 @@ async def tariff_change_confirm_apply_callback(callback: types.CallbackQuery, i1
@router.callback_query(F.data.startswith("tariff_change:confirm_pay:"))
async def tariff_change_confirm_pay_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings):
async def tariff_change_confirm_pay_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
config = settings.tariffs_config
@@ -512,8 +666,18 @@ async def tariff_change_confirm_pay_callback(callback: types.CallbackQuery, i18n
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
target = config.require(tariff_key)
rows = [
[InlineKeyboardButton(text="✅ Подтвердить и оплатить", callback_data=f"tariff_change:pay:{target.key}:{amount_raw}")],
[InlineKeyboardButton(text=i18n.gettext(current_lang, "back_to_main_menu_button"), callback_data=f"tariff_change:select:{target.key}")],
[
InlineKeyboardButton(
text="✅ Подтвердить и оплатить",
callback_data=f"tariff_change:pay:{target.key}:{amount_raw}",
)
],
[
InlineKeyboardButton(
text=i18n.gettext(current_lang, "back_to_main_menu_button"),
callback_data=f"tariff_change:select:{target.key}",
)
],
]
await callback.message.edit_text(
f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.",
@@ -523,19 +687,37 @@ async def tariff_change_confirm_pay_callback(callback: types.CallbackQuery, i18n
@router.callback_query(F.data.startswith("tariff_change:apply:"))
async def tariff_change_apply_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, subscription_service: SubscriptionService, session: AsyncSession):
async def tariff_change_apply_callback(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
subscription_service: SubscriptionService,
session: AsyncSession,
):
_, _, tariff_key, mode = callback.data.split(":", 3)
result = await subscription_service.switch_tariff_without_payment(session, callback.from_user.id, tariff_key, mode)
result = await subscription_service.switch_tariff_without_payment(
session, callback.from_user.id, tariff_key, mode
)
if result:
await session.commit()
await callback.answer("Готово", show_alert=True)
await my_subscription_command_handler(callback, i18n_data, settings, subscription_service.panel_service, subscription_service, session, callback.bot)
await my_subscription_command_handler(
callback,
i18n_data,
settings,
subscription_service.panel_service,
subscription_service,
session,
callback.bot,
)
else:
await callback.answer("Error", show_alert=True)
@router.callback_query(F.data.startswith("tariff_change:pay:"))
async def tariff_change_pay_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
async def tariff_change_pay_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
_, _, tariff_key, amount_raw = callback.data.split(":", 3)
@@ -613,6 +795,7 @@ async def my_subscription_command_handler(
config_link_display = active.get("config_link")
connect_button_url = active.get("connect_button_url")
config_link_value = config_link_display or get_text("config_link_not_available")
def _fmt_gb(val: Optional[float]) -> str:
if val is None:
return get_text("traffic_na")
@@ -623,6 +806,7 @@ async def my_subscription_command_handler(
except Exception:
pass
return str(val)
def _format_traffic_period(strategy: Optional[str]) -> Optional[str]:
if not strategy:
return None
@@ -639,14 +823,18 @@ async def my_subscription_command_handler(
def _format_used_with_period(used_display: str, period_label: Optional[str]) -> str:
if not period_label:
return used_display
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
return get_text(
"traffic_used_with_period", traffic_used=used_display, traffic_period=period_label
)
period_label = _format_traffic_period(active.get("traffic_limit_strategy"))
period_label = period_label or get_text("traffic_period_unknown")
if traffic_mode:
limit_display = _fmt_gb(active.get("traffic_limit_bytes"))
used_display = _format_used_with_period(_fmt_gb(active.get("traffic_used_bytes")), period_label)
used_display = _format_used_with_period(
_fmt_gb(active.get("traffic_used_bytes")), period_label
)
remaining_display = get_text("traffic_na")
try:
limit_val = active.get("traffic_limit_bytes") or 0
@@ -677,10 +865,16 @@ async def my_subscription_command_handler(
days_left=max(0, days_left),
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
config_link=config_link_value,
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
traffic_limit=(
f"{active['traffic_limit_bytes'] / 2**30:.2f} GB"
if active.get("traffic_limit_bytes")
else get_text("traffic_unlimited")
),
traffic_used=(
_format_used_with_period(
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na"),
f"{active['traffic_used_bytes'] / 2**30:.2f} GB"
if active.get("traffic_used_bytes") is not None
else get_text("traffic_na"),
period_label,
)
),
@@ -721,26 +915,32 @@ async def my_subscription_command_handler(
)
kb = base_markup.inline_keyboard
try:
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
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) Connect button: prefer the actual subscription URL; fall back to mini-app
cfg_link_val = connect_button_url or config_link_display
if cfg_link_val:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("connect_button"),
url=cfg_link_val,
)
])
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("connect_button"),
url=cfg_link_val,
)
]
)
elif settings.SUBSCRIPTION_MINI_APP_URL:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("connect_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
])
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("connect_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
]
)
if settings.MY_DEVICES_SECTION_ENABLED:
max_devices_value = active.get("max_devices")
@@ -786,47 +986,69 @@ async def my_subscription_command_handler(
current_devices=current_devices_display,
max_devices=max_devices_display,
)
prepend_rows.append([
InlineKeyboardButton(
text=devices_button_text,
callback_data="main_action:my_devices",
)
])
prepend_rows.append(
[
InlineKeyboardButton(
text=devices_button_text,
callback_data="main_action:my_devices",
)
]
)
if settings.tariffs_config and local_sub and local_sub.tariff_key:
try:
tariff_for_devices = settings.tariffs_config.require(local_sub.tariff_key)
if tariff_for_devices.hwid_device_packages and tariff_for_devices.hwid_device_packages.rub:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("buy_hwid_devices_menu_button"),
callback_data="hwid_devices:list",
)
])
if (
tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
):
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("buy_hwid_devices_menu_button"),
callback_data="hwid_devices:list",
)
]
)
except Exception:
pass
# 2) Auto-renew toggle (YooKassa only)
if not traffic_mode and local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active:
if (
not traffic_mode
and local_sub
and local_sub.provider == "yookassa"
and settings.yookassa_autopayments_active
):
toggle_text = (
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
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}",
)
]
)
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 not traffic_mode and settings.yookassa_autopayments_active:
prepend_rows.append([
InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")
])
prepend_rows.append(
[
InlineKeyboardButton(
text=get_text("payment_methods_manage_button"), callback_data="pm:manage"
)
]
)
if settings.tariffs_config and local_sub and local_sub.tariff_key:
tariff_actions = []
if _has_multiple_enabled_tariffs(settings):
tariff_actions.append(InlineKeyboardButton(text="Сменить тариф", callback_data="tariff_change:list"))
tariff_actions.append(
InlineKeyboardButton(text="Сменить тариф", callback_data="tariff_change:list")
)
try:
tariff = settings.tariffs_config.require(local_sub.tariff_key)
topup_packages = settings.tariffs_config.topup_packages_for(tariff)
@@ -837,7 +1059,9 @@ async def my_subscription_command_handler(
except Exception:
has_topup_packages = False
if has_topup_packages:
tariff_actions.append(InlineKeyboardButton(text="Докупить трафик", callback_data="tariff_topup:list"))
tariff_actions.append(
InlineKeyboardButton(text="Докупить трафик", callback_data="tariff_topup:list")
)
if tariff_actions:
prepend_rows.append(tariff_actions)
@@ -853,7 +1077,9 @@ async def my_subscription_command_handler(
except Exception:
pass
try:
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
await event.message.edit_text(
text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True
)
except Exception:
await bot.send_message(
chat_id=target.chat.id,
@@ -863,7 +1089,9 @@ async def my_subscription_command_handler(
disable_web_page_preview=True,
)
else:
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
await target.answer(
text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True
)
@router.callback_query(F.data == "main_action:my_devices")
@@ -942,46 +1170,79 @@ async def my_devices_command_handler(
devices_list = []
current_devices = len(devices_list_raw)
for index, device in enumerate(devices_list_raw, start=1):
device_model = device.get('deviceModel') or None
platform = device.get('platform') or None
user_agent = device.get('userAgent') or None
os_version = device.get('osVersion') or None
created_at = device.get('createdAt')
hwid = device.get('hwid')
device_model = device.get("deviceModel") or None
platform = device.get("platform") or None
user_agent = device.get("userAgent") or None
os_version = device.get("osVersion") or None
created_at = device.get("createdAt")
hwid = device.get("hwid")
try:
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M") if created_at else "-"
created_at_str = (
datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M")
if created_at
else "-"
)
except Exception:
created_at_str = str(created_at)
device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid)
device_details = get_text(
"device_details",
index=index,
device_model=device_model,
platform=platform,
os_version=os_version,
created_at_str=created_at_str,
user_agent=user_agent,
hwid=hwid,
)
devices_list.append(device_details)
text = get_text("my_devices_details", devices="\n\n".join(devices_list), current_devices=current_devices, max_devices=max_devices_display)
text = get_text(
"my_devices_details",
devices="\n\n".join(devices_list),
current_devices=current_devices,
max_devices=max_devices_display,
)
base_markup = get_back_to_main_menu_markup(current_lang, i18n, callback_data="main_action:my_subscription")
base_markup = get_back_to_main_menu_markup(
current_lang, i18n, callback_data="main_action:my_subscription"
)
kb = base_markup.inline_keyboard
devices_kb = []
if settings.tariffs_config and active.get("tariff_key") and max_devices_value != 0:
try:
tariff_for_devices = settings.tariffs_config.require(active["tariff_key"])
if tariff_for_devices.hwid_device_packages and tariff_for_devices.hwid_device_packages.rub:
devices_kb.append([
InlineKeyboardButton(
text=get_text("buy_hwid_devices_menu_button"),
callback_data="hwid_devices:list",
)
])
if (
tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
):
devices_kb.append(
[
InlineKeyboardButton(
text=get_text("buy_hwid_devices_menu_button"),
callback_data="hwid_devices:list",
)
]
)
except Exception:
pass
for index, device in enumerate(devices_list_raw, start=1):
hwid = device.get('hwid')
hwid = device.get("hwid")
if not hwid:
continue
device_button_text = get_text("disconnect_device_button", hwid=_shorten_hwid_for_display(hwid), index=index)
device_button_text = get_text(
"disconnect_device_button", hwid=_shorten_hwid_for_display(hwid), index=index
)
hwid_token = _hwid_callback_token(hwid)
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid_token}")])
devices_kb.append(
[
InlineKeyboardButton(
text=device_button_text, callback_data=f"disconnect_device:{hwid_token}"
)
]
)
kb = devices_kb + kb
markup = InlineKeyboardMarkup(inline_keyboard=kb)
@@ -1028,7 +1289,9 @@ async def disconnect_device_handler(
pass
return
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
active = await subscription_service.get_active_subscription_details(
session, callback.from_user.id
)
if not active or not active.get("user_id"):
await callback.answer(get_text("subscription_not_active"), show_alert=True)
return
@@ -1064,7 +1327,9 @@ async def disconnect_device_handler(
await callback.answer(get_text("device_disconnected"))
except Exception:
pass
await my_devices_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
await my_devices_command_handler(
callback, i18n_data, settings, panel_service, subscription_service, session, bot
)
@router.callback_query(F.data.startswith("toggle_autorenew:"))
@@ -1101,7 +1366,9 @@ async def toggle_autorenew_handler(
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if enable:
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
has_saved_card = await user_billing_dal.user_has_saved_payment_method(
session, callback.from_user.id
)
if not has_saved_card:
try:
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
@@ -1110,7 +1377,9 @@ async def toggle_autorenew_handler(
return
# Show confirmation popup and inline buttons
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
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)
@@ -1159,25 +1428,33 @@ async def confirm_autorenew_handler(
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if enable:
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
has_saved_card = await user_billing_dal.user_has_saved_payment_method(
session, callback.from_user.id
)
if not has_saved_card:
try:
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
except Exception:
pass
try:
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
await my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service, session, bot
)
except Exception:
pass
return
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
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)
await my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service, session, bot
)
@router.callback_query(F.data == "autorenew:cancel")
@@ -1196,6 +1473,7 @@ async def autorenew_cancel_from_webhook_button(
# Disable auto-renew on the active subscription
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:
@@ -1209,13 +1487,17 @@ async def autorenew_cancel_from_webhook_button(
except Exception:
pass
return
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": False})
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)
await my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service, session, bot
)
@router.message(Command("connect"))
@@ -1229,4 +1511,6 @@ async def connect_command_handler(
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)
await my_subscription_command_handler(
message, i18n_data, settings, panel_service, subscription_service, session, bot
)
+126 -40
View File
@@ -1,25 +1,28 @@
from aiogram import Router, F, types
from typing import Optional, List
from typing import List, Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from config.settings import Settings
from bot.keyboards.inline.user_keyboards import (
get_payment_methods_list_keyboard,
get_bind_url_keyboard,
get_payment_method_delete_confirm_keyboard,
get_payment_method_details_keyboard,
get_bind_url_keyboard,
get_payment_methods_list_keyboard,
)
from bot.services.yookassa_service import YooKassaService
from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
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):
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 settings.yookassa_autopayments_active:
@@ -32,6 +35,7 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
_ = 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] = []
@@ -64,7 +68,9 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
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))
await callback.message.edit_text(
text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)
)
try:
await callback.answer()
except Exception:
@@ -72,7 +78,13 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin
@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):
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 settings.yookassa_autopayments_active:
@@ -98,7 +110,10 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
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))
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:
@@ -106,7 +121,9 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings,
@router.callback_query(F.data.startswith("pm:delete_confirm"))
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
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 settings.yookassa_autopayments_active:
@@ -119,7 +136,10 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
_ = 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))
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:
@@ -127,7 +147,9 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings:
@router.callback_query(F.data.startswith("pm:delete"))
async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
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 settings.yookassa_autopayments_active:
@@ -148,13 +170,20 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
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))
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)
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)
legacy_deleted = await user_billing_dal.delete_yk_payment_method(
session, callback.from_user.id
)
deleted = deleted or legacy_deleted
except Exception:
pass
@@ -164,12 +193,15 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
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 "")
@@ -181,12 +213,16 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic")
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))
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:
@@ -201,7 +237,9 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting
@router.callback_query(F.data.startswith("pm:view"))
async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
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 settings.yookassa_autopayments_active:
@@ -216,13 +254,21 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
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])
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()
@@ -245,15 +291,15 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
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 ""
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',
Payment.status == "succeeded",
Payment.provider == "yookassa",
)
.order_by(Payment.created_at.desc())
.limit(1)
@@ -261,26 +307,33 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
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')
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))
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 ""
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',
Payment.status == "succeeded",
Payment.provider == "yookassa",
)
.order_by(Payment.created_at.desc())
.limit(1)
@@ -288,7 +341,7 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
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')
last_tx = last_payment.created_at.strftime("%Y-%m-%d")
except Exception:
pass
@@ -314,7 +367,12 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
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))
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:
@@ -322,7 +380,13 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings,
@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):
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 settings.yookassa_autopayments_active:
@@ -335,6 +399,7 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
_ = 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]
@@ -346,6 +411,7 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
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:
@@ -362,7 +428,7 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
if selected_pm_provider_id:
filtered: List[Payment] = []
for p in user_payments:
if p.provider != 'yookassa':
if p.provider != "yookassa":
continue
if p.yookassa_payment_id and yookassa_service:
try:
@@ -376,7 +442,11 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
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
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)
@@ -395,11 +465,15 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
def _format_item(p: Payment) -> str:
if traffic_mode:
units_val = p.subscription_duration_months or 0
units_display = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
units_display = (
str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
)
title = p.description or _("traffic_purchase_title", traffic_gb=units_display)
else:
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"
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]
@@ -408,7 +482,11 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
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
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
@@ -418,21 +496,27 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin
@router.callback_query(F.data.startswith("pm:list:"))
async def payment_methods_list(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
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 "")
@@ -444,6 +528,7 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic")
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}"))
@@ -456,9 +541,10 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings
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))
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
@@ -1,4 +1,4 @@
from typing import Optional
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,7 +21,7 @@ async def pay_crypto_callback_handler(
):
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)
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
@@ -30,7 +30,11 @@ async def pay_crypto_callback_handler(
pass
return
if not settings.CRYPTOPAY_ENABLED or not cryptopay_service or not getattr(cryptopay_service, "configured", False):
if (
not settings.CRYPTOPAY_ENABLED
or 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:
@@ -56,7 +60,11 @@ async def pay_crypto_callback_handler(
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months)))
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
invoice_url = await cryptopay_service.create_invoice(
@@ -72,7 +80,9 @@ async def pay_crypto_callback_handler(
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -89,7 +99,9 @@ async def pay_crypto_callback_handler(
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -1,4 +1,4 @@
import logging
import logging
from datetime import datetime
from typing import Optional
@@ -65,9 +65,17 @@ async def pay_fk_callback_handler(
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months)))
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = (
getattr(freekassa_service, "default_currency", None)
or settings.DEFAULT_CURRENCY_SYMBOL
or "RUB"
)
currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_record_payload = {
"user_id": user_id,
@@ -79,8 +87,12 @@ async def pay_fk_callback_handler(
"provider": "freekassa",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
"purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
@@ -138,7 +150,9 @@ async def pay_fk_callback_handler(
)
if location:
order_identifier_display = str(order_id_api or provider_identifier or payment_record.payment_id)
order_identifier_display = str(
order_id_api or provider_identifier or payment_record.payment_id
)
order_info_text = get_text(
"free_kassa_order_info",
order_id=order_identifier_display,
@@ -146,8 +160,11 @@ async def pay_fk_callback_handler(
)
try:
await callback.message.edit_text(
f"{order_info_text}\n\n" + get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
f"{order_info_text}\n\n"
+ get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -161,11 +178,16 @@ async def pay_fk_callback_handler(
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.")
logging.warning(
f"FreeKassa: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
f"{order_info_text}\n\n" + get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
f"{order_info_text}\n\n"
+ get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -207,7 +229,10 @@ async def pay_fk_callback_handler(
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
logging.error(
f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
@@ -1,4 +1,4 @@
import json
import json
import logging
from typing import Optional
@@ -92,7 +92,11 @@ async def pay_platega_callback_handler(
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months)))
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
@@ -106,8 +110,12 @@ async def pay_platega_callback_handler(
"provider": "platega",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
"purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
@@ -178,7 +186,9 @@ async def pay_platega_callback_handler(
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -192,11 +202,15 @@ async def pay_platega_callback_handler(
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(f"Platega: failed to display payment link ({e_edit}), sending new message.")
logging.warning(
f"Platega: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -232,7 +246,10 @@ async def pay_platega_callback_handler(
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
logging.error(
f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
@@ -1,4 +1,4 @@
import logging
import logging
from typing import Optional
from aiogram import F, Router, types
@@ -64,7 +64,11 @@ async def pay_severpay_callback_handler(
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months)))
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
@@ -78,8 +82,12 @@ async def pay_severpay_callback_handler(
"provider": "severpay",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
"purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
@@ -138,7 +146,9 @@ async def pay_severpay_callback_handler(
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -152,11 +162,15 @@ async def pay_severpay_callback_handler(
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(f"SeverPay: failed to display payment link ({e_edit}), sending new message.")
logging.warning(
f"SeverPay: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
@@ -192,7 +206,10 @@ async def pay_severpay_callback_handler(
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
logging.error(
f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
@@ -1,4 +1,4 @@
import logging
import logging
from typing import Optional
from aiogram import F, Router, types
@@ -22,7 +22,7 @@ async def pay_stars_callback_handler(
):
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)
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
@@ -57,7 +57,11 @@ async def pay_stars_callback_handler(
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months)))
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
payment_db_id = await stars_service.create_invoice(
@@ -73,16 +77,22 @@ async def pay_stars_callback_handler(
try:
await callback.message.edit_text(
get_text(
"payment_invoice_sent_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_invoice_sent_message",
"payment_invoice_sent_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_invoice_sent_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=get_text("back_to_payment_methods_button"),
callback_data=f"subscribe_period:{human_value}",
)]
]),
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=get_text("back_to_payment_methods_button"),
callback_data=f"subscribe_period:{human_value}",
)
]
]
),
)
except Exception as e_edit:
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
@@ -115,8 +125,9 @@ async def handle_successful_stars_payment(
session: AsyncSession,
stars_service: StarsService,
):
payload = (message.successful_payment.invoice_payload
if message and message.successful_payment else "")
payload = (
message.successful_payment.invoice_payload if message and message.successful_payment else ""
)
try:
parts = (payload or "").split(":")
payment_db_id = int(parts[0])
@@ -43,7 +43,9 @@ async def select_subscription_period_callback_handler(
return
price_source = traffic_packages if traffic_mode else settings.subscription_options
stars_price_source = stars_traffic_packages if traffic_mode else settings.stars_subscription_options
stars_price_source = (
stars_traffic_packages if traffic_mode else settings.stars_subscription_options
)
price_rub = price_source.get(months)
stars_price = stars_price_source.get(months)
@@ -82,7 +84,11 @@ async def select_subscription_period_callback_handler(
pass
return
text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method")
text_content = (
get_text("choose_payment_method_traffic")
if traffic_mode
else get_text("choose_payment_method")
)
reply_markup = get_payment_method_keyboard(
months,
price_rub,
@@ -1,4 +1,4 @@
import logging
import logging
from typing import List, Optional, Tuple
from aiogram import F, Router, types
@@ -37,7 +37,9 @@ def _sale_mode_base(sale_mode: str) -> str:
return (sale_mode or "subscription").split("@", 1)[0].split("|", 1)[0]
def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str:
def _format_saved_payment_method_title(
get_text, network: Optional[str], last4: Optional[str], is_default: bool
) -> str:
def _is_yoomoney_network(name: Optional[str]) -> bool:
s = (name or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
@@ -85,7 +87,11 @@ async def _initiate_yk_payment(
payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (get_text("payment_description_hwid_devices", count=int(months)) if sale_base in {"hwid_device", "hwid_devices"} else get_text("payment_description_subscription", months=int(months)))
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
payment_record_data = {
"user_id": user_id,
@@ -96,8 +102,12 @@ async def _initiate_yk_payment(
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"sale_mode": sale_base,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months) if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else None,
"purchased_hwid_devices": int(months) if sale_base in {"hwid_device", "hwid_devices"} else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
db_payment_record = None
@@ -157,7 +167,11 @@ async def _initiate_yk_payment(
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"}:
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"}:
@@ -206,7 +220,9 @@ async def _initiate_yk_payment(
session, user_id, selected_method_internal_id
)
except Exception:
logging.exception("Failed to set default payment method after initiating payment")
logging.exception(
"Failed to set default payment method after initiating payment"
)
await session.commit()
except Exception as e_db_update_ykid:
await session.rollback()
@@ -223,7 +239,9 @@ async def _initiate_yk_payment(
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
@@ -237,13 +255,13 @@ async def _initiate_yk_payment(
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
f"Edit message for payment link failed: {e_edit}. Sending new one."
)
logging.warning(f"Edit message for payment link failed: {e_edit}. Sending new one.")
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic" if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} else "payment_link_message",
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
@@ -275,7 +293,9 @@ async def _initiate_yk_payment(
session, user_id, selected_method_internal_id
)
except Exception:
logging.exception("Failed to set default payment method after saved-card payment start")
logging.exception(
"Failed to set default payment method after saved-card payment start"
)
await session.commit()
except Exception as e_db_update_saved:
await session.rollback()
@@ -328,7 +348,13 @@ async def _initiate_yk_payment(
@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):
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
@@ -372,9 +398,13 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" and not settings.traffic_sale_mode)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
autopay_require_binding = bool(
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
)
saved_methods: List = []
if autopay_enabled:
@@ -444,7 +474,13 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
@router.callback_query(F.data.startswith("pay_yk_new:"))
async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
async def pay_yk_new_card_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
@@ -490,9 +526,13 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" and not settings.traffic_sale_mode)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
autopay_require_binding = bool(
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
)
await _initiate_yk_payment(
@@ -518,7 +558,13 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
@router.callback_query(F.data.startswith("pay_yk_saved_list:"))
async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
async def pay_yk_saved_list_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
@@ -562,7 +608,11 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
pass
return
autopay_enabled = bool(settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" and not settings.traffic_sale_mode)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -662,7 +712,13 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
@router.callback_query(F.data.startswith("pay_yk_use_saved:"))
async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession):
async def pay_yk_use_saved_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
@@ -717,7 +773,11 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
pass
return
autopay_enabled = bool(settings.yookassa_autopayments_active and _sale_mode_base(sale_mode) == "subscription" and not settings.traffic_sale_mode)
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
@@ -747,7 +807,9 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
break
if not selected_method:
logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}")
logging.warning(
f"Selected payment method not found for user {user_id}: {method_identifier}"
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
+30 -57
View File
@@ -1,20 +1,21 @@
import logging
from aiogram import Router, F, types, Bot
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import (
get_trial_confirmation_keyboard,
get_main_menu_inline_keyboard,
get_connect_and_main_keyboard,
get_main_menu_inline_keyboard,
)
from bot.utils.config_link import prepare_config_links
from bot.middlewares.i18n import JsonI18n
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from config.settings import Settings
from .start import send_main_menu
router = Router(name="user_trial_router")
@@ -47,9 +48,7 @@ async def request_trial_confirmation_handler(
if not settings.TRIAL_ENABLED:
await callback.message.edit_text(
_("trial_feature_disabled"),
reply_markup=get_main_menu_inline_keyboard(
current_lang, i18n, settings, False
),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
)
try:
await callback.answer()
@@ -60,9 +59,7 @@ async def request_trial_confirmation_handler(
if await subscription_service.has_had_any_subscription(session, user_id):
await callback.message.edit_text(
_("trial_already_had_subscription_or_trial"),
reply_markup=get_main_menu_inline_keyboard(
current_lang, i18n, settings, False
),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
)
try:
await callback.answer()
@@ -71,9 +68,7 @@ async def request_trial_confirmation_handler(
return
# Directly activate trial without confirmation
activation_result = await subscription_service.activate_trial_subscription(
session, user_id
)
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
final_message_text_in_chat = ""
show_trial_button_after_action = False
@@ -93,9 +88,7 @@ async def request_trial_confirmation_handler(
)
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
traffic_gb_val = activation_result.get(
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
)
traffic_gb_val = activation_result.get("traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB)
traffic_display = (
f"{traffic_gb_val} GB"
if traffic_gb_val and traffic_gb_val > 0
@@ -106,20 +99,19 @@ async def request_trial_confirmation_handler(
"trial_activated_details_message",
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
end_date=(
end_date_obj.strftime("%Y-%m-%d")
if isinstance(end_date_obj, datetime)
else "N/A"
end_date_obj.strftime("%Y-%m-%d") if isinstance(end_date_obj, datetime) else "N/A"
),
config_link=config_link_for_trial,
traffic_gb=traffic_display,
)
# Send notification to admin about new trial
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
# Mark ad attribution trial if exists
try:
from db.dal import ad_dal as _ad_dal
await _ad_dal.mark_trial_activated(session, user_id)
await session.commit()
except Exception as e_mark:
@@ -136,11 +128,8 @@ async def request_trial_confirmation_handler(
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_had_any_subscription(
session, user_id
)
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
session, user_id
):
show_trial_button_after_action = True
@@ -166,9 +155,7 @@ async def request_trial_confirmation_handler(
disable_web_page_preview=True,
)
except Exception as e_edit:
logging.warning(
f"Could not edit trial result message: {e_edit}. Sending new one."
)
logging.warning(f"Could not edit trial result message: {e_edit}. Sending new one.")
if callback.message:
await callback.message.answer(
@@ -213,9 +200,7 @@ async def confirm_activate_trial_handler(
return
if await subscription_service.has_had_any_subscription(session, user_id):
try:
await callback.answer(
_("trial_already_had_subscription_or_trial"), show_alert=True
)
await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True)
except Exception:
pass
await send_main_menu(
@@ -223,9 +208,7 @@ async def confirm_activate_trial_handler(
)
return
activation_result = await subscription_service.activate_trial_subscription(
session, user_id
)
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
final_message_text_in_chat = ""
show_trial_button_after_action = False
@@ -245,9 +228,7 @@ async def confirm_activate_trial_handler(
)
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
traffic_gb_val = activation_result.get(
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
)
traffic_gb_val = activation_result.get("traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB)
traffic_display = (
f"{traffic_gb_val} GB"
if traffic_gb_val and traffic_gb_val > 0
@@ -258,9 +239,7 @@ async def confirm_activate_trial_handler(
"trial_activated_details_message",
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
end_date=(
end_date_obj.strftime("%Y-%m-%d")
if isinstance(end_date_obj, datetime)
else "N/A"
end_date_obj.strftime("%Y-%m-%d") if isinstance(end_date_obj, datetime) else "N/A"
),
config_link=config_link_for_trial,
traffic_gb=traffic_display,
@@ -276,11 +255,8 @@ async def confirm_activate_trial_handler(
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_had_any_subscription(
session, user_id
)
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
session, user_id
):
show_trial_button_after_action = True
@@ -306,9 +282,7 @@ async def confirm_activate_trial_handler(
disable_web_page_preview=True,
)
except Exception as e_edit:
logging.warning(
f"Could not edit trial result message: {e_edit}. Sending new one."
)
logging.warning(f"Could not edit trial result message: {e_edit}. Sending new one.")
if callback.message:
await callback.message.answer(
@@ -323,6 +297,7 @@ async def confirm_activate_trial_handler(
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:
@@ -338,6 +313,4 @@ async def cancel_trial_activation(
subscription_service: SubscriptionService,
session: AsyncSession,
):
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
await send_main_menu(callback, settings, i18n_data, subscription_service, session, is_edit=True)