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