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,
|
||||
|
||||
Reference in New Issue
Block a user