Skip to content

Conversation

http-teapot
Copy link
Contributor

@http-teapot http-teapot commented Jul 11, 2025

Description

  1. Updated the subscription lifecycle to use Stripe webhooks only.
  2. Added rate limiting logic tied to subscription lifecycle
  3. Updated frontend to use rate limiting when user has active subscription

Related Issues

Type of Change

  • Bug fix
  • New feature
  • Documentation update
  • Release
  • Refactor
  • Other (please describe):

Testing

  1. Make sure you start with a clean DB.
  2. Log in.
  3. Click the profile dropdown, make sure it reflects the free rates.
  4. Create a new project or update an existing project (interact with the AI).
  5. Verify that the usage counted towards the free rate limit.
  6. Purchase a new subscription.
  7. Verify that the limit reflects the purchased subscription and that the previous message does not count towards the limit.
  8. Post a message (similar as step 4).
  9. Verify that the usage counted towards the subscription monthly limit.
  10. Upgrade the subscription to the next tier.
  11. Verify in the dropdown that the rate limit as increased accordingly to the delta between the previous pricing and the new pricing.
  12. Post a message
  13. Verify that the usage has increased accordingly.
  14. Simulate a renewal
    1. Go to Stripe Sandbox
    2. Find and click on the subscription
    3. In the Actions dropdown, click "Update subscription"
    4. Add a new tier (add product) and remove the existing tier (Stripe handles that as an upgrade)
    5. Check "Reset billing cycle" (this will invoice the user, just like it'd if it renewed)
    6. Click "Update subscription"
  15. The new rate limit should include the previous rate limit minus the used credits
  16. Repeat step (14)
  17. The new rate limit should include the previous rate limit but the one before that (ie. credits are carried over only once)

Screenshots (if applicable)

Additional Notes


Important

Refactor subscription backend to use Stripe webhooks and implement rate limiting based on subscription status, updating database schema and frontend accordingly.

  • Behavior:
    • Refactor subscription lifecycle to use Stripe webhooks exclusively in route.ts and stripe.ts.
    • Implement rate limiting logic tied to subscription lifecycle in route.ts and usage/index.ts.
    • Update frontend to reflect rate limiting based on subscription status in route.ts.
  • Database:
    • Add rateLimits table in rate-limits.ts to track usage limits per subscription.
    • Update subscriptions table in subscription.ts to include period start and end timestamps.
    • Modify usageRecords table in usage.ts to include indexing for efficient querying.
  • Stripe Integration:
    • Add createCustomer function in functions.ts to handle new customer creation.
    • Modify createCheckoutSession to include stripeCustomerId in functions.ts.
    • Update subscription handling functions to manage subscription updates and cancellations in functions.ts.

This description was created by Ellipsis for bf4cdb4. You can customize this summary. It will automatically update as commits are pushed.

Copy link

vercel bot commented Jul 11, 2025

Someone is attempting to deploy a commit to the Onlook Team on Vercel.

A member of the Team first needs to authorize it.

Comment on lines 258 to 266
// Here you can decide the logic for the carry-over.
// Example, you may want to carry over 100% of the credits on the first carry-over,
// and 50% of the credits on the next carry-overs.
// const max = rate.carryOverTotal === 0 ? rate.left : rate.left * 0.50;
const max = rate.left;

// For now, we only carry over the credits on the first carry-over.
// In the future, we may want to carry over the credits on the next carry-overs.
if (rate.carryOverTotal === 0) {
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 is where you can adjust the carry-over rules. Right now it is set up as:

  1. It only carries over once.
  2. 100% of the remaining credits carry over.

timestamp: new Date(),
// running a transaction helps with concurrency issues and ensures that
// the usage is incremented atomically
return db.transaction(async (tx) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

What happens when increment is called on a free plan with no rateLimits? Seems like in this case there should be an upsert or some logic here to handle that? Seeing a 500 error when running with an existing user with no subscription

Copy link
Contributor

Choose a reason for hiding this comment

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

 @onlook/web-client dev: [TRPC] usage.increment took 327ms to execute
│ @onlook/web-client dev:  ⨯ Error [TRPCError]: Rollback
│ @onlook/web-client dev:     at <unknown> (src/server/api/routers/usage/index.ts:122:19)
│ @onlook/web-client dev:     at async (src/server/api/trpc.ts:105:19)
│ @onlook/web-client dev:     at async streamResponse (src/app/api/chat/route.ts:102:29)
│ @onlook/web-client dev:   120 |             // if there are no credits left then rollback
│ @onlook/web-client dev:   121 |             if (!limit?.left) {
│ @onlook/web-client dev: > 122 |                 tx.rollback();
│ @onlook/web-client dev:       |                   ^
│ @onlook/web-client dev:   123 |                 return;
│ @onlook/web-client dev:   124 |             }
│ @onlook/web-client dev:   125 | {
│ @onlook/web-client dev:   code: 'INTERNAL_SERVER_ERROR',
│ @onlook/web-client dev:   [cause]: Error [DrizzleError]: Rollback
│ @onlook/web-client dev:       at <unknown> (src/server/api/routers/usage/index.ts:122:19)
│ @onlook/web-client dev:       at async (src/server/api/trpc.ts:105:19)
│ @onlook/web-client dev:       at async streamResponse (src/app/api/chat/route.ts:102:29)
│ @onlook/web-client dev:     120 |             // if there are no credits left then rollback
│ @onlook/web-client dev:     121 |             if (!limit?.left) {
│ @onlook/web-client dev:   > 122 |                 tx.rollback();
│ @onlook/web-client dev:         |                   ^
│ @onlook/web-client dev:     123 |                 return;
│ @onlook/web-client dev:     124 |             }
│ @onlook/web-client dev:     125 | {
│ @onlook/web-client dev:     cause: undefined
│ @onlook/web-client dev:   }
│ @onlook/web-client dev: }
│ @onlook/web-client dev:  ⨯ Error [TRPCError]: Rollback
│ @onlook/web-client dev:     at <unknown> (src/server/api/routers/usage/index.ts:122:19)
│ @onlook/web-client dev:     at async (src/server/api/trpc.ts:105:19)
│ @onlook/web-client dev:     at async streamResponse (src/app/api/chat/route.ts:102:29)
│ @onlook/web-client dev:   120 |             // if there are no credits left then rollback
│ @onlook/web-client dev:   121 |             if (!limit?.left) {
│ @onlook/web-client dev: > 122 |                 tx.rollback();
│ @onlook/web-client dev:       |                   ^
│ @onlook/web-client dev:   123 |                 return;
│ @onlook/web-client dev:   124 |             }
│ @onlook/web-client dev:   125 | {
│ @onlook/web-client dev:   code: 'INTERNAL_SERVER_ERROR',
│ @onlook/web-client dev:   [cause]: Error [DrizzleError]: Rollback
│ @onlook/web-client dev:       at <unknown> (src/server/api/routers/usage/index.ts:122:19)
│ @onlook/web-client dev:       at async (src/server/api/trpc.ts:105:19)
│ @onlook/web-client dev:       at async streamResponse (src/app/api/chat/route.ts:102:29)
│ @onlook/web-client dev:     120 |             // if there are no credits left then rollback
│ @onlook/web-client dev:     121 |             if (!limit?.left) {
│ @onlook/web-client dev:   > 122 |                 tx.rollback();
│ @onlook/web-client dev:         |                   ^
│ @onlook/web-client dev:     123 |                 return;
│ @onlook/web-client dev:     124 |             }
│ @onlook/web-client dev:     125 | {
│ @onlook/web-client dev:     cause: undefined
│ @onlook/web-client dev:   }
│ @onlook/web-client dev: }

Kitenite and others added 5 commits July 17, 2025 17:33
where: and(eq(subscriptions.userId, user.id), eq(subscriptions.status, SubscriptionStatus.ACTIVE)),
});
if (!subscription) {
return { rateLimitId: undefined, usageRecordId: undefined };
Copy link
Contributor

@Kitenite Kitenite Jul 18, 2025

Choose a reason for hiding this comment

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

I think in this case we need to check the records for the free user to make sure they don't go over their free tier limit. Would it make sense to create a rate-limit row? Or just use our custom logic and count the usage record?

This would also not count towards free usage limit because it's a no-op. Ideally we still want to make sure this works. You can simulate free user by editing the subscription table row and marking the subscription as cancelled.
Screenshot 2025-07-18 at 12 47 23 PM

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah yeah, great catch

I didn't want to have rows for free users since they have daily rates, if you have a million users then each month would add 30 million rows in the table. On the other hand, your comment does highlight an issue with separating systems and for free users, it involves counting rows on a large table (though, now indexed) which is not optimal.

I was trying to keep it simple so the credits stored in the database were tied to the subscription's lifecycle. The alternative would be to inject two rate limits (one daily and one monthly) when a free user sends a message and then when a free user converts to paid, have the daily/monthly rate limits endedAt in the past.

IMO, it's the sort of thing you want to stabilize so it doesn't have to be revisited until months/years from now. By that time, you may revisit how you monetize things and start from scratch.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah I see the problem with the usage calculation now. Especially when scaled up. Perhaps having a daily usage count system could make sense.

One last wrinkle here is that the daily usage counting is now only monthly limit counting when user doesn't have a subscription. Seems like this would not reset daily so free users will only have 5 free messages per month. Please let me know if this is accurate.


const insertRateLimit = async (tx: PgTransaction<any, any, any>, item: StripeItem) => {
console.log(`Inserting rate limit for subscription ${item.subscription.id}`);
// One month from the start date. NOT SURE IF THIS IS CORRECT.
Copy link
Contributor

Choose a reason for hiding this comment

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

@http-teapot should we use a better end date here?

Copy link
Contributor Author

@http-teapot http-teapot Aug 1, 2025

Choose a reason for hiding this comment

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

If stripeCurrentPeriodEnd is available then I'd use that so you don't have to calculate 31/30 days based on month

Copy link

vercel bot commented Aug 1, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
web ✅ Ready (Inspect) Visit Preview 💬 Add feedback Aug 1, 2025 6:47am

@Kitenite Kitenite merged commit 51f4826 into onlook-dev:main Aug 1, 2025
3 of 4 checks passed
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.

4 participants