fix: separate HWID device renewal flows
Keep one-off device top-ups scoped to the active subscription term and move device renewal into subscription checkout. Carry HWID renewal metadata through provider callbacks and webhooks, including YooKassa saved-card flows. Add admin extension controls, docs, demo data, and regression coverage.
This commit is contained in:
@@ -1515,6 +1515,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
return _error(400, "invalid_days")
|
||||
if days <= 0:
|
||||
return _error(400, "invalid_days")
|
||||
extend_hwid_devices = payload.get("extend_hwid_devices")
|
||||
extend_hwid_devices = True if extend_hwid_devices is None else bool(extend_hwid_devices)
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if subscription_service is None:
|
||||
@@ -1527,6 +1529,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
target_id,
|
||||
days,
|
||||
"admin_extend_subscription_webapp",
|
||||
extend_hwid_devices=extend_hwid_devices,
|
||||
)
|
||||
if not new_end:
|
||||
await session.rollback()
|
||||
@@ -1537,7 +1540,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_extend_subscription_webapp",
|
||||
"content": f"+{days}d -> {new_end.isoformat()}",
|
||||
"content": (
|
||||
f"+{days}d -> {new_end.isoformat()} "
|
||||
f"(hwid={'yes' if extend_hwid_devices else 'no'})"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
|
||||
@@ -121,10 +121,11 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
hwid_quote: Optional[Dict[str, Any]] = None
|
||||
requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or ""))
|
||||
|
||||
if tariffs_config and requested_sale_mode == "hwid_devices_renewal":
|
||||
return _json_error(400, "invalid_plan", "Device renewal is part of subscription renewal")
|
||||
if tariffs_config and requested_sale_mode in {
|
||||
"hwid_device",
|
||||
"hwid_devices",
|
||||
"hwid_devices_renewal",
|
||||
}:
|
||||
tariff_key = str(payment_payload.tariff_key or "").strip()
|
||||
if not tariff_key:
|
||||
@@ -318,7 +319,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
user_id=user_id,
|
||||
device_count=int(payment_units),
|
||||
tariff_key=sale_tariff_key,
|
||||
renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal",
|
||||
renewal=False,
|
||||
currency=currency,
|
||||
)
|
||||
if not hwid_quote:
|
||||
@@ -331,6 +332,25 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
else:
|
||||
price = float(hwid_quote["price"])
|
||||
stars_price = None
|
||||
elif _sale_mode_base(sale_mode) == "subscription" and bool(
|
||||
payment_payload.renew_hwid_devices
|
||||
):
|
||||
currency = "stars" if method == "stars" else default_currency
|
||||
sale_tariff_key = _sale_mode_tariff_key(sale_mode)
|
||||
if sale_tariff_key:
|
||||
hwid_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=sale_tariff_key,
|
||||
months=int(payment_units),
|
||||
currency=currency,
|
||||
)
|
||||
if hwid_quote:
|
||||
if method == "stars":
|
||||
stars_price = int(stars_price or 0) + int(hwid_quote["price"])
|
||||
else:
|
||||
price = float(price or 0) + float(hwid_quote["price"])
|
||||
stars_price = None
|
||||
admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])}
|
||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||
return await _create_subscription_payment(
|
||||
@@ -691,7 +711,6 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
|
||||
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
renewal_available = bool(active and active.get("device_topup_renewal_available"))
|
||||
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None
|
||||
extra_hwid_valid_until_text = (
|
||||
active.get("extra_hwid_devices_valid_until_text") if active else None
|
||||
@@ -713,7 +732,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
user_id=user_id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=renewal_available,
|
||||
renewal=False,
|
||||
currency=default_currency,
|
||||
)
|
||||
if count in currency_counts
|
||||
@@ -725,7 +744,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
user_id=user_id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=renewal_available,
|
||||
renewal=False,
|
||||
currency="stars",
|
||||
)
|
||||
if count in stars_counts
|
||||
@@ -733,28 +752,27 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
if not currency_quote and not stars_quote:
|
||||
continue
|
||||
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
|
||||
quote = currency_quote or stars_quote
|
||||
valid_from = quote.get("valid_from")
|
||||
valid_until = quote.get("valid_until")
|
||||
plan = {
|
||||
"id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}",
|
||||
"id": f"{tariff.key}:hwid:{count}",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"sale_mode": sale_mode_for_plan,
|
||||
"sale_mode": "hwid_devices",
|
||||
"renewal": False,
|
||||
"months": count,
|
||||
"device_count": count,
|
||||
"price": float(currency_quote.get("price") if currency_quote else 0),
|
||||
"currency": default_currency_code,
|
||||
"title": f"+{count}",
|
||||
"subtitle": tariff.name(lang),
|
||||
"valid_from": _billing_iso_datetime(
|
||||
(currency_quote or stars_quote).get("valid_from")
|
||||
),
|
||||
"valid_until": _billing_iso_datetime(
|
||||
(currency_quote or stars_quote).get("valid_until")
|
||||
),
|
||||
"proration_ratio": float(
|
||||
(currency_quote or stars_quote).get("proration_ratio") or 0
|
||||
),
|
||||
"valid_from": _billing_iso_datetime(valid_from),
|
||||
"valid_from_text": _billing_datetime_text(valid_from),
|
||||
"valid_until": _billing_iso_datetime(valid_until),
|
||||
"valid_until_text": _billing_datetime_text(valid_until),
|
||||
"proration_ratio": float(quote.get("proration_ratio") or 0),
|
||||
}
|
||||
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||
plan["stars_price"] = int(stars_quote["price"])
|
||||
@@ -770,10 +788,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
else int(sub.extra_hwid_devices or 0),
|
||||
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
|
||||
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
|
||||
"renewal_available": renewal_available,
|
||||
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
|
||||
if active and renewal_available
|
||||
else 0,
|
||||
"renewal_available": False,
|
||||
"renewal_recommended_count": 0,
|
||||
"plans": plans,
|
||||
}
|
||||
)
|
||||
@@ -939,7 +955,11 @@ async def payment_status_route(request: web.Request) -> web.Response:
|
||||
payment = await _refresh_yookassa_payment_status(request, session, payment)
|
||||
payment = await _refresh_wata_payment_status(request, session, payment)
|
||||
if payment.status == "succeeded":
|
||||
await invalidate_webapp_user_caches(request.app["settings"], user_id)
|
||||
await invalidate_webapp_user_caches(
|
||||
request.app["settings"],
|
||||
user_id,
|
||||
include_devices=True,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -1037,6 +1057,7 @@ async def _create_subscription_payment(
|
||||
description=description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
|
||||
hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
|
||||
|
||||
@@ -49,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel):
|
||||
device_count: Any = None
|
||||
tariff_key: Optional[constr(max_length=128)] = None
|
||||
sale_mode: Optional[constr(max_length=64)] = None
|
||||
renew_hwid_devices: Optional[bool] = None
|
||||
description: Optional[constr(max_length=4096)] = None
|
||||
comment: Optional[constr(max_length=4096)] = None
|
||||
note: Optional[constr(max_length=4096)] = None
|
||||
|
||||
@@ -69,13 +69,30 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
and settings.TRIAL_DURATION_DAYS > 0
|
||||
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
)
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
plans_payload = _serialize_plans(
|
||||
settings,
|
||||
lang,
|
||||
subscription_options=cached["subscription_options"],
|
||||
stars_subscription_options=cached["stars_subscription_options"],
|
||||
traffic_packages=cached["traffic_packages"],
|
||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||
)
|
||||
await _attach_hwid_renewal_quotes_to_plans(
|
||||
session,
|
||||
subscription_service,
|
||||
user_id=user_id,
|
||||
settings=settings,
|
||||
active=active,
|
||||
local_sub=local_sub,
|
||||
plans=plans_payload,
|
||||
)
|
||||
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
|
||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||
telegram_notifications_status = normalize_telegram_notification_status(
|
||||
@@ -128,14 +145,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
),
|
||||
"bonus_details": _serialize_referral_bonus_details(settings, lang),
|
||||
},
|
||||
"plans": _serialize_plans(
|
||||
settings,
|
||||
lang,
|
||||
subscription_options=cached["subscription_options"],
|
||||
stars_subscription_options=cached["stars_subscription_options"],
|
||||
traffic_packages=cached["traffic_packages"],
|
||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||
),
|
||||
"plans": plans_payload,
|
||||
"payment_methods": _serialize_payment_methods(
|
||||
settings,
|
||||
request.app,
|
||||
@@ -438,6 +448,102 @@ def _serialize_subscription(
|
||||
}
|
||||
|
||||
|
||||
def _webapp_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _webapp_datetime_text(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
return str(value)
|
||||
|
||||
|
||||
async def _attach_hwid_renewal_quotes_to_plans(
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
*,
|
||||
user_id: int,
|
||||
settings: Settings,
|
||||
active: Optional[Dict[str, Any]],
|
||||
local_sub: Optional[Any],
|
||||
plans: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
quote_method = getattr(subscription_service, "quote_hwid_device_renewal_for_subscription", None)
|
||||
if not callable(quote_method):
|
||||
return
|
||||
if not active or not local_sub or not settings.tariffs_config:
|
||||
return
|
||||
if not active.get("end_date") or int(active.get("extra_hwid_devices") or 0) <= 0:
|
||||
return
|
||||
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
for plan in plans:
|
||||
if str(plan.get("sale_mode") or "subscription") != "subscription":
|
||||
continue
|
||||
target_tariff_key = str(plan.get("tariff_key") or "").strip()
|
||||
if not target_tariff_key:
|
||||
continue
|
||||
try:
|
||||
months = int(plan.get("months") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if months <= 0:
|
||||
continue
|
||||
try:
|
||||
currency_quote = await quote_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=target_tariff_key,
|
||||
months=months,
|
||||
currency=default_currency,
|
||||
)
|
||||
stars_quote = await quote_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=target_tariff_key,
|
||||
months=months,
|
||||
currency="stars",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to quote HWID renewal for plan %s/%s",
|
||||
target_tariff_key,
|
||||
months,
|
||||
)
|
||||
continue
|
||||
quote = currency_quote or stars_quote
|
||||
if not quote:
|
||||
continue
|
||||
valid_from = quote.get("valid_from")
|
||||
valid_until = quote.get("valid_until")
|
||||
active_until = quote.get("active_until")
|
||||
renewal = {
|
||||
"available": True,
|
||||
"device_count": int(quote.get("device_count") or 0),
|
||||
"price": float(currency_quote.get("price") if currency_quote else 0),
|
||||
"currency": default_currency_code,
|
||||
"valid_from": _webapp_iso_datetime(valid_from),
|
||||
"valid_from_text": _webapp_datetime_text(valid_from),
|
||||
"valid_until": _webapp_iso_datetime(valid_until),
|
||||
"valid_until_text": _webapp_datetime_text(valid_until),
|
||||
"active_until": _webapp_iso_datetime(active_until),
|
||||
"active_until_text": _webapp_datetime_text(active_until),
|
||||
"pricing_period_months": int(quote.get("pricing_period_months") or months),
|
||||
}
|
||||
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||
renewal["stars_price"] = int(stars_quote["price"])
|
||||
plan["hwid_renewal"] = renewal
|
||||
|
||||
|
||||
def _build_install_share_link(
|
||||
request: Optional[web.Request],
|
||||
settings: Settings,
|
||||
|
||||
@@ -319,7 +319,11 @@ async def select_tariff_callback(
|
||||
|
||||
@router.callback_query(F.data.startswith("tariff:period:"))
|
||||
async def select_tariff_period_callback(
|
||||
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
@@ -333,7 +337,9 @@ async def select_tariff_period_callback(
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
tariff_key, months_raw = parts[2], parts[3]
|
||||
callback_context = parts[4] if len(parts) > 4 else None
|
||||
callback_tokens = [part for part in parts[4:] if part]
|
||||
callback_context = "bot" if "bot" in callback_tokens else None
|
||||
renew_hwid_devices = "no_hwid" not in callback_tokens
|
||||
tariff = config.require(tariff_key)
|
||||
months = int(months_raw)
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
@@ -343,6 +349,22 @@ async def select_tariff_period_callback(
|
||||
if price_rub is None:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
hwid_renewal_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
target_tariff_key=tariff.key,
|
||||
months=months,
|
||||
currency=default_currency,
|
||||
)
|
||||
hwid_renewal_stars_quote = (
|
||||
await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
target_tariff_key=tariff.key,
|
||||
months=months,
|
||||
currency="stars",
|
||||
)
|
||||
)
|
||||
markup = get_payment_method_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
@@ -354,6 +376,9 @@ async def select_tariff_period_callback(
|
||||
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
|
||||
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
|
||||
user_id=callback.from_user.id,
|
||||
hwid_renewal_quote=hwid_renewal_quote,
|
||||
hwid_renewal_stars_quote=hwid_renewal_stars_quote,
|
||||
hwid_renewal_selected=bool(renew_hwid_devices),
|
||||
)
|
||||
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -577,7 +602,6 @@ async def hwid_devices_list_callback(
|
||||
if not packages:
|
||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||
return
|
||||
renewal_available = bool(active.get("device_topup_renewal_available"))
|
||||
markup = get_hwid_device_packages_keyboard(
|
||||
tariff,
|
||||
packages,
|
||||
@@ -585,14 +609,11 @@ async def hwid_devices_list_callback(
|
||||
i18n,
|
||||
settings,
|
||||
back_callback="main_action:my_devices",
|
||||
renewal=renewal_available,
|
||||
)
|
||||
text_key = (
|
||||
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
|
||||
renewal=False,
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
text_key,
|
||||
"select_hwid_device_package",
|
||||
date=active.get("extra_hwid_devices_valid_until_text") or "",
|
||||
),
|
||||
reply_markup=markup,
|
||||
@@ -640,6 +661,7 @@ async def hwid_devices_package_callback(
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
|
||||
renewal = action == "renewal_package"
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
currency_quote = await subscription_service.quote_hwid_device_topup(
|
||||
@@ -647,7 +669,7 @@ async def hwid_devices_package_callback(
|
||||
user_id=callback.from_user.id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=action == "renewal_package",
|
||||
renewal=renewal,
|
||||
currency=default_currency,
|
||||
)
|
||||
stars_quote = await subscription_service.quote_hwid_device_topup(
|
||||
@@ -655,7 +677,7 @@ async def hwid_devices_package_callback(
|
||||
user_id=callback.from_user.id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=action == "renewal_package",
|
||||
renewal=renewal,
|
||||
currency="stars",
|
||||
)
|
||||
if not currency_quote and not stars_quote:
|
||||
|
||||
@@ -14,6 +14,13 @@ from config.tariffs_config import (
|
||||
)
|
||||
|
||||
BOT_MENU_CONTEXT = "bot"
|
||||
HWID_RENEWAL_TOKEN = "hwid_renewal"
|
||||
|
||||
|
||||
def sale_mode_tokens(sale_mode: Optional[str]) -> Tuple[str, ...]:
|
||||
if not sale_mode or "|" not in sale_mode:
|
||||
return ()
|
||||
return tuple(token.strip() for token in str(sale_mode).split("|")[1:] if token.strip())
|
||||
|
||||
|
||||
def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]:
|
||||
@@ -24,16 +31,36 @@ def callback_context_from_back_callback(back_callback: Optional[str]) -> Optiona
|
||||
|
||||
def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
if not context or "|" in sale_mode:
|
||||
if not context or context in sale_mode_tokens(sale_mode):
|
||||
return sale_mode
|
||||
return f"{sale_mode}|{context}"
|
||||
|
||||
|
||||
def sale_mode_with_token(sale_mode: str, token: str) -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
token = str(token or "").strip()
|
||||
if not token or token in sale_mode_tokens(sale_mode):
|
||||
return sale_mode
|
||||
return f"{sale_mode}|{token}"
|
||||
|
||||
|
||||
def sale_mode_without_token(sale_mode: str, token: str) -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
token = str(token or "").strip()
|
||||
if not token or "|" not in sale_mode:
|
||||
return sale_mode
|
||||
base, *tokens = sale_mode.split("|")
|
||||
kept = [item for item in tokens if item.strip() and item.strip() != token]
|
||||
return "|".join([base, *kept])
|
||||
|
||||
|
||||
def sale_mode_has_token(sale_mode: Optional[str], token: str) -> bool:
|
||||
return str(token or "").strip() in sale_mode_tokens(sale_mode)
|
||||
|
||||
|
||||
def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]:
|
||||
if not sale_mode or "|" not in sale_mode:
|
||||
return None
|
||||
context = str(sale_mode).split("|", 1)[1].strip()
|
||||
return context or None
|
||||
tokens = sale_mode_tokens(sale_mode)
|
||||
return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None
|
||||
|
||||
|
||||
def callback_suffix_for_context(context: Optional[str]) -> str:
|
||||
@@ -484,6 +511,9 @@ def get_payment_method_keyboard(
|
||||
back_callback: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: Optional[bool] = None,
|
||||
hwid_renewal_quote: Optional[Dict[str, Any]] = None,
|
||||
hwid_renewal_stars_quote: Optional[Dict[str, Any]] = None,
|
||||
hwid_renewal_selected: bool = True,
|
||||
) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
@@ -492,12 +522,39 @@ def get_payment_method_keyboard(
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
|
||||
value_str = _format_value(months)
|
||||
import logging as _kbd_logging
|
||||
|
||||
_kbd_logging.info(
|
||||
"payment_method_keyboard build: order=%s",
|
||||
settings.payment_methods_order,
|
||||
)
|
||||
payment_sale_mode = sale_mode
|
||||
selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote
|
||||
if selected_hwid_quote:
|
||||
tariff_key = None
|
||||
sale_mode_main = str(sale_mode or "").split("|", 1)[0]
|
||||
if "@" in sale_mode_main:
|
||||
tariff_key = sale_mode_main.split("@", 1)[1]
|
||||
context = callback_context_from_sale_mode(sale_mode)
|
||||
toggle_tokens = [f"tariff:period:{tariff_key}:{value_str}"]
|
||||
if context:
|
||||
toggle_tokens.append(context)
|
||||
toggle_tokens.append("no_hwid" if hwid_renewal_selected else "hwid")
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(
|
||||
"payment_hwid_renewal_toggle_on"
|
||||
if hwid_renewal_selected
|
||||
else "payment_hwid_renewal_toggle_off",
|
||||
count=int(selected_hwid_quote.get("device_count") or 0),
|
||||
price=(
|
||||
hwid_renewal_quote.get("price")
|
||||
if hwid_renewal_quote
|
||||
else hwid_renewal_stars_quote.get("price")
|
||||
),
|
||||
currency_symbol=currency_symbol_val,
|
||||
),
|
||||
callback_data=":".join(toggle_tokens),
|
||||
)
|
||||
)
|
||||
if hwid_renewal_selected:
|
||||
payment_sale_mode = sale_mode_with_token(sale_mode, HWID_RENEWAL_TOKEN)
|
||||
else:
|
||||
payment_sale_mode = sale_mode_without_token(sale_mode, HWID_RENEWAL_TOKEN)
|
||||
from bot.payment_providers import get_provider_spec, provider_telegram_button_text
|
||||
|
||||
for method in settings.payment_methods_order:
|
||||
@@ -518,7 +575,7 @@ def get_payment_method_keyboard(
|
||||
value=value_str,
|
||||
rub_price=price,
|
||||
stars_price=stars_price,
|
||||
sale_mode=sale_mode,
|
||||
sale_mode=payment_sale_mode,
|
||||
)
|
||||
if not callback_data:
|
||||
continue
|
||||
@@ -577,7 +634,7 @@ def get_yk_autopay_choice_keyboard(
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}",
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:0{suffix}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
|
||||
@@ -114,6 +114,7 @@ class WebAppPaymentContext:
|
||||
sale_mode: str
|
||||
currency: str = "RUB"
|
||||
traffic_gb: Optional[float] = None
|
||||
hwid_device_count: Optional[int] = None
|
||||
hwid_valid_from: Optional[Any] = None
|
||||
hwid_valid_until: Optional[Any] = None
|
||||
hwid_pricing_period_months: Optional[int] = None
|
||||
|
||||
@@ -192,6 +192,7 @@ class CryptoPayService:
|
||||
sale_mode: str = "subscription",
|
||||
url_kind: str = "bot",
|
||||
hwid_quote: Optional[dict] = None,
|
||||
hwid_device_count: Optional[int] = None,
|
||||
currency: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
if not self.configured or not self.client:
|
||||
@@ -210,7 +211,11 @@ class CryptoPayService:
|
||||
return None
|
||||
|
||||
sale_base = sale_mode_base(sale_mode)
|
||||
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
|
||||
amounts = payment_record_amounts(
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
hwid_device_count=hwid_device_count,
|
||||
)
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
@@ -252,6 +257,7 @@ class CryptoPayService:
|
||||
"payment_db_id": str(payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
|
||||
"hwid_devices": amounts.purchased_hwid_devices,
|
||||
}
|
||||
)
|
||||
try:
|
||||
@@ -513,6 +519,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
}
|
||||
if ctx.hwid_valid_from and ctx.hwid_valid_until
|
||||
else None,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
if not url:
|
||||
return payment_failed()
|
||||
|
||||
@@ -609,6 +609,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
|
||||
@@ -8,8 +8,10 @@ from aiogram import types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
HWID_RENEWAL_TOKEN,
|
||||
get_payment_url_keyboard,
|
||||
payment_methods_back_callback,
|
||||
sale_mode_has_token,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import payment_dal
|
||||
@@ -123,6 +125,27 @@ async def quote_hwid_callback_parts(
|
||||
subscription_service,
|
||||
currency: str = "rub",
|
||||
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
|
||||
base = sale_mode_base(parts.sale_mode)
|
||||
if base == "subscription" and sale_mode_has_token(parts.sale_mode, HWID_RENEWAL_TOKEN):
|
||||
try:
|
||||
months = int(parts.months)
|
||||
except (TypeError, ValueError):
|
||||
return None, None
|
||||
quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=sale_mode_tariff_key(parts.sale_mode),
|
||||
months=months,
|
||||
currency=currency,
|
||||
)
|
||||
if not quote:
|
||||
return parts, None
|
||||
quoted_parts = PaymentCallbackParts(
|
||||
months=months,
|
||||
price=float(parts.price or 0) + float(quote.get("price") or 0),
|
||||
sale_mode=parts.sale_mode,
|
||||
)
|
||||
return quoted_parts, quote
|
||||
if not sale_mode_is_hwid_devices(parts.sale_mode):
|
||||
return parts, None
|
||||
device_count = parse_positive_int_units(parts.months)
|
||||
|
||||
@@ -100,6 +100,11 @@ def build_payment_record_payload(
|
||||
base = sale_mode_base(sale_mode)
|
||||
is_traffic = sale_mode_is_traffic(sale_mode)
|
||||
is_hwid = sale_mode_is_hwid_devices(sale_mode)
|
||||
hwid_devices = int(float(months)) if is_hwid else None
|
||||
if hwid_quote:
|
||||
quote_devices = parse_positive_int_units(hwid_quote.get("device_count"))
|
||||
if quote_devices is not None:
|
||||
hwid_devices = quote_devices
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"amount": amount,
|
||||
@@ -111,9 +116,9 @@ def build_payment_record_payload(
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode_tariff_key(sale_mode),
|
||||
"purchased_gb": float(months) if is_traffic else None,
|
||||
"purchased_hwid_devices": int(float(months)) if is_hwid else None,
|
||||
"purchased_hwid_devices": hwid_devices,
|
||||
}
|
||||
if hwid_quote and is_hwid:
|
||||
if hwid_quote and hwid_devices is not None:
|
||||
payload.update(
|
||||
{
|
||||
"hwid_valid_from": hwid_quote.get("valid_from"),
|
||||
@@ -164,14 +169,20 @@ def payment_record_amounts(
|
||||
months: Any,
|
||||
sale_mode: str,
|
||||
traffic_gb: Optional[float] = None,
|
||||
hwid_device_count: Optional[int] = None,
|
||||
) -> PaymentRecordAmounts:
|
||||
traffic_sale = sale_mode_is_traffic(sale_mode)
|
||||
hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode)
|
||||
units = traffic_gb if traffic_sale and traffic_gb is not None else months
|
||||
purchased_hwid_devices = int(float(months)) if hwid_devices_sale else None
|
||||
if not hwid_devices_sale and hwid_device_count is not None:
|
||||
parsed_hwid_devices = parse_positive_int_units(hwid_device_count)
|
||||
if parsed_hwid_devices is not None:
|
||||
purchased_hwid_devices = parsed_hwid_devices
|
||||
return PaymentRecordAmounts(
|
||||
months=int(float(units)) if traffic_sale else int(float(months)),
|
||||
purchased_gb=float(units) if traffic_sale else None,
|
||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
||||
purchased_hwid_devices=purchased_hwid_devices,
|
||||
tariff_key=sale_mode_tariff_key(sale_mode),
|
||||
traffic_sale=traffic_sale,
|
||||
hwid_devices_sale=hwid_devices_sale,
|
||||
@@ -281,6 +292,7 @@ async def create_webapp_payment_record(
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
return await create_base_payment_record(
|
||||
ctx.session,
|
||||
|
||||
@@ -156,6 +156,28 @@ def append_hwid_renewal_note(
|
||||
return f"{text}\n\n{note}"
|
||||
|
||||
|
||||
def append_hwid_renewed_note(
|
||||
text: str,
|
||||
translator: Translator,
|
||||
*,
|
||||
count: Any,
|
||||
valid_until: Optional[datetime],
|
||||
) -> str:
|
||||
try:
|
||||
count_int = int(count or 0)
|
||||
except (TypeError, ValueError):
|
||||
count_int = 0
|
||||
if count_int <= 0:
|
||||
return text
|
||||
date_text = valid_until.strftime("%Y-%m-%d") if valid_until else ""
|
||||
note = translator(
|
||||
"payment_successful_hwid_devices_renewed_note",
|
||||
count=format_human_units(count_int),
|
||||
date=date_text,
|
||||
)
|
||||
return f"{text}\n\n{note}"
|
||||
|
||||
|
||||
async def send_success_message_to_user(
|
||||
*,
|
||||
bot: Bot,
|
||||
@@ -320,8 +342,37 @@ async def finalize_successful_payment(
|
||||
req.log_prefix,
|
||||
req.payment.payment_id,
|
||||
)
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
req.session,
|
||||
req.payment.payment_id,
|
||||
"activation_failed",
|
||||
)
|
||||
await req.session.commit()
|
||||
except Exception:
|
||||
await req.session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to mark payment %s activation_failed.",
|
||||
req.log_prefix,
|
||||
req.payment.payment_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||
|
||||
await invalidate_webapp_user_caches(
|
||||
req.settings,
|
||||
req.user_id,
|
||||
include_devices=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"%s: failed to invalidate webapp caches for user %s.",
|
||||
req.log_prefix,
|
||||
req.user_id,
|
||||
)
|
||||
|
||||
db_user, language = await resolve_user_language(
|
||||
req.session,
|
||||
user_id=req.user_id,
|
||||
@@ -363,12 +414,20 @@ async def finalize_successful_payment(
|
||||
)
|
||||
)
|
||||
if is_subscription and activation:
|
||||
success_text = append_hwid_renewal_note(
|
||||
success_text,
|
||||
translator,
|
||||
count=activation.get("hwid_devices_renewal_recommended_count"),
|
||||
valid_until=activation.get("hwid_devices_valid_until"),
|
||||
)
|
||||
if activation.get("hwid_devices_renewed_count"):
|
||||
success_text = append_hwid_renewed_note(
|
||||
success_text,
|
||||
translator,
|
||||
count=activation.get("hwid_devices_renewed_count"),
|
||||
valid_until=final_end_date or activation.get("hwid_devices_renewed_until"),
|
||||
)
|
||||
else:
|
||||
success_text = append_hwid_renewal_note(
|
||||
success_text,
|
||||
translator,
|
||||
count=activation.get("hwid_devices_renewal_recommended_count"),
|
||||
valid_until=activation.get("hwid_devices_valid_until"),
|
||||
)
|
||||
if req.text_prefix:
|
||||
success_text = f"{req.text_prefix}\n{success_text}"
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ async def notify_user_payment_failed(
|
||||
message_key: str = "payment_failed",
|
||||
) -> None:
|
||||
"""Send the localized ``payment_failed`` text to the user; never raises."""
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||
language = (
|
||||
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
@@ -344,6 +344,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
|
||||
@@ -978,6 +978,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
months_for_lookup = (
|
||||
reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None
|
||||
|
||||
@@ -448,6 +448,36 @@ def _metadata_value_present(value: Optional[Any]) -> bool:
|
||||
return value is not None and str(value).strip() != ""
|
||||
|
||||
|
||||
def _metadata_int(value: Optional[Any]) -> Optional[int]:
|
||||
if not _metadata_value_present(value):
|
||||
return None
|
||||
try:
|
||||
return int(float(str(value).strip()))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _metadata_float(value: Optional[Any]) -> Optional[float]:
|
||||
if not _metadata_value_present(value):
|
||||
return None
|
||||
try:
|
||||
return float(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _metadata_datetime(value: Optional[Any]) -> Optional[datetime]:
|
||||
if not _metadata_value_present(value):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _resolve_yookassa_activation_amounts(
|
||||
*,
|
||||
sale_mode_base: str,
|
||||
@@ -559,6 +589,11 @@ async def process_successful_payment(
|
||||
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
|
||||
payment_value = float(amount_data.get("value", 0.0))
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
hwid_valid_from = _metadata_datetime(metadata.get("hwid_valid_from"))
|
||||
hwid_valid_until = _metadata_datetime(metadata.get("hwid_valid_until"))
|
||||
hwid_pricing_period_months = _metadata_int(metadata.get("hwid_pricing_period_months"))
|
||||
hwid_proration_ratio = _metadata_float(metadata.get("hwid_proration_ratio"))
|
||||
hwid_full_price = _metadata_float(metadata.get("hwid_full_price"))
|
||||
|
||||
if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0:
|
||||
logging.error(
|
||||
@@ -574,6 +609,19 @@ async def process_successful_payment(
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
return
|
||||
if sale_mode_base == "subscription" and hwid_devices_count > 0:
|
||||
if (
|
||||
not hwid_valid_from
|
||||
or not hwid_valid_until
|
||||
or hwid_valid_from >= hwid_valid_until
|
||||
or hwid_full_price is None
|
||||
):
|
||||
logging.error(
|
||||
"YooKassa subscription+HWID payment %s has invalid HWID metadata: %s",
|
||||
yk_payment_id_from_hook,
|
||||
metadata,
|
||||
)
|
||||
return
|
||||
|
||||
payment_record = None
|
||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||
@@ -600,6 +648,16 @@ async def process_successful_payment(
|
||||
or f"Auto-renewal for {months_for_record or subscription_months} months",
|
||||
provider="yookassa",
|
||||
provider_payment_id=yk_payment_id_from_hook,
|
||||
sale_mode=sale_mode,
|
||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||
purchased_hwid_devices=(
|
||||
hwid_devices_count if hwid_devices_count > 0 else None
|
||||
),
|
||||
hwid_valid_from=hwid_valid_from,
|
||||
hwid_valid_until=hwid_valid_until,
|
||||
hwid_pricing_period_months=hwid_pricing_period_months,
|
||||
hwid_proration_ratio=hwid_proration_ratio,
|
||||
hwid_full_price=hwid_full_price,
|
||||
)
|
||||
payment_db_id = payment_record.payment_id
|
||||
except Exception as e_ensure:
|
||||
@@ -1315,6 +1373,36 @@ def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_saved_list_payload(payload: str) -> Optional[Tuple[float, float, int, str]]:
|
||||
parts = payload.split(":")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price = float(parts[1])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
page = 0
|
||||
sale_mode = "subscription"
|
||||
if len(parts) > 2:
|
||||
try:
|
||||
page = int(parts[2])
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except ValueError:
|
||||
sale_mode = parts[2]
|
||||
return months, price, page, sale_mode
|
||||
|
||||
|
||||
def _metadata_iso(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _format_saved_payment_method_title(
|
||||
get_text, network: Optional[str], last4: Optional[str], is_default: bool
|
||||
) -> str:
|
||||
@@ -1363,6 +1451,9 @@ async def _initiate_yk_payment(
|
||||
return False
|
||||
|
||||
sale_base = _sale_mode_base(sale_mode)
|
||||
hwid_device_count = None
|
||||
if hwid_quote:
|
||||
hwid_device_count = parse_positive_int_units(hwid_quote.get("device_count"))
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
@@ -1379,12 +1470,14 @@ async def _initiate_yk_payment(
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||
"sale_mode": sale_base,
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None,
|
||||
"purchased_gb": float(months)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
"purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None,
|
||||
"purchased_hwid_devices": (
|
||||
int(months) if sale_base in HWID_DEVICE_SALE_BASES else hwid_device_count
|
||||
),
|
||||
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
||||
@@ -1430,6 +1523,19 @@ async def _initiate_yk_payment(
|
||||
yookassa_metadata["traffic_gb"] = str(months)
|
||||
if sale_base in HWID_DEVICE_SALE_BASES:
|
||||
yookassa_metadata["hwid_devices"] = str(months)
|
||||
elif hwid_device_count:
|
||||
yookassa_metadata["hwid_devices"] = str(hwid_device_count)
|
||||
if hwid_quote and hwid_device_count:
|
||||
hwid_metadata = {
|
||||
"hwid_valid_from": _metadata_iso(hwid_quote.get("valid_from")),
|
||||
"hwid_valid_until": _metadata_iso(hwid_quote.get("valid_until")),
|
||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months"),
|
||||
"hwid_proration_ratio": hwid_quote.get("proration_ratio"),
|
||||
"hwid_full_price": hwid_quote.get("full_price"),
|
||||
}
|
||||
yookassa_metadata.update(
|
||||
{key: str(value) for key, value in hwid_metadata.items() if value is not None}
|
||||
)
|
||||
if payment_method_id:
|
||||
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
||||
|
||||
@@ -1709,22 +1815,6 @@ async def pay_yk_callback_handler(
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
hwid_quote = None
|
||||
if _sale_mode_base(sale_mode) in HWID_DEVICE_SALE_BASES:
|
||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
||||
autopay_enabled = bool(
|
||||
@@ -1786,6 +1876,22 @@ async def pay_yk_callback_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
@@ -1863,6 +1969,22 @@ async def pay_yk_new_card_handler(
|
||||
return
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
hwid_quote = None
|
||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
||||
autopay_enabled = bool(
|
||||
@@ -1889,6 +2011,7 @@ async def pay_yk_new_card_handler(
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
|
||||
sale_mode=sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
@@ -1928,27 +2051,15 @@ async def pay_yk_saved_list_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
if len(parts) < 2:
|
||||
parsed_saved_list = _parse_saved_list_payload(data_payload)
|
||||
if not parsed_saved_list:
|
||||
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
page = int(parts[2]) if len(parts) > 2 else 0
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months, price_rub, page, sale_mode = parsed_saved_list
|
||||
|
||||
autopay_enabled = bool(
|
||||
settings.yookassa_autopayments_active
|
||||
@@ -2138,6 +2249,24 @@ async def pay_yk_use_saved_handler(
|
||||
|
||||
method_identifier = parts[2]
|
||||
user_id = callback.from_user.id
|
||||
base_months = months
|
||||
base_price_rub = price_rub
|
||||
hwid_quote = None
|
||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
@@ -2182,10 +2311,13 @@ async def pay_yk_use_saved_handler(
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=False,
|
||||
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
|
||||
back_callback=(
|
||||
f"pay_yk_saved_list:{_format_value(base_months)}:{base_price_rub}:0:{sale_mode}"
|
||||
),
|
||||
payment_method_id=selected_method.provider_payment_method_id,
|
||||
selected_method_internal_id=selected_method.method_id,
|
||||
sale_mode=sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
@@ -2754,6 +2886,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
@@ -2775,8 +2908,8 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
}
|
||||
if amounts.traffic_sale:
|
||||
metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months)
|
||||
if amounts.hwid_devices_sale:
|
||||
metadata["hwid_devices"] = str(int(float(ctx.months)))
|
||||
if amounts.purchased_hwid_devices:
|
||||
metadata["hwid_devices"] = str(int(amounts.purchased_hwid_devices))
|
||||
if amounts.tariff_key:
|
||||
metadata["tariff_key"] = amounts.tariff_key
|
||||
response = await service.create_payment(
|
||||
|
||||
@@ -116,6 +116,64 @@ class HwidDeviceMixin:
|
||||
packages = package_set.for_currency(currency)
|
||||
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
|
||||
|
||||
@staticmethod
|
||||
def _quote_hwid_full_period_package_price(
|
||||
tariff: Tariff,
|
||||
*,
|
||||
device_count: int,
|
||||
period_months: int,
|
||||
currency: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
package_set = tariff.hwid_device_packages
|
||||
if not package_set:
|
||||
return None
|
||||
try:
|
||||
target_count = int(device_count)
|
||||
months = max(1, int(period_months))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if target_count <= 0:
|
||||
return None
|
||||
|
||||
packages = [
|
||||
package
|
||||
for package in package_set.for_currency(currency)
|
||||
if int(getattr(package, "count", 0) or 0) > 0
|
||||
]
|
||||
if not packages:
|
||||
return None
|
||||
|
||||
best: Dict[int, tuple[float, List[Any]]] = {0: (0.0, [])}
|
||||
for count in range(1, target_count + 1):
|
||||
best_for_count: Optional[tuple[float, List[Any]]] = None
|
||||
for package in packages:
|
||||
package_count = int(package.count)
|
||||
previous = best.get(count - package_count)
|
||||
if previous is None:
|
||||
continue
|
||||
price = previous[0] + float(package.price_for_period(months))
|
||||
selected = [*previous[1], package]
|
||||
if best_for_count is None or price < best_for_count[0]:
|
||||
best_for_count = (price, selected)
|
||||
if best_for_count is not None:
|
||||
best[count] = best_for_count
|
||||
|
||||
resolved = best.get(target_count)
|
||||
if resolved is None:
|
||||
return None
|
||||
full_price, selected_packages = resolved
|
||||
rounded_price = HwidDeviceMixin._round_hwid_price(full_price, currency=currency)
|
||||
if currency == "stars":
|
||||
rounded_price = float(int(math.ceil(rounded_price)))
|
||||
return {
|
||||
"price": rounded_price,
|
||||
"full_price": float(full_price),
|
||||
"pricing_period_months": months,
|
||||
"proration_ratio": 1.0,
|
||||
"currency": currency,
|
||||
"package_counts": [int(package.count) for package in selected_packages],
|
||||
}
|
||||
|
||||
def _quote_hwid_package_price(
|
||||
self,
|
||||
*,
|
||||
@@ -128,16 +186,10 @@ class HwidDeviceMixin:
|
||||
) -> Dict[str, Any]:
|
||||
period_months = max(1, int(getattr(sub, "duration_months", None) or 1))
|
||||
full_price = float(package.price_for_period(period_months))
|
||||
period_start = self._as_aware_utc(getattr(sub, "start_date", None))
|
||||
period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until
|
||||
inferred_period_start = add_months(period_end, -period_months)
|
||||
if not period_start or period_start >= period_end or period_start < inferred_period_start:
|
||||
period_start = inferred_period_start
|
||||
|
||||
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
|
||||
basis_seconds = max(1.0, float(period_months * 30 * 24 * 60 * 60))
|
||||
billable_start = max(now, valid_from)
|
||||
billable_seconds = max(0.0, (valid_until - billable_start).total_seconds())
|
||||
ratio = billable_seconds / basis_seconds
|
||||
ratio = min(1.0, billable_seconds / basis_seconds)
|
||||
raw_price = full_price * ratio
|
||||
price = self._round_hwid_price(raw_price, currency=currency)
|
||||
min_price = getattr(package, "min_price", None)
|
||||
@@ -230,6 +282,80 @@ class HwidDeviceMixin:
|
||||
)
|
||||
return quote
|
||||
|
||||
async def quote_hwid_device_renewal_for_subscription(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
target_tariff_key: str,
|
||||
months: int,
|
||||
currency: str = "rub",
|
||||
now: Optional[datetime] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
period_months = int(months)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if period_months <= 0:
|
||||
return None
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or not db_user.panel_user_uuid:
|
||||
return None
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, db_user.panel_user_uuid
|
||||
)
|
||||
if not sub or not sub.end_date:
|
||||
return None
|
||||
|
||||
now = now or datetime.now(timezone.utc)
|
||||
subscription_end = self._as_aware_utc(sub.end_date)
|
||||
if not subscription_end or subscription_end <= now:
|
||||
return None
|
||||
|
||||
try:
|
||||
tariff = self._resolve_tariff(target_tariff_key)
|
||||
except Exception:
|
||||
return None
|
||||
if not tariff or tariff.billing_model != "period":
|
||||
return None
|
||||
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
|
||||
if base_hwid_limit in (None, 0):
|
||||
return None
|
||||
|
||||
entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary(
|
||||
session,
|
||||
subscription_id=sub.subscription_id,
|
||||
at=now,
|
||||
)
|
||||
active_devices = int(entitlement_summary.get("active_devices") or 0)
|
||||
if active_devices <= 0:
|
||||
return None
|
||||
|
||||
price_quote = self._quote_hwid_full_period_package_price(
|
||||
tariff,
|
||||
device_count=active_devices,
|
||||
period_months=period_months,
|
||||
currency=currency,
|
||||
)
|
||||
if not price_quote:
|
||||
return None
|
||||
|
||||
valid_from = subscription_end
|
||||
valid_until = add_months(valid_from, period_months)
|
||||
price_quote.update(
|
||||
{
|
||||
"subscription_id": sub.subscription_id,
|
||||
"tariff_key": tariff.key,
|
||||
"device_count": active_devices,
|
||||
"renewal": True,
|
||||
"valid_from": valid_from,
|
||||
"valid_until": valid_until,
|
||||
"active_until": entitlement_summary.get("active_until"),
|
||||
}
|
||||
)
|
||||
return price_quote
|
||||
|
||||
async def activate_hwid_device_topup(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
|
||||
@@ -178,6 +178,7 @@ class SubscriptionLifecycleMixin:
|
||||
user_id: int,
|
||||
target_tariff_key: str,
|
||||
mode: str,
|
||||
payment_id: Optional[int] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
config = self._tariffs_config()
|
||||
if not config:
|
||||
@@ -336,7 +337,7 @@ class SubscriptionLifecycleMixin:
|
||||
"from_tariff_key": before_tariff_key,
|
||||
"to_tariff_key": target.key,
|
||||
"mode": mode,
|
||||
"payment_id": None,
|
||||
"payment_id": payment_id,
|
||||
"days_before": options.get("remaining_days"),
|
||||
"days_after": (updated.end_date - now).days
|
||||
if updated.end_date and target.billing_model == "period"
|
||||
@@ -454,27 +455,11 @@ class SubscriptionLifecycleMixin:
|
||||
user_id,
|
||||
tariff_key,
|
||||
"paid_diff",
|
||||
payment_id=payment_db_id,
|
||||
)
|
||||
if result:
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
||||
if sub:
|
||||
await tariff_dal.create_tariff_change(
|
||||
session,
|
||||
{
|
||||
"subscription_id": sub.subscription_id,
|
||||
"from_tariff_key": None,
|
||||
"to_tariff_key": tariff_key,
|
||||
"mode": "paid_diff",
|
||||
"payment_id": payment_db_id,
|
||||
"days_before": None,
|
||||
"days_after": (sub.end_date - datetime.now(timezone.utc)).days
|
||||
if sub.end_date
|
||||
else None,
|
||||
"converted_bytes": None,
|
||||
"eff_price_before": None,
|
||||
"eff_price_after": sub.effective_monthly_price_rub,
|
||||
},
|
||||
)
|
||||
result["end_date"] = sub.end_date
|
||||
result["is_active"] = sub.is_active
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
@@ -494,10 +479,29 @@ class SubscriptionLifecycleMixin:
|
||||
await self._record_payment_context(
|
||||
session,
|
||||
payment_db_id,
|
||||
sale_mode=sale_mode_base,
|
||||
sale_mode=sale_mode,
|
||||
tariff_key=tariff.key if tariff else tariff_key,
|
||||
purchased_gb=None,
|
||||
)
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
try:
|
||||
hwid_renewal_devices = int(getattr(payment, "purchased_hwid_devices", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
hwid_renewal_devices = 0
|
||||
try:
|
||||
hwid_renewal_price = (
|
||||
float(getattr(payment, "hwid_full_price", 0) or 0)
|
||||
if hwid_renewal_devices > 0
|
||||
else 0.0
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
hwid_renewal_price = 0.0
|
||||
hwid_renewal_valid_from = self._as_aware_utc(
|
||||
getattr(payment, "hwid_valid_from", None) if payment else None
|
||||
)
|
||||
hwid_renewal_valid_until = self._as_aware_utc(
|
||||
getattr(payment, "hwid_valid_until", None) if payment else None
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
@@ -569,6 +573,26 @@ class SubscriptionLifecycleMixin:
|
||||
promo_code_id_from_payment = None
|
||||
|
||||
final_end_date = start_date + timedelta(days=duration_days_total)
|
||||
if hwid_renewal_devices > 0 and hwid_renewal_valid_until and applied_promo_bonus_days:
|
||||
hwid_renewal_valid_until = hwid_renewal_valid_until + timedelta(
|
||||
days=applied_promo_bonus_days
|
||||
)
|
||||
if payment:
|
||||
payment.hwid_valid_until = hwid_renewal_valid_until
|
||||
elif applied_promo_bonus_days > 0 and current_active_sub:
|
||||
try:
|
||||
await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
|
||||
session,
|
||||
subscription_id=current_active_sub.subscription_id,
|
||||
at=datetime.now(timezone.utc),
|
||||
subscription_end_before=start_date,
|
||||
delta=timedelta(days=applied_promo_bonus_days),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to extend HWID device purchases for promo payment bonus of user %s",
|
||||
user_id,
|
||||
)
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_user_uuid, panel_sub_link_id
|
||||
)
|
||||
@@ -614,7 +638,8 @@ class SubscriptionLifecycleMixin:
|
||||
premium_topup_balance_bytes,
|
||||
premium_topup_used_bytes,
|
||||
)
|
||||
effective_monthly_price = float(payment_amount) / max(1, months_int)
|
||||
subscription_amount_for_pricing = max(0.0, float(payment_amount) - hwid_renewal_price)
|
||||
effective_monthly_price = subscription_amount_for_pricing / max(1, months_int)
|
||||
regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0)
|
||||
regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False))
|
||||
traffic_limit_bytes = self._traffic_limit_for_period_tariff(
|
||||
@@ -698,6 +723,31 @@ class SubscriptionLifecycleMixin:
|
||||
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
hwid_devices_renewed_count = 0
|
||||
hwid_devices_renewed_until = None
|
||||
if hwid_renewal_devices > 0:
|
||||
if (
|
||||
hwid_renewal_valid_from
|
||||
and hwid_renewal_valid_until
|
||||
and hwid_renewal_valid_from < hwid_renewal_valid_until
|
||||
):
|
||||
await tariff_dal.create_hwid_device_purchase(
|
||||
session,
|
||||
subscription_id=new_or_updated_sub.subscription_id,
|
||||
payment_id=payment_db_id,
|
||||
purchased_devices=hwid_renewal_devices,
|
||||
valid_from=hwid_renewal_valid_from,
|
||||
valid_until=hwid_renewal_valid_until,
|
||||
)
|
||||
hwid_devices_renewed_count = hwid_renewal_devices
|
||||
hwid_devices_renewed_until = hwid_renewal_valid_until
|
||||
else:
|
||||
logging.warning(
|
||||
"Skipping HWID renewal purchase for payment %s: invalid window %s -> %s",
|
||||
payment_db_id,
|
||||
hwid_renewal_valid_from,
|
||||
hwid_renewal_valid_until,
|
||||
)
|
||||
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
@@ -718,8 +768,12 @@ class SubscriptionLifecycleMixin:
|
||||
"subscription_url": final_subscription_url,
|
||||
"applied_promo_bonus_days": applied_promo_bonus_days,
|
||||
"tariff_key": tariff.key if tariff else None,
|
||||
"hwid_devices_renewal_recommended_count": extra_hwid_devices,
|
||||
"hwid_devices_valid_until": hwid_devices_valid_until,
|
||||
"hwid_devices_renewal_recommended_count": 0
|
||||
if hwid_devices_renewed_count
|
||||
else extra_hwid_devices,
|
||||
"hwid_devices_valid_until": hwid_devices_renewed_until or hwid_devices_valid_until,
|
||||
"hwid_devices_renewed_count": hwid_devices_renewed_count,
|
||||
"hwid_devices_renewed_until": hwid_devices_renewed_until,
|
||||
}
|
||||
|
||||
async def extend_active_subscription_days(
|
||||
@@ -728,6 +782,7 @@ class SubscriptionLifecycleMixin:
|
||||
user_id: int,
|
||||
bonus_days: int,
|
||||
reason: str = "bonus",
|
||||
extend_hwid_devices: bool = True,
|
||||
) -> Optional[datetime]:
|
||||
reason_lower = (reason or "").lower()
|
||||
apply_main_traffic_limit = any(
|
||||
@@ -798,6 +853,21 @@ class SubscriptionLifecycleMixin:
|
||||
updated_sub_model = await subscription_dal.update_subscription_end_date(
|
||||
session, active_sub.subscription_id, new_end_date_obj
|
||||
)
|
||||
if updated_sub_model and extend_hwid_devices:
|
||||
try:
|
||||
await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
|
||||
session,
|
||||
subscription_id=active_sub.subscription_id,
|
||||
at=now_utc,
|
||||
subscription_end_before=current_end_date,
|
||||
delta=timedelta(days=bonus_days),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to extend HWID device purchases for %s bonus of user %s",
|
||||
reason,
|
||||
user_id,
|
||||
)
|
||||
|
||||
if (
|
||||
apply_main_traffic_limit
|
||||
|
||||
@@ -38,7 +38,8 @@ class PaymentContextMixin:
|
||||
payment.sale_mode = sale_mode
|
||||
payment.tariff_key = tariff_key
|
||||
payment.purchased_gb = purchased_gb
|
||||
payment.purchased_hwid_devices = purchased_hwid_devices
|
||||
if purchased_hwid_devices is not None:
|
||||
payment.purchased_hwid_devices = purchased_hwid_devices
|
||||
if hwid_valid_from is not None:
|
||||
payment.hwid_valid_from = hwid_valid_from
|
||||
if hwid_valid_until is not None:
|
||||
|
||||
@@ -42,6 +42,8 @@ class RenewalMixin:
|
||||
|
||||
months = sub.duration_months or 1
|
||||
currency = default_payment_currency_code_for_settings(self.settings)
|
||||
tariff_key = str(getattr(sub, "tariff_key", "") or "").strip() or None
|
||||
sale_mode = f"subscription@{tariff_key}" if tariff_key else "subscription"
|
||||
amount = None
|
||||
tariffs_config = (
|
||||
self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None
|
||||
@@ -62,11 +64,55 @@ class RenewalMixin:
|
||||
logging.error(f"Auto-renew price missing for {months} months")
|
||||
return False
|
||||
|
||||
hwid_quote = None
|
||||
quote_hwid_renewal = getattr(
|
||||
self,
|
||||
"quote_hwid_device_renewal_for_subscription",
|
||||
None,
|
||||
)
|
||||
if tariff_key and callable(quote_hwid_renewal):
|
||||
try:
|
||||
hwid_quote = await quote_hwid_renewal(
|
||||
session,
|
||||
user_id=sub.user_id,
|
||||
target_tariff_key=tariff_key,
|
||||
months=int(months),
|
||||
currency=default_currency_key_for_settings(self.settings),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to quote HWID devices for auto-renew user %s",
|
||||
sub.user_id,
|
||||
)
|
||||
hwid_quote = None
|
||||
if hwid_quote:
|
||||
amount = float(amount) + float(hwid_quote.get("price") or 0)
|
||||
|
||||
metadata = {
|
||||
"user_id": str(sub.user_id),
|
||||
"auto_renew_for_subscription_id": str(sub.subscription_id),
|
||||
"subscription_months": str(months),
|
||||
"sale_mode": sale_mode,
|
||||
}
|
||||
if hwid_quote:
|
||||
metadata["hwid_devices"] = str(int(hwid_quote.get("device_count") or 0))
|
||||
for source_key, metadata_key in (
|
||||
("valid_from", "hwid_valid_from"),
|
||||
("valid_until", "hwid_valid_until"),
|
||||
):
|
||||
value = hwid_quote.get(source_key)
|
||||
if value:
|
||||
metadata[metadata_key] = (
|
||||
value.isoformat() if hasattr(value, "isoformat") else str(value)
|
||||
)
|
||||
for key in (
|
||||
"pricing_period_months",
|
||||
"proration_ratio",
|
||||
"full_price",
|
||||
):
|
||||
value = hwid_quote.get(key)
|
||||
if value is not None:
|
||||
metadata[f"hwid_{key}"] = str(value)
|
||||
resp = await yk.create_payment(
|
||||
amount=float(amount),
|
||||
currency=currency,
|
||||
|
||||
@@ -51,6 +51,15 @@ async def ensure_payment_with_provider_id(
|
||||
description: str,
|
||||
provider: str,
|
||||
provider_payment_id: str,
|
||||
sale_mode: Optional[str] = None,
|
||||
tariff_key: Optional[str] = None,
|
||||
purchased_gb: Optional[float] = None,
|
||||
purchased_hwid_devices: Optional[int] = None,
|
||||
hwid_valid_from: Optional[Any] = None,
|
||||
hwid_valid_until: Optional[Any] = None,
|
||||
hwid_pricing_period_months: Optional[int] = None,
|
||||
hwid_proration_ratio: Optional[float] = None,
|
||||
hwid_full_price: Optional[float] = None,
|
||||
) -> Payment:
|
||||
"""Idempotently create a payment record for a provider event.
|
||||
|
||||
@@ -72,6 +81,20 @@ async def ensure_payment_with_provider_id(
|
||||
"provider_payment_id": provider_payment_id,
|
||||
"provider": provider,
|
||||
}
|
||||
optional_fields = {
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": tariff_key,
|
||||
"purchased_gb": purchased_gb,
|
||||
"purchased_hwid_devices": purchased_hwid_devices,
|
||||
"hwid_valid_from": hwid_valid_from,
|
||||
"hwid_valid_until": hwid_valid_until,
|
||||
"hwid_pricing_period_months": hwid_pricing_period_months,
|
||||
"hwid_proration_ratio": hwid_proration_ratio,
|
||||
"hwid_full_price": hwid_full_price,
|
||||
}
|
||||
payment_payload.update(
|
||||
{field: value for field, value in optional_fields.items() if value is not None}
|
||||
)
|
||||
return await create_payment_record(session, payment_payload)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import inspect
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_, delete, func, or_, select, update
|
||||
@@ -189,6 +189,63 @@ async def expire_hwid_device_purchases(
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def _normalize_aware_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
async def extend_hwid_device_purchases_for_subscription_bonus(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
subscription_id: int,
|
||||
at: Optional[datetime] = None,
|
||||
subscription_end_before: Optional[datetime] = None,
|
||||
delta: timedelta,
|
||||
) -> int:
|
||||
if delta.total_seconds() <= 0:
|
||||
return 0
|
||||
at = _normalize_aware_utc(at or datetime.now(timezone.utc))
|
||||
end_before = _normalize_aware_utc(subscription_end_before) if subscription_end_before else None
|
||||
|
||||
target_records: List[HwidDevicePurchase] = []
|
||||
if end_before:
|
||||
tail_result = await session.execute(
|
||||
select(HwidDevicePurchase).where(
|
||||
and_(
|
||||
HwidDevicePurchase.subscription_id == subscription_id,
|
||||
HwidDevicePurchase.purchased_devices > 0,
|
||||
HwidDevicePurchase.valid_until.is_not(None),
|
||||
HwidDevicePurchase.valid_until >= end_before,
|
||||
HwidDevicePurchase.valid_until > at,
|
||||
or_(
|
||||
HwidDevicePurchase.valid_from.is_(None),
|
||||
HwidDevicePurchase.valid_from < end_before,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
target_records = list(tail_result.scalars().all())
|
||||
|
||||
if not target_records:
|
||||
active_result = await session.execute(
|
||||
select(HwidDevicePurchase).where(
|
||||
and_(
|
||||
*_hwid_active_conditions(subscription_id, at),
|
||||
HwidDevicePurchase.valid_until.is_not(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
target_records = list(active_result.scalars().all())
|
||||
|
||||
for record in target_records:
|
||||
if record.valid_until is not None:
|
||||
record.valid_until = _normalize_aware_utc(record.valid_until) + delta
|
||||
if target_records:
|
||||
await session.flush()
|
||||
return len(target_records)
|
||||
|
||||
|
||||
async def create_tariff_change(
|
||||
session: AsyncSession,
|
||||
change_data: Dict[str, Any],
|
||||
|
||||
@@ -18,7 +18,7 @@ Minishop отправляет уведомления в Telegram и на email.
|
||||
| Успешная покупка отдельного пакета трафика | Пользователь | ✓ | ✓ | Для `traffic` / `traffic_package`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Успешная докупка обычного трафика к тарифу | Пользователь | ✓ | ✓ | Для `topup`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Для `premium_topup`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после оплаты `hwid_devices` или `hwid_devices_renewal`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Успешная покупка HWID-устройств | Пользователь | ✓ | ✓ | Отправляется после отдельной оплаты `hwid_devices`; при продлении устройств вместе с подпиской добавляется примечание к уведомлению об успешной оплате подписки. Email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Платное повышение тарифа | Пользователь | ✓ | ✓ | Для `tariff_upgrade`; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Способ оплаты YooKassa привязан | Пользователь | ✓ | ✓ | Отправляется после успешного сохранения платежного метода через webhook YooKassa; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
| Ошибка оплаты по webhook провайдера | Пользователь | ✓ | ✓ | Отправляется, когда платежный провайдер сообщает о неуспешном платеже; email отправляется, если SMTP настроен и у пользователя есть email. |
|
||||
|
||||
@@ -280,7 +280,10 @@ limit_after = current_used + balance_after
|
||||
- полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`;
|
||||
- фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe;
|
||||
- для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода;
|
||||
- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка;
|
||||
- кнопка докупки устройств всегда покупает устройства только для текущей активной подписки и только до текущего срока ее действия;
|
||||
- при продлении подписки пользователь видит отдельный чекбокс продления действующих докупленных устройств; чекбокс включен по умолчанию, цена считается по текущему тарифу и добавляется в тот же платеж подписки;
|
||||
- если пользователь продлил подписку без продления устройств, старая докупка продолжает действовать до своего `valid_until`, а Web App показывает предупреждение о возможном временном возврате к базовому лимиту;
|
||||
- админские продления, промокоды и реферальные бонусы добавляют фиксированное количество дней отдельно к подписке и к действующим докупкам устройств, не склеивая даты окончания;
|
||||
- `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки;
|
||||
- при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`;
|
||||
- история докупок пишется в `hwid_device_purchases`;
|
||||
|
||||
@@ -1520,6 +1520,7 @@
|
||||
selectedPlan: null,
|
||||
selectedTariffKey: "",
|
||||
paymentStep: "tariff",
|
||||
renewHwidDevices: true,
|
||||
selectedMethod: payload.payment_methods?.[0]?.id || "",
|
||||
}));
|
||||
const currentQuery = currentSearchParams();
|
||||
@@ -2421,6 +2422,7 @@
|
||||
bind:paymentStep={$billingStore.paymentStep}
|
||||
bind:selectedMethod={$billingStore.selectedMethod}
|
||||
bind:selectedPlan={$billingStore.selectedPlan}
|
||||
bind:renewHwidDevices={$billingStore.renewHwidDevices}
|
||||
bind:selectedTariffKey={$billingStore.selectedTariffKey}
|
||||
bind:setPasswordCode={$accountStore.setPasswordCode}
|
||||
bind:setPasswordConfirm={$accountStore.setPasswordConfirm}
|
||||
|
||||
@@ -759,6 +759,41 @@
|
||||
{at("user_btn_extend", {}, "Продлить")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
{#if Number(openedUserDetail?.active_subscription?.extra_hwid_devices || 0) > 0}
|
||||
<label class="admin-extend-hwid-option">
|
||||
<Checkbox
|
||||
bind:checked={$usersStore.userExtendHwidDevices}
|
||||
disabled={userActionBusy}
|
||||
ariaLabel={at(
|
||||
"user_extend_hwid_devices_aria",
|
||||
{},
|
||||
"Продлить докупленные HWID-устройства"
|
||||
)}
|
||||
/>
|
||||
<span>
|
||||
<strong>
|
||||
{at(
|
||||
"user_extend_hwid_devices",
|
||||
{
|
||||
count: Number(
|
||||
openedUserDetail.active_subscription.extra_hwid_devices || 0
|
||||
),
|
||||
},
|
||||
`Продлить также +${Number(
|
||||
openedUserDetail.active_subscription.extra_hwid_devices || 0
|
||||
)} HWID-устройств`
|
||||
)}
|
||||
</strong>
|
||||
<small>
|
||||
{at(
|
||||
"user_extend_hwid_devices_hint",
|
||||
{},
|
||||
"Срок действующих докупок увеличится на те же дни."
|
||||
)}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
</Label.Root>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
||||
userDetailLoading: false,
|
||||
userMessageDraft: "",
|
||||
userExtendDays: 30,
|
||||
userExtendHwidDevices: true,
|
||||
userActionBusy: false,
|
||||
userDeleteOpen: false,
|
||||
userBanConfirmOpen: false,
|
||||
@@ -133,6 +134,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
||||
userMessageDraft: "",
|
||||
userMessageConfirmOpen: false,
|
||||
userExtendDays: 30,
|
||||
userExtendHwidDevices: true,
|
||||
userDetailLoading: true,
|
||||
userDetailTab: "subscription",
|
||||
userReferralsOpen: false,
|
||||
@@ -455,7 +457,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
||||
try {
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/extend`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ days }),
|
||||
body: JSON.stringify({ days, extend_hwid_devices: Boolean(s.userExtendHwidDevices) }),
|
||||
});
|
||||
if (res?.ok) {
|
||||
onToast(at("subscription_extended", { days }, `Продлено на ${days} д.`));
|
||||
|
||||
@@ -27,13 +27,14 @@ export function createBillingActions({ api }) {
|
||||
return api("/tariffs/change-payment", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
function planPaymentBody(plan, method) {
|
||||
function planPaymentBody(plan, method, options = {}) {
|
||||
return {
|
||||
months: plan.months,
|
||||
traffic_gb: plan.traffic_gb,
|
||||
device_count: plan.device_count,
|
||||
tariff_key: plan.tariff_key,
|
||||
sale_mode: plan.sale_mode,
|
||||
renew_hwid_devices: Boolean(options.renewHwidDevices),
|
||||
method,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127478,22 +127478,22 @@ export const DEMO_DATASET = {
|
||||
audience: "user",
|
||||
values: {
|
||||
ru: {
|
||||
base: "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.",
|
||||
base: "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.",
|
||||
fallback:
|
||||
"Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.",
|
||||
"Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.",
|
||||
effective:
|
||||
"Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.",
|
||||
"Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
},
|
||||
en: {
|
||||
base: "Your +{count} extra devices are valid until {date}. Renewing the subscription does not renew them automatically.",
|
||||
base: "Your +{count} extra devices are valid until {date}. You can renew them together with the subscription.",
|
||||
fallback:
|
||||
"Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.",
|
||||
"Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.",
|
||||
effective:
|
||||
"Your +{count} extra devices are valid until {date}. Renewing the subscription does not renew them automatically.",
|
||||
"Your +{count} extra devices are valid until {date}. You can renew them together with the subscription.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
@@ -127506,22 +127506,22 @@ export const DEMO_DATASET = {
|
||||
audience: "user",
|
||||
values: {
|
||||
ru: {
|
||||
base: "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.",
|
||||
base: "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.",
|
||||
fallback:
|
||||
"Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.",
|
||||
"Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.",
|
||||
effective:
|
||||
"Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.",
|
||||
"Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
},
|
||||
en: {
|
||||
base: "Your current +{count} device top-up is valid until {date}. Choose a package to renew devices for the new subscription period.",
|
||||
base: "Your current +{count} device top-up is valid until {date}. Device renewal is handled together with subscription renewal.",
|
||||
fallback:
|
||||
"Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.",
|
||||
"Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.",
|
||||
effective:
|
||||
"Your current +{count} device top-up is valid until {date}. Choose a package to renew devices for the new subscription period.",
|
||||
"Your current +{count} device top-up is valid until {date}. Device renewal is handled together with subscription renewal.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
@@ -127534,18 +127534,22 @@ export const DEMO_DATASET = {
|
||||
audience: "user",
|
||||
values: {
|
||||
ru: {
|
||||
base: "Подписка продлена. Докупите устройства для нового срока.",
|
||||
fallback: "Подписка продлена. Докупите устройства для нового срока.",
|
||||
effective: "Подписка продлена. Докупите устройства для нового срока.",
|
||||
base: "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.",
|
||||
fallback:
|
||||
"Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.",
|
||||
effective:
|
||||
"Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
},
|
||||
en: {
|
||||
base: "Subscription renewed. Buy devices again for the new period.",
|
||||
fallback: "Подписка продлена. Докупите устройства для нового срока.",
|
||||
effective: "Subscription renewed. Buy devices again for the new period.",
|
||||
base: "Subscription renewed. If devices were not renewed with it, the current device top-up remains valid until its own end date.",
|
||||
fallback:
|
||||
"Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.",
|
||||
effective:
|
||||
"Subscription renewed. If devices were not renewed with it, the current device top-up remains valid until its own end date.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
@@ -134134,22 +134138,22 @@ export const DEMO_DATASET = {
|
||||
audience: "user",
|
||||
values: {
|
||||
ru: {
|
||||
base: "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.",
|
||||
base: "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.",
|
||||
fallback:
|
||||
"У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.",
|
||||
"У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.",
|
||||
effective:
|
||||
"У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.",
|
||||
"У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
},
|
||||
en: {
|
||||
base: "You have +{count} extra HWID devices valid until {date}. Renewing the subscription does not renew the device top-up automatically.",
|
||||
base: "You have +{count} extra HWID devices valid until {date}. When renewing the subscription, you can include these devices in the same payment.",
|
||||
fallback:
|
||||
"У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.",
|
||||
"У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.",
|
||||
effective:
|
||||
"You have +{count} extra HWID devices valid until {date}. Renewing the subscription does not renew the device top-up automatically.",
|
||||
"You have +{count} extra HWID devices valid until {date}. When renewing the subscription, you can include these devices in the same payment.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
@@ -135835,22 +135839,22 @@ export const DEMO_DATASET = {
|
||||
audience: "user",
|
||||
values: {
|
||||
ru: {
|
||||
base: "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.",
|
||||
base: "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.",
|
||||
fallback:
|
||||
"У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.",
|
||||
"Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.",
|
||||
effective:
|
||||
"У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.",
|
||||
"Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
},
|
||||
en: {
|
||||
base: "You currently have +{count} extra HWID devices valid until {date}. Buy them again if you need them for the renewed subscription period.",
|
||||
base: "Your +{count} extra HWID devices are valid until {date}. To keep them for the next period, enable device renewal while renewing the subscription.",
|
||||
fallback:
|
||||
"У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.",
|
||||
"Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.",
|
||||
effective:
|
||||
"You currently have +{count} extra HWID devices valid until {date}. Buy them again if you need them for the renewed subscription period.",
|
||||
"Your +{count} extra HWID devices are valid until {date}. To keep them for the next period, enable device renewal while renewing the subscription.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
@@ -139676,22 +139680,22 @@ export const DEMO_DATASET = {
|
||||
audience: "user",
|
||||
values: {
|
||||
ru: {
|
||||
base: "Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.",
|
||||
base: "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.",
|
||||
fallback:
|
||||
"Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.",
|
||||
"Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.",
|
||||
effective:
|
||||
"Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.",
|
||||
"Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
},
|
||||
en: {
|
||||
base: "Select an HWID device package for the new subscription period. Your current top-up is valid until {date}.",
|
||||
base: "Purchased HWID devices are renewed together with subscription renewal. Your current top-up is valid until {date}.",
|
||||
fallback:
|
||||
"Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.",
|
||||
"Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.",
|
||||
effective:
|
||||
"Select an HWID device package for the new subscription period. Your current top-up is valid until {date}.",
|
||||
"Purchased HWID devices are renewed together with subscription renewal. Your current top-up is valid until {date}.",
|
||||
override: "",
|
||||
overridden: false,
|
||||
updated_at: null,
|
||||
|
||||
@@ -18,6 +18,7 @@ export function createBillingStore({
|
||||
selectedTariffKey: "",
|
||||
selectedPlan: null,
|
||||
selectedMethod: "",
|
||||
renewHwidDevices: true,
|
||||
paymentStartedWithActiveSubscription: false,
|
||||
topupModalOpen: false,
|
||||
topupKind: "regular",
|
||||
@@ -73,15 +74,7 @@ export function createBillingStore({
|
||||
paymentPollToken += 1;
|
||||
}
|
||||
showToast(t("wa_payment_success", {}, "Payment successful"));
|
||||
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 || "");
|
||||
}
|
||||
await loadData({ fresh: true });
|
||||
if (
|
||||
successContext.initialSubscriptionPayment &&
|
||||
typeof onSubscriptionActivated === "function"
|
||||
@@ -163,6 +156,7 @@ export function createBillingStore({
|
||||
selectedTariffKey: tariffKey,
|
||||
selectedPlan: plan,
|
||||
selectedMethod: s.selectedMethod || defaultMethod,
|
||||
renewHwidDevices: true,
|
||||
paymentStartedWithActiveSubscription: Boolean(subscription?.active),
|
||||
};
|
||||
});
|
||||
@@ -179,6 +173,7 @@ export function createBillingStore({
|
||||
...s,
|
||||
selectedTariffKey: key,
|
||||
selectedPlan: plans.find((plan) => plan?.tariff_key === key) || null,
|
||||
renewHwidDevices: true,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -189,6 +184,7 @@ export function createBillingStore({
|
||||
...s,
|
||||
selectedPlan: s.selectedPlan || selectedTariffPlans[0] || null,
|
||||
paymentStep: "checkout",
|
||||
renewHwidDevices: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -355,7 +351,10 @@ export function createBillingStore({
|
||||
state.update((s) => ({ ...s, payBusy: true }));
|
||||
try {
|
||||
const response = await billing.postPayment(
|
||||
billing.planPaymentBody(s.selectedPlan, s.selectedMethod)
|
||||
billing.planPaymentBody(s.selectedPlan, s.selectedMethod, {
|
||||
renewHwidDevices:
|
||||
s.renewHwidDevices && Boolean(s.selectedPlan?.hwid_renewal?.available),
|
||||
})
|
||||
);
|
||||
const successContext = paymentSuccessContext(s, response);
|
||||
rememberSubscriptionActivationPending(successContext);
|
||||
|
||||
@@ -4288,6 +4288,37 @@
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.admin-extend-hwid-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
background: var(--admin-surface-2);
|
||||
color: var(--admin-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-extend-hwid-option > span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-extend-hwid-option strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-extend-hwid-option small {
|
||||
color: var(--admin-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 1fr) auto;
|
||||
|
||||
@@ -1189,6 +1189,43 @@ a {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hwid-renewal-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--surface-muted));
|
||||
box-shadow: inset 0 1px 0 var(--inset-highlight);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hwid-renewal-option > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hwid-renewal-option strong {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hwid-renewal-option small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hwid-renewal-option .hwid-renewal-warning {
|
||||
color: var(--warning-text, var(--warning));
|
||||
}
|
||||
|
||||
.skeleton-row,
|
||||
.skeleton-method,
|
||||
.skeleton-pay-button {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { Tooltip } from "$components/ui/primitives.js";
|
||||
|
||||
import Button from "$components/ui/button.svelte";
|
||||
import Checkbox from "$components/ui/checkbox.svelte";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
import EmailCodeScreen from "./auth/EmailCodeScreen.svelte";
|
||||
import Input from "$components/ui/input.svelte";
|
||||
@@ -52,6 +53,7 @@
|
||||
export let selectedTariff = null;
|
||||
export let selectedTariffKey = "";
|
||||
export let selectedTariffPlans = [];
|
||||
export let renewHwidDevices = true;
|
||||
export let setPasswordBusy = false;
|
||||
export let setPasswordCode = "";
|
||||
export let setPasswordConfirm = "";
|
||||
@@ -72,6 +74,81 @@
|
||||
function priceLabel(plan) {
|
||||
return priceLabelFn(plan, selectedMethod);
|
||||
}
|
||||
function methodUsesStars() {
|
||||
return String(selectedMethod || "")
|
||||
.toLowerCase()
|
||||
.includes("stars");
|
||||
}
|
||||
function hwidRenewalFor(plan) {
|
||||
return plan?.hwid_renewal?.available ? plan.hwid_renewal : null;
|
||||
}
|
||||
function isSubscriptionPlan(plan) {
|
||||
const saleMode = String(plan?.sale_mode || "subscription").toLowerCase();
|
||||
return saleMode === "subscription";
|
||||
}
|
||||
function hwidRenewalAvailableForMethod(plan) {
|
||||
const renewal = hwidRenewalFor(plan);
|
||||
if (!subscription?.active || !isSubscriptionPlan(plan) || !renewal) return false;
|
||||
if (methodUsesStars()) return Number(renewal.stars_price || 0) > 0;
|
||||
return Number(renewal.price || 0) > 0;
|
||||
}
|
||||
function planWithSelectedHwidRenewal(plan) {
|
||||
if (!plan || !renewHwidDevices || !hwidRenewalAvailableForMethod(plan)) return plan;
|
||||
const renewal = hwidRenewalFor(plan);
|
||||
const withRenewal = {
|
||||
...plan,
|
||||
price: Number(plan.price || 0) + Number(renewal.price || 0),
|
||||
};
|
||||
if (Number(plan.stars_price || 0) > 0 && Number(renewal.stars_price || 0) > 0) {
|
||||
withRenewal.stars_price = Number(plan.stars_price || 0) + Number(renewal.stars_price || 0);
|
||||
}
|
||||
return withRenewal;
|
||||
}
|
||||
function paymentPriceLabel(plan) {
|
||||
return priceLabelFn(planWithSelectedHwidRenewal(plan), selectedMethod);
|
||||
}
|
||||
function hwidRenewalPriceLabel(plan = selectedPlan) {
|
||||
const renewal = hwidRenewalFor(plan);
|
||||
if (!renewal) return "";
|
||||
return priceLabelFn(
|
||||
{
|
||||
price: renewal.price || 0,
|
||||
stars_price: renewal.stars_price,
|
||||
currency: renewal.currency || plan?.currency,
|
||||
},
|
||||
selectedMethod
|
||||
);
|
||||
}
|
||||
function showHwidRenewalBlock() {
|
||||
return hwidRenewalAvailableForMethod(selectedPlan);
|
||||
}
|
||||
function showHwidRenewalUnavailableNote() {
|
||||
return Boolean(
|
||||
subscription?.active &&
|
||||
Number(subscription?.extra_hwid_devices || 0) > 0 &&
|
||||
isSubscriptionPlan(selectedPlan) &&
|
||||
!showHwidRenewalBlock()
|
||||
);
|
||||
}
|
||||
function hwidRenewalCount(plan = selectedPlan) {
|
||||
return Number(hwidRenewalFor(plan)?.device_count || subscription?.extra_hwid_devices || 0);
|
||||
}
|
||||
function hwidRenewalHint(plan = selectedPlan) {
|
||||
const renewal = hwidRenewalFor(plan);
|
||||
if (renewal?.valid_from_text && renewal?.valid_until_text) {
|
||||
return t("wa_hwid_devices_renewal_checkbox_hint", {
|
||||
from: renewal.valid_from_text,
|
||||
to: renewal.valid_until_text,
|
||||
});
|
||||
}
|
||||
return t("wa_hwid_devices_renewal_checkbox_hint_short");
|
||||
}
|
||||
function showHwidDesyncNotice() {
|
||||
return Boolean(
|
||||
subscription?.device_topup_renewal_available &&
|
||||
subscription?.extra_hwid_devices_valid_until_text
|
||||
);
|
||||
}
|
||||
function planKey(plan) {
|
||||
return planKeyFn(plan);
|
||||
}
|
||||
@@ -197,10 +274,34 @@
|
||||
<p>{subscriptionPurchaseDescription}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if subscription?.active && Number(subscription?.extra_hwid_devices || 0) > 0}
|
||||
{#if showHwidRenewalBlock()}
|
||||
<label class="hwid-renewal-option">
|
||||
<Checkbox
|
||||
checked={renewHwidDevices}
|
||||
ariaLabel={t("wa_hwid_devices_renewal_checkbox_aria")}
|
||||
onCheckedChange={(checked) => (renewHwidDevices = checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>
|
||||
{t("wa_hwid_devices_renewal_checkbox", {
|
||||
count: hwidRenewalCount(),
|
||||
price: hwidRenewalPriceLabel(),
|
||||
})}
|
||||
</strong>
|
||||
<small>{hwidRenewalHint()}</small>
|
||||
{#if showHwidDesyncNotice()}
|
||||
<small class="hwid-renewal-warning">
|
||||
{t("wa_hwid_devices_desync_notice", {
|
||||
date: subscription.extra_hwid_devices_valid_until_text,
|
||||
})}
|
||||
</small>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
{:else if showHwidRenewalUnavailableNote()}
|
||||
<div class="subscription-purchase-description">
|
||||
<p>
|
||||
{t("wa_hwid_devices_renewal_notice", {
|
||||
{t("wa_hwid_devices_renewal_unavailable", {
|
||||
count: Number(subscription.extra_hwid_devices || 0),
|
||||
date: subscription.extra_hwid_devices_valid_until_text || "",
|
||||
})}
|
||||
@@ -243,7 +344,7 @@
|
||||
disabled={!selectedPlan || !methods.length || payBusy}
|
||||
>
|
||||
{t("wa_pay")}
|
||||
{selectedPlan ? priceLabel(selectedPlan) : ""}
|
||||
{selectedPlan ? paymentPriceLabel(selectedPlan) : ""}
|
||||
<LockKeyhole size={17} />
|
||||
</Button>
|
||||
{:else}
|
||||
@@ -261,10 +362,34 @@
|
||||
<p>{subscriptionPurchaseDescription}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if subscription?.active && Number(subscription?.extra_hwid_devices || 0) > 0}
|
||||
{#if showHwidRenewalBlock()}
|
||||
<label class="hwid-renewal-option">
|
||||
<Checkbox
|
||||
checked={renewHwidDevices}
|
||||
ariaLabel={t("wa_hwid_devices_renewal_checkbox_aria")}
|
||||
onCheckedChange={(checked) => (renewHwidDevices = checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>
|
||||
{t("wa_hwid_devices_renewal_checkbox", {
|
||||
count: hwidRenewalCount(),
|
||||
price: hwidRenewalPriceLabel(),
|
||||
})}
|
||||
</strong>
|
||||
<small>{hwidRenewalHint()}</small>
|
||||
{#if showHwidDesyncNotice()}
|
||||
<small class="hwid-renewal-warning">
|
||||
{t("wa_hwid_devices_desync_notice", {
|
||||
date: subscription.extra_hwid_devices_valid_until_text,
|
||||
})}
|
||||
</small>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
{:else if showHwidRenewalUnavailableNote()}
|
||||
<div class="subscription-purchase-description">
|
||||
<p>
|
||||
{t("wa_hwid_devices_renewal_notice", {
|
||||
{t("wa_hwid_devices_renewal_unavailable", {
|
||||
count: Number(subscription.extra_hwid_devices || 0),
|
||||
date: subscription.extra_hwid_devices_valid_until_text || "",
|
||||
})}
|
||||
@@ -310,7 +435,7 @@
|
||||
disabled={!selectedPlan || !methods.length || payBusy}
|
||||
>
|
||||
{t("wa_pay")}
|
||||
{selectedPlan ? priceLabel(selectedPlan) : ""}
|
||||
{selectedPlan ? paymentPriceLabel(selectedPlan) : ""}
|
||||
<LockKeyhole size={17} />
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
@@ -127,6 +127,19 @@
|
||||
: "";
|
||||
}
|
||||
|
||||
function deviceTopupPlanTitle(plan) {
|
||||
return t("wa_hwid_devices_package", {
|
||||
count: Number(plan?.device_count || plan?.months || 0),
|
||||
});
|
||||
}
|
||||
|
||||
function deviceTopupPlanHint(plan) {
|
||||
if (plan?.valid_until_text) {
|
||||
return t("wa_hwid_devices_active_until", { date: plan.valid_until_text });
|
||||
}
|
||||
return plan?.subtitle || deviceTopupOptions?.tariff_name || "";
|
||||
}
|
||||
|
||||
function tariffChangeModalDescription() {
|
||||
if (!changeOptions) return "";
|
||||
return changeOptions?.current
|
||||
@@ -369,16 +382,7 @@
|
||||
{#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}
|
||||
{#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", {
|
||||
@@ -397,12 +401,8 @@
|
||||
onclick={() => (selectedDeviceTopupPlan = plan)}
|
||||
>
|
||||
<span class="option-row-main">
|
||||
<strong
|
||||
>{t("wa_hwid_devices_package", {
|
||||
count: Number(plan.device_count || plan.months || 0),
|
||||
})}</strong
|
||||
>
|
||||
<small>{plan.subtitle || deviceTopupOptions.tariff_name}</small>
|
||||
<strong>{deviceTopupPlanTitle(plan)}</strong>
|
||||
<small>{deviceTopupPlanHint(plan)}</small>
|
||||
</span>
|
||||
<span class="option-row-meta">
|
||||
<em>{priceLabel(plan)}</em>
|
||||
|
||||
+21
-6
@@ -948,20 +948,32 @@
|
||||
"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}.",
|
||||
"select_hwid_device_renewal_package": "Purchased HWID devices are renewed together with subscription renewal. Your current top-up is valid until {date}.",
|
||||
"choose_payment_method_hwid_devices": "Choose a payment method for extra HWID devices:",
|
||||
"payment_hwid_renewal_toggle_on": "✅ Also renew +{count} HWID devices for {price} {currency_symbol}",
|
||||
"payment_hwid_renewal_toggle_off": "☐ Renew +{count} HWID devices for {price} {currency_symbol}",
|
||||
"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.",
|
||||
"payment_successful_hwid_devices_renewal_note": "Your +{count} extra HWID devices are valid until {date}. To keep them for the next period, enable device renewal while renewing the subscription.",
|
||||
"payment_successful_hwid_devices_renewed_note": "Your +{count} extra HWID devices were renewed with the subscription until {date}.",
|
||||
"subscription_hwid_renewal_reminder": "You have +{count} extra HWID devices valid until {date}. When renewing the subscription, you can include these devices in the same payment.",
|
||||
"wa_buy_hwid_devices": "Buy devices",
|
||||
"wa_device_topup_for_tariff": "Device packages for {tariff}",
|
||||
"wa_hwid_devices_package": "+{count} devices",
|
||||
"wa_hwid_devices_renewal_package": "Renew +{count} devices",
|
||||
"wa_hwid_devices_active_until": "Active until {date}",
|
||||
"wa_hwid_devices_renewal_period": "From {from} to {to}",
|
||||
"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_hwid_devices_renewal_notice": "Your +{count} extra devices are valid until {date}. You can renew them together with the subscription.",
|
||||
"wa_hwid_devices_renewal_checkbox": "Also renew +{count} extra devices for {price}",
|
||||
"wa_hwid_devices_renewal_checkbox_aria": "Renew extra devices with the subscription",
|
||||
"wa_hwid_devices_renewal_checkbox_hint": "New device period: from {from} to {to}",
|
||||
"wa_hwid_devices_renewal_checkbox_hint_short": "Devices will be renewed for the new subscription period",
|
||||
"wa_hwid_devices_desync_notice": "The current device top-up is valid until {date}; before the new period starts, the limit may return to the base tariff limit.",
|
||||
"wa_hwid_devices_renewal_offer": "Your current +{count} device top-up is valid until {date}. Device renewal is handled together with subscription renewal.",
|
||||
"wa_hwid_devices_renewal_prompt": "Subscription renewed. If devices were not renewed with it, the current device top-up remains valid until its own end date.",
|
||||
"wa_hwid_devices_renewal_unavailable": "You currently have +{count} extra devices valid until {date}. Device renewal is not available for the selected payment method or tariff.",
|
||||
"wa_no_hwid_device_options": "No device packages available",
|
||||
"wa_device_topup_options_failed": "Could not load device packages",
|
||||
"admin_nav_overview": "Overview",
|
||||
@@ -1392,6 +1404,9 @@
|
||||
"admin_user_btn_reset_trial": "Reset Trial",
|
||||
"admin_user_label_extend": "Extend Subscription",
|
||||
"admin_user_label_extend_days": "Days",
|
||||
"admin_user_extend_hwid_devices": "Also extend +{count} HWID devices",
|
||||
"admin_user_extend_hwid_devices_aria": "Extend purchased HWID devices",
|
||||
"admin_user_extend_hwid_devices_hint": "Active device top-ups will receive the same number of extra days.",
|
||||
"admin_user_btn_extend": "Extend",
|
||||
"admin_user_label_telegram_msg": "Telegram Message",
|
||||
"admin_user_hint_telegram_msg": "Telegram HTML formatting supported",
|
||||
|
||||
+21
-6
@@ -948,20 +948,32 @@
|
||||
"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}.",
|
||||
"select_hwid_device_renewal_package": "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.",
|
||||
"choose_payment_method_hwid_devices": "Выберите способ оплаты дополнительных HWID устройств:",
|
||||
"payment_hwid_renewal_toggle_on": "✅ Продлить также +{count} HWID устройств за {price} {currency_symbol}",
|
||||
"payment_hwid_renewal_toggle_off": "☐ Продлить +{count} HWID устройств за {price} {currency_symbol}",
|
||||
"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}. При продлении подписки докупку нужно оформить заново.",
|
||||
"payment_successful_hwid_devices_renewal_note": "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.",
|
||||
"payment_successful_hwid_devices_renewed_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_renewal_package": "Продлить +{count} устройств",
|
||||
"wa_hwid_devices_active_until": "Действует до {date}",
|
||||
"wa_hwid_devices_renewal_period": "С {from} до {to}",
|
||||
"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_hwid_devices_renewal_notice": "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.",
|
||||
"wa_hwid_devices_renewal_checkbox": "Продлить также +{count} доп. устройств за {price}",
|
||||
"wa_hwid_devices_renewal_checkbox_aria": "Продлить дополнительные устройства вместе с подпиской",
|
||||
"wa_hwid_devices_renewal_checkbox_hint": "Новый срок устройств: с {from} до {to}",
|
||||
"wa_hwid_devices_renewal_checkbox_hint_short": "Устройства будут продлены на новый срок подписки",
|
||||
"wa_hwid_devices_desync_notice": "Текущая докупка действует до {date}; до начала нового периода лимит может вернуться к базовому.",
|
||||
"wa_hwid_devices_renewal_offer": "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.",
|
||||
"wa_hwid_devices_renewal_prompt": "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.",
|
||||
"wa_hwid_devices_renewal_unavailable": "Сейчас докуплено +{count} устройств до {date}. Для выбранного способа оплаты или тарифа продление этих устройств недоступно.",
|
||||
"wa_no_hwid_device_options": "Нет доступных пакетов устройств",
|
||||
"wa_device_topup_options_failed": "Не удалось загрузить пакеты устройств",
|
||||
"admin_nav_overview": "Обзор",
|
||||
@@ -1392,6 +1404,9 @@
|
||||
"admin_user_btn_reset_trial": "Сбросить триал",
|
||||
"admin_user_label_extend": "Продлить подписку",
|
||||
"admin_user_label_extend_days": "Дней",
|
||||
"admin_user_extend_hwid_devices": "Продлить также +{count} HWID устройств",
|
||||
"admin_user_extend_hwid_devices_aria": "Продлить докупленные HWID устройства",
|
||||
"admin_user_extend_hwid_devices_hint": "Срок действующих докупок устройств увеличится на те же дни.",
|
||||
"admin_user_btn_extend": "Продлить",
|
||||
"admin_user_label_telegram_msg": "Сообщение в Telegram",
|
||||
"admin_user_hint_telegram_msg": "Поддерживается HTML-разметка Telegram",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -118,8 +119,76 @@ class AdminUserHwidLimitRouteTests(unittest.IsolatedAsyncioTestCase):
|
||||
response = await admin_users.admin_user_hwid_device_limit_route(request)
|
||||
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertEqual(json.loads(response.text)["error"], "invalid_hwid_device_limit")
|
||||
subscription_service.sync_hwid_device_limit_to_panel.assert_not_awaited()
|
||||
|
||||
|
||||
class AdminUserExtendRouteTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_extend_route_can_skip_hwid_device_extension(self):
|
||||
session = FakeSession()
|
||||
new_end = datetime(2099, 2, 1, tzinfo=timezone.utc)
|
||||
subscription_service = SimpleNamespace(
|
||||
extend_active_subscription_days=AsyncMock(return_value=new_end)
|
||||
)
|
||||
request = FakeRequest(
|
||||
{"days": 10, "extend_hwid_devices": False},
|
||||
session,
|
||||
subscription_service,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(admin_users, "_require_admin_user_id", return_value=100),
|
||||
patch.object(admin_users.message_log_dal, "create_message_log", AsyncMock()) as log,
|
||||
patch.object(
|
||||
admin_users.subscription_dal,
|
||||
"get_active_subscription_by_user_id",
|
||||
AsyncMock(return_value=SimpleNamespace(subscription_id=1)),
|
||||
),
|
||||
patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()),
|
||||
patch.object(admin_users, "_serialize_subscription", return_value={"ok": True}),
|
||||
):
|
||||
response = await admin_users.admin_user_extend_route(request)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
subscription_service.extend_active_subscription_days.assert_awaited_once_with(
|
||||
session,
|
||||
42,
|
||||
10,
|
||||
"admin_extend_subscription_webapp",
|
||||
extend_hwid_devices=False,
|
||||
)
|
||||
self.assertIn("hwid=no", log.await_args.args[1]["content"])
|
||||
self.assertTrue(session.committed)
|
||||
|
||||
async def test_extend_route_extends_hwid_devices_by_default(self):
|
||||
session = FakeSession()
|
||||
new_end = datetime(2099, 2, 1, tzinfo=timezone.utc)
|
||||
subscription_service = SimpleNamespace(
|
||||
extend_active_subscription_days=AsyncMock(return_value=new_end)
|
||||
)
|
||||
request = FakeRequest({"days": 10}, session, subscription_service)
|
||||
|
||||
with (
|
||||
patch.object(admin_users, "_require_admin_user_id", return_value=100),
|
||||
patch.object(admin_users.message_log_dal, "create_message_log", AsyncMock()) as log,
|
||||
patch.object(
|
||||
admin_users.subscription_dal,
|
||||
"get_active_subscription_by_user_id",
|
||||
AsyncMock(return_value=SimpleNamespace(subscription_id=1)),
|
||||
),
|
||||
patch.object(admin_users, "_invalidate_after_admin_user_mutation", AsyncMock()),
|
||||
patch.object(admin_users, "_serialize_subscription", return_value={"ok": True}),
|
||||
):
|
||||
response = await admin_users.admin_user_extend_route(request)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
subscription_service.extend_active_subscription_days.assert_awaited_once_with(
|
||||
session,
|
||||
42,
|
||||
10,
|
||||
"admin_extend_subscription_webapp",
|
||||
extend_hwid_devices=True,
|
||||
)
|
||||
self.assertIn("hwid=yes", log.await_args.args[1]["content"])
|
||||
self.assertTrue(session.committed)
|
||||
|
||||
async def test_over_max_limit_is_rejected(self):
|
||||
session = FakeSession()
|
||||
|
||||
@@ -20,9 +20,10 @@ return.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from bot.services.subscription_service_impl.renewal import RenewalMixin
|
||||
|
||||
@@ -276,6 +277,59 @@ class ChargeRenewalHappyPathTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(yk.calls[0]["metadata"]["subscription_months"], "1")
|
||||
self.assertEqual(yk.calls[0]["amount"], 99.0)
|
||||
|
||||
async def test_includes_hwid_device_renewal_in_saved_method_charge(self):
|
||||
yk = _FakeYooKassaService(response={"id": "auto-pay-8", "status": "pending"})
|
||||
mixin = _make_mixin(yk=yk, price_for_months=399.0)
|
||||
valid_from = datetime(2099, 2, 1, tzinfo=timezone.utc)
|
||||
valid_until = datetime(2099, 3, 1, tzinfo=timezone.utc)
|
||||
mixin.quote_hwid_device_renewal_for_subscription = AsyncMock(
|
||||
return_value={
|
||||
"device_count": 2,
|
||||
"price": 50.0,
|
||||
"full_price": 50.0,
|
||||
"valid_from": valid_from,
|
||||
"valid_until": valid_until,
|
||||
"pricing_period_months": 1,
|
||||
"proration_ratio": 1.0,
|
||||
}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"db.dal.user_billing_dal.get_user_default_payment_method",
|
||||
_stub_default_pm,
|
||||
):
|
||||
ok = await mixin.charge_subscription_renewal(
|
||||
session=None,
|
||||
sub=_FakeSub(
|
||||
auto_renew_enabled=True,
|
||||
provider="yookassa",
|
||||
user_id=77,
|
||||
subscription_id=555,
|
||||
tariff_key="standard",
|
||||
duration_months=1,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(len(yk.calls), 1)
|
||||
call = yk.calls[0]
|
||||
self.assertEqual(call["amount"], 449.0)
|
||||
meta = call["metadata"]
|
||||
self.assertEqual(meta["sale_mode"], "subscription@standard")
|
||||
self.assertEqual(meta["hwid_devices"], "2")
|
||||
self.assertEqual(meta["hwid_valid_from"], valid_from.isoformat())
|
||||
self.assertEqual(meta["hwid_valid_until"], valid_until.isoformat())
|
||||
self.assertEqual(meta["hwid_pricing_period_months"], "1")
|
||||
self.assertEqual(meta["hwid_proration_ratio"], "1.0")
|
||||
self.assertEqual(meta["hwid_full_price"], "50.0")
|
||||
mixin.quote_hwid_device_renewal_for_subscription.assert_awaited_once_with(
|
||||
None,
|
||||
user_id=77,
|
||||
target_tariff_key="standard",
|
||||
months=1,
|
||||
currency="rub",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from db.dal import tariff_dal
|
||||
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, records):
|
||||
self._records = records
|
||||
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return self._records
|
||||
|
||||
|
||||
class HwidDeviceBonusExtensionTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_extends_tail_purchase_when_it_covers_subscription_end(self):
|
||||
subscription_end = datetime(2099, 2, 1, tzinfo=timezone.utc)
|
||||
future_purchase = SimpleNamespace(
|
||||
valid_until=subscription_end,
|
||||
)
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=_ScalarResult([future_purchase])),
|
||||
flush=AsyncMock(),
|
||||
)
|
||||
|
||||
updated = await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
|
||||
session,
|
||||
subscription_id=10,
|
||||
at=datetime(2099, 1, 1, tzinfo=timezone.utc),
|
||||
subscription_end_before=subscription_end,
|
||||
delta=timedelta(days=7),
|
||||
)
|
||||
|
||||
self.assertEqual(updated, 1)
|
||||
self.assertEqual(future_purchase.valid_until, subscription_end + timedelta(days=7))
|
||||
session.flush.assert_awaited_once()
|
||||
self.assertEqual(session.execute.await_count, 1)
|
||||
|
||||
async def test_extends_active_purchase_when_no_tail_purchase_exists(self):
|
||||
active_until = datetime(2099, 1, 16, tzinfo=timezone.utc)
|
||||
active_purchase = SimpleNamespace(valid_until=active_until)
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(
|
||||
side_effect=[
|
||||
_ScalarResult([]),
|
||||
_ScalarResult([active_purchase]),
|
||||
]
|
||||
),
|
||||
flush=AsyncMock(),
|
||||
)
|
||||
|
||||
updated = await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
|
||||
session,
|
||||
subscription_id=10,
|
||||
at=datetime(2099, 1, 1, tzinfo=timezone.utc),
|
||||
subscription_end_before=datetime(2099, 2, 1, tzinfo=timezone.utc),
|
||||
delta=timedelta(days=7),
|
||||
)
|
||||
|
||||
self.assertEqual(updated, 1)
|
||||
self.assertEqual(active_purchase.valid_until, active_until + timedelta(days=7))
|
||||
session.flush.assert_awaited_once()
|
||||
self.assertEqual(session.execute.await_count, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
@@ -273,6 +273,115 @@ class HwidDeviceTopupBehaviourTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(quote["price"], 150)
|
||||
self.assertAlmostEqual(quote["proration_ratio"], 1.0)
|
||||
|
||||
async def test_quote_keeps_immediate_and_renewal_windows_separate(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
settings = _make_settings(
|
||||
tmpdir,
|
||||
_tariffs_config_payload(
|
||||
hwid_device_packages={
|
||||
"rub": [{"count": 1, "price": 50, "prices": {"1": 50}}],
|
||||
"stars": [],
|
||||
}
|
||||
),
|
||||
)
|
||||
service = _make_service(settings)
|
||||
sub = _make_sub()
|
||||
sub.start_date = datetime(2098, 12, 1, tzinfo=timezone.utc)
|
||||
sub.end_date = datetime(2099, 2, 1, tzinfo=timezone.utc)
|
||||
user = _make_user()
|
||||
now = datetime(2099, 1, 2, tzinfo=timezone.utc)
|
||||
existing_extra_until = datetime(2099, 1, 17, tzinfo=timezone.utc)
|
||||
|
||||
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": 1,
|
||||
"active_until": existing_extra_until,
|
||||
}
|
||||
),
|
||||
),
|
||||
):
|
||||
immediate = await service.quote_hwid_device_topup(
|
||||
session=AsyncMock(),
|
||||
user_id=42,
|
||||
device_count=1,
|
||||
tariff_key="standard",
|
||||
currency="rub",
|
||||
renewal=False,
|
||||
now=now,
|
||||
)
|
||||
renewal = await service.quote_hwid_device_topup(
|
||||
session=AsyncMock(),
|
||||
user_id=42,
|
||||
device_count=1,
|
||||
tariff_key="standard",
|
||||
currency="rub",
|
||||
renewal=True,
|
||||
now=now,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(immediate)
|
||||
self.assertIsNotNone(renewal)
|
||||
self.assertEqual(immediate["valid_from"], now)
|
||||
self.assertEqual(immediate["valid_until"], sub.end_date)
|
||||
self.assertEqual(immediate["price"], 50)
|
||||
self.assertEqual(renewal["valid_from"], existing_extra_until)
|
||||
self.assertEqual(renewal["valid_until"], sub.end_date)
|
||||
self.assertLess(renewal["price"], immediate["price"])
|
||||
|
||||
async def test_subscription_renewal_quote_prices_current_active_extra_devices(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
settings = _make_settings(tmpdir, _tariffs_config_payload())
|
||||
service = _make_service(settings)
|
||||
sub = _make_sub()
|
||||
sub.end_date = datetime(2099, 2, 1, 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": 4,
|
||||
"active_until": datetime(2099, 1, 16, tzinfo=timezone.utc),
|
||||
}
|
||||
),
|
||||
),
|
||||
):
|
||||
quote = await service.quote_hwid_device_renewal_for_subscription(
|
||||
session=AsyncMock(),
|
||||
user_id=42,
|
||||
target_tariff_key="standard",
|
||||
months=1,
|
||||
currency="rub",
|
||||
now=datetime(2099, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
self.assertIsNotNone(quote)
|
||||
self.assertEqual(quote["device_count"], 4)
|
||||
self.assertEqual(quote["price"], 170)
|
||||
self.assertEqual(sorted(quote["package_counts"]), [1, 3])
|
||||
self.assertEqual(quote["valid_from"], sub.end_date)
|
||||
self.assertEqual(quote["valid_until"], datetime(2099, 3, 1, tzinfo=timezone.utc))
|
||||
|
||||
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:
|
||||
|
||||
@@ -185,6 +185,97 @@ class HwidTariffSwitchConversionTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(change_payload["converted_hwid_value_rub"], 50)
|
||||
self.assertEqual(change_payload["converted_hwid_days"], 7)
|
||||
|
||||
async def test_paid_switch_records_payment_id_in_single_tariff_change(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=0,
|
||||
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": 20,
|
||||
"paid_diff_rub": 50,
|
||||
"target_monthly_rub": 200,
|
||||
"converted_hwid_value_rub": 0,
|
||||
"converted_hwid_days": 0,
|
||||
"convertible_hwid_purchase_ids": [],
|
||||
}
|
||||
),
|
||||
),
|
||||
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="paid_diff",
|
||||
payment_id=99,
|
||||
)
|
||||
|
||||
self.assertEqual(result["tariff_key"], "pro")
|
||||
create_change.assert_awaited_once()
|
||||
change_payload = create_change.await_args.args[1]
|
||||
self.assertEqual(change_payload["payment_id"], 99)
|
||||
self.assertEqual(change_payload["mode"], "paid_diff")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
|
||||
@@ -30,7 +30,10 @@ from bot.payment_providers.shared import (
|
||||
sale_mode_is_traffic,
|
||||
sale_mode_tariff_key,
|
||||
)
|
||||
from bot.payment_providers.yookassa import _resolve_yookassa_activation_amounts
|
||||
from bot.payment_providers.yookassa import (
|
||||
_parse_saved_list_payload,
|
||||
_resolve_yookassa_activation_amounts,
|
||||
)
|
||||
from config.settings import Settings
|
||||
|
||||
_LEGACY_PROVIDER_FILES = [
|
||||
@@ -201,6 +204,47 @@ def test_provider_presentation_ignores_cross_language_override():
|
||||
assert resolve_provider_presentation(spec, settings, language="en").webapp_label == "YooKassa"
|
||||
|
||||
|
||||
def test_subscription_hwid_renewal_token_adds_quote_to_callback_parts():
|
||||
service = SimpleNamespace(
|
||||
quote_hwid_device_renewal_for_subscription=AsyncMock(
|
||||
return_value={
|
||||
"device_count": 2,
|
||||
"price": 50,
|
||||
"valid_from": "2099-02-01",
|
||||
"valid_until": "2099-03-01",
|
||||
}
|
||||
)
|
||||
)
|
||||
session = AsyncMock()
|
||||
|
||||
parts, quote = asyncio.run(
|
||||
quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=77,
|
||||
parts=PaymentCallbackParts(
|
||||
months=1,
|
||||
price=100,
|
||||
sale_mode="subscription@basic|hwid_renewal",
|
||||
),
|
||||
subscription_service=service,
|
||||
currency="rub",
|
||||
)
|
||||
)
|
||||
|
||||
assert parts is not None
|
||||
assert quote is not None
|
||||
assert parts.months == 1
|
||||
assert parts.price == 150
|
||||
assert quote["device_count"] == 2
|
||||
service.quote_hwid_device_renewal_for_subscription.assert_awaited_once_with(
|
||||
session,
|
||||
user_id=77,
|
||||
target_tariff_key="basic",
|
||||
months=1,
|
||||
currency="rub",
|
||||
)
|
||||
|
||||
|
||||
def test_payment_method_keyboard_uses_custom_telegram_text_without_changing_callback(monkeypatch):
|
||||
monkeypatch.setenv("WATA_ENABLED", "True")
|
||||
monkeypatch.setenv("PAYMENT_WATA_TELEGRAM_LABEL_EN", "Wata custom")
|
||||
@@ -502,3 +546,19 @@ def test_yookassa_hwid_metadata_rejects_fractional_device_count():
|
||||
traffic_gb_raw=None,
|
||||
hwid_devices_raw="1.9",
|
||||
)
|
||||
|
||||
|
||||
def test_yookassa_saved_card_payload_parser_accepts_new_and_legacy_formats():
|
||||
assert _parse_saved_list_payload("1:100:0:subscription@vip|hwid_renewal") == (
|
||||
1,
|
||||
100,
|
||||
0,
|
||||
"subscription@vip|hwid_renewal",
|
||||
)
|
||||
assert _parse_saved_list_payload("1:100:subscription@vip|hwid_renewal") == (
|
||||
1,
|
||||
100,
|
||||
0,
|
||||
"subscription@vip|hwid_renewal",
|
||||
)
|
||||
assert _parse_saved_list_payload("bad:100:subscription") is None
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest import IsolatedAsyncioTestCase
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from bot.payment_providers.shared import webhooks
|
||||
from bot.payment_providers.shared.success import PaymentSuccessRequest, finalize_successful_payment
|
||||
|
||||
|
||||
class _PaymentWithLazyUser:
|
||||
payment_id = 12
|
||||
user_id = 42
|
||||
|
||||
@property
|
||||
def user(self):
|
||||
raise RuntimeError("lazy relationship access is not allowed here")
|
||||
|
||||
|
||||
class _I18n:
|
||||
def gettext(self, _language, key, **_kwargs):
|
||||
return key
|
||||
|
||||
|
||||
class PaymentWebhookNotificationTests(IsolatedAsyncioTestCase):
|
||||
async def test_failed_payment_notification_loads_user_explicitly(self):
|
||||
user = SimpleNamespace(user_id=42, language_code="ru", email="u@example.test")
|
||||
bot = SimpleNamespace(send_message=AsyncMock())
|
||||
settings = SimpleNamespace(DEFAULT_LANGUAGE="en", SUBSCRIPTION_MINI_APP_URL="")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
webhooks.user_dal,
|
||||
"get_user_by_id",
|
||||
AsyncMock(return_value=user),
|
||||
) as get_user,
|
||||
patch.object(
|
||||
webhooks,
|
||||
"send_user_notification_email",
|
||||
AsyncMock(),
|
||||
) as send_email,
|
||||
):
|
||||
await webhooks.notify_user_payment_failed(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=_I18n(),
|
||||
session=AsyncMock(),
|
||||
payment=_PaymentWithLazyUser(),
|
||||
)
|
||||
|
||||
get_user.assert_awaited_once()
|
||||
bot.send_message.assert_awaited_once_with(42, "payment_failed")
|
||||
send_email.assert_awaited_once()
|
||||
|
||||
async def test_finalize_failure_marks_payment_retryable(self):
|
||||
session = AsyncMock()
|
||||
payment = SimpleNamespace(payment_id=12, user_id=42, status="succeeded")
|
||||
subscription_service = SimpleNamespace(
|
||||
activate_subscription=AsyncMock(side_effect=RuntimeError("panel failed"))
|
||||
)
|
||||
|
||||
with patch(
|
||||
"bot.payment_providers.shared.success.payment_dal.update_payment_status_by_db_id",
|
||||
AsyncMock(return_value=payment),
|
||||
) as update_status:
|
||||
result = await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
bot=SimpleNamespace(),
|
||||
settings=SimpleNamespace(DEFAULT_LANGUAGE="en"),
|
||||
i18n=_I18n(),
|
||||
session=session,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=SimpleNamespace(),
|
||||
payment=payment,
|
||||
user_id=42,
|
||||
amount=50,
|
||||
currency="RUB",
|
||||
sale_mode="hwid_devices@standard",
|
||||
months=1,
|
||||
traffic_amount=1,
|
||||
provider_subscription="platega",
|
||||
provider_notification="platega",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIsNone(result)
|
||||
session.rollback.assert_awaited_once()
|
||||
update_status.assert_awaited_once_with(session, 12, "activation_failed")
|
||||
session.commit.assert_awaited_once()
|
||||
@@ -433,6 +433,103 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas
|
||||
self.assertEqual(kwargs["tariff_key"], "standard")
|
||||
self.assertEqual(kwargs["payment_db_id"], 12)
|
||||
|
||||
async def test_activate_subscription_records_hwid_renewal_without_inflating_tariff_price(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
settings = _make_settings(_tariffs_config_payload(), tmpdir)
|
||||
service = _make_service(settings)
|
||||
service._get_or_create_panel_user_link_details = AsyncMock(
|
||||
return_value=("panel-user", "short-uuid", "short", False)
|
||||
)
|
||||
service.panel_service.update_user_details_on_panel = AsyncMock(
|
||||
return_value={"subscriptionUrl": "https://panel/sub", "shortUuid": "short"}
|
||||
)
|
||||
service._send_payment_success_email = AsyncMock()
|
||||
now = datetime.now(timezone.utc)
|
||||
current_end = now + timedelta(days=20)
|
||||
current_sub = SimpleNamespace(
|
||||
subscription_id=10,
|
||||
end_date=current_end,
|
||||
tariff_key="standard",
|
||||
topup_balance_bytes=0,
|
||||
extra_hwid_devices=1,
|
||||
premium_topup_balance_bytes=0,
|
||||
premium_topup_used_bytes=0,
|
||||
premium_used_bytes=0,
|
||||
premium_period_start_at=None,
|
||||
regular_bonus_bytes=0,
|
||||
regular_unlimited_override=False,
|
||||
)
|
||||
updated_sub = SimpleNamespace(subscription_id=10)
|
||||
payment = SimpleNamespace(
|
||||
purchased_hwid_devices=1,
|
||||
hwid_valid_from=current_end,
|
||||
hwid_valid_until=current_end + timedelta(days=30),
|
||||
hwid_full_price=50,
|
||||
hwid_pricing_period_months=1,
|
||||
hwid_proration_ratio=1.0,
|
||||
)
|
||||
db_user = SimpleNamespace(
|
||||
user_id=42,
|
||||
panel_user_uuid="panel-user",
|
||||
telegram_id=42,
|
||||
username="alice",
|
||||
email=None,
|
||||
language_code="en",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id",
|
||||
AsyncMock(return_value=db_user),
|
||||
),
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.lifecycle.payment_dal.get_payment_by_db_id",
|
||||
AsyncMock(return_value=payment),
|
||||
),
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id",
|
||||
AsyncMock(return_value=current_sub),
|
||||
),
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.lifecycle.subscription_dal.deactivate_other_active_subscriptions",
|
||||
AsyncMock(),
|
||||
),
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.lifecycle.subscription_dal.upsert_subscription",
|
||||
AsyncMock(return_value=updated_sub),
|
||||
) as upsert_subscription,
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.lifecycle.tariff_dal.get_hwid_device_entitlement_summary",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
"active_devices": 1,
|
||||
"active_until": current_end,
|
||||
}
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.lifecycle.tariff_dal.create_hwid_device_purchase",
|
||||
AsyncMock(),
|
||||
) as create_hwid_purchase,
|
||||
):
|
||||
result = await service.activate_subscription(
|
||||
session=AsyncMock(),
|
||||
user_id=42,
|
||||
months=1,
|
||||
payment_amount=150,
|
||||
payment_db_id=99,
|
||||
sale_mode="subscription@standard",
|
||||
)
|
||||
|
||||
self.assertEqual(result["hwid_devices_renewed_count"], 1)
|
||||
sub_payload = upsert_subscription.await_args.args[1]
|
||||
self.assertEqual(sub_payload["effective_monthly_price_rub"], 100)
|
||||
create_hwid_purchase.assert_awaited_once()
|
||||
purchase_kwargs = create_hwid_purchase.await_args.kwargs
|
||||
self.assertEqual(purchase_kwargs["payment_id"], 99)
|
||||
self.assertEqual(purchase_kwargs["purchased_devices"], 1)
|
||||
self.assertEqual(purchase_kwargs["valid_from"], current_end)
|
||||
|
||||
|
||||
class SubscriptionServiceBonusExtensionTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_referral_extension_preserves_existing_tariff_limit(self):
|
||||
@@ -479,6 +576,10 @@ class SubscriptionServiceBonusExtensionTests(unittest.IsolatedAsyncioTestCase):
|
||||
"bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription",
|
||||
AsyncMock(),
|
||||
) as update_subscription,
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.lifecycle.tariff_dal.extend_hwid_device_purchases_for_subscription_bonus",
|
||||
AsyncMock(return_value=1),
|
||||
) as extend_hwid,
|
||||
):
|
||||
await service.extend_active_subscription_days(
|
||||
session=AsyncMock(),
|
||||
@@ -488,10 +589,68 @@ class SubscriptionServiceBonusExtensionTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
|
||||
update_subscription.assert_not_awaited()
|
||||
extend_hwid.assert_awaited_once()
|
||||
self.assertEqual(extend_hwid.await_args.kwargs["subscription_id"], 10)
|
||||
self.assertEqual(extend_hwid.await_args.kwargs["delta"], timedelta(days=3))
|
||||
payload = service.panel_service.update_user_details_on_panel.await_args.args[1]
|
||||
self.assertNotIn("trafficLimitBytes", payload)
|
||||
self.assertNotIn("trafficLimitStrategy", payload)
|
||||
|
||||
async def test_admin_extension_can_skip_hwid_purchase_extension(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
settings = _make_settings(
|
||||
_tariffs_config_payload(),
|
||||
tmpdir,
|
||||
USER_TRAFFIC_LIMIT_GB=999,
|
||||
)
|
||||
service = _make_service(settings)
|
||||
service._get_or_create_panel_user_link_details = AsyncMock(
|
||||
return_value=("panel-user", "short-uuid", "short", False)
|
||||
)
|
||||
service.panel_service.update_user_details_on_panel = AsyncMock(
|
||||
return_value={"ok": True}
|
||||
)
|
||||
active_sub = SimpleNamespace(
|
||||
subscription_id=10,
|
||||
end_date=datetime.now(timezone.utc) + timedelta(days=5),
|
||||
traffic_limit_bytes=100 * GIB,
|
||||
tariff_key="standard",
|
||||
)
|
||||
updated_sub = SimpleNamespace(
|
||||
subscription_id=10,
|
||||
end_date=active_sub.end_date + timedelta(days=3),
|
||||
traffic_limit_bytes=100 * GIB,
|
||||
tariff_key="standard",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id",
|
||||
AsyncMock(return_value=SimpleNamespace(user_id=42)),
|
||||
),
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id",
|
||||
AsyncMock(return_value=active_sub),
|
||||
),
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription_end_date",
|
||||
AsyncMock(return_value=updated_sub),
|
||||
),
|
||||
patch(
|
||||
"bot.services.subscription_service_impl.lifecycle.tariff_dal.extend_hwid_device_purchases_for_subscription_bonus",
|
||||
AsyncMock(return_value=1),
|
||||
) as extend_hwid,
|
||||
):
|
||||
await service.extend_active_subscription_days(
|
||||
session=AsyncMock(),
|
||||
user_id=42,
|
||||
bonus_days=3,
|
||||
reason="admin_extend_subscription_webapp",
|
||||
extend_hwid_devices=False,
|
||||
)
|
||||
|
||||
extend_hwid.assert_not_awaited()
|
||||
|
||||
|
||||
class SubscriptionServiceActiveDetailsTests(unittest.IsolatedAsyncioTestCase):
|
||||
def _local_active_sub(self) -> SimpleNamespace:
|
||||
|
||||
@@ -20,9 +20,11 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_subscription_options_keyboard,
|
||||
get_tariff_catalog_keyboard,
|
||||
get_tariff_periods_keyboard,
|
||||
get_yk_autopay_choice_keyboard,
|
||||
payment_methods_back_callback,
|
||||
payment_options_back_callback,
|
||||
)
|
||||
from bot.middlewares.i18n import LOCALE_KEY_ALIASES
|
||||
from config.tariffs_config import TariffsConfig
|
||||
|
||||
|
||||
@@ -31,6 +33,7 @@ class JsonI18nStub:
|
||||
self.translations = json.loads(Path("locales/en.json").read_text(encoding="utf-8"))
|
||||
|
||||
def gettext(self, lang, key, **kwargs):
|
||||
key = LOCALE_KEY_ALIASES.get(key, key)
|
||||
text = self.translations[key]
|
||||
return text.format(**kwargs) if kwargs else text
|
||||
|
||||
@@ -248,6 +251,63 @@ class UserBotMenuTests(unittest.TestCase):
|
||||
"subscribe_period:1:bot",
|
||||
)
|
||||
|
||||
def test_payment_navigation_context_ignores_hwid_renewal_token(self):
|
||||
self.assertEqual(
|
||||
payment_options_back_callback("subscription@basic|bot|hwid_renewal"),
|
||||
"tariff:select:basic:bot",
|
||||
)
|
||||
self.assertEqual(
|
||||
payment_methods_back_callback("1", "subscription@basic|bot|hwid_renewal"),
|
||||
"tariff:period:basic:1:bot",
|
||||
)
|
||||
|
||||
def test_payment_method_keyboard_adds_hwid_renewal_toggle(self):
|
||||
settings = SimpleNamespace(payment_methods_order=[])
|
||||
quote = {"device_count": 2, "price": 50}
|
||||
|
||||
selected = get_payment_method_keyboard(
|
||||
1,
|
||||
100,
|
||||
None,
|
||||
"RUB",
|
||||
"en",
|
||||
self.i18n,
|
||||
settings,
|
||||
sale_mode="subscription@basic|bot",
|
||||
hwid_renewal_quote=quote,
|
||||
hwid_renewal_selected=True,
|
||||
)
|
||||
disabled = get_payment_method_keyboard(
|
||||
1,
|
||||
100,
|
||||
None,
|
||||
"RUB",
|
||||
"en",
|
||||
self.i18n,
|
||||
settings,
|
||||
sale_mode="subscription@basic|bot",
|
||||
hwid_renewal_quote=quote,
|
||||
hwid_renewal_selected=False,
|
||||
)
|
||||
|
||||
self.assertIn("tariff:period:basic:1:bot:no_hwid", self._callback_data(selected))
|
||||
self.assertIn("tariff:period:basic:1:bot:hwid", self._callback_data(disabled))
|
||||
|
||||
def test_yookassa_saved_card_choice_keeps_sale_mode_after_page_token(self):
|
||||
markup = get_yk_autopay_choice_keyboard(
|
||||
1,
|
||||
100,
|
||||
"en",
|
||||
self.i18n,
|
||||
has_saved_cards=True,
|
||||
sale_mode="subscription@basic|hwid_renewal",
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"pay_yk_saved_list:1:100:0:subscription@basic|hwid_renewal",
|
||||
self._callback_data(markup),
|
||||
)
|
||||
|
||||
def test_tariff_back_buttons_return_to_previous_level(self):
|
||||
tariff = SimpleNamespace(
|
||||
key="basic",
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest import IsolatedAsyncioTestCase
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import ANY, AsyncMock, patch
|
||||
|
||||
import bot.app.web.subscription_webapp # noqa: F401
|
||||
from bot.app.web.webapp import billing as billing_module
|
||||
@@ -95,6 +95,101 @@ class WebAppDeviceTopupOptionsTests(IsolatedAsyncioTestCase):
|
||||
self.assertEqual(payload["plans"][0]["valid_from"], valid_from.isoformat())
|
||||
self.assertEqual(payload["plans"][0]["valid_until"], active_until.isoformat())
|
||||
|
||||
async def test_offers_only_immediate_topup_when_existing_extra_expires_early(self):
|
||||
current_extra_until = datetime(2099, 1, 16, tzinfo=timezone.utc)
|
||||
subscription_until = datetime(2099, 2, 1, tzinfo=timezone.utc)
|
||||
tariff = SimpleNamespace(
|
||||
key="standard",
|
||||
billing_model="period",
|
||||
hwid_device_packages=SimpleNamespace(
|
||||
rub=[SimpleNamespace(count=1)],
|
||||
stars=[],
|
||||
),
|
||||
name=lambda lang: "Standard",
|
||||
)
|
||||
settings = SimpleNamespace(
|
||||
MY_DEVICES_SECTION_ENABLED=True,
|
||||
tariffs_config=SimpleNamespace(require=lambda key: tariff),
|
||||
DEFAULT_LANGUAGE="en",
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB",
|
||||
)
|
||||
|
||||
async def quote_hwid_device_topup(*args, **kwargs):
|
||||
if kwargs.get("renewal"):
|
||||
return {
|
||||
"price": 25,
|
||||
"valid_from": current_extra_until,
|
||||
"valid_until": subscription_until,
|
||||
"proration_ratio": 0.5,
|
||||
}
|
||||
return {
|
||||
"price": 50,
|
||||
"valid_from": datetime(2099, 1, 1, tzinfo=timezone.utc),
|
||||
"valid_until": subscription_until,
|
||||
"proration_ratio": 1.0,
|
||||
}
|
||||
|
||||
subscription_service = SimpleNamespace(
|
||||
get_active_subscription_details=AsyncMock(
|
||||
return_value={
|
||||
"max_devices": 3,
|
||||
"extra_hwid_devices": 1,
|
||||
"extra_hwid_devices_valid_until": current_extra_until,
|
||||
"extra_hwid_devices_valid_until_text": "16.01.2099 00:00",
|
||||
"device_topup_renewal_available": True,
|
||||
}
|
||||
),
|
||||
quote_hwid_device_topup=AsyncMock(side_effect=quote_hwid_device_topup),
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"settings": settings,
|
||||
"async_session_factory": _SessionFactory(),
|
||||
"subscription_service": subscription_service,
|
||||
}
|
||||
)
|
||||
db_user = SimpleNamespace(
|
||||
is_banned=False,
|
||||
panel_user_uuid="panel-user",
|
||||
language_code="en",
|
||||
)
|
||||
sub = SimpleNamespace(
|
||||
tariff_key="standard",
|
||||
extra_hwid_devices=1,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(billing_module, "_require_user_id", return_value=42),
|
||||
patch.object(
|
||||
billing_module.user_dal,
|
||||
"get_user_by_id",
|
||||
AsyncMock(return_value=db_user),
|
||||
),
|
||||
patch.object(
|
||||
billing_module.subscription_dal,
|
||||
"get_active_subscription_by_user_id",
|
||||
AsyncMock(return_value=sub),
|
||||
),
|
||||
):
|
||||
response = await billing_module.device_topup_options_route(request)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
payload = json.loads(response.text)
|
||||
self.assertEqual([plan["sale_mode"] for plan in payload["plans"]], ["hwid_devices"])
|
||||
self.assertEqual([plan["price"] for plan in payload["plans"]], [50])
|
||||
self.assertFalse(payload["plans"][0]["renewal"])
|
||||
self.assertEqual(payload["plans"][0]["valid_until"], subscription_until.isoformat())
|
||||
self.assertEqual(payload["renewal_available"], False)
|
||||
self.assertEqual(payload["renewal_recommended_count"], 0)
|
||||
renewal_flags = [
|
||||
call.kwargs.get("renewal")
|
||||
for call in subscription_service.quote_hwid_device_topup.await_args_list
|
||||
]
|
||||
self.assertEqual(
|
||||
renewal_flags,
|
||||
[False],
|
||||
)
|
||||
|
||||
async def test_create_payment_route_quotes_hwid_with_app_subscription_service(self):
|
||||
tariff = SimpleNamespace(
|
||||
key="standard",
|
||||
@@ -197,6 +292,110 @@ class WebAppDeviceTopupOptionsTests(IsolatedAsyncioTestCase):
|
||||
subscription_service.quote_hwid_device_topup.assert_awaited_once()
|
||||
create_payment.assert_awaited_once()
|
||||
|
||||
async def test_create_payment_route_adds_hwid_renewal_to_subscription_payment(self):
|
||||
tariff = SimpleNamespace(
|
||||
key="standard",
|
||||
billing_model="period",
|
||||
enabled_periods=[1],
|
||||
hwid_device_packages=SimpleNamespace(
|
||||
rub=[SimpleNamespace(count=1)],
|
||||
stars=[],
|
||||
),
|
||||
period_price=lambda months, currency: 100 if currency == "rub" else None,
|
||||
)
|
||||
settings = SimpleNamespace(
|
||||
traffic_sale_mode=False,
|
||||
tariffs_config=SimpleNamespace(require=lambda key: tariff),
|
||||
DEFAULT_LANGUAGE="en",
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB",
|
||||
ADMIN_IDS=[],
|
||||
)
|
||||
hwid_quote = {
|
||||
"price": 50,
|
||||
"device_count": 1,
|
||||
"valid_from": datetime(2099, 1, 1, tzinfo=timezone.utc),
|
||||
"valid_until": datetime(2099, 2, 1, tzinfo=timezone.utc),
|
||||
"pricing_period_months": 1,
|
||||
"proration_ratio": 1.0,
|
||||
"full_price": 50,
|
||||
}
|
||||
subscription_service = SimpleNamespace(
|
||||
quote_hwid_device_renewal_for_subscription=AsyncMock(return_value=hwid_quote)
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"settings": settings,
|
||||
"async_session_factory": _SessionFactory(),
|
||||
"subscription_service": subscription_service,
|
||||
}
|
||||
)
|
||||
db_user = SimpleNamespace(
|
||||
is_banned=False,
|
||||
panel_user_uuid="panel-user",
|
||||
language_code="en",
|
||||
telegram_id=42,
|
||||
)
|
||||
|
||||
async def _fake_create_payment(**kwargs):
|
||||
return billing_module.web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"price": kwargs["price"],
|
||||
"hwid_device_count": kwargs["hwid_quote"]["device_count"],
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(billing_module, "_require_user_id", return_value=42),
|
||||
patch.object(
|
||||
billing_module,
|
||||
"_enforce_webapp_rate_limit",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch.object(
|
||||
billing_module,
|
||||
"_read_json",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
"method": "yookassa",
|
||||
"months": 1,
|
||||
"tariff_key": "standard",
|
||||
"sale_mode": "subscription",
|
||||
"renew_hwid_devices": True,
|
||||
}
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
billing_module,
|
||||
"_get_cached_webapp_settings",
|
||||
return_value={"subscription_options": {}, "stars_subscription_options": {}},
|
||||
),
|
||||
patch.object(
|
||||
billing_module.user_dal,
|
||||
"get_user_by_id",
|
||||
AsyncMock(return_value=db_user),
|
||||
),
|
||||
patch.object(
|
||||
billing_module,
|
||||
"_create_subscription_payment",
|
||||
AsyncMock(side_effect=_fake_create_payment),
|
||||
) as create_payment,
|
||||
):
|
||||
response = await billing_module.create_payment_route(request)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
payload = json.loads(response.text)
|
||||
self.assertEqual(payload["price"], 150)
|
||||
self.assertEqual(payload["hwid_device_count"], 1)
|
||||
subscription_service.quote_hwid_device_renewal_for_subscription.assert_awaited_once_with(
|
||||
ANY,
|
||||
user_id=42,
|
||||
target_tariff_key="standard",
|
||||
months=1,
|
||||
currency="rub",
|
||||
)
|
||||
create_payment.assert_awaited_once()
|
||||
|
||||
async def test_create_payment_route_rejects_fractional_hwid_device_count(self):
|
||||
tariff = SimpleNamespace(
|
||||
key="standard",
|
||||
|
||||
@@ -164,7 +164,7 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase):
|
||||
):
|
||||
response = await billing_module.payment_status_route(request)
|
||||
|
||||
invalidate_cache.assert_awaited_once_with(settings, 1001)
|
||||
invalidate_cache.assert_awaited_once_with(settings, 1001, include_devices=True)
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
async def test_wata_pending_payment_refresh_delegates_to_provider_service(self):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest import IsolatedAsyncioTestCase
|
||||
from unittest.mock import AsyncMock, patch
|
||||
@@ -15,6 +16,81 @@ class _I18n:
|
||||
|
||||
|
||||
class YooKassaHwidWebhookTests(IsolatedAsyncioTestCase):
|
||||
async def test_telegram_subscription_hwid_quote_is_stored_in_yookassa_metadata(self):
|
||||
valid_from = datetime(2099, 2, 1, tzinfo=timezone.utc)
|
||||
valid_until = datetime(2099, 3, 1, tzinfo=timezone.utc)
|
||||
session = AsyncMock()
|
||||
callback = SimpleNamespace(
|
||||
from_user=SimpleNamespace(id=42),
|
||||
message=SimpleNamespace(edit_text=AsyncMock()),
|
||||
)
|
||||
service = SimpleNamespace(
|
||||
config=SimpleNamespace(DEFAULT_RECEIPT_EMAIL="receipt@example.test"),
|
||||
create_payment=AsyncMock(
|
||||
return_value={
|
||||
"id": "yk-pay-1",
|
||||
"status": "pending",
|
||||
"confirmation_url": "https://pay.example.test/1",
|
||||
}
|
||||
),
|
||||
)
|
||||
hwid_quote = {
|
||||
"device_count": 2,
|
||||
"valid_from": valid_from,
|
||||
"valid_until": valid_until,
|
||||
"pricing_period_months": 1,
|
||||
"proration_ratio": 1.0,
|
||||
"full_price": 50.0,
|
||||
}
|
||||
payment = SimpleNamespace(payment_id=123)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
yookassa.payment_dal,
|
||||
"create_payment_record",
|
||||
AsyncMock(return_value=payment),
|
||||
) as create_record,
|
||||
patch.object(
|
||||
yookassa.payment_dal,
|
||||
"update_payment_status_by_db_id",
|
||||
AsyncMock(),
|
||||
),
|
||||
):
|
||||
result = await yookassa._initiate_yk_payment(
|
||||
callback,
|
||||
settings=SimpleNamespace(),
|
||||
session=session,
|
||||
yookassa_service=service,
|
||||
i18n=_I18n(),
|
||||
current_lang="en",
|
||||
get_text=lambda key, **kwargs: key,
|
||||
user_id=42,
|
||||
months=1,
|
||||
price_rub=150,
|
||||
currency_code_for_yk="RUB",
|
||||
save_payment_method=False,
|
||||
back_callback="tariff:period:standard:1",
|
||||
sale_mode="subscription@standard|hwid_renewal",
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
record_payload = create_record.await_args.args[1]
|
||||
assert record_payload["sale_mode"] == "subscription@standard|hwid_renewal"
|
||||
assert record_payload["tariff_key"] == "standard"
|
||||
assert record_payload["purchased_hwid_devices"] == 2
|
||||
assert record_payload["hwid_valid_from"] == valid_from
|
||||
assert record_payload["hwid_valid_until"] == valid_until
|
||||
metadata = service.create_payment.await_args.kwargs["metadata"]
|
||||
assert metadata["sale_mode"] == "subscription@standard|hwid_renewal"
|
||||
assert metadata["hwid_devices"] == "2"
|
||||
assert metadata["hwid_valid_from"] == valid_from.isoformat()
|
||||
assert metadata["hwid_valid_until"] == valid_until.isoformat()
|
||||
assert metadata["hwid_pricing_period_months"] == "1"
|
||||
assert metadata["hwid_proration_ratio"] == "1.0"
|
||||
assert metadata["hwid_full_price"] == "50.0"
|
||||
assert service.create_payment.await_args.kwargs["amount"] == 150
|
||||
|
||||
async def test_webapp_hwid_metadata_activates_device_count_without_end_date(self):
|
||||
payment = SimpleNamespace(payment_id=5, status="pending_yookassa", tariff_key="standard")
|
||||
updated_payment = SimpleNamespace(payment_id=5, status="succeeded", tariff_key="standard")
|
||||
@@ -99,3 +175,110 @@ class YooKassaHwidWebhookTests(IsolatedAsyncioTestCase):
|
||||
assert activation_kwargs["traffic_gb"] is None
|
||||
update_status.assert_awaited_once()
|
||||
send_success.assert_awaited_once()
|
||||
|
||||
async def test_auto_renew_hwid_metadata_is_persisted_for_activation(self):
|
||||
valid_from = datetime(2099, 2, 1, tzinfo=timezone.utc)
|
||||
valid_until = datetime(2099, 3, 1, tzinfo=timezone.utc)
|
||||
payment = SimpleNamespace(payment_id=5, status="pending_yookassa", tariff_key="standard")
|
||||
updated_payment = SimpleNamespace(payment_id=5, status="succeeded", tariff_key="standard")
|
||||
db_user = SimpleNamespace(
|
||||
user_id=42,
|
||||
username="alice",
|
||||
language_code="en",
|
||||
referred_by_id=None,
|
||||
)
|
||||
subscription_service = SimpleNamespace(
|
||||
activate_subscription=AsyncMock(
|
||||
return_value={
|
||||
"subscription_id": 11,
|
||||
"end_date": valid_until,
|
||||
"hwid_devices_renewed_count": 2,
|
||||
"hwid_devices_valid_until": valid_until,
|
||||
}
|
||||
)
|
||||
)
|
||||
referral_service = SimpleNamespace(
|
||||
apply_referral_bonuses_for_payment=AsyncMock(return_value={})
|
||||
)
|
||||
settings = SimpleNamespace(
|
||||
traffic_sale_mode=False,
|
||||
yookassa_autopayments_active=False,
|
||||
DEFAULT_LANGUAGE="en",
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB",
|
||||
LKNPD_RECEIPT_NAME_TRAFFIC="{gb} GB",
|
||||
LKNPD_RECEIPT_NAME_SUBSCRIPTION="{months} months",
|
||||
)
|
||||
payment_info = {
|
||||
"id": "yk-auto-hwid-1",
|
||||
"status": "succeeded",
|
||||
"paid": True,
|
||||
"amount": {"value": "449.00", "currency": "RUB"},
|
||||
"metadata": {
|
||||
"user_id": "42",
|
||||
"subscription_months": "1",
|
||||
"auto_renew_for_subscription_id": "555",
|
||||
"sale_mode": "subscription@standard",
|
||||
"hwid_devices": "2",
|
||||
"hwid_valid_from": valid_from.isoformat(),
|
||||
"hwid_valid_until": valid_until.isoformat(),
|
||||
"hwid_pricing_period_months": "1",
|
||||
"hwid_proration_ratio": "1.0",
|
||||
"hwid_full_price": "50.0",
|
||||
},
|
||||
"description": "Auto-renewal for 1 months",
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
yookassa.payment_dal,
|
||||
"get_payment_by_provider_payment_id",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch.object(
|
||||
yookassa.payment_dal,
|
||||
"ensure_payment_with_provider_id",
|
||||
AsyncMock(return_value=payment),
|
||||
) as ensure_payment,
|
||||
patch.object(
|
||||
yookassa.payment_dal,
|
||||
"get_payment_by_db_id",
|
||||
AsyncMock(return_value=payment),
|
||||
),
|
||||
patch.object(
|
||||
yookassa.payment_dal,
|
||||
"update_payment_status_by_db_id",
|
||||
AsyncMock(return_value=updated_payment),
|
||||
),
|
||||
patch.object(yookassa.user_dal, "get_user_by_id", AsyncMock(return_value=db_user)),
|
||||
patch.object(
|
||||
yookassa,
|
||||
"prepare_config_links",
|
||||
AsyncMock(return_value=("link", "https://example.test/sub")),
|
||||
),
|
||||
patch.object(yookassa, "send_success_message_to_user", AsyncMock()) as send_success,
|
||||
patch.object(yookassa, "notify_admins_payment_received", AsyncMock()),
|
||||
):
|
||||
await yookassa.process_successful_payment(
|
||||
AsyncMock(),
|
||||
AsyncMock(),
|
||||
payment_info,
|
||||
_I18n(),
|
||||
settings,
|
||||
AsyncMock(),
|
||||
subscription_service,
|
||||
referral_service,
|
||||
)
|
||||
|
||||
ensure_payment.assert_awaited_once()
|
||||
ensure_kwargs = ensure_payment.await_args.kwargs
|
||||
assert ensure_kwargs["sale_mode"] == "subscription@standard"
|
||||
assert ensure_kwargs["tariff_key"] == "standard"
|
||||
assert ensure_kwargs["purchased_hwid_devices"] == 2
|
||||
assert ensure_kwargs["hwid_valid_from"] == valid_from
|
||||
assert ensure_kwargs["hwid_valid_until"] == valid_until
|
||||
assert ensure_kwargs["hwid_pricing_period_months"] == 1
|
||||
assert ensure_kwargs["hwid_proration_ratio"] == 1.0
|
||||
assert ensure_kwargs["hwid_full_price"] == 50.0
|
||||
activation_kwargs = subscription_service.activate_subscription.await_args.kwargs
|
||||
assert activation_kwargs["sale_mode"] == "subscription@standard"
|
||||
send_success.assert_awaited_once()
|
||||
|
||||
Reference in New Issue
Block a user