fix: defer expiry reminders for trial and bonus subscriptions
Trial and registration/referral-bonus subscriptions usually last only a few days, so the multi-day ending-soon reminders fired almost the moment they were granted and needlessly alarmed newcomers. Track this with a new subscription flag (suppress_early_expiry_notifications, migration 0035): trial activation, referral welcome bonus and inviter bonus grants set it, while a real paid purchase clears it on upsert. While set, the notification worker skips the day-before stages but still sends the hours-before reminder and the expiry/after-expiry notices, so users are warned shortly before access ends. Once they pay for a full subscription the complete reminder spectrum resumes.
This commit is contained in:
@@ -197,6 +197,9 @@ class ReferralService:
|
|||||||
"status_from_panel": "ACTIVE_BONUS",
|
"status_from_panel": "ACTIVE_BONUS",
|
||||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||||
"auto_renew_enabled": False,
|
"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:
|
try:
|
||||||
await subscription_dal.deactivate_other_active_subscriptions(
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
|
|||||||
@@ -153,6 +153,15 @@ class SubscriptionNotificationWorker:
|
|||||||
hours_before=hours_before,
|
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(
|
days_before_limit = max(
|
||||||
0,
|
0,
|
||||||
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0),
|
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0),
|
||||||
|
|||||||
@@ -641,6 +641,9 @@ class SubscriptionLifecycleMixin:
|
|||||||
"traffic_limit_bytes": traffic_limit_bytes,
|
"traffic_limit_bytes": traffic_limit_bytes,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"skip_notifications": False,
|
"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,
|
"auto_renew_enabled": auto_renew_should_enable,
|
||||||
"tariff_key": tariff.key if tariff else None,
|
"tariff_key": tariff.key if tariff else None,
|
||||||
"tier_baseline_bytes": tier_baseline_bytes,
|
"tier_baseline_bytes": tier_baseline_bytes,
|
||||||
@@ -776,6 +779,9 @@ class SubscriptionLifecycleMixin:
|
|||||||
"status_from_panel": "ACTIVE_BONUS",
|
"status_from_panel": "ACTIVE_BONUS",
|
||||||
"traffic_limit_bytes": traffic_limit,
|
"traffic_limit_bytes": traffic_limit,
|
||||||
"auto_renew_enabled": False,
|
"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(
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
session, panel_uuid, panel_sub_uuid
|
session, panel_uuid, panel_sub_uuid
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ class TrialSubscriptionMixin:
|
|||||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||||
"auto_renew_enabled": False,
|
"auto_renew_enabled": False,
|
||||||
"provider": "trial",
|
"provider": "trial",
|
||||||
|
# Short trial: only warn a few hours before it ends, not days ahead.
|
||||||
|
"suppress_early_expiry_notifications": True,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
||||||
|
|||||||
@@ -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] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
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",
|
description="Store legacy import mappings and referral codes for source-bot migrations",
|
||||||
upgrade=_migration_0034_add_legacy_import_compatibility,
|
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,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,12 @@ class Subscription(Base):
|
|||||||
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
||||||
provider = Column(String, nullable=True)
|
provider = Column(String, nullable=True)
|
||||||
skip_notifications = Column(Boolean, default=False)
|
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)
|
auto_renew_enabled = Column(Boolean, default=True, index=True)
|
||||||
tariff_key = Column(String, nullable=True, index=True)
|
tariff_key = Column(String, nullable=True, index=True)
|
||||||
tier_baseline_bytes = Column(BigInteger, nullable=True)
|
tier_baseline_bytes = Column(BigInteger, nullable=True)
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ def _worker(**overrides):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sub(end_date):
|
def _sub(end_date, *, suppress_early_expiry_notifications=False):
|
||||||
return SimpleNamespace(end_date=end_date)
|
return SimpleNamespace(
|
||||||
|
end_date=end_date,
|
||||||
|
suppress_early_expiry_notifications=suppress_early_expiry_notifications,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_stage_prefers_hour_reminder_over_day_backlog():
|
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.key == "expired_24h_after"
|
||||||
assert stage.message_key == "subscription_expired_yesterday_notification"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user