-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Ensure workspace remains active if customer still has another valid subscription #2778
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…s. Introduced updateWorkspacePlan utility to streamline plan updates and added logic to check for active subscriptions upon deletion. Cleaned up unused code in customerSubscriptionUpdated.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughCentralizes workspace plan transition logic into a new updateWorkspacePlan helper. customer-subscription-updated delegates plan updates to that helper. customer-subscription-deleted now checks Stripe for any remaining active subscriptions; if found, it maps the priceId to a plan, calls updateWorkspacePlan, and short-circuits the cancellation flow. (48 words) Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Stripe
participant WebhookDeleted as "Webhook: customer.subscription.deleted"
participant StripeAPI as "Stripe API"
participant Utils as "updateWorkspacePlan"
participant DB as "DB / Caches"
Stripe->>WebhookDeleted: customer.subscription.deleted
WebhookDeleted->>StripeAPI: subscriptions.list(customer, status: "active")
StripeAPI-->>WebhookDeleted: activeSubscriptions[]
alt Active subscriptions exist
WebhookDeleted->>Utils: updateWorkspacePlan({ workspace, plan, priceId })
Utils->>DB: update project plan & limits, update tokens, disable webhooks, delete folders (async)
DB-->>Utils: results
Utils-->>WebhookDeleted: done
WebhookDeleted-->>Stripe: 200 OK
else No active subscriptions
WebhookDeleted->>WebhookDeleted: continue cancellation flow
WebhookDeleted-->>Stripe: 200 OK
end
sequenceDiagram
autonumber
actor Stripe
participant WebhookUpdated as "Webhook: customer.subscription.updated"
participant Utils as "updateWorkspacePlan"
participant DB as "DB / Caches"
Stripe->>WebhookUpdated: customer.subscription.updated
WebhookUpdated->>WebhookUpdated: validate workspace & priceId -> get plan
WebhookUpdated->>Utils: updateWorkspacePlan({ workspace, plan, priceId })
Utils->>DB: apply project updates, token updates, webhook state, folder deletes (async)
DB-->>Utils: results
Utils-->>WebhookUpdated: done
WebhookUpdated-->>Stripe: 200 OK
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested reviewers
Poem
✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🧹 Nitpick comments (3)
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts (1)
65-69: LGTM, plus one defensive improvement suggestion.
The delegation is correct. Consider guarding priceId extraction to avoid undefined when subscription has multiple items/add-ons. Example (elsewhere in this file where priceId is read): prefer a recurring item and bail if none is found.const priceId = subscriptionUpdated.items.data.find((i) => i.price?.type === "recurring") ?.price?.id; if (!priceId) { console.log(`No recurring price on subscription ${subscriptionUpdated.id}`); return; }apps/web/app/(ee)/api/stripe/webhook/utils.ts (2)
80-116: Avoid allSettled for core DB mutations to prevent partial state.
project.update and restrictedToken.updateMany should succeed or fail together; cache expiry can remain best-effort.- await Promise.allSettled([ - prisma.project.update({ + await Promise.all([ + prisma.project.update({ where: { id: workspace.id, }, data: { plan: newPlanName, usageLimit: plan.limits.clicks!, linksLimit: plan.limits.links!, payoutsLimit: plan.limits.payouts!, domainsLimit: plan.limits.domains!, aiLimit: plan.limits.ai!, tagsLimit: plan.limits.tags!, foldersLimit: plan.limits.folders!, groupsLimit: plan.limits.groups!, usersLimit: plan.limits.users!, paymentFailedAt: null, ...(shouldDeleteFolders && { foldersUsage: 0 }), }, }), - - prisma.restrictedToken.updateMany({ + prisma.restrictedToken.updateMany({ where: { projectId: workspace.id, }, data: { rateLimit: plan.limits.api, }, }), - - // expire tokens cache - tokenCache.expireMany({ - hashedKeys: workspace.restrictedTokens.map( - ({ hashedKey }) => hashedKey, - ), - }), - ]); + ]); + // expire tokens cache (best-effort) + await tokenCache.expireMany({ + hashedKeys: workspace.restrictedTokens.map(({ hashedKey }) => hashedKey), + });
69-70: Derive webhook capability from plan metadata instead of name, if possible.
If available, prefer a plan capability flag (e.g., plan.features.webhooks) over string matching "free"/"pro" to future-proof.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (3)
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts(3 hunks)apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts(2 hunks)apps/web/app/(ee)/api/stripe/webhook/utils.ts(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts (2)
packages/utils/src/constants/pricing.tsx (1)
getPlanFromPriceId(352-354)apps/web/app/(ee)/api/stripe/webhook/utils.ts (1)
updateWorkspacePlan(47-174)
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts (1)
apps/web/app/(ee)/api/stripe/webhook/utils.ts (1)
updateWorkspacePlan(47-174)
apps/web/app/(ee)/api/stripe/webhook/utils.ts (3)
apps/web/lib/types.ts (1)
WorkspaceProps(161-177)packages/utils/src/constants/pricing.tsx (2)
getPlanFromPriceId(352-354)LEGACY_PRICE_IDS(32-35)apps/web/lib/auth/token-cache.ts (1)
tokenCache(68-68)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Vade Review
- GitHub Check: build
🔇 Additional comments (4)
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts (2)
2-2: Centralizing plan derivation is correct.
Importing getPlanFromPriceId here aligns with the new shared helper flow.
5-5: Good: delegate side-effects to a single helper.
Moving plan updates to updateWorkspacePlan reduces duplication and drift.apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts (2)
28-32: Selecting plan, payoutsLimit, and paymentFailedAt is necessary for the new helper.
This matches updateWorkspacePlan’s input shape.
77-96: Confirm valid Stripe subscription statuses
According to Stripe’s official documentation,Subscription.statuscan be one of:
incompleteincomplete_expiredtrialingactivepast_duecanceledunpaidpaused(docs.stripe.com)Of these, only the following represent subscriptions you can continue to provision (keep the workspace active):
trialingactivepast_dueAll other statuses either never become fully active (
incomplete,incomplete_expired), or require you to revoke access (canceled,unpaid), or represent a paused state (paused) that should be handled separately (docs.stripe.com).Please update the filter accordingly:
- const validStatuses = new Set<Stripe.Subscription.Status>([ - "active", - "trialing", - "past_due", - ]); + const validStatuses = new Set<Stripe.Subscription.Status>([ + "trialing", + "active", + "past_due", + ]);
|
@coderabbitai full review please |
|
@devkiran I'll conduct a full review of all the changes in this PR, examining the webhook handling logic, the new updateWorkspacePlan function, and ensuring the implementation correctly handles cases where customers have multiple active subscriptions. ✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
♻️ Duplicate comments (1)
apps/web/app/(ee)/api/stripe/webhook/utils.ts (1)
68-71: Folder clean-up condition fixed — matches original intent.Using workspace.foldersUsage > 0 for Free downgrades is correct and avoids unnecessary deletes when there are no folders.
🧹 Nitpick comments (3)
apps/web/app/(ee)/api/stripe/webhook/utils.ts (3)
119-140: Reduce one DB write: include webhookEnabled in the primary project update.You already know whether webhooks should be disabled. Fold this into the first update, then keep webhook.updateMany + cache refresh.
data: { plan: newPlanName, usageLimit: plan.limits.clicks!, linksLimit: plan.limits.links!, payoutsLimit: plan.limits.payouts!, domainsLimit: plan.limits.domains!, aiLimit: plan.limits.ai!, tagsLimit: plan.limits.tags!, foldersLimit: plan.limits.folders!, groupsLimit: plan.limits.groups!, usersLimit: plan.limits.users!, paymentFailedAt: null, + ...(shouldDisableWebhooks && { webhookEnabled: false }), },- if (shouldDisableWebhooks) { - await Promise.all([ - prisma.project.update({ - where: { - id: workspace.id, - }, - data: { - webhookEnabled: false, - }, - }), - - prisma.webhook.updateMany({ + if (shouldDisableWebhooks) { + await prisma.webhook.updateMany({ where: { projectId: workspace.id, }, data: { disabledAt: new Date(), }, - }), - ]); + });Also applies to: 86-99
61-63: Use a type-only import for typing to avoid pulling code at runtime.Minor build hygiene: import getPlanFromPriceId as a type.
-import { getPlanFromPriceId, LEGACY_PRICE_IDS } from "@dub/utils"; +import { LEGACY_PRICE_IDS } from "@dub/utils"; +import type { getPlanFromPriceId } from "@dub/utils";Also applies to: 7-7
52-60: Clarify the workspace param type with a named alias.Define a WorkspaceForPlanUpdate type for reuse and readability instead of an inline Pick & intersection.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
apps/web/app/(ee)/api/stripe/webhook/utils.ts(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/stripe/webhook/utils.ts (3)
apps/web/lib/types.ts (1)
WorkspaceProps(161-177)packages/utils/src/constants/pricing.tsx (2)
getPlanFromPriceId(352-354)LEGACY_PRICE_IDS(32-35)apps/web/lib/auth/token-cache.ts (1)
tokenCache(68-68)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Vade Review
- GitHub Check: build
🔇 Additional comments (3)
apps/web/app/(ee)/api/stripe/webhook/utils.ts (3)
141-156: Webhook cache refresh after disabling — looks good.Fetching fresh webhooks and mset on the cache is the right sequence.
78-80: Confirm legacy payouts behavior.This condition skips all updates when only payoutsLimit differs on a legacy price ID. Is that intentional (i.e., legacy plans keep existing payouts limit and no other side-effects should run)?
165-174: Graceful recovery path — LGTM.Clearing paymentFailedAt when no other updates are needed is correct.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (2)
apps/web/app/(ee)/api/stripe/webhook/utils.ts (2)
81-118: Avoid masking failures; use a DB transaction and expire caches after.Promise.allSettled can leave partial DB state. Make the two Prisma writes atomic and expire token cache only on success.
- await Promise.allSettled([ - prisma.project.update({ + await prisma.$transaction([ + prisma.project.update({ where: { id: workspace.id, }, data: { plan: newPlanName, usageLimit: plan.limits.clicks!, linksLimit: plan.limits.links!, payoutsLimit: plan.limits.payouts!, domainsLimit: plan.limits.domains!, aiLimit: plan.limits.ai!, tagsLimit: plan.limits.tags!, foldersLimit: plan.limits.folders!, groupsLimit: plan.limits.groups!, usersLimit: plan.limits.users!, paymentFailedAt: null, - ...(shouldDeleteFolders && { foldersUsage: 0 }), }, }), - prisma.restrictedToken.updateMany({ + prisma.restrictedToken.updateMany({ where: { projectId: workspace.id, }, data: { rateLimit: plan.limits.api, }, }), - - // expire tokens cache - tokenCache.expireMany({ - hashedKeys: workspace.restrictedTokens.map( - ({ hashedKey }) => hashedKey, - ), - }), ]); + + // expire tokens cache (non-DB) + await tokenCache.expireMany({ + hashedKeys: workspace.restrictedTokens.map(({ hashedKey }) => hashedKey), + });
98-99: Don’t zero foldersUsage before deletion; update it after deletes succeed.Zeroing early risks inconsistent stats if deletion fails.
@@ - ...(shouldDeleteFolders && { foldersUsage: 0 }), @@ - if (shouldDeleteFolders) { - await deleteWorkspaceFolders({ - workspaceId: workspace.id, - }); - } + if (shouldDeleteFolders) { + await deleteWorkspaceFolders({ workspaceId: workspace.id }); + await prisma.project.update({ + where: { id: workspace.id }, + data: { foldersUsage: 0 }, + }); + }Also applies to: 160-164
🧹 Nitpick comments (1)
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts (1)
65-69: Plan update centralization: looks good; consider retry-safety.Delegation to updateWorkspacePlan is the right move. Minor: wrap in try/catch to log context (event.id, workspace.id) so Stripe retries are diagnosable.
Example:
- await updateWorkspacePlan({ + try { + await updateWorkspacePlan({ workspace, plan, priceId, - }); + }); + } catch (err) { + console.error("customer.subscription.updated failed", { + eventId: event.id, + stripeId, + priceId, + err, + }); + throw err; // let Stripe retry + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (3)
apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts(3 hunks)apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts(2 hunks)apps/web/app/(ee)/api/stripe/webhook/utils.ts(2 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: api-tests
🔇 Additional comments (1)
apps/web/app/(ee)/api/stripe/webhook/utils.ts (1)
69-70: No Changes Needed for Pro Webhook BehaviorThe logic in
apps/web/app/(ee)/api/stripe/webhook/utils.tscorrectly disables webhooks for the “pro” plan. The central plan capabilities configuration explicitly disallows webhook creation on both “free” and “pro” tiers:
- In
apps/web/lib/plan-capabilities.ts,canCreateWebhooksis set to!!plan && !["free", "pro"].includes(plan), meaning only plans above “pro” (e.g., “business”, “advanced”, “enterprise”) may create webhooks.Since the webhook util disables webhooks when
newPlanNameis"free"or"pro", it aligns perfectly with this configuration. No updates are required here.
|
@coderabbitai final review. Any breaking changes in the Stripe webhook? |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: Length of output: 15568 @devkiran Based on my analysis of the Stripe webhook changes, there are no breaking changes to the external webhook contract. Here's my detailed assessment: ✅ No Breaking Changes FoundWebhook Contract Remains Intact:
🔄 Internal Logic Changes (Non-Breaking)customer-subscription-deleted.ts:
customer-subscription-updated.ts:
updateWorkspacePlan Function:
🧪 Recommended TestingSince there's significant refactoring, I recommend testing:
The changes enhance functionality while maintaining full backward compatibility with Stripe's webhook contract. |
Summary by CodeRabbit
New Features
Bug Fixes
Refactor