Skip to content

Conversation

@Yopi
Copy link
Contributor

@Yopi Yopi commented Sep 30, 2025

📋 Summary

Related Issue: Fixes #7010

This PR adds a recurring_interval_count in addition to the recurring_interval on the product and subscription models. Additionally it makes the cycle calculations for billing take the recurring_interval_count in consideration.

🎯 What

Backend:

  • Add the necessary properties to the Product and Subscription models
  • Create the database migration to add those fields and migrate existing Products and Subscriptions
  • Expose and handle those properties correctly in the Product, Subscription and Checkout API
  • Handle the configuration correctly when handling a cycle.

Frontend

  • Adapt the Product creation/update form to configure the recurring interval
    • [?] Nice to have: show quick and common configurations by default like 1 month or 1 year[ ]
  • Display the interval nicely across the dashboard (Product details, Subscription details…)
  • Display the interval nicely in the Customer Portal
  • Display the interval nicely in the Checkout form

🤔 Why

To allow for more flexibility in billing intervals.

🔧 How

🧪 Testing

  • I have tested these changes locally
  • All existing tests pass (uv run task test for backend, pnpm test for frontend)
  • I have added new tests for new functionality
  • I have run linting and type checking (uv run task lint && uv run task lint_types for backend)

Test Instructions

🖼️ Screenshots/Recordings

Screen Recording 2025-10-01 at 16 10 09

Screenshot 2025-10-01 at 16 47 06 Screenshot 2025-10-01 at 16 46 57

📝 Additional Notes

✅ Pre-submission Checklist

  • [WIP] My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code where necessary
  • [X I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have updated the relevant tests
  • All tests pass locally
  • AI/LLM Policy: If I used AI assistance, I have tested and executed the code locally (not just "vibe-coded")

@vercel
Copy link

vercel bot commented Sep 30, 2025

@Yopi is attempting to deploy a commit to the polar-sh Team on Vercel.

A member of the Team first needs to authorize it.

@Yopi Yopi force-pushed the 7010-flexible-subscription-intervals branch 3 times, most recently from 709f890 to 851e515 Compare October 1, 2025 17:47
{},
),
})
} as schemas['ProductCreate'])
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not super impressed with having to do this, but Typescript couldn't figure the type out without it when I added the recurring_interval_count number / null.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it usually gets a bit tricky with those forms. We can live with it IMO :)

required: 'This field is required when billing cycle is set',
min: { value: 1, message: 'Interval count must be at least 1' },
max: {
value: 999,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably doesn't really make sense, but I'm also not sure what would make sense here out of a product perspective.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had the same debate with the trial configuration, and ended up also limiting to 1000. A bit arbitrary, but at least make sure we don't blow up datetimes.

Comment on lines +724 to +951
Set how often customers are billed (e.g., "every 2
months")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to have an example here since it might not be intuitive, but we might also want to update the example based on the recurring_interval for it to be a bit more dynamic.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I guess we'll have quite a lot of back and forth on this UX. Will do fine for now.

switch (interval) {
case 'day':
return ' / dy'
return ` /${prefix} dy`
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks fine to me, but the text becomes a bit longer and I am guessing the intention was for it to be short given that it's being shortened (wk, etc).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great 👍

recurring_interval_count: int | None = Field(
description=(
"Number of interval units of the subscription."
"If this is set to 1 the charge will happen every interval (e.g. every month),"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, due to the possibility of it not being intuitive I wanted to give a clearer example.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great 👍

@Yopi Yopi marked this pull request as ready for review October 1, 2025 17:56
Copy link
Member

@frankie567 frankie567 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @Yopi, very great and polished work 🙏

I suggested a few nitpicks; if you have time to address them, would be great 🙂

➕ if you can pass linters: uv run task lint && uv run task lint_types

index=True,
default=None,
)
recurring_interval_count: Mapped[int | None] = mapped_column(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be made non-nullable, especially since we take care to migrate existing data.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was my initial thought as well, but when recurring_interval is null I made this null as well. Otherwise we would need to give it a value. Perhaps setting it to 0 would make sense though?

recurring_interval: Mapped[SubscriptionRecurringInterval] = mapped_column(
StringEnum(SubscriptionRecurringInterval), nullable=False, index=True
)
recurring_interval_count: Mapped[int | None] = mapped_column(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment above about non-nullable

recurring_interval_count: int | None = Field(
description=(
"Number of interval units of the subscription."
"If this is set to 1 the charge will happen every interval (e.g. every month),"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great 👍

if product.is_legacy_recurring_price:
prices = [checkout.product_price]
recurring_interval = prices[0].recurring_interval
recurring_interval_count = 1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch 😄

{},
),
})
} as schemas['ProductCreate'])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it usually gets a bit tricky with those forms. We can live with it IMO :)

required: 'This field is required when billing cycle is set',
min: { value: 1, message: 'Interval count must be at least 1' },
max: {
value: 999,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had the same debate with the trial configuration, and ended up also limiting to 1000. A bit arbitrary, but at least make sure we don't blow up datetimes.

)
recurring_interval_count: int | None = Field(
default=None,
ge=1,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also probably add the upper limit validation here.

Comment on lines +724 to +951
Set how often customers are billed (e.g., "every 2
months")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I guess we'll have quite a lot of back and forth on this UX. Will do fine for now.

switch (interval) {
case 'day':
return ' / dy'
return ` /${prefix} dy`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great 👍

@vercel
Copy link

vercel bot commented Oct 2, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Preview Comments Updated (UTC)
polar Ignored Ignored Preview Oct 2, 2025 9:34am
polar-sandbox Ignored Ignored Preview Oct 2, 2025 9:34am

@Yopi Yopi force-pushed the 7010-flexible-subscription-intervals branch from 851e515 to 12a9543 Compare October 2, 2025 10:00
@Yopi
Copy link
Contributor Author

Yopi commented Oct 2, 2025

Thank you @Yopi, very great and polished work 🙏

I suggested a few nitpicks; if you have time to address them, would be great 🙂

➕ if you can pass linters: uv run task lint && uv run task lint_types

Sorry, I thought I had ran through the linter, but seems not. All should be good now.

I pushed the changes you requested (made the recurring_interval_count not nullable and defaulted to 0 when we don't have a recurring_interval)

@Yopi
Copy link
Contributor Author

Yopi commented Oct 23, 2025

Superseded by #7410 and #7431

@Yopi Yopi closed this Oct 23, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support more flexible subscription intervals

2 participants