fix: delegate tariff resets to panel

This commit is contained in:
3252a8
2026-04-28 15:36:13 +03:00
parent 258b2f4dec
commit 4958e1fdfd
5 changed files with 119 additions and 171 deletions
+6 -10
View File
@@ -8,7 +8,7 @@ from bot.middlewares.i18n import JsonI18n
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal, tariff_dal from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal, tariff_dal
from config.tariffs_config import Tariff from config.tariffs_config import Tariff
from bot.utils.date_utils import add_months, month_start from bot.utils.date_utils import add_months
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from db.models import User, Subscription from db.models import User, Subscription
@@ -825,7 +825,7 @@ class SubscriptionService:
if target.billing_model == "period": if target.billing_model == "period":
update_data["tier_baseline_bytes"] = target.monthly_bytes update_data["tier_baseline_bytes"] = target.monthly_bytes
update_data["traffic_limit_bytes"] = target.monthly_bytes + int(sub.topup_balance_bytes or 0) update_data["traffic_limit_bytes"] = target.monthly_bytes + int(sub.topup_balance_bytes or 0)
update_data["period_start_at"] = month_start() update_data["period_start_at"] = None
update_data["effective_monthly_price_rub"] = target.period_price(1, "rub") or target.min_period_price_rub() update_data["effective_monthly_price_rub"] = target.period_price(1, "rub") or target.min_period_price_rub()
if mode == "recalc_days" and options.get("recalc_days") is not None: if mode == "recalc_days" and options.get("recalc_days") is not None:
update_data["end_date"] = now + timedelta(days=int(options["recalc_days"])) update_data["end_date"] = now + timedelta(days=int(options["recalc_days"]))
@@ -858,7 +858,7 @@ class SubscriptionService:
expire_at=updated.end_date, expire_at=updated.end_date,
status="ACTIVE", status="ACTIVE",
traffic_limit_bytes=updated.traffic_limit_bytes, traffic_limit_bytes=updated.traffic_limit_bytes,
traffic_limit_strategy="NO_RESET" if target.billing_model == "traffic" else self.settings.USER_TRAFFIC_STRATEGY, traffic_limit_strategy="NO_RESET" if target.billing_model == "traffic" else "MONTH",
) )
panel_payload["activeInternalSquads"] = target.squad_uuids panel_payload["activeInternalSquads"] = target.squad_uuids
panel_payload.update(self._panel_identity_payload_for_user(db_user)) panel_payload.update(self._panel_identity_payload_for_user(db_user))
@@ -1077,11 +1077,6 @@ class SubscriptionService:
topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0) topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0)
tier_baseline_bytes = tariff.monthly_bytes if tariff else self.settings.user_traffic_limit_bytes tier_baseline_bytes = tariff.monthly_bytes if tariff else self.settings.user_traffic_limit_bytes
period_start_at = month_start() if tariff else (
datetime.now(timezone.utc)
if starts_after_lapse or not current_active_sub or not getattr(current_active_sub, "period_start_at", None)
else current_active_sub.period_start_at
)
effective_monthly_price = float(payment_amount) / max(1, months_int) effective_monthly_price = float(payment_amount) / max(1, months_int)
traffic_limit_bytes = self._traffic_limit_for_period_tariff(tariff, topup_balance_bytes) traffic_limit_bytes = self._traffic_limit_for_period_tariff(tariff, topup_balance_bytes)
sub_payload = { sub_payload = {
@@ -1100,7 +1095,7 @@ class SubscriptionService:
"tariff_key": tariff.key if tariff else None, "tariff_key": tariff.key if tariff else None,
"tier_baseline_bytes": tier_baseline_bytes, "tier_baseline_bytes": tier_baseline_bytes,
"topup_balance_bytes": topup_balance_bytes, "topup_balance_bytes": topup_balance_bytes,
"period_start_at": period_start_at, "period_start_at": None,
"is_throttled": False, "is_throttled": False,
"effective_monthly_price_rub": effective_monthly_price, "effective_monthly_price_rub": effective_monthly_price,
} }
@@ -1120,6 +1115,7 @@ class SubscriptionService:
expire_at=final_end_date, expire_at=final_end_date,
status="ACTIVE", status="ACTIVE",
traffic_limit_bytes=traffic_limit_bytes, traffic_limit_bytes=traffic_limit_bytes,
traffic_limit_strategy="MONTH" if tariff else self.settings.USER_TRAFFIC_STRATEGY,
) )
if tariff: if tariff:
panel_update_payload["activeInternalSquads"] = tariff.squad_uuids panel_update_payload["activeInternalSquads"] = tariff.squad_uuids
@@ -1381,7 +1377,7 @@ class SubscriptionService:
except Exception: except Exception:
tariff = None tariff = None
billing_model_display = tariff.billing_model if tariff else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period") billing_model_display = tariff.billing_model if tariff else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period")
traffic_limit_strategy = "MONTH" if billing_model_display == "period" else panel_traffic_strategy traffic_limit_strategy = panel_traffic_strategy
return { return {
"user_id": panel_user_data.get("uuid"), "user_id": panel_user_data.get("uuid"),
+31 -47
View File
@@ -58,6 +58,7 @@ class TariffTrafficWorker:
async def traffic_period_tick(self, session: AsyncSession) -> None: async def traffic_period_tick(self, session: AsyncSession) -> None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
warning_period_start = month_start(now)
result = await session.execute( result = await session.execute(
select(Subscription).where( select(Subscription).where(
Subscription.is_active == True, Subscription.is_active == True,
@@ -71,61 +72,42 @@ class TariffTrafficWorker:
except Exception: except Exception:
continue continue
panel_data = await self.panel_service.get_user_by_uuid(sub.panel_user_uuid, log_response=False) or {} panel_data = await self.panel_service.get_user_by_uuid(sub.panel_user_uuid, log_response=False) or {}
used, limit, _ = self.subscription_service._extract_panel_traffic_details(panel_data) used, limit, panel_strategy = self.subscription_service._extract_panel_traffic_details(panel_data)
if used is not None and used != sub.traffic_used_bytes: if used is not None and used != sub.traffic_used_bytes:
sub.traffic_used_bytes = used sub.traffic_used_bytes = used
if limit is not None and limit != sub.traffic_limit_bytes: if limit is not None and limit != sub.traffic_limit_bytes:
sub.traffic_limit_bytes = limit sub.traffic_limit_bytes = limit
if tariff.billing_model == "period": if tariff.billing_model == "period":
reset_happened = await self._maybe_reset_period(session, sub, tariff, used) await self._ensure_period_reset_strategy(sub, tariff, limit, panel_strategy)
if reset_happened: await self._maybe_warn_or_throttle(
used = 0
sub.traffic_used_bytes = 0
await self._maybe_warn_or_throttle(session, sub, tariff, used, limit)
async def _maybe_reset_period(self, session: AsyncSession, sub: Subscription, tariff, used: Optional[int]) -> bool:
now = datetime.now(timezone.utc)
current_month_start = month_start(now)
if not sub.period_start_at:
await subscription_dal.update_subscription(
session, session,
sub.subscription_id, sub,
{"period_start_at": current_month_start}, tariff,
used,
limit,
warning_period_start=warning_period_start if tariff.billing_model == "period" else None,
) )
sub.period_start_at = current_month_start
return False
stored_month_start = month_start(sub.period_start_at)
if stored_month_start == current_month_start or sub.end_date <= now:
return False
await self.panel_service.reset_user_traffic(sub.panel_user_uuid) async def _ensure_period_reset_strategy(
restore_throttled = bool(sub.is_throttled) self,
restore_succeeded = True sub: Subscription,
if restore_throttled: tariff,
for squad_uuid in tariff.squad_uuids: limit: Optional[int],
restore_succeeded = await self.panel_service.add_users_to_internal_squad( panel_strategy: Optional[str],
squad_uuid, ) -> None:
[sub.panel_user_uuid], if str(panel_strategy or "").upper() == "MONTH":
) and restore_succeeded return
should_clear_throttle = restore_throttled and restore_succeeded traffic_limit_bytes = int(limit or sub.traffic_limit_bytes or (tariff.monthly_bytes + int(sub.topup_balance_bytes or 0)))
await subscription_dal.update_subscription( payload = self.subscription_service._build_panel_update_payload(
session, panel_user_uuid=sub.panel_user_uuid,
sub.subscription_id, expire_at=sub.end_date,
{ status="ACTIVE",
"period_start_at": current_month_start, traffic_limit_bytes=traffic_limit_bytes,
"traffic_used_bytes": 0, traffic_limit_strategy="MONTH",
"is_throttled": False if should_clear_throttle else sub.is_throttled,
"status_from_panel": "ACTIVE" if should_clear_throttle else sub.status_from_panel,
},
) )
sub.period_start_at = current_month_start payload["activeInternalSquads"] = tariff.squad_uuids
sub.traffic_used_bytes = 0 await self.panel_service.update_user_details_on_panel(sub.panel_user_uuid, payload, log_response=False)
if should_clear_throttle:
sub.is_throttled = False
sub.status_from_panel = "ACTIVE"
await tariff_dal.clear_period_warnings(session, sub.subscription_id)
return True
async def _maybe_warn_or_throttle( async def _maybe_warn_or_throttle(
self, self,
@@ -134,6 +116,8 @@ class TariffTrafficWorker:
tariff, tariff,
used: Optional[int], used: Optional[int],
limit: Optional[int], limit: Optional[int],
*,
warning_period_start: Optional[datetime] = None,
) -> None: ) -> None:
used_val = int(used or sub.traffic_used_bytes or 0) used_val = int(used or sub.traffic_used_bytes or 0)
limit_val = int(limit or sub.traffic_limit_bytes or 0) limit_val = int(limit or sub.traffic_limit_bytes or 0)
@@ -146,7 +130,7 @@ class TariffTrafficWorker:
warning = await tariff_dal.get_warning( warning = await tariff_dal.get_warning(
session, session,
subscription_id=sub.subscription_id, subscription_id=sub.subscription_id,
period_start_at=sub.period_start_at if tariff.billing_model == "period" else None, period_start_at=warning_period_start if tariff.billing_model == "period" else None,
level=level, level=level,
traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None, traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None,
) )
@@ -155,7 +139,7 @@ class TariffTrafficWorker:
await tariff_dal.create_warning( await tariff_dal.create_warning(
session, session,
subscription_id=sub.subscription_id, subscription_id=sub.subscription_id,
period_start_at=sub.period_start_at if tariff.billing_model == "period" else None, period_start_at=warning_period_start if tariff.billing_model == "period" else None,
level=level, level=level,
traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None, traffic_limit_bytes=limit_val if tariff.billing_model == "traffic" else None,
) )
+11 -60
View File
@@ -1,13 +1,9 @@
import logging import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from bot.utils.date_utils import month_start
from config.settings import Settings from config.settings import Settings
from db.dal import subscription_dal from db.models import Base
from db.models import Base, Subscription
from .migrator import run_database_migrations from .migrator import run_database_migrations
async_engine = None async_engine = None
@@ -105,19 +101,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
tariff_key = COALESCE(s.tariff_key, :tariff_key), tariff_key = COALESCE(s.tariff_key, :tariff_key),
tier_baseline_bytes = COALESCE(s.tier_baseline_bytes, s.traffic_limit_bytes, :baseline), tier_baseline_bytes = COALESCE(s.tier_baseline_bytes, s.traffic_limit_bytes, :baseline),
topup_balance_bytes = COALESCE(s.topup_balance_bytes, 0), topup_balance_bytes = COALESCE(s.topup_balance_bytes, 0),
period_start_at = COALESCE( period_start_at = NULL,
s.period_start_at,
(
SELECT p.created_at
FROM payments p
WHERE p.user_id = s.user_id
AND p.status = 'succeeded'
ORDER BY p.created_at DESC
LIMIT 1
),
s.start_date,
NOW()
),
effective_monthly_price_rub = COALESCE( effective_monthly_price_rub = COALESCE(
s.effective_monthly_price_rub, s.effective_monthly_price_rub,
( (
@@ -141,49 +125,16 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
"default_price": default_price, "default_price": default_price,
}, },
) )
month_anchor = month_start() await session.execute(
active_subs = await session.execute( text(
select(Subscription).where(Subscription.is_active == True) """
UPDATE subscriptions
SET period_start_at = NULL
WHERE is_active = TRUE
AND tariff_key IS NOT NULL
"""
)
) )
for sub in active_subs.scalars().all():
tariff_key = sub.tariff_key or default_tariff.key
try:
tariff = settings.tariffs_config.require(tariff_key)
except Exception:
continue
update_data = {
"tariff_key": tariff.key,
"topup_balance_bytes": int(sub.topup_balance_bytes or 0),
}
if tariff.billing_model == "period":
baseline_source = (
sub.tier_baseline_bytes
if sub.tier_baseline_bytes is not None
else (sub.traffic_limit_bytes if sub.traffic_limit_bytes is not None else tariff.monthly_bytes)
)
baseline = int(baseline_source or 0)
update_data.update(
{
"tier_baseline_bytes": baseline,
"traffic_limit_bytes": baseline + int(sub.topup_balance_bytes or 0),
"period_start_at": month_anchor,
"effective_monthly_price_rub": (
sub.effective_monthly_price_rub
if sub.effective_monthly_price_rub is not None
else default_price
),
}
)
else:
update_data.update(
{
"tier_baseline_bytes": 0,
"period_start_at": None,
"effective_monthly_price_rub": None,
}
)
await subscription_dal.update_subscription(session, sub.subscription_id, update_data)
await session.commit() await session.commit()
except Exception: except Exception:
await session.rollback() await session.rollback()
+5 -4
View File
@@ -26,9 +26,10 @@ Period-тариф продает доступ на срок и лимит тра
- `monthly_gb` превращается в `tier_baseline_bytes`. - `monthly_gb` превращается в `tier_baseline_bytes`.
- Докупленные пакеты хранятся в `topup_balance_bytes`. - Докупленные пакеты хранятся в `topup_balance_bytes`.
- В Remnawave пушится `trafficLimitBytes = tier_baseline_bytes + topup_balance_bytes`. - В Remnawave пушится `trafficLimitBytes = tier_baseline_bytes + topup_balance_bytes`.
- Сброс происходит в начале календарного месяца для всех одинаково. - Для period-тарифов бот выставляет `trafficLimitStrategy = MONTH`, а дальнейший reset делает сама панель.
- Если покупка или продление были в середине месяца, reset всё равно придёт на ближайший первый день следующего месяца. - Дата сброса больше не считается в боте.
- Панель по-прежнему считает `usedTrafficBytes`, а бот только синхронизирует месячный reset и лимиты. - Если покупка или продление были в середине месяца, сброс всё равно произойдёт по правилам панели для `MONTH`.
- Бот только меняет лимиты в GB и следит за предупреждениями/throttle на основе текущего usage из панели.
## Traffic-Тариф ## Traffic-Тариф
@@ -62,7 +63,7 @@ Legacy поле `subscription_duration_months` остается для совм
`TariffTrafficWorker` запускается, только если активен `tariffs.json`. `TariffTrafficWorker` запускается, только если активен `tariffs.json`.
- Раз в несколько минут проверяет наступление нового календарного месяца. - Раз в несколько минут синхронизирует `trafficLimitStrategy = MONTH` для period-тарифов, если панель ещё не переключена.
- Отправляет/дедуплицирует уровни предупреждений 80/95/100 через `traffic_warnings`. - Отправляет/дедуплицирует уровни предупреждений 80/95/100 через `traffic_warnings`.
- При 100% удаляет пользователя из squad-ов тарифа и ставит `is_throttled`. - При 100% удаляет пользователя из squad-ов тарифа и ставит `is_throttled`.
- Возвращает пользователя в squad-ы, когда лимит снова больше использованного трафика. - Возвращает пользователя в squad-ы, когда лимит снова больше использованного трафика.
+66 -50
View File
@@ -1,62 +1,78 @@
import json
import tempfile
import unittest import unittest
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock
from config.settings import Settings from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.services.tariff_worker import TariffTrafficWorker from bot.services.tariff_worker import TariffTrafficWorker
from bot.utils.date_utils import month_start from config.settings import Settings
def _tariffs_config_payload() -> dict:
return {
"default_tariff": "standard",
"tariffs": [
{
"key": "standard",
"names": {"ru": "Стандарт"},
"descriptions": {"ru": "Base"},
"squad_uuids": ["squad-1"],
"billing_model": "period",
"monthly_gb": 500,
"prices_rub": {"1": 150},
"prices_stars": {"1": 0},
"enabled_periods": [1],
"enabled": True,
}
],
}
class TariffWorkerTests(unittest.IsolatedAsyncioTestCase): class TariffWorkerTests(unittest.IsolatedAsyncioTestCase):
async def test_monthly_reset_triggers_on_calendar_change_before_30_days(self): async def test_period_tariff_uses_panel_month_strategy_without_resetting(self):
settings = Settings( with tempfile.TemporaryDirectory() as tmpdir:
_env_file=None, config_path = Path(tmpdir) / "tariffs.json"
BOT_TOKEN="token", config_path.write_text(json.dumps(_tariffs_config_payload()), encoding="utf-8")
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
)
panel_service = AsyncMock()
panel_service.reset_user_traffic = AsyncMock(return_value=True)
panel_service.add_users_to_internal_squad = AsyncMock(return_value=True)
subscription_service = SimpleNamespace()
worker = TariffTrafficWorker(
settings=settings,
session_factory=SimpleNamespace(),
panel_service=panel_service,
subscription_service=subscription_service,
)
now = datetime.now(timezone.utc) settings = Settings(
current_month_start = month_start(now) _env_file=None,
previous_month_anchor = current_month_start - timedelta(days=2) BOT_TOKEN="token",
sub = SimpleNamespace( POSTGRES_USER="app_user",
subscription_id=1, POSTGRES_PASSWORD="app_password",
panel_user_uuid="panel-uuid", TARIFFS_CONFIG_PATH=str(config_path),
period_start_at=previous_month_anchor, )
end_date=now + timedelta(days=10), panel_service = AsyncMock(spec=PanelApiService)
traffic_used_bytes=123, panel_service.update_user_details_on_panel = AsyncMock(return_value={"response": {}})
traffic_limit_bytes=456, panel_service.reset_user_traffic = AsyncMock(return_value=True)
topup_balance_bytes=0, panel_service.add_users_to_internal_squad = AsyncMock(return_value=True)
is_throttled=False, subscription_service = SubscriptionService(settings, panel_service)
status_from_panel="ACTIVE", worker = TariffTrafficWorker(
) settings=settings,
tariff = SimpleNamespace(billing_model="period", squad_uuids=["squad-1"]) session_factory=SimpleNamespace(),
panel_service=panel_service,
subscription_service=subscription_service,
)
session = object() sub = SimpleNamespace(
subscription_id=1,
user_id=123,
panel_user_uuid="panel-uuid",
end_date=datetime.now(timezone.utc) + timedelta(days=10),
traffic_limit_bytes=500 * (1024**3),
topup_balance_bytes=0,
is_throttled=False,
status_from_panel="ACTIVE",
)
tariff = settings.tariffs_config.require("standard")
with patch( await worker._ensure_period_reset_strategy(sub, tariff, sub.traffic_limit_bytes, "NO_RESET")
"bot.services.tariff_worker.subscription_dal.update_subscription",
new=AsyncMock(),
) as update_subscription, patch(
"bot.services.tariff_worker.tariff_dal.clear_period_warnings",
new=AsyncMock(),
) as clear_period_warnings:
await worker._maybe_reset_period(session, sub, tariff, used=123)
panel_service.reset_user_traffic.assert_awaited_once_with("panel-uuid") panel_service.update_user_details_on_panel.assert_awaited_once()
update_subscription.assert_awaited_once() panel_service.reset_user_traffic.assert_not_awaited()
update_payload = update_subscription.await_args.args[2] update_payload = panel_service.update_user_details_on_panel.await_args.args[1]
self.assertEqual(update_payload["period_start_at"], current_month_start) self.assertEqual(update_payload["trafficLimitStrategy"], "MONTH")
self.assertEqual(update_payload["traffic_used_bytes"], 0) self.assertEqual(update_payload["trafficLimitBytes"], sub.traffic_limit_bytes)
clear_period_warnings.assert_awaited_once_with(session, 1)