fix: bind HWID top-ups to subscription periods
This commit is contained in:
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
@@ -1240,6 +1240,7 @@
|
||||
stripTopupQueryFromUrl();
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function loadPublicInstall(shareToken) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Обзор",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user