feat: Handle team subscription update events#24390
feat: Handle team subscription update events#24390joeauyeung wants to merge 1204 commits intodevin/team-subscription-payment-failed-email-1760634153from
Conversation
|
Hey there and thank you for opening this pull request! 👋🏼 We require pull request titles to follow the Conventional Commits specification and it looks like your proposed title needs to be adjusted. Details: |
WalkthroughAdds typed, lazy-loaded Stripe webhook handling and a single runtime-facing stripeWebhookHandler; introduces a customer.subscription.updated handler that dispatches per-product sub-handlers (team/org and Cal AI phone-number), plus a new TeamSubscriptionEventHandler to manage subscription migrations and updates. Billing model and repositories gain subscriptionStart, subscriptionTrialEnd, subscriptionEnd fields and new getBySubscriptionId/update methods. TeamRepository adds findBySubscriptionId. API routes and invoice/checkout handlers propagate the extra subscription date fields. New environment constants and tests for webhook flows and subscription handling are included. A Stripe status-mapping method was renamed. Possibly related PRs
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
| const teamOrOrgSubscriptionItem = orgSubscriptionItem || teamSubscriptionItem; | ||
|
|
||
| // A subscription will either have a team/org item or a cal.ai phone number item | ||
| const calAiPhoneNumberItem = subscription.items.data.find( |
There was a problem hiding this comment.
Subscriptions for cal.ai phone numbers are handled in the same webhook endpoint. So we only run cal.ai logic if the phone number product id is a part of the subscription. CC @Udit-takkar
| const billingRepository = BillingRepositoryFactory.getRepository(isOrganization); | ||
| const teamRepository = new TeamRepository(prisma); | ||
| const teamSubscriptionEventHandler = new TeamSubscriptionEventHandler(billingRepository, teamRepository); |
There was a problem hiding this comment.
Initialize dependencies for TeamSubscriptionEventHandler
| const status = StripeBillingService.mapSubscriptionStatusToCalStatus({ | ||
| stripeStatus: subscription.status, | ||
| subscriptionId: subscription.id, | ||
| }); | ||
|
|
||
| const { subscriptionStart, subscriptionTrialEnd, subscriptionEnd } = | ||
| StripeBillingService.extractSubscriptionDates(subscription); |
There was a problem hiding this comment.
Handle stripe specific data before passing to the service
| export type IBillingRepositoryCreateArgs = Omit<BillingRecord, "id">; | ||
|
|
||
| export type IBillingRepositoryUpdateArgs = Omit< |
There was a problem hiding this comment.
Have the args extend from the BillingRecord type so it acts as the source of truth.
| StripeBillingService.extractSubscriptionDates(subscription); | ||
|
|
||
| try { | ||
| await teamSubscriptionEventHandler.handleUpdate({ |
There was a problem hiding this comment.
With the preprocessing in the Stripe webhook handler, the teamSubscriptionEventHandler doesn't depend on any specific data from Stripe.
| } | ||
|
|
||
| // if (teamSubscriptionInDb && teamSubscriptionInDb.status !== subscriptionStatus) { | ||
| if (this.hasSubscriptionChanged({ subscription, dbSubscription: teamSubscriptionInDb })) { |
There was a problem hiding this comment.
Only update the subscription in the DB if any of the fields have changed from Stripe vs in the DB
| const teamSubscriptionInDb = await this.billingRepository.getBySubscriptionId(subscriptionId); | ||
|
|
||
| // If the subscription doesn't exist in the DB, migrate it | ||
| if (!teamSubscriptionInDb) { |
There was a problem hiding this comment.
If the subscription is not in the DB then take this opportunity to migrate the subscription to the billing tables.
There was a problem hiding this comment.
I think it would be a good idea to mark it in metadata that this has been migrated. This makes it convenient to check whether a particular metadata is migrated or not.
Also, It allows us to identify when the migration is fully complete and thus we can do cleanup.
| subscription: TSubscriptionUpdate; | ||
| dbSubscription: BillingRecord; | ||
| }) { | ||
| const fieldsToCompare = ["status", "subscriptionTrialEnd", "subscriptionEnd"]; |
There was a problem hiding this comment.
These are the only fields that should be changed for a subscription
There was a problem hiding this comment.
I wonder what's the harm in checking all the fields or just updating the DB without checking if Stripe is sending updated event. This kind of whitelisting could make us out of sync with Stripe data for certain fields.
There was a problem hiding this comment.
Cursor recommends allowing update for atleast subscriptionItemId also as that could change due to upgrade/downgrade.
There was a problem hiding this comment.
Unable to authenticate your request. Please make sure to connect your GitHub account to Cursor. Go to Cursor
| return !conflictingTeam; | ||
| } | ||
|
|
||
| async findBySubscriptionId(subscriptionId: string) { |
There was a problem hiding this comment.
Used to find teams where the subscriptionId is still in the team metadata
| STRIPE_PRIVATE_KEY= | ||
| STRIPE_CLIENT_ID= | ||
|
|
||
| STRIPE_CAL_AI_PHONE_NUMBER_PRODUCT_ID= |
There was a problem hiding this comment.
Need this to determine if the subscription is for cal.ai phone number
E2E results are ready! |
There was a problem hiding this comment.
Create custom product handlers for the _customer.subscription.updated event so we can lazy load the handlers similar to what we're doing in _customer.subscription.deleted
There was a problem hiding this comment.
All this logic is moved out of _customer.subscription.updated handler.
There was a problem hiding this comment.
@Udit-takkar moved cal.ai subscription update logic here.
| import { HttpCode } from "../../../lib/httpCode"; | ||
| import type { SWHMap } from "../../../lib/types"; |
There was a problem hiding this comment.
Moved the types and HttpCode class out of the __handler into their own files.
There was a problem hiding this comment.
Main handler for the _custpmer.subscription.updated event. Moved this and all product update handlers into a separate folder.
Keeping this layer thin by moving product handlers into their own files.
|
|
||
| const results = []; | ||
|
|
||
| for (const item of subscription.items.data) { |
There was a problem hiding this comment.
There could be the possibility of a subscription containing multiple tracked products (ex. org sub item and cal ai sub item). If that happens we should handle all possibilities since they will share the same subscription status.
| } | ||
|
|
||
| if (results.length > 0) { | ||
| log.warn(`Subscription ${subscription.id} contains multiple tracked products`); |
There was a problem hiding this comment.
Will need to add this log statement as an alert
| /** Stripe Webhook Handler Mappings */ | ||
| export type SWHMap = { | ||
| [T in Stripe.DiscriminatedEvent as T["type"]]: { | ||
| [K in keyof T as Exclude<K, "type">]: T[K]; | ||
| }; | ||
| }; | ||
|
|
||
| export type LazyModule<D> = Promise<{ | ||
| default: (data: D) => unknown | Promise<unknown>; | ||
| }>; | ||
|
|
||
| type SWHandlers = { | ||
| [K in keyof SWHMap]?: () => LazyModule<SWHMap[K]["data"]>; | ||
| }; | ||
|
|
||
| /** Just a shorthand for HttpError */ | ||
| export class HttpCode extends HttpError { | ||
| constructor(statusCode: number, message: string) { | ||
| super({ statusCode, message }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Abstracted these to their own files so we're not importing from the main _handler file
| const { subscriptionStart, subscriptionTrialEnd, subscriptionEnd } = | ||
| StripeBillingService.extractSubscriptionDates(stripeSubscription); |
There was a problem hiding this comment.
We are now always passing these dates since the type is now Date | null.
| "payment_intent.succeeded": () => import("./_payment_intent.succeeded"), | ||
| "customer.subscription.deleted": () => import("./_customer.subscription.deleted"), | ||
| "customer.subscription.updated": () => import("./_customer.subscription.updated"), | ||
| "customer.subscription.updated": () => import("./_customer.subscription.updated/_handler"), |
There was a problem hiding this comment.
We moved the main handler into it's own folder to organizer the handler for the update event and product specific handlers.
| subscriptionTrialEnd?: Date; | ||
| subscriptionEnd?: Date; | ||
| } | ||
| export type IBillingRepositoryCreateArgs = Omit<BillingRecord, "id">; |
There was a problem hiding this comment.
Args are now based on the main BillingRecord type as the source of truth.
| this.teamRepository = teamRepository; | ||
| } | ||
|
|
||
| async handleUpdate(subscription: Omit<IBillingRepositoryCreateArgs, "teamId" | "planName">) { |
There was a problem hiding this comment.
The args in this method aren't dependent on the raw data from Stripe.
| // This is a placeholder to showcase adding new event handlers | ||
| const handler = async (data: SWHMap["payment_intent.succeeded"]["data"]) => { | ||
| const paymentIntent = data.object; | ||
| console.log(paymentIntent); |
There was a problem hiding this comment.
Need this or else eslint throws an error that paymentIntent is an unused var
There was a problem hiding this comment.
But this is an unused var right? Can we just comment this line instead?
…dules (#27221) * refactor: move booking-audit client components from packages/features to apps/web/modules This is part of the larger effort to move tRPC-dependent UI components from packages/features to apps/web/modules to eliminate circular dependencies. Changes: - Move BookingHistory.tsx and BookingHistoryPage.tsx to apps/web/modules/booking-audit/components/ - Update imports in apps/web to use the new location Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * refactor: move formbricks client from packages/features to apps/web/modules Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * refactor: move hooks and stores from packages/features to apps/web/modules - Move useAppsData hook from packages/features/apps/hooks to apps/web/modules/apps/hooks - Move onboardingStore from packages/features/ee/organizations/lib to apps/web/modules/ee/organizations/lib - Move useWelcomeModal hook from packages/features/ee/organizations/hooks to apps/web/modules/ee/organizations/hooks - Move useAgentsData hook from packages/features/ee/workflows/hooks to apps/web/modules/ee/workflows/hooks - Update all import paths in consuming files Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * refactor: move onboardingStore test file to apps/web/modules Co-Authored-By: benny@cal.com <sldisek783@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(companion): split calcom.ts into modular service files Split the monolithic 1692-line calcom.ts file into focused modules: - auth.ts: Authentication configuration and token management - bookings.ts: Booking CRUD operations and actions - conferencing.ts: Conferencing options - event-types.ts: Event type CRUD operations - private-links.ts: Private link management for event types - request.ts: Core HTTP request functionality - schedules.ts: Schedule CRUD operations - user.ts: User profile management - utils.ts: JSON parsing and payload sanitization utilities - webhooks.ts: Webhook management (global and event type specific) - index.ts: Re-exports all functions and maintains backward compatibility This improves code organization, maintainability, and makes it easier to understand and modify specific functionality. Co-Authored-By: peer@cal.com <peer@cal.com> * fix(companion): avoid logging sensitive booking response data Replace logging of full responseText (which may contain PII like attendee emails and names) with responseLength for safer debugging. Addresses Cubic AI review feedback (confidence 9/10). Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… conflict (#27121) * fix: use createPortal for FeatureOptInBanner to avoid Intercom widget conflict Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: guard portal rendering against non-browser environments Add typeof document check before accessing document.body in createPortal to prevent SSR/test crashes when document is undefined. Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* wip flow * add tests * WIP migrateing view * push back step * fix tests and logic for adding new members to existing teams * few UI fixes * type fixes * fix nits * few UI + re-route fixes * fix teamId when migrating
…ions (#27211) Co-authored-by: peer@cal.com <peer@cal.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: pasqualevitiello <pasqualevitiello@gmail.com>
…27242) * fix: remove sticky positioning from page headers in pages using ShellMain * correct fix (mostly) * fix: disable sticky headers on routing forms and members pages Applied disableSticky={true} to remaining affected pages: - Routing Forms page - Organization Members view - Platform Members view --------- Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
* style(ui): improve padding for VerticalTabItem * correct fix --------- Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
… rate limits (#27260) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… layout for badge spacing (#27265)
…27270) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: ali@cal.com <ali@cal.com>
* fix: improve error handling in Stripe collectCard method - Add CollectCardFailure error code for better error categorization - Replace generic error with ErrorWithCode for consistent error handling - Add error mappings for common Stripe errors (customer not found, invalid API key, rate limit, etc.) - Include Stripe error message when available for better debugging - Follow the same pattern used in chargeCard method Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add CollectCardFailure to getHttpStatusCode mapping This ensures the error returns 400 instead of 500 when collectCard fails. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * docs: add comments explaining ChargeCardFailure vs CollectCardFailure Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * refactor: simplify collectCard error handling Remove complex error mapping logic per code review feedback. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * test: add CollectCardFailure to HTTP status code tests Co-Authored-By: benny@cal.com <sldisek783@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Add three new optional callbacks to CalProvider that enable users to react during the access token refresh lifecycle: - onTokenRefreshStart: Called when token refresh begins - onTokenRefreshSuccess: Called when token refresh succeeds - onTokenRefreshError: Called when token refresh fails with error message These callbacks are invoked in both token refresh locations: 1. Response interceptor (when API returns 498 status) 2. Initial access token validation The callbacks are properly passed through the component hierarchy: CalProvider -> BaseCalProvider -> useOAuthFlow hook Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
* feat: add proration invoice and reminder email templates Add email templates for monthly proration billing notifications: - ProrationInvoiceEmail: Sent when invoice is created for additional seats - ProrationReminderEmail: Sent 7 days later if invoice remains unpaid Includes: - React email templates using V2BaseEmailHtml - BaseEmail classes for rendering - Billing email service functions - Translation keys for email content Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use allSettled --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: lowercase identifier values in routing form editor Co-Authored-By: peer@cal.com <peer@cal.com> * fix: remove unused @ts-expect-error directives Co-Authored-By: peer@cal.com <peer@cal.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* Create `getSalesforceTokenLifetime` * When connecting salesforce, add token_lifetime * Migrate token_lifetime and refetch if token expiry doesn't match * Add tests for Salesforce token lifetime feature - Add unit tests for getSalesforceTokenLifetime function - Add integration tests for token lifecycle management in CrmService - Fix type error in CrmService.ts by extracting refreshToken variable Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * chore: re-trigger CI Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This reverts commit 2337e56.
* feat: apply Frame component to webhooks settings page Co-Authored-By: peer@cal.com <peer@cal.com> * refactor: include header and description in Frame component Co-Authored-By: peer@cal.com <peer@cal.com> * refactor: wrap each webhook in individual FramePanel Co-Authored-By: peer@cal.com <peer@cal.com> * refactor: implement two-row badge limit with overflow tooltip for webhooks Co-Authored-By: peer@cal.com <peer@cal.com> * style: improve spacing between webhook items and header Co-Authored-By: peer@cal.com <peer@cal.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Resolved repository/interface refactoring conflicts (IBillingRepository, PrismaTeamBillingRepository, PrismaOrganizationBillingRepository, TeamRepository) - Updated webhook handlers to use DI pattern instead of static methods from deleted stripe-billing-service.ts - Resolved team creation route conflicts - Preserved branch's modular webhook handler architecture while adopting main's DI patterns Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>
Stacked on #24518
What does this PR do?
Adds comprehensive Stripe webhook handling for team and organization subscription events to Cal.com. This extends the existing
customer.subscription.updatedwebhook handler to process subscription updates for teams and organizations, while maintaining backwards compatibility with existing phone number subscription handling.Key additions:
_teamAndOrgUpdateHandler.ts_calAIPhoneNumberUpdateHandler.tsTeamSubscriptionEventHandlerservice class for subscription managementUpdates since last revision
Merged main into this branch and resolved 12 merge conflicts:
getBillingProviderService()) instead of static methods from the deletedstripe-billing-service.tsgetBySubscriptionId→findBySubscriptionId,update→updateById)IBillingRepositoryinterface with new fields from main (billingPeriod,pricePerSeat,paidSeats)Visual Demo (For contributors especially)
https://www.loom.com/share/31035b700fe74f2397cdb0c96b99d425?sid=ca463f26-32ae-4d83-952c-a2506614d9e8
Mandatory Tasks (DO NOT REMOVE)
How should this be tested?
Environment variables required:
STRIPE_TEAM_PRODUCT_ID- Stripe product ID for team subscriptionsSTRIPE_ORG_PRODUCT_ID- Stripe product ID for organization subscriptionsSTRIPE_CAL_AI_PHONE_NUMBER_PRODUCT_ID- Stripe product ID for Cal AI phone numbersDatabase requirements:
TeamBillingandOrganizationBillingmodels exist in Prisma schemayarn prisma generateto update TypeScript typesTesting scenarios:
_calAIPhoneNumberUpdateHandlerChecklist
Human Review Checklist
Critical items to verify:
TeamSubscriptionEventHandler.test.tsimports from correct path (../repository/billing/IBillingRepositoryvs../repository/IBillingRepository)getBillingProviderService()instead of staticStripeBillingServicemethods_handler.tscorrectly routes to appropriate handler based on product IDTeamBillingandOrganizationBillingmodels exist inpackages/prisma/schema.prismaLink to Devin run: https://app.devin.ai/sessions/2e1197c2397b44e2b1308638447969b5
Requested by: @joeauyeung
This PR resolves the issue where Stripe webhook events for team and organization subscriptions were not being properly handled, ensuring subscription status synchronization between Stripe and Cal.com's billing system.