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
+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: