Files
3252a8 fbb89793cb fix: separate HWID device renewal flows
Keep one-off device top-ups scoped to the active subscription term and move device renewal into subscription checkout.

Carry HWID renewal metadata through provider callbacks and webhooks, including YooKassa saved-card flows.

Add admin extension controls, docs, demo data, and regression coverage.
2026-06-03 23:51:46 +03:00

497 lines
16 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 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,
sale_mode_tariff_key,
)
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
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]
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,
)
if base in _HWID_DEVICE_MODES:
return _(
"payment_successful_hwid_devices_full",
count=format_human_units(payload.months),
)
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"),
)
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,
)
return _(
"payment_successful_full",
months=payload.months,
end_date=end_text,
)
def append_hwid_renewal_note(
text: str,
translator: Translator,
*,
count: Any,
valid_until: Optional[datetime],
) -> str:
try:
count_int = int(count or 0)
except (TypeError, ValueError):
count_int = 0
if count_int <= 0:
return text
date_text = valid_until.strftime("%Y-%m-%d") if valid_until else ""
note = translator(
"payment_successful_hwid_devices_renewal_note",
count=format_human_units(count_int),
date=date_text,
)
return f"{text}\n\n{note}"
def append_hwid_renewed_note(
text: str,
translator: Translator,
*,
count: Any,
valid_until: Optional[datetime],
) -> str:
try:
count_int = int(count or 0)
except (TypeError, ValueError):
count_int = 0
if count_int <= 0:
return text
date_text = valid_until.strftime("%Y-%m-%d") if valid_until else ""
note = translator(
"payment_successful_hwid_devices_renewed_note",
count=format_human_units(count_int),
date=date_text,
)
return f"{text}\n\n{note}"
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",
email: Optional[str] = None,
) -> 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,
email=email,
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,
tariff_key=sale_mode_tariff_key(req.sale_mode),
)
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,
)
try:
await payment_dal.update_payment_status_by_db_id(
req.session,
req.payment.payment_id,
"activation_failed",
)
await req.session.commit()
except Exception:
await req.session.rollback()
logging.exception(
"%s: failed to mark payment %s activation_failed.",
req.log_prefix,
req.payment.payment_id,
)
return None
try:
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
await invalidate_webapp_user_caches(
req.settings,
req.user_id,
include_devices=True,
)
except Exception:
logging.exception(
"%s: failed to invalidate webapp caches for user %s.",
req.log_prefix,
req.user_id,
)
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
)
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,
applied_referee_bonus_days=applied_referee_bonus_days,
applied_promo_bonus_days=applied_promo_bonus_days,
inviter_name=inviter_name,
)
)
if is_subscription and activation:
if activation.get("hwid_devices_renewed_count"):
success_text = append_hwid_renewed_note(
success_text,
translator,
count=activation.get("hwid_devices_renewed_count"),
valid_until=final_end_date or activation.get("hwid_devices_renewed_until"),
)
else:
success_text = append_hwid_renewal_note(
success_text,
translator,
count=activation.get("hwid_devices_renewal_recommended_count"),
valid_until=activation.get("hwid_devices_valid_until"),
)
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()
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,
email=getattr(db_user, "email", None) 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,
)