Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
2 changes: 2 additions & 0 deletions server/emails/src/emails/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -37,6 +38,7 @@ const TEMPLATES: Record<string, React.FC<any>> = {
subscription_confirmation: SubscriptionConfirmation,
subscription_cycled: SubscriptionCycled,
subscription_past_due: SubscriptionPastDue,
subscription_renewal_reminder: SubscriptionRenewalReminder,
subscription_revoked: SubscriptionRevoked,
subscription_uncanceled: SubscriptionUncanceled,
subscription_updated: SubscriptionUpdated,
Expand Down
77 changes: 77 additions & 0 deletions server/emails/src/emails/subscription_renewal_reminder.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Wrapper>
<Preview>Your subscription to {product.name} renews soon</Preview>
<OrganizationHeader organization={organization} />
<Section className="pt-10">
<Heading as="h1" className="text-xl font-bold text-gray-900">
Your subscription renews soon
</Heading>
<BodyText>
This is a friendly reminder that your yearly subscription to{' '}
<span className="font-bold">{product.name}</span> will renew
automatically in 7 days.
</BodyText>
<BodyText>
You don't need to do anything — your subscription will continue
uninterrupted with the same benefits you enjoy today.
</BodyText>
<BodyText>
If you'd like to review your subscription or make any changes, you can
manage everything from your customer portal.
</BodyText>
</Section>
<Section className="my-8 text-center">
<Button href={url}>Manage my subscription</Button>
</Section>
<Hr />
<Section className="mt-6 border-t border-gray-200 pt-6">
<Text className="text-sm text-gray-600">
If you're having trouble with the button above, copy and paste the URL
below into your web browser.
</Text>
<Text className="text-sm">
<Link href={url} className="text-blue-600 underline">
{url}
</Link>
</Text>
</Section>
<FooterCustomer organization={organization} email={email} />
</Wrapper>
)
}

SubscriptionRenewalReminder.PreviewProps = {
email: '[email protected]',
organization,
product,
subscription: {
id: '12345',
status: 'active',
},
url: 'https://polar.sh/acme-inc/portal/subscriptions/12345',
}

export default SubscriptionRenewalReminder
Original file line number Diff line number Diff line change
@@ -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 ###
12 changes: 12 additions & 0 deletions server/polar/email/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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): ...


Expand Down Expand Up @@ -330,6 +341,7 @@ class NotificationCreateAccountEmail(BaseModel):
| SubscriptionConfirmationEmail
| SubscriptionCycledEmail
| SubscriptionPastDueEmail
| SubscriptionRenewalReminderEmail
| SubscriptionRevokedEmail
| SubscriptionUncanceledEmail
| SubscriptionUpdatedEmail
Expand Down
2 changes: 2 additions & 0 deletions server/polar/models/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -104,6 +105,7 @@ class OrganizationCustomerEmailSettings(TypedDict):
"subscription_revoked": True,
"subscription_uncanceled": True,
"subscription_updated": True,
"subscription_renewal_reminder": False,
}


Expand Down
4 changes: 4 additions & 0 deletions server/polar/models/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
74 changes: 74 additions & 0 deletions server/polar/subscription/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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()
18 changes: 17 additions & 1 deletion server/polar/subscription/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading
Loading