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