diff --git a/backend/bot/services/referral_service.py b/backend/bot/services/referral_service.py index 53efd36..28c2d0e 100644 --- a/backend/bot/services/referral_service.py +++ b/backend/bot/services/referral_service.py @@ -197,6 +197,9 @@ class ReferralService: "status_from_panel": "ACTIVE_BONUS", "traffic_limit_bytes": self.settings.user_traffic_limit_bytes, "auto_renew_enabled": False, + # Short bonus grant: warn only hours before it + # ends, not days ahead. A real payment clears this. + "suppress_early_expiry_notifications": True, } try: await subscription_dal.deactivate_other_active_subscriptions( diff --git a/backend/bot/services/subscription_notification_worker.py b/backend/bot/services/subscription_notification_worker.py index 674a5d9..74d4c83 100644 --- a/backend/bot/services/subscription_notification_worker.py +++ b/backend/bot/services/subscription_notification_worker.py @@ -153,6 +153,15 @@ class SubscriptionNotificationWorker: hours_before=hours_before, ) + # Trial and registration/referral-bonus subscriptions last only a + # few days, so a multi-day "ending soon" reminder would fire almost + # the moment they are granted and needlessly alarm newcomers. Skip + # the day-before stages for them — they still get the hours-before + # reminder above and the expiry/after-expiry notices below. Paying + # for a real subscription clears the flag and restores all stages. + if bool(getattr(sub, "suppress_early_expiry_notifications", False)): + return None + days_before_limit = max( 0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0), diff --git a/backend/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py index 4a4eea3..ff34104 100644 --- a/backend/bot/services/subscription_service_impl/lifecycle.py +++ b/backend/bot/services/subscription_service_impl/lifecycle.py @@ -641,6 +641,9 @@ class SubscriptionLifecycleMixin: "traffic_limit_bytes": traffic_limit_bytes, "provider": provider, "skip_notifications": False, + # A real payment restores the full reminder spectrum, clearing any + # trial/bonus suppression carried over on this panel subscription. + "suppress_early_expiry_notifications": False, "auto_renew_enabled": auto_renew_should_enable, "tariff_key": tariff.key if tariff else None, "tier_baseline_bytes": tier_baseline_bytes, @@ -776,6 +779,9 @@ class SubscriptionLifecycleMixin: "status_from_panel": "ACTIVE_BONUS", "traffic_limit_bytes": traffic_limit, "auto_renew_enabled": False, + # Registration/referral bonus grants are short-lived, like a + # trial: only warn a few hours before they end, not days ahead. + "suppress_early_expiry_notifications": True, } await subscription_dal.deactivate_other_active_subscriptions( session, panel_uuid, panel_sub_uuid diff --git a/backend/bot/services/subscription_service_impl/trial.py b/backend/bot/services/subscription_service_impl/trial.py index a963a85..1940e4b 100644 --- a/backend/bot/services/subscription_service_impl/trial.py +++ b/backend/bot/services/subscription_service_impl/trial.py @@ -63,6 +63,8 @@ class TrialSubscriptionMixin: "traffic_limit_bytes": self.settings.trial_traffic_limit_bytes, "auto_renew_enabled": False, "provider": "trial", + # Short trial: only warn a few hours before it ends, not days ahead. + "suppress_early_expiry_notifications": True, } try: await subscription_dal.upsert_subscription(session, trial_sub_data) diff --git a/backend/db/migrator.py b/backend/db/migrator.py index 18a76fa..ad97cce 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -1143,6 +1143,18 @@ def _migration_0034_add_legacy_import_compatibility(connection: Connection) -> N ) +def _migration_0035_add_subscription_promo_expiry_flag(connection: Connection) -> None: + inspector = inspect(connection) + columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")} + if "suppress_early_expiry_notifications" not in columns: + connection.execute( + text( + "ALTER TABLE subscriptions ADD COLUMN suppress_early_expiry_notifications " + "BOOLEAN NOT NULL DEFAULT FALSE" + ) + ) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -1325,6 +1337,11 @@ MIGRATIONS: List[Migration] = [ description="Store legacy import mappings and referral codes for source-bot migrations", upgrade=_migration_0034_add_legacy_import_compatibility, ), + Migration( + id="0035_add_subscription_promo_expiry_flag", + description="Suppress multi-day expiry reminders for trial and bonus subscriptions", + upgrade=_migration_0035_add_subscription_promo_expiry_flag, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index 585dcb4..25d70e2 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -122,6 +122,12 @@ class Subscription(Base): last_notification_sent = Column(DateTime(timezone=True), nullable=True) provider = Column(String, nullable=True) skip_notifications = Column(Boolean, default=False) + # Trial and registration/referral-bonus subscriptions are only a few days + # long, so the multi-day "ending soon" reminders would fire almost as soon + # as they are granted. While this is set the worker keeps only the + # hours-before reminder plus the expiry/after-expiry notices; a real payment + # clears it so the full reminder spectrum resumes. + suppress_early_expiry_notifications = Column(Boolean, nullable=False, default=False) auto_renew_enabled = Column(Boolean, default=True, index=True) tariff_key = Column(String, nullable=True, index=True) tier_baseline_bytes = Column(BigInteger, nullable=True) diff --git a/tests/test_subscription_notification_worker.py b/tests/test_subscription_notification_worker.py index ba7d0cd..dcbbf7d 100644 --- a/tests/test_subscription_notification_worker.py +++ b/tests/test_subscription_notification_worker.py @@ -23,8 +23,11 @@ def _worker(**overrides): ) -def _sub(end_date): - return SimpleNamespace(end_date=end_date) +def _sub(end_date, *, suppress_early_expiry_notifications=False): + return SimpleNamespace( + end_date=end_date, + suppress_early_expiry_notifications=suppress_early_expiry_notifications, + ) def test_stage_prefers_hour_reminder_over_day_backlog(): @@ -58,3 +61,28 @@ def test_stage_sends_yesterday_notice_only_after_first_day(): assert stage.key == "expired_24h_after" assert stage.message_key == "subscription_expired_yesterday_notification" + + +def test_promo_subscription_skips_day_before_reminders(): + now = datetime(2026, 5, 28, 12, tzinfo=timezone.utc) + sub = _sub(now + timedelta(hours=23), suppress_early_expiry_notifications=True) + + assert _worker().stage_for_subscription(sub, now) is None + + +def test_promo_subscription_still_gets_hours_before_reminder(): + now = datetime(2026, 5, 28, 12, tzinfo=timezone.utc) + sub = _sub(now + timedelta(hours=2, minutes=30), suppress_early_expiry_notifications=True) + stage = _worker().stage_for_subscription(sub, now) + + assert stage.key == "before_3h" + assert stage.hours_before == 3 + + +def test_promo_subscription_still_gets_expiry_notice(): + now = datetime(2026, 5, 28, 12, tzinfo=timezone.utc) + sub = _sub(now - timedelta(hours=1), suppress_early_expiry_notifications=True) + stage = _worker().stage_for_subscription(sub, now) + + assert stage.key == "expired" + assert stage.message_key == "subscription_expired_notification"