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"),