Files
remnawave-minishop/backend/bot/payment_providers/shared/success.py
T

408 lines
13 KiB
Python

from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Optional
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import NotificationService
from bot.utils.config_link import prepare_config_links
from bot.utils.install_links import (
append_install_share_link_text,
ensure_user_install_guide_links,
)
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from db.dal import payment_dal, user_dal
from db.models import Payment, User
from .common import Translator, format_human_units, make_translator, sale_mode_base
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
def is_traffic_sale_base(sale_base: str) -> bool:
return sale_base in _TRAFFIC_MODES
async def resolve_user_language(
session: AsyncSession,
*,
user_id: int,
db_user: Optional[User],
settings: Any,
) -> tuple[Optional[User], str]:
"""Return the loaded user and the language to use for messaging."""
if db_user is None:
db_user = await user_dal.get_user_by_id(session, user_id)
language = (
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
)
return db_user, language
async def resolve_inviter_name(
session: AsyncSession,
translator: Translator,
db_user: Optional[User],
) -> str:
"""Return a display name for the user's inviter, or the localized placeholder."""
placeholder = translator("friend_placeholder")
if not db_user or not db_user.referred_by_id:
return placeholder
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if not inviter:
return placeholder
if inviter.first_name:
safe_name = sanitize_display_name(inviter.first_name)
if safe_name:
return safe_name
if inviter.username:
return username_for_display(inviter.username, with_at=False)
return placeholder
@dataclass
class SuccessMessage:
"""Inputs for ``build_success_message``."""
translator: Translator
sale_mode: str
months: Any
base_end_date: Optional[datetime]
final_end_date: Optional[datetime]
config_link_text: str
applied_referee_bonus_days: int = 0
applied_promo_bonus_days: int = 0
inviter_name: Optional[str] = None
fallback_date_text: str = ""
def _fmt_date(dt: Optional[datetime], fallback: str) -> str:
return dt.strftime("%Y-%m-%d") if dt else fallback
def build_success_message(payload: SuccessMessage) -> str:
"""Render the post-payment user-facing text.
Picks one of: ``payment_successful_traffic_full`` /
``payment_successful_with_referral_bonus_full`` /
``payment_successful_with_promo_full`` / ``payment_successful_full``.
"""
base = sale_mode_base(payload.sale_mode)
_ = payload.translator
end_text = _fmt_date(payload.final_end_date, payload.fallback_date_text)
if is_traffic_sale_base(base):
return _(
"payment_successful_traffic_full",
traffic_gb=format_human_units(payload.months),
end_date=end_text,
config_link=payload.config_link_text,
)
if payload.applied_referee_bonus_days and payload.final_end_date:
base_end_text = _fmt_date(payload.base_end_date or payload.final_end_date, end_text)
return _(
"payment_successful_with_referral_bonus_full",
months=payload.months,
base_end_date=base_end_text,
bonus_days=payload.applied_referee_bonus_days,
final_end_date=end_text,
inviter_name=payload.inviter_name or _("friend_placeholder"),
config_link=payload.config_link_text,
)
if payload.applied_promo_bonus_days and payload.final_end_date:
return _(
"payment_successful_with_promo_full",
months=payload.months,
bonus_days=payload.applied_promo_bonus_days,
end_date=end_text,
config_link=payload.config_link_text,
)
return _(
"payment_successful_full",
months=payload.months,
end_date=end_text,
config_link=payload.config_link_text,
)
async def send_success_message_to_user(
*,
bot: Bot,
user_id: int,
text: str,
language: str,
i18n: Any,
settings: Any,
config_link_display: Optional[str],
connect_button_url: Optional[str],
install_share_url: Optional[str] = None,
include_keyboard: bool = True,
log_prefix: str = "payment_providers",
) -> None:
"""Send the rendered success text with the standard connect keyboard."""
markup = None
if include_keyboard:
markup = get_connect_and_main_keyboard(
language,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
install_share_url=install_share_url,
preserve_message=True,
)
try:
await bot.send_message(
user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception:
logging.exception("%s: failed to notify user %s.", log_prefix, user_id)
async def notify_admins_payment_received(
*,
bot: Bot,
settings: Any,
i18n: Any,
user_id: int,
amount: float,
currency: str,
months_for_admin: int,
traffic_gb_for_admin: Optional[float],
payment_provider: str,
username: Optional[str],
traffic_is_premium: bool,
tariff_key: Optional[str],
log_prefix: str = "payment_providers",
) -> None:
"""Push the standard ``notify_payment_received`` to the admin log channel."""
try:
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_payment_received(
user_id=user_id,
amount=amount,
currency=currency,
months=months_for_admin,
traffic_gb=traffic_gb_for_admin,
payment_provider=payment_provider,
username=username,
traffic_is_premium=traffic_is_premium,
tariff_key=tariff_key,
)
except Exception:
logging.exception("%s: failed to notify admins.", log_prefix)
@dataclass
class PaymentSuccessRequest:
"""All the inputs ``finalize_successful_payment`` needs."""
bot: Bot
settings: Any
i18n: Any
session: AsyncSession
subscription_service: Any
referral_service: Any
payment: Payment
user_id: int
amount: float
currency: str
sale_mode: str
months: Any
traffic_amount: Optional[float]
provider_subscription: str
provider_notification: str
db_user: Optional[User] = None
log_prefix: str = "payment_providers"
activation_extra_kwargs: dict = field(default_factory=dict)
skip_keyboard: bool = False
text_prefix: Optional[str] = None
@dataclass
class PaymentSuccessOutcome:
activation: Optional[dict]
referral_bonus: Optional[dict]
final_end_date: Optional[datetime]
applied_referee_bonus_days: int
applied_promo_bonus_days: int
db_user: Optional[User]
language: str
async def finalize_successful_payment(
req: PaymentSuccessRequest,
) -> Optional[PaymentSuccessOutcome]:
"""Activate the subscription, apply referral bonus, notify user + admins.
Returns ``None`` if the activation pipeline failed mid-way (errors are
logged and the session is rolled back). On success returns an outcome
object so callers can drive extra side-effects (e.g. yookassa LKNPD
receipts) using the same activation result.
"""
base = sale_mode_base(req.sale_mode)
is_subscription = base == "subscription"
is_traffic = is_traffic_sale_base(base)
activation_months = (
int(float(req.months)) if is_subscription else int(float(req.traffic_amount or req.months))
)
traffic_gb_for_activation = float(req.traffic_amount or req.months) if is_traffic else None
try:
activation = await req.subscription_service.activate_subscription(
req.session,
req.user_id,
activation_months,
req.amount,
req.payment.payment_id,
provider=req.provider_subscription,
sale_mode=req.sale_mode,
traffic_gb=traffic_gb_for_activation,
**req.activation_extra_kwargs,
)
referral_bonus = None
if is_subscription:
referral_bonus = await req.referral_service.apply_referral_bonuses_for_payment(
req.session,
req.user_id,
activation_months or 1,
current_payment_db_id=req.payment.payment_id,
skip_if_active_before_payment=False,
)
await req.session.commit()
except Exception:
await req.session.rollback()
logging.exception(
"%s: failed to activate subscription for payment %s.",
req.log_prefix,
req.payment.payment_id,
)
return None
db_user, language = await resolve_user_language(
req.session,
user_id=req.user_id,
db_user=req.db_user,
settings=req.settings,
)
translator = make_translator(req.i18n, language)
raw_config_link = activation.get("subscription_url") if activation else None
config_link_display, connect_button_url = await prepare_config_links(
req.settings, raw_config_link
)
config_link_text = config_link_display or translator("config_link_not_available")
base_end_date = activation.get("end_date") if activation else None
final_end_date = base_end_date
applied_referee_bonus_days = 0
applied_promo_bonus_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
inviter_name: Optional[str] = None
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end_date = referral_bonus["referee_new_end_date"]
applied_referee_bonus_days = referral_bonus.get("referee_bonus_applied_days", 0) or 0
inviter_name = await resolve_inviter_name(req.session, translator, db_user)
success_text = build_success_message(
SuccessMessage(
translator=translator,
sale_mode=req.sale_mode,
months=(
activation_months
if is_subscription
else format_human_units(req.traffic_amount or req.months)
),
base_end_date=base_end_date,
final_end_date=final_end_date,
config_link_text=config_link_text,
applied_referee_bonus_days=applied_referee_bonus_days,
applied_promo_bonus_days=applied_promo_bonus_days,
inviter_name=inviter_name,
)
)
if req.text_prefix:
success_text = f"{req.text_prefix}\n{success_text}"
install_share_url = None
if not req.skip_keyboard:
install_links = await ensure_user_install_guide_links(
req.session,
req.settings,
req.user_id,
)
install_share_url = install_links.public_share_url
if install_share_url:
try:
await req.session.commit()
success_text = append_install_share_link_text(
success_text,
translator,
install_share_url,
)
except Exception:
await req.session.rollback()
logging.exception(
"%s: failed to persist install guide share token for user %s.",
req.log_prefix,
req.user_id,
)
install_share_url = None
await send_success_message_to_user(
bot=req.bot,
user_id=req.user_id,
text=success_text,
language=language,
i18n=req.i18n,
settings=req.settings,
config_link_display=config_link_display,
connect_button_url=connect_button_url,
install_share_url=install_share_url,
include_keyboard=not req.skip_keyboard,
log_prefix=req.log_prefix,
)
refreshed_payment = await payment_dal.get_payment_by_db_id(req.session, req.payment.payment_id)
tariff_key = getattr(refreshed_payment or req.payment, "tariff_key", None)
await notify_admins_payment_received(
bot=req.bot,
settings=req.settings,
i18n=req.i18n,
user_id=req.user_id,
amount=req.amount,
currency=req.currency,
months_for_admin=activation_months if is_subscription else 0,
traffic_gb_for_admin=traffic_gb_for_activation,
payment_provider=req.provider_notification,
username=db_user.username if db_user else None,
traffic_is_premium=base == "premium_topup",
tariff_key=tariff_key,
log_prefix=req.log_prefix,
)
return PaymentSuccessOutcome(
activation=activation,
referral_bonus=referral_bonus,
final_end_date=final_end_date,
applied_referee_bonus_days=applied_referee_bonus_days,
applied_promo_bonus_days=applied_promo_bonus_days,
db_user=db_user,
language=language,
)