diff --git a/.gitignore b/.gitignore index bc2510c..4dcd4e4 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ __pycache__/ locales/ru_backup.json locales/en_backup.json db/models_old.py +config/tariffs.json diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 1a8e601..cacbe49 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -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, diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index c4e3d10..0a22226 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -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, diff --git a/bot/services/tariff_worker.py b/bot/services/tariff_worker.py index 71e3899..1896cf3 100644 --- a/bot/services/tariff_worker.py +++ b/bot/services/tariff_worker.py @@ -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, diff --git a/bot/utils/date_utils.py b/bot/utils/date_utils.py index b9e2fdf..6e5bec7 100644 --- a/bot/utils/date_utils.py +++ b/bot/utils/date_utils.py @@ -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) + + diff --git a/db/database_setup.py b/db/database_setup.py index b1da42c..813a389 100644 --- a/db/database_setup.py +++ b/db/database_setup.py @@ -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() diff --git a/docs/tariffs.md b/docs/tariffs.md index 7b6f1bf..064dcb4 100644 --- a/docs/tariffs.md +++ b/docs/tariffs.md @@ -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-ы, когда лимит снова больше использованного трафика. diff --git a/tests/test_date_utils.py b/tests/test_date_utils.py new file mode 100644 index 0000000..de05369 --- /dev/null +++ b/tests/test_date_utils.py @@ -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)) diff --git a/tests/test_tariff_worker.py b/tests/test_tariff_worker.py new file mode 100644 index 0000000..879d9df --- /dev/null +++ b/tests/test_tariff_worker.py @@ -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)