refactor: project architecture refactor, container splitting
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
from aiogram import Router
|
||||
|
||||
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
|
||||
|
||||
user_router_aggregate = Router(name="user_router_aggregate")
|
||||
|
||||
user_router_aggregate.include_router(promo_user.router)
|
||||
user_router_aggregate.include_router(trial_handler.router)
|
||||
user_router_aggregate.include_router(start.router)
|
||||
user_router_aggregate.include_router(subscription_router)
|
||||
user_router_aggregate.include_router(referral.router)
|
||||
@@ -0,0 +1,800 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
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 bot.infra.webhook_queue import enqueue_webhook_event
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
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_WEBHOOK_ALLOWED_IPS = [
|
||||
"185.71.76.0/27",
|
||||
"185.71.77.0/27",
|
||||
"77.75.153.0/25",
|
||||
"77.75.156.11",
|
||||
"77.75.156.35",
|
||||
"77.75.154.128/25",
|
||||
"2a02:5180::/32",
|
||||
]
|
||||
|
||||
|
||||
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_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")
|
||||
|
||||
# For auto-renew payments, payment_db_id may be absent. In that case,
|
||||
# we will create/ensure a payment record idempotently using provider payment id.
|
||||
if (
|
||||
not user_id_str
|
||||
or (not subscription_months_str and not traffic_gb_str)
|
||||
or (not payment_db_id_str and not auto_renew_subscription_id_str)
|
||||
):
|
||||
logging.error(
|
||||
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" # noqa: E501
|
||||
)
|
||||
return
|
||||
|
||||
db_user = None
|
||||
try:
|
||||
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
|
||||
)
|
||||
|
||||
amount_data = payment_info_from_webhook.get("amount", {})
|
||||
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
|
||||
payment_value = float(amount_data.get("value", 0.0))
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
|
||||
payment_record = None
|
||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||
if payment_db_id is None and auto_renew_subscription_id_str:
|
||||
try:
|
||||
if not yk_payment_id_from_hook:
|
||||
logging.error(
|
||||
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." # noqa: E501
|
||||
)
|
||||
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
|
||||
)
|
||||
if not payment_record:
|
||||
payment_record = await _payment_dal.ensure_payment_with_provider_id(
|
||||
session,
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
||||
months=months_for_record or 1,
|
||||
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,
|
||||
)
|
||||
payment_db_id = payment_record.payment_id
|
||||
except Exception as e_ensure:
|
||||
logging.error(
|
||||
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
elif payment_db_id is not None:
|
||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment_record:
|
||||
logging.error(
|
||||
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
|
||||
)
|
||||
return
|
||||
|
||||
if payment_record and payment_record.status == "succeeded":
|
||||
logging.info(
|
||||
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})." # noqa: E501
|
||||
)
|
||||
return
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
logging.error(
|
||||
f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}." # noqa: E501
|
||||
)
|
||||
|
||||
await payment_dal.update_payment_status_by_db_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}")
|
||||
|
||||
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}")
|
||||
return
|
||||
|
||||
try:
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
payment_before_update = None
|
||||
if payment_db_id is not None:
|
||||
payment_before_update = await payment_dal.get_payment_by_db_id(
|
||||
session,
|
||||
payment_db_id,
|
||||
)
|
||||
should_send_lknpd_receipt = bool(
|
||||
lknpd_service
|
||||
and lknpd_service.configured
|
||||
and payment_info_from_webhook.get("paid") is True
|
||||
and payment_info_from_webhook.get("status") == "succeeded"
|
||||
and payment_before_update
|
||||
and payment_before_update.status != "succeeded"
|
||||
)
|
||||
# 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)
|
||||
):
|
||||
pm_id = payment_method.get("id")
|
||||
pm_type = payment_method.get("type")
|
||||
title = payment_method.get("title")
|
||||
card = payment_method.get("card") or {}
|
||||
account_number = payment_method.get("account_number") or payment_method.get(
|
||||
"account"
|
||||
)
|
||||
display_network = None
|
||||
display_last4 = None
|
||||
# Build generic display for various instrument types
|
||||
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||
display_network = card.get("card_type") or title or "Card"
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
# Normalize wallet display name to avoid leaking full account from title
|
||||
display_network = "YooMoney"
|
||||
if isinstance(account_number, str) and len(account_number) >= 4:
|
||||
display_last4 = account_number[-4:]
|
||||
else:
|
||||
display_last4 = None
|
||||
else:
|
||||
# Wallets, SBP, etc. — use provided title/type; no last4
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=pm_id,
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
try:
|
||||
await user_billing_dal.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=pm_id,
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
except Exception:
|
||||
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)
|
||||
)
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
months_for_activation,
|
||||
payment_value,
|
||||
payment_db_id,
|
||||
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,
|
||||
)
|
||||
|
||||
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}" # noqa: E501
|
||||
)
|
||||
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,
|
||||
)
|
||||
if not updated_payment_record:
|
||||
logging.error(
|
||||
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}" # noqa: E501
|
||||
)
|
||||
raise Exception(f"DB Error: Could not update payment record {payment_db_id}")
|
||||
|
||||
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)
|
||||
|
||||
referral_bonus_info = None
|
||||
if sale_mode_base == "subscription":
|
||||
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
user_id,
|
||||
months_for_activation or int(subscription_months) or 1,
|
||||
current_payment_db_id=payment_db_id,
|
||||
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"]
|
||||
applied_referee_bonus_days_from_referral = referral_bonus_info.get(
|
||||
"referee_bonus_applied_days"
|
||||
)
|
||||
|
||||
# Use user's DB language for all user-facing messages
|
||||
user_lang = (
|
||||
db_user.language_code
|
||||
if db_user and db_user.language_code
|
||||
else settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
|
||||
traffic_label = (
|
||||
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")
|
||||
if not receipt_item_name:
|
||||
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)
|
||||
)
|
||||
try:
|
||||
await lknpd_service.create_income_receipt(
|
||||
item_name=receipt_item_name,
|
||||
amount=payment_value,
|
||||
quantity=1.0,
|
||||
operation_time=datetime.now(timezone.utc),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to send LKNPD receipt for payment %s",
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
config_link_display, connect_button_url = await prepare_config_links(
|
||||
settings, activation_details.get("subscription_url") if activation_details else None
|
||||
)
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
# For auto-renew charges, avoid re-sending config link; send concise message
|
||||
if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"yookassa_auto_renewal",
|
||||
months=int(subscription_months),
|
||||
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 "—",
|
||||
config_link=config_link_text,
|
||||
)
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
else:
|
||||
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
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
|
||||
)
|
||||
|
||||
details_message = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=int(subscription_months),
|
||||
base_end_date=base_subscription_end_date.strftime("%Y-%m-%d"),
|
||||
bonus_days=applied_referee_bonus_days_from_referral,
|
||||
final_end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link_text,
|
||||
)
|
||||
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
|
||||
details_message = _(
|
||||
"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"),
|
||||
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"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501
|
||||
)
|
||||
details_message = _("payment_successful_error_details")
|
||||
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
user_id,
|
||||
details_message,
|
||||
reply_markup=details_markup,
|
||||
parse_mode="HTML",
|
||||
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}")
|
||||
|
||||
# Send notification about payment
|
||||
try:
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
tariff_for_log = None
|
||||
if payment_before_update and getattr(payment_before_update, "tariff_key", None):
|
||||
tariff_for_log = payment_before_update.tariff_key
|
||||
elif updated_payment_record and getattr(updated_payment_record, "tariff_key", None):
|
||||
tariff_for_log = updated_payment_record.tariff_key
|
||||
elif payment_record and getattr(payment_record, "tariff_key", None):
|
||||
tariff_for_log = payment_record.tariff_key
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
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_is_premium=sale_mode_base == "premium_topup",
|
||||
tariff_key=tariff_for_log,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send payment notification: {e}")
|
||||
|
||||
except Exception as e_process:
|
||||
logging.error(
|
||||
f"Error during process_successful_payment main try block for user {user_id}: {e_process}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
|
||||
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")
|
||||
payment_db_id_str = metadata.get("payment_db_id")
|
||||
|
||||
if not user_id_str or not payment_db_id_str:
|
||||
logging.warning(
|
||||
f"Missing metadata in cancelled payment webhook: {payment_info_from_webhook.get('id')}"
|
||||
)
|
||||
return
|
||||
try:
|
||||
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}")
|
||||
return
|
||||
|
||||
try:
|
||||
updated_payment = await payment_dal.update_payment_status_by_db_id(
|
||||
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"),
|
||||
)
|
||||
|
||||
if updated_payment:
|
||||
logging.info(
|
||||
f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}." # noqa: E501
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}." # noqa: E501
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
await bot.send_message(user_id, _("payment_failed"))
|
||||
|
||||
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}", # noqa: E501
|
||||
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"]
|
||||
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")
|
||||
|
||||
client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||
if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS):
|
||||
logging.warning("YooKassa webhook denied from unauthorized IP source.")
|
||||
return web.Response(status=403)
|
||||
|
||||
try:
|
||||
event_json = await request.json()
|
||||
|
||||
notification_object = WebhookNotification(event_json)
|
||||
payment_data_from_notification = notification_object.object
|
||||
|
||||
logging.info(
|
||||
f"YooKassa Webhook Parsed: Event='{notification_object.event}', "
|
||||
f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'" # noqa: E501
|
||||
)
|
||||
|
||||
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." # noqa: E501
|
||||
)
|
||||
return web.Response(status=200, text="ok_error_no_metadata")
|
||||
|
||||
# Safely extract payment_method details (SDK objects may not have to_dict)
|
||||
pm_obj = getattr(payment_data_from_notification, "payment_method", None)
|
||||
pm_dict = None
|
||||
if pm_obj is not None:
|
||||
try:
|
||||
card_obj = getattr(pm_obj, "card", None)
|
||||
pm_dict = {
|
||||
"id": getattr(pm_obj, "id", None),
|
||||
"type": getattr(pm_obj, "type", None),
|
||||
"saved": bool(getattr(pm_obj, "saved", False)),
|
||||
"title": getattr(pm_obj, "title", None),
|
||||
"account_number": (
|
||||
getattr(pm_obj, "account_number", None)
|
||||
if hasattr(pm_obj, "account_number")
|
||||
else (
|
||||
getattr(pm_obj, "account", None) if hasattr(pm_obj, "account") else None
|
||||
)
|
||||
),
|
||||
"card": (
|
||||
{
|
||||
"first6": getattr(card_obj, "first6", None),
|
||||
"last4": getattr(card_obj, "last4", None),
|
||||
"expiry_month": getattr(card_obj, "expiry_month", None),
|
||||
"expiry_year": getattr(card_obj, "expiry_year", None),
|
||||
"card_type": getattr(card_obj, "card_type", None),
|
||||
}
|
||||
if card_obj is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
except Exception:
|
||||
logging.exception("Failed to serialize YooKassa payment_method from webhook")
|
||||
pm_dict = None
|
||||
|
||||
payment_dict_for_processing = {
|
||||
"id": str(payment_data_from_notification.id),
|
||||
"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,
|
||||
"payment_method": pm_dict,
|
||||
}
|
||||
|
||||
if notification_object.event in {
|
||||
YOOKASSA_EVENT_PAYMENT_SUCCEEDED,
|
||||
YOOKASSA_EVENT_PAYMENT_CANCELED,
|
||||
}:
|
||||
queued = await enqueue_webhook_event(
|
||||
settings,
|
||||
"yookassa",
|
||||
{
|
||||
"event": notification_object.event,
|
||||
"payment": payment_dict_for_processing,
|
||||
},
|
||||
event_id=f"{notification_object.event}:{payment_dict_for_processing.get('id')}",
|
||||
)
|
||||
if queued:
|
||||
return web.Response(status=200, text="queued")
|
||||
|
||||
async with payment_processing_lock:
|
||||
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"
|
||||
):
|
||||
await process_successful_payment(
|
||||
session,
|
||||
bot,
|
||||
payment_dict_for_processing,
|
||||
i18n_instance,
|
||||
settings,
|
||||
panel_service,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
lknpd_service,
|
||||
)
|
||||
await session.commit()
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " # noqa: E501
|
||||
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " # noqa: E501
|
||||
f"paid='{payment_dict_for_processing.get('paid')}'"
|
||||
)
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
|
||||
await process_cancelled_payment(
|
||||
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"
|
||||
):
|
||||
try:
|
||||
user_id_str = metadata.get("user_id")
|
||||
if user_id_str and user_id_str.isdigit():
|
||||
user_id = int(user_id_str)
|
||||
payment_method = payment_dict_for_processing.get(
|
||||
"payment_method"
|
||||
)
|
||||
if isinstance(payment_method, dict) and payment_method.get(
|
||||
"id"
|
||||
):
|
||||
pm_type = payment_method.get("type")
|
||||
title = payment_method.get("title")
|
||||
card = payment_method.get("card") or {}
|
||||
account_number = payment_method.get(
|
||||
"account_number"
|
||||
) or payment_method.get("account")
|
||||
display_network = None
|
||||
display_last4 = None
|
||||
if (pm_type or "").lower() in {
|
||||
"bank_card",
|
||||
"bank-card",
|
||||
"card",
|
||||
}:
|
||||
display_network = (
|
||||
card.get("card_type") or title or "Card"
|
||||
)
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {
|
||||
"yoo_money",
|
||||
"yoomoney",
|
||||
"yoo-money",
|
||||
"wallet",
|
||||
}:
|
||||
# Normalize wallet display name to avoid leaking full account from title # noqa: E501
|
||||
display_network = "YooMoney"
|
||||
if (
|
||||
isinstance(account_number, str)
|
||||
and len(account_number) >= 4
|
||||
):
|
||||
display_last4 = account_number[-4:]
|
||||
else:
|
||||
display_last4 = None
|
||||
else:
|
||||
display_network = title or (
|
||||
pm_type.upper() if pm_type else "Payment method"
|
||||
)
|
||||
display_last4 = None
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=payment_method.get("id"),
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
await session.commit()
|
||||
# Save multi-card entry and mark default if first
|
||||
try:
|
||||
from db.dal import user_billing_dal as ub
|
||||
|
||||
await ub.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=payment_method.get("id"),
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=True,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
# Notify user about successful binding with Back button
|
||||
try:
|
||||
# Use user's DB language for bind success notification
|
||||
i18n_lang = settings.DEFAULT_LANGUAGE
|
||||
from db.dal import user_dal
|
||||
|
||||
db_user = await user_dal.get_user_by_id(
|
||||
session, user_id
|
||||
)
|
||||
if db_user and db_user.language_code:
|
||||
i18n_lang = db_user.language_code
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(
|
||||
i18n_lang, key, **kwargs
|
||||
)
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_payment_methods_keyboard,
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=_("payment_method_bound_success"),
|
||||
reply_markup=get_back_to_payment_methods_keyboard(
|
||||
i18n_lang, i18n_instance
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Attempt to cancel the authorization to avoid charge hold
|
||||
try:
|
||||
yk: YooKassaService = request.app.get(
|
||||
"yookassa_service"
|
||||
)
|
||||
if yk:
|
||||
await yk.cancel_payment(
|
||||
payment_dict_for_processing.get("id")
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to cancel bind-only payment auth"
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to handle bind-only waiting_for_capture webhook"
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.", # noqa: E501
|
||||
notification_object.event,
|
||||
payment_dict_for_processing.get("id"),
|
||||
)
|
||||
return web.Response(status=500, text="internal_processing_error")
|
||||
|
||||
return web.Response(status=200, text="ok")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logging.error("YooKassa Webhook: Invalid JSON received.")
|
||||
return web.Response(status=400, text="bad_request_invalid_json")
|
||||
except Exception:
|
||||
logging.exception("YooKassa Webhook general processing error.")
|
||||
return web.Response(status=500, text="internal_error")
|
||||
@@ -0,0 +1,220 @@
|
||||
import logging
|
||||
import re
|
||||
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 bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_main_menu_markup,
|
||||
get_connect_and_main_keyboard,
|
||||
)
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
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",
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await safe_answer_callback(callback, "Language service error.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if not callback.message:
|
||||
logging.error("CallbackQuery has no message in prompt_promo_code_input")
|
||||
await safe_answer_callback(
|
||||
callback,
|
||||
_("error_occurred_processing_request"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text=_(key="promo_code_prompt"),
|
||||
reply_markup=get_back_to_main_menu_markup(
|
||||
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.")
|
||||
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()}"
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
):
|
||||
logging.info(
|
||||
f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'" # noqa: E501
|
||||
)
|
||||
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
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")
|
||||
await message.reply("Service error. Please try again later.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
code_input = message.text.strip() if message.text else ""
|
||||
user = message.from_user
|
||||
|
||||
is_suspicious = False
|
||||
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)
|
||||
):
|
||||
is_suspicious = True
|
||||
logging.warning(
|
||||
f"Suspicious input for promo code by user {user.id} (len: {len(code_input)}): '{code_input}'" # noqa: E501
|
||||
)
|
||||
|
||||
response_to_user_text = ""
|
||||
if is_suspicious:
|
||||
# Send notification through NotificationService if enabled
|
||||
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,
|
||||
)
|
||||
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
|
||||
)
|
||||
if success:
|
||||
await session.commit()
|
||||
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)
|
||||
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")
|
||||
|
||||
response_to_user_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"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
reply_markup = get_connect_and_main_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
)
|
||||
else:
|
||||
await session.commit()
|
||||
logging.info(
|
||||
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)
|
||||
|
||||
await message.answer(
|
||||
response_to_user_text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.clear()
|
||||
logging.info(
|
||||
f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared." # noqa: E501
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in cancel_promo_input_via_button")
|
||||
await safe_answer_callback(callback, "Language error", show_alert=True)
|
||||
return
|
||||
|
||||
logging.info(
|
||||
f"User {callback.from_user.id} cancelled promo code input via button from state {await state.get_state()}. Clearing state." # noqa: E501
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
if callback.message:
|
||||
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,
|
||||
_("promo_input_cancelled_short"),
|
||||
show_alert=False,
|
||||
)
|
||||
@@ -0,0 +1,254 @@
|
||||
import logging
|
||||
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
|
||||
|
||||
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",
|
||||
):
|
||||
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
|
||||
if not target_message_obj:
|
||||
logging.error(
|
||||
"Target message is None in referral_command_handler (possibly from callback without message)." # noqa: E501
|
||||
)
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
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()
|
||||
return
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
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}")
|
||||
await target_message_obj.answer(_("error_generating_referral_link"))
|
||||
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()
|
||||
return
|
||||
|
||||
inviter_user_id = event.from_user.id
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, inviter_user_id
|
||||
)
|
||||
|
||||
if not referral_link:
|
||||
logging.error(
|
||||
"Failed to generate referral link for user %s (probably missing DB record).",
|
||||
inviter_user_id,
|
||||
)
|
||||
await target_message_obj.answer(_("error_generating_referral_link"))
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
return
|
||||
|
||||
bonus_info_parts = []
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
bonus_details_str = _("referral_not_available_for_traffic")
|
||||
else:
|
||||
if settings.subscription_options:
|
||||
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"),
|
||||
)
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
webapp_referral_link = await _generate_webapp_referral_link(
|
||||
session,
|
||||
settings,
|
||||
inviter_user_id,
|
||||
)
|
||||
webapp_link_section = (
|
||||
_(
|
||||
"referral_webapp_link_line",
|
||||
webapp_referral_link=webapp_referral_link,
|
||||
)
|
||||
if webapp_referral_link
|
||||
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"],
|
||||
)
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
|
||||
|
||||
reply_markup_val = get_referral_link_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=back_callback,
|
||||
)
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
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
|
||||
)
|
||||
except Exception as e_edit:
|
||||
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.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,
|
||||
):
|
||||
action = callback.data.split(":")[1]
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if action == "share_message":
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
bot_username = bot_info.username
|
||||
if not bot_username:
|
||||
await callback.answer(_("error_generating_referral_link"), show_alert=True)
|
||||
return
|
||||
|
||||
inviter_user_id = callback.from_user.id
|
||||
referral_link = await referral_service.generate_referral_link(
|
||||
session, bot_username, inviter_user_id
|
||||
)
|
||||
|
||||
if not referral_link:
|
||||
logging.error(
|
||||
"Failed to generate referral link for user %s via inline button.",
|
||||
inviter_user_id,
|
||||
)
|
||||
await callback.answer(_("error_generating_referral_link"), show_alert=True)
|
||||
return
|
||||
|
||||
webapp_referral_link = await _generate_webapp_referral_link(
|
||||
session,
|
||||
settings,
|
||||
inviter_user_id,
|
||||
)
|
||||
if webapp_referral_link:
|
||||
friend_message = _(
|
||||
"referral_friend_message_with_webapp",
|
||||
referral_link=referral_link,
|
||||
webapp_referral_link=webapp_referral_link,
|
||||
)
|
||||
else:
|
||||
friend_message = _("referral_friend_message", referral_link=referral_link)
|
||||
|
||||
await callback.message.answer(friend_message, disable_web_page_preview=True)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in referral share message: {e}")
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
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)
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["ref"] = f"u{referral_code}"
|
||||
return urlunsplit(
|
||||
(
|
||||
parts.scheme,
|
||||
parts.netloc,
|
||||
parts.path or "/",
|
||||
urlencode(query),
|
||||
parts.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _generate_webapp_referral_link(
|
||||
session: AsyncSession,
|
||||
settings: Settings,
|
||||
inviter_user_id: int,
|
||||
) -> Optional[str]:
|
||||
if not settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
return None
|
||||
db_user = await user_dal.get_user_by_id(session, inviter_user_id)
|
||||
referral_code = await user_dal.ensure_referral_code(session, db_user) if db_user else None
|
||||
return _build_webapp_referral_link(
|
||||
settings.SUBSCRIPTION_MINI_APP_URL,
|
||||
referral_code,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
):
|
||||
await referral_command_handler(message, settings, i18n_data, referral_service, bot, session)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
from aiogram import Router
|
||||
|
||||
from . import core, payment_methods, payments
|
||||
|
||||
router = Router(name="user_subscription_router")
|
||||
|
||||
# Include sub-routers
|
||||
router.include_router(core.router)
|
||||
router.include_router(payments.router)
|
||||
router.include_router(payment_methods.router)
|
||||
|
||||
# Re-export commonly used entrypoints for backward compatibility
|
||||
from .core import ( # noqa: E402,F401
|
||||
display_subscription_options,
|
||||
my_devices_command_handler,
|
||||
my_subscription_command_handler,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,550 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_bind_url_keyboard,
|
||||
get_payment_method_delete_confirm_keyboard,
|
||||
get_payment_method_details_keyboard,
|
||||
get_payment_methods_list_keyboard,
|
||||
)
|
||||
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
|
||||
|
||||
router = Router(name="user_subscription_payment_methods_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pm:manage")
|
||||
async def payment_methods_manage(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
|
||||
get_text = _
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
cards: List[tuple] = []
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card")
|
||||
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)
|
||||
|
||||
for m in methods:
|
||||
title = _format_pm_title(m.card_network, m.card_last4)
|
||||
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
||||
|
||||
text = get_text("payment_methods_title")
|
||||
if not cards:
|
||||
text += "\n\n" + get_text("payment_method_none")
|
||||
|
||||
await callback.message.edit_text(
|
||||
text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pm:bind")
|
||||
async def payment_method_bind(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
yookassa_service: YooKassaService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
|
||||
resp = await yookassa_service.create_payment(
|
||||
amount=1.00,
|
||||
currency="RUB",
|
||||
description="Bind card",
|
||||
metadata=metadata,
|
||||
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
|
||||
save_payment_method=True,
|
||||
capture=False,
|
||||
bind_only=True,
|
||||
)
|
||||
if not resp or not resp.get("confirmation_url"):
|
||||
await callback.answer(_("error_payment_gateway"), show_alert=True)
|
||||
return
|
||||
await callback.message.edit_text(
|
||||
_("payment_methods_title"),
|
||||
reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n),
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:delete_confirm"))
|
||||
async def payment_method_delete_confirm(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
parts = callback.data.split(":", 2)
|
||||
pm_id = parts[2] if len(parts) >= 3 else ""
|
||||
await callback.message.edit_text(
|
||||
_("payment_method_delete_confirm"),
|
||||
reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n),
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:delete"))
|
||||
async def payment_method_delete(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
parts = callback.data.split(":", 2)
|
||||
pm_id_raw = parts[2] if len(parts) >= 3 else ""
|
||||
deleted = False
|
||||
|
||||
try:
|
||||
from db.dal.user_billing_dal import (
|
||||
delete_user_payment_method,
|
||||
delete_user_payment_method_by_provider_id,
|
||||
list_user_payment_methods,
|
||||
)
|
||||
|
||||
if pm_id_raw:
|
||||
if pm_id_raw.isdigit():
|
||||
deleted = await delete_user_payment_method(
|
||||
session, callback.from_user.id, int(pm_id_raw)
|
||||
)
|
||||
else:
|
||||
deleted = await delete_user_payment_method_by_provider_id(
|
||||
session, callback.from_user.id, pm_id_raw
|
||||
)
|
||||
try:
|
||||
legacy_deleted = await user_billing_dal.delete_yk_payment_method(
|
||||
session, callback.from_user.id
|
||||
)
|
||||
deleted = deleted or legacy_deleted
|
||||
except Exception:
|
||||
pass
|
||||
await session.commit()
|
||||
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
text = _("payment_methods_title")
|
||||
cards = []
|
||||
for m in methods:
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card")
|
||||
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),
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
try:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:view"))
|
||||
async def payment_method_view(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
|
||||
if not billing or not billing.yookassa_payment_method_id:
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
if not methods:
|
||||
await callback.answer(_("payment_method_none"), show_alert=True)
|
||||
return
|
||||
parts = callback.data.split(":", 2)
|
||||
pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id)
|
||||
sel = next(
|
||||
(
|
||||
m
|
||||
for m in methods
|
||||
if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id
|
||||
),
|
||||
methods[0],
|
||||
)
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card")
|
||||
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(sel.card_network, sel.card_last4)
|
||||
added_at = sel.created_at.strftime("%Y-%m-%d") if getattr(sel, "created_at", None) else "—"
|
||||
last_tx = "—"
|
||||
try:
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.where(
|
||||
Payment.user_id == callback.from_user.id,
|
||||
Payment.status == "succeeded",
|
||||
Payment.provider == "yookassa",
|
||||
)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
lp = result.scalar_one_or_none()
|
||||
if lp and lp.created_at:
|
||||
last_tx = lp.created_at.strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
pass
|
||||
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501
|
||||
await callback.message.edit_text(
|
||||
details,
|
||||
reply_markup=get_payment_method_details_keyboard(
|
||||
str(sel.method_id), current_lang, i18n
|
||||
),
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
added_at = (
|
||||
billing.created_at.strftime("%Y-%m-%d") if getattr(billing, "created_at", None) else "—"
|
||||
)
|
||||
last_tx = "—"
|
||||
try:
|
||||
stmt = (
|
||||
select(Payment)
|
||||
.where(
|
||||
Payment.user_id == callback.from_user.id,
|
||||
Payment.status == "succeeded",
|
||||
Payment.provider == "yookassa",
|
||||
)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
last_payment = result.scalar_one_or_none()
|
||||
if last_payment and last_payment.created_at:
|
||||
last_tx = last_payment.created_at.strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return _("payment_method_wallet_title", last4=l4)
|
||||
return _("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or _("payment_network_card")
|
||||
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(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)}" # noqa: E501
|
||||
await callback.message.edit_text(
|
||||
details,
|
||||
reply_markup=get_payment_method_details_keyboard(
|
||||
billing.yookassa_payment_method_id, current_lang, i18n
|
||||
),
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:history"))
|
||||
async def payment_method_history(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
yookassa_service: YooKassaService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not settings.yookassa_autopayments_active:
|
||||
try:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
from db.dal import payment_dal
|
||||
|
||||
payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0)
|
||||
user_payments = [p for p in payments if p.user_id == callback.from_user.id]
|
||||
|
||||
selected_pm_provider_id: Optional[str] = None
|
||||
pm_filter_requested: bool = False
|
||||
try:
|
||||
split_a, split_b, split_pm_id = callback.data.split(":", 2)
|
||||
if split_pm_id:
|
||||
pm_filter_requested = True
|
||||
if split_pm_id.isdigit():
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
sel = next((m for m in methods if str(m.method_id) == split_pm_id), None)
|
||||
if sel and sel.provider_payment_method_id:
|
||||
selected_pm_provider_id = sel.provider_payment_method_id
|
||||
else:
|
||||
selected_pm_provider_id = split_pm_id
|
||||
except Exception:
|
||||
selected_pm_provider_id = None
|
||||
pm_filter_requested = False
|
||||
|
||||
if pm_filter_requested and not selected_pm_provider_id:
|
||||
user_payments = []
|
||||
|
||||
if selected_pm_provider_id:
|
||||
filtered: List[Payment] = []
|
||||
for p in user_payments:
|
||||
if p.provider != "yookassa":
|
||||
continue
|
||||
if p.yookassa_payment_id and yookassa_service:
|
||||
try:
|
||||
info = await yookassa_service.get_payment_info(p.yookassa_payment_id)
|
||||
pm = (info or {}).get("payment_method") or {}
|
||||
if pm.get("id") == selected_pm_provider_id:
|
||||
filtered.append(p)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
user_payments = filtered
|
||||
|
||||
if not user_payments:
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_payment_method_details_keyboard,
|
||||
get_payment_methods_manage_keyboard,
|
||||
)
|
||||
|
||||
back_pm_id = ""
|
||||
try:
|
||||
split_a, split_b, back_pm_id = callback.data.split(":", 2)
|
||||
except Exception:
|
||||
back_pm_id = ""
|
||||
back_markup = (
|
||||
get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n)
|
||||
if back_pm_id
|
||||
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
||||
)
|
||||
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
|
||||
return
|
||||
|
||||
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
||||
|
||||
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}"
|
||||
)
|
||||
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"
|
||||
return f"{date_str} — {title} — {p.amount:.2f} {p.currency}"
|
||||
|
||||
lines = [_format_item(p) for p in user_payments]
|
||||
text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines)
|
||||
try:
|
||||
split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2)
|
||||
except Exception:
|
||||
split_pm_id_for_back = ""
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_payment_method_details_keyboard,
|
||||
get_payment_methods_manage_keyboard,
|
||||
)
|
||||
|
||||
back_markup = (
|
||||
get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n)
|
||||
if split_pm_id_for_back
|
||||
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
||||
)
|
||||
await callback.message.edit_text(text, reply_markup=back_markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pm:list:"))
|
||||
async def payment_methods_list(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
from db.dal.user_billing_dal import list_user_payment_methods
|
||||
|
||||
cards: List[tuple] = []
|
||||
methods = await list_user_payment_methods(session, callback.from_user.id)
|
||||
for m in methods:
|
||||
|
||||
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
||||
s = (network or "").lower()
|
||||
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
||||
|
||||
def _extract_last4(text: str) -> Optional[str]:
|
||||
digits = "".join(ch for ch in text if ch.isdigit())
|
||||
return digits[-4:] if len(digits) >= 4 else None
|
||||
|
||||
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
||||
if _is_yoomoney_network(network):
|
||||
l4 = last4 or _extract_last4(network or "")
|
||||
if l4:
|
||||
return get_text("payment_method_wallet_title", last4=l4)
|
||||
return get_text("payment_method_wallet_title", last4="****")
|
||||
if last4:
|
||||
network_name = network or get_text("payment_network_card")
|
||||
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}"))
|
||||
|
||||
try:
|
||||
_, _, page_str = callback.data.split(":", 2)
|
||||
page = int(page_str)
|
||||
except Exception:
|
||||
page = 0
|
||||
|
||||
text = get_text("payment_methods_title")
|
||||
if not cards:
|
||||
text += "\n\n" + get_text("payment_method_none")
|
||||
await callback.message.edit_text(
|
||||
text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n)
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,21 @@
|
||||
from aiogram import Router
|
||||
|
||||
from .payments_crypto import router as crypto_router
|
||||
from .payments_freekassa import router as freekassa_router
|
||||
from .payments_platega import router as platega_router
|
||||
from .payments_severpay import router as severpay_router
|
||||
from .payments_stars import router as stars_router
|
||||
from .payments_subscription import router as subscription_selection_router
|
||||
from .payments_yookassa import router as yookassa_router
|
||||
|
||||
router = Router(name="user_subscription_payments_router")
|
||||
|
||||
router.include_router(subscription_selection_router)
|
||||
router.include_router(yookassa_router)
|
||||
router.include_router(freekassa_router)
|
||||
router.include_router(platega_router)
|
||||
router.include_router(severpay_router)
|
||||
router.include_router(crypto_router)
|
||||
router.include_router(stars_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,128 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_crypto_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_crypto:"))
|
||||
async def pay_crypto_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
cryptopay_service: CryptoPayService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if (
|
||||
not 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:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_amount = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
||||
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))
|
||||
)
|
||||
)
|
||||
|
||||
invoice_url = await cryptopay_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_amount,
|
||||
description=payment_description,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
|
||||
if invoice_url:
|
||||
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",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
invoice_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
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",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
invoice_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,244 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_freekassa_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_fk:"))
|
||||
async def pay_fk_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
freekassa_service: FreeKassaService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not freekassa_service or not freekassa_service.configured:
|
||||
logging.error("FreeKassa service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_fk data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
||||
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))
|
||||
)
|
||||
)
|
||||
currency_code = (
|
||||
getattr(freekassa_service, "default_currency", None)
|
||||
or settings.DEFAULT_CURRENCY_SYMBOL
|
||||
or "RUB"
|
||||
)
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code,
|
||||
"status": "pending_freekassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||
"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,
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
success, response_data = await freekassa_service.create_order(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=payment_record.user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=freekassa_service.default_currency,
|
||||
payment_method_id=freekassa_service.payment_method_id,
|
||||
ip_address=freekassa_service.server_ip,
|
||||
extra_params={
|
||||
"us_method": freekassa_service.payment_method_id,
|
||||
},
|
||||
)
|
||||
|
||||
if success:
|
||||
location = response_data.get("location")
|
||||
order_hash = response_data.get("orderHash")
|
||||
order_id_api = response_data.get("orderId")
|
||||
provider_identifier = order_hash or order_id_api
|
||||
|
||||
if provider_identifier:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(provider_identifier),
|
||||
payment_record.status,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if location:
|
||||
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,
|
||||
date=datetime.now().strftime("%Y-%m-%d"),
|
||||
)
|
||||
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",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
location,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
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",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
location,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", # noqa: E501
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
"FreeKassa: create_order failed for payment %s with response %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
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}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,261 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.platega_service import PlategaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_platega_router")
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data.startswith("pay_platega_sbp:")
|
||||
| F.data.startswith("pay_platega_crypto:")
|
||||
| F.data.startswith("pay_platega:")
|
||||
)
|
||||
async def pay_platega_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
platega_service: PlategaService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
callback_prefix, _, _ = (callback.data or "").partition(":")
|
||||
if callback_prefix == "pay_platega_crypto":
|
||||
platega_method_id = settings.PLATEGA_CRYPTO_METHOD
|
||||
platega_variant = "crypto"
|
||||
if not settings.PLATEGA_CRYPTO_ENABLED:
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
elif callback_prefix == "pay_platega_sbp":
|
||||
platega_method_id = settings.platega_sbp_method_resolved
|
||||
platega_variant = "sbp"
|
||||
if not settings.PLATEGA_SBP_ENABLED:
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
else:
|
||||
# Legacy callback (pre-split): keep working as SBP
|
||||
platega_method_id = settings.platega_sbp_method_resolved
|
||||
platega_variant = "sbp"
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not platega_service or not platega_service.configured:
|
||||
logging.error("Platega service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_platega data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
||||
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))
|
||||
)
|
||||
)
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code,
|
||||
"status": "pending_platega",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||
"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,
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Platega: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
payload_meta = json.dumps(
|
||||
{
|
||||
"payment_db_id": payment_record.payment_id,
|
||||
"user_id": user_id,
|
||||
"months": months,
|
||||
"sale_mode": sale_mode,
|
||||
"platega_variant": platega_variant,
|
||||
}
|
||||
)
|
||||
|
||||
success, response_data = await platega_service.create_transaction(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
payload=payload_meta,
|
||||
payment_method=platega_method_id,
|
||||
)
|
||||
|
||||
if success:
|
||||
transaction_id = response_data.get("transactionId") or response_data.get("id")
|
||||
redirect_url = (
|
||||
response_data.get("redirect")
|
||||
or response_data.get("url")
|
||||
or response_data.get("paymentUrl")
|
||||
)
|
||||
provider_status = response_data.get("status", payment_record.status)
|
||||
|
||||
if transaction_id and redirect_url:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(transaction_id),
|
||||
str(provider_status),
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
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",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
redirect_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
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",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
redirect_url,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s", # noqa: E501
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
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}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,221 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_severpay_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_severpay:"))
|
||||
async def pay_severpay_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
severpay_service: SeverPayService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not severpay_service or not severpay_service.configured:
|
||||
logging.error("SeverPay service is not configured or unavailable.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_severpay data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
||||
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))
|
||||
)
|
||||
)
|
||||
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
payment_record_payload = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code,
|
||||
"status": "pending_severpay",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||
"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,
|
||||
}
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
|
||||
await session.commit()
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"SeverPay: failed to create payment record for user {user_id}: {e_db_create}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
success, response_data = await severpay_service.create_payment(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=price_rub,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
)
|
||||
|
||||
if success:
|
||||
payment_link = (
|
||||
response_data.get("url")
|
||||
or response_data.get("payment_url")
|
||||
or response_data.get("paymentUrl")
|
||||
)
|
||||
provider_identifier = response_data.get("id") or response_data.get("uid")
|
||||
|
||||
if provider_identifier:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(provider_identifier),
|
||||
payment_record.status,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_status:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if payment_link:
|
||||
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",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_link,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
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",
|
||||
months=int(months),
|
||||
traffic_gb=human_value,
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_link,
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=f"subscribe_period:{human_value}",
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
logging.error(
|
||||
"SeverPay: payment created but missing payment link for payment %s. Response: %s",
|
||||
payment_record.payment_id,
|
||||
response_data,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
"failed_creation",
|
||||
)
|
||||
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}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,148 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.stars_service import StarsService
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_stars_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_stars:"))
|
||||
async def pay_stars_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not settings.STARS_ENABLED:
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
parts = data_payload.split(":")
|
||||
months = float(parts[0])
|
||||
stars_price = int(float(parts[1]))
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
|
||||
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
||||
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))
|
||||
)
|
||||
)
|
||||
|
||||
payment_db_id = await stars_service.create_invoice(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
stars_price=stars_price,
|
||||
description=payment_description,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
|
||||
if payment_db_id:
|
||||
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",
|
||||
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}",
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
await query.answer(ok=True)
|
||||
except Exception:
|
||||
# Nothing else to do here; Telegram will show an error if not answered
|
||||
pass
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def handle_successful_stars_payment(
|
||||
message: types.Message,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
stars_service: StarsService,
|
||||
):
|
||||
payload = (
|
||||
message.successful_payment.invoice_payload if message and message.successful_payment else ""
|
||||
)
|
||||
try:
|
||||
parts = (payload or "").split(":")
|
||||
payment_db_id = int(parts[0])
|
||||
months = float(parts[1]) if len(parts) > 1 else 0
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
except Exception:
|
||||
return
|
||||
|
||||
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
|
||||
await stars_service.process_successful_payment(
|
||||
session=session,
|
||||
message=message,
|
||||
payment_db_id=payment_db_id,
|
||||
months=months,
|
||||
stars_amount=stars_amount,
|
||||
i18n_data=i18n_data,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
|
||||
router = Router(name="user_subscription_payments_selection_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("subscribe_period:"))
|
||||
async def select_subscription_period_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
|
||||
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
|
||||
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
|
||||
try:
|
||||
months = float(callback.data.split(":")[-1])
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid subscription period in callback_data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
price_source = traffic_packages if traffic_mode else settings.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)
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
|
||||
if price_rub is None:
|
||||
if traffic_mode and not price_source and stars_price is not None:
|
||||
currency_methods_enabled = any(
|
||||
[
|
||||
settings.FREEKASSA_ENABLED,
|
||||
settings.PLATEGA_ENABLED,
|
||||
settings.SEVERPAY_ENABLED,
|
||||
settings.YOOKASSA_ENABLED,
|
||||
settings.CRYPTOPAY_ENABLED,
|
||||
]
|
||||
)
|
||||
if currency_methods_enabled:
|
||||
logging.error(
|
||||
"Currency price missing for traffic option %s while fiat providers are enabled.", # noqa: E501
|
||||
months,
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
price_rub = 0.0
|
||||
currency_symbol_val = "⭐"
|
||||
else:
|
||||
logging.error(
|
||||
f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}." # noqa: E501
|
||||
)
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
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,
|
||||
stars_price,
|
||||
currency_symbol_val,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
sale_mode="traffic" if traffic_mode else "subscription",
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text_content, reply_markup=reply_markup)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
|
||||
)
|
||||
await callback.message.answer(text_content, reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,842 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_main_menu_markup,
|
||||
get_payment_url_keyboard,
|
||||
get_yk_autopay_choice_keyboard,
|
||||
get_yk_saved_cards_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, user_billing_dal
|
||||
|
||||
router = Router(name="user_subscription_payments_yookassa_router")
|
||||
|
||||
|
||||
def _format_value(val: float) -> str:
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
|
||||
|
||||
def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
|
||||
try:
|
||||
parts = payload.split(":")
|
||||
value = float(parts[0])
|
||||
price = float(parts[1])
|
||||
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
||||
return value, price, sale_mode
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
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 _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
|
||||
|
||||
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
|
||||
|
||||
if _is_yoomoney_network(network):
|
||||
inferred_last4 = last4 or (_extract_last4(network or "") or "****")
|
||||
title = get_text("payment_method_wallet_title", last4=inferred_last4)
|
||||
elif last4:
|
||||
network_name = network or get_text("payment_network_card")
|
||||
title = get_text("payment_method_card_title", network=network_name, last4=last4)
|
||||
else:
|
||||
network_name = network or get_text("payment_network_generic")
|
||||
title = get_text("payment_method_generic_title", network=network_name)
|
||||
return f"⭐ {title}" if is_default else title
|
||||
|
||||
|
||||
async def _initiate_yk_payment(
|
||||
callback: types.CallbackQuery,
|
||||
*,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
yookassa_service: YooKassaService,
|
||||
i18n: Optional[JsonI18n],
|
||||
current_lang: str,
|
||||
get_text,
|
||||
user_id: int,
|
||||
months: int,
|
||||
price_rub: float,
|
||||
currency_code_for_yk: str,
|
||||
save_payment_method: bool,
|
||||
back_callback: str,
|
||||
payment_method_id: Optional[str] = None,
|
||||
selected_method_internal_id: Optional[int] = None,
|
||||
sale_mode: str = "subscription",
|
||||
) -> bool:
|
||||
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
|
||||
if not callback.message:
|
||||
return False
|
||||
|
||||
sale_base = _sale_mode_base(sale_mode)
|
||||
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))
|
||||
)
|
||||
)
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code_for_yk,
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"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,
|
||||
}
|
||||
|
||||
db_payment_record = None
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." # noqa: E501
|
||||
)
|
||||
except Exception as e_db_payment:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
if not db_payment_record:
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
yookassa_metadata = {
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(db_payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
}
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
|
||||
yookassa_metadata["traffic_gb"] = str(months)
|
||||
if payment_method_id:
|
||||
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
||||
|
||||
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
payment_response_yk = await yookassa_service.create_payment(
|
||||
amount=price_rub,
|
||||
currency=currency_code_for_yk,
|
||||
description=payment_description,
|
||||
metadata=yookassa_metadata,
|
||||
receipt_email=receipt_email_for_yk,
|
||||
save_payment_method=save_payment_method,
|
||||
payment_method_id=payment_method_id,
|
||||
)
|
||||
|
||||
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
||||
pm = payment_response_yk.get("payment_method")
|
||||
try:
|
||||
if pm and pm.get("id"):
|
||||
pm_type = pm.get("type")
|
||||
title = pm.get("title")
|
||||
card = pm.get("card") or {}
|
||||
account_number = pm.get("account_number") or pm.get("account")
|
||||
if isinstance(card, dict) and (pm_type or "").lower() in {
|
||||
"bank_card",
|
||||
"bank-card",
|
||||
"card",
|
||||
}:
|
||||
display_network = card.get("card_type") or title or "Card"
|
||||
display_last4 = card.get("last4")
|
||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||
display_network = "YooMoney"
|
||||
display_last4 = (
|
||||
account_number[-4:]
|
||||
if isinstance(account_number, str) and len(account_number) >= 4
|
||||
else None
|
||||
)
|
||||
else:
|
||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||
display_last4 = None
|
||||
await user_billing_dal.upsert_yk_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
payment_method_id=pm["id"],
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
)
|
||||
try:
|
||||
await user_billing_dal.upsert_user_payment_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
provider_payment_method_id=pm["id"],
|
||||
provider="yookassa",
|
||||
card_last4=display_last4,
|
||||
card_network=display_network,
|
||||
set_default=save_payment_method,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception("Failed to save YooKassa payment method preliminarily")
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=payment_response_yk.get("status", "pending"),
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
if selected_method_internal_id is not None:
|
||||
try:
|
||||
await user_billing_dal.set_user_default_payment_method(
|
||||
session, user_id, selected_method_internal_id
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to set default payment method after initiating payment"
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_update_ykid:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
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",
|
||||
months=int(months),
|
||||
traffic_gb=_format_value(months),
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response_yk["confirmation_url"],
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=back_callback,
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
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",
|
||||
months=int(months),
|
||||
traffic_gb=_format_value(months),
|
||||
),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response_yk["confirmation_url"],
|
||||
current_lang,
|
||||
i18n,
|
||||
back_callback=back_callback,
|
||||
back_text_key="back_to_payment_methods_button",
|
||||
),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
if payment_response_yk and payment_method_id:
|
||||
status_to_store = payment_response_yk.get("status", "pending")
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=status_to_store,
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
if selected_method_internal_id is not None:
|
||||
try:
|
||||
await user_billing_dal.set_user_default_payment_method(
|
||||
session, user_id, selected_method_internal_id
|
||||
)
|
||||
except Exception:
|
||||
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()
|
||||
logging.error(
|
||||
f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
message_text = get_text("yookassa_autopay_charge_initiated")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to notify about saved-card charge start: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
message_text,
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, db_payment_record.payment_id, "failed_creation"
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e_db_fail_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
logging.error(
|
||||
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" # noqa: E501
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk:"))
|
||||
async def pay_yk_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
yookassa_service: YooKassaService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service is not configured or unavailable.")
|
||||
target_msg_edit = callback.message
|
||||
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parsed = _parse_offer_payload(data_payload)
|
||||
if not parsed:
|
||||
logging.error(f"Invalid pay_yk payload structure: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
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_require_binding = bool(
|
||||
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
|
||||
)
|
||||
saved_methods: List = []
|
||||
if autopay_enabled:
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
if autopay_enabled and saved_methods:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_flow_prompt"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=True,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_flow_prompt"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=True,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=f"subscribe_period:{_format_value(months)}",
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@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,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service unavailable for pay_yk_new.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_new data in callback: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parsed = _parse_offer_payload(data_payload)
|
||||
if not parsed:
|
||||
logging.error(f"Invalid pay_yk_new payload structure: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
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_require_binding = bool(
|
||||
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
|
||||
)
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=f"subscribe_period:{_format_value(months)}",
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@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,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_saved_list data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
if len(parts) < 2:
|
||||
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
page = int(parts[2]) if len(parts) > 2 else 0
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
if not saved_methods:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_no_saved_cards"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=False,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to display no-saved-card notice: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_no_saved_cards"),
|
||||
reply_markup=get_yk_autopay_choice_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
has_saved_cards=False,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
cards: List[Tuple[str, str]] = []
|
||||
for method in saved_methods:
|
||||
title = _format_saved_payment_method_title(
|
||||
get_text, method.card_network, method.card_last4, method.is_default
|
||||
)
|
||||
cards.append((str(method.method_id), title))
|
||||
|
||||
per_page = 5
|
||||
max_page = max(0, (len(cards) - 1) // per_page)
|
||||
page = max(0, min(page, max_page))
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
get_text("yookassa_autopay_choose_saved_card"),
|
||||
reply_markup=get_yk_saved_cards_keyboard(
|
||||
cards,
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
page=page,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Failed to display saved card list: {e_edit}")
|
||||
try:
|
||||
await callback.message.answer(
|
||||
get_text("yookassa_autopay_choose_saved_card"),
|
||||
reply_markup=get_yk_saved_cards_keyboard(
|
||||
cards,
|
||||
months,
|
||||
price_rub,
|
||||
current_lang,
|
||||
i18n,
|
||||
page=page,
|
||||
sale_mode=sale_mode,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@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,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
|
||||
try:
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid pay_yk_use_saved data: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
if len(parts) < 3:
|
||||
logging.error(f"pay_yk_use_saved payload missing components: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
method_identifier = parts[2]
|
||||
user_id = callback.from_user.id
|
||||
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
session, user_id, provider="yookassa"
|
||||
)
|
||||
except Exception as e_list:
|
||||
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
||||
saved_methods = []
|
||||
|
||||
selected_method = None
|
||||
for method in saved_methods:
|
||||
if method_identifier.isdigit():
|
||||
if method.method_id == int(method_identifier):
|
||||
selected_method = method
|
||||
break
|
||||
if method.provider_payment_method_id == method_identifier:
|
||||
selected_method = method
|
||||
break
|
||||
|
||||
if not selected_method:
|
||||
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:
|
||||
pass
|
||||
return
|
||||
|
||||
currency_code_for_yk = "RUB"
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
session=session,
|
||||
yookassa_service=yookassa_service,
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
get_text=get_text,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=False,
|
||||
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
|
||||
payment_method_id=selected_method.provider_payment_method_id,
|
||||
selected_method_internal_id=selected_method.method_id,
|
||||
sale_mode=sale_mode,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,315 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_connect_and_main_keyboard,
|
||||
get_main_menu_inline_keyboard,
|
||||
)
|
||||
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")
|
||||
|
||||
|
||||
async def request_trial_confirmation_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
user_id = callback.from_user.id
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if settings.TRIAL_ENABLED:
|
||||
if not await subscription_service.has_had_any_subscription(session, user_id):
|
||||
pass
|
||||
|
||||
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),
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
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),
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Directly activate trial without confirmation
|
||||
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
|
||||
|
||||
final_message_text_in_chat = ""
|
||||
show_trial_button_after_action = False
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
await callback.answer(_("trial_activated_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
|
||||
settings, activation_result.get("subscription_url")
|
||||
)
|
||||
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_display = (
|
||||
f"{traffic_gb_val} GB"
|
||||
if traffic_gb_val and traffic_gb_val > 0
|
||||
else _("traffic_unlimited")
|
||||
)
|
||||
|
||||
final_message_text_in_chat = _(
|
||||
"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"
|
||||
),
|
||||
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:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}")
|
||||
else:
|
||||
message_key_from_service = (
|
||||
activation_result.get("message_key", "trial_activation_failed")
|
||||
if activation_result
|
||||
else "trial_activation_failed"
|
||||
)
|
||||
final_message_text_in_chat = _(message_key_from_service)
|
||||
try:
|
||||
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
|
||||
):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
reply_markup = (
|
||||
get_connect_and_main_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, show_trial_button_after_action
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Could not edit trial result message: {e_edit}. Sending new one.")
|
||||
|
||||
if callback.message:
|
||||
await callback.message.answer(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "trial_action:confirm_activate")
|
||||
async def confirm_activate_trial_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
user_id = callback.from_user.id
|
||||
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
try:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if not settings.TRIAL_ENABLED:
|
||||
try:
|
||||
await callback.answer(_("trial_feature_disabled"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
return
|
||||
|
||||
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
|
||||
|
||||
final_message_text_in_chat = ""
|
||||
show_trial_button_after_action = False
|
||||
config_link_display_for_trial = None
|
||||
config_link_for_trial = None
|
||||
connect_button_url_for_trial = None
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
try:
|
||||
await callback.answer(_("trial_activated_alert"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
|
||||
settings, activation_result.get("subscription_url")
|
||||
)
|
||||
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_display = (
|
||||
f"{traffic_gb_val} GB"
|
||||
if traffic_gb_val and traffic_gb_val > 0
|
||||
else _("traffic_unlimited")
|
||||
)
|
||||
|
||||
final_message_text_in_chat = _(
|
||||
"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"
|
||||
),
|
||||
config_link=config_link_for_trial,
|
||||
traffic_gb=traffic_display,
|
||||
)
|
||||
else:
|
||||
message_key_from_service = (
|
||||
activation_result.get("message_key", "trial_activation_failed")
|
||||
if activation_result
|
||||
else "trial_activation_failed"
|
||||
)
|
||||
final_message_text_in_chat = _(message_key_from_service)
|
||||
try:
|
||||
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
|
||||
):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
reply_markup = (
|
||||
get_connect_and_main_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display_for_trial,
|
||||
connect_button_url=connect_button_url_for_trial,
|
||||
)
|
||||
if activation_result and activation_result.get("activated")
|
||||
else get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, show_trial_button_after_action
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(f"Could not edit trial result message: {e_edit}. Sending new one.")
|
||||
|
||||
if callback.message:
|
||||
await callback.message.answer(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
if activation_result and activation_result.get("activated") and end_date_obj:
|
||||
notification_service = NotificationService(callback.bot, settings, i18n)
|
||||
await notification_service.notify_trial_activation(user_id, end_date_obj)
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
|
||||
await _ad_dal.mark_trial_activated(session, user_id)
|
||||
await session.commit()
|
||||
except Exception as e_mark:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:cancel_trial")
|
||||
async def cancel_trial_activation(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
await send_main_menu(callback, settings, i18n_data, subscription_service, session, is_edit=True)
|
||||
Reference in New Issue
Block a user