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:
3252a8
2026-06-03 23:51:46 +03:00
parent a06884d816
commit fbb89793cb
48 changed files with 2410 additions and 224 deletions
+7 -1
View File
@@ -1515,6 +1515,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
return _error(400, "invalid_days") return _error(400, "invalid_days")
if days <= 0: if days <= 0:
return _error(400, "invalid_days") 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") subscription_service = request.app.get("subscription_service")
if subscription_service is None: if subscription_service is None:
@@ -1527,6 +1529,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
target_id, target_id,
days, days,
"admin_extend_subscription_webapp", "admin_extend_subscription_webapp",
extend_hwid_devices=extend_hwid_devices,
) )
if not new_end: if not new_end:
await session.rollback() await session.rollback()
@@ -1537,7 +1540,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
{ {
"user_id": actor_id, "user_id": actor_id,
"event_type": "admin_extend_subscription_webapp", "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, "is_admin_event": True,
"target_user_id": target_id, "target_user_id": target_id,
}, },
+43 -22
View File
@@ -121,10 +121,11 @@ async def create_payment_route(request: web.Request) -> web.Response:
hwid_quote: Optional[Dict[str, Any]] = None hwid_quote: Optional[Dict[str, Any]] = None
requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or "")) 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 { if tariffs_config and requested_sale_mode in {
"hwid_device", "hwid_device",
"hwid_devices", "hwid_devices",
"hwid_devices_renewal",
}: }:
tariff_key = str(payment_payload.tariff_key or "").strip() tariff_key = str(payment_payload.tariff_key or "").strip()
if not tariff_key: if not tariff_key:
@@ -318,7 +319,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
user_id=user_id, user_id=user_id,
device_count=int(payment_units), device_count=int(payment_units),
tariff_key=sale_tariff_key, tariff_key=sale_tariff_key,
renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal", renewal=False,
currency=currency, currency=currency,
) )
if not hwid_quote: if not hwid_quote:
@@ -331,6 +332,25 @@ async def create_payment_route(request: web.Request) -> web.Response:
else: else:
price = float(hwid_quote["price"]) price = float(hwid_quote["price"])
stars_price = None 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 [])} 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) is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
return await _create_subscription_payment( 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") return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
lang = db_user.language_code or settings.DEFAULT_LANGUAGE lang = db_user.language_code or settings.DEFAULT_LANGUAGE
active = await subscription_service.get_active_subscription_details(session, user_id) 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 = active.get("extra_hwid_devices_valid_until") if active else None
extra_hwid_valid_until_text = ( extra_hwid_valid_until_text = (
active.get("extra_hwid_devices_valid_until_text") if active else None 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, user_id=user_id,
device_count=count, device_count=count,
tariff_key=tariff.key, tariff_key=tariff.key,
renewal=renewal_available, renewal=False,
currency=default_currency, currency=default_currency,
) )
if count in currency_counts if count in currency_counts
@@ -725,7 +744,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
user_id=user_id, user_id=user_id,
device_count=count, device_count=count,
tariff_key=tariff.key, tariff_key=tariff.key,
renewal=renewal_available, renewal=False,
currency="stars", currency="stars",
) )
if count in stars_counts 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: if not currency_quote and not stars_quote:
continue 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 = { plan = {
"id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}", "id": f"{tariff.key}:hwid:{count}",
"tariff_key": tariff.key, "tariff_key": tariff.key,
"tariff_name": tariff.name(lang), "tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model, "billing_model": tariff.billing_model,
"sale_mode": sale_mode_for_plan, "sale_mode": "hwid_devices",
"renewal": False,
"months": count, "months": count,
"device_count": count, "device_count": count,
"price": float(currency_quote.get("price") if currency_quote else 0), "price": float(currency_quote.get("price") if currency_quote else 0),
"currency": default_currency_code, "currency": default_currency_code,
"title": f"+{count}", "title": f"+{count}",
"subtitle": tariff.name(lang), "subtitle": tariff.name(lang),
"valid_from": _billing_iso_datetime( "valid_from": _billing_iso_datetime(valid_from),
(currency_quote or stars_quote).get("valid_from") "valid_from_text": _billing_datetime_text(valid_from),
), "valid_until": _billing_iso_datetime(valid_until),
"valid_until": _billing_iso_datetime( "valid_until_text": _billing_datetime_text(valid_until),
(currency_quote or stars_quote).get("valid_until") "proration_ratio": float(quote.get("proration_ratio") or 0),
),
"proration_ratio": float(
(currency_quote or stars_quote).get("proration_ratio") or 0
),
} }
if stars_quote and int(stars_quote.get("price") or 0) > 0: if stars_quote and int(stars_quote.get("price") or 0) > 0:
plan["stars_price"] = int(stars_quote["price"]) 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), 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": _billing_iso_datetime(extra_hwid_valid_until),
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text, "extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
"renewal_available": renewal_available, "renewal_available": False,
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0) "renewal_recommended_count": 0,
if active and renewal_available
else 0,
"plans": plans, "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_yookassa_payment_status(request, session, payment)
payment = await _refresh_wata_payment_status(request, session, payment) payment = await _refresh_wata_payment_status(request, session, payment)
if payment.status == "succeeded": 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( return web.json_response(
{ {
"ok": True, "ok": True,
@@ -1037,6 +1057,7 @@ async def _create_subscription_payment(
description=description, description=description,
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=traffic_gb, 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_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_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
hwid_pricing_period_months=hwid_quote.get("pricing_period_months") hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
+1
View File
@@ -49,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel):
device_count: Any = None device_count: Any = None
tariff_key: Optional[constr(max_length=128)] = None tariff_key: Optional[constr(max_length=128)] = None
sale_mode: Optional[constr(max_length=64)] = None sale_mode: Optional[constr(max_length=64)] = None
renew_hwid_devices: Optional[bool] = None
description: Optional[constr(max_length=4096)] = None description: Optional[constr(max_length=4096)] = None
comment: Optional[constr(max_length=4096)] = None comment: Optional[constr(max_length=4096)] = None
note: Optional[constr(max_length=4096)] = None note: Optional[constr(max_length=4096)] = None
+115 -9
View File
@@ -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 settings.TRIAL_DURATION_DAYS > 0
and not await subscription_service.has_trial_blocking_subscription(session, user_id) 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) avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
try: try:
await session.commit() await session.commit()
except Exception: except Exception:
await session.rollback() 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 [])} 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) is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
telegram_notifications_status = normalize_telegram_notification_status( 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), "bonus_details": _serialize_referral_bonus_details(settings, lang),
}, },
"plans": _serialize_plans( "plans": plans_payload,
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"],
),
"payment_methods": _serialize_payment_methods( "payment_methods": _serialize_payment_methods(
settings, settings,
request.app, 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( def _build_install_share_link(
request: Optional[web.Request], request: Optional[web.Request],
settings: Settings, settings: Settings,
+32 -10
View File
@@ -319,7 +319,11 @@ async def select_tariff_callback(
@router.callback_query(F.data.startswith("tariff:period:")) @router.callback_query(F.data.startswith("tariff:period:"))
async def select_tariff_period_callback( 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) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance") 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) await callback.answer(get_text("error_try_again"), show_alert=True)
return return
tariff_key, months_raw = parts[2], parts[3] 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) tariff = config.require(tariff_key)
months = int(months_raw) months = int(months_raw)
default_currency = default_currency_key_for_settings(settings) default_currency = default_currency_key_for_settings(settings)
@@ -343,6 +349,22 @@ async def select_tariff_period_callback(
if price_rub is None: if price_rub is None:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
return 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( markup = get_payment_method_keyboard(
months, months,
price_rub, 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), 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)}", back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
user_id=callback.from_user.id, 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.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
await callback.answer() await callback.answer()
@@ -577,7 +602,6 @@ async def hwid_devices_list_callback(
if not packages: if not packages:
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True) await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
return return
renewal_available = bool(active.get("device_topup_renewal_available"))
markup = get_hwid_device_packages_keyboard( markup = get_hwid_device_packages_keyboard(
tariff, tariff,
packages, packages,
@@ -585,14 +609,11 @@ async def hwid_devices_list_callback(
i18n, i18n,
settings, settings,
back_callback="main_action:my_devices", back_callback="main_action:my_devices",
renewal=renewal_available, renewal=False,
)
text_key = (
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
) )
await callback.message.edit_text( await callback.message.edit_text(
get_text( get_text(
text_key, "select_hwid_device_package",
date=active.get("extra_hwid_devices_valid_until_text") or "", date=active.get("extra_hwid_devices_valid_until_text") or "",
), ),
reply_markup=markup, reply_markup=markup,
@@ -640,6 +661,7 @@ async def hwid_devices_package_callback(
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
return return
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices" 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) default_currency = default_currency_key_for_settings(settings)
currency_code = default_payment_currency_code_for_settings(settings) currency_code = default_payment_currency_code_for_settings(settings)
currency_quote = await subscription_service.quote_hwid_device_topup( 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, user_id=callback.from_user.id,
device_count=count, device_count=count,
tariff_key=tariff.key, tariff_key=tariff.key,
renewal=action == "renewal_package", renewal=renewal,
currency=default_currency, currency=default_currency,
) )
stars_quote = await subscription_service.quote_hwid_device_topup( 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, user_id=callback.from_user.id,
device_count=count, device_count=count,
tariff_key=tariff.key, tariff_key=tariff.key,
renewal=action == "renewal_package", renewal=renewal,
currency="stars", currency="stars",
) )
if not currency_quote and not stars_quote: if not currency_quote and not stars_quote:
+69 -12
View File
@@ -14,6 +14,13 @@ from config.tariffs_config import (
) )
BOT_MENU_CONTEXT = "bot" 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]: 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: def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str:
sale_mode = sale_mode or "subscription" 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 sale_mode
return f"{sale_mode}|{context}" 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]: def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]:
if not sale_mode or "|" not in sale_mode: tokens = sale_mode_tokens(sale_mode)
return None return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None
context = str(sale_mode).split("|", 1)[1].strip()
return context or None
def callback_suffix_for_context(context: Optional[str]) -> str: def callback_suffix_for_context(context: Optional[str]) -> str:
@@ -484,6 +511,9 @@ def get_payment_method_keyboard(
back_callback: Optional[str] = None, back_callback: Optional[str] = None,
user_id: Optional[int] = None, user_id: Optional[int] = None,
is_admin: Optional[bool] = 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: ) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
@@ -492,12 +522,39 @@ def get_payment_method_keyboard(
return str(int(val)) if float(val).is_integer() else f"{val:g}" return str(int(val)) if float(val).is_integer() else f"{val:g}"
value_str = _format_value(months) value_str = _format_value(months)
import logging as _kbd_logging payment_sale_mode = sale_mode
selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote
_kbd_logging.info( if selected_hwid_quote:
"payment_method_keyboard build: order=%s", tariff_key = None
settings.payment_methods_order, 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 from bot.payment_providers import get_provider_spec, provider_telegram_button_text
for method in settings.payment_methods_order: for method in settings.payment_methods_order:
@@ -518,7 +575,7 @@ def get_payment_method_keyboard(
value=value_str, value=value_str,
rub_price=price, rub_price=price,
stars_price=stars_price, stars_price=stars_price,
sale_mode=sale_mode, sale_mode=payment_sale_mode,
) )
if not callback_data: if not callback_data:
continue continue
@@ -577,7 +634,7 @@ def get_yk_autopay_choice_keyboard(
builder.row( builder.row(
InlineKeyboardButton( InlineKeyboardButton(
text=_(key="yookassa_autopay_pay_saved_card_button"), 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( builder.row(
+1
View File
@@ -114,6 +114,7 @@ class WebAppPaymentContext:
sale_mode: str sale_mode: str
currency: str = "RUB" currency: str = "RUB"
traffic_gb: Optional[float] = None traffic_gb: Optional[float] = None
hwid_device_count: Optional[int] = None
hwid_valid_from: Optional[Any] = None hwid_valid_from: Optional[Any] = None
hwid_valid_until: Optional[Any] = None hwid_valid_until: Optional[Any] = None
hwid_pricing_period_months: Optional[int] = None hwid_pricing_period_months: Optional[int] = None
+8 -1
View File
@@ -192,6 +192,7 @@ class CryptoPayService:
sale_mode: str = "subscription", sale_mode: str = "subscription",
url_kind: str = "bot", url_kind: str = "bot",
hwid_quote: Optional[dict] = None, hwid_quote: Optional[dict] = None,
hwid_device_count: Optional[int] = None,
currency: Optional[str] = None, currency: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
if not self.configured or not self.client: if not self.configured or not self.client:
@@ -210,7 +211,11 @@ class CryptoPayService:
return None return None
sale_base = sale_mode_base(sale_mode) 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: try:
payment_record = await payment_dal.create_payment_record( payment_record = await payment_dal.create_payment_record(
session, session,
@@ -252,6 +257,7 @@ class CryptoPayService:
"payment_db_id": str(payment_record.payment_id), "payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode, "sale_mode": sale_mode,
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None, "traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
"hwid_devices": amounts.purchased_hwid_devices,
} }
) )
try: try:
@@ -513,6 +519,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
} }
if ctx.hwid_valid_from and ctx.hwid_valid_until if ctx.hwid_valid_from and ctx.hwid_valid_until
else None, else None,
hwid_device_count=ctx.hwid_device_count,
) )
if not url: if not url:
return payment_failed() return payment_failed()
+1
View File
@@ -609,6 +609,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
months=ctx.months, months=ctx.months,
sale_mode=ctx.sale_mode, sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb, traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
) )
payment = await create_webapp_payment_record( payment = await create_webapp_payment_record(
ctx, ctx,
@@ -8,8 +8,10 @@ from aiogram import types
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import ( from bot.keyboards.inline.user_keyboards import (
HWID_RENEWAL_TOKEN,
get_payment_url_keyboard, get_payment_url_keyboard,
payment_methods_back_callback, payment_methods_back_callback,
sale_mode_has_token,
) )
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from db.dal import payment_dal from db.dal import payment_dal
@@ -123,6 +125,27 @@ async def quote_hwid_callback_parts(
subscription_service, subscription_service,
currency: str = "rub", currency: str = "rub",
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]: ) -> 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): if not sale_mode_is_hwid_devices(parts.sale_mode):
return parts, None return parts, None
device_count = parse_positive_int_units(parts.months) device_count = parse_positive_int_units(parts.months)
+15 -3
View File
@@ -100,6 +100,11 @@ def build_payment_record_payload(
base = sale_mode_base(sale_mode) base = sale_mode_base(sale_mode)
is_traffic = sale_mode_is_traffic(sale_mode) is_traffic = sale_mode_is_traffic(sale_mode)
is_hwid = sale_mode_is_hwid_devices(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 = { payload = {
"user_id": user_id, "user_id": user_id,
"amount": amount, "amount": amount,
@@ -111,9 +116,9 @@ def build_payment_record_payload(
"sale_mode": sale_mode, "sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode), "tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None, "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( payload.update(
{ {
"hwid_valid_from": hwid_quote.get("valid_from"), "hwid_valid_from": hwid_quote.get("valid_from"),
@@ -164,14 +169,20 @@ def payment_record_amounts(
months: Any, months: Any,
sale_mode: str, sale_mode: str,
traffic_gb: Optional[float] = None, traffic_gb: Optional[float] = None,
hwid_device_count: Optional[int] = None,
) -> PaymentRecordAmounts: ) -> PaymentRecordAmounts:
traffic_sale = sale_mode_is_traffic(sale_mode) traffic_sale = sale_mode_is_traffic(sale_mode)
hwid_devices_sale = sale_mode_is_hwid_devices(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 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( return PaymentRecordAmounts(
months=int(float(units)) if traffic_sale else int(float(months)), months=int(float(units)) if traffic_sale else int(float(months)),
purchased_gb=float(units) if traffic_sale else None, 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), tariff_key=sale_mode_tariff_key(sale_mode),
traffic_sale=traffic_sale, traffic_sale=traffic_sale,
hwid_devices_sale=hwid_devices_sale, hwid_devices_sale=hwid_devices_sale,
@@ -281,6 +292,7 @@ async def create_webapp_payment_record(
months=ctx.months, months=ctx.months,
sale_mode=ctx.sale_mode, sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb, traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
) )
return await create_base_payment_record( return await create_base_payment_record(
ctx.session, ctx.session,
@@ -156,6 +156,28 @@ def append_hwid_renewal_note(
return f"{text}\n\n{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( async def send_success_message_to_user(
*, *,
bot: Bot, bot: Bot,
@@ -320,8 +342,37 @@ async def finalize_successful_payment(
req.log_prefix, req.log_prefix,
req.payment.payment_id, 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 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( db_user, language = await resolve_user_language(
req.session, req.session,
user_id=req.user_id, user_id=req.user_id,
@@ -363,6 +414,14 @@ async def finalize_successful_payment(
) )
) )
if is_subscription and activation: if is_subscription and activation:
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 = append_hwid_renewal_note(
success_text, success_text,
translator, translator,
@@ -51,7 +51,7 @@ async def notify_user_payment_failed(
message_key: str = "payment_failed", message_key: str = "payment_failed",
) -> None: ) -> None:
"""Send the localized ``payment_failed`` text to the user; never raises.""" """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 = ( language = (
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
) )
+1
View File
@@ -344,6 +344,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
months=ctx.months, months=ctx.months,
sale_mode=ctx.sale_mode, sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb, traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
) )
payment = await create_webapp_payment_record( payment = await create_webapp_payment_record(
ctx, ctx,
+1
View File
@@ -978,6 +978,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
months=ctx.months, months=ctx.months,
sale_mode=ctx.sale_mode, sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb, traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
) )
months_for_lookup = ( months_for_lookup = (
reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None
+169 -36
View File
@@ -448,6 +448,36 @@ def _metadata_value_present(value: Optional[Any]) -> bool:
return value is not None and str(value).strip() != "" 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( def _resolve_yookassa_activation_amounts(
*, *,
sale_mode_base: str, 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 months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
payment_value = float(amount_data.get("value", 0.0)) payment_value = float(amount_data.get("value", 0.0))
yk_payment_id_from_hook = payment_info_from_webhook.get("id") 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: if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0:
logging.error( logging.error(
@@ -574,6 +609,19 @@ async def process_successful_payment(
yk_payment_id_from_hook, yk_payment_id_from_hook,
) )
return 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 payment_record = None
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists # 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", or f"Auto-renewal for {months_for_record or subscription_months} months",
provider="yookassa", provider="yookassa",
provider_payment_id=yk_payment_id_from_hook, 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 payment_db_id = payment_record.payment_id
except Exception as e_ensure: except Exception as e_ensure:
@@ -1315,6 +1373,36 @@ def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
return None 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( def _format_saved_payment_method_title(
get_text, network: Optional[str], last4: Optional[str], is_default: bool get_text, network: Optional[str], last4: Optional[str], is_default: bool
) -> str: ) -> str:
@@ -1363,6 +1451,9 @@ async def _initiate_yk_payment(
return False return False
sale_base = _sale_mode_base(sale_mode) 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 = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months)) get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
@@ -1379,12 +1470,14 @@ async def _initiate_yk_payment(
"status": "pending_yookassa", "status": "pending_yookassa",
"description": payment_description, "description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None, "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, "tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None,
"purchased_gb": float(months) "purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"} if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None, 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_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_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months") "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) yookassa_metadata["traffic_gb"] = str(months)
if sale_base in HWID_DEVICE_SALE_BASES: if sale_base in HWID_DEVICE_SALE_BASES:
yookassa_metadata["hwid_devices"] = str(months) 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: if payment_method_id:
yookassa_metadata["used_saved_payment_method_id"] = 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 months, price_rub, sale_mode = parsed
hwid_quote = None 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 user_id = callback.from_user.id
currency_code_for_yk = default_payment_currency_code_for_settings(settings) currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool( autopay_enabled = bool(
@@ -1786,6 +1876,22 @@ async def pay_yk_callback_handler(
pass pass
return 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( await _initiate_yk_payment(
callback, callback,
settings=settings, settings=settings,
@@ -1863,6 +1969,22 @@ async def pay_yk_new_card_handler(
return return
months, price_rub, sale_mode = parsed 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 user_id = callback.from_user.id
currency_code_for_yk = default_payment_currency_code_for_settings(settings) currency_code_for_yk = default_payment_currency_code_for_settings(settings)
autopay_enabled = bool( autopay_enabled = bool(
@@ -1889,6 +2011,7 @@ async def pay_yk_new_card_handler(
save_payment_method=autopay_enabled and autopay_require_binding, save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub), back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
sale_mode=sale_mode, sale_mode=sale_mode,
hwid_quote=hwid_quote,
) )
try: try:
await callback.answer() await callback.answer()
@@ -1928,27 +2051,15 @@ async def pay_yk_saved_list_handler(
pass pass
return return
parts = data_payload.split(":") parsed_saved_list = _parse_saved_list_payload(data_payload)
if len(parts) < 2: if not parsed_saved_list:
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
try: try:
await callback.answer(get_text("error_try_again"), show_alert=True) await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception: except Exception:
pass pass
return return
months, price_rub, page, sale_mode = parsed_saved_list
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
autopay_enabled = bool( autopay_enabled = bool(
settings.yookassa_autopayments_active settings.yookassa_autopayments_active
@@ -2138,6 +2249,24 @@ async def pay_yk_use_saved_handler(
method_identifier = parts[2] method_identifier = parts[2]
user_id = callback.from_user.id 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: try:
saved_methods = await user_billing_dal.list_user_payment_methods( 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, price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk, currency_code_for_yk=currency_code_for_yk,
save_payment_method=False, 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, payment_method_id=selected_method.provider_payment_method_id,
selected_method_internal_id=selected_method.method_id, selected_method_internal_id=selected_method.method_id,
sale_mode=sale_mode, sale_mode=sale_mode,
hwid_quote=hwid_quote,
) )
try: try:
await callback.answer() await callback.answer()
@@ -2754,6 +2886,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
months=ctx.months, months=ctx.months,
sale_mode=ctx.sale_mode, sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb, traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
) )
payment = await create_webapp_payment_record( payment = await create_webapp_payment_record(
ctx, ctx,
@@ -2775,8 +2908,8 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
} }
if amounts.traffic_sale: if amounts.traffic_sale:
metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months) metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months)
if amounts.hwid_devices_sale: if amounts.purchased_hwid_devices:
metadata["hwid_devices"] = str(int(float(ctx.months))) metadata["hwid_devices"] = str(int(amounts.purchased_hwid_devices))
if amounts.tariff_key: if amounts.tariff_key:
metadata["tariff_key"] = amounts.tariff_key metadata["tariff_key"] = amounts.tariff_key
response = await service.create_payment( response = await service.create_payment(
@@ -116,6 +116,64 @@ class HwidDeviceMixin:
packages = package_set.for_currency(currency) packages = package_set.for_currency(currency)
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None) 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( def _quote_hwid_package_price(
self, self,
*, *,
@@ -128,16 +186,10 @@ class HwidDeviceMixin:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
period_months = max(1, int(getattr(sub, "duration_months", None) or 1)) period_months = max(1, int(getattr(sub, "duration_months", None) or 1))
full_price = float(package.price_for_period(period_months)) full_price = float(package.price_for_period(period_months))
period_start = self._as_aware_utc(getattr(sub, "start_date", None)) basis_seconds = max(1.0, float(period_months * 30 * 24 * 60 * 60))
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())
billable_start = max(now, valid_from) billable_start = max(now, valid_from)
billable_seconds = max(0.0, (valid_until - billable_start).total_seconds()) 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 raw_price = full_price * ratio
price = self._round_hwid_price(raw_price, currency=currency) price = self._round_hwid_price(raw_price, currency=currency)
min_price = getattr(package, "min_price", None) min_price = getattr(package, "min_price", None)
@@ -230,6 +282,80 @@ class HwidDeviceMixin:
) )
return quote 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( async def activate_hwid_device_topup(
self, self,
session: AsyncSession, session: AsyncSession,
@@ -178,6 +178,7 @@ class SubscriptionLifecycleMixin:
user_id: int, user_id: int,
target_tariff_key: str, target_tariff_key: str,
mode: str, mode: str,
payment_id: Optional[int] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
config = self._tariffs_config() config = self._tariffs_config()
if not config: if not config:
@@ -336,7 +337,7 @@ class SubscriptionLifecycleMixin:
"from_tariff_key": before_tariff_key, "from_tariff_key": before_tariff_key,
"to_tariff_key": target.key, "to_tariff_key": target.key,
"mode": mode, "mode": mode,
"payment_id": None, "payment_id": payment_id,
"days_before": options.get("remaining_days"), "days_before": options.get("remaining_days"),
"days_after": (updated.end_date - now).days "days_after": (updated.end_date - now).days
if updated.end_date and target.billing_model == "period" if updated.end_date and target.billing_model == "period"
@@ -454,27 +455,11 @@ class SubscriptionLifecycleMixin:
user_id, user_id,
tariff_key, tariff_key,
"paid_diff", "paid_diff",
payment_id=payment_db_id,
) )
if result: if result:
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
if sub: 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["end_date"] = sub.end_date
result["is_active"] = sub.is_active result["is_active"] = sub.is_active
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
@@ -494,10 +479,29 @@ class SubscriptionLifecycleMixin:
await self._record_payment_context( await self._record_payment_context(
session, session,
payment_db_id, payment_db_id,
sale_mode=sale_mode_base, sale_mode=sale_mode,
tariff_key=tariff.key if tariff else tariff_key, tariff_key=tariff.key if tariff else tariff_key,
purchased_gb=None, 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) db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user: if not db_user:
@@ -569,6 +573,26 @@ class SubscriptionLifecycleMixin:
promo_code_id_from_payment = None promo_code_id_from_payment = None
final_end_date = start_date + timedelta(days=duration_days_total) 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( await subscription_dal.deactivate_other_active_subscriptions(
session, panel_user_uuid, panel_sub_link_id session, panel_user_uuid, panel_sub_link_id
) )
@@ -614,7 +638,8 @@ class SubscriptionLifecycleMixin:
premium_topup_balance_bytes, premium_topup_balance_bytes,
premium_topup_used_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_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)) regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False))
traffic_limit_bytes = self._traffic_limit_for_period_tariff( traffic_limit_bytes = self._traffic_limit_for_period_tariff(
@@ -698,6 +723,31 @@ class SubscriptionLifecycleMixin:
final_subscription_url = updated_panel_user.get("subscriptionUrl") final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) 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( await self._send_payment_success_email(
db_user=db_user, db_user=db_user,
@@ -718,8 +768,12 @@ class SubscriptionLifecycleMixin:
"subscription_url": final_subscription_url, "subscription_url": final_subscription_url,
"applied_promo_bonus_days": applied_promo_bonus_days, "applied_promo_bonus_days": applied_promo_bonus_days,
"tariff_key": tariff.key if tariff else None, "tariff_key": tariff.key if tariff else None,
"hwid_devices_renewal_recommended_count": extra_hwid_devices, "hwid_devices_renewal_recommended_count": 0
"hwid_devices_valid_until": hwid_devices_valid_until, 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( async def extend_active_subscription_days(
@@ -728,6 +782,7 @@ class SubscriptionLifecycleMixin:
user_id: int, user_id: int,
bonus_days: int, bonus_days: int,
reason: str = "bonus", reason: str = "bonus",
extend_hwid_devices: bool = True,
) -> Optional[datetime]: ) -> Optional[datetime]:
reason_lower = (reason or "").lower() reason_lower = (reason or "").lower()
apply_main_traffic_limit = any( apply_main_traffic_limit = any(
@@ -798,6 +853,21 @@ class SubscriptionLifecycleMixin:
updated_sub_model = await subscription_dal.update_subscription_end_date( updated_sub_model = await subscription_dal.update_subscription_end_date(
session, active_sub.subscription_id, new_end_date_obj 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 ( if (
apply_main_traffic_limit apply_main_traffic_limit
@@ -38,6 +38,7 @@ class PaymentContextMixin:
payment.sale_mode = sale_mode payment.sale_mode = sale_mode
payment.tariff_key = tariff_key payment.tariff_key = tariff_key
payment.purchased_gb = purchased_gb payment.purchased_gb = purchased_gb
if purchased_hwid_devices is not None:
payment.purchased_hwid_devices = purchased_hwid_devices payment.purchased_hwid_devices = purchased_hwid_devices
if hwid_valid_from is not None: if hwid_valid_from is not None:
payment.hwid_valid_from = hwid_valid_from payment.hwid_valid_from = hwid_valid_from
@@ -42,6 +42,8 @@ class RenewalMixin:
months = sub.duration_months or 1 months = sub.duration_months or 1
currency = default_payment_currency_code_for_settings(self.settings) 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 amount = None
tariffs_config = ( tariffs_config = (
self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None 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") logging.error(f"Auto-renew price missing for {months} months")
return False 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 = { metadata = {
"user_id": str(sub.user_id), "user_id": str(sub.user_id),
"auto_renew_for_subscription_id": str(sub.subscription_id), "auto_renew_for_subscription_id": str(sub.subscription_id),
"subscription_months": str(months), "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( resp = await yk.create_payment(
amount=float(amount), amount=float(amount),
currency=currency, currency=currency,
+23
View File
@@ -51,6 +51,15 @@ async def ensure_payment_with_provider_id(
description: str, description: str,
provider: str, provider: str,
provider_payment_id: 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: ) -> Payment:
"""Idempotently create a payment record for a provider event. """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_payment_id": provider_payment_id,
"provider": provider, "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) return await create_payment_record(session, payment_payload)
+58 -1
View File
@@ -1,5 +1,5 @@
import inspect import inspect
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy import and_, delete, func, or_, select, update
@@ -189,6 +189,63 @@ async def expire_hwid_device_purchases(
return result.rowcount or 0 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( async def create_tariff_change(
session: AsyncSession, session: AsyncSession,
change_data: Dict[str, Any], change_data: Dict[str, Any],
+1 -1
View File
@@ -18,7 +18,7 @@ Minishop отправляет уведомления в Telegram и на email.
| Успешная покупка отдельного пакета трафика | Пользователь | ✓ | ✓ | Для `traffic` / `traffic_package`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная покупка отдельного пакета трафика | Пользователь | ✓ | ✓ | Для `traffic` / `traffic_package`; email отправляется, если SMTP настроен и у пользователя есть email. |
| Успешная докупка обычного трафика к тарифу | Пользователь | ✓ | ✓ | Для `topup`; email отправляется, если SMTP настроен и у пользователя есть email. | | Успешная докупка обычного трафика к тарифу | Пользователь | ✓ | ✓ | Для `topup`; email отправляется, если SMTP настроен и у пользователя есть email. |
| Успешная покупка premium-трафика | Пользователь | ✓ | ✓ | Для `premium_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. | | Платное повышение тарифа | Пользователь | ✓ | ✓ | Для `tariff_upgrade`; email отправляется, если SMTP настроен и у пользователя есть email. |
| Способ оплаты YooKassa привязан | Пользователь | ✓ | ✓ | Отправляется после успешного сохранения платежного метода через webhook YooKassa; email отправляется, если SMTP настроен и у пользователя есть email. | | Способ оплаты YooKassa привязан | Пользователь | ✓ | ✓ | Отправляется после успешного сохранения платежного метода через webhook YooKassa; email отправляется, если SMTP настроен и у пользователя есть email. |
| Ошибка оплаты по webhook провайдера | Пользователь | ✓ | ✓ | Отправляется, когда платежный провайдер сообщает о неуспешном платеже; email отправляется, если SMTP настроен и у пользователя есть email. | | Ошибка оплаты по webhook провайдера | Пользователь | ✓ | ✓ | Отправляется, когда платежный провайдер сообщает о неуспешном платеже; email отправляется, если SMTP настроен и у пользователя есть email. |
+4 -1
View File
@@ -280,7 +280,10 @@ limit_after = current_used + balance_after
- полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`; - полная цена HWID-пакета берется из `prices[duration_months]`; если периода нет, используется fallback `price * duration_months`;
- фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe; - фактическая цена докупки считается пропорционально оплачиваемому окну `valid_from -> valid_until` относительно периода подписки и фиксируется в платежe;
- для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода; - для Telegram Stars цена округляется вверх до целого Stars, для платежной валюты — вверх до копеек; `min_price` защищает от микроплатежей в конце периода;
- при продлении подписки докупленные устройства не продлеваются автоматически: старая докупка действует до прежнего `end_date`, а для нового срока создается отдельная `hwid_devices_renewal`-покупка; - кнопка докупки устройств всегда покупает устройства только для текущей активной подписки и только до текущего срока ее действия;
- при продлении подписки пользователь видит отдельный чекбокс продления действующих докупленных устройств; чекбокс включен по умолчанию, цена считается по текущему тарифу и добавляется в тот же платеж подписки;
- если пользователь продлил подписку без продления устройств, старая докупка продолжает действовать до своего `valid_until`, а Web App показывает предупреждение о возможном временном возврате к базовому лимиту;
- админские продления, промокоды и реферальные бонусы добавляют фиксированное количество дней отдельно к подписке и к действующим докупкам устройств, не склеивая даты окончания;
- `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки; - `traffic`-тарифы не показывают и не принимают докупку HWID-устройств, потому что у них нет срока подписки;
- при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`; - при смене тарифа базовый лимит берется из целевого тарифа, а неиспользованная стоимость HWID-докупок в платежной валюте конвертируется в дни нового period-тарифа или GB traffic-тарифа; XTR/Stars-докупки не конвертируются без явного курса и продолжают жить по своему `valid_until`;
- история докупок пишется в `hwid_device_purchases`; - история докупок пишется в `hwid_device_purchases`;
+2
View File
@@ -1520,6 +1520,7 @@
selectedPlan: null, selectedPlan: null,
selectedTariffKey: "", selectedTariffKey: "",
paymentStep: "tariff", paymentStep: "tariff",
renewHwidDevices: true,
selectedMethod: payload.payment_methods?.[0]?.id || "", selectedMethod: payload.payment_methods?.[0]?.id || "",
})); }));
const currentQuery = currentSearchParams(); const currentQuery = currentSearchParams();
@@ -2421,6 +2422,7 @@
bind:paymentStep={$billingStore.paymentStep} bind:paymentStep={$billingStore.paymentStep}
bind:selectedMethod={$billingStore.selectedMethod} bind:selectedMethod={$billingStore.selectedMethod}
bind:selectedPlan={$billingStore.selectedPlan} bind:selectedPlan={$billingStore.selectedPlan}
bind:renewHwidDevices={$billingStore.renewHwidDevices}
bind:selectedTariffKey={$billingStore.selectedTariffKey} bind:selectedTariffKey={$billingStore.selectedTariffKey}
bind:setPasswordCode={$accountStore.setPasswordCode} bind:setPasswordCode={$accountStore.setPasswordCode}
bind:setPasswordConfirm={$accountStore.setPasswordConfirm} bind:setPasswordConfirm={$accountStore.setPasswordConfirm}
@@ -759,6 +759,41 @@
{at("user_btn_extend", {}, "Продлить")} {at("user_btn_extend", {}, "Продлить")}
</AdminButton> </AdminButton>
</div> </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> </Label.Root>
</div> </div>
+3 -1
View File
@@ -21,6 +21,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
userDetailLoading: false, userDetailLoading: false,
userMessageDraft: "", userMessageDraft: "",
userExtendDays: 30, userExtendDays: 30,
userExtendHwidDevices: true,
userActionBusy: false, userActionBusy: false,
userDeleteOpen: false, userDeleteOpen: false,
userBanConfirmOpen: false, userBanConfirmOpen: false,
@@ -133,6 +134,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
userMessageDraft: "", userMessageDraft: "",
userMessageConfirmOpen: false, userMessageConfirmOpen: false,
userExtendDays: 30, userExtendDays: 30,
userExtendHwidDevices: true,
userDetailLoading: true, userDetailLoading: true,
userDetailTab: "subscription", userDetailTab: "subscription",
userReferralsOpen: false, userReferralsOpen: false,
@@ -455,7 +457,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
try { try {
const res = await api(`/admin/users/${s.openedUser.user_id}/extend`, { const res = await api(`/admin/users/${s.openedUser.user_id}/extend`, {
method: "POST", method: "POST",
body: JSON.stringify({ days }), body: JSON.stringify({ days, extend_hwid_devices: Boolean(s.userExtendHwidDevices) }),
}); });
if (res?.ok) { if (res?.ok) {
onToast(at("subscription_extended", { days }, `Продлено на ${days} д.`)); onToast(at("subscription_extended", { days }, `Продлено на ${days} д.`));
+2 -1
View File
@@ -27,13 +27,14 @@ export function createBillingActions({ api }) {
return api("/tariffs/change-payment", { method: "POST", body: JSON.stringify(body) }); return api("/tariffs/change-payment", { method: "POST", body: JSON.stringify(body) });
} }
function planPaymentBody(plan, method) { function planPaymentBody(plan, method, options = {}) {
return { return {
months: plan.months, months: plan.months,
traffic_gb: plan.traffic_gb, traffic_gb: plan.traffic_gb,
device_count: plan.device_count, device_count: plan.device_count,
tariff_key: plan.tariff_key, tariff_key: plan.tariff_key,
sale_mode: plan.sale_mode, sale_mode: plan.sale_mode,
renew_hwid_devices: Boolean(options.renewHwidDevices),
method, method,
}; };
} }
+40 -36
View File
@@ -127478,22 +127478,22 @@ export const DEMO_DATASET = {
audience: "user", audience: "user",
values: { values: {
ru: { ru: {
base: "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", base: "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.",
fallback: fallback:
"Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.",
effective: effective:
"Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.",
override: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
updated_by: null, updated_by: null,
}, },
en: { 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: fallback:
"Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.",
effective: 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: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
@@ -127506,22 +127506,22 @@ export const DEMO_DATASET = {
audience: "user", audience: "user",
values: { values: {
ru: { ru: {
base: "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", base: "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.",
fallback: fallback:
"Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.",
effective: effective:
"Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.",
override: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
updated_by: null, updated_by: null,
}, },
en: { 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: fallback:
"Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", "Текущая докупка +{count} устройств действует до {date}. Продление устройств выполняется вместе с продлением подписки.",
effective: 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: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
@@ -127534,18 +127534,22 @@ export const DEMO_DATASET = {
audience: "user", audience: "user",
values: { values: {
ru: { ru: {
base: "Подписка продлена. Докупите устройства для нового срока.", base: "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.",
fallback: "Подписка продлена. Докупите устройства для нового срока.", fallback:
effective: "Подписка продлена. Докупите устройства для нового срока.", "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.",
effective:
"Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.",
override: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
updated_by: null, updated_by: null,
}, },
en: { en: {
base: "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: "Подписка продлена. Докупите устройства для нового срока.", fallback:
effective: "Subscription renewed. Buy devices again for the new period.", "Подписка продлена. Если устройства не продлевались вместе с ней, текущая докупка действует до своей даты окончания.",
effective:
"Subscription renewed. If devices were not renewed with it, the current device top-up remains valid until its own end date.",
override: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
@@ -134134,22 +134138,22 @@ export const DEMO_DATASET = {
audience: "user", audience: "user",
values: { values: {
ru: { ru: {
base: "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", base: "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.",
fallback: fallback:
"У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.",
effective: effective:
"У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.",
override: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
updated_by: null, updated_by: null,
}, },
en: { 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: fallback:
"У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.",
effective: 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: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
@@ -135835,22 +135839,22 @@ export const DEMO_DATASET = {
audience: "user", audience: "user",
values: { values: {
ru: { ru: {
base: "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", base: "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.",
fallback: fallback:
"У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.",
effective: effective:
"У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.",
override: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
updated_by: null, updated_by: null,
}, },
en: { 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: fallback:
"У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.",
effective: 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: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
@@ -139676,22 +139680,22 @@ export const DEMO_DATASET = {
audience: "user", audience: "user",
values: { values: {
ru: { ru: {
base: "Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.", base: "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.",
fallback: fallback:
"Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.", "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.",
effective: effective:
"Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.", "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.",
override: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
updated_by: null, updated_by: null,
}, },
en: { 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: fallback:
"Выберите пакет HWID устройств для нового срока подписки. Текущая докупка действует до {date}.", "Продление докупленных HWID устройств выполняется вместе с продлением подписки. Текущая докупка действует до {date}.",
effective: 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: "", override: "",
overridden: false, overridden: false,
updated_at: null, updated_at: null,
+9 -10
View File
@@ -18,6 +18,7 @@ export function createBillingStore({
selectedTariffKey: "", selectedTariffKey: "",
selectedPlan: null, selectedPlan: null,
selectedMethod: "", selectedMethod: "",
renewHwidDevices: true,
paymentStartedWithActiveSubscription: false, paymentStartedWithActiveSubscription: false,
topupModalOpen: false, topupModalOpen: false,
topupKind: "regular", topupKind: "regular",
@@ -73,15 +74,7 @@ export function createBillingStore({
paymentPollToken += 1; paymentPollToken += 1;
} }
showToast(t("wa_payment_success", {}, "Payment successful")); showToast(t("wa_payment_success", {}, "Payment successful"));
const payload = await loadData({ fresh: true }); await loadData({ fresh: true });
if (
successContext.renewalSubscriptionPayment &&
payload?.subscription?.device_topup_renewal_available &&
payload?.subscription?.can_topup_devices
) {
showToast(t("wa_hwid_devices_renewal_prompt"));
openDeviceTopupModal(payload.payment_methods?.[0]?.id || "");
}
if ( if (
successContext.initialSubscriptionPayment && successContext.initialSubscriptionPayment &&
typeof onSubscriptionActivated === "function" typeof onSubscriptionActivated === "function"
@@ -163,6 +156,7 @@ export function createBillingStore({
selectedTariffKey: tariffKey, selectedTariffKey: tariffKey,
selectedPlan: plan, selectedPlan: plan,
selectedMethod: s.selectedMethod || defaultMethod, selectedMethod: s.selectedMethod || defaultMethod,
renewHwidDevices: true,
paymentStartedWithActiveSubscription: Boolean(subscription?.active), paymentStartedWithActiveSubscription: Boolean(subscription?.active),
}; };
}); });
@@ -179,6 +173,7 @@ export function createBillingStore({
...s, ...s,
selectedTariffKey: key, selectedTariffKey: key,
selectedPlan: plans.find((plan) => plan?.tariff_key === key) || null, selectedPlan: plans.find((plan) => plan?.tariff_key === key) || null,
renewHwidDevices: true,
})); }));
} }
@@ -189,6 +184,7 @@ export function createBillingStore({
...s, ...s,
selectedPlan: s.selectedPlan || selectedTariffPlans[0] || null, selectedPlan: s.selectedPlan || selectedTariffPlans[0] || null,
paymentStep: "checkout", paymentStep: "checkout",
renewHwidDevices: true,
}; };
}); });
} }
@@ -355,7 +351,10 @@ export function createBillingStore({
state.update((s) => ({ ...s, payBusy: true })); state.update((s) => ({ ...s, payBusy: true }));
try { try {
const response = await billing.postPayment( 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); const successContext = paymentSuccessContext(s, response);
rememberSubscriptionActivationPending(successContext); rememberSubscriptionActivationPending(successContext);
+31
View File
@@ -4288,6 +4288,37 @@
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); 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 { .admin-input-row {
display: grid; display: grid;
grid-template-columns: minmax(72px, 1fr) auto; grid-template-columns: minmax(72px, 1fr) auto;
+37
View File
@@ -1189,6 +1189,43 @@ a {
overflow-wrap: anywhere; 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-row,
.skeleton-method, .skeleton-method,
.skeleton-pay-button { .skeleton-pay-button {
+131 -6
View File
@@ -10,6 +10,7 @@
import { Tooltip } from "$components/ui/primitives.js"; import { Tooltip } from "$components/ui/primitives.js";
import Button from "$components/ui/button.svelte"; import Button from "$components/ui/button.svelte";
import Checkbox from "$components/ui/checkbox.svelte";
import Dialog from "$components/ui/dialog.svelte"; import Dialog from "$components/ui/dialog.svelte";
import EmailCodeScreen from "./auth/EmailCodeScreen.svelte"; import EmailCodeScreen from "./auth/EmailCodeScreen.svelte";
import Input from "$components/ui/input.svelte"; import Input from "$components/ui/input.svelte";
@@ -52,6 +53,7 @@
export let selectedTariff = null; export let selectedTariff = null;
export let selectedTariffKey = ""; export let selectedTariffKey = "";
export let selectedTariffPlans = []; export let selectedTariffPlans = [];
export let renewHwidDevices = true;
export let setPasswordBusy = false; export let setPasswordBusy = false;
export let setPasswordCode = ""; export let setPasswordCode = "";
export let setPasswordConfirm = ""; export let setPasswordConfirm = "";
@@ -72,6 +74,81 @@
function priceLabel(plan) { function priceLabel(plan) {
return priceLabelFn(plan, selectedMethod); 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) { function planKey(plan) {
return planKeyFn(plan); return planKeyFn(plan);
} }
@@ -197,10 +274,34 @@
<p>{subscriptionPurchaseDescription}</p> <p>{subscriptionPurchaseDescription}</p>
</div> </div>
{/if} {/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"> <div class="subscription-purchase-description">
<p> <p>
{t("wa_hwid_devices_renewal_notice", { {t("wa_hwid_devices_renewal_unavailable", {
count: Number(subscription.extra_hwid_devices || 0), count: Number(subscription.extra_hwid_devices || 0),
date: subscription.extra_hwid_devices_valid_until_text || "", date: subscription.extra_hwid_devices_valid_until_text || "",
})} })}
@@ -243,7 +344,7 @@
disabled={!selectedPlan || !methods.length || payBusy} disabled={!selectedPlan || !methods.length || payBusy}
> >
{t("wa_pay")} {t("wa_pay")}
{selectedPlan ? priceLabel(selectedPlan) : ""} {selectedPlan ? paymentPriceLabel(selectedPlan) : ""}
<LockKeyhole size={17} /> <LockKeyhole size={17} />
</Button> </Button>
{:else} {:else}
@@ -261,10 +362,34 @@
<p>{subscriptionPurchaseDescription}</p> <p>{subscriptionPurchaseDescription}</p>
</div> </div>
{/if} {/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"> <div class="subscription-purchase-description">
<p> <p>
{t("wa_hwid_devices_renewal_notice", { {t("wa_hwid_devices_renewal_unavailable", {
count: Number(subscription.extra_hwid_devices || 0), count: Number(subscription.extra_hwid_devices || 0),
date: subscription.extra_hwid_devices_valid_until_text || "", date: subscription.extra_hwid_devices_valid_until_text || "",
})} })}
@@ -310,7 +435,7 @@
disabled={!selectedPlan || !methods.length || payBusy} disabled={!selectedPlan || !methods.length || payBusy}
> >
{t("wa_pay")} {t("wa_pay")}
{selectedPlan ? priceLabel(selectedPlan) : ""} {selectedPlan ? paymentPriceLabel(selectedPlan) : ""}
<LockKeyhole size={17} /> <LockKeyhole size={17} />
</Button> </Button>
{/if} {/if}
+16 -16
View File
@@ -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() { function tariffChangeModalDescription() {
if (!changeOptions) return ""; if (!changeOptions) return "";
return changeOptions?.current return changeOptions?.current
@@ -369,16 +382,7 @@
{#if !deviceTopupOptions} {#if !deviceTopupOptions}
<DialogOptionsSkeleton label={t("wa_tariff_options_loading")} rows={3} /> <DialogOptionsSkeleton label={t("wa_tariff_options_loading")} rows={3} />
{:else if deviceTopupOptions?.plans?.length} {:else if deviceTopupOptions?.plans?.length}
{#if deviceTopupOptions?.renewal_available} {#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_renewal_offer", {
count: Number(deviceTopupOptions.renewal_recommended_count || 0),
date: deviceTopupOptions.extra_hwid_devices_valid_until_text || "",
})}
</p>
</div>
{:else if Number(deviceTopupOptions?.extra_hwid_devices || 0) > 0 && deviceTopupOptions?.extra_hwid_devices_valid_until_text}
<div class="topup-carryover-note"> <div class="topup-carryover-note">
<p> <p>
{t("wa_hwid_devices_valid_until", { {t("wa_hwid_devices_valid_until", {
@@ -397,12 +401,8 @@
onclick={() => (selectedDeviceTopupPlan = plan)} onclick={() => (selectedDeviceTopupPlan = plan)}
> >
<span class="option-row-main"> <span class="option-row-main">
<strong <strong>{deviceTopupPlanTitle(plan)}</strong>
>{t("wa_hwid_devices_package", { <small>{deviceTopupPlanHint(plan)}</small>
count: Number(plan.device_count || plan.months || 0),
})}</strong
>
<small>{plan.subtitle || deviceTopupOptions.tariff_name}</small>
</span> </span>
<span class="option-row-meta"> <span class="option-row-meta">
<em>{priceLabel(plan)}</em> <em>{priceLabel(plan)}</em>
+21 -6
View File
@@ -948,20 +948,32 @@
"buy_hwid_devices_menu_button": "+ HWID devices", "buy_hwid_devices_menu_button": "+ HWID devices",
"buy_hwid_devices_button": "+{count} HWID for {price} {currency_symbol}", "buy_hwid_devices_button": "+{count} HWID for {price} {currency_symbol}",
"select_hwid_device_package": "Select HWID device package:", "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:", "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.", "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.", "hwid_devices_unlimited_no_topup": "Your device limit is already unlimited.",
"payment_description_hwid_devices": "Extra HWID devices +{count}", "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.", "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.",
"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_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_buy_hwid_devices": "Buy devices",
"wa_device_topup_for_tariff": "Device packages for {tariff}", "wa_device_topup_for_tariff": "Device packages for {tariff}",
"wa_hwid_devices_package": "+{count} devices", "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_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_notice": "Your +{count} extra devices are valid until {date}. You can renew them together with the subscription.",
"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_checkbox": "Also renew +{count} extra devices for {price}",
"wa_hwid_devices_renewal_prompt": "Subscription renewed. Buy devices again for the new period.", "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_no_hwid_device_options": "No device packages available",
"wa_device_topup_options_failed": "Could not load device packages", "wa_device_topup_options_failed": "Could not load device packages",
"admin_nav_overview": "Overview", "admin_nav_overview": "Overview",
@@ -1392,6 +1404,9 @@
"admin_user_btn_reset_trial": "Reset Trial", "admin_user_btn_reset_trial": "Reset Trial",
"admin_user_label_extend": "Extend Subscription", "admin_user_label_extend": "Extend Subscription",
"admin_user_label_extend_days": "Days", "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_btn_extend": "Extend",
"admin_user_label_telegram_msg": "Telegram Message", "admin_user_label_telegram_msg": "Telegram Message",
"admin_user_hint_telegram_msg": "Telegram HTML formatting supported", "admin_user_hint_telegram_msg": "Telegram HTML formatting supported",
+21 -6
View File
@@ -948,20 +948,32 @@
"buy_hwid_devices_menu_button": "+ HWID устройства", "buy_hwid_devices_menu_button": "+ HWID устройства",
"buy_hwid_devices_button": "+{count} HWID за {price} {currency_symbol}", "buy_hwid_devices_button": "+{count} HWID за {price} {currency_symbol}",
"select_hwid_device_package": "Выберите пакет HWID устройств:", "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 устройств:", "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 устройства для этого тарифа не настроены.", "no_hwid_device_packages_available": "Дополнительные HWID устройства для этого тарифа не настроены.",
"hwid_devices_unlimited_no_topup": "У вас уже безлимитное число устройств.", "hwid_devices_unlimited_no_topup": "У вас уже безлимитное число устройств.",
"payment_description_hwid_devices": "Дополнительные HWID устройства +{count}", "payment_description_hwid_devices": "Дополнительные HWID устройства +{count}",
"payment_successful_hwid_devices_renewal_note": "У вас сейчас докуплено +{count} HWID устройств до {date}. При продлении подписки их нужно докупить заново для нового срока.", "payment_successful_hwid_devices_renewal_note": "Докупленные +{count} HWID устройств действуют до {date}. Если нужно продлить их на следующий срок, включите продление устройств при оплате подписки.",
"subscription_hwid_renewal_reminder": "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки докупку нужно оформить заново.", "payment_successful_hwid_devices_renewed_note": "Докупленные +{count} HWID устройств продлены вместе с подпиской до {date}.",
"subscription_hwid_renewal_reminder": "У вас докуплено +{count} HWID устройств до {date}. При продлении подписки можно включить продление этих устройств в тот же платеж.",
"wa_buy_hwid_devices": "Купить устройства", "wa_buy_hwid_devices": "Купить устройства",
"wa_device_topup_for_tariff": "Пакеты устройств для тарифа {tariff}", "wa_device_topup_for_tariff": "Пакеты устройств для тарифа {tariff}",
"wa_hwid_devices_package": "+{count} устройств", "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_valid_until": "+{count} дополнительных устройств действует до {date}",
"wa_hwid_devices_renewal_notice": "Докупленные +{count} устройств действуют до {date}. При продлении подписки их нужно докупить заново.", "wa_hwid_devices_renewal_notice": "Докупленные +{count} устройств действуют до {date}. Продлить их на следующий срок можно вместе с продлением подписки.",
"wa_hwid_devices_renewal_offer": "Текущая докупка +{count} устройств действует до {date}. Выберите пакет, чтобы продлить устройства на новый срок подписки.", "wa_hwid_devices_renewal_checkbox": "Продлить также +{count} доп. устройств за {price}",
"wa_hwid_devices_renewal_prompt": "Подписка продлена. Докупите устройства для нового срока.", "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_no_hwid_device_options": "Нет доступных пакетов устройств",
"wa_device_topup_options_failed": "Не удалось загрузить пакеты устройств", "wa_device_topup_options_failed": "Не удалось загрузить пакеты устройств",
"admin_nav_overview": "Обзор", "admin_nav_overview": "Обзор",
@@ -1392,6 +1404,9 @@
"admin_user_btn_reset_trial": "Сбросить триал", "admin_user_btn_reset_trial": "Сбросить триал",
"admin_user_label_extend": "Продлить подписку", "admin_user_label_extend": "Продлить подписку",
"admin_user_label_extend_days": "Дней", "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_btn_extend": "Продлить",
"admin_user_label_telegram_msg": "Сообщение в Telegram", "admin_user_label_telegram_msg": "Сообщение в Telegram",
"admin_user_hint_telegram_msg": "Поддерживается HTML-разметка Telegram", "admin_user_hint_telegram_msg": "Поддерживается HTML-разметка Telegram",
+71 -2
View File
@@ -1,5 +1,6 @@
import json import json
import unittest import unittest
from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch 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) response = await admin_users.admin_user_hwid_device_limit_route(request)
self.assertEqual(response.status, 400) 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): async def test_over_max_limit_is_rejected(self):
session = FakeSession() session = FakeSession()
+55 -1
View File
@@ -20,9 +20,10 @@ return.
""" """
import unittest import unittest
from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, Dict, List, Optional 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 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]["metadata"]["subscription_months"], "1")
self.assertEqual(yk.calls[0]["amount"], 99.0) 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 if __name__ == "__main__": # pragma: no cover
unittest.main() unittest.main()
+72
View File
@@ -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()
+109
View File
@@ -273,6 +273,115 @@ class HwidDeviceTopupBehaviourTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(quote["price"], 150) self.assertEqual(quote["price"], 150)
self.assertAlmostEqual(quote["proration_ratio"], 1.0) 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): async def test_unlimited_subscriber_returns_noop_payload(self):
# hwid_device_limit == 0 means unlimited — top-up makes no sense and must skip. # hwid_device_limit == 0 means unlimited — top-up makes no sense and must skip.
with tempfile.TemporaryDirectory() as tmpdir: 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_value_rub"], 50)
self.assertEqual(change_payload["converted_hwid_days"], 7) 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 if __name__ == "__main__": # pragma: no cover
unittest.main() unittest.main()
+61 -1
View File
@@ -30,7 +30,10 @@ from bot.payment_providers.shared import (
sale_mode_is_traffic, sale_mode_is_traffic,
sale_mode_tariff_key, 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 from config.settings import Settings
_LEGACY_PROVIDER_FILES = [ _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" 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): def test_payment_method_keyboard_uses_custom_telegram_text_without_changing_callback(monkeypatch):
monkeypatch.setenv("WATA_ENABLED", "True") monkeypatch.setenv("WATA_ENABLED", "True")
monkeypatch.setenv("PAYMENT_WATA_TELEGRAM_LABEL_EN", "Wata custom") 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, traffic_gb_raw=None,
hwid_devices_raw="1.9", 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()
+159
View File
@@ -433,6 +433,103 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas
self.assertEqual(kwargs["tariff_key"], "standard") self.assertEqual(kwargs["tariff_key"], "standard")
self.assertEqual(kwargs["payment_db_id"], 12) 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): class SubscriptionServiceBonusExtensionTests(unittest.IsolatedAsyncioTestCase):
async def test_referral_extension_preserves_existing_tariff_limit(self): 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", "bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription",
AsyncMock(), AsyncMock(),
) as update_subscription, ) 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( await service.extend_active_subscription_days(
session=AsyncMock(), session=AsyncMock(),
@@ -488,10 +589,68 @@ class SubscriptionServiceBonusExtensionTests(unittest.IsolatedAsyncioTestCase):
) )
update_subscription.assert_not_awaited() 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] payload = service.panel_service.update_user_details_on_panel.await_args.args[1]
self.assertNotIn("trafficLimitBytes", payload) self.assertNotIn("trafficLimitBytes", payload)
self.assertNotIn("trafficLimitStrategy", 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): class SubscriptionServiceActiveDetailsTests(unittest.IsolatedAsyncioTestCase):
def _local_active_sub(self) -> SimpleNamespace: def _local_active_sub(self) -> SimpleNamespace:
+60
View File
@@ -20,9 +20,11 @@ from bot.keyboards.inline.user_keyboards import (
get_subscription_options_keyboard, get_subscription_options_keyboard,
get_tariff_catalog_keyboard, get_tariff_catalog_keyboard,
get_tariff_periods_keyboard, get_tariff_periods_keyboard,
get_yk_autopay_choice_keyboard,
payment_methods_back_callback, payment_methods_back_callback,
payment_options_back_callback, payment_options_back_callback,
) )
from bot.middlewares.i18n import LOCALE_KEY_ALIASES
from config.tariffs_config import TariffsConfig 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")) self.translations = json.loads(Path("locales/en.json").read_text(encoding="utf-8"))
def gettext(self, lang, key, **kwargs): def gettext(self, lang, key, **kwargs):
key = LOCALE_KEY_ALIASES.get(key, key)
text = self.translations[key] text = self.translations[key]
return text.format(**kwargs) if kwargs else text return text.format(**kwargs) if kwargs else text
@@ -248,6 +251,63 @@ class UserBotMenuTests(unittest.TestCase):
"subscribe_period:1:bot", "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): def test_tariff_back_buttons_return_to_previous_level(self):
tariff = SimpleNamespace( tariff = SimpleNamespace(
key="basic", key="basic",
+200 -1
View File
@@ -2,7 +2,7 @@ import json
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest import IsolatedAsyncioTestCase 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 import bot.app.web.subscription_webapp # noqa: F401
from bot.app.web.webapp import billing as billing_module 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_from"], valid_from.isoformat())
self.assertEqual(payload["plans"][0]["valid_until"], active_until.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): async def test_create_payment_route_quotes_hwid_with_app_subscription_service(self):
tariff = SimpleNamespace( tariff = SimpleNamespace(
key="standard", key="standard",
@@ -197,6 +292,110 @@ class WebAppDeviceTopupOptionsTests(IsolatedAsyncioTestCase):
subscription_service.quote_hwid_device_topup.assert_awaited_once() subscription_service.quote_hwid_device_topup.assert_awaited_once()
create_payment.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): async def test_create_payment_route_rejects_fractional_hwid_device_count(self):
tariff = SimpleNamespace( tariff = SimpleNamespace(
key="standard", key="standard",
+1 -1
View File
@@ -164,7 +164,7 @@ class WebAppPaymentStatusTests(IsolatedAsyncioTestCase):
): ):
response = await billing_module.payment_status_route(request) 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) self.assertEqual(response.status, 200)
async def test_wata_pending_payment_refresh_delegates_to_provider_service(self): async def test_wata_pending_payment_refresh_delegates_to_provider_service(self):
+183
View File
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest import IsolatedAsyncioTestCase from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@@ -15,6 +16,81 @@ class _I18n:
class YooKassaHwidWebhookTests(IsolatedAsyncioTestCase): 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): async def test_webapp_hwid_metadata_activates_device_count_without_end_date(self):
payment = SimpleNamespace(payment_id=5, status="pending_yookassa", tariff_key="standard") payment = SimpleNamespace(payment_id=5, status="pending_yookassa", tariff_key="standard")
updated_payment = SimpleNamespace(payment_id=5, status="succeeded", 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 assert activation_kwargs["traffic_gb"] is None
update_status.assert_awaited_once() update_status.assert_awaited_once()
send_success.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()