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,
+26
View File
@@ -25,6 +25,8 @@ class TrafficPackage(BaseModel):
class HwidDevicePackage(BaseModel):
count: int
price: float
prices: Dict[str, float] = Field(default_factory=dict)
min_price: Optional[float] = None
@model_validator(mode="after")
def validate_values(self) -> "HwidDevicePackage":
@@ -32,8 +34,32 @@ class HwidDevicePackage(BaseModel):
raise ValueError("device package count must be greater than zero")
if self.price < 0:
raise ValueError("device package price must be non-negative")
normalized_prices: Dict[str, float] = {}
for period, value in (self.prices or {}).items():
period_key = str(period).strip()
if not period_key:
raise ValueError("device package price period must not be empty")
try:
period_months = int(period_key)
except (TypeError, ValueError) as exc:
raise ValueError("device package price period must be an integer") from exc
if period_months <= 0:
raise ValueError("device package price period must be positive")
if float(value) < 0:
raise ValueError("device package period price must be non-negative")
normalized_prices[str(period_months)] = float(value)
self.prices = normalized_prices
if self.min_price is not None and self.min_price < 0:
raise ValueError("device package min_price must be non-negative")
return self
def price_for_period(self, months: int) -> float:
months_int = max(1, int(months or 1))
value = self.prices.get(str(months_int))
if value is not None:
return float(value)
return float(self.price) * months_int
class PackageSet(BaseModel):
rub: List[TrafficPackage] = Field(default_factory=list)
+129 -2
View File
@@ -1,9 +1,11 @@
import inspect
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, delete, func, select
from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import HwidDevicePurchase, TariffChange, TrafficTopup, TrafficWarning
from db.models import HwidDevicePurchase, Payment, TariffChange, TrafficTopup, TrafficWarning
async def create_traffic_topup(
@@ -52,11 +54,15 @@ async def create_hwid_device_purchase(
subscription_id: int,
payment_id: Optional[int],
purchased_devices: int,
valid_from: Optional[datetime] = None,
valid_until: Optional[datetime] = None,
) -> HwidDevicePurchase:
record = HwidDevicePurchase(
subscription_id=subscription_id,
payment_id=payment_id,
purchased_devices=purchased_devices,
valid_from=valid_from or datetime.now(timezone.utc),
valid_until=valid_until,
)
session.add(record)
await session.flush()
@@ -64,6 +70,127 @@ async def create_hwid_device_purchase(
return record
def _hwid_active_conditions(subscription_id: int, at: datetime) -> List[Any]:
return [
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
or_(HwidDevicePurchase.valid_from.is_(None), HwidDevicePurchase.valid_from <= at),
or_(HwidDevicePurchase.valid_until.is_(None), HwidDevicePurchase.valid_until > at),
]
async def _resolve_result_value(value: Any) -> Any:
if inspect.isawaitable(value):
return await value
return value
async def sum_active_hwid_devices(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
) -> int:
at = at or datetime.now(timezone.utc)
result = await session.execute(
select(func.coalesce(func.sum(HwidDevicePurchase.purchased_devices), 0)).where(
and_(*_hwid_active_conditions(subscription_id, at))
)
)
return int(await _resolve_result_value(result.scalar()) or 0)
async def get_hwid_device_entitlement_summary(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
) -> Dict[str, Any]:
at = at or datetime.now(timezone.utc)
active_result = await session.execute(
select(
func.coalesce(func.sum(HwidDevicePurchase.purchased_devices), 0),
func.max(HwidDevicePurchase.valid_until),
).where(and_(*_hwid_active_conditions(subscription_id, at)))
)
active_devices, active_until = await _resolve_result_value(active_result.one())
future_result = await session.execute(
select(func.min(HwidDevicePurchase.valid_from)).where(
and_(
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
HwidDevicePurchase.valid_from > at,
)
)
)
return {
"active_devices": int(active_devices or 0),
"active_until": active_until,
"next_valid_from": await _resolve_result_value(future_result.scalar_one_or_none()),
}
async def get_hwid_device_value_entries(
session: AsyncSession,
*,
subscription_id: int,
at: Optional[datetime] = None,
) -> List[Dict[str, Any]]:
at = at or datetime.now(timezone.utc)
result = await session.execute(
select(
HwidDevicePurchase.purchase_id,
HwidDevicePurchase.purchased_devices,
HwidDevicePurchase.valid_from,
HwidDevicePurchase.valid_until,
HwidDevicePurchase.created_at,
Payment.amount,
Payment.currency,
)
.outerjoin(Payment, Payment.payment_id == HwidDevicePurchase.payment_id)
.where(
and_(
HwidDevicePurchase.subscription_id == subscription_id,
HwidDevicePurchase.purchased_devices > 0,
or_(HwidDevicePurchase.valid_until.is_(None), HwidDevicePurchase.valid_until > at),
)
)
)
entries = []
rows = await _resolve_result_value(result.all())
for row in rows:
entries.append(
{
"purchase_id": row[0],
"purchased_devices": row[1],
"valid_from": row[2],
"valid_until": row[3],
"created_at": row[4],
"amount": row[5],
"currency": row[6],
}
)
return entries
async def expire_hwid_device_purchases(
session: AsyncSession,
*,
purchase_ids: List[int],
at: Optional[datetime] = None,
) -> int:
ids = [int(item) for item in purchase_ids if item is not None]
if not ids:
return 0
at = at or datetime.now(timezone.utc)
result = await session.execute(
update(HwidDevicePurchase)
.where(HwidDevicePurchase.purchase_id.in_(ids))
.values(valid_until=at)
)
return result.rowcount or 0
async def create_tariff_change(
session: AsyncSession,
change_data: Dict[str, Any],
+3 -2
View File
@@ -1,5 +1,6 @@
import logging
from sqlalchemy import text
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
@@ -10,6 +11,7 @@ from db.models import Base
from .migrator import run_database_migrations
async_engine = None
DB_INIT_ADVISORY_LOCK_ID = 817512404897421337
def redacted_database_url(database_url: str) -> str:
@@ -71,6 +73,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
)
async with async_engine.begin() as conn:
await conn.execute(text(f"SELECT pg_advisory_xact_lock({DB_INIT_ADVISORY_LOCK_ID})"))
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(run_database_migrations)
logging.info("PostgreSQL database initialized/checked successfully using SQLAlchemy.")
@@ -83,8 +86,6 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
logging.warning(f"Failed to load setting overrides on startup: {e_overrides}")
async with session_factory() as session:
from sqlalchemy import text
from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status
try:
+107
View File
@@ -919,6 +919,103 @@ def _migration_0028_add_locale_overrides(connection: Connection) -> None:
)
def _migration_0029_add_hwid_device_purchase_validity(connection: Connection) -> None:
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
if "hwid_device_purchases" not in table_names or "subscriptions" not in table_names:
return
columns: Set[str] = {
col["name"] for col in inspector.get_columns("hwid_device_purchases")
}
if "valid_from" not in columns:
connection.execute(
text("ALTER TABLE hwid_device_purchases ADD COLUMN valid_from TIMESTAMPTZ")
)
if "valid_until" not in columns:
connection.execute(
text("ALTER TABLE hwid_device_purchases ADD COLUMN valid_until TIMESTAMPTZ")
)
connection.execute(
text(
"""
UPDATE hwid_device_purchases hp
SET
valid_from = COALESCE(hp.valid_from, hp.created_at, s.start_date, NOW()),
valid_until = COALESCE(hp.valid_until, s.end_date)
FROM subscriptions s
WHERE hp.subscription_id = s.subscription_id
AND (hp.valid_from IS NULL OR hp.valid_until IS NULL)
"""
)
)
connection.execute(
text(
"""
INSERT INTO hwid_device_purchases (
subscription_id,
payment_id,
purchased_devices,
valid_from,
valid_until
)
SELECT
s.subscription_id,
NULL,
s.extra_hwid_devices,
COALESCE(s.start_date, NOW()),
s.end_date
FROM subscriptions s
WHERE COALESCE(s.extra_hwid_devices, 0) > 0
AND s.end_date IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM hwid_device_purchases hp
WHERE hp.subscription_id = s.subscription_id
)
"""
)
)
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_subscription_window "
"ON hwid_device_purchases (subscription_id, valid_from, valid_until)"
)
)
def _migration_0030_add_hwid_pricing_metadata(connection: Connection) -> None:
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
if "payments" in table_names:
payment_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")}
payment_additions = {
"hwid_valid_from": "TIMESTAMPTZ",
"hwid_valid_until": "TIMESTAMPTZ",
"hwid_pricing_period_months": "INTEGER",
"hwid_proration_ratio": "DOUBLE PRECISION",
"hwid_full_price": "DOUBLE PRECISION",
}
for column, ddl_type in payment_additions.items():
if column not in payment_columns:
connection.execute(text(f"ALTER TABLE payments ADD COLUMN {column} {ddl_type}"))
if "tariff_changes" in table_names:
change_columns: Set[str] = {
col["name"] for col in inspector.get_columns("tariff_changes")
}
change_additions = {
"converted_hwid_value_rub": "NUMERIC",
"converted_hwid_days": "INTEGER",
}
for column, ddl_type in change_additions.items():
if column not in change_columns:
connection.execute(
text(f"ALTER TABLE tariff_changes ADD COLUMN {column} {ddl_type}")
)
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
@@ -1071,6 +1168,16 @@ MIGRATIONS: List[Migration] = [
description="Persist runtime overrides for localization strings",
upgrade=_migration_0028_add_locale_overrides,
),
Migration(
id="0029_add_hwid_device_purchase_validity",
description="Track validity windows for HWID device top-ups",
upgrade=_migration_0029_add_hwid_device_purchase_validity,
),
Migration(
id="0030_add_hwid_pricing_metadata",
description="Persist quoted HWID top-up pricing windows and conversion audit",
upgrade=_migration_0030_add_hwid_pricing_metadata,
),
]
+17
View File
@@ -203,6 +203,11 @@ class Payment(Base):
tariff_key = Column(String, nullable=True, index=True)
purchased_gb = Column(Float, nullable=True)
purchased_hwid_devices = Column(Integer, nullable=True)
hwid_valid_from = Column(DateTime(timezone=True), nullable=True)
hwid_valid_until = Column(DateTime(timezone=True), nullable=True)
hwid_pricing_period_months = Column(Integer, nullable=True)
hwid_proration_ratio = Column(Float, nullable=True)
hwid_full_price = Column(Float, nullable=True)
promo_code_id = Column(Integer, ForeignKey("promo_codes.promo_code_id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
@@ -229,6 +234,14 @@ class TrafficTopup(Base):
class HwidDevicePurchase(Base):
__tablename__ = "hwid_device_purchases"
__table_args__ = (
Index(
"ix_hwid_device_purchases_subscription_window",
"subscription_id",
"valid_from",
"valid_until",
),
)
purchase_id = Column(Integer, primary_key=True, autoincrement=True)
subscription_id = Column(
@@ -236,6 +249,8 @@ class HwidDevicePurchase(Base):
)
payment_id = Column(Integer, ForeignKey("payments.payment_id"), nullable=True, index=True)
purchased_devices = Column(Integer, nullable=False)
valid_from = Column(DateTime(timezone=True), nullable=True)
valid_until = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
subscription = relationship("Subscription")
@@ -276,6 +291,8 @@ class TariffChange(Base):
days_before = Column(Integer, nullable=True)
days_after = Column(Integer, nullable=True)
converted_bytes = Column(BigInteger, nullable=True)
converted_hwid_value_rub = Column(Numeric, nullable=True)
converted_hwid_days = Column(Integer, nullable=True)
eff_price_before = Column(Numeric, nullable=True)
eff_price_after = Column(Numeric, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
+1 -2
View File
@@ -24,7 +24,6 @@ from bot.payment_providers.yookassa import (
process_successful_payment,
)
from bot.services.locale_override_service import load_locale_overrides
from bot.services.settings_override_service import load_overrides_from_db
from bot.services.tariff_worker import TariffTrafficWorker
from bot.utils.message_queue import init_queue_manager
from config.settings import get_settings
@@ -32,7 +31,7 @@ from config.settings import get_settings
async def _build_worker_context(settings):
session_factory = database_setup.init_db_connection(settings)
await load_overrides_from_db(settings, session_factory)
await database_setup.init_db(settings, session_factory)
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
+5 -3
View File
@@ -21,8 +21,9 @@ docker compose logs -f backend worker frontend
- `worker` только после успешных миграций;
- `frontend` как отдельный nginx-образ без Python runtime.
Миграции не запускаются внутри backend. Их выполняет отдельный сервис `migrate`, поэтому старт
приложения не создает гонки на схеме БД.
Основной путь миграций — отдельный сервис `migrate`. `backend` и `worker` также выполняют
безопасную проверку схемы на старте под PostgreSQL advisory lock, поэтому прямой запуск сервиса
без compose тоже применит недостающие миграции и не создаст гонку на схеме БД.
## Готовые папки запуска
@@ -71,7 +72,8 @@ docker compose logs migrate
```
`backend` и `worker` зависят от `migrate` через `service_completed_successfully`; если миграции
падают, приложение не стартует поверх неподготовленной БД.
падают, приложение не стартует поверх неподготовленной БД. При прямом запуске `backend` или
`worker` без compose тот же `init_db` применяет недостающие миграции перед стартом логики сервиса.
## Сервисы
+35 -9
View File
@@ -66,6 +66,17 @@ JSON-каталог может содержать несколько тариф
"rub": [{ "gb": 10, "price": 99 }],
"stars": [{ "gb": 10, "price": 2500 }]
},
"hwid_device_packages": {
"rub": [
{
"count": 1,
"price": 99,
"prices": { "1": 99, "3": 249 },
"min_price": 20
}
],
"stars": [{ "count": 1, "price": 50, "prices": { "1": 50, "3": 130 } }]
},
"enabled": true
}
]
@@ -88,7 +99,7 @@ JSON-каталог может содержать несколько тариф
| `tariffs[].premium_topup_packages` | Пакеты докупки premium-трафика в формате `{ "rub": [{ "gb": 10, "price": 99 }], "stars": [...] }`. Требуют `premium_squad_uuids`. |
| `tariffs[].billing_model` | Модель тарифа: `period` или `traffic`. |
| `tariffs[].hwid_device_limit` | Базовый лимит HWID-устройств. `0` означает безлимит, отсутствие поля использует `USER_HWID_DEVICE_LIMIT`. |
| `tariffs[].hwid_device_packages` | Пакеты докупки устройств: `{ "count": 1, "price": 99 }`. |
| `tariffs[].hwid_device_packages` | Пакеты докупки устройств. `price` — legacy/monthly fallback, `prices` задаёт полную цену пакета для периодов тарифа (`"1"`, `"3"`, `"6"`, `"12"`), `min_price` задаёт минимальную цену prorate-докупки. |
Для `period`-тарифа также используются:
@@ -210,8 +221,15 @@ limit_after = current_used + balance_after
{
"hwid_device_limit": 5,
"hwid_device_packages": {
"rub": [{ "count": 1, "price": 99 }],
"stars": [{ "count": 1, "price": 2500 }]
"rub": [
{
"count": 1,
"price": 99,
"prices": { "1": 99, "3": 249, "6": 449, "12": 799 },
"min_price": 20
}
],
"stars": [{ "count": 1, "price": 50, "prices": { "1": 50, "3": 130 } }]
}
}
```
@@ -219,11 +237,17 @@ limit_after = current_used + balance_after
Правила:
- `hwid_device_limit` хранит базовый лимит тарифа;
- `extra_hwid_devices` хранит количество докупленных устройств;
- эффективный лимит равен `hwid_device_limit + extra_hwid_devices`;
- `extra_hwid_devices` хранит только текущую активную сумму докупленных устройств;
- срок действия каждой докупки хранится в `hwid_device_purchases.valid_from` / `valid_until`;
- эффективный лимит равен `hwid_device_limit + active extra_hwid_devices`;
- базовый лимит `0` означает безлимит, в Remnawave отправляется `hwidDeviceLimit = 0`;
- при безлимитном базовом лимите докупка устройств не применяется;
- при смене тарифа базовый лимит берется из целевого тарифа, а докупленные устройства сохраняются;
- полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`;
- фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe;
- для Telegram Stars цена округляется вверх до целого Stars, для RUB — вверх до копеек; `min_price` защищает от микроплатежей в конце периода;
- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка;
- `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки;
- при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная RUB-стоимость HWID-докупок конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`;
- история докупок пишется в `hwid_device_purchases`;
- платеж хранит количество устройств в `payments.purchased_hwid_devices`.
@@ -237,9 +261,9 @@ limit_after = current_used + balance_after
| Переход | Поведение |
| --- | --- |
| `period -> period` | Остаток оплаченных дней оценивается по `effective_monthly_price_rub`, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Количество дней округляется вниз. |
| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; после оплаты применяется целевой тариф. |
| `period -> traffic` | Остаток оплаченных дней конвертируется в GB по `conversion_rate_rub_per_gb` или минимальной RUB-цене GB из пакетов целевого тарифа. |
| `period -> period` | Остаток оплаченных дней оценивается по `effective_monthly_price_rub`, затем пересчитывается в дни целевого тарифа через месячную цену целевого тарифа. Неиспользованная RUB-стоимость HWID-докупок добавляется к этому расчету как дополнительные дни. Количество дней округляется вниз. |
| `period -> period` с доплатой | Если целевой тариф дороже, может быть создан платеж `tariff_upgrade`; неиспользованная RUB-стоимость HWID-докупок уменьшает сумму доплаты. После оплаты применяется целевой тариф, а конвертированные HWID-окна закрываются. |
| `period -> traffic` | Остаток оплаченных дней и неиспользованная RUB-стоимость HWID-докупок конвертируются в GB по `conversion_rate_rub_per_gb` или минимальной RUB-цене GB из пакетов целевого тарифа. |
| `traffic -> period` | Пользователь выбирает и оплачивает период целевого тарифа; остаток GB сохраняется как `topup_balance_bytes` поверх лимита period-тарифа. |
При смене тарифа бот меняет:
@@ -262,6 +286,8 @@ limit_after = current_used + balance_after
| `tariff_key` | Ключ тарифа, к которому относится платеж. |
| `purchased_gb` | Купленный объем GB для traffic-пакетов и докупки трафика. |
| `purchased_hwid_devices` | Количество устройств при докупке HWID. |
| `hwid_valid_from`, `hwid_valid_until` | Зафиксированное окно действия HWID-докупки на момент создания платежа. |
| `hwid_pricing_period_months`, `hwid_proration_ratio`, `hwid_full_price` | Метаданные расчета цены HWID-докупки: период тарифа, коэффициент prorate и полная цена пакета для периода. |
| `subscription_duration_months` | Количество месяцев для подписки на срок; также используется платежными обработчиками как числовое поле покупки. |
В callback и metadata платежных провайдеров `sale_mode` может передаваться с суффиксом тарифа, например `subscription@standard` или `topup@standard`. При активации платежа тариф сохраняется отдельно в `tariff_key`.
+1
View File
@@ -1240,6 +1240,7 @@
stripTopupQueryFromUrl();
}
}
return payload;
}
async function loadPublicInstall(shareToken) {
+16 -4
View File
@@ -49,6 +49,8 @@ export function rowsFromPackages(packageSet, currency, valueKey) {
return (packageSet?.[currency] || []).map((pkg) => ({
[valueKey]: pkg[valueKey],
price: pkg.price,
prices: pkg.prices ? structuredCloneSafe(pkg.prices) : undefined,
min_price: pkg.min_price ?? "",
}));
}
@@ -115,10 +117,20 @@ export function compactMap(obj) {
export function packagesFromRows(rows, valueKey) {
return (rows || [])
.map((row) => ({
[valueKey]: parseNumber(row[valueKey]),
price: parseNumber(row.price),
}))
.map((row) => {
const pkg = {
[valueKey]: parseNumber(row[valueKey]),
price: parseNumber(row.price),
};
if (row.prices && typeof row.prices === "object") {
pkg.prices = structuredCloneSafe(row.prices);
}
const minPrice = parseNumber(row.min_price);
if (minPrice !== null) {
pkg.min_price = minPrice;
}
return pkg;
})
.filter((row) => row[valueKey] > 0 && row.price !== null && row.price >= 0);
}
+1 -1
View File
@@ -53,7 +53,7 @@ export function createBillingActions({ api }) {
months: plan.device_count || plan.months,
device_count: plan.device_count || plan.months,
tariff_key: plan.tariff_key || fallbackTariffKey,
sale_mode: "hwid_devices",
sale_mode: plan.sale_mode || "hwid_devices",
method,
};
}
+6
View File
@@ -669,6 +669,10 @@ export function applyPreviewMock(kind) {
tariff_key: "standard",
tariff_name: "Стандарт",
current_limit: 5,
extra_hwid_devices: 2,
extra_hwid_devices_valid_until_text: "01.06.2026 12:00",
renewal_available: false,
renewal_recommended_count: 0,
plans: [
{
id: "standard:hwid:1",
@@ -702,6 +706,8 @@ export function applyPreviewMock(kind) {
...DEV_MOCK.data.subscription,
active: true,
max_devices: 5,
extra_hwid_devices: 2,
extra_hwid_devices_valid_until_text: "01.06.2026 12:00",
};
} else if (mode === "trial") {
DEV_MOCK.data.settings.traffic_mode = false;
+21 -4
View File
@@ -43,9 +43,14 @@ export function createBillingStore({
function isSubscriptionSale(plan) {
const saleMode = String(plan?.sale_mode || "subscription").toLowerCase();
return !["traffic", "traffic_package", "topup", "premium_topup", "hwid_devices"].includes(
saleMode
);
return ![
"traffic",
"traffic_package",
"topup",
"premium_topup",
"hwid_devices",
"hwid_devices_renewal",
].includes(saleMode);
}
function paymentSuccessContext(s, response = {}) {
@@ -53,6 +58,8 @@ export function createBillingStore({
paymentId: response.payment_id || "",
initialSubscriptionPayment:
!s.paymentStartedWithActiveSubscription && isSubscriptionSale(s.selectedPlan),
renewalSubscriptionPayment:
s.paymentStartedWithActiveSubscription && isSubscriptionSale(s.selectedPlan),
};
}
@@ -64,7 +71,15 @@ export function createBillingStore({
paymentPollToken += 1;
}
showToast(t("wa_payment_success", {}, "Payment successful"));
await loadData({ fresh: true });
const payload = await loadData({ fresh: true });
if (
successContext.renewalSubscriptionPayment &&
payload?.subscription?.device_topup_renewal_available &&
payload?.subscription?.can_topup_devices
) {
showToast(t("wa_hwid_devices_renewal_prompt"));
openDeviceTopupModal(payload.payment_methods?.[0]?.id || "");
}
if (
successContext.initialSubscriptionPayment &&
typeof onSubscriptionActivated === "function"
@@ -190,6 +205,8 @@ export function createBillingStore({
state.update((s) => ({
...s,
deviceTopupModalOpen: true,
deviceTopupOptions: null,
selectedDeviceTopupPlan: null,
selectedMethod: s.selectedMethod || defaultMethod,
}));
loadDeviceTopupOptions();
+7
View File
@@ -591,6 +591,13 @@ a {
margin-top: 0;
}
.devices-topup-validity {
margin: 0;
color: var(--muted);
font-size: 11px;
line-height: 1.35;
}
.devices-list {
display: grid;
gap: 8px;
+20
View File
@@ -197,6 +197,16 @@
<p>{subscriptionPurchaseDescription}</p>
</div>
{/if}
{#if subscription?.active && Number(subscription?.extra_hwid_devices || 0) > 0}
<div class="subscription-purchase-description">
<p>
{t("wa_hwid_devices_renewal_notice", {
count: Number(subscription.extra_hwid_devices || 0),
date: subscription.extra_hwid_devices_valid_until_text || "",
})}
</p>
</div>
{/if}
<div class="period-grid period-grid-two-columns">
{#each selectedTariffPlans as plan}
<button
@@ -251,6 +261,16 @@
<p>{subscriptionPurchaseDescription}</p>
</div>
{/if}
{#if subscription?.active && Number(subscription?.extra_hwid_devices || 0) > 0}
<div class="subscription-purchase-description">
<p>
{t("wa_hwid_devices_renewal_notice", {
count: Number(subscription.extra_hwid_devices || 0),
date: subscription.extra_hwid_devices_valid_until_text || "",
})}
</p>
</div>
{/if}
<div class="period-grid period-grid-two-columns">
{#each plans as plan}
<button
+19
View File
@@ -369,6 +369,25 @@
{#if !deviceTopupOptions}
<DialogOptionsSkeleton label={t("wa_tariff_options_loading")} rows={3} />
{:else if deviceTopupOptions?.plans?.length}
{#if deviceTopupOptions?.renewal_available}
<div class="topup-carryover-note">
<p>
{t("wa_hwid_devices_renewal_offer", {
count: Number(deviceTopupOptions.renewal_recommended_count || 0),
date: deviceTopupOptions.extra_hwid_devices_valid_until_text || "",
})}
</p>
</div>
{:else if Number(deviceTopupOptions?.extra_hwid_devices || 0) > 0 && deviceTopupOptions?.extra_hwid_devices_valid_until_text}
<div class="topup-carryover-note">
<p>
{t("wa_hwid_devices_valid_until", {
count: Number(deviceTopupOptions.extra_hwid_devices || 0),
date: deviceTopupOptions.extra_hwid_devices_valid_until_text,
})}
</p>
</div>
{/if}
<div class="option-list">
{#each deviceTopupOptions.plans as plan}
<button
@@ -46,6 +46,14 @@
value={devicesPercent(devicesData)}
label={t("wa_devices_title")}
/>
{#if Number(subscription?.extra_hwid_devices || 0) > 0 && subscription?.extra_hwid_devices_valid_until_text}
<p class="devices-topup-validity">
{t("wa_hwid_devices_valid_until", {
count: Number(subscription.extra_hwid_devices || 0),
date: subscription.extra_hwid_devices_valid_until_text,
})}
</p>
{/if}
{#if subscription?.active && subscription?.max_devices !== 0 && subscription?.can_topup_devices}
<Button variant="secondary" class="wide" onclick={openDeviceTopupModal}>
<Plus size={17} />
+7
View File
@@ -841,13 +841,20 @@
"buy_hwid_devices_menu_button": "+ HWID devices",
"buy_hwid_devices_button": "+{count} HWID for {price} {currency_symbol}",
"select_hwid_device_package": "Select HWID device package:",
"select_hwid_device_renewal_package": "Select an HWID device package for the new subscription period. Your current top-up is valid until {date}.",
"choose_payment_method_hwid_devices": "Choose a payment method for extra HWID devices:",
"no_hwid_device_packages_available": "Extra HWID devices are not configured for this tariff.",
"hwid_devices_unlimited_no_topup": "Your device limit is already unlimited.",
"payment_description_hwid_devices": "Extra HWID devices +{count}",
"payment_successful_hwid_devices_renewal_note": "You currently have +{count} extra HWID devices valid until {date}. Buy them again if you need them for the renewed subscription period.",
"subscription_hwid_renewal_reminder": "You have +{count} extra HWID devices valid until {date}. Renewing the subscription does not renew the device top-up automatically.",
"wa_buy_hwid_devices": "Buy devices",
"wa_device_topup_for_tariff": "Device packages for {tariff}",
"wa_hwid_devices_package": "+{count} devices",
"wa_hwid_devices_valid_until": "+{count} extra devices are valid until {date}",
"wa_hwid_devices_renewal_notice": "Your +{count} extra devices are valid until {date}. Renewing the subscription does not renew them automatically.",
"wa_hwid_devices_renewal_offer": "Your current +{count} device top-up is valid until {date}. Choose a package to renew devices for the new subscription period.",
"wa_hwid_devices_renewal_prompt": "Subscription renewed. Buy devices again for the new period.",
"wa_no_hwid_device_options": "No device packages available",
"wa_device_topup_options_failed": "Could not load device packages",
"admin_nav_overview": "Overview",
+7
View File
@@ -841,13 +841,20 @@
"buy_hwid_devices_menu_button": "+ HWID устройства",
"buy_hwid_devices_button": "+{count} HWID за {price} {currency_symbol}",
"select_hwid_device_package": "Выберите пакет HWID устройств:",
"select_hwid_device_renewal_package": "Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.",
"choose_payment_method_hwid_devices": "Выберите способ оплаты дополнительных HWID устройств:",
"no_hwid_device_packages_available": "Дополнительные HWID устройства для этого тарифа не настроены.",
"hwid_devices_unlimited_no_topup": "У вас уже безлимитное число устройств.",
"payment_description_hwid_devices": "Дополнительные HWID устройства +{count}",
"payment_successful_hwid_devices_renewal_note": "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.",
"subscription_hwid_renewal_reminder": "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.",
"wa_buy_hwid_devices": "Купить устройства",
"wa_device_topup_for_tariff": "Пакеты устройств для тарифа {tariff}",
"wa_hwid_devices_package": "+{count} устройств",
"wa_hwid_devices_valid_until": "+{count} дополнительных устройств действует до {date}",
"wa_hwid_devices_renewal_notice": "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.",
"wa_hwid_devices_renewal_offer": "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.",
"wa_hwid_devices_renewal_prompt": "Подписка продлена. Докупите устройства для нового срока.",
"wa_no_hwid_device_options": "Нет доступных пакетов устройств",
"wa_device_topup_options_failed": "Не удалось загрузить пакеты устройств",
"admin_nav_overview": "Обзор",
+30
View File
@@ -56,6 +56,22 @@ def _tariffs_payload(*, hwid_rub=None, hwid_stars=None, has_premium=False) -> di
return {"default_tariff": "standard", "tariffs": [tariff]}
def _traffic_tariffs_payload(*, hwid_rub=None) -> dict:
tariff: Dict[str, Any] = {
"key": "traffic",
"names": {"en": "Traffic"},
"descriptions": {"en": "Traffic"},
"squad_uuids": ["main"],
"billing_model": "traffic",
"traffic_packages": {"rub": [{"gb": 100, "price": 100}], "stars": []},
"hwid_device_limit": 3,
"enabled": True,
}
if hwid_rub:
tariff["hwid_device_packages"] = {"rub": hwid_rub, "stars": []}
return {"default_tariff": "traffic", "tariffs": [tariff]}
def _make_settings(tmpdir: str, payload: Optional[dict] = None, **overrides: Any) -> Settings:
values: Dict[str, Any] = {
"_env_file": None,
@@ -178,6 +194,20 @@ class CanTopupDevicesFlagTests(unittest.TestCase):
)
self.assertFalse(payload["can_topup_devices"])
def test_flag_is_false_for_traffic_tariff_even_with_packages(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(
tmpdir,
_traffic_tariffs_payload(hwid_rub=[{"count": 1, "price": 50}]),
)
payload = _serialize_subscription(
settings,
_active(tariff_key="traffic", billing_model="traffic"),
None,
"en",
)
self.assertFalse(payload["can_topup_devices"])
if __name__ == "__main__": # pragma: no cover
unittest.main()
+219 -4
View File
@@ -9,8 +9,9 @@ recent fix). These tests pin that:
returns a meaningful payload;
* the panel call uses ``hwidDeviceLimit = base + extra``, not just ``base``;
* a panel failure returns ``None`` and DOES NOT write the audit row;
* the happy path persists ``extra_hwid_devices = old + purchased`` and
records the device purchase.
* the happy path records a validity window ending at the subscription end;
* renewal top-ups start after the current HWID entitlement and do not double
the active device limit before that date.
"""
import json
@@ -75,6 +76,8 @@ def _make_sub(*, hwid_device_limit=3, extra_hwid_devices=0):
panel_subscription_uuid="panel-sub",
tariff_key="standard",
end_date=datetime(2099, 1, 1, tzinfo=timezone.utc),
start_date=datetime(2098, 12, 1, tzinfo=timezone.utc),
duration_months=1,
hwid_device_limit=hwid_device_limit,
extra_hwid_devices=extra_hwid_devices,
)
@@ -135,8 +138,90 @@ class HwidDeviceTopupInputTests(unittest.IsolatedAsyncioTestCase):
)
self.assertIsNone(result)
async def test_rejects_traffic_tariff(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(
tmpdir,
_tariffs_config_payload(
billing_model="traffic",
traffic_packages={"rub": [{"gb": 100, "price": 100}], "stars": []},
),
)
service = _make_service(settings)
sub = _make_sub()
user = _make_user()
with (
patch(
"bot.services.subscription_service_impl.devices.user_dal.get_user_by_id",
AsyncMock(return_value=user),
),
patch(
"bot.services.subscription_service_impl.devices.subscription_dal.get_active_subscription_by_user_id",
AsyncMock(return_value=sub),
),
):
result = await service.activate_hwid_device_topup(
session=AsyncMock(),
user_id=42,
device_count=1,
payment_amount=50,
payment_db_id=1,
)
self.assertIsNone(result)
class HwidDeviceTopupBehaviourTests(unittest.IsolatedAsyncioTestCase):
async def test_quote_prorates_period_price_for_remaining_subscription_window(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(
tmpdir,
_tariffs_config_payload(
hwid_device_packages={
"rub": [
{
"count": 1,
"price": 100,
"prices": {"1": 100},
"min_price": 10,
}
],
"stars": [],
}
),
)
service = _make_service(settings)
sub = _make_sub()
sub.start_date = datetime(2099, 1, 1, tzinfo=timezone.utc)
sub.end_date = datetime(2099, 1, 31, tzinfo=timezone.utc)
user = _make_user()
with (
patch(
"bot.services.subscription_service_impl.devices.user_dal.get_user_by_id",
AsyncMock(return_value=user),
),
patch(
"bot.services.subscription_service_impl.devices.subscription_dal.get_active_subscription_by_user_id",
AsyncMock(return_value=sub),
),
patch(
"bot.services.subscription_service_impl.devices.tariff_dal.get_hwid_device_entitlement_summary",
AsyncMock(return_value={"active_devices": 0, "active_until": None}),
),
):
quote = await service.quote_hwid_device_topup(
session=AsyncMock(),
user_id=42,
device_count=1,
tariff_key="standard",
currency="rub",
now=datetime(2099, 1, 16, tzinfo=timezone.utc),
)
self.assertIsNotNone(quote)
self.assertEqual(quote["price"], 50)
self.assertAlmostEqual(quote["proration_ratio"], 0.5)
async def test_unlimited_subscriber_returns_noop_payload(self):
# hwid_device_limit == 0 means unlimited — top-up makes no sense and must skip.
with tempfile.TemporaryDirectory() as tmpdir:
@@ -170,7 +255,7 @@ class HwidDeviceTopupBehaviourTests(unittest.IsolatedAsyncioTestCase):
# Unlimited subscriber: no audit row, no panel touch.
create_purchase.assert_not_awaited()
async def test_panel_payload_uses_effective_limit_with_extras(self):
async def test_panel_payload_uses_effective_limit_with_active_entitlements(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(tmpdir, _tariffs_config_payload())
service = _make_service(settings)
@@ -196,6 +281,10 @@ class HwidDeviceTopupBehaviourTests(unittest.IsolatedAsyncioTestCase):
"bot.services.subscription_service_impl.devices.subscription_dal.update_subscription",
AsyncMock(return_value=updated_sub),
),
patch(
"bot.services.subscription_service_impl.devices.tariff_dal.get_hwid_device_entitlement_summary",
AsyncMock(return_value={"active_devices": 2, "active_until": sub.end_date}),
),
patch(
"bot.services.subscription_service_impl.payments.payment_dal.get_payment_by_db_id",
AsyncMock(return_value=SimpleNamespace()),
@@ -203,7 +292,7 @@ class HwidDeviceTopupBehaviourTests(unittest.IsolatedAsyncioTestCase):
patch(
"bot.services.subscription_service_impl.devices.tariff_dal.create_hwid_device_purchase",
AsyncMock(),
),
) as create_purchase,
):
result = await service.activate_hwid_device_topup(
session=AsyncMock(),
@@ -217,10 +306,132 @@ class HwidDeviceTopupBehaviourTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result["hwid_device_limit"], 6)
self.assertEqual(result["extra_hwid_devices"], 3)
self.assertEqual(result["purchased_hwid_devices"], 1)
create_purchase.assert_awaited_once()
purchase_kwargs = create_purchase.await_args.kwargs
self.assertEqual(purchase_kwargs["valid_until"], sub.end_date)
self.assertLess(purchase_kwargs["valid_from"], sub.end_date)
# Panel must see the full effective limit, not just the base.
panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1]
self.assertEqual(panel_payload["hwidDeviceLimit"], 6)
async def test_activation_uses_frozen_payment_validity_window(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(tmpdir, _tariffs_config_payload())
service = _make_service(settings)
sub = _make_sub(hwid_device_limit=3, extra_hwid_devices=0)
sub.end_date = datetime(2099, 3, 1, tzinfo=timezone.utc)
user = _make_user()
frozen_until = datetime(2099, 2, 1, tzinfo=timezone.utc)
payment = SimpleNamespace(
hwid_valid_from=datetime(2099, 1, 1, tzinfo=timezone.utc),
hwid_valid_until=frozen_until,
hwid_pricing_period_months=1,
hwid_proration_ratio=1.0,
hwid_full_price=50,
)
updated_sub = SimpleNamespace(subscription_id=11, end_date=sub.end_date)
service.panel_service.update_user_details_on_panel = AsyncMock(
return_value={"ok": True}
)
with (
patch(
"bot.services.subscription_service_impl.devices.user_dal.get_user_by_id",
AsyncMock(return_value=user),
),
patch(
"bot.services.subscription_service_impl.devices.subscription_dal.get_active_subscription_by_user_id",
AsyncMock(return_value=sub),
),
patch(
"bot.services.subscription_service_impl.devices.subscription_dal.update_subscription",
AsyncMock(return_value=updated_sub),
),
patch(
"bot.services.subscription_service_impl.devices.tariff_dal.get_hwid_device_entitlement_summary",
AsyncMock(return_value={"active_devices": 0, "active_until": None}),
),
patch(
"bot.services.subscription_service_impl.payments.payment_dal.get_payment_by_db_id",
AsyncMock(return_value=payment),
),
patch(
"bot.services.subscription_service_impl.devices.tariff_dal.create_hwid_device_purchase",
AsyncMock(),
) as create_purchase,
):
result = await service.activate_hwid_device_topup(
session=AsyncMock(),
user_id=42,
device_count=1,
payment_amount=50,
payment_db_id=1,
)
self.assertEqual(result["hwid_devices_valid_until"], frozen_until)
purchase_kwargs = create_purchase.await_args.kwargs
self.assertEqual(purchase_kwargs["valid_until"], frozen_until)
async def test_renewal_topup_starts_after_existing_entitlement(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(tmpdir, _tariffs_config_payload())
service = _make_service(settings)
current_entitlement_end = datetime(2099, 1, 1, tzinfo=timezone.utc)
renewed_end = datetime(2099, 2, 1, tzinfo=timezone.utc)
sub = _make_sub(hwid_device_limit=3, extra_hwid_devices=2)
sub.end_date = renewed_end
user = _make_user()
updated_sub = SimpleNamespace(subscription_id=11, end_date=renewed_end)
service.panel_service.update_user_details_on_panel = AsyncMock(
return_value={"ok": True}
)
with (
patch(
"bot.services.subscription_service_impl.devices.user_dal.get_user_by_id",
AsyncMock(return_value=user),
),
patch(
"bot.services.subscription_service_impl.devices.subscription_dal.get_active_subscription_by_user_id",
AsyncMock(return_value=sub),
),
patch(
"bot.services.subscription_service_impl.devices.subscription_dal.update_subscription",
AsyncMock(return_value=updated_sub),
),
patch(
"bot.services.subscription_service_impl.devices.tariff_dal.get_hwid_device_entitlement_summary",
AsyncMock(
return_value={
"active_devices": 2,
"active_until": current_entitlement_end,
}
),
),
patch(
"bot.services.subscription_service_impl.payments.payment_dal.get_payment_by_db_id",
AsyncMock(return_value=SimpleNamespace()),
),
patch(
"bot.services.subscription_service_impl.devices.tariff_dal.create_hwid_device_purchase",
AsyncMock(),
) as create_purchase,
):
result = await service.activate_hwid_device_topup(
session=AsyncMock(),
user_id=42,
device_count=1,
payment_amount=50,
payment_db_id=1,
renewal=True,
)
self.assertEqual(result["extra_hwid_devices"], 2)
self.assertEqual(result["hwid_device_limit"], 5)
purchase_kwargs = create_purchase.await_args.kwargs
self.assertEqual(purchase_kwargs["valid_from"], current_entitlement_end)
self.assertEqual(purchase_kwargs["valid_until"], renewed_end)
panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1]
self.assertEqual(panel_payload["hwidDeviceLimit"], 5)
async def test_panel_failure_returns_none_and_skips_audit(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _make_settings(tmpdir, _tariffs_config_payload())
@@ -242,6 +453,10 @@ class HwidDeviceTopupBehaviourTests(unittest.IsolatedAsyncioTestCase):
"bot.services.subscription_service_impl.devices.subscription_dal.update_subscription",
AsyncMock(return_value=updated_sub),
),
patch(
"bot.services.subscription_service_impl.devices.tariff_dal.get_hwid_device_entitlement_summary",
AsyncMock(return_value={"active_devices": 0, "active_until": None}),
),
patch(
"bot.services.subscription_service_impl.payments.payment_dal.get_payment_by_db_id",
AsyncMock(return_value=SimpleNamespace()),
+80
View File
@@ -0,0 +1,80 @@
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from bot.services.tariff_worker import TariffTrafficWorker
class _Service:
def _base_hwid_limit_for_tariff(self, tariff):
return tariff.hwid_device_limit
@staticmethod
def _effective_hwid_limit(base_limit, extra_devices=0):
if base_limit is None:
return None
base_int = max(0, int(base_limit))
if base_int == 0:
return 0
return base_int + max(0, int(extra_devices or 0))
@staticmethod
def _build_panel_update_payload(
*,
panel_user_uuid=None,
expire_at=None,
hwid_device_limit=None,
include_default_squads=True,
**_kwargs,
):
payload = {}
if panel_user_uuid:
payload["uuid"] = panel_user_uuid
if expire_at:
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace(
"+00:00", "Z"
)
if hwid_device_limit is not None:
payload["hwidDeviceLimit"] = int(hwid_device_limit)
return payload
class HwidDeviceWorkerTests(unittest.IsolatedAsyncioTestCase):
async def test_worker_resets_expired_hwid_entitlement_on_panel(self):
panel = SimpleNamespace(
update_user_details_on_panel=AsyncMock(return_value={"ok": True})
)
worker = TariffTrafficWorker(
settings=SimpleNamespace(),
session_factory=None,
panel_service=panel,
subscription_service=_Service(),
)
sub = SimpleNamespace(
subscription_id=11,
panel_user_uuid="panel-user",
end_date=datetime(2099, 1, 1, tzinfo=timezone.utc),
hwid_device_limit=3,
extra_hwid_devices=2,
)
tariff = SimpleNamespace(hwid_device_limit=3)
with patch(
"bot.services.tariff_worker.tariff_dal.sum_active_hwid_devices",
AsyncMock(return_value=0),
):
await worker._sync_hwid_device_limit(
session=AsyncMock(),
sub=sub,
tariff=tariff,
panel_data={"hwidDeviceLimit": 5},
)
self.assertEqual(sub.extra_hwid_devices, 0)
panel_payload = panel.update_user_details_on_panel.await_args.args[1]
self.assertEqual(panel_payload["hwidDeviceLimit"], 3)
if __name__ == "__main__": # pragma: no cover
unittest.main()
+190
View File
@@ -0,0 +1,190 @@
import json
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
def _settings(tmpdir: str) -> Settings:
payload = {
"default_tariff": "basic",
"tariffs": [
{
"key": "basic",
"names": {"en": "Basic"},
"descriptions": {"en": "Basic"},
"squad_uuids": ["basic"],
"billing_model": "period",
"monthly_gb": 100,
"prices_rub": {"1": 100},
"enabled_periods": [1],
"hwid_device_limit": 3,
"enabled": True,
},
{
"key": "pro",
"names": {"en": "Pro"},
"descriptions": {"en": "Pro"},
"squad_uuids": ["pro"],
"billing_model": "period",
"monthly_gb": 200,
"prices_rub": {"1": 200},
"enabled_periods": [1],
"hwid_device_limit": 5,
"enabled": True,
},
],
}
config_path = Path(tmpdir) / "tariffs.json"
config_path.write_text(json.dumps(payload), encoding="utf-8")
return Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="u",
POSTGRES_PASSWORD="p",
TARIFFS_CONFIG_PATH=str(config_path),
)
def _service(settings: Settings) -> SubscriptionService:
panel = AsyncMock(spec=PanelApiService)
panel.update_user_details_on_panel = AsyncMock(return_value={"ok": True})
return SubscriptionService(settings, panel)
class HwidTariffSwitchConversionTests(unittest.IsolatedAsyncioTestCase):
async def test_hwid_remaining_rub_value_is_converted_to_target_tariff_days(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _settings(tmpdir)
service = _service(settings)
now = datetime(2099, 1, 15, tzinfo=timezone.utc)
sub = SimpleNamespace(subscription_id=11)
with patch(
"bot.services.subscription_service_impl.tariffs.tariff_dal.get_hwid_device_value_entries",
AsyncMock(
return_value=[
{
"purchase_id": 7,
"purchased_devices": 1,
"valid_from": now - timedelta(days=15),
"valid_until": now + timedelta(days=15),
"created_at": now - timedelta(days=15),
"amount": 100,
"currency": "RUB",
}
]
),
):
credit = await service._hwid_conversion_credit(
AsyncMock(),
sub,
at=now,
)
self.assertEqual(credit["purchase_ids"], [7])
self.assertAlmostEqual(credit["value_rub"], 50)
async def test_switch_expires_converted_hwid_purchases_and_audits_value(self):
with tempfile.TemporaryDirectory() as tmpdir:
settings = _settings(tmpdir)
service = _service(settings)
user = SimpleNamespace(
user_id=42,
telegram_id=42,
panel_user_uuid="panel-user",
email=None,
username="u",
first_name="U",
last_name="L",
)
sub = SimpleNamespace(
subscription_id=11,
user_id=42,
panel_user_uuid="panel-user",
panel_subscription_uuid="panel-sub",
tariff_key="basic",
start_date=datetime(2099, 1, 1, tzinfo=timezone.utc),
end_date=datetime(2099, 2, 1, tzinfo=timezone.utc),
effective_monthly_price_rub=100,
premium_topup_balance_bytes=0,
premium_topup_used_bytes=0,
premium_used_bytes=0,
topup_balance_bytes=0,
regular_bonus_bytes=0,
regular_unlimited_override=False,
traffic_used_bytes=0,
extra_hwid_devices=1,
hwid_device_limit=3,
)
updated = SimpleNamespace(**{**sub.__dict__, "tariff_key": "pro"})
updated.hwid_device_limit = 5
updated.extra_hwid_devices = 0
updated.traffic_limit_bytes = 200 * (1024**3)
updated.premium_is_limited = False
updated.effective_monthly_price_rub = 200
with (
patch(
"bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id",
AsyncMock(return_value=user),
),
patch(
"bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id",
AsyncMock(return_value=sub),
),
patch.object(
service,
"calculate_tariff_switch_options_with_hwid",
AsyncMock(
return_value={
"mode": "period_to_period",
"remaining_days": 20,
"recalc_days": 25,
"paid_diff_rub": 0,
"target_monthly_rub": 200,
"converted_hwid_value_rub": 50,
"converted_hwid_days": 7,
"convertible_hwid_purchase_ids": [7],
}
),
),
patch(
"bot.services.subscription_service_impl.lifecycle.tariff_dal.expire_hwid_device_purchases",
AsyncMock(return_value=1),
) as expire_purchases,
patch(
"bot.services.subscription_service_impl.lifecycle.tariff_dal.sum_active_hwid_devices",
AsyncMock(return_value=0),
),
patch(
"bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription",
AsyncMock(return_value=updated),
),
patch(
"bot.services.subscription_service_impl.lifecycle.tariff_dal.create_tariff_change",
AsyncMock(),
) as create_change,
):
result = await service.switch_tariff_without_payment(
AsyncMock(),
user_id=42,
target_tariff_key="pro",
mode="recalc_days",
)
self.assertEqual(result["tariff_key"], "pro")
expire_purchases.assert_awaited_once()
change_payload = create_change.await_args.args[1]
self.assertEqual(change_payload["converted_hwid_value_rub"], 50)
self.assertEqual(change_payload["converted_hwid_days"], 7)
if __name__ == "__main__": # pragma: no cover
unittest.main()
+4 -1
View File
@@ -110,7 +110,7 @@ class TariffsConfigTests(unittest.TestCase):
data = _valid_config()
data["tariffs"][0]["hwid_device_limit"] = 5
data["tariffs"][0]["hwid_device_packages"] = {
"rub": [{"count": 1, "price": 99}],
"rub": [{"count": 1, "price": 99, "prices": {"3": 249}, "min_price": 20}],
"stars": [{"count": 1, "price": 2500}],
}
@@ -120,6 +120,9 @@ class TariffsConfigTests(unittest.TestCase):
self.assertEqual(tariff.hwid_device_limit, 5)
self.assertTrue(tariff.has_hwid_device_packages())
self.assertEqual(tariff.hwid_device_packages.rub[0].count, 1)
self.assertEqual(tariff.hwid_device_packages.rub[0].price_for_period(3), 249)
self.assertEqual(tariff.hwid_device_packages.rub[0].price_for_period(6), 594)
self.assertEqual(tariff.hwid_device_packages.rub[0].min_price, 20)
def test_negative_hwid_device_limit_rejected(self):
data = _valid_config()