feat: align tariff resets to calendar month

This commit is contained in:
3252a8
2026-04-28 15:25:46 +03:00
parent 338008bbd0
commit 258b2f4dec
9 changed files with 193 additions and 37 deletions
+1
View File
@@ -29,3 +29,4 @@ __pycache__/
locales/ru_backup.json locales/ru_backup.json
locales/en_backup.json locales/en_backup.json
db/models_old.py db/models_old.py
config/tariffs.json
+1
View File
@@ -2209,6 +2209,7 @@ def _serialize_subscription(
"tariff_name": active.get("tariff_name"), "tariff_name": active.get("tariff_name"),
"tariff_description": active.get("tariff_description"), "tariff_description": active.get("tariff_description"),
"billing_model": active.get("billing_model"), "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")), "tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")),
"topup_balance_bytes": _coerce_int_or_none(active.get("topup_balance_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, "period_start_at": active.get("period_start_at").isoformat() if active.get("period_start_at") else None,
+7 -5
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 from bot.utils.date_utils import add_months, month_start
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"] = 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() 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"]))
@@ -1077,7 +1077,7 @@ 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 = ( period_start_at = month_start() if tariff else (
datetime.now(timezone.utc) datetime.now(timezone.utc)
if starts_after_lapse or not current_active_sub or not getattr(current_active_sub, "period_start_at", None) 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 else current_active_sub.period_start_at
@@ -1380,6 +1380,8 @@ class SubscriptionService:
tariff = self._resolve_tariff(local_active_sub.tariff_key) tariff = self._resolve_tariff(local_active_sub.tariff_key)
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")
traffic_limit_strategy = "MONTH" if billing_model_display == "period" else panel_traffic_strategy
return { return {
"user_id": panel_user_data.get("uuid"), "user_id": panel_user_data.get("uuid"),
@@ -1389,11 +1391,11 @@ class SubscriptionService:
"connect_button_url": connect_button_url, "connect_button_url": connect_button_url,
"traffic_limit_bytes": panel_traffic_limit, "traffic_limit_bytes": panel_traffic_limit,
"traffic_used_bytes": panel_traffic_used, "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_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_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, "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, "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, "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, "period_start_at": local_active_sub.period_start_at if local_active_sub else None,
+37 -26
View File
@@ -1,6 +1,6 @@
import asyncio import asyncio
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from aiogram import Bot from aiogram import Bot
@@ -11,6 +11,7 @@ from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.utils.date_utils import month_start
from config.settings import Settings from config.settings import Settings
from db.dal import subscription_dal, tariff_dal from db.dal import subscription_dal, tariff_dal
from db.models import Subscription from db.models import Subscription
@@ -77,44 +78,54 @@ class TariffTrafficWorker:
sub.traffic_limit_bytes = limit sub.traffic_limit_bytes = limit
if tariff.billing_model == "period": 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) 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: async def _maybe_reset_period(self, session: AsyncSession, sub: Subscription, tariff, used: Optional[int]) -> bool:
if not sub.period_start_at:
sub.period_start_at = datetime.now(timezone.utc)
return
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
next_reset = sub.period_start_at + timedelta(days=30) current_month_start = month_start(now)
if now < next_reset or sub.end_date <= now: if not sub.period_start_at:
return 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) await self.panel_service.reset_user_traffic(sub.panel_user_uuid)
new_limit = baseline + new_topup restore_throttled = bool(sub.is_throttled)
payload = self.subscription_service._build_panel_update_payload( restore_succeeded = True
panel_user_uuid=sub.panel_user_uuid, if restore_throttled:
expire_at=sub.end_date, for squad_uuid in tariff.squad_uuids:
status="ACTIVE", restore_succeeded = await self.panel_service.add_users_to_internal_squad(
traffic_limit_bytes=new_limit, squad_uuid,
) [sub.panel_user_uuid],
payload["activeInternalSquads"] = tariff.squad_uuids ) and restore_succeeded
await self.panel_service.update_user_details_on_panel(sub.panel_user_uuid, payload, log_response=False) should_clear_throttle = restore_throttled and restore_succeeded
await subscription_dal.update_subscription( await subscription_dal.update_subscription(
session, session,
sub.subscription_id, sub.subscription_id,
{ {
"period_start_at": next_reset, "period_start_at": current_month_start,
"traffic_used_bytes": 0, "traffic_used_bytes": 0,
"traffic_limit_bytes": new_limit, "is_throttled": False if should_clear_throttle else sub.is_throttled,
"topup_balance_bytes": new_topup, "status_from_panel": "ACTIVE" if should_clear_throttle else sub.status_from_panel,
"is_throttled": False,
}, },
) )
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) 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,
+12 -1
View File
@@ -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: 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) 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
View File
@@ -1,9 +1,13 @@
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 .models import Base from db.dal import subscription_dal
from db.models import Base, Subscription
from .migrator import run_database_migrations from .migrator import run_database_migrations
async_engine = None async_engine = None
@@ -137,6 +141,49 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
"default_price": default_price, "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() await session.commit()
except Exception: except Exception:
await session.rollback() await session.rollback()
+5 -4
View File
@@ -21,13 +21,14 @@
## Period-Тариф ## Period-Тариф
Period-тариф продает доступ на срок и личный 30-дневный лимит трафика. 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`.
- При продлении до окончания подписки дата 30-дневного сброса не меняется. - Сброс происходит в начале календарного месяца для всех одинаково.
- При покупке после лапса новый период начинается с момента оплаты. - Если покупка или продление были в середине месяца, reset всё равно придёт на ближайший первый день следующего месяца.
- Панель по-прежнему считает `usedTrafficBytes`, а бот только синхронизирует месячный reset и лимиты.
## Traffic-Тариф ## Traffic-Тариф
@@ -61,7 +62,7 @@ Legacy поле `subscription_duration_months` остается для совм
`TariffTrafficWorker` запускается, только если активен `tariffs.json`. `TariffTrafficWorker` запускается, только если активен `tariffs.json`.
- Раз в несколько минут проверяет 30-дневные сбросы. - Раз в несколько минут проверяет наступление нового календарного месяца.
- Отправляет/дедуплицирует уровни предупреждений 80/95/100 через `traffic_warnings`. - Отправляет/дедуплицирует уровни предупреждений 80/95/100 через `traffic_warnings`.
- При 100% удаляет пользователя из squad-ов тарифа и ставит `is_throttled`. - При 100% удаляет пользователя из squad-ов тарифа и ставит `is_throttled`.
- Возвращает пользователя в squad-ы, когда лимит снова больше использованного трафика. - Возвращает пользователя в squad-ы, когда лимит снова больше использованного трафика.
+20
View File
@@ -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))
+62
View File
@@ -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)