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.
This commit is contained in:
3252a8
2026-06-03 23:51:46 +03:00
parent a06884d816
commit fbb89793cb
48 changed files with 2410 additions and 224 deletions
+7 -1
View File
@@ -1515,6 +1515,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
return _error(400, "invalid_days")
if days <= 0:
return _error(400, "invalid_days")
extend_hwid_devices = payload.get("extend_hwid_devices")
extend_hwid_devices = True if extend_hwid_devices is None else bool(extend_hwid_devices)
subscription_service = request.app.get("subscription_service")
if subscription_service is None:
@@ -1527,6 +1529,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
target_id,
days,
"admin_extend_subscription_webapp",
extend_hwid_devices=extend_hwid_devices,
)
if not new_end:
await session.rollback()
@@ -1537,7 +1540,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
{
"user_id": actor_id,
"event_type": "admin_extend_subscription_webapp",
"content": f"+{days}d -> {new_end.isoformat()}",
"content": (
f"+{days}d -> {new_end.isoformat()} "
f"(hwid={'yes' if extend_hwid_devices else 'no'})"
),
"is_admin_event": True,
"target_user_id": target_id,
},
+43 -22
View File
@@ -121,10 +121,11 @@ async def create_payment_route(request: web.Request) -> web.Response:
hwid_quote: Optional[Dict[str, Any]] = None
requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or ""))
if tariffs_config and requested_sale_mode == "hwid_devices_renewal":
return _json_error(400, "invalid_plan", "Device renewal is part of subscription renewal")
if tariffs_config and requested_sale_mode in {
"hwid_device",
"hwid_devices",
"hwid_devices_renewal",
}:
tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key:
@@ -318,7 +319,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
user_id=user_id,
device_count=int(payment_units),
tariff_key=sale_tariff_key,
renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal",
renewal=False,
currency=currency,
)
if not hwid_quote:
@@ -331,6 +332,25 @@ async def create_payment_route(request: web.Request) -> web.Response:
else:
price = float(hwid_quote["price"])
stars_price = None
elif _sale_mode_base(sale_mode) == "subscription" and bool(
payment_payload.renew_hwid_devices
):
currency = "stars" if method == "stars" else default_currency
sale_tariff_key = _sale_mode_tariff_key(sale_mode)
if sale_tariff_key:
hwid_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
session,
user_id=user_id,
target_tariff_key=sale_tariff_key,
months=int(payment_units),
currency=currency,
)
if hwid_quote:
if method == "stars":
stars_price = int(stars_price or 0) + int(hwid_quote["price"])
else:
price = float(price or 0) + float(hwid_quote["price"])
stars_price = None
admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])}
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
return await _create_subscription_payment(
@@ -691,7 +711,6 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
active = await subscription_service.get_active_subscription_details(session, user_id)
renewal_available = bool(active and active.get("device_topup_renewal_available"))
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None
extra_hwid_valid_until_text = (
active.get("extra_hwid_devices_valid_until_text") if active else None
@@ -713,7 +732,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
renewal=False,
currency=default_currency,
)
if count in currency_counts
@@ -725,7 +744,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
renewal=False,
currency="stars",
)
if count in stars_counts
@@ -733,28 +752,27 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
)
if not currency_quote and not stars_quote:
continue
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
quote = currency_quote or stars_quote
valid_from = quote.get("valid_from")
valid_until = quote.get("valid_until")
plan = {
"id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}",
"id": f"{tariff.key}:hwid:{count}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"sale_mode": sale_mode_for_plan,
"sale_mode": "hwid_devices",
"renewal": False,
"months": count,
"device_count": count,
"price": float(currency_quote.get("price") if currency_quote else 0),
"currency": default_currency_code,
"title": f"+{count}",
"subtitle": tariff.name(lang),
"valid_from": _billing_iso_datetime(
(currency_quote or stars_quote).get("valid_from")
),
"valid_until": _billing_iso_datetime(
(currency_quote or stars_quote).get("valid_until")
),
"proration_ratio": float(
(currency_quote or stars_quote).get("proration_ratio") or 0
),
"valid_from": _billing_iso_datetime(valid_from),
"valid_from_text": _billing_datetime_text(valid_from),
"valid_until": _billing_iso_datetime(valid_until),
"valid_until_text": _billing_datetime_text(valid_until),
"proration_ratio": float(quote.get("proration_ratio") or 0),
}
if stars_quote and int(stars_quote.get("price") or 0) > 0:
plan["stars_price"] = int(stars_quote["price"])
@@ -770,10 +788,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
else int(sub.extra_hwid_devices or 0),
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
"renewal_available": renewal_available,
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
if active and renewal_available
else 0,
"renewal_available": False,
"renewal_recommended_count": 0,
"plans": plans,
}
)
@@ -939,7 +955,11 @@ async def payment_status_route(request: web.Request) -> web.Response:
payment = await _refresh_yookassa_payment_status(request, session, payment)
payment = await _refresh_wata_payment_status(request, session, payment)
if payment.status == "succeeded":
await invalidate_webapp_user_caches(request.app["settings"], user_id)
await invalidate_webapp_user_caches(
request.app["settings"],
user_id,
include_devices=True,
)
return web.json_response(
{
"ok": True,
@@ -1037,6 +1057,7 @@ async def _create_subscription_payment(
description=description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None,
hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
+1
View File
@@ -49,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel):
device_count: Any = None
tariff_key: Optional[constr(max_length=128)] = None
sale_mode: Optional[constr(max_length=64)] = None
renew_hwid_devices: Optional[bool] = None
description: Optional[constr(max_length=4096)] = None
comment: Optional[constr(max_length=4096)] = None
note: Optional[constr(max_length=4096)] = None
+115 -9
View File
@@ -69,13 +69,30 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
and settings.TRIAL_DURATION_DAYS > 0
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
)
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
plans_payload = _serialize_plans(
settings,
lang,
subscription_options=cached["subscription_options"],
stars_subscription_options=cached["stars_subscription_options"],
traffic_packages=cached["traffic_packages"],
stars_traffic_packages=cached["stars_traffic_packages"],
)
await _attach_hwid_renewal_quotes_to_plans(
session,
subscription_service,
user_id=user_id,
settings=settings,
active=active,
local_sub=local_sub,
plans=plans_payload,
)
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
try:
await session.commit()
except Exception:
await session.rollback()
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
telegram_notifications_status = normalize_telegram_notification_status(
@@ -128,14 +145,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
),
"bonus_details": _serialize_referral_bonus_details(settings, lang),
},
"plans": _serialize_plans(
settings,
lang,
subscription_options=cached["subscription_options"],
stars_subscription_options=cached["stars_subscription_options"],
traffic_packages=cached["traffic_packages"],
stars_traffic_packages=cached["stars_traffic_packages"],
),
"plans": plans_payload,
"payment_methods": _serialize_payment_methods(
settings,
request.app,
@@ -438,6 +448,102 @@ def _serialize_subscription(
}
def _webapp_iso_datetime(value: Optional[Any]) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.isoformat()
return str(value)
def _webapp_datetime_text(value: Optional[Any]) -> Optional[str]:
if not value:
return None
if isinstance(value, datetime):
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
return str(value)
async def _attach_hwid_renewal_quotes_to_plans(
session: AsyncSession,
subscription_service: SubscriptionService,
*,
user_id: int,
settings: Settings,
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
plans: List[Dict[str, Any]],
) -> None:
quote_method = getattr(subscription_service, "quote_hwid_device_renewal_for_subscription", None)
if not callable(quote_method):
return
if not active or not local_sub or not settings.tariffs_config:
return
if not active.get("end_date") or int(active.get("extra_hwid_devices") or 0) <= 0:
return
default_currency = default_currency_key_for_settings(settings)
default_currency_code = payment_currency_code(default_currency)
for plan in plans:
if str(plan.get("sale_mode") or "subscription") != "subscription":
continue
target_tariff_key = str(plan.get("tariff_key") or "").strip()
if not target_tariff_key:
continue
try:
months = int(plan.get("months") or 0)
except (TypeError, ValueError):
continue
if months <= 0:
continue
try:
currency_quote = await quote_method(
session,
user_id=user_id,
target_tariff_key=target_tariff_key,
months=months,
currency=default_currency,
)
stars_quote = await quote_method(
session,
user_id=user_id,
target_tariff_key=target_tariff_key,
months=months,
currency="stars",
)
except Exception:
logger.exception(
"Failed to quote HWID renewal for plan %s/%s",
target_tariff_key,
months,
)
continue
quote = currency_quote or stars_quote
if not quote:
continue
valid_from = quote.get("valid_from")
valid_until = quote.get("valid_until")
active_until = quote.get("active_until")
renewal = {
"available": True,
"device_count": int(quote.get("device_count") or 0),
"price": float(currency_quote.get("price") if currency_quote else 0),
"currency": default_currency_code,
"valid_from": _webapp_iso_datetime(valid_from),
"valid_from_text": _webapp_datetime_text(valid_from),
"valid_until": _webapp_iso_datetime(valid_until),
"valid_until_text": _webapp_datetime_text(valid_until),
"active_until": _webapp_iso_datetime(active_until),
"active_until_text": _webapp_datetime_text(active_until),
"pricing_period_months": int(quote.get("pricing_period_months") or months),
}
if stars_quote and int(stars_quote.get("price") or 0) > 0:
renewal["stars_price"] = int(stars_quote["price"])
plan["hwid_renewal"] = renewal
def _build_install_share_link(
request: Optional[web.Request],
settings: Settings,
+32 -10
View File
@@ -319,7 +319,11 @@ async def select_tariff_callback(
@router.callback_query(F.data.startswith("tariff:period:"))
async def select_tariff_period_callback(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
subscription_service: SubscriptionService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
@@ -333,7 +337,9 @@ async def select_tariff_period_callback(
await callback.answer(get_text("error_try_again"), show_alert=True)
return
tariff_key, months_raw = parts[2], parts[3]
callback_context = parts[4] if len(parts) > 4 else None
callback_tokens = [part for part in parts[4:] if part]
callback_context = "bot" if "bot" in callback_tokens else None
renew_hwid_devices = "no_hwid" not in callback_tokens
tariff = config.require(tariff_key)
months = int(months_raw)
default_currency = default_currency_key_for_settings(settings)
@@ -343,6 +349,22 @@ async def select_tariff_period_callback(
if price_rub is None:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
hwid_renewal_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
session,
user_id=callback.from_user.id,
target_tariff_key=tariff.key,
months=months,
currency=default_currency,
)
hwid_renewal_stars_quote = (
await subscription_service.quote_hwid_device_renewal_for_subscription(
session,
user_id=callback.from_user.id,
target_tariff_key=tariff.key,
months=months,
currency="stars",
)
)
markup = get_payment_method_keyboard(
months,
price_rub,
@@ -354,6 +376,9 @@ async def select_tariff_period_callback(
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
user_id=callback.from_user.id,
hwid_renewal_quote=hwid_renewal_quote,
hwid_renewal_stars_quote=hwid_renewal_stars_quote,
hwid_renewal_selected=bool(renew_hwid_devices),
)
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
await callback.answer()
@@ -577,7 +602,6 @@ async def hwid_devices_list_callback(
if not packages:
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
renewal_available = bool(active.get("device_topup_renewal_available"))
markup = get_hwid_device_packages_keyboard(
tariff,
packages,
@@ -585,14 +609,11 @@ async def hwid_devices_list_callback(
i18n,
settings,
back_callback="main_action:my_devices",
renewal=renewal_available,
)
text_key = (
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
renewal=False,
)
await callback.message.edit_text(
get_text(
text_key,
"select_hwid_device_package",
date=active.get("extra_hwid_devices_valid_until_text") or "",
),
reply_markup=markup,
@@ -640,6 +661,7 @@ async def hwid_devices_package_callback(
await callback.answer(get_text("error_try_again"), show_alert=True)
return
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
renewal = action == "renewal_package"
default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings)
currency_quote = await subscription_service.quote_hwid_device_topup(
@@ -647,7 +669,7 @@ async def hwid_devices_package_callback(
user_id=callback.from_user.id,
device_count=count,
tariff_key=tariff.key,
renewal=action == "renewal_package",
renewal=renewal,
currency=default_currency,
)
stars_quote = await subscription_service.quote_hwid_device_topup(
@@ -655,7 +677,7 @@ async def hwid_devices_package_callback(
user_id=callback.from_user.id,
device_count=count,
tariff_key=tariff.key,
renewal=action == "renewal_package",
renewal=renewal,
currency="stars",
)
if not currency_quote and not stars_quote:
+70 -13
View File
@@ -14,6 +14,13 @@ from config.tariffs_config import (
)
BOT_MENU_CONTEXT = "bot"
HWID_RENEWAL_TOKEN = "hwid_renewal"
def sale_mode_tokens(sale_mode: Optional[str]) -> Tuple[str, ...]:
if not sale_mode or "|" not in sale_mode:
return ()
return tuple(token.strip() for token in str(sale_mode).split("|")[1:] if token.strip())
def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]:
@@ -24,16 +31,36 @@ def callback_context_from_back_callback(back_callback: Optional[str]) -> Optiona
def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str:
sale_mode = sale_mode or "subscription"
if not context or "|" in sale_mode:
if not context or context in sale_mode_tokens(sale_mode):
return sale_mode
return f"{sale_mode}|{context}"
def sale_mode_with_token(sale_mode: str, token: str) -> str:
sale_mode = sale_mode or "subscription"
token = str(token or "").strip()
if not token or token in sale_mode_tokens(sale_mode):
return sale_mode
return f"{sale_mode}|{token}"
def sale_mode_without_token(sale_mode: str, token: str) -> str:
sale_mode = sale_mode or "subscription"
token = str(token or "").strip()
if not token or "|" not in sale_mode:
return sale_mode
base, *tokens = sale_mode.split("|")
kept = [item for item in tokens if item.strip() and item.strip() != token]
return "|".join([base, *kept])
def sale_mode_has_token(sale_mode: Optional[str], token: str) -> bool:
return str(token or "").strip() in sale_mode_tokens(sale_mode)
def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]:
if not sale_mode or "|" not in sale_mode:
return None
context = str(sale_mode).split("|", 1)[1].strip()
return context or None
tokens = sale_mode_tokens(sale_mode)
return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None
def callback_suffix_for_context(context: Optional[str]) -> str:
@@ -484,6 +511,9 @@ def get_payment_method_keyboard(
back_callback: Optional[str] = None,
user_id: Optional[int] = None,
is_admin: Optional[bool] = None,
hwid_renewal_quote: Optional[Dict[str, Any]] = None,
hwid_renewal_stars_quote: Optional[Dict[str, Any]] = None,
hwid_renewal_selected: bool = True,
) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
@@ -492,12 +522,39 @@ def get_payment_method_keyboard(
return str(int(val)) if float(val).is_integer() else f"{val:g}"
value_str = _format_value(months)
import logging as _kbd_logging
_kbd_logging.info(
"payment_method_keyboard build: order=%s",
settings.payment_methods_order,
)
payment_sale_mode = sale_mode
selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote
if selected_hwid_quote:
tariff_key = None
sale_mode_main = str(sale_mode or "").split("|", 1)[0]
if "@" in sale_mode_main:
tariff_key = sale_mode_main.split("@", 1)[1]
context = callback_context_from_sale_mode(sale_mode)
toggle_tokens = [f"tariff:period:{tariff_key}:{value_str}"]
if context:
toggle_tokens.append(context)
toggle_tokens.append("no_hwid" if hwid_renewal_selected else "hwid")
builder.row(
InlineKeyboardButton(
text=_(
"payment_hwid_renewal_toggle_on"
if hwid_renewal_selected
else "payment_hwid_renewal_toggle_off",
count=int(selected_hwid_quote.get("device_count") or 0),
price=(
hwid_renewal_quote.get("price")
if hwid_renewal_quote
else hwid_renewal_stars_quote.get("price")
),
currency_symbol=currency_symbol_val,
),
callback_data=":".join(toggle_tokens),
)
)
if hwid_renewal_selected:
payment_sale_mode = sale_mode_with_token(sale_mode, HWID_RENEWAL_TOKEN)
else:
payment_sale_mode = sale_mode_without_token(sale_mode, HWID_RENEWAL_TOKEN)
from bot.payment_providers import get_provider_spec, provider_telegram_button_text
for method in settings.payment_methods_order:
@@ -518,7 +575,7 @@ def get_payment_method_keyboard(
value=value_str,
rub_price=price,
stars_price=stars_price,
sale_mode=sale_mode,
sale_mode=payment_sale_mode,
)
if not callback_data:
continue
@@ -577,7 +634,7 @@ def get_yk_autopay_choice_keyboard(
builder.row(
InlineKeyboardButton(
text=_(key="yookassa_autopay_pay_saved_card_button"),
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}",
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:0{suffix}",
)
)
builder.row(
+1
View File
@@ -114,6 +114,7 @@ class WebAppPaymentContext:
sale_mode: str
currency: str = "RUB"
traffic_gb: Optional[float] = None
hwid_device_count: Optional[int] = None
hwid_valid_from: Optional[Any] = None
hwid_valid_until: Optional[Any] = None
hwid_pricing_period_months: Optional[int] = None
+8 -1
View File
@@ -192,6 +192,7 @@ class CryptoPayService:
sale_mode: str = "subscription",
url_kind: str = "bot",
hwid_quote: Optional[dict] = None,
hwid_device_count: Optional[int] = None,
currency: Optional[str] = None,
) -> Optional[str]:
if not self.configured or not self.client:
@@ -210,7 +211,11 @@ class CryptoPayService:
return None
sale_base = sale_mode_base(sale_mode)
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
amounts = payment_record_amounts(
months=months,
sale_mode=sale_mode,
hwid_device_count=hwid_device_count,
)
try:
payment_record = await payment_dal.create_payment_record(
session,
@@ -252,6 +257,7 @@ class CryptoPayService:
"payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode,
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
"hwid_devices": amounts.purchased_hwid_devices,
}
)
try:
@@ -513,6 +519,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
}
if ctx.hwid_valid_from and ctx.hwid_valid_until
else None,
hwid_device_count=ctx.hwid_device_count,
)
if not url:
return payment_failed()
+1
View File
@@ -609,6 +609,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
payment = await create_webapp_payment_record(
ctx,
@@ -8,8 +8,10 @@ from aiogram import types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import (
HWID_RENEWAL_TOKEN,
get_payment_url_keyboard,
payment_methods_back_callback,
sale_mode_has_token,
)
from bot.middlewares.i18n import JsonI18n
from db.dal import payment_dal
@@ -123,6 +125,27 @@ async def quote_hwid_callback_parts(
subscription_service,
currency: str = "rub",
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
base = sale_mode_base(parts.sale_mode)
if base == "subscription" and sale_mode_has_token(parts.sale_mode, HWID_RENEWAL_TOKEN):
try:
months = int(parts.months)
except (TypeError, ValueError):
return None, None
quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
session,
user_id=user_id,
target_tariff_key=sale_mode_tariff_key(parts.sale_mode),
months=months,
currency=currency,
)
if not quote:
return parts, None
quoted_parts = PaymentCallbackParts(
months=months,
price=float(parts.price or 0) + float(quote.get("price") or 0),
sale_mode=parts.sale_mode,
)
return quoted_parts, quote
if not sale_mode_is_hwid_devices(parts.sale_mode):
return parts, None
device_count = parse_positive_int_units(parts.months)
+15 -3
View File
@@ -100,6 +100,11 @@ def build_payment_record_payload(
base = sale_mode_base(sale_mode)
is_traffic = sale_mode_is_traffic(sale_mode)
is_hwid = sale_mode_is_hwid_devices(sale_mode)
hwid_devices = int(float(months)) if is_hwid else None
if hwid_quote:
quote_devices = parse_positive_int_units(hwid_quote.get("device_count"))
if quote_devices is not None:
hwid_devices = quote_devices
payload = {
"user_id": user_id,
"amount": amount,
@@ -111,9 +116,9 @@ def build_payment_record_payload(
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
"purchased_hwid_devices": int(float(months)) if is_hwid else None,
"purchased_hwid_devices": hwid_devices,
}
if hwid_quote and is_hwid:
if hwid_quote and hwid_devices is not None:
payload.update(
{
"hwid_valid_from": hwid_quote.get("valid_from"),
@@ -164,14 +169,20 @@ def payment_record_amounts(
months: Any,
sale_mode: str,
traffic_gb: Optional[float] = None,
hwid_device_count: Optional[int] = None,
) -> PaymentRecordAmounts:
traffic_sale = sale_mode_is_traffic(sale_mode)
hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode)
units = traffic_gb if traffic_sale and traffic_gb is not None else months
purchased_hwid_devices = int(float(months)) if hwid_devices_sale else None
if not hwid_devices_sale and hwid_device_count is not None:
parsed_hwid_devices = parse_positive_int_units(hwid_device_count)
if parsed_hwid_devices is not None:
purchased_hwid_devices = parsed_hwid_devices
return PaymentRecordAmounts(
months=int(float(units)) if traffic_sale else int(float(months)),
purchased_gb=float(units) if traffic_sale else None,
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
purchased_hwid_devices=purchased_hwid_devices,
tariff_key=sale_mode_tariff_key(sale_mode),
traffic_sale=traffic_sale,
hwid_devices_sale=hwid_devices_sale,
@@ -281,6 +292,7 @@ async def create_webapp_payment_record(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
return await create_base_payment_record(
ctx.session,
@@ -156,6 +156,28 @@ def append_hwid_renewal_note(
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,
@@ -320,8 +342,37 @@ async def finalize_successful_payment(
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,
@@ -363,12 +414,20 @@ async def finalize_successful_payment(
)
)
if is_subscription and activation:
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 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}"
@@ -51,7 +51,7 @@ async def notify_user_payment_failed(
message_key: str = "payment_failed",
) -> None:
"""Send the localized ``payment_failed`` text to the user; never raises."""
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
db_user = await user_dal.get_user_by_id(session, payment.user_id)
language = (
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
)
+1
View File
@@ -344,6 +344,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
payment = await create_webapp_payment_record(
ctx,
+1
View File
@@ -978,6 +978,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
months_for_lookup = (
reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None
+169 -36
View File
@@ -448,6 +448,36 @@ def _metadata_value_present(value: Optional[Any]) -> bool:
return value is not None and str(value).strip() != ""
def _metadata_int(value: Optional[Any]) -> Optional[int]:
if not _metadata_value_present(value):
return None
try:
return int(float(str(value).strip()))
except (TypeError, ValueError):
return None
def _metadata_float(value: Optional[Any]) -> Optional[float]:
if not _metadata_value_present(value):
return None
try:
return float(str(value).strip())
except (TypeError, ValueError):
return None
def _metadata_datetime(value: Optional[Any]) -> Optional[datetime]:
if not _metadata_value_present(value):
return None
try:
parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
except (TypeError, ValueError):
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def _resolve_yookassa_activation_amounts(
*,
sale_mode_base: str,
@@ -559,6 +589,11 @@ async def process_successful_payment(
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")
hwid_valid_from = _metadata_datetime(metadata.get("hwid_valid_from"))
hwid_valid_until = _metadata_datetime(metadata.get("hwid_valid_until"))
hwid_pricing_period_months = _metadata_int(metadata.get("hwid_pricing_period_months"))
hwid_proration_ratio = _metadata_float(metadata.get("hwid_proration_ratio"))
hwid_full_price = _metadata_float(metadata.get("hwid_full_price"))
if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0:
logging.error(
@@ -574,6 +609,19 @@ async def process_successful_payment(
yk_payment_id_from_hook,
)
return
if sale_mode_base == "subscription" and hwid_devices_count > 0:
if (
not hwid_valid_from
or not hwid_valid_until
or hwid_valid_from >= hwid_valid_until
or hwid_full_price is None
):
logging.error(
"YooKassa subscription+HWID payment %s has invalid HWID metadata: %s",
yk_payment_id_from_hook,
metadata,
)
return
payment_record = None
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
@@ -600,6 +648,16 @@ async def process_successful_payment(
or f"Auto-renewal for {months_for_record or subscription_months} months",
provider="yookassa",
provider_payment_id=yk_payment_id_from_hook,
sale_mode=sale_mode,
tariff_key=_sale_mode_tariff_key(sale_mode),
purchased_hwid_devices=(
hwid_devices_count if hwid_devices_count > 0 else None
),
hwid_valid_from=hwid_valid_from,
hwid_valid_until=hwid_valid_until,
hwid_pricing_period_months=hwid_pricing_period_months,
hwid_proration_ratio=hwid_proration_ratio,
hwid_full_price=hwid_full_price,
)
payment_db_id = payment_record.payment_id
except Exception as e_ensure:
@@ -1315,6 +1373,36 @@ def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
return None
def _parse_saved_list_payload(payload: str) -> Optional[Tuple[float, float, int, str]]:
parts = payload.split(":")
if len(parts) < 2:
return None
try:
months = float(parts[0])
price = float(parts[1])
except (ValueError, IndexError):
return None
page = 0
sale_mode = "subscription"
if len(parts) > 2:
try:
page = int(parts[2])
sale_mode = parts[3] if len(parts) > 3 else "subscription"
except ValueError:
sale_mode = parts[2]
return months, price, page, sale_mode
def _metadata_iso(value: Any) -> Optional[str]:
if value is None:
return None
if hasattr(value, "isoformat"):
return value.isoformat()
text = str(value).strip()
return text or None
def _format_saved_payment_method_title(
get_text, network: Optional[str], last4: Optional[str], is_default: bool
) -> str:
@@ -1363,6 +1451,9 @@ async def _initiate_yk_payment(
return False
sale_base = _sale_mode_base(sale_mode)
hwid_device_count = None
if hwid_quote:
hwid_device_count = parse_positive_int_units(hwid_quote.get("device_count"))
payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
@@ -1379,12 +1470,14 @@ async def _initiate_yk_payment(
"status": "pending_yookassa",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"sale_mode": sale_base,
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] 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_SALE_BASES else None,
"purchased_hwid_devices": (
int(months) if sale_base in HWID_DEVICE_SALE_BASES else hwid_device_count
),
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
@@ -1430,6 +1523,19 @@ async def _initiate_yk_payment(
yookassa_metadata["traffic_gb"] = str(months)
if sale_base in HWID_DEVICE_SALE_BASES:
yookassa_metadata["hwid_devices"] = str(months)
elif hwid_device_count:
yookassa_metadata["hwid_devices"] = str(hwid_device_count)
if hwid_quote and hwid_device_count:
hwid_metadata = {
"hwid_valid_from": _metadata_iso(hwid_quote.get("valid_from")),
"hwid_valid_until": _metadata_iso(hwid_quote.get("valid_until")),
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months"),
"hwid_proration_ratio": hwid_quote.get("proration_ratio"),
"hwid_full_price": hwid_quote.get("full_price"),
}
yookassa_metadata.update(
{key: str(value) for key, value in hwid_metadata.items() if value is not None}
)
if payment_method_id:
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
@@ -1709,22 +1815,6 @@ async def pay_yk_callback_handler(
months, price_rub, sale_mode = parsed
hwid_quote = None
if _sale_mode_base(sale_mode) in HWID_DEVICE_SALE_BASES:
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service,
currency=default_currency_key_for_settings(settings),
)
if not quoted_parts:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months = quoted_parts.months
price_rub = quoted_parts.price
user_id = callback.from_user.id
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool(
@@ -1786,6 +1876,22 @@ async def pay_yk_callback_handler(
pass
return
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service,
currency=default_currency_key_for_settings(settings),
)
if not quoted_parts:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months = quoted_parts.months
price_rub = quoted_parts.price
await _initiate_yk_payment(
callback,
settings=settings,
@@ -1863,6 +1969,22 @@ async def pay_yk_new_card_handler(
return
months, price_rub, sale_mode = parsed
hwid_quote = None
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service,
currency=default_currency_key_for_settings(settings),
)
if not quoted_parts:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months = quoted_parts.months
price_rub = quoted_parts.price
user_id = callback.from_user.id
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool(
@@ -1889,6 +2011,7 @@ async def pay_yk_new_card_handler(
save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
sale_mode=sale_mode,
hwid_quote=hwid_quote,
)
try:
await callback.answer()
@@ -1928,27 +2051,15 @@ async def pay_yk_saved_list_handler(
pass
return
parts = data_payload.split(":")
if len(parts) < 2:
parsed_saved_list = _parse_saved_list_payload(data_payload)
if not parsed_saved_list:
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
months, price_rub, page, sale_mode = parsed_saved_list
autopay_enabled = bool(
settings.yookassa_autopayments_active
@@ -2138,6 +2249,24 @@ async def pay_yk_use_saved_handler(
method_identifier = parts[2]
user_id = callback.from_user.id
base_months = months
base_price_rub = price_rub
hwid_quote = None
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=user_id,
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
subscription_service=yookassa_service.subscription_service,
currency=default_currency_key_for_settings(settings),
)
if not quoted_parts:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months = quoted_parts.months
price_rub = quoted_parts.price
try:
saved_methods = await user_billing_dal.list_user_payment_methods(
@@ -2182,10 +2311,13 @@ async def pay_yk_use_saved_handler(
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}",
back_callback=(
f"pay_yk_saved_list:{_format_value(base_months)}:{base_price_rub}:0:{sale_mode}"
),
payment_method_id=selected_method.provider_payment_method_id,
selected_method_internal_id=selected_method.method_id,
sale_mode=sale_mode,
hwid_quote=hwid_quote,
)
try:
await callback.answer()
@@ -2754,6 +2886,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
payment = await create_webapp_payment_record(
ctx,
@@ -2775,8 +2908,8 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
}
if amounts.traffic_sale:
metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months)
if amounts.hwid_devices_sale:
metadata["hwid_devices"] = str(int(float(ctx.months)))
if amounts.purchased_hwid_devices:
metadata["hwid_devices"] = str(int(amounts.purchased_hwid_devices))
if amounts.tariff_key:
metadata["tariff_key"] = amounts.tariff_key
response = await service.create_payment(
@@ -116,6 +116,64 @@ class HwidDeviceMixin:
packages = package_set.for_currency(currency)
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
@staticmethod
def _quote_hwid_full_period_package_price(
tariff: Tariff,
*,
device_count: int,
period_months: int,
currency: str,
) -> Optional[Dict[str, Any]]:
package_set = tariff.hwid_device_packages
if not package_set:
return None
try:
target_count = int(device_count)
months = max(1, int(period_months))
except (TypeError, ValueError):
return None
if target_count <= 0:
return None
packages = [
package
for package in package_set.for_currency(currency)
if int(getattr(package, "count", 0) or 0) > 0
]
if not packages:
return None
best: Dict[int, tuple[float, List[Any]]] = {0: (0.0, [])}
for count in range(1, target_count + 1):
best_for_count: Optional[tuple[float, List[Any]]] = None
for package in packages:
package_count = int(package.count)
previous = best.get(count - package_count)
if previous is None:
continue
price = previous[0] + float(package.price_for_period(months))
selected = [*previous[1], package]
if best_for_count is None or price < best_for_count[0]:
best_for_count = (price, selected)
if best_for_count is not None:
best[count] = best_for_count
resolved = best.get(target_count)
if resolved is None:
return None
full_price, selected_packages = resolved
rounded_price = HwidDeviceMixin._round_hwid_price(full_price, currency=currency)
if currency == "stars":
rounded_price = float(int(math.ceil(rounded_price)))
return {
"price": rounded_price,
"full_price": float(full_price),
"pricing_period_months": months,
"proration_ratio": 1.0,
"currency": currency,
"package_counts": [int(package.count) for package in selected_packages],
}
def _quote_hwid_package_price(
self,
*,
@@ -128,16 +186,10 @@ class HwidDeviceMixin:
) -> Dict[str, Any]:
period_months = max(1, int(getattr(sub, "duration_months", None) or 1))
full_price = float(package.price_for_period(period_months))
period_start = self._as_aware_utc(getattr(sub, "start_date", None))
period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until
inferred_period_start = add_months(period_end, -period_months)
if not period_start or period_start >= period_end or period_start < inferred_period_start:
period_start = inferred_period_start
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
basis_seconds = max(1.0, float(period_months * 30 * 24 * 60 * 60))
billable_start = max(now, valid_from)
billable_seconds = max(0.0, (valid_until - billable_start).total_seconds())
ratio = billable_seconds / basis_seconds
ratio = min(1.0, billable_seconds / basis_seconds)
raw_price = full_price * ratio
price = self._round_hwid_price(raw_price, currency=currency)
min_price = getattr(package, "min_price", None)
@@ -230,6 +282,80 @@ class HwidDeviceMixin:
)
return quote
async def quote_hwid_device_renewal_for_subscription(
self,
session: AsyncSession,
*,
user_id: int,
target_tariff_key: str,
months: int,
currency: str = "rub",
now: Optional[datetime] = None,
) -> Optional[Dict[str, Any]]:
try:
period_months = int(months)
except (TypeError, ValueError):
return None
if period_months <= 0:
return None
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or not db_user.panel_user_uuid:
return None
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
if not sub or not sub.end_date:
return None
now = now or datetime.now(timezone.utc)
subscription_end = self._as_aware_utc(sub.end_date)
if not subscription_end or subscription_end <= now:
return None
try:
tariff = self._resolve_tariff(target_tariff_key)
except Exception:
return None
if not tariff or tariff.billing_model != "period":
return None
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
if base_hwid_limit in (None, 0):
return None
entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary(
session,
subscription_id=sub.subscription_id,
at=now,
)
active_devices = int(entitlement_summary.get("active_devices") or 0)
if active_devices <= 0:
return None
price_quote = self._quote_hwid_full_period_package_price(
tariff,
device_count=active_devices,
period_months=period_months,
currency=currency,
)
if not price_quote:
return None
valid_from = subscription_end
valid_until = add_months(valid_from, period_months)
price_quote.update(
{
"subscription_id": sub.subscription_id,
"tariff_key": tariff.key,
"device_count": active_devices,
"renewal": True,
"valid_from": valid_from,
"valid_until": valid_until,
"active_until": entitlement_summary.get("active_until"),
}
)
return price_quote
async def activate_hwid_device_topup(
self,
session: AsyncSession,
@@ -178,6 +178,7 @@ class SubscriptionLifecycleMixin:
user_id: int,
target_tariff_key: str,
mode: str,
payment_id: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
config = self._tariffs_config()
if not config:
@@ -336,7 +337,7 @@ class SubscriptionLifecycleMixin:
"from_tariff_key": before_tariff_key,
"to_tariff_key": target.key,
"mode": mode,
"payment_id": None,
"payment_id": payment_id,
"days_before": options.get("remaining_days"),
"days_after": (updated.end_date - now).days
if updated.end_date and target.billing_model == "period"
@@ -454,27 +455,11 @@ class SubscriptionLifecycleMixin:
user_id,
tariff_key,
"paid_diff",
payment_id=payment_db_id,
)
if result:
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
if sub:
await tariff_dal.create_tariff_change(
session,
{
"subscription_id": sub.subscription_id,
"from_tariff_key": None,
"to_tariff_key": tariff_key,
"mode": "paid_diff",
"payment_id": payment_db_id,
"days_before": None,
"days_after": (sub.end_date - datetime.now(timezone.utc)).days
if sub.end_date
else None,
"converted_bytes": None,
"eff_price_before": None,
"eff_price_after": sub.effective_monthly_price_rub,
},
)
result["end_date"] = sub.end_date
result["is_active"] = sub.is_active
db_user = await user_dal.get_user_by_id(session, user_id)
@@ -494,10 +479,29 @@ class SubscriptionLifecycleMixin:
await self._record_payment_context(
session,
payment_db_id,
sale_mode=sale_mode_base,
sale_mode=sale_mode,
tariff_key=tariff.key if tariff else tariff_key,
purchased_gb=None,
)
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
try:
hwid_renewal_devices = int(getattr(payment, "purchased_hwid_devices", 0) or 0)
except (TypeError, ValueError):
hwid_renewal_devices = 0
try:
hwid_renewal_price = (
float(getattr(payment, "hwid_full_price", 0) or 0)
if hwid_renewal_devices > 0
else 0.0
)
except (TypeError, ValueError):
hwid_renewal_price = 0.0
hwid_renewal_valid_from = self._as_aware_utc(
getattr(payment, "hwid_valid_from", None) if payment else None
)
hwid_renewal_valid_until = self._as_aware_utc(
getattr(payment, "hwid_valid_until", None) if payment else None
)
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
@@ -569,6 +573,26 @@ class SubscriptionLifecycleMixin:
promo_code_id_from_payment = None
final_end_date = start_date + timedelta(days=duration_days_total)
if hwid_renewal_devices > 0 and hwid_renewal_valid_until and applied_promo_bonus_days:
hwid_renewal_valid_until = hwid_renewal_valid_until + timedelta(
days=applied_promo_bonus_days
)
if payment:
payment.hwid_valid_until = hwid_renewal_valid_until
elif applied_promo_bonus_days > 0 and current_active_sub:
try:
await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
session,
subscription_id=current_active_sub.subscription_id,
at=datetime.now(timezone.utc),
subscription_end_before=start_date,
delta=timedelta(days=applied_promo_bonus_days),
)
except Exception:
logging.exception(
"Failed to extend HWID device purchases for promo payment bonus of user %s",
user_id,
)
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_user_uuid, panel_sub_link_id
)
@@ -614,7 +638,8 @@ class SubscriptionLifecycleMixin:
premium_topup_balance_bytes,
premium_topup_used_bytes,
)
effective_monthly_price = float(payment_amount) / max(1, months_int)
subscription_amount_for_pricing = max(0.0, float(payment_amount) - hwid_renewal_price)
effective_monthly_price = subscription_amount_for_pricing / max(1, months_int)
regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0)
regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False))
traffic_limit_bytes = self._traffic_limit_for_period_tariff(
@@ -698,6 +723,31 @@ class SubscriptionLifecycleMixin:
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
hwid_devices_renewed_count = 0
hwid_devices_renewed_until = None
if hwid_renewal_devices > 0:
if (
hwid_renewal_valid_from
and hwid_renewal_valid_until
and hwid_renewal_valid_from < hwid_renewal_valid_until
):
await tariff_dal.create_hwid_device_purchase(
session,
subscription_id=new_or_updated_sub.subscription_id,
payment_id=payment_db_id,
purchased_devices=hwid_renewal_devices,
valid_from=hwid_renewal_valid_from,
valid_until=hwid_renewal_valid_until,
)
hwid_devices_renewed_count = hwid_renewal_devices
hwid_devices_renewed_until = hwid_renewal_valid_until
else:
logging.warning(
"Skipping HWID renewal purchase for payment %s: invalid window %s -> %s",
payment_db_id,
hwid_renewal_valid_from,
hwid_renewal_valid_until,
)
await self._send_payment_success_email(
db_user=db_user,
@@ -718,8 +768,12 @@ class SubscriptionLifecycleMixin:
"subscription_url": final_subscription_url,
"applied_promo_bonus_days": applied_promo_bonus_days,
"tariff_key": tariff.key if tariff else None,
"hwid_devices_renewal_recommended_count": extra_hwid_devices,
"hwid_devices_valid_until": hwid_devices_valid_until,
"hwid_devices_renewal_recommended_count": 0
if hwid_devices_renewed_count
else extra_hwid_devices,
"hwid_devices_valid_until": hwid_devices_renewed_until or hwid_devices_valid_until,
"hwid_devices_renewed_count": hwid_devices_renewed_count,
"hwid_devices_renewed_until": hwid_devices_renewed_until,
}
async def extend_active_subscription_days(
@@ -728,6 +782,7 @@ class SubscriptionLifecycleMixin:
user_id: int,
bonus_days: int,
reason: str = "bonus",
extend_hwid_devices: bool = True,
) -> Optional[datetime]:
reason_lower = (reason or "").lower()
apply_main_traffic_limit = any(
@@ -798,6 +853,21 @@ class SubscriptionLifecycleMixin:
updated_sub_model = await subscription_dal.update_subscription_end_date(
session, active_sub.subscription_id, new_end_date_obj
)
if updated_sub_model and extend_hwid_devices:
try:
await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
session,
subscription_id=active_sub.subscription_id,
at=now_utc,
subscription_end_before=current_end_date,
delta=timedelta(days=bonus_days),
)
except Exception:
logging.exception(
"Failed to extend HWID device purchases for %s bonus of user %s",
reason,
user_id,
)
if (
apply_main_traffic_limit
@@ -38,7 +38,8 @@ class PaymentContextMixin:
payment.sale_mode = sale_mode
payment.tariff_key = tariff_key
payment.purchased_gb = purchased_gb
payment.purchased_hwid_devices = purchased_hwid_devices
if purchased_hwid_devices is not None:
payment.purchased_hwid_devices = purchased_hwid_devices
if hwid_valid_from is not None:
payment.hwid_valid_from = hwid_valid_from
if hwid_valid_until is not None:
@@ -42,6 +42,8 @@ class RenewalMixin:
months = sub.duration_months or 1
currency = default_payment_currency_code_for_settings(self.settings)
tariff_key = str(getattr(sub, "tariff_key", "") or "").strip() or None
sale_mode = f"subscription@{tariff_key}" if tariff_key else "subscription"
amount = None
tariffs_config = (
self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None
@@ -62,11 +64,55 @@ class RenewalMixin:
logging.error(f"Auto-renew price missing for {months} months")
return False
hwid_quote = None
quote_hwid_renewal = getattr(
self,
"quote_hwid_device_renewal_for_subscription",
None,
)
if tariff_key and callable(quote_hwid_renewal):
try:
hwid_quote = await quote_hwid_renewal(
session,
user_id=sub.user_id,
target_tariff_key=tariff_key,
months=int(months),
currency=default_currency_key_for_settings(self.settings),
)
except Exception:
logging.exception(
"Failed to quote HWID devices for auto-renew user %s",
sub.user_id,
)
hwid_quote = None
if hwid_quote:
amount = float(amount) + float(hwid_quote.get("price") or 0)
metadata = {
"user_id": str(sub.user_id),
"auto_renew_for_subscription_id": str(sub.subscription_id),
"subscription_months": str(months),
"sale_mode": sale_mode,
}
if hwid_quote:
metadata["hwid_devices"] = str(int(hwid_quote.get("device_count") or 0))
for source_key, metadata_key in (
("valid_from", "hwid_valid_from"),
("valid_until", "hwid_valid_until"),
):
value = hwid_quote.get(source_key)
if value:
metadata[metadata_key] = (
value.isoformat() if hasattr(value, "isoformat") else str(value)
)
for key in (
"pricing_period_months",
"proration_ratio",
"full_price",
):
value = hwid_quote.get(key)
if value is not None:
metadata[f"hwid_{key}"] = str(value)
resp = await yk.create_payment(
amount=float(amount),
currency=currency,
+23
View File
@@ -51,6 +51,15 @@ async def ensure_payment_with_provider_id(
description: str,
provider: str,
provider_payment_id: str,
sale_mode: Optional[str] = None,
tariff_key: Optional[str] = None,
purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = None,
hwid_valid_from: Optional[Any] = None,
hwid_valid_until: Optional[Any] = None,
hwid_pricing_period_months: Optional[int] = None,
hwid_proration_ratio: Optional[float] = None,
hwid_full_price: Optional[float] = None,
) -> Payment:
"""Idempotently create a payment record for a provider event.
@@ -72,6 +81,20 @@ async def ensure_payment_with_provider_id(
"provider_payment_id": provider_payment_id,
"provider": provider,
}
optional_fields = {
"sale_mode": sale_mode,
"tariff_key": tariff_key,
"purchased_gb": purchased_gb,
"purchased_hwid_devices": purchased_hwid_devices,
"hwid_valid_from": hwid_valid_from,
"hwid_valid_until": hwid_valid_until,
"hwid_pricing_period_months": hwid_pricing_period_months,
"hwid_proration_ratio": hwid_proration_ratio,
"hwid_full_price": hwid_full_price,
}
payment_payload.update(
{field: value for field, value in optional_fields.items() if value is not None}
)
return await create_payment_record(session, payment_payload)
+58 -1
View File
@@ -1,5 +1,5 @@
import inspect
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, delete, func, or_, select, update
@@ -189,6 +189,63 @@ async def expire_hwid_device_purchases(
return result.rowcount or 0
def _normalize_aware_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
async def extend_hwid_device_purchases_for_subscription_bonus(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
subscription_end_before: Optional[datetime] = None,
delta: timedelta,
) -> int:
if delta.total_seconds() <= 0:
return 0
at = _normalize_aware_utc(at or datetime.now(timezone.utc))
end_before = _normalize_aware_utc(subscription_end_before) if subscription_end_before else None
target_records: List[HwidDevicePurchase] = []
if end_before:
tail_result = await session.execute(
select(HwidDevicePurchase).where(
and_(
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
HwidDevicePurchase.valid_until.is_not(None),
HwidDevicePurchase.valid_until >= end_before,
HwidDevicePurchase.valid_until > at,
or_(
HwidDevicePurchase.valid_from.is_(None),
HwidDevicePurchase.valid_from < end_before,
),
)
)
)
target_records = list(tail_result.scalars().all())
if not target_records:
active_result = await session.execute(
select(HwidDevicePurchase).where(
and_(
*_hwid_active_conditions(subscription_id, at),
HwidDevicePurchase.valid_until.is_not(None),
)
)
)
target_records = list(active_result.scalars().all())
for record in target_records:
if record.valid_until is not None:
record.valid_until = _normalize_aware_utc(record.valid_until) + delta
if target_records:
await session.flush()
return len(target_records)
async def create_tariff_change(
session: AsyncSession,
change_data: Dict[str, Any],