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