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
@@ -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,
+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 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,
+37 -26
View File
@@ -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
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:
@@ -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)