diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 686a6ea..e56c67a 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -409,6 +409,14 @@ SETTINGS_MANIFEST: List[SettingField] = [ "За сколько дней предупреждать", min=0, ), + SettingField( + "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", + "int", + "notifications", + "За сколько часов предупреждать", + min=0, + max=23, + ), SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"), SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"), SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"), diff --git a/backend/bot/services/subscription_notification_worker.py b/backend/bot/services/subscription_notification_worker.py new file mode 100644 index 0000000..636d9a8 --- /dev/null +++ b/backend/bot/services/subscription_notification_worker.py @@ -0,0 +1,346 @@ +import asyncio +import logging +import time +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Optional + +from aiogram import Bot +from aiogram.utils.text_decorations import html_decoration as hd +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload, sessionmaker + +from bot.infra.redis import redis_lock +from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup +from bot.middlewares.i18n import JsonI18n +from bot.services.panel_api_service import PanelApiService +from bot.services.subscription_service import SubscriptionService +from config.settings import Settings +from db.dal import subscription_dal +from db.models import Subscription + +SUBSCRIPTION_NOTIFICATION_LOCK = "subscription-notification-worker" +DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS = 300 +EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24) +EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48) + + +@dataclass(frozen=True) +class SubscriptionNotificationStage: + key: str + message_key: str + hours_before: Optional[int] = None + + +class SubscriptionNotificationWorker: + def __init__( + self, + settings: Settings, + session_factory: sessionmaker, + bot: Bot, + i18n: JsonI18n, + panel_service: PanelApiService, + subscription_service: SubscriptionService, + ) -> None: + self.settings = settings + self.session_factory = session_factory + self.bot = bot + self.i18n = i18n + self.panel_service = panel_service + self.subscription_service = subscription_service + self._stopped = asyncio.Event() + + async def run(self) -> None: + while not self._stopped.is_set(): + try: + async with redis_lock( + self.settings, + SUBSCRIPTION_NOTIFICATION_LOCK, + ttl_seconds=max(60, self._tick_seconds() - 10), + ) as acquired: + if not acquired: + logging.info( + "SubscriptionNotificationWorker tick skipped: Redis lock is held" + ) + else: + started = time.monotonic() + async with self.session_factory() as session: + await self.expiry_tick(session) + await self.trial_traffic_tick(session) + await session.commit() + logging.info( + "metric worker_tick_duration_seconds=%.3f " + "worker=subscription_notification", + time.monotonic() - started, + ) + except Exception: + logging.exception("SubscriptionNotificationWorker tick failed") + try: + await asyncio.wait_for(self._stopped.wait(), timeout=self._tick_seconds()) + except asyncio.TimeoutError: + pass + + def stop(self) -> None: + self._stopped.set() + + def _tick_seconds(self) -> int: + return int( + getattr( + self.settings, + "SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS", + DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS, + ) + or DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS + ) + + async def expiry_tick(self, session: AsyncSession) -> None: + if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True): + return + now = datetime.now(timezone.utc) + lower = now - EXPIRED_AFTER_NOTIFICATION_WINDOW + upper = now + self._max_before_window() + result = await session.execute( + select(Subscription) + .where( + Subscription.skip_notifications == False, + Subscription.end_date >= lower, + Subscription.end_date <= upper, + ) + .options(selectinload(Subscription.user)) + .order_by(Subscription.end_date.asc()) + ) + for sub in result.scalars().all(): + stage = self.stage_for_subscription(sub, now) + if stage is None: + continue + if await subscription_dal.has_subscription_notification( + session, + sub.subscription_id, + stage.key, + ): + continue + if not await self._send_expiry_notification(sub, stage): + continue + await subscription_dal.record_subscription_notification( + session, + sub.subscription_id, + stage.key, + sent_at=now, + ) + + def stage_for_subscription( + self, + sub: Subscription, + now: datetime, + ) -> Optional[SubscriptionNotificationStage]: + end_date = self._as_utc(getattr(sub, "end_date", None)) + if end_date is None: + return None + + seconds_left = (end_date - now).total_seconds() + if seconds_left > 0: + hours_before = int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0) + if 0 < hours_before <= 23 and seconds_left <= hours_before * 3600: + return SubscriptionNotificationStage( + key=f"before_{hours_before}h", + message_key="subscription_hours_notification", + hours_before=hours_before, + ) + + days_before_limit = max( + 0, + int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0), + ) + day_stages = ( + (1, "subscription_24h_notification"), + (2, "subscription_48h_notification"), + (3, "subscription_72h_notification"), + ) + for days_before, message_key in day_stages: + if days_before > days_before_limit: + continue + if seconds_left <= days_before * 24 * 3600: + return SubscriptionNotificationStage( + key=f"before_{days_before}d", + message_key=message_key, + ) + return None + + expired_for = now - end_date + if ( + getattr(self.settings, "SUBSCRIPTION_NOTIFY_ON_EXPIRE", True) + and expired_for <= EXPIRED_NOTIFICATION_WINDOW + ): + return SubscriptionNotificationStage( + key="expired", + message_key="subscription_expired_notification", + ) + if ( + getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True) + and EXPIRED_NOTIFICATION_WINDOW < expired_for <= EXPIRED_AFTER_NOTIFICATION_WINDOW + ): + return SubscriptionNotificationStage( + key="expired_24h_after", + message_key="subscription_expired_yesterday_notification", + ) + return None + + async def trial_traffic_tick(self, session: AsyncSession) -> None: + if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True): + return + now = datetime.now(timezone.utc) + result = await session.execute( + select(Subscription) + .where( + Subscription.skip_notifications == False, + Subscription.is_active == True, + Subscription.end_date > now, + Subscription.traffic_limit_bytes.is_not(None), + Subscription.traffic_limit_bytes > 0, + or_( + Subscription.provider == "trial", + Subscription.status_from_panel == "TRIAL", + Subscription.duration_months == 0, + ), + ) + .options(selectinload(Subscription.user)) + .order_by(Subscription.end_date.asc()) + ) + for sub in result.scalars().all(): + if await subscription_dal.has_subscription_notification( + session, + sub.subscription_id, + "trial_traffic_depleted", + ): + continue + + used = int(getattr(sub, "traffic_used_bytes", 0) or 0) + limit = int(getattr(sub, "traffic_limit_bytes", 0) or 0) + panel_data = await self._panel_user(sub) + if panel_data: + panel_used, panel_limit, _ = ( + self.subscription_service._extract_panel_traffic_details(panel_data) + ) + if panel_used is not None: + used = int(panel_used) + sub.traffic_used_bytes = used + if panel_limit is not None: + limit = int(panel_limit) + sub.traffic_limit_bytes = limit + panel_status = str(panel_data.get("status") or "").upper() + if panel_status: + sub.status_from_panel = panel_status + + if limit <= 0 or used < limit: + continue + if not await self._send_trial_traffic_depleted(sub, used=used, limit=limit): + continue + await subscription_dal.record_subscription_notification( + session, + sub.subscription_id, + "trial_traffic_depleted", + sent_at=now, + ) + + async def _panel_user(self, sub: Subscription) -> Optional[dict]: + panel_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip() + if not panel_uuid: + return None + try: + data = await self.panel_service.get_user_by_uuid(panel_uuid, log_response=False) + except Exception: + logging.exception( + "SubscriptionNotificationWorker: failed to fetch panel user %s", + panel_uuid, + ) + return None + return data if isinstance(data, dict) else None + + async def _send_expiry_notification( + self, + sub: Subscription, + stage: SubscriptionNotificationStage, + ) -> bool: + user_id = int(getattr(sub, "user_id", 0) or 0) + if user_id <= 0: + return False + user = getattr(sub, "user", None) + lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE + user_name = getattr(user, "first_name", None) or f"User {user_id}" + end_date = self._as_utc(getattr(sub, "end_date", None)) + end_date_text = end_date.strftime("%Y-%m-%d") if end_date else "" + translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw) + kwargs = {"user_name": user_name, "end_date": end_date_text} + if stage.hours_before is not None: + kwargs["hours"] = stage.hours_before + try: + await self.bot.send_message( + user_id, + translate(stage.message_key, **kwargs), + reply_markup=get_subscribe_only_markup(lang, self.i18n), + ) + return True + except Exception: + logging.exception( + "Failed to send subscription notification %s to user %s", + stage.key, + user_id, + ) + return False + + async def _send_trial_traffic_depleted( + self, + sub: Subscription, + *, + used: int, + limit: int, + ) -> bool: + user_id = int(getattr(sub, "user_id", 0) or 0) + if user_id <= 0: + return False + user = getattr(sub, "user", None) + lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE + translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw) + remaining = max(0, limit - used) + try: + await self.bot.send_message( + user_id, + translate( + "trial_traffic_depleted_notification", + used=hd.quote(self._fmt_bytes(used)), + remaining=hd.quote(self._fmt_bytes(remaining)), + limit_total=hd.quote(self._fmt_bytes(limit)), + ), + reply_markup=get_subscribe_only_markup(lang, self.i18n), + parse_mode="HTML", + ) + return True + except Exception: + logging.exception("Failed to send trial traffic depleted warning to user %s", user_id) + return False + + def _max_before_window(self) -> timedelta: + days_before = max(0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0)) + hours_before = max( + 0, + int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0), + ) + return max(timedelta(days=min(days_before, 3)), timedelta(hours=hours_before)) + + @staticmethod + def _as_utc(value: Optional[datetime]) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + @staticmethod + def _fmt_bytes(value: int) -> str: + size = float(max(0, int(value or 0))) + for unit in ("B", "KB", "MB", "GB", "TB"): + if size < 1024 or unit == "TB": + return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B" + size /= 1024 + return f"{size:.1f} TB" diff --git a/backend/bot/services/subscription_service_impl/trial.py b/backend/bot/services/subscription_service_impl/trial.py index 509d3d9..636e042 100644 --- a/backend/bot/services/subscription_service_impl/trial.py +++ b/backend/bot/services/subscription_service_impl/trial.py @@ -62,6 +62,7 @@ class TrialSubscriptionMixin: "status_from_panel": "TRIAL", "traffic_limit_bytes": self.settings.trial_traffic_limit_bytes, "auto_renew_enabled": False, + "provider": "trial", } try: await subscription_dal.upsert_subscription(session, trial_sub_data) diff --git a/backend/config/settings.py b/backend/config/settings.py index d0ace68..93d93a6 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -243,6 +243,8 @@ class Settings(BaseSettings): SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True) SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True) SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3) + SUBSCRIPTION_NOTIFY_HOURS_BEFORE: int = Field(default=3) + SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS: int = Field(default=300) REFERRAL_BONUS_DAYS_INVITER_1_MONTH: Optional[int] = Field( default=3, alias="REFERRAL_BONUS_DAYS_1_MONTH" diff --git a/backend/db/dal/subscription_dal.py b/backend/db/dal/subscription_dal.py index f8392c3..a59d900 100644 --- a/backend/db/dal/subscription_dal.py +++ b/backend/db/dal/subscription_dal.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload -from db.models import Subscription +from db.models import Subscription, SubscriptionNotification INSTALL_SHARE_TOKEN_BYTES = 16 @@ -318,6 +318,45 @@ async def update_subscription_notification_time( ) +async def has_subscription_notification( + session: AsyncSession, + subscription_id: int, + notification_key: str, +) -> bool: + stmt = ( + select(SubscriptionNotification.notification_id) + .where( + SubscriptionNotification.subscription_id == subscription_id, + SubscriptionNotification.notification_key == notification_key, + ) + .limit(1) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() is not None + + +async def record_subscription_notification( + session: AsyncSession, + subscription_id: int, + notification_key: str, + *, + sent_at: Optional[datetime] = None, +) -> None: + if sent_at is None: + sent_at = datetime.now(timezone.utc) + existing = await has_subscription_notification(session, subscription_id, notification_key) + if existing: + return + session.add( + SubscriptionNotification( + subscription_id=subscription_id, + notification_key=notification_key, + sent_at=sent_at, + ) + ) + await update_subscription_notification_time(session, subscription_id, sent_at) + + async def find_subscription_for_notification_update( session: AsyncSession, user_id: int, subscription_end_date_to_match: datetime ) -> Optional[Subscription]: diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 3a1b54a..3b2b7dd 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -18,6 +18,7 @@ from ..models import ( Payment, PromoCodeActivation, Subscription, + SubscriptionNotification, SupportTicket, SupportTicketMessage, TariffChange, @@ -779,6 +780,11 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool await session.execute( delete(TrafficWarning).where(TrafficWarning.subscription_id.in_(subscription_ids)) ) + await session.execute( + delete(SubscriptionNotification).where( + SubscriptionNotification.subscription_id.in_(subscription_ids) + ) + ) await session.execute( delete(SupportTicketMessage).where(SupportTicketMessage.ticket_id.in_(support_ticket_ids)) ) diff --git a/backend/db/migrator.py b/backend/db/migrator.py index cc32ca1..9afa401 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -1012,6 +1012,41 @@ def _migration_0030_add_hwid_pricing_metadata(connection: Connection) -> None: ) +def _migration_0031_add_subscription_notifications(connection: Connection) -> None: + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS subscription_notifications ( + notification_id SERIAL PRIMARY KEY, + subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id), + notification_key VARCHAR(64) NOT NULL, + sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_subscription_notification_key UNIQUE ( + subscription_id, + notification_key + ) + ) + """ + ) + ) + connection.execute( + text( + """ + CREATE INDEX IF NOT EXISTS ix_subscription_notifications_subscription_id + ON subscription_notifications (subscription_id) + """ + ) + ) + connection.execute( + text( + """ + CREATE INDEX IF NOT EXISTS ix_subscription_notifications_notification_key + ON subscription_notifications (notification_key) + """ + ) + ) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -1174,6 +1209,11 @@ MIGRATIONS: List[Migration] = [ description="Persist quoted HWID top-up pricing windows and conversion audit", upgrade=_migration_0030_add_hwid_pricing_metadata, ), + Migration( + id="0031_add_subscription_notifications", + description="Track sent subscription notification stages", + upgrade=_migration_0031_add_subscription_notifications, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index b8859a1..08b17d7 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -277,6 +277,26 @@ class TrafficWarning(Base): subscription = relationship("Subscription") +class SubscriptionNotification(Base): + __tablename__ = "subscription_notifications" + __table_args__ = ( + UniqueConstraint( + "subscription_id", + "notification_key", + name="uq_subscription_notification_key", + ), + ) + + notification_id = Column(Integer, primary_key=True, autoincrement=True) + subscription_id = Column( + Integer, ForeignKey("subscriptions.subscription_id"), nullable=False, index=True + ) + notification_key = Column(String(64), nullable=False, index=True) + sent_at = Column(DateTime(timezone=True), server_default=func.now()) + + subscription = relationship("Subscription") + + class TariffChange(Base): __tablename__ = "tariff_changes" diff --git a/backend/main_worker.py b/backend/main_worker.py index 846c14f..547d7fc 100644 --- a/backend/main_worker.py +++ b/backend/main_worker.py @@ -25,6 +25,7 @@ from bot.payment_providers.yookassa import ( ) from bot.services.backup_worker import BackupWorker from bot.services.locale_override_service import load_locale_overrides +from bot.services.subscription_notification_worker import SubscriptionNotificationWorker from bot.services.tariff_worker import TariffTrafficWorker from bot.utils.message_queue import init_queue_manager from config.settings import get_settings @@ -192,6 +193,20 @@ async def main() -> None: tasks = [] if settings.tariffs_config: tasks.append(asyncio.create_task(tariff_worker.run(), name="TariffTrafficWorker")) + subscription_notification_worker = SubscriptionNotificationWorker( + settings, + session_factory, + bot, + i18n, + services["panel_service"], + services["subscription_service"], + ) + tasks.append( + asyncio.create_task( + subscription_notification_worker.run(), + name="SubscriptionNotificationWorker", + ) + ) backup_worker = BackupWorker(settings, bot, session_factory=session_factory) tasks.append(asyncio.create_task(backup_worker.run(), name="BackupWorker")) tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services))) diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index 19c624d..07e9a58 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -388,6 +388,8 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI | `SUBSCRIPTION_NOTIFY_ON_EXPIRE` | Уведомлять в день окончания. | | `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE` | Уведомлять после окончания. | | `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` | За сколько дней предупреждать. | +| `SUBSCRIPTION_NOTIFY_HOURS_BEFORE` | За сколько часов предупреждать дополнительно. | +| `SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS` | Период локальной проверки уведомлений. | ## Поддержка diff --git a/locales/en.json b/locales/en.json index 92573cd..a35660e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -257,8 +257,10 @@ "subscription_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.", "subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.", "subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.", + "subscription_hours_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in {hours} hours — {end_date}.\n\nPlease renew it using the button below.", "subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your service subscription expired on {end_date}.\n\nPlease renew it using the button below.", "subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.", + "trial_traffic_depleted_notification": "⛔️ Trial traffic is used up.\n\nUsed: {used}\nRemaining: {remaining}\nLimit: {limit_total}\n\nTo keep using the service, buy a subscription using the button below.", "autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.", "autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.", "autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.", @@ -1559,6 +1561,7 @@ "admin_settings_field_subscription_notify_on_expire_label": "Subscription Notify On Expire", "admin_settings_field_subscription_notify_after_expire_label": "Subscription Notify After Expire", "admin_settings_field_subscription_notify_days_before_label": "Subscription Notify Days Before", + "admin_settings_field_subscription_notify_hours_before_label": "Subscription Notify Hours Before", "admin_settings_field_log_new_users_label": "Log New Users", "admin_settings_field_log_payments_label": "Log Payments", "admin_settings_field_log_promo_activations_label": "Log Promo Activations", diff --git a/locales/ru.json b/locales/ru.json index 231742a..db400ea 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -190,8 +190,10 @@ "subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.", + "subscription_hours_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через {hours} ч. — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на сервис истек ({end_date}).\n\nПродлите её по кнопке ниже.", "subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.", + "trial_traffic_depleted_notification": "⛔️ Трафик пробного периода израсходован.\n\nИспользовано: {used}\nОсталось: {remaining}\nЛимит: {limit_total}\n\nЧтобы продолжить пользоваться сервисом, оформите подписку по кнопке ниже.", "autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.", "autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.", "autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.", @@ -1559,6 +1561,7 @@ "admin_settings_field_subscription_notify_on_expire_label": "Уведомлять об истечении", "admin_settings_field_subscription_notify_after_expire_label": "Уведомлять после истечения", "admin_settings_field_subscription_notify_days_before_label": "За сколько дней предупреждать", + "admin_settings_field_subscription_notify_hours_before_label": "За сколько часов предупреждать", "admin_settings_field_log_new_users_label": "Логировать новых пользователей", "admin_settings_field_log_payments_label": "Логировать платежи", "admin_settings_field_log_promo_activations_label": "Логировать активации промокодов", diff --git a/tests/test_settings.py b/tests/test_settings.py index 46be6e4..0cb713e 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -271,3 +271,14 @@ class SettingsTests(unittest.TestCase): ) self.assertEqual(settings.tariff_traffic_warning_levels, [85, 90, 95]) + + def test_subscription_hour_notification_default_is_available(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + ) + + self.assertEqual(settings.SUBSCRIPTION_NOTIFY_HOURS_BEFORE, 3) + self.assertEqual(settings.SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS, 300) diff --git a/tests/test_subscription_notification_worker.py b/tests/test_subscription_notification_worker.py new file mode 100644 index 0000000..ba7d0cd --- /dev/null +++ b/tests/test_subscription_notification_worker.py @@ -0,0 +1,60 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +from bot.services.subscription_notification_worker import SubscriptionNotificationWorker + + +def _worker(**overrides): + settings = SimpleNamespace( + SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3, + SUBSCRIPTION_NOTIFY_HOURS_BEFORE=3, + SUBSCRIPTION_NOTIFY_ON_EXPIRE=True, + SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True, + SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS=300, + **overrides, + ) + return SubscriptionNotificationWorker( + settings=settings, + session_factory=object(), + bot=object(), + i18n=object(), + panel_service=object(), + subscription_service=object(), + ) + + +def _sub(end_date): + return SimpleNamespace(end_date=end_date) + + +def test_stage_prefers_hour_reminder_over_day_backlog(): + now = datetime(2026, 5, 28, 12, tzinfo=timezone.utc) + stage = _worker().stage_for_subscription(_sub(now + timedelta(hours=2, minutes=30)), now) + + assert stage.key == "before_3h" + assert stage.message_key == "subscription_hours_notification" + assert stage.hours_before == 3 + + +def test_stage_uses_most_imminent_day_reminder(): + now = datetime(2026, 5, 28, 12, tzinfo=timezone.utc) + stage = _worker().stage_for_subscription(_sub(now + timedelta(hours=23)), now) + + assert stage.key == "before_1d" + assert stage.message_key == "subscription_24h_notification" + + +def test_stage_sends_expired_during_first_day_after_end(): + now = datetime(2026, 5, 28, 12, tzinfo=timezone.utc) + stage = _worker().stage_for_subscription(_sub(now - timedelta(hours=1)), now) + + assert stage.key == "expired" + assert stage.message_key == "subscription_expired_notification" + + +def test_stage_sends_yesterday_notice_only_after_first_day(): + now = datetime(2026, 5, 28, 12, tzinfo=timezone.utc) + stage = _worker().stage_for_subscription(_sub(now - timedelta(hours=25)), now) + + assert stage.key == "expired_24h_after" + assert stage.message_key == "subscription_expired_yesterday_notification" diff --git a/tests/test_user_dal.py b/tests/test_user_dal.py index 15fa647..05cb13e 100644 --- a/tests/test_user_dal.py +++ b/tests/test_user_dal.py @@ -83,6 +83,10 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase): delete_tables.index("traffic_warnings"), delete_tables.index("subscriptions"), ) + self.assertLess( + delete_tables.index("subscription_notifications"), + delete_tables.index("subscriptions"), + ) self.assertLess( delete_tables.index("promo_code_activations"), delete_tables.index("payments"),