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
+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()