fix: bind HWID top-ups to subscription periods

This commit is contained in:
3252a8
2026-05-25 19:33:50 +03:00
parent 59fa301344
commit c68cb97964
49 changed files with 2027 additions and 119 deletions
+147 -27
View File
@@ -69,9 +69,14 @@ async def create_payment_route(request: web.Request) -> web.Response:
traffic_mode = bool(settings.traffic_sale_mode)
sale_mode = "subscription"
traffic_gb_for_payment: Optional[float] = None
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 in {"hwid_device", "hwid_devices"}:
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:
return _json_error(400, "invalid_plan", "Tariff is not selected")
@@ -79,6 +84,8 @@ async def create_payment_route(request: web.Request) -> web.Response:
tariff = tariffs_config.require(tariff_key)
except Exception:
return _json_error(400, "invalid_plan", "Tariff is not available")
if tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available")
try:
device_count = int(
float(
@@ -89,23 +96,10 @@ async def create_payment_route(request: web.Request) -> web.Response:
)
except (TypeError, ValueError):
return _json_error(400, "invalid_plan", "Invalid device package")
packages = tariff.hwid_device_packages
rub_packages = {
int(package.count): float(package.price)
for package in (packages.rub if packages else [])
}
stars_packages = {
int(package.count): int(float(package.price))
for package in (packages.stars if packages else [])
}
price = rub_packages.get(device_count)
stars_price = stars_packages.get(device_count)
if price is None and method != "stars":
if not tariff.hwid_device_packages:
return _json_error(400, "invalid_plan", "Device package is not available")
if method == "stars" and (stars_price is None or int(stars_price) <= 0):
return _json_error(400, "invalid_plan", "Stars price is not configured")
payment_units = device_count
sale_mode = f"hwid_devices@{tariff.key}"
sale_mode = f"{requested_sale_mode}@{tariff.key}"
elif tariffs_config and requested_sale_mode in {"topup", "premium_topup"}:
tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key:
@@ -253,6 +247,40 @@ async def create_payment_route(request: web.Request) -> web.Response:
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
if _sale_mode_is_hwid_devices(sale_mode):
sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, db_user.panel_user_uuid
)
sale_tariff_key = _sale_mode_tariff_key(sale_mode)
if not sub or not sub.tariff_key or sub.tariff_key != sale_tariff_key:
return _json_error(
400, "subscription_required", "Active tariff subscription is required"
)
try:
active_tariff = tariffs_config.require(sub.tariff_key) if tariffs_config else None
except Exception:
active_tariff = None
if not active_tariff or active_tariff.billing_model != "period":
return _json_error(400, "invalid_plan", "Device top-up is not available")
currency = "stars" if method == "stars" else "rub"
hwid_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=int(payment_units),
tariff_key=sale_tariff_key,
renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal",
currency=currency,
)
if not hwid_quote:
return _json_error(400, "invalid_plan", "Device package is not available")
if method == "stars":
stars_price = int(hwid_quote["price"])
price = 0.0
if stars_price <= 0:
return _json_error(400, "invalid_plan", "Stars price is not configured")
else:
price = 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(
@@ -267,6 +295,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
sale_mode=sale_mode,
traffic_gb=traffic_gb_for_payment,
is_admin=is_admin,
hwid_quote=hwid_quote,
)
@@ -460,7 +489,9 @@ async def tariff_change_options_route(request: web.Request) -> web.Response:
for tariff in config.enabled_tariffs:
if tariff.key == current.key:
continue
options = subscription_service.calculate_tariff_switch_options(sub, tariff)
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, sub, tariff
)
targets.append(_serialize_tariff_change_target(settings, config, tariff, options, lang))
return web.json_response(
{
@@ -537,7 +568,9 @@ async def tariff_change_payment_route(request: web.Request) -> web.Response:
400, "subscription_required", "Active tariff subscription is required"
)
target = config.require(tariff_key)
options = subscription_service.calculate_tariff_switch_options(sub, target)
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, sub, target
)
price = float(options.get("paid_diff_rub") or 0)
if price <= 0:
return _json_error(
@@ -579,20 +612,93 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
400, "subscription_required", "Active tariff subscription is required"
)
tariff = config.require(sub.tariff_key)
if tariff.billing_model != "period":
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)
plans = _serialize_hwid_device_packages(
settings,
tariff,
tariff.hwid_device_packages,
db_user.language_code or settings.DEFAULT_LANGUAGE,
)
renewal_available = bool(active and active.get("device_topup_renewal_available"))
packages = tariff.hwid_device_packages
rub_counts = {int(package.count) for package in (packages.rub if packages else [])}
stars_counts = {int(package.count) for package in (packages.stars if packages else [])}
plans = []
for count in sorted(rub_counts | stars_counts):
rub_quote = (
await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
currency="rub",
)
if count in rub_counts
else None
)
stars_quote = (
await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=count,
tariff_key=tariff.key,
renewal=renewal_available,
currency="stars",
)
if count in stars_counts
else None
)
if not rub_quote and not stars_quote:
continue
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
plan = {
"id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}",
"tariff_key": tariff.key,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"sale_mode": sale_mode_for_plan,
"months": count,
"device_count": count,
"price": float(rub_quote.get("price") if rub_quote else 0),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": f"+{count}",
"subtitle": tariff.name(lang),
"valid_from": (
(rub_quote or stars_quote)["valid_from"].isoformat()
if (rub_quote or stars_quote).get("valid_from")
else None
),
"valid_until": (
(rub_quote or stars_quote)["valid_until"].isoformat()
if (rub_quote or stars_quote).get("valid_until")
else None
),
"proration_ratio": float((rub_quote or stars_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"])
plans.append(plan)
return web.json_response(
{
"ok": True,
"tariff_key": tariff.key,
"tariff_name": tariff.name(db_user.language_code or settings.DEFAULT_LANGUAGE),
"tariff_name": tariff.name(lang),
"current_limit": _coerce_int_or_none(active.get("max_devices")) if active else None,
"extra_hwid_devices": int(sub.extra_hwid_devices or 0),
"extra_hwid_devices": int(active.get("extra_hwid_devices") or 0)
if active
else int(sub.extra_hwid_devices or 0),
"extra_hwid_devices_valid_until": active.get("extra_hwid_devices_valid_until")
if active
else None,
"extra_hwid_devices_valid_until_text": active.get(
"extra_hwid_devices_valid_until_text"
)
if active
else None,
"renewal_available": renewal_available,
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
if active and renewal_available
else 0,
"plans": plans,
}
)
@@ -784,7 +890,11 @@ def _sale_mode_is_traffic(sale_mode: str) -> bool:
def _sale_mode_is_hwid_devices(sale_mode: str) -> bool:
return _sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"}
return _sale_mode_base(sale_mode) in {
"hwid_device",
"hwid_devices",
"hwid_devices_renewal",
}
async def _create_subscription_payment(
@@ -800,6 +910,7 @@ async def _create_subscription_payment(
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
is_admin: bool = False,
hwid_quote: Optional[Dict[str, Any]] = None,
) -> web.Response:
settings: Settings = request.app["settings"]
sale_mode = str(sale_mode or "subscription")
@@ -837,6 +948,15 @@ async def _create_subscription_payment(
description=description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
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")
if hwid_quote
else None,
hwid_proration_ratio=hwid_quote.get("proration_ratio")
if hwid_quote
else None,
hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None,
)
)
+41 -7
View File
@@ -259,7 +259,8 @@ def _serialize_subscription(
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
# max_devices == 0 means unlimited — top-up is pointless in that case.
can_topup_devices = bool(
tariff.has_hwid_device_packages()
tariff.billing_model == "period"
and tariff.has_hwid_device_packages()
and _coerce_int_or_none(active.get("max_devices")) != 0
)
except Exception:
@@ -272,6 +273,19 @@ def _serialize_subscription(
share_token = str(
install_share_token or getattr(local_sub, "install_share_token", "") or ""
).strip()
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until")
if extra_hwid_valid_until and extra_hwid_valid_until.tzinfo is None:
extra_hwid_valid_until = extra_hwid_valid_until.replace(tzinfo=timezone.utc)
extra_hwid_next_valid_from = active.get("extra_hwid_devices_next_valid_from")
if extra_hwid_next_valid_from and extra_hwid_next_valid_from.tzinfo is None:
extra_hwid_next_valid_from = extra_hwid_next_valid_from.replace(tzinfo=timezone.utc)
extra_hwid_count = _coerce_int_or_none(active.get("extra_hwid_devices")) or 0
device_topup_renewal_available = bool(
extra_hwid_count > 0
and extra_hwid_valid_until
and end_date
and extra_hwid_valid_until < end_date
)
return {
"active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN",
@@ -322,7 +336,17 @@ def _serialize_subscription(
"is_throttled": bool(active.get("is_throttled")),
"max_devices": _coerce_int_or_none(active.get("max_devices")),
"base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")),
"extra_hwid_devices": _coerce_int_or_none(active.get("extra_hwid_devices")) or 0,
"extra_hwid_devices": extra_hwid_count,
"extra_hwid_devices_valid_until": extra_hwid_valid_until.isoformat()
if extra_hwid_valid_until
else None,
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until.strftime("%d.%m.%Y %H:%M")
if extra_hwid_valid_until
else None,
"extra_hwid_devices_next_valid_from": extra_hwid_next_valid_from.isoformat()
if extra_hwid_next_valid_from
else None,
"device_topup_renewal_available": device_topup_renewal_available,
"auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
"provider": getattr(local_sub, "provider", None),
}
@@ -378,7 +402,9 @@ def _serialize_plans(
tariff,
tariff.hwid_device_packages,
lang,
),
)
if tariff.billing_model == "period"
else [],
}
if tariff.billing_model == "period":
for months in sorted(tariff.enabled_periods):
@@ -585,10 +611,14 @@ def _serialize_tariff_change_target(
"mode": "recalc_days",
"kind": "free",
"title": "recalc_days",
"days_after": int(options.get("recalc_days") or 0),
"remaining_days": int(options.get("remaining_days") or 0),
}
)
"days_after": int(options.get("recalc_days") or 0),
"remaining_days": int(options.get("remaining_days") or 0),
"converted_hwid_value_rub": float(
options.get("converted_hwid_value_rub") or 0
),
"converted_hwid_days": int(options.get("converted_hwid_days") or 0),
}
)
paid_diff = float(options.get("paid_diff_rub") or 0)
if paid_diff > 0:
actions.append(
@@ -608,6 +638,10 @@ def _serialize_tariff_change_target(
"title": "convert_days_to_gb",
"converted_gb": float(options.get("converted_gb") or 0),
"remaining_days": int(options.get("remaining_days") or 0),
"converted_hwid_value_rub": float(
options.get("converted_hwid_value_rub") or 0
),
"converted_hwid_gb": float(options.get("converted_hwid_gb") or 0),
}
)
actions.extend(
+62 -10
View File
@@ -525,10 +525,14 @@ async def hwid_devices_list_callback(
await callback.answer(get_text("hwid_devices_unlimited_no_topup"), show_alert=True)
return
tariff = config.require(active["tariff_key"])
if tariff.billing_model != "period":
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
packages = tariff.hwid_device_packages.rub if tariff.hwid_device_packages else []
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,
@@ -536,14 +540,31 @@ 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"
)
await callback.message.edit_text(
get_text(
text_key,
date=active.get("extra_hwid_devices_valid_until_text") or "",
),
reply_markup=markup,
)
await callback.message.edit_text(get_text("select_hwid_device_package"), reply_markup=markup)
await callback.answer()
@router.callback_query(F.data.startswith("hwid_devices:package:"))
@router.callback_query(F.data.startswith("hwid_devices:renewal_package:"))
async def hwid_devices_package_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")
@@ -552,8 +573,11 @@ async def hwid_devices_package_callback(
if not config or not callback.message:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
return
_, _, tariff_key, count_raw = callback.data.split(":", 3)
_, action, tariff_key, count_raw = callback.data.split(":", 3)
tariff = config.require(tariff_key)
if tariff.billing_model != "period":
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return
count = int(count_raw)
package = next(
(
@@ -566,15 +590,37 @@ async def hwid_devices_package_callback(
if not package:
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"
rub_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=callback.from_user.id,
device_count=count,
tariff_key=tariff.key,
renewal=action == "renewal_package",
currency="rub",
)
stars_quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=callback.from_user.id,
device_count=count,
tariff_key=tariff.key,
renewal=action == "renewal_package",
currency="stars",
)
if not rub_quote and not stars_quote:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
markup = get_payment_method_keyboard(
count,
package.price,
None,
float(rub_quote.get("price") if rub_quote else 0),
int(stars_quote["price"])
if stars_quote and int(stars_quote.get("price") or 0) > 0
else None,
settings.DEFAULT_CURRENCY_SYMBOL,
current_lang,
i18n,
settings,
sale_mode=f"hwid_devices@{tariff.key}",
sale_mode=f"{sale_mode_base}@{tariff.key}",
back_callback="hwid_devices:list",
user_id=callback.from_user.id,
)
@@ -654,7 +700,9 @@ async def tariff_change_select_callback(
if not db_sub:
await callback.answer("Error", show_alert=True)
return
options = subscription_service.calculate_tariff_switch_options(db_sub, target)
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, db_sub, target
)
rows = []
if options["mode"] == "period_to_period":
rows.append(
@@ -741,7 +789,9 @@ async def tariff_change_confirm_apply_callback(
if not db_sub:
await callback.answer("Error", show_alert=True)
return
options = subscription_service.calculate_tariff_switch_options(db_sub, target)
options = await subscription_service.calculate_tariff_switch_options_with_hwid(
session, db_sub, target
)
if mode == "recalc_days":
action_text = f"после перехода останется {options.get('recalc_days', 0)} дн."
elif mode == "convert_days_to_gb":
@@ -1162,7 +1212,8 @@ async def my_subscription_command_handler(
try:
tariff_for_devices = settings.tariffs_config.require(local_sub.tariff_key)
if (
tariff_for_devices.hwid_device_packages
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
):
prepend_rows.append(
@@ -1378,7 +1429,8 @@ async def my_devices_command_handler(
try:
tariff_for_devices = settings.tariffs_config.require(active["tariff_key"])
if (
tariff_for_devices.hwid_device_packages
tariff_for_devices.billing_model == "period"
and tariff_for_devices.hwid_device_packages
and tariff_for_devices.hwid_device_packages.rub
):
devices_kb.append(
@@ -57,8 +57,9 @@ def payment_methods_back_callback(
return f"tariff:package:{tariff_key}:{value}"
if sale_base == "premium_topup" and tariff_key:
return f"tariff:premium_package:{tariff_key}:{value}"
if sale_base in {"hwid_device", "hwid_devices"} and tariff_key:
return f"hwid_devices:package:{tariff_key}:{value}"
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"} and tariff_key:
action = "renewal_package" if sale_base == "hwid_devices_renewal" else "package"
return f"hwid_devices:{action}:{tariff_key}:{value}"
if sale_base == "tariff_upgrade" and tariff_key:
amount = str(price) if price is not None else value
return f"tariff_change:pay:{tariff_key}:{amount}"
@@ -79,7 +80,7 @@ def payment_options_back_callback(sale_mode: str = "subscription") -> str:
return f"tariff:select:{tariff_key}{context_suffix}"
if sale_base in {"topup", "premium_topup"}:
return "tariff_topup:list"
if sale_base in {"hwid_device", "hwid_devices"}:
if sale_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
return "hwid_devices:list"
return subscription_options_callback(context)
@@ -425,6 +426,7 @@ def get_hwid_device_packages_keyboard(
i18n_instance,
settings: Settings,
back_callback: str = "main_action:my_subscription",
renewal: bool = False,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
@@ -437,7 +439,10 @@ def get_hwid_device_packages_keyboard(
price=package.price,
currency_symbol=settings.DEFAULT_CURRENCY_SYMBOL,
),
callback_data=f"hwid_devices:package:{tariff.key}:{package.count}",
callback_data=(
f"hwid_devices:{'renewal_package' if renewal else 'package'}:"
f"{tariff.key}:{package.count}"
),
)
)
builder.row(
+2 -3
View File
@@ -13,10 +13,9 @@ from bot.infra.redis import close_redis
from bot.middlewares.i18n import JsonI18n
from bot.routers import build_root_router
from bot.services.locale_override_service import load_locale_overrides
from bot.services.settings_override_service import load_overrides_from_db
from bot.utils.message_queue import init_queue_manager
from config.settings import Settings
from db.database_setup import init_db_connection
from db.database_setup import init_db, init_db_connection
TELEGRAM_STARTUP_RETRY_DELAY_SECONDS = 2.0
@@ -267,7 +266,7 @@ async def run_bot(settings_param: Settings):
if local_async_session_factory is None:
logging.critical("Failed to initialize database connection and session factory. Exiting.")
return
await load_overrides_from_db(settings_param, local_async_session_factory)
await init_db(settings_param, local_async_session_factory)
dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory)
i18n_instance = extra["i18n_instance"]
await load_locale_overrides(i18n_instance, local_async_session_factory)
+5
View File
@@ -113,6 +113,11 @@ class WebAppPaymentContext:
description: str
sale_mode: str
traffic_gb: Optional[float] = 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
EnabledPredicate = Callable[[Any], bool]
+37 -3
View File
@@ -38,9 +38,12 @@ from .shared import (
parse_payment_callback,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
quote_hwid_callback_parts,
render_payment_link,
sale_mode_base,
sale_mode_is_traffic,
sale_mode_tariff_key,
)
@@ -155,13 +158,14 @@ class CryptoPayService:
description: str,
sale_mode: str = "subscription",
url_kind: str = "bot",
hwid_quote: Optional[dict] = None,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
try:
payment_record = await payment_dal.create_payment_record(
session,
@@ -177,7 +181,17 @@ class CryptoPayService:
"provider": "cryptopay",
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
"purchased_gb": amounts.purchased_gb,
"purchased_hwid_devices": amounts.purchased_hwid_devices,
"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")
if hwid_quote
else None,
"hwid_proration_ratio": hwid_quote.get("proration_ratio")
if hwid_quote
else None,
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
},
)
await session.commit()
@@ -192,7 +206,7 @@ class CryptoPayService:
"subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode,
"traffic_gb": str(months) if is_traffic else None,
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
}
)
try:
@@ -363,6 +377,16 @@ async def pay_crypto_callback_handler(
return
parts = parse_payment_callback(callback.data or "")
if not parts:
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=cryptopay_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
@@ -375,6 +399,7 @@ async def pay_crypto_callback_handler(
amount=parts.price,
description=payment_description,
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
if invoice_url:
@@ -424,6 +449,15 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
description=ctx.description,
sale_mode=ctx.sale_mode,
url_kind="web",
hwid_quote={
"valid_from": ctx.hwid_valid_from,
"valid_until": ctx.hwid_valid_until,
"pricing_period_months": ctx.hwid_pricing_period_months,
"proration_ratio": ctx.hwid_proration_ratio,
"full_price": ctx.hwid_full_price,
}
if ctx.hwid_valid_from and ctx.hwid_valid_until
else None,
)
if not url:
return payment_failed()
@@ -50,6 +50,7 @@ from .shared import (
payment_failed,
payment_unavailable,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
)
@@ -470,6 +471,16 @@ async def pay_fk_callback_handler(
logging.error("Invalid pay_fk data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=freekassa_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = (
getattr(freekassa_service, "default_currency", None)
@@ -486,6 +497,7 @@ async def pay_fk_callback_handler(
months=parts.months,
provider="freekassa",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
+12
View File
@@ -49,6 +49,7 @@ from .shared import (
parse_payment_callback,
payment_failed,
payment_unavailable,
quote_hwid_callback_parts,
render_link_or_fail,
)
@@ -565,6 +566,16 @@ async def pay_heleket_callback_handler(
logging.error("Invalid pay_heleket data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=heleket_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
payment_description = describe_payment(translator, parts)
@@ -577,6 +588,7 @@ async def pay_heleket_callback_handler(
months=parts.months,
provider="heleket",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
+12
View File
@@ -46,6 +46,7 @@ from .shared import (
payment_record_amounts,
payment_unavailable,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
safe_callback_answer,
)
@@ -475,6 +476,16 @@ async def pay_platega_callback_handler(
logging.error("Invalid pay_platega data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=platega_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
@@ -487,6 +498,7 @@ async def pay_platega_callback_handler(
months=parts.months,
provider="platega",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
+12
View File
@@ -47,6 +47,7 @@ from .shared import (
payment_failed,
payment_unavailable,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
)
@@ -414,6 +415,16 @@ async def pay_severpay_callback_handler(
logging.error("Invalid pay_severpay data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=severpay_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
@@ -426,6 +437,7 @@ async def pay_severpay_callback_handler(
months=parts.months,
provider="severpay",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
@@ -15,6 +15,7 @@ from .callbacks import (
notify_service_unavailable,
parse_payment_callback,
payment_link_message_text,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
@@ -55,6 +56,7 @@ from .success import (
PaymentSuccessOutcome,
PaymentSuccessRequest,
SuccessMessage,
append_hwid_renewal_note,
build_success_message,
finalize_successful_payment,
is_traffic_sale_base,
@@ -82,6 +84,7 @@ __all__ = [
"build_payment_description",
"build_payment_record_payload",
"build_success_message",
"append_hwid_renewal_note",
"coerce_payment_db_id",
"create_base_payment_record",
"create_webapp_payment_record",
@@ -113,6 +116,7 @@ __all__ = [
"payment_record_amounts",
"payment_unavailable",
"post_json_request",
"quote_hwid_callback_parts",
"render_link_or_fail",
"render_payment_link",
"resolve_inviter_name",
@@ -21,6 +21,8 @@ from .common import (
format_human_units,
mark_payment_failed_creation,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_tariff_key,
)
@@ -112,6 +114,34 @@ def describe_payment(translator: Translator, parts: PaymentCallbackParts) -> str
)
async def quote_hwid_callback_parts(
*,
session: AsyncSession,
user_id: int,
parts: PaymentCallbackParts,
subscription_service,
currency: str = "rub",
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
if not sale_mode_is_hwid_devices(parts.sale_mode):
return parts, None
quote = await subscription_service.quote_hwid_device_topup(
session,
user_id=user_id,
device_count=int(parts.months),
tariff_key=sale_mode_tariff_key(parts.sale_mode),
renewal=sale_mode_base(parts.sale_mode) == "hwid_devices_renewal",
currency=currency,
)
if not quote:
return None, None
quoted_parts = PaymentCallbackParts(
months=parts.months,
price=float(quote.get("price") or 0),
sale_mode=parts.sale_mode,
)
return quoted_parts, quote
def payment_link_message_text(
translator: Translator,
parts: PaymentCallbackParts,
+30 -3
View File
@@ -60,7 +60,7 @@ def build_payment_description(
"payment_description_traffic",
traffic_gb=human_value if human_value is not None else format_human_units(months),
)
if base in {"hwid_device", "hwid_devices"}:
if base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
return translator("payment_description_hwid_devices", count=int(float(months)))
return translator("payment_description_subscription", months=int(float(months)))
@@ -75,6 +75,7 @@ def build_payment_record_payload(
months: Any,
provider: str,
sale_mode: str,
hwid_quote: Optional[dict] = None,
) -> dict:
"""Assemble the payment-record dict that every callback handler used to inline.
@@ -85,7 +86,7 @@ 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)
return {
payload = {
"user_id": user_id,
"amount": amount,
"currency": currency,
@@ -98,6 +99,17 @@ def build_payment_record_payload(
"purchased_gb": float(months) if is_traffic else None,
"purchased_hwid_devices": int(float(months)) if is_hwid else None,
}
if hwid_quote and is_hwid:
payload.update(
{
"hwid_valid_from": hwid_quote.get("valid_from"),
"hwid_valid_until": 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"),
}
)
return payload
@dataclass(frozen=True)
@@ -119,7 +131,7 @@ def sale_mode_is_traffic(sale_mode: str) -> bool:
def sale_mode_is_hwid_devices(sale_mode: str) -> bool:
return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"}
return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
def sale_mode_tariff_key(sale_mode: str) -> Optional[str]:
@@ -194,6 +206,11 @@ async def create_base_payment_record(
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:
payment = await payment_dal.create_payment_record(
session,
@@ -209,6 +226,11 @@ async def create_base_payment_record(
"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,
},
)
await session.commit()
@@ -241,6 +263,11 @@ async def create_webapp_payment_record(
tariff_key=amounts.tariff_key,
purchased_gb=amounts.purchased_gb,
purchased_hwid_devices=amounts.purchased_hwid_devices,
hwid_valid_from=ctx.hwid_valid_from,
hwid_valid_until=ctx.hwid_valid_until,
hwid_pricing_period_months=ctx.hwid_pricing_period_months,
hwid_proration_ratio=ctx.hwid_proration_ratio,
hwid_full_price=ctx.hwid_full_price,
)
@@ -19,7 +19,7 @@ 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"}
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices"}
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
def is_traffic_sale_base(sale_base: str) -> bool:
@@ -128,6 +128,28 @@ def build_success_message(payload: SuccessMessage) -> str:
)
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}"
async def send_success_message_to_user(
*,
bot: Bot,
@@ -333,6 +355,13 @@ async def finalize_successful_payment(
inviter_name=inviter_name,
)
)
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 req.text_prefix:
success_text = f"{req.text_prefix}\n{success_text}"
+23 -2
View File
@@ -35,6 +35,7 @@ from .shared import (
payment_failed,
payment_record_amounts,
payment_unavailable,
quote_hwid_callback_parts,
safe_callback_answer,
sale_mode_base,
sale_mode_tariff_key,
@@ -80,9 +81,10 @@ class StarsService:
stars_price: int,
description: str,
sale_mode: str = "subscription",
hwid_quote: Optional[dict] = None,
) -> Optional[int]:
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
payment_record_data = {
"user_id": user_id,
"amount": float(stars_price),
@@ -93,7 +95,15 @@ class StarsService:
"provider": "telegram_stars",
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
"purchased_gb": amounts.purchased_gb,
"purchased_hwid_devices": amounts.purchased_hwid_devices,
"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")
if hwid_quote
else None,
"hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None,
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
}
try:
db_payment_record = await payment_dal.create_payment_record(
@@ -206,6 +216,16 @@ async def pay_stars_callback_handler(
return
parts = parse_payment_callback(callback.data or "")
if not parts:
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=stars_service.subscription_service,
currency="stars",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
@@ -221,6 +241,7 @@ async def pay_stars_callback_handler(
stars_price=stars_price,
description=payment_description,
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
if payment_db_id:
+12
View File
@@ -53,6 +53,7 @@ from .shared import (
payment_record_amounts,
payment_unavailable,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
@@ -856,6 +857,16 @@ async def pay_wata_callback_handler(
logging.error("Invalid pay_wata data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=wata_service.subscription_service,
currency="rub",
)
if not parts:
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
@@ -901,6 +912,7 @@ async def pay_wata_callback_handler(
months=parts.months,
provider="wata",
sale_mode=parts.sale_mode,
hwid_quote=hwid_quote,
)
try:
+40 -3
View File
@@ -52,7 +52,9 @@ from .base import (
provider_runtime_enabled,
)
from .shared import (
PaymentCallbackParts,
SuccessMessage,
append_hwid_renewal_note,
build_success_message,
create_webapp_payment_record,
format_human_units,
@@ -65,6 +67,7 @@ from .shared import (
payment_link_response,
payment_record_amounts,
payment_unavailable,
quote_hwid_callback_parts,
resolve_inviter_name,
send_success_message_to_user,
)
@@ -419,7 +422,7 @@ YOOKASSA_WEBHOOK_ALLOWED_IPS = [
"77.75.154.128/25",
"2a02:5180::/32",
]
HWID_DEVICE_SALE_BASES = {"hwid_device", "hwid_devices"}
HWID_DEVICE_SALE_BASES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
def _is_hwid_device_sale_base(sale_mode_base: str) -> bool:
@@ -832,6 +835,14 @@ async def process_successful_payment(
)
include_keyboard = True
if sale_mode_base == "subscription" and activation_details:
details_message = append_hwid_renewal_note(
details_message,
translator,
count=activation_details.get("hwid_devices_renewal_recommended_count"),
valid_until=activation_details.get("hwid_devices_valid_until"),
)
install_share_url = None
if include_keyboard:
install_links = await ensure_user_install_guide_links(session, settings, user_id)
@@ -1288,6 +1299,7 @@ async def _initiate_yk_payment(
payment_method_id: Optional[str] = None,
selected_method_internal_id: Optional[int] = None,
sale_mode: str = "subscription",
hwid_quote: Optional[dict] = None,
) -> bool:
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
if not callback.message:
@@ -1299,7 +1311,7 @@ async def _initiate_yk_payment(
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"}
if sale_base in HWID_DEVICE_SALE_BASES
else get_text("payment_description_subscription", months=int(months))
)
)
@@ -1316,8 +1328,15 @@ async def _initiate_yk_payment(
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"}
if sale_base in HWID_DEVICE_SALE_BASES
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")
if hwid_quote
else None,
"hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None,
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
}
db_payment_record = None
@@ -1634,6 +1653,23 @@ async def pay_yk_callback_handler(
return
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="rub",
)
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 = "RUB"
autopay_enabled = bool(
@@ -1710,6 +1746,7 @@ async def pay_yk_callback_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()
+38 -2
View File
@@ -17,7 +17,7 @@ from bot.keyboards.inline.user_keyboards import (
)
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from db.dal import user_dal
from db.dal import tariff_dal, user_dal
from .email_auth_service import EmailAuthService
from .email_templates import render_subscription_expiring
@@ -63,13 +63,46 @@ class PanelWebhookService:
**kwargs,
):
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
extra_text = str(kwargs.pop("extra_text", "") or "").strip()
try:
text = _(message_key, **kwargs)
if extra_text:
text = f"{text}\n\n{extra_text}"
await self.bot.send_message(
user_id, _(message_key, **kwargs), reply_markup=reply_markup
user_id, text, reply_markup=reply_markup
)
except Exception:
logging.exception("Failed to send notification to %s", user_id)
async def _hwid_renewal_note(self, internal_user_id: int, lang: str) -> str:
try:
from db.dal import subscription_dal
async with self.async_session_factory() as session:
sub = await subscription_dal.get_active_subscription_by_user_id(
session, internal_user_id
)
if not sub:
return ""
summary = await tariff_dal.get_hwid_device_entitlement_summary(
session,
subscription_id=sub.subscription_id,
)
count = int(summary.get("active_devices") or sub.extra_hwid_devices or 0)
if count <= 0:
return ""
active_until = summary.get("active_until") or sub.end_date
date_text = active_until.strftime("%Y-%m-%d") if active_until else ""
except Exception:
logging.exception("Failed to build HWID renewal note for user %s", internal_user_id)
return ""
return self.i18n.gettext(
lang,
"subscription_hwid_renewal_reminder",
count=count,
date=date_text,
)
async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId")
if not telegram_id:
@@ -97,6 +130,7 @@ class PanelWebhookService:
if event_name in EVENT_MAP:
days_left, msg_key = EVENT_MAP[event_name]
hwid_renewal_note = await self._hwid_renewal_note(internal_user_id, lang)
if days_left == 1:
# Trigger auto-renew via SubscriptionService (wired in at factory)
try:
@@ -148,6 +182,7 @@ class PanelWebhookService:
"autorenew_48h_charge_tomorrow_notice",
reply_markup=cancel_kb,
user_name=first_name,
extra_text=hwid_renewal_note,
)
return
await self._send_message(
@@ -157,6 +192,7 @@ class PanelWebhookService:
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
extra_text=hwid_renewal_note,
)
if days_left == 3 and user_email:
await self._send_subscription_expiring_email(
@@ -3,6 +3,190 @@ from ._runtime import * # noqa: F403,F405
class HwidDeviceMixin:
@staticmethod
def _as_aware_utc(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
async def _active_hwid_extra_devices_for_sub(
self,
session: AsyncSession,
sub: Subscription,
*,
at: Optional[datetime] = None,
) -> int:
try:
return await tariff_dal.sum_active_hwid_devices(
session,
subscription_id=sub.subscription_id,
at=at or datetime.now(timezone.utc),
)
except Exception:
logging.exception(
"Failed to recalculate active HWID devices for subscription %s",
getattr(sub, "subscription_id", None),
)
return int(getattr(sub, "extra_hwid_devices", 0) or 0)
async def _hwid_topup_validity_window(
self,
session: AsyncSession,
sub: Subscription,
*,
renewal: bool,
now: datetime,
) -> Optional[Tuple[datetime, datetime, Dict[str, Any]]]:
valid_until = self._as_aware_utc(getattr(sub, "end_date", None))
if not valid_until or valid_until <= now:
return None
summary = await tariff_dal.get_hwid_device_entitlement_summary(
session,
subscription_id=sub.subscription_id,
at=now,
)
valid_from = now
if renewal:
active_until = self._as_aware_utc(summary.get("active_until"))
if active_until and now < active_until < valid_until:
valid_from = active_until
elif active_until and active_until >= valid_until:
return None
return valid_from, valid_until, summary
@staticmethod
def _round_hwid_price(value: float, *, currency: str) -> float:
if value <= 0:
return 0.0
if currency == "stars":
return float(math.ceil(value))
return math.ceil(float(value) * 100) / 100
@staticmethod
def _find_hwid_package(tariff: Tariff, device_count: int, currency: str) -> Optional[Any]:
package_set = tariff.hwid_device_packages
if not package_set:
return None
packages = package_set.for_currency("stars" if currency == "stars" else "rub")
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
def _quote_hwid_package_price(
self,
*,
sub: Subscription,
package: Any,
valid_from: datetime,
valid_until: datetime,
now: datetime,
currency: str,
) -> 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
if not period_start or period_start >= period_end:
period_start = valid_from
period_end = valid_until
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
billable_start = max(now, valid_from)
billable_seconds = max(0.0, (valid_until - billable_start).total_seconds())
ratio = 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)
if raw_price > 0 and min_price is not None:
price = max(price, self._round_hwid_price(float(min_price), currency=currency))
if currency == "stars":
price = float(int(math.ceil(price)))
return {
"price": price,
"full_price": full_price,
"pricing_period_months": period_months,
"proration_ratio": ratio,
"valid_from": valid_from,
"valid_until": valid_until,
"billable_seconds": billable_seconds,
"period_seconds": basis_seconds,
"currency": currency,
}
async def quote_hwid_device_topup(
self,
session: AsyncSession,
*,
user_id: int,
device_count: int,
tariff_key: Optional[str] = None,
renewal: bool = False,
currency: str = "rub",
now: Optional[datetime] = None,
) -> Optional[Dict[str, Any]]:
try:
purchased_devices = int(device_count)
except (TypeError, ValueError):
return None
if purchased_devices <= 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:
return None
tariff = self._resolve_tariff(tariff_key or sub.tariff_key)
if not tariff or tariff.billing_model != "period":
return None
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
if base_hwid_limit == 0:
return None
package = self._find_hwid_package(tariff, purchased_devices, currency)
if not package:
return None
now = now or datetime.now(timezone.utc)
window = await self._hwid_topup_validity_window(
session,
sub,
renewal=renewal,
now=now,
)
if not window:
return None
valid_from, valid_until, entitlement_summary = window
quote = self._quote_hwid_package_price(
sub=sub,
package=package,
valid_from=valid_from,
valid_until=valid_until,
now=now,
currency="stars" if currency == "stars" else "rub",
)
quote.update(
{
"subscription_id": sub.subscription_id,
"tariff_key": tariff.key,
"device_count": purchased_devices,
"renewal": renewal,
"active_extra_devices": int(entitlement_summary.get("active_devices") or 0),
"active_until": entitlement_summary.get("active_until"),
}
)
return quote
async def activate_hwid_device_topup(
self,
session: AsyncSession,
@@ -12,6 +196,7 @@ class HwidDeviceMixin:
payment_db_id: int,
provider: str = "yookassa",
tariff_key: Optional[str] = None,
renewal: bool = False,
) -> Optional[Dict[str, Any]]:
try:
purchased_devices = int(device_count)
@@ -33,6 +218,14 @@ class HwidDeviceMixin:
tariff = None
if self._tariffs_config():
tariff = self._resolve_tariff(tariff_key or sub.tariff_key)
if tariff.billing_model != "period":
logging.info(
"Skipping HWID top-up for user %s because tariff %s is %s",
user_id,
tariff.key,
tariff.billing_model,
)
return None
packages = (
[*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars]
if tariff.hwid_device_packages
@@ -66,14 +259,60 @@ class HwidDeviceMixin:
"purchased_hwid_devices": 0,
}
new_extra_devices = int(sub.extra_hwid_devices or 0) + purchased_devices
now = datetime.now(timezone.utc)
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary(
session,
subscription_id=sub.subscription_id,
at=now,
)
valid_from = self._as_aware_utc(getattr(payment, "hwid_valid_from", None))
valid_until = self._as_aware_utc(getattr(payment, "hwid_valid_until", None))
if valid_from and valid_until:
if valid_until <= now or valid_from >= valid_until:
logging.error(
"Frozen HWID quote is no longer valid for user %s "
"(payment_id=%s, valid_from=%s, valid_until=%s)",
user_id,
payment_db_id,
valid_from,
valid_until,
)
return None
else:
window = await self._hwid_topup_validity_window(
session,
sub,
renewal=renewal,
now=now,
)
if window:
valid_from, valid_until, entitlement_summary = window
if not valid_from or not valid_until:
logging.error(
"HWID top-up has no valid subscription window for user %s "
"(subscription_id=%s, renewal=%s)",
user_id,
sub.subscription_id,
renewal,
)
return None
active_extra_devices = int(entitlement_summary.get("active_devices") or 0)
starts_now = valid_from <= now < valid_until
new_extra_devices = active_extra_devices + (purchased_devices if starts_now else 0)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, new_extra_devices)
await self._record_payment_context(
session,
payment_db_id,
sale_mode="hwid_devices",
sale_mode="hwid_devices_renewal" if renewal else "hwid_devices",
tariff_key=tariff.key if tariff else sub.tariff_key,
purchased_hwid_devices=purchased_devices,
hwid_valid_from=valid_from,
hwid_valid_until=valid_until,
hwid_pricing_period_months=getattr(payment, "hwid_pricing_period_months", None),
hwid_proration_ratio=getattr(payment, "hwid_proration_ratio", None),
hwid_full_price=getattr(payment, "hwid_full_price", None),
)
updated_sub = await subscription_dal.update_subscription(
session,
@@ -115,6 +354,8 @@ class HwidDeviceMixin:
subscription_id=updated_sub.subscription_id,
payment_id=payment_db_id,
purchased_devices=purchased_devices,
valid_from=valid_from,
valid_until=valid_until,
)
return {
"subscription_id": updated_sub.subscription_id,
@@ -127,4 +368,7 @@ class HwidDeviceMixin:
"extra_hwid_devices": new_extra_devices,
"purchased_hwid_devices": purchased_devices,
"tariff_key": tariff.key if tariff else sub.tariff_key,
"hwid_devices_valid_from": valid_from,
"hwid_devices_valid_until": valid_until,
"hwid_devices_renewal": renewal,
}
@@ -23,8 +23,15 @@ class SubscriptionLifecycleMixin:
if not sub:
return None
before_tariff_key = sub.tariff_key
options = self.calculate_tariff_switch_options(sub, target)
now = datetime.now(timezone.utc)
options = await self.calculate_tariff_switch_options_with_hwid(session, sub, target)
converted_hwid_purchase_ids = list(options.get("convertible_hwid_purchase_ids") or [])
if converted_hwid_purchase_ids:
await tariff_dal.expire_hwid_device_purchases(
session,
purchase_ids=converted_hwid_purchase_ids,
at=now,
)
premium_topup_balance = int(sub.premium_topup_balance_bytes or 0)
premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
premium_baseline = target.premium_monthly_bytes
@@ -44,8 +51,20 @@ class SubscriptionLifecycleMixin:
}
converted_bytes = None
base_hwid_limit = self._base_hwid_limit_for_tariff(target)
extra_hwid_devices = int(sub.extra_hwid_devices or 0)
try:
extra_hwid_devices = await tariff_dal.sum_active_hwid_devices(
session,
subscription_id=sub.subscription_id,
at=now,
)
except Exception:
logging.exception(
"Failed to recalculate HWID devices during tariff switch for user %s",
user_id,
)
extra_hwid_devices = int(sub.extra_hwid_devices or 0)
update_data["hwid_device_limit"] = base_hwid_limit
update_data["extra_hwid_devices"] = extra_hwid_devices
if target.billing_model == "period":
update_data["tier_baseline_bytes"] = target.monthly_bytes
@@ -154,6 +173,8 @@ class SubscriptionLifecycleMixin:
if updated.end_date and target.billing_model == "period"
else None,
"converted_bytes": converted_bytes,
"converted_hwid_value_rub": options.get("converted_hwid_value_rub"),
"converted_hwid_days": options.get("converted_hwid_days"),
"eff_price_before": sub.effective_monthly_price_rub,
"eff_price_after": updated.effective_monthly_price_rub,
},
@@ -236,7 +257,7 @@ class SubscriptionLifecycleMixin:
payment_db_id=payment_db_id,
provider=provider,
)
if sale_mode_base in {"hwid_device", "hwid_devices"}:
if sale_mode_base in {"hwid_device", "hwid_devices", "hwid_devices_renewal"}:
target_devices = int(traffic_gb if traffic_gb is not None else months)
return await self.activate_hwid_device_topup(
session=session,
@@ -246,6 +267,7 @@ class SubscriptionLifecycleMixin:
payment_db_id=payment_db_id,
provider=provider,
tariff_key=tariff_key,
renewal=sale_mode_base == "hwid_devices_renewal",
)
if sale_mode_base == "tariff_upgrade":
if not tariff_key:
@@ -378,7 +400,25 @@ class SubscriptionLifecycleMixin:
)
topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0)
extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0)
extra_hwid_devices = 0
hwid_devices_valid_until = None
if current_active_sub:
try:
hwid_summary = await tariff_dal.get_hwid_device_entitlement_summary(
session,
subscription_id=current_active_sub.subscription_id,
at=datetime.now(timezone.utc),
)
extra_hwid_devices = int(hwid_summary.get("active_devices") or 0)
hwid_devices_valid_until = hwid_summary.get("active_until")
except Exception:
logging.exception(
"Failed to recalculate active HWID devices for renewal of user %s",
user_id,
)
extra_hwid_devices = int(
getattr(current_active_sub, "extra_hwid_devices", 0) or 0
)
premium_topup_balance_bytes = int(
getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0
)
@@ -497,6 +537,8 @@ 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,
}
async def extend_active_subscription_days(
@@ -760,6 +802,43 @@ class SubscriptionLifecycleMixin:
if local_active_sub
else False
)
hwid_entitlement_summary: Dict[str, Any] = {}
active_extra_hwid_devices = (
int(local_active_sub.extra_hwid_devices or 0) if local_active_sub else 0
)
if local_active_sub:
try:
hwid_entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary(
session,
subscription_id=local_active_sub.subscription_id,
at=datetime.now(timezone.utc),
)
active_extra_hwid_devices = int(
hwid_entitlement_summary.get("active_devices") or 0
)
if active_extra_hwid_devices != int(local_active_sub.extra_hwid_devices or 0):
await subscription_dal.update_subscription(
session,
local_active_sub.subscription_id,
{"extra_hwid_devices": active_extra_hwid_devices},
)
local_active_sub.extra_hwid_devices = active_extra_hwid_devices
except Exception:
logging.exception(
"Failed to load HWID entitlement summary for subscription %s",
local_active_sub.subscription_id,
)
base_hwid_limit_for_payload = (
local_active_sub.hwid_device_limit
if local_active_sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
expected_hwid_limit = self._effective_hwid_limit(
base_hwid_limit_for_payload,
active_extra_hwid_devices,
)
if expected_hwid_limit is not None:
hwid_limit = expected_hwid_limit
return {
"user_id": panel_user_data.get("uuid"),
@@ -820,9 +899,11 @@ class SubscriptionLifecycleMixin:
"base_hwid_device_limit": local_active_sub.hwid_device_limit
if local_active_sub
else None,
"extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0)
if local_active_sub
else 0,
"extra_hwid_devices": active_extra_hwid_devices,
"extra_hwid_devices_valid_until": hwid_entitlement_summary.get("active_until"),
"extra_hwid_devices_next_valid_from": hwid_entitlement_summary.get(
"next_valid_from"
),
"user_bot_username": db_user.username,
"is_panel_data": True,
"max_devices": hwid_limit,
@@ -26,6 +26,11 @@ class PaymentContextMixin:
tariff_key: Optional[str],
purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = None,
hwid_valid_from: Optional[datetime] = None,
hwid_valid_until: Optional[datetime] = None,
hwid_pricing_period_months: Optional[int] = None,
hwid_proration_ratio: Optional[float] = None,
hwid_full_price: Optional[float] = None,
) -> None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
@@ -34,6 +39,16 @@ class PaymentContextMixin:
payment.tariff_key = tariff_key
payment.purchased_gb = purchased_gb
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:
payment.hwid_valid_until = hwid_valid_until
if hwid_pricing_period_months is not None:
payment.hwid_pricing_period_months = hwid_pricing_period_months
if hwid_proration_ratio is not None:
payment.hwid_proration_ratio = hwid_proration_ratio
if hwid_full_price is not None:
payment.hwid_full_price = hwid_full_price
await session.flush()
async def get_user_language(self, session: AsyncSession, user_id: int) -> str:
@@ -370,3 +370,91 @@ class TariffMixin:
}
return {"mode": "traffic_to_period", "remaining_days": remaining_days}
@staticmethod
def _aware_utc(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
async def _hwid_conversion_credit(
self,
session: AsyncSession,
sub: Subscription,
*,
at: datetime,
) -> Dict[str, Any]:
entries = await tariff_dal.get_hwid_device_value_entries(
session,
subscription_id=sub.subscription_id,
at=at,
)
value_rub = 0.0
purchase_ids: List[int] = []
skipped_devices = 0
for entry in entries:
currency = str(entry.get("currency") or "").upper()
if currency in {"XTR", "STARS", "STAR"}:
skipped_devices += int(entry.get("purchased_devices") or 0)
continue
amount = float(entry.get("amount") or 0)
if amount <= 0:
continue
valid_from = (
self._aware_utc(entry.get("valid_from"))
or self._aware_utc(entry.get("created_at"))
or at
)
valid_until = self._aware_utc(entry.get("valid_until"))
if not valid_until or valid_until <= at or valid_from >= valid_until:
continue
total_seconds = max(1.0, (valid_until - valid_from).total_seconds())
remaining_start = max(at, valid_from)
remaining_seconds = max(0.0, (valid_until - remaining_start).total_seconds())
if remaining_seconds <= 0:
continue
value_rub += amount * (remaining_seconds / total_seconds)
purchase_ids.append(int(entry["purchase_id"]))
return {
"value_rub": value_rub,
"purchase_ids": purchase_ids,
"skipped_devices": skipped_devices,
}
async def calculate_tariff_switch_options_with_hwid(
self,
session: AsyncSession,
sub: Subscription,
target_tariff: Tariff,
) -> Dict[str, Any]:
options = dict(self.calculate_tariff_switch_options(sub, target_tariff))
now = datetime.now(timezone.utc)
credit = await self._hwid_conversion_credit(session, sub, at=now)
value_rub = float(credit.get("value_rub") or 0)
options["converted_hwid_value_rub"] = round(value_rub, 2)
options["convertible_hwid_purchase_ids"] = list(credit.get("purchase_ids") or [])
options["nonconverted_hwid_devices"] = int(credit.get("skipped_devices") or 0)
if value_rub <= 0:
return options
if options.get("mode") == "period_to_period":
target_monthly = float(options.get("target_monthly_rub") or 0)
hwid_days = (
math.floor((value_rub / target_monthly) * 30) if target_monthly > 0 else 0
)
options["converted_hwid_days"] = max(0, hwid_days)
options["recalc_days"] = int(options.get("recalc_days") or 0) + max(0, hwid_days)
options["paid_diff_rub"] = max(
0,
math.ceil(float(options.get("paid_diff_rub") or 0) - value_rub),
)
return options
if options.get("mode") == "period_to_traffic":
rub_per_gb = float(options.get("rub_per_gb") or 0)
hwid_gb = math.floor(value_rub / rub_per_gb) if rub_per_gb > 0 else 0
options["converted_hwid_gb"] = max(0, hwid_gb)
options["converted_gb"] = int(options.get("converted_gb") or 0) + max(0, hwid_gb)
return options
@@ -53,7 +53,11 @@ class TrafficMixin:
current_used = active_sub.traffic_used_bytes
purchase_bytes = self.gb_to_bytes(traffic_gb)
extra_hwid_devices = int(getattr(active_sub, "extra_hwid_devices", 0) or 0)
extra_hwid_devices = (
await self._active_hwid_extra_devices_for_sub(session, active_sub)
if active_sub
else 0
)
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
remaining_bytes = max(0, int(current_limit or 0) - int(current_used or 0))
@@ -222,10 +226,8 @@ class TrafficMixin:
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
effective_hwid_limit = self._effective_hwid_limit(
base_hwid_limit,
int(sub.extra_hwid_devices or 0),
)
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
updated_sub = await subscription_dal.update_subscription(
session,
sub.subscription_id,
@@ -235,6 +237,7 @@ class TrafficMixin:
"is_throttled": False,
"tariff_key": tariff.key,
"hwid_device_limit": base_hwid_limit,
"extra_hwid_devices": extra_hwid_devices,
},
)
panel_payload = self._build_panel_update_payload(
@@ -499,10 +502,8 @@ class TrafficMixin:
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
effective_hwid_limit = self._effective_hwid_limit(
base_hwid_limit,
int(sub.extra_hwid_devices or 0),
)
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
updated_sub = await subscription_dal.update_subscription(
session,
sub.subscription_id,
@@ -511,6 +512,7 @@ class TrafficMixin:
"traffic_limit_bytes": new_limit,
"is_throttled": False,
"hwid_device_limit": base_hwid_limit,
"extra_hwid_devices": extra_hwid_devices,
},
)
panel_payload = self._build_panel_update_payload(
@@ -580,10 +582,9 @@ class TrafficMixin:
if sub.hwid_device_limit is not None
else self._base_hwid_limit_for_tariff(tariff)
)
effective_hwid_limit = self._effective_hwid_limit(
base_hwid_limit,
int(sub.extra_hwid_devices or 0),
)
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
sub.extra_hwid_devices = extra_hwid_devices
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
panel_payload = self._build_panel_update_payload(
panel_user_uuid=db_user.panel_user_uuid,
expire_at=sub.end_date,
+59
View File
@@ -215,6 +215,7 @@ class TariffTrafficWorker:
if tariff.billing_model == "period":
await self._ensure_period_reset_strategy(sub, tariff, limit, panel_strategy)
await self._sync_hwid_device_limit(session, sub, tariff, panel_data)
await self._maybe_warn_or_throttle(
session,
sub,
@@ -377,6 +378,64 @@ class TariffTrafficWorker:
sub.panel_user_uuid, payload, log_response=False
)
async def _sync_hwid_device_limit(
self,
session: AsyncSession,
sub: Subscription,
tariff,
panel_data: dict,
) -> None:
base_hwid_limit = (
int(sub.hwid_device_limit)
if sub.hwid_device_limit is not None
else self.subscription_service._base_hwid_limit_for_tariff(tariff)
)
active_extra = await tariff_dal.sum_active_hwid_devices(
session,
subscription_id=sub.subscription_id,
at=datetime.now(timezone.utc),
)
update_data = {}
if sub.hwid_device_limit != base_hwid_limit:
update_data["hwid_device_limit"] = base_hwid_limit
if int(sub.extra_hwid_devices or 0) != active_extra:
update_data["extra_hwid_devices"] = active_extra
if update_data:
for key, value in update_data.items():
setattr(sub, key, value)
effective_limit = self.subscription_service._effective_hwid_limit(
base_hwid_limit,
active_extra,
)
if effective_limit is None:
return
try:
panel_limit = panel_data.get("hwidDeviceLimit")
panel_limit_int = int(panel_limit) if panel_limit is not None else None
except (TypeError, ValueError):
panel_limit_int = None
if panel_limit_int == effective_limit:
return
payload = self.subscription_service._build_panel_update_payload(
panel_user_uuid=sub.panel_user_uuid,
expire_at=sub.end_date,
hwid_device_limit=effective_limit,
include_default_squads=False,
)
updated_panel = await self.panel_service.update_user_details_on_panel(
sub.panel_user_uuid,
payload,
log_response=False,
)
if not updated_panel or updated_panel.get("error"):
logging.warning(
"TariffTrafficWorker: failed to sync HWID limit for subscription %s: %s",
sub.subscription_id,
updated_panel,
)
async def _maybe_warn_or_throttle(
self,
session: AsyncSession,