diff --git a/clients/apps/web/src/components/Settings/OrganizationCustomerEmailSettings.tsx b/clients/apps/web/src/components/Settings/OrganizationCustomerEmailSettings.tsx index 20e25d6f89..fb5ac695d0 100644 --- a/clients/apps/web/src/components/Settings/OrganizationCustomerEmailSettings.tsx +++ b/clients/apps/web/src/components/Settings/OrganizationCustomerEmailSettings.tsx @@ -71,6 +71,11 @@ const customerEmails: { title: 'Subscription past due', description: 'Sent when a subscription payment fails and becomes overdue', }, + { + key: 'subscription_renewal_reminder', + title: 'Subscription renewal reminder', + description: 'Sent 7 days before a yearly subscription renews automatically', + }, ] const OrganizationCustomerEmailSettings: React.FC< diff --git a/server/emails/src/emails/index.ts b/server/emails/src/emails/index.ts index c8e37595d1..4bded1e9c7 100644 --- a/server/emails/src/emails/index.ts +++ b/server/emails/src/emails/index.ts @@ -17,6 +17,7 @@ import { SubscriptionCancellation } from './subscription_cancellation' import { SubscriptionConfirmation } from './subscription_confirmation' import { SubscriptionCycled } from './subscription_cycled' import { SubscriptionPastDue } from './subscription_past_due' +import { SubscriptionRenewalReminder } from './subscription_renewal_reminder' import { SubscriptionRevoked } from './subscription_revoked' import { SubscriptionUncanceled } from './subscription_uncanceled' import { SubscriptionUpdated } from './subscription_updated' @@ -37,6 +38,7 @@ const TEMPLATES: Record> = { subscription_confirmation: SubscriptionConfirmation, subscription_cycled: SubscriptionCycled, subscription_past_due: SubscriptionPastDue, + subscription_renewal_reminder: SubscriptionRenewalReminder, subscription_revoked: SubscriptionRevoked, subscription_uncanceled: SubscriptionUncanceled, subscription_updated: SubscriptionUpdated, diff --git a/server/emails/src/emails/subscription_renewal_reminder.tsx b/server/emails/src/emails/subscription_renewal_reminder.tsx new file mode 100644 index 0000000000..3a77df5b25 --- /dev/null +++ b/server/emails/src/emails/subscription_renewal_reminder.tsx @@ -0,0 +1,77 @@ +import { + Heading, + Hr, + Link, + Preview, + Section, + Text, +} from '@react-email/components' +import BodyText from '../components/BodyText' +import Button from '../components/Button' +import FooterCustomer from '../components/FooterCustomer' +import OrganizationHeader from '../components/OrganizationHeader' +import Wrapper from '../components/Wrapper' +import { organization, product } from '../preview' +import type { schemas } from '../types' + +export function SubscriptionRenewalReminder({ + email, + organization, + product, + subscription, + url, +}: schemas['SubscriptionRenewalReminderProps']) { + return ( + + Your subscription to {product.name} renews soon + +
+ + Your subscription renews soon + + + This is a friendly reminder that your yearly subscription to{' '} + {product.name} will renew + automatically in 7 days. + + + You don't need to do anything — your subscription will continue + uninterrupted with the same benefits you enjoy today. + + + If you'd like to review your subscription or make any changes, you can + manage everything from your customer portal. + +
+
+ +
+
+
+ + If you're having trouble with the button above, copy and paste the URL + below into your web browser. + + + + {url} + + +
+ +
+ ) +} + +SubscriptionRenewalReminder.PreviewProps = { + email: 'john@example.com', + organization, + product, + subscription: { + id: '12345', + status: 'active', + }, + url: 'https://polar.sh/acme-inc/portal/subscriptions/12345', +} + +export default SubscriptionRenewalReminder diff --git a/server/migrations/versions/2025-10-31-1531_add_subscription_renewal_reminder.py b/server/migrations/versions/2025-10-31-1531_add_subscription_renewal_reminder.py new file mode 100644 index 0000000000..5579809e16 --- /dev/null +++ b/server/migrations/versions/2025-10-31-1531_add_subscription_renewal_reminder.py @@ -0,0 +1,44 @@ +"""add_subscription_renewal_reminder + +Revision ID: fe37b447bcc0 +Revises: f3cbc3937937 +Create Date: 2025-10-31 15:31:18.537831 + +""" + +import sqlalchemy as sa +from alembic import op + +# Polar Custom Imports + +# revision identifiers, used by Alembic. +revision = "fe37b447bcc0" +down_revision = "f3cbc3937937" +branch_labels: tuple[str] | None = None +depends_on: tuple[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "subscriptions", + sa.Column( + "renewal_reminder_sent_at", sa.TIMESTAMP(timezone=True), nullable=True + ), + ) + # Add subscription_renewal_reminder to existing customer_email_settings + op.execute( + """ + UPDATE organizations + SET customer_email_settings = customer_email_settings || '{"subscription_renewal_reminder": false}'::jsonb + WHERE customer_email_settings IS NOT NULL + AND NOT customer_email_settings ? 'subscription_renewal_reminder' + """ + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("subscriptions", "renewal_reminder_sent_at") + # ### end Alembic commands ### diff --git a/server/polar/email/schemas.py b/server/polar/email/schemas.py index b37f68ea16..baaee54f92 100644 --- a/server/polar/email/schemas.py +++ b/server/polar/email/schemas.py @@ -33,6 +33,7 @@ class EmailTemplate(StrEnum): subscription_confirmation = "subscription_confirmation" subscription_cycled = "subscription_cycled" subscription_past_due = "subscription_past_due" + subscription_renewal_reminder = "subscription_renewal_reminder" subscription_revoked = "subscription_revoked" subscription_uncanceled = "subscription_uncanceled" subscription_updated = "subscription_updated" @@ -236,6 +237,16 @@ class SubscriptionPastDueEmail(BaseModel): props: SubscriptionPastDueProps +class SubscriptionRenewalReminderProps(SubscriptionPropsBase): ... + + +class SubscriptionRenewalReminderEmail(BaseModel): + template: Literal[EmailTemplate.subscription_renewal_reminder] = ( + EmailTemplate.subscription_renewal_reminder + ) + props: SubscriptionRenewalReminderProps + + class SubscriptionRevokedProps(SubscriptionPropsBase): ... @@ -330,6 +341,7 @@ class NotificationCreateAccountEmail(BaseModel): | SubscriptionConfirmationEmail | SubscriptionCycledEmail | SubscriptionPastDueEmail + | SubscriptionRenewalReminderEmail | SubscriptionRevokedEmail | SubscriptionUncanceledEmail | SubscriptionUpdatedEmail diff --git a/server/polar/models/organization.py b/server/polar/models/organization.py index fb32f76568..d3390b4e1b 100644 --- a/server/polar/models/organization.py +++ b/server/polar/models/organization.py @@ -93,6 +93,7 @@ class OrganizationCustomerEmailSettings(TypedDict): subscription_revoked: bool subscription_uncanceled: bool subscription_updated: bool + subscription_renewal_reminder: bool _default_customer_email_settings: OrganizationCustomerEmailSettings = { @@ -104,6 +105,7 @@ class OrganizationCustomerEmailSettings(TypedDict): "subscription_revoked": True, "subscription_uncanceled": True, "subscription_updated": True, + "subscription_renewal_reminder": False, } diff --git a/server/polar/models/subscription.py b/server/polar/models/subscription.py index 2ddbbff75f..9ac04ac1c4 100644 --- a/server/polar/models/subscription.py +++ b/server/polar/models/subscription.py @@ -169,6 +169,10 @@ class Subscription(CustomFieldDataMixin, MetadataMixin, RecordModel): TIMESTAMP(timezone=True), nullable=True, default=None ) + renewal_reminder_sent_at: Mapped[datetime | None] = mapped_column( + TIMESTAMP(timezone=True), nullable=True, default=None + ) + scheduler_locked_at: Mapped[datetime | None] = mapped_column( TIMESTAMP(timezone=True), nullable=True, default=None, index=True ) diff --git a/server/polar/subscription/service.py b/server/polar/subscription/service.py index 2189380f3e..5166fd097d 100644 --- a/server/polar/subscription/service.py +++ b/server/polar/subscription/service.py @@ -2475,6 +2475,16 @@ async def send_subscription_updated_email( }, ) + async def send_renewal_reminder_email( + self, session: AsyncSession, subscription: Subscription + ) -> None: + return await self._send_customer_email( + session, + subscription, + subject_template="Your {product.name} subscription renews soon", + template_name="subscription_renewal_reminder", + ) + async def _send_customer_email( self, session: AsyncSession, @@ -2484,6 +2494,7 @@ async def _send_customer_email( template_name: Literal[ "subscription_cancellation", "subscription_past_due", + "subscription_renewal_reminder", "subscription_revoked", "subscription_uncanceled", "subscription_updated", @@ -2731,5 +2742,68 @@ async def migrate_stripe_subscription( return subscription + async def send_renewal_reminders(self, session: AsyncSession) -> None: + """ + Find and send renewal reminder emails for yearly subscriptions + that will renew in 7 days and haven't been reminded yet. + """ + now = utc_now() + reminder_date = now + timedelta(days=7) + + # Find subscriptions that: + # - Are active + # - Have yearly recurring interval + # - Will renew in 7 days (within a 24-hour window) + # - Haven't been reminded yet + # - Don't have Stripe subscription ID (managed by Polar) + repository = SubscriptionRepository.from_session(session) + statement = ( + repository.get_base_statement() + .join(Product, Subscription.product_id == Product.id) + .join(Organization, Product.organization_id == Organization.id) + .where( + Subscription.active.is_(True), + Subscription.recurring_interval == SubscriptionRecurringInterval.year, + Subscription.current_period_end.is_not(None), + Subscription.current_period_end >= reminder_date, + Subscription.current_period_end < reminder_date + timedelta(days=1), + Subscription.renewal_reminder_sent_at.is_(None), + Subscription.stripe_subscription_id.is_(None), + ) + .options( + contains_eager(Subscription.product).contains_eager( + Product.organization + ), + selectinload(Subscription.customer), + selectinload(Subscription.subscription_product_prices), + ) + ) + + subscriptions = await repository.get_all(statement) + + for subscription in subscriptions: + product = subscription.product + organization = product.organization + + # Skip if organization doesn't have renewal reminders enabled + if not organization.customer_email_settings.get( + "subscription_renewal_reminder", False + ): + continue + + # Send reminder email + await self.send_renewal_reminder_email(session, subscription) + + # Mark as reminded + subscription.renewal_reminder_sent_at = now + await repository.update(subscription, flush=True) + + log.info( + "Sent renewal reminder", + subscription_id=subscription.id, + customer_id=subscription.customer_id, + renewal_date=subscription.current_period_end, + ) + subscription = SubscriptionService() diff --git a/server/polar/subscription/tasks.py b/server/polar/subscription/tasks.py index 411bb7dae3..2d749141f9 100644 --- a/server/polar/subscription/tasks.py +++ b/server/polar/subscription/tasks.py @@ -15,7 +15,13 @@ ) from polar.product.repository import ProductRepository from polar.subscription.repository import SubscriptionRepository -from polar.worker import AsyncSessionMaker, TaskPriority, actor, enqueue_job +from polar.worker import ( + AsyncSessionMaker, + CronTrigger, + TaskPriority, + actor, + enqueue_job, +) from .service import SubscriptionNotReadyForMigration from .service import subscription as subscription_service @@ -145,3 +151,13 @@ async def migrate_stripe_subscription(subscription_id: uuid.UUID) -> None: except SubscriptionNotReadyForMigration: # Retry another time pass + + +@actor( + actor_name="subscription.send_renewal_reminders", + cron_trigger=CronTrigger(hour=9, minute=0), + priority=TaskPriority.LOW, +) +async def subscription_send_renewal_reminders() -> None: + async with AsyncSessionMaker() as session: + await subscription_service.send_renewal_reminders(session) diff --git a/server/tests/subscription/test_renewal_reminders.py b/server/tests/subscription/test_renewal_reminders.py new file mode 100644 index 0000000000..f9fd654576 --- /dev/null +++ b/server/tests/subscription/test_renewal_reminders.py @@ -0,0 +1,238 @@ +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest +from freezegun import freeze_time + +from polar.enums import SubscriptionRecurringInterval +from polar.kit.utils import utc_now +from polar.models import Customer, Organization +from polar.postgres import AsyncSession +from polar.subscription.service import subscription as subscription_service +from tests.fixtures.database import SaveFixture +from tests.fixtures.random_objects import ( + create_active_subscription, + create_product, +) + + +@pytest.mark.asyncio +class TestSendRenewalReminders: + @freeze_time("2025-01-01 12:00:00") + async def test_sends_reminder_for_yearly_subscription_7_days_before_renewal( + self, + session: AsyncSession, + save_fixture: SaveFixture, + customer: Customer, + organization: Organization, + mocker: MagicMock, + ) -> None: + # Enable renewal reminders for organization + organization.customer_email_settings["subscription_renewal_reminder"] = True + await save_fixture(organization) + + # Create a yearly subscription that renews in exactly 7 days + now = utc_now() + renewal_date = now + timedelta(days=7, hours=3) # 7 days and 3 hours ahead + + product = await create_product( + save_fixture, + organization=organization, + recurring_interval=SubscriptionRecurringInterval.year, + ) + subscription = await create_active_subscription( + save_fixture, + product=product, + customer=customer, + ) + subscription.current_period_end = renewal_date + subscription.renewal_reminder_sent_at = None + subscription.stripe_subscription_id = ( + None # Clear to indicate Polar-managed subscription + ) + await save_fixture(subscription) + + # Mock the email sending method + mock_send_email = mocker.patch.object( + subscription_service, "send_renewal_reminder_email", new=AsyncMock() + ) + + # When + await subscription_service.send_renewal_reminders(session) + await session.flush() + + # Then + mock_send_email.assert_called_once() + assert mock_send_email.call_args[0][1].id == subscription.id + + # Verify reminder was marked as sent + await session.refresh(subscription) + assert subscription.renewal_reminder_sent_at is not None + + async def test_does_not_send_reminder_when_organization_disabled( + self, + session: AsyncSession, + save_fixture: SaveFixture, + customer: Customer, + organization: Organization, + mocker: MagicMock, + ) -> None: + # Disable renewal reminders for organization + organization.customer_email_settings["subscription_renewal_reminder"] = False + await save_fixture(organization) + + # Create a yearly subscription that renews in 7 days + now = utc_now() + renewal_date = now + timedelta(days=7) + + product = await create_product( + save_fixture, + organization=organization, + recurring_interval=SubscriptionRecurringInterval.year, + ) + subscription = await create_active_subscription( + save_fixture, + product=product, + customer=customer, + ) + subscription.current_period_end = renewal_date + subscription.renewal_reminder_sent_at = None + subscription.stripe_subscription_id = None + await save_fixture(subscription) + + # Mock the email sending method + mock_send_email = mocker.patch.object( + subscription_service, "send_renewal_reminder_email", new=AsyncMock() + ) + + # When + await subscription_service.send_renewal_reminders(session) + + # Then + mock_send_email.assert_not_called() + + async def test_does_not_send_reminder_for_monthly_subscription( + self, + session: AsyncSession, + save_fixture: SaveFixture, + customer: Customer, + organization: Organization, + mocker: MagicMock, + ) -> None: + # Enable renewal reminders for organization + organization.customer_email_settings["subscription_renewal_reminder"] = True + await save_fixture(organization) + + # Create a monthly subscription that renews in 7 days + now = utc_now() + renewal_date = now + timedelta(days=7) + + product = await create_product( + save_fixture, + organization=organization, + recurring_interval=SubscriptionRecurringInterval.month, + ) + subscription = await create_active_subscription( + save_fixture, + product=product, + customer=customer, + ) + subscription.current_period_end = renewal_date + subscription.renewal_reminder_sent_at = None + subscription.stripe_subscription_id = None + await save_fixture(subscription) + + # Mock the email sending method + mock_send_email = mocker.patch.object( + subscription_service, "send_renewal_reminder_email", new=AsyncMock() + ) + + # When + await subscription_service.send_renewal_reminders(session) + + # Then + mock_send_email.assert_not_called() + + async def test_does_not_send_duplicate_reminders( + self, + session: AsyncSession, + save_fixture: SaveFixture, + customer: Customer, + organization: Organization, + mocker: MagicMock, + ) -> None: + # Enable renewal reminders for organization + organization.customer_email_settings["subscription_renewal_reminder"] = True + await save_fixture(organization) + + # Create a yearly subscription that renews in 7 days with reminder already sent + now = utc_now() + renewal_date = now + timedelta(days=7) + + product = await create_product( + save_fixture, + organization=organization, + recurring_interval=SubscriptionRecurringInterval.year, + ) + subscription = await create_active_subscription( + save_fixture, + product=product, + customer=customer, + ) + subscription.current_period_end = renewal_date + subscription.renewal_reminder_sent_at = now - timedelta(days=1) + subscription.stripe_subscription_id = None + await save_fixture(subscription) + + # Mock the email sending method + mock_send_email = mocker.patch.object( + subscription_service, "send_renewal_reminder_email", new=AsyncMock() + ) + + # When + await subscription_service.send_renewal_reminders(session) + + # Then + mock_send_email.assert_not_called() + + async def test_does_not_send_reminder_for_stripe_managed_subscription( + self, + session: AsyncSession, + save_fixture: SaveFixture, + customer: Customer, + organization: Organization, + mocker: MagicMock, + ) -> None: + # Enable renewal reminders for organization + organization.customer_email_settings["subscription_renewal_reminder"] = True + await save_fixture(organization) + + # Create a yearly subscription with Stripe ID + now = utc_now() + renewal_date = now + timedelta(days=7) + + product = await create_product( + save_fixture, + organization=organization, + recurring_interval=SubscriptionRecurringInterval.year, + ) + subscription = await create_active_subscription( + save_fixture, + product=product, + customer=customer, + ) + subscription.current_period_end = renewal_date + subscription.renewal_reminder_sent_at = None + subscription.stripe_subscription_id = "sub_test123" + await save_fixture(subscription) + + # Mock the email sending method + mock_send_email = mocker.patch.object( + subscription_service, "send_renewal_reminder_email", new=AsyncMock() + ) + + # When + await subscription_service.send_renewal_reminders(session) + + # Then + mock_send_email.assert_not_called()