feat: align tariff resets to calendar month
This commit is contained in:
@@ -29,3 +29,4 @@ __pycache__/
|
||||
locales/ru_backup.json
|
||||
locales/en_backup.json
|
||||
db/models_old.py
|
||||
config/tariffs.json
|
||||
|
||||
@@ -2209,6 +2209,7 @@ def _serialize_subscription(
|
||||
"tariff_name": active.get("tariff_name"),
|
||||
"tariff_description": active.get("tariff_description"),
|
||||
"billing_model": active.get("billing_model"),
|
||||
"traffic_limit_strategy": active.get("traffic_limit_strategy"),
|
||||
"tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")),
|
||||
"topup_balance_bytes": _coerce_int_or_none(active.get("topup_balance_bytes")),
|
||||
"period_start_at": active.get("period_start_at").isoformat() if active.get("period_start_at") else None,
|
||||
|
||||
@@ -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 config.tariffs_config import Tariff
|
||||
from bot.utils.date_utils import add_months
|
||||
from bot.utils.date_utils import add_months, month_start
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from db.models import User, Subscription
|
||||
|
||||
@@ -825,7 +825,7 @@ class SubscriptionService:
|
||||
if target.billing_model == "period":
|
||||
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["period_start_at"] = sub.period_start_at or now
|
||||
update_data["period_start_at"] = month_start()
|
||||
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:
|
||||
update_data["end_date"] = now + timedelta(days=int(options["recalc_days"]))
|
||||
@@ -1077,7 +1077,7 @@ class SubscriptionService:
|
||||
|
||||
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
|
||||
period_start_at = (
|
||||
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
|
||||
@@ -1380,6 +1380,8 @@ class SubscriptionService:
|
||||
tariff = self._resolve_tariff(local_active_sub.tariff_key)
|
||||
except Exception:
|
||||
tariff = None
|
||||
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
|
||||
|
||||
return {
|
||||
"user_id": panel_user_data.get("uuid"),
|
||||
@@ -1389,11 +1391,11 @@ class SubscriptionService:
|
||||
"connect_button_url": connect_button_url,
|
||||
"traffic_limit_bytes": panel_traffic_limit,
|
||||
"traffic_used_bytes": panel_traffic_used,
|
||||
"traffic_limit_strategy": panel_traffic_strategy,
|
||||
"traffic_limit_strategy": traffic_limit_strategy,
|
||||
"tariff_key": local_active_sub.tariff_key if local_active_sub else None,
|
||||
"tariff_name": tariff.name(db_user.language_code or self.settings.DEFAULT_LANGUAGE) if tariff else None,
|
||||
"tariff_description": tariff.description(db_user.language_code or self.settings.DEFAULT_LANGUAGE) if tariff else None,
|
||||
"billing_model": tariff.billing_model if tariff else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period"),
|
||||
"billing_model": billing_model_display,
|
||||
"tier_baseline_bytes": local_active_sub.tier_baseline_bytes if local_active_sub else None,
|
||||
"topup_balance_bytes": local_active_sub.topup_balance_bytes if local_active_sub else 0,
|
||||
"period_start_at": local_active_sub.period_start_at if local_active_sub else None,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.date_utils import month_start
|
||||
from config.settings import Settings
|
||||
from db.dal import subscription_dal, tariff_dal
|
||||
from db.models import Subscription
|
||||
@@ -77,44 +78,54 @@ class TariffTrafficWorker:
|
||||
sub.traffic_limit_bytes = limit
|
||||
|
||||
if tariff.billing_model == "period":
|
||||
await self._maybe_reset_period(session, sub, tariff, used)
|
||||
reset_happened = await self._maybe_reset_period(session, sub, tariff, used)
|
||||
if reset_happened:
|
||||
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]) -> None:
|
||||
if not sub.period_start_at:
|
||||
sub.period_start_at = datetime.now(timezone.utc)
|
||||
return
|
||||
async def _maybe_reset_period(self, session: AsyncSession, sub: Subscription, tariff, used: Optional[int]) -> bool:
|
||||
now = datetime.now(timezone.utc)
|
||||
next_reset = sub.period_start_at + timedelta(days=30)
|
||||
if now < next_reset or sub.end_date <= now:
|
||||
return
|
||||
current_month_start = month_start(now)
|
||||
if not sub.period_start_at:
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
{"period_start_at": current_month_start},
|
||||
)
|
||||
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
|
||||
|
||||
used_now = int(used or sub.traffic_used_bytes or 0)
|
||||
baseline = int(sub.tier_baseline_bytes or tariff.monthly_bytes)
|
||||
topup_used = max(0, used_now - baseline)
|
||||
new_topup = max(0, int(sub.topup_balance_bytes or 0) - topup_used)
|
||||
await self.panel_service.reset_user_traffic(sub.panel_user_uuid)
|
||||
new_limit = baseline + new_topup
|
||||
payload = self.subscription_service._build_panel_update_payload(
|
||||
panel_user_uuid=sub.panel_user_uuid,
|
||||
expire_at=sub.end_date,
|
||||
status="ACTIVE",
|
||||
traffic_limit_bytes=new_limit,
|
||||
)
|
||||
payload["activeInternalSquads"] = tariff.squad_uuids
|
||||
await self.panel_service.update_user_details_on_panel(sub.panel_user_uuid, payload, log_response=False)
|
||||
restore_throttled = bool(sub.is_throttled)
|
||||
restore_succeeded = True
|
||||
if restore_throttled:
|
||||
for squad_uuid in tariff.squad_uuids:
|
||||
restore_succeeded = await self.panel_service.add_users_to_internal_squad(
|
||||
squad_uuid,
|
||||
[sub.panel_user_uuid],
|
||||
) and restore_succeeded
|
||||
should_clear_throttle = restore_throttled and restore_succeeded
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
{
|
||||
"period_start_at": next_reset,
|
||||
"period_start_at": current_month_start,
|
||||
"traffic_used_bytes": 0,
|
||||
"traffic_limit_bytes": new_limit,
|
||||
"topup_balance_bytes": new_topup,
|
||||
"is_throttled": False,
|
||||
"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
|
||||
sub.traffic_used_bytes = 0
|
||||
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(
|
||||
self,
|
||||
|
||||
+12
-1
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def add_months(base_dt: datetime, months_to_add: int) -> datetime:
|
||||
@@ -25,3 +26,13 @@ def add_months(base_dt: datetime, months_to_add: int) -> datetime:
|
||||
return base_dt.replace(year=year, month=month, day=clamped_day)
|
||||
|
||||
|
||||
def month_start(base_dt: Optional[datetime] = None) -> datetime:
|
||||
"""Return the first instant of the month in UTC for a datetime."""
|
||||
moment = base_dt or datetime.now(timezone.utc)
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
moment = moment.astimezone(timezone.utc)
|
||||
return datetime(moment.year, moment.month, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
|
||||
+48
-1
@@ -1,9 +1,13 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.utils.date_utils import month_start
|
||||
from config.settings import Settings
|
||||
from .models import Base
|
||||
from db.dal import subscription_dal
|
||||
from db.models import Base, Subscription
|
||||
from .migrator import run_database_migrations
|
||||
|
||||
async_engine = None
|
||||
@@ -137,6 +141,49 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
|
||||
"default_price": default_price,
|
||||
},
|
||||
)
|
||||
month_anchor = month_start()
|
||||
active_subs = await session.execute(
|
||||
select(Subscription).where(Subscription.is_active == True)
|
||||
)
|
||||
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()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
|
||||
+5
-4
@@ -21,13 +21,14 @@
|
||||
|
||||
## Period-Тариф
|
||||
|
||||
Period-тариф продает доступ на срок и личный 30-дневный лимит трафика.
|
||||
Period-тариф продает доступ на срок и лимит трафика с календарным ежемесячным сбросом.
|
||||
|
||||
- `monthly_gb` превращается в `tier_baseline_bytes`.
|
||||
- Докупленные пакеты хранятся в `topup_balance_bytes`.
|
||||
- В Remnawave пушится `trafficLimitBytes = tier_baseline_bytes + topup_balance_bytes`.
|
||||
- При продлении до окончания подписки дата 30-дневного сброса не меняется.
|
||||
- При покупке после лапса новый период начинается с момента оплаты.
|
||||
- Сброс происходит в начале календарного месяца для всех одинаково.
|
||||
- Если покупка или продление были в середине месяца, reset всё равно придёт на ближайший первый день следующего месяца.
|
||||
- Панель по-прежнему считает `usedTrafficBytes`, а бот только синхронизирует месячный reset и лимиты.
|
||||
|
||||
## Traffic-Тариф
|
||||
|
||||
@@ -61,7 +62,7 @@ Legacy поле `subscription_duration_months` остается для совм
|
||||
|
||||
`TariffTrafficWorker` запускается, только если активен `tariffs.json`.
|
||||
|
||||
- Раз в несколько минут проверяет 30-дневные сбросы.
|
||||
- Раз в несколько минут проверяет наступление нового календарного месяца.
|
||||
- Отправляет/дедуплицирует уровни предупреждений 80/95/100 через `traffic_warnings`.
|
||||
- При 100% удаляет пользователя из squad-ов тарифа и ставит `is_throttled`.
|
||||
- Возвращает пользователя в squad-ы, когда лимит снова больше использованного трафика.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from bot.utils.date_utils import month_start
|
||||
|
||||
|
||||
class DateUtilsTests(unittest.TestCase):
|
||||
def test_month_start_normalizes_aware_datetime(self):
|
||||
dt = datetime(2026, 4, 28, 15, 45, tzinfo=timezone.utc)
|
||||
|
||||
result = month_start(dt)
|
||||
|
||||
self.assertEqual(result, datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc))
|
||||
|
||||
def test_month_start_normalizes_naive_datetime_as_utc(self):
|
||||
dt = datetime(2026, 12, 31, 23, 59)
|
||||
|
||||
result = month_start(dt)
|
||||
|
||||
self.assertEqual(result, datetime(2026, 12, 1, 0, 0, tzinfo=timezone.utc))
|
||||
@@ -0,0 +1,62 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.services.tariff_worker import TariffTrafficWorker
|
||||
from bot.utils.date_utils import month_start
|
||||
|
||||
|
||||
class TariffWorkerTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_monthly_reset_triggers_on_calendar_change_before_30_days(self):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
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)
|
||||
current_month_start = month_start(now)
|
||||
previous_month_anchor = current_month_start - timedelta(days=2)
|
||||
sub = SimpleNamespace(
|
||||
subscription_id=1,
|
||||
panel_user_uuid="panel-uuid",
|
||||
period_start_at=previous_month_anchor,
|
||||
end_date=now + timedelta(days=10),
|
||||
traffic_used_bytes=123,
|
||||
traffic_limit_bytes=456,
|
||||
topup_balance_bytes=0,
|
||||
is_throttled=False,
|
||||
status_from_panel="ACTIVE",
|
||||
)
|
||||
tariff = SimpleNamespace(billing_model="period", squad_uuids=["squad-1"])
|
||||
|
||||
session = object()
|
||||
|
||||
with patch(
|
||||
"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")
|
||||
update_subscription.assert_awaited_once()
|
||||
update_payload = update_subscription.await_args.args[2]
|
||||
self.assertEqual(update_payload["period_start_at"], current_month_start)
|
||||
self.assertEqual(update_payload["traffic_used_bytes"], 0)
|
||||
clear_period_warnings.assert_awaited_once_with(session, 1)
|
||||
Reference in New Issue
Block a user