From efb0f8abdb04a8394656febe5448b4a6af62acc2 Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Mon, 12 Jan 2026 13:45:26 +0000 Subject: [PATCH 01/26] feat: track creation for team billing + add logging for seat tracking --- .../web/app/api/teams/[team]/upgrade/route.ts | 33 +++++++++++++ apps/web/app/api/teams/api/create/route.ts | 12 ++++- apps/web/app/api/teams/create/route.ts | 12 ++++- ...onthlyProrationService.integration-test.ts | 46 +++++++++++++++---- .../lib/users/createUsersAndConnectToOrg.ts | 11 ++++- .../features/ee/teams/services/teamService.ts | 28 +++++++++++ .../organizations/bulkDeleteUsers.handler.ts | 34 ++++++++++---- .../routers/viewer/organizations/utils.ts | 37 +++++++++++++++ .../viewer/teams/inviteMember/utils.ts | 36 +++++++++++++++ 9 files changed, 227 insertions(+), 22 deletions(-) diff --git a/apps/web/app/api/teams/[team]/upgrade/route.ts b/apps/web/app/api/teams/[team]/upgrade/route.ts index 57bd3264986b8a..5b0c6d66f44fb2 100644 --- a/apps/web/app/api/teams/[team]/upgrade/route.ts +++ b/apps/web/app/api/teams/[team]/upgrade/route.ts @@ -7,6 +7,10 @@ import type Stripe from "stripe"; import { z } from "zod"; import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; +import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; +import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; +import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils"; +import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/billing/IBillingRepository"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -86,6 +90,35 @@ async function getHandler(req: NextRequest, { params }: { params: Promise { }); it("should process end-to-end proration for annual team with seat additions", async () => { - const seatTracker = new SeatChangeTrackingService(); const prorationService = new MonthlyProrationService(undefined, mockBillingService); + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(7); - await seatTracker.logSeatAddition({ + const newMembers = await Promise.all( + [0, 1, 2].map((index) => + prisma.user.create({ + data: { + email: `test-proration-member-${timestamp}-${randomSuffix}-${index}@example.com`, + username: `testprorationmember-${timestamp}-${randomSuffix}-${index}`, + name: `Test Proration Member ${index}`, + }, + select: { + id: true, + email: true, + username: true, + identityProvider: true, + completedOnboarding: true, + }, + }) + ) + ); + + await createMemberships({ teamId: testTeam.id, - userId: testUser.id, - triggeredBy: testUser.id, - seatCount: 3, + language: "en", + invitees: newMembers.map((member) => ({ + ...member, + profiles: [], + teams: [], + password: null, + newRole: MembershipRole.MEMBER, + needToCreateOrgMembership: false, + })), + parentId: null, + accepted: true, }); - await seatTracker.logSeatRemoval({ + await TeamService.leaveTeamMembership({ teamId: testTeam.id, - userId: testUser.id, - triggeredBy: testUser.id, - seatCount: 1, + userId: newMembers[0].id, }); const proration = await prorationService.createProrationForTeam({ diff --git a/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts b/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts index 51e2cfbb99a9c0..3621be9a240e57 100644 --- a/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts +++ b/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts @@ -1,4 +1,5 @@ import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import prisma from "@calcom/prisma"; import type { IdentityProvider } from "@calcom/prisma/enums"; @@ -69,7 +70,7 @@ export const createUsersAndConnectToOrg = async ({ }); // Create memberships for new members - await MembershipRepository.createMany( + const membershipResult = await MembershipRepository.createMany( users.map((user) => ({ userId: user.id, teamId: org.id, @@ -78,6 +79,14 @@ export const createUsersAndConnectToOrg = async ({ })) ); + if (membershipResult.count > 0) { + const seatTracker = new SeatChangeTrackingService(); + await seatTracker.logSeatAddition({ + teamId: org.id, + seatCount: membershipResult.count, + }); + } + return users; }; diff --git a/packages/features/ee/teams/services/teamService.ts b/packages/features/ee/teams/services/teamService.ts index 054e0c613499c5..835bccfc1300dd 100644 --- a/packages/features/ee/teams/services/teamService.ts +++ b/packages/features/ee/teams/services/teamService.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; +import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService"; import { deleteWorkfowRemindersOfRemovedMember } from "@calcom/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember"; import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries"; import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; @@ -188,6 +189,7 @@ export class TeamService { team: { select: { name: true, + parentId: true, }, }, }, @@ -218,6 +220,15 @@ export class TeamService { } else throw e; } + if (!verificationToken.team.parentId) { + const seatTracker = new SeatChangeTrackingService(); + await seatTracker.logSeatAddition({ + teamId: verificationToken.teamId, + userId, + triggeredBy: userId, + }); + } + const teamBillingServiceFactory = getTeamBillingServiceFactory(); const teamBillingService = await teamBillingServiceFactory.findAndInit(verificationToken.teamId); await teamBillingService.updateQuantity(); @@ -296,6 +307,15 @@ export class TeamService { }, }); } + + if (!membership.team.parentId) { + const seatTracker = new SeatChangeTrackingService(); + await seatTracker.logSeatRemoval({ + teamId, + userId, + triggeredBy: userId, + }); + } } catch (e) { console.log(e); } @@ -375,6 +395,14 @@ export class TeamService { await deleteWorkfowRemindersOfRemovedMember(team, userId, isOrg); + if (!team.parentId) { + const seatTracker = new SeatChangeTrackingService(); + await seatTracker.logSeatRemoval({ + teamId, + userId, + }); + } + return { membership }; } diff --git a/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts index 52fd7d30fcaa23..27668fbeafb4dc 100644 --- a/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts @@ -1,4 +1,5 @@ import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; +import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService"; import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry"; import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; @@ -56,19 +57,22 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand throw new TRPCError({ code: "UNAUTHORIZED" }); } - // Loop over all users in input.userIds and remove all memberships for the organization including child teams - const deleteMany = prisma.membership.deleteMany({ + const deleteOrganizationMemberships = prisma.membership.deleteMany({ + where: { + teamId: currentUserOrgId, + userId: { + in: input.userIds, + }, + }, + }); + + const deleteSubteamMemberships = prisma.membership.deleteMany({ where: { userId: { in: input.userIds, }, team: { - OR: [ - { - parentId: currentUserOrgId, - }, - { id: currentUserOrgId }, - ], + parentId: currentUserOrgId, }, }, }); @@ -130,14 +134,24 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand // We do this in a transaction to make sure that all memberships are removed before we remove the organization relation from the user // We also do this to make sure that if one of the queries fail, the whole transaction fails - await prisma.$transaction([ + const [, { count: orgMembershipRemovalCount }] = await prisma.$transaction([ removeProfiles, - deleteMany, + deleteOrganizationMemberships, + deleteSubteamMemberships, removeOrgrelation, removeManagedEventTypes, removeHostAssignment, ]); + if (orgMembershipRemovalCount > 0) { + const seatTracker = new SeatChangeTrackingService(); + await seatTracker.logSeatRemoval({ + teamId: currentUserOrgId, + seatCount: orgMembershipRemovalCount, + triggeredBy: currentUser.id, + }); + } + const teamBillingServiceFactory = getTeamBillingServiceFactory(); const teamBillingService = await teamBillingServiceFactory.findAndInit(currentUserOrgId); await teamBillingService.updateQuantity(); diff --git a/packages/trpc/server/routers/viewer/organizations/utils.ts b/packages/trpc/server/routers/viewer/organizations/utils.ts index 2c36d0376c3ba1..187a0f25571734 100644 --- a/packages/trpc/server/routers/viewer/organizations/utils.ts +++ b/packages/trpc/server/routers/viewer/organizations/utils.ts @@ -1,4 +1,5 @@ import { TeamRepository } from "@calcom/ee/teams/repositories/TeamRepository"; +import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService"; import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { prisma } from "@calcom/prisma"; @@ -33,6 +34,22 @@ export const addMembersToTeams = async ({ user, input }: AddBulkToTeamProps) => }); } + const teamsForSeatTracking = await prisma.team.findMany({ + where: { + id: { + in: input.teamIds, + }, + }, + select: { + id: true, + parentId: true, + }, + }); + + const topLevelTeamIds = new Set( + teamsForSeatTracking.filter((team) => !team.parentId).map((team) => team.id) + ); + // Check if user has permission to invite team members in the organization const permissionCheckService = new PermissionCheckService(); const hasPermission = await permissionCheckService.checkPermission({ @@ -106,6 +123,26 @@ export const addMembersToTeams = async ({ user, input }: AddBulkToTeamProps) => data: membershipData, }); + if (topLevelTeamIds.size > 0 && membershipData.length > 0) { + const seatTracker = new SeatChangeTrackingService(); + const additionsByTeam = Array.from(topLevelTeamIds) + .map((teamId) => ({ + teamId, + seatCount: membershipData.filter((entry) => entry.teamId === teamId).length, + })) + .filter((entry) => entry.seatCount > 0); + + await Promise.all( + additionsByTeam.map(({ teamId, seatCount }) => + seatTracker.logSeatAddition({ + teamId, + seatCount, + triggeredBy: user.id, + }) + ) + ); + } + membershipData.forEach(async ({ userId, teamId }) => { await updateNewTeamMemberEventTypes(userId, teamId); }); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index 826cc8f94628e6..f65be82765d101 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -4,6 +4,7 @@ import type { TFunction } from "i18next"; import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; import { sendTeamInviteEmail } from "@calcom/emails/organization-email-service"; import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; +import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService"; import { getParsedTeam } from "@calcom/features/ee/teams/lib/getParsedTeam"; import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries"; import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; @@ -464,6 +465,33 @@ export async function createMemberships({ return data; }), }); + + const seatTracker = new SeatChangeTrackingService(); + const teamSeatAdditions = parentId ? 0 : invitees.length; + const organizationSeatAdditions = parentId + ? invitees.filter((invitee) => invitee.needToCreateOrgMembership).length + : 0; + + const trackingPromises: Promise[] = []; + if (teamSeatAdditions > 0) { + trackingPromises.push( + seatTracker.logSeatAddition({ + teamId, + seatCount: teamSeatAdditions, + }) + ); + } + + if (parentId && organizationSeatAdditions > 0) { + trackingPromises.push( + seatTracker.logSeatAddition({ + teamId: parentId, + seatCount: organizationSeatAdditions, + }) + ); + } + + await Promise.all(trackingPromises); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { logger.error("Failed to create memberships", teamId); @@ -928,6 +956,14 @@ export async function handleExistingUsersInvites({ }) ); + if (!team.parentId && existingUsersWithMembershipsNew.length > 0) { + const seatTracker = new SeatChangeTrackingService(); + await seatTracker.logSeatAddition({ + teamId: team.id, + seatCount: existingUsersWithMembershipsNew.length, + }); + } + const autoJoinUsers = existingUsersWithMembershipsNew.filter( (user) => orgConnectInfoByUsernameOrEmail[user.email].autoAccept ); From 10e1aec1c11848182019e8f874713bff716c0b7d Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Mon, 12 Jan 2026 14:06:49 +0000 Subject: [PATCH 02/26] track payment failures and successes on proration --- .../webhook/_customer.subscription.updated.ts | 76 ++++++++++++++++++- .../billing/api/webhook/_invoice.paid.org.ts | 12 ++- .../api/webhook/_invoice.payment_failed.ts | 47 ++++++++++++ .../api/webhook/_invoice.payment_succeeded.ts | 34 +++++++++ .../features/ee/billing/api/webhook/index.ts | 2 + .../IBillingProviderService.ts | 2 + .../billingProvider/StripeBillingService.ts | 12 +++ ...onthlyProrationService.integration-test.ts | 1 + .../__tests__/MonthlyProrationService.test.ts | 1 + .../service/teams/TeamBillingFactory.test.ts | 1 + .../service/teams/TeamBillingService.test.ts | 1 + .../teams/internal-team-billing.test.ts | 1 + 12 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts create mode 100644 packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts index 0621025b0bf865..bb2aa738c2d9a3 100644 --- a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts @@ -1,5 +1,8 @@ +import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; import { PrismaPhoneNumberRepository } from "@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository"; +import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils"; import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; import type { SWHMap } from "./__handler"; @@ -9,6 +12,7 @@ type Data = SWHMap["customer.subscription.updated"]["data"]; const handler = async (data: Data) => { const subscription = data.object; + const previousAttributes = data.previous_attributes; if (!subscription.id) { throw new HttpCode(400, "Subscription ID not found"); @@ -19,11 +23,16 @@ const handler = async (data: Data) => { stripeSubscriptionId: subscription.id, }); - if (!phoneNumber) { - throw new HttpCode(202, "Phone number not found"); - } + const phoneNumberResult = phoneNumber + ? await handleCalAIPhoneNumberSubscriptionUpdate(subscription, phoneNumber) + : null; + + const teamBillingResult = await handleTeamBillingRenewal(subscription, previousAttributes); - return await handleCalAIPhoneNumberSubscriptionUpdate(subscription, phoneNumber); + return { + phoneNumber: phoneNumberResult, + teamBilling: teamBillingResult, + }; }; type Subscription = Data["object"]; @@ -58,4 +67,63 @@ async function handleCalAIPhoneNumberSubscriptionUpdate( return { success: true, subscriptionId: subscription.id, status: subscriptionStatus }; } +async function handleTeamBillingRenewal( + subscription: Subscription, + previousAttributes: Data["previous_attributes"] +) { + if (!previousAttributes?.current_period_start) { + return { skipped: true, reason: "not a renewal" }; + } + + const billingProviderService = getBillingProviderService(); + const { subscriptionStart, subscriptionEnd, subscriptionTrialEnd } = + billingProviderService.extractSubscriptionDates(subscription); + + const { billingPeriod, pricePerSeat, paidSeats } = extractBillingDataFromStripeSubscription(subscription); + + const teamBilling = await prisma.teamBilling.findUnique({ + where: { subscriptionId: subscription.id }, + }); + + if (teamBilling) { + const teamBillingUpdate = { + paidSeats: paidSeats ?? null, + subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd, + billingPeriod, + pricePerSeat: pricePerSeat ?? null, + } as Prisma.TeamBillingUpdateInput; + + await prisma.teamBilling.update({ + where: { id: teamBilling.id }, + data: teamBillingUpdate, + }); + return { success: true, type: "team", teamId: teamBilling.teamId }; + } + + const orgBilling = await prisma.organizationBilling.findUnique({ + where: { subscriptionId: subscription.id }, + }); + + if (orgBilling) { + const organizationBillingUpdate = { + paidSeats: paidSeats ?? null, + subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd, + billingPeriod, + pricePerSeat: pricePerSeat ?? null, + } as Prisma.OrganizationBillingUpdateInput; + + await prisma.organizationBilling.update({ + where: { id: orgBilling.id }, + data: organizationBillingUpdate, + }); + return { success: true, type: "organization", teamId: orgBilling.teamId }; + } + + return { skipped: true, reason: "no billing record found" }; +} + export default handler; diff --git a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts index 2558804303e227..b6ce4090adcf7c 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; +import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils"; import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/billing/IBillingRepository"; import { BillingEnabledOrgOnboardingService } from "@calcom/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService"; import stripe from "@calcom/features/ee/payments/server/stripe"; @@ -124,7 +125,11 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => { // Get the Stripe subscription object const stripeSubscription = await stripe.subscriptions.retrieve(paymentSubscriptionId); const billingService = getBillingProviderService(); - const { subscriptionStart } = billingService.extractSubscriptionDates(stripeSubscription); + const { subscriptionStart, subscriptionEnd, subscriptionTrialEnd } = + billingService.extractSubscriptionDates(stripeSubscription); + + const { billingPeriod, pricePerSeat, paidSeats } = + extractBillingDataFromStripeSubscription(stripeSubscription); const teamBillingServiceFactory = getTeamBillingServiceFactory(); const teamBillingService = teamBillingServiceFactory.init(organization); @@ -137,6 +142,11 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => { status: SubscriptionStatus.ACTIVE, planName: Plan.ORGANIZATION, subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd, + billingPeriod, + pricePerSeat, + paidSeats, }); logger.debug(`Marking onboarding as complete for organization ${organization.id}`); diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts new file mode 100644 index 00000000000000..6676014660cc4b --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts @@ -0,0 +1,47 @@ +import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; +import logger from "@calcom/lib/logger"; + +import { MonthlyProrationService } from "../../service/proration/MonthlyProrationService"; +import type { SWHMap } from "./__handler"; + +const log = logger.getSubLogger({ prefix: ["invoice-payment-failed"] }); + +type Data = SWHMap["invoice.payment_failed"]["data"]; + +const handler = async (data: Data) => { + const invoice = data.object; + + const prorationLineItem = invoice.lines.data.find((line) => line.metadata?.type === "monthly_proration"); + + if (!prorationLineItem) { + return { success: true, message: "no proration line items in invoice" }; + } + + const prorationId = prorationLineItem.metadata?.prorationId; + if (!prorationId) { + log.warn("proration line item missing prorationId metadata"); + return { success: false, message: "missing prorationId in metadata" }; + } + + const prorationService = new MonthlyProrationService(); + let failureReason = invoice.status ?? "payment_failed"; + const paymentIntentId = + typeof invoice.payment_intent === "string" ? invoice.payment_intent : invoice.payment_intent?.id; + + if (paymentIntentId) { + const billingProviderService = getBillingProviderService(); + const paymentFailureReason = await billingProviderService.getPaymentIntentFailureReason(paymentIntentId); + failureReason = paymentFailureReason ?? failureReason; + } + + await prorationService.handleProrationPaymentFailure({ + prorationId, + reason: failureReason, + }); + + log.info(`proration ${prorationId} marked as failed`); + + return { success: true }; +}; + +export default handler; diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts new file mode 100644 index 00000000000000..7465304c108fd2 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts @@ -0,0 +1,34 @@ +import logger from "@calcom/lib/logger"; + +import { MonthlyProrationService } from "../../service/proration/MonthlyProrationService"; +import type { SWHMap } from "./__handler"; + +const log = logger.getSubLogger({ prefix: ["invoice-payment-succeeded"] }); + +type Data = SWHMap["invoice.payment_succeeded"]["data"]; + +const handler = async (data: Data) => { + const invoice = data.object; + + const prorationLineItem = invoice.lines.data.find((line) => line.metadata?.type === "monthly_proration"); + + if (!prorationLineItem) { + return { success: true, message: "no proration line items in invoice" }; + } + + const prorationId = prorationLineItem.metadata?.prorationId; + if (!prorationId) { + log.warn("proration line item missing prorationId metadata"); + return { success: false, message: "missing prorationId in metadata" }; + } + + const prorationService = new MonthlyProrationService(); + + await prorationService.handleProrationPaymentSuccess(prorationId); + + log.info(`proration ${prorationId} marked as charged`); + + return { success: true }; +}; + +export default handler; diff --git a/packages/features/ee/billing/api/webhook/index.ts b/packages/features/ee/billing/api/webhook/index.ts index 44729974614fc8..74346405e54e22 100644 --- a/packages/features/ee/billing/api/webhook/index.ts +++ b/packages/features/ee/billing/api/webhook/index.ts @@ -9,6 +9,8 @@ const handlers = { "customer.subscription.deleted": () => import("./_customer.subscription.deleted"), "customer.subscription.updated": () => import("./_customer.subscription.updated"), "invoice.paid": () => import("./_invoice.paid"), + "invoice.payment_failed": () => import("./_invoice.payment_failed"), + "invoice.payment_succeeded": () => import("./_invoice.payment_succeeded"), "checkout.session.completed": () => import("./_checkout.session.completed"), }; diff --git a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts index b4b4ca43b21f8b..971c9fd1eea05d 100644 --- a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts +++ b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts @@ -83,6 +83,8 @@ export interface IBillingProviderService { finalizeInvoice(invoiceId: string): Promise; + getPaymentIntentFailureReason(paymentIntentId: string): Promise; + // Subscription queries getSubscription(subscriptionId: string): Promise<{ items: Array<{ diff --git a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts index ec4d2ec09cc969..7ebbc71aa1881a 100644 --- a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts @@ -5,6 +5,8 @@ import logger from "@calcom/lib/logger"; import { SubscriptionStatus } from "../../repository/billing/IBillingRepository"; import type { IBillingProviderService } from "./IBillingProviderService"; +const log = logger.getSubLogger({ prefix: ["StripeBillingService"] }); + export class StripeBillingService implements IBillingProviderService { constructor(private stripe: Stripe) {} @@ -272,6 +274,16 @@ export class StripeBillingService implements IBillingProviderService { await this.stripe.invoices.finalizeInvoice(invoiceId); } + async getPaymentIntentFailureReason(paymentIntentId: string) { + try { + const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId); + return paymentIntent.last_payment_error?.message ?? null; + } catch (error) { + log.warn("Failed to retrieve payment intent failure reason", { paymentIntentId, error }); + return null; + } + } + async getSubscription(subscriptionId: string) { const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); if (!subscription) return null; diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts index d4f34c0c110490..ca95893963da2a 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts @@ -40,6 +40,7 @@ const mockBillingService: IBillingProviderService = { getCustomer: vi.fn().mockResolvedValue(null), getSubscriptions: vi.fn().mockResolvedValue(null), updateCustomer: vi.fn().mockResolvedValue(undefined), + getPaymentIntentFailureReason: vi.fn().mockResolvedValue(null), } as IBillingProviderService; describe("MonthlyProrationService Integration Tests", () => { diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts index a037fdec30e7e0..02cf98fa330a9c 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts @@ -98,6 +98,7 @@ const mockBillingService: IBillingProviderService = { getCustomer: vi.fn().mockResolvedValue(null), getSubscriptions: vi.fn().mockResolvedValue(null), updateCustomer: vi.fn().mockResolvedValue(undefined), + getPaymentIntentFailureReason: vi.fn().mockResolvedValue(null), } as IBillingProviderService; vi.mock("../../../repository/proration/MonthlyProrationTeamRepository", () => ({ diff --git a/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts b/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts index 8a7b7c596b52a0..f08ccf169e21ca 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts @@ -27,6 +27,7 @@ describe("TeamBilling", () => { getPrice: vi.fn(), getCheckoutSession: vi.fn(), createCheckoutSession: vi.fn(), + getPaymentIntentFailureReason: vi.fn(), }); const createMockTeamBillingDataRepository = (): ITeamBillingDataRepository => ({ diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts index 6e6605697e2c72..239d552a4c14f8 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts @@ -45,6 +45,7 @@ const createMockBillingProviderService = (): IBillingProviderService => ({ getPrice: vi.fn(), getCheckoutSession: vi.fn(), createCheckoutSession: vi.fn(), + getPaymentIntentFailureReason: vi.fn(), }); const createMockTeamBillingDataRepository = (): ITeamBillingDataRepository => ({ diff --git a/packages/features/ee/billing/teams/internal-team-billing.test.ts b/packages/features/ee/billing/teams/internal-team-billing.test.ts index dac7e034bdc75e..2176f81a634226 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.test.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.test.ts @@ -48,6 +48,7 @@ describe("TeamBillingService", () => { checkoutSessionIsPaid: vi.fn(), getSubscriptionStatus: vi.fn(), handleEndTrial: vi.fn(), + getPaymentIntentFailureReason: vi.fn(), } as IBillingProviderService; mockTeamBillingDataRepository = { From 2510d2ea9a642cd3a75abef02d93b6750d2cd2b2 Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Mon, 12 Jan 2026 14:35:22 +0000 Subject: [PATCH 03/26] test: wip on tests for webhooks and services --- .../_customer.subscription.updated.test.ts | 90 +++++++++++++++++++ .../webhook/_invoice.payment_failed.test.ts | 53 +++++++++++ .../_invoice.payment_succeeded.test.ts | 61 +++++++++++++ .../service/teams/TeamBillingFactory.test.ts | 12 ++- .../service/teams/TeamBillingService.test.ts | 34 ++++--- .../teams/internal-team-billing.test.ts | 34 +++++-- 6 files changed, 264 insertions(+), 20 deletions(-) create mode 100644 packages/features/ee/billing/api/webhook/_customer.subscription.updated.test.ts create mode 100644 packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts create mode 100644 packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.test.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.test.ts new file mode 100644 index 00000000000000..ca6a8fe9e538a2 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SWHMap } from "./__handler"; +import handler from "./_customer.subscription.updated"; + +const prismaMock = { + teamBilling: { + findUnique: vi.fn(), + update: vi.fn(), + }, + organizationBilling: { + findUnique: vi.fn(), + update: vi.fn(), + }, + calAiPhoneNumber: { + update: vi.fn(), + }, +}; + +const findByStripeSubscriptionId = vi.fn().mockResolvedValue(null); + +vi.mock("@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository", () => ({ + PrismaPhoneNumberRepository: class { + findByStripeSubscriptionId = findByStripeSubscriptionId; + }, +})); + +vi.mock("@calcom/ee/billing/di/containers/Billing", () => ({ + getBillingProviderService: () => ({ + extractSubscriptionDates: () => ({ + subscriptionStart: new Date("2024-01-01T00:00:00.000Z"), + subscriptionEnd: new Date("2024-12-31T00:00:00.000Z"), + subscriptionTrialEnd: null, + }), + }), +})); + +vi.mock("@calcom/prisma", () => ({ + default: prismaMock, +})); + +describe("customer.subscription.updated webhook", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("updates team billing on renewal", async () => { + prismaMock.teamBilling.findUnique.mockResolvedValue({ id: "tb_1", teamId: 123 }); + prismaMock.organizationBilling.findUnique.mockResolvedValue(null); + + const data = { + object: { + id: "sub_123", + status: "active", + items: { + data: [ + { + quantity: 5, + price: { + unit_amount: 12000, + recurring: { interval: "year" }, + }, + }, + ], + }, + }, + previous_attributes: { + current_period_start: 1690000000, + }, + } as unknown as SWHMap["customer.subscription.updated"]["data"]; + + const result = await handler(data); + + expect(prismaMock.teamBilling.update).toHaveBeenCalledWith({ + where: { id: "tb_1" }, + data: expect.objectContaining({ + billingPeriod: "ANNUALLY", + pricePerSeat: 12000, + paidSeats: 5, + subscriptionStart: new Date("2024-01-01T00:00:00.000Z"), + subscriptionEnd: new Date("2024-12-31T00:00:00.000Z"), + subscriptionTrialEnd: null, + }), + }); + expect(result).toEqual({ + phoneNumber: null, + teamBilling: { success: true, type: "team", teamId: 123 }, + }); + }); +}); diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts new file mode 100644 index 00000000000000..d94ec2f40cad94 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SWHMap } from "./__handler"; +import handler from "./_invoice.payment_failed"; + +const handleProrationPaymentFailure = vi.fn(); +const getPaymentIntentFailureReason = vi.fn().mockResolvedValue("card_declined"); + +vi.mock("@calcom/ee/billing/di/containers/Billing", () => ({ + getBillingProviderService: () => ({ + getPaymentIntentFailureReason, + }), +})); + +vi.mock("../../service/proration/MonthlyProrationService", () => ({ + MonthlyProrationService: class { + handleProrationPaymentFailure = handleProrationPaymentFailure; + }, +})); + +describe("invoice.payment_failed webhook", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("records proration failure with payment intent reason", async () => { + const data = { + object: { + payment_intent: "pi_123", + status: "open", + lines: { + data: [ + { + metadata: { + type: "monthly_proration", + prorationId: "pr_123", + }, + }, + ], + }, + }, + } as unknown as SWHMap["invoice.payment_failed"]["data"]; + + const result = await handler(data); + + expect(getPaymentIntentFailureReason).toHaveBeenCalledWith("pi_123"); + expect(handleProrationPaymentFailure).toHaveBeenCalledWith({ + prorationId: "pr_123", + reason: "card_declined", + }); + expect(result).toEqual({ success: true }); + }); +}); diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts new file mode 100644 index 00000000000000..a585a7cb0221f8 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SWHMap } from "./__handler"; +import handler from "./_invoice.payment_succeeded"; + +const handleProrationPaymentSuccess = vi.fn(); + +vi.mock("../../service/proration/MonthlyProrationService", () => ({ + MonthlyProrationService: class { + handleProrationPaymentSuccess = handleProrationPaymentSuccess; + }, +})); + +describe("invoice.payment_succeeded webhook", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("marks proration as charged when line item is present", async () => { + const data = { + object: { + lines: { + data: [ + { + metadata: { + type: "monthly_proration", + prorationId: "pr_123", + }, + }, + ], + }, + }, + } as unknown as SWHMap["invoice.payment_succeeded"]["data"]; + + const result = await handler(data); + + expect(handleProrationPaymentSuccess).toHaveBeenCalledWith("pr_123"); + expect(result).toEqual({ success: true }); + }); + + it("skips when no proration line item exists", async () => { + const data = { + object: { + lines: { + data: [ + { + metadata: { + type: "other", + }, + }, + ], + }, + }, + } as unknown as SWHMap["invoice.payment_succeeded"]["data"]; + + const result = await handler(data); + + expect(handleProrationPaymentSuccess).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true, message: "no proration line items in invoice" }); + }); +}); diff --git a/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts b/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts index f08ccf169e21ca..45658cfac4e2ea 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts @@ -19,20 +19,30 @@ describe("TeamBilling", () => { const createMockBillingProviderService = (): IBillingProviderService => ({ handleSubscriptionCancel: vi.fn(), handleSubscriptionUpdate: vi.fn(), + handleSubscriptionCreation: vi.fn(), checkoutSessionIsPaid: vi.fn(), getSubscriptionStatus: vi.fn(), handleEndTrial: vi.fn(), createCustomer: vi.fn(), + createPaymentIntent: vi.fn(), + createSubscriptionCheckout: vi.fn(), createPrice: vi.fn(), getPrice: vi.fn(), getCheckoutSession: vi.fn(), - createCheckoutSession: vi.fn(), + getCustomer: vi.fn(), + getSubscriptions: vi.fn(), + updateCustomer: vi.fn(), + createInvoiceItem: vi.fn(), + createInvoice: vi.fn(), + finalizeInvoice: vi.fn(), + getSubscription: vi.fn(), getPaymentIntentFailureReason: vi.fn(), }); const createMockTeamBillingDataRepository = (): ITeamBillingDataRepository => ({ find: vi.fn(), findMany: vi.fn(), + findBySubscriptionId: vi.fn(), }); const createMockBillingRepository = (): IBillingRepository => ({ diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts index 239d552a4c14f8..d744d7d3bb187e 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { Plan, SubscriptionStatus } from "../../repository/billing/IBillingRepository"; import type { IBillingRepository } from "../../repository/billing/IBillingRepository"; import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; @@ -37,19 +38,30 @@ const mockTeam = { const createMockBillingProviderService = (): IBillingProviderService => ({ handleSubscriptionCancel: vi.fn(), handleSubscriptionUpdate: vi.fn(), + handleSubscriptionCreation: vi.fn(), checkoutSessionIsPaid: vi.fn(), getSubscriptionStatus: vi.fn(), handleEndTrial: vi.fn(), createCustomer: vi.fn(), + createPaymentIntent: vi.fn(), + createSubscriptionCheckout: vi.fn(), createPrice: vi.fn(), getPrice: vi.fn(), getCheckoutSession: vi.fn(), - createCheckoutSession: vi.fn(), + getCustomer: vi.fn(), + getSubscriptions: vi.fn(), + updateCustomer: vi.fn(), + createInvoiceItem: vi.fn(), + createInvoice: vi.fn(), + finalizeInvoice: vi.fn(), + getSubscription: vi.fn(), getPaymentIntentFailureReason: vi.fn(), }); const createMockTeamBillingDataRepository = (): ITeamBillingDataRepository => ({ find: vi.fn(), + findMany: vi.fn(), + findBySubscriptionId: vi.fn(), }); const createMockBillingRepository = (): IBillingRepository => ({ @@ -245,8 +257,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_org_123", subscriptionItemId: "si_org_123", customerId: "cus_org_123", - planName: "ORGANIZATION" as const, - status: "ACTIVE" as const, + planName: Plan.ORGANIZATION, + status: SubscriptionStatus.ACTIVE, }; const mockCreatedRecord = { @@ -283,8 +295,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_team_456", subscriptionItemId: "si_team_456", customerId: "cus_team_456", - planName: "TEAM" as const, - status: "ACTIVE" as const, + planName: Plan.TEAM, + status: SubscriptionStatus.ACTIVE, }; const mockCreatedRecord = { @@ -321,8 +333,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_detailed_789", subscriptionItemId: "si_detailed_789", customerId: "cus_detailed_789", - planName: "ENTERPRISE" as const, - status: "TRIALING" as const, + planName: Plan.ENTERPRISE, + status: SubscriptionStatus.TRIALING, }; const mockCreatedRecord = { @@ -349,8 +361,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_detailed_789", subscriptionItemId: "si_detailed_789", customerId: "cus_detailed_789", - planName: "ENTERPRISE", - status: "TRIALING", + planName: Plan.ENTERPRISE, + status: SubscriptionStatus.TRIALING, }) ); }); @@ -368,8 +380,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_error_999", subscriptionItemId: "si_error_999", customerId: "cus_error_999", - planName: "TEAM" as const, - status: "ACTIVE" as const, + planName: Plan.TEAM, + status: SubscriptionStatus.ACTIVE, }; const repositoryError = new Error("Database constraint violation"); diff --git a/packages/features/ee/billing/teams/internal-team-billing.test.ts b/packages/features/ee/billing/teams/internal-team-billing.test.ts index 2176f81a634226..067ffd1ed92213 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.test.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.test.ts @@ -5,6 +5,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { Plan, SubscriptionStatus } from "../repository/billing/IBillingRepository"; + import type { IBillingRepository } from "../repository/billing/IBillingRepository"; import type { ITeamBillingDataRepository } from "../repository/teamBillingData/ITeamBillingDataRepository"; import type { IBillingProviderService } from "../service/billingProvider/IBillingProviderService"; @@ -45,14 +47,30 @@ describe("TeamBillingService", () => { mockBillingProviderService = { handleSubscriptionCancel: vi.fn(), handleSubscriptionUpdate: vi.fn(), + handleSubscriptionCreation: vi.fn(), checkoutSessionIsPaid: vi.fn(), getSubscriptionStatus: vi.fn(), handleEndTrial: vi.fn(), + createCustomer: vi.fn(), + createPaymentIntent: vi.fn(), + createSubscriptionCheckout: vi.fn(), + createPrice: vi.fn(), + getPrice: vi.fn(), + getCheckoutSession: vi.fn(), + getCustomer: vi.fn(), + getSubscriptions: vi.fn(), + updateCustomer: vi.fn(), + createInvoiceItem: vi.fn(), + createInvoice: vi.fn(), + finalizeInvoice: vi.fn(), + getSubscription: vi.fn(), getPaymentIntentFailureReason: vi.fn(), } as IBillingProviderService; mockTeamBillingDataRepository = { find: vi.fn(), + findMany: vi.fn(), + findBySubscriptionId: vi.fn(), } as unknown as ITeamBillingDataRepository; mockBillingRepository = { @@ -214,8 +232,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_org_123", subscriptionItemId: "si_org_123", customerId: "cus_org_123", - planName: "ORGANIZATION" as const, - status: "ACTIVE" as const, + planName: Plan.ORGANIZATION, + status: SubscriptionStatus.ACTIVE, }; await teamBillingService.saveTeamBilling(mockBillingArgs); @@ -229,8 +247,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_detailed_789", subscriptionItemId: "si_detailed_789", customerId: "cus_detailed_789", - planName: "ENTERPRISE" as const, - status: "TRIALING" as const, + planName: Plan.ENTERPRISE, + status: SubscriptionStatus.TRIALING, }; await teamBillingService.saveTeamBilling(mockBillingArgs); @@ -241,8 +259,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_detailed_789", subscriptionItemId: "si_detailed_789", customerId: "cus_detailed_789", - planName: "ENTERPRISE", - status: "TRIALING", + planName: Plan.ENTERPRISE, + status: SubscriptionStatus.TRIALING, }) ); }); @@ -253,8 +271,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_error_999", subscriptionItemId: "si_error_999", customerId: "cus_error_999", - planName: "TEAM" as const, - status: "ACTIVE" as const, + planName: Plan.TEAM, + status: SubscriptionStatus.ACTIVE, }; const repositoryError = new Error("Database constraint violation"); From f81e784eb8c4d5d7aa5c34bc275be4f842a1fde8 Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Mon, 12 Jan 2026 15:14:28 +0000 Subject: [PATCH 04/26] feat: add cron job --- .../app/api/cron/monthly-proration/route.ts | 44 +++++++++++++++++++ apps/web/vercel.json | 4 ++ 2 files changed, 48 insertions(+) create mode 100644 apps/web/app/api/cron/monthly-proration/route.ts diff --git a/apps/web/app/api/cron/monthly-proration/route.ts b/apps/web/app/api/cron/monthly-proration/route.ts new file mode 100644 index 00000000000000..a0f719bfa5a014 --- /dev/null +++ b/apps/web/app/api/cron/monthly-proration/route.ts @@ -0,0 +1,44 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import logger from "@calcom/lib/logger"; +import { prisma } from "@calcom/prisma"; +import { MonthlyProrationService } from "@calcom/features/ee/billing/service/proration/MonthlyProrationService"; +import { subMonths } from "date-fns"; + +const log = logger.getSubLogger({ prefix: ["monthly-proration-cron"] }); + +async function getHandler(request: NextRequest) { + const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); + + if (process.env.CRON_API_KEY !== apiKey) { + return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); + } + + const featuresRepository = new FeaturesRepository(prisma); + const isEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("monthly-proration"); + + if (!isEnabled) { + return NextResponse.json({ message: "Monthly proration disabled" }); + } + + const now = new Date(); + const startOfCurrentMonthUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const previousMonthUtc = subMonths(startOfCurrentMonthUtc, 1); + const monthKey = `${previousMonthUtc.getUTCFullYear()}-${String( + previousMonthUtc.getUTCMonth() + 1 + ).padStart(2, "0")}`; + + log.info(`Processing monthly prorations for ${monthKey}`); + + const prorationService = new MonthlyProrationService(); + const results = await prorationService.processMonthlyProrations({ monthKey }); + + return NextResponse.json({ + monthKey, + processedTeams: results.length, + }); +} + +export const GET = getHandler; diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 007507b6d356d1..45793682a3830b 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -27,6 +27,10 @@ { "path": "/api/cron/selected-calendars", "schedule": "*/5 * * * *" + }, + { + "path": "/api/cron/monthly-proration", + "schedule": "0 0 1 * *" } ], "functions": { From 7b357a7124090229e2df07e325e1644061c61f5e Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Tue, 13 Jan 2026 10:13:23 +0000 Subject: [PATCH 05/26] collect invoice automatically with payment method --- .../service/billingProvider/IBillingProviderService.ts | 3 ++- .../billing/service/billingProvider/StripeBillingService.ts | 6 +++--- .../ee/billing/service/proration/MonthlyProrationService.ts | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts index 971c9fd1eea05d..55bb025ce4cd10 100644 --- a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts +++ b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts @@ -1,6 +1,6 @@ import type Stripe from "stripe"; -import { SubscriptionStatus } from "../../repository/billing/IBillingRepository"; +import type { SubscriptionStatus } from "../../repository/billing/IBillingRepository"; export interface IBillingProviderService { checkoutSessionIsPaid(paymentId: string): Promise; @@ -78,6 +78,7 @@ export interface IBillingProviderService { createInvoice(args: { customerId: string; autoAdvance: boolean; + collectionMethod?: "charge_automatically" | "send_invoice"; metadata?: Record; }): Promise<{ invoiceId: string }>; diff --git a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts index 7ebbc71aa1881a..d55fad737934ff 100644 --- a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts @@ -1,6 +1,5 @@ -import type Stripe from "stripe"; - import logger from "@calcom/lib/logger"; +import type Stripe from "stripe"; import { SubscriptionStatus } from "../../repository/billing/IBillingRepository"; import type { IBillingProviderService } from "./IBillingProviderService"; @@ -260,10 +259,11 @@ export class StripeBillingService implements IBillingProviderService { } async createInvoice(args: Parameters[0]) { - const { customerId, autoAdvance, metadata } = args; + const { customerId, autoAdvance, collectionMethod, metadata } = args; const invoice = await this.stripe.invoices.create({ customer: customerId, auto_advance: autoAdvance, + collection_method: collectionMethod, metadata, }); diff --git a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts index 3d9c86fa77e57d..b184cb26e9536e 100644 --- a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts +++ b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts @@ -214,6 +214,7 @@ export class MonthlyProrationService { const { invoiceId } = await this.billingService.createInvoice({ customerId: proration.customerId, autoAdvance: true, + collectionMethod: "charge_automatically", metadata: { type: "monthly_proration", prorationId: proration.id, From d144c3d60349013b085dbf4918b31e59e2385716 Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Tue, 13 Jan 2026 10:19:53 +0000 Subject: [PATCH 06/26] feat: add auto charge + testing --- .../MonthlyProrationService.integration-test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts index ca95893963da2a..8d6359829777ea 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts @@ -46,6 +46,7 @@ const mockBillingService: IBillingProviderService = { describe("MonthlyProrationService Integration Tests", () => { let testUser: User; let testTeam: Team; + let billingCustomerId: string; const monthKey = "2026-01"; beforeEach(async () => { @@ -81,12 +82,14 @@ describe("MonthlyProrationService Integration Tests", () => { const subscriptionEnd = new Date("2026-06-01T00:00:00Z"); const subscriptionTrialEnd = new Date("2025-06-08T00:00:00Z"); + billingCustomerId = `cus_test_${timestamp}`; + await prisma.teamBilling.create({ data: { teamId: testTeam.id, subscriptionId: `sub_test_${timestamp}`, subscriptionItemId: `si_test_${timestamp}`, - customerId: `cus_test_${timestamp}`, + customerId: billingCustomerId, billingPeriod: "ANNUALLY", pricePerSeat: 12000, paidSeats: 0, @@ -104,6 +107,9 @@ describe("MonthlyProrationService Integration Tests", () => { const timestamp = Date.now(); const randomSuffix = Math.random().toString(36).substring(7); + vi.mocked(mockBillingService.createInvoice).mockClear(); + vi.mocked(mockBillingService.createInvoiceItem).mockClear(); + const newMembers = await Promise.all( [0, 1, 2].map((index) => prisma.user.create({ @@ -154,6 +160,15 @@ describe("MonthlyProrationService Integration Tests", () => { expect(proration?.seatsRemoved).toBe(1); expect(proration?.status).toBe("INVOICE_CREATED"); expect(proration?.invoiceItemId).toBe("ii_test_123"); + expect(mockBillingService.createInvoice).toHaveBeenCalledWith({ + customerId: billingCustomerId, + autoAdvance: true, + collectionMethod: "charge_automatically", + metadata: { + type: "monthly_proration", + prorationId: proration!.id, + }, + }); const seatChanges = await prisma.seatChangeLog.findMany({ where: { teamId: testTeam.id, monthKey }, From 2f2dbbefa5920d91fd7a2f03652e06d19f1a0a7c Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Tue, 13 Jan 2026 10:42:04 +0000 Subject: [PATCH 07/26] fix test --- .../_customer.subscription.updated.test.ts | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.test.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.test.ts index ca6a8fe9e538a2..a5730a63d9c4c0 100644 --- a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.test.ts +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.test.ts @@ -3,27 +3,31 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SWHMap } from "./__handler"; import handler from "./_customer.subscription.updated"; -const prismaMock = { +let prismaMock!: { teamBilling: { - findUnique: vi.fn(), - update: vi.fn(), - }, + findUnique: ReturnType; + update: ReturnType; + }; organizationBilling: { - findUnique: vi.fn(), - update: vi.fn(), - }, + findUnique: ReturnType; + update: ReturnType; + }; calAiPhoneNumber: { - update: vi.fn(), - }, + update: ReturnType; + }; }; -const findByStripeSubscriptionId = vi.fn().mockResolvedValue(null); +let findByStripeSubscriptionId!: ReturnType; -vi.mock("@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository", () => ({ - PrismaPhoneNumberRepository: class { - findByStripeSubscriptionId = findByStripeSubscriptionId; - }, -})); +vi.mock("@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository", () => { + findByStripeSubscriptionId = vi.fn().mockResolvedValue(null); + + return { + PrismaPhoneNumberRepository: class { + findByStripeSubscriptionId = findByStripeSubscriptionId; + }, + }; +}); vi.mock("@calcom/ee/billing/di/containers/Billing", () => ({ getBillingProviderService: () => ({ @@ -35,9 +39,25 @@ vi.mock("@calcom/ee/billing/di/containers/Billing", () => ({ }), })); -vi.mock("@calcom/prisma", () => ({ - default: prismaMock, -})); +vi.mock("@calcom/prisma", () => { + prismaMock = { + teamBilling: { + findUnique: vi.fn(), + update: vi.fn(), + }, + organizationBilling: { + findUnique: vi.fn(), + update: vi.fn(), + }, + calAiPhoneNumber: { + update: vi.fn(), + }, + }; + + return { + default: prismaMock, + }; +}); describe("customer.subscription.updated webhook", () => { beforeEach(() => { From fb2ba75ec9a7fc99ee1289a483c7c8f18610d10c Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Tue, 13 Jan 2026 13:15:35 +0000 Subject: [PATCH 08/26] feat: proration auto charge - skip sub updates monthy proration - seat change logs for new user invites - cron month key params --- .../app/api/cron/monthly-proration/route.ts | 17 ++- .../IBillingProviderService.ts | 3 + .../StripeBillingService.test.ts | 11 +- .../billingProvider/StripeBillingService.ts | 30 +++++- .../proration/MonthlyProrationService.ts | 10 +- ...onthlyProrationService.integration-test.ts | 2 + .../__tests__/MonthlyProrationService.test.ts | 65 +++++++++++ .../service/teams/TeamBillingService.test.ts | 44 ++++++-- .../service/teams/TeamBillingService.ts | 19 ++-- .../teams/internal-team-billing.test.ts | 44 ++++++-- .../inviteMember/inviteMemberUtils.test.ts | 102 +++++++++++++++--- .../viewer/teams/inviteMember/utils.ts | 23 ++-- 12 files changed, 322 insertions(+), 48 deletions(-) diff --git a/apps/web/app/api/cron/monthly-proration/route.ts b/apps/web/app/api/cron/monthly-proration/route.ts index a0f719bfa5a014..9089bd7b5856ae 100644 --- a/apps/web/app/api/cron/monthly-proration/route.ts +++ b/apps/web/app/api/cron/monthly-proration/route.ts @@ -1,11 +1,11 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - +import process from "node:process"; +import { MonthlyProrationService } from "@calcom/features/ee/billing/service/proration/MonthlyProrationService"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; -import { MonthlyProrationService } from "@calcom/features/ee/billing/service/proration/MonthlyProrationService"; import { subMonths } from "date-fns"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; const log = logger.getSubLogger({ prefix: ["monthly-proration-cron"] }); @@ -23,12 +23,19 @@ async function getHandler(request: NextRequest) { return NextResponse.json({ message: "Monthly proration disabled" }); } + const requestedMonthKey = request.nextUrl.searchParams.get("monthKey"); + const monthKeyPattern = /^\d{4}-(0[1-9]|1[0-2])$/; + if (requestedMonthKey && !monthKeyPattern.test(requestedMonthKey)) { + return NextResponse.json({ message: "Invalid monthKey format. Use YYYY-MM." }, { status: 400 }); + } + const now = new Date(); const startOfCurrentMonthUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); const previousMonthUtc = subMonths(startOfCurrentMonthUtc, 1); - const monthKey = `${previousMonthUtc.getUTCFullYear()}-${String( + const defaultMonthKey = `${previousMonthUtc.getUTCFullYear()}-${String( previousMonthUtc.getUTCMonth() + 1 ).padStart(2, "0")}`; + const monthKey = requestedMonthKey || defaultMonthKey; log.info(`Processing monthly prorations for ${monthKey}`); diff --git a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts index 55bb025ce4cd10..b1fe572a027b9b 100644 --- a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts +++ b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts @@ -10,6 +10,7 @@ export interface IBillingProviderService { subscriptionId: string; subscriptionItemId: string; membershipCount: number; + prorationBehavior?: "none" | "create_prorations" | "always_invoice"; }): Promise; handleEndTrial(subscriptionId: string): Promise; @@ -86,6 +87,8 @@ export interface IBillingProviderService { getPaymentIntentFailureReason(paymentIntentId: string): Promise; + hasDefaultPaymentMethod(args: { customerId: string; subscriptionId?: string }): Promise; + // Subscription queries getSubscription(subscriptionId: string): Promise<{ items: Array<{ diff --git a/packages/features/ee/billing/service/billingProvider/StripeBillingService.test.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.test.ts index 60acf3689c1b5b..1d2475e7476fb2 100644 --- a/packages/features/ee/billing/service/billingProvider/StripeBillingService.test.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.test.ts @@ -1,5 +1,5 @@ import type Stripe from "stripe"; -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { StripeBillingService } from "./StripeBillingService"; @@ -45,6 +45,15 @@ describe("StripeBillingService", () => { expect(stripeMock.subscriptions.update).toHaveBeenCalledWith(args.subscriptionId, { items: [{ quantity: args.membershipCount, id: args.subscriptionItemId }], }); + + await stripeBillingService.handleSubscriptionUpdate({ + ...args, + prorationBehavior: "none", + }); + expect(stripeMock.subscriptions.update).toHaveBeenCalledWith(args.subscriptionId, { + items: [{ quantity: args.membershipCount, id: args.subscriptionItemId }], + proration_behavior: "none", + }); }); it("should throw an error if subscription item is not found", async () => { diff --git a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts index d55fad737934ff..44637cb4e7a8a3 100644 --- a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts @@ -139,7 +139,7 @@ export class StripeBillingService implements IBillingProviderService { } async handleSubscriptionUpdate(args: Parameters[0]) { - const { subscriptionId, subscriptionItemId, membershipCount } = args; + const { subscriptionId, subscriptionItemId, membershipCount, prorationBehavior } = args; const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); const subscriptionQuantity = subscription.items.data.find( (sub) => sub.id === subscriptionItemId @@ -147,6 +147,7 @@ export class StripeBillingService implements IBillingProviderService { if (!subscriptionQuantity) throw new Error("Subscription not found"); await this.stripe.subscriptions.update(subscriptionId, { items: [{ quantity: membershipCount, id: subscriptionItemId }], + ...(prorationBehavior ? { proration_behavior: prorationBehavior } : {}), }); } @@ -284,6 +285,33 @@ export class StripeBillingService implements IBillingProviderService { } } + async hasDefaultPaymentMethod(args: Parameters[0]) { + const { customerId, subscriptionId } = args; + const subscription = subscriptionId ? await this.stripe.subscriptions.retrieve(subscriptionId) : null; + + const subscriptionDefault = subscription + ? typeof subscription.default_payment_method === "string" + ? subscription.default_payment_method + : subscription.default_payment_method?.id + : null; + + if (subscriptionDefault) { + return true; + } + + const customer = await this.stripe.customers.retrieve(customerId); + if (customer.deleted) { + return false; + } + + const customerDefault = + typeof customer.invoice_settings?.default_payment_method === "string" + ? customer.invoice_settings.default_payment_method + : customer.invoice_settings?.default_payment_method?.id; + + return Boolean(customerDefault); + } + async getSubscription(subscriptionId: string) { const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); if (!subscription) return null; diff --git a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts index b184cb26e9536e..3c8af3491dd3a5 100644 --- a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts +++ b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts @@ -211,10 +211,15 @@ export class MonthlyProrationService { }, }); + const hasDefaultPaymentMethod = await this.billingService.hasDefaultPaymentMethod({ + customerId: proration.customerId, + subscriptionId: proration.subscriptionId, + }); + const { invoiceId } = await this.billingService.createInvoice({ customerId: proration.customerId, autoAdvance: true, - collectionMethod: "charge_automatically", + collectionMethod: hasDefaultPaymentMethod ? "charge_automatically" : "send_invoice", metadata: { type: "monthly_proration", prorationId: proration.id, @@ -225,7 +230,7 @@ export class MonthlyProrationService { const updatedProration = await this.prorationRepository.updateProrationStatus( proration.id, - "INVOICE_CREATED", + hasDefaultPaymentMethod ? "INVOICE_CREATED" : "PENDING", { invoiceItemId, invoiceId, @@ -273,6 +278,7 @@ export class MonthlyProrationService { subscriptionId, subscriptionItemId, membershipCount: quantity, + prorationBehavior: "none", }); } catch (error) { this.logger.error(`Failed to update subscription ${subscriptionId} quantity to ${quantity}:`, error); diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts index 8d6359829777ea..812042e481ea32 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts @@ -41,6 +41,7 @@ const mockBillingService: IBillingProviderService = { getSubscriptions: vi.fn().mockResolvedValue(null), updateCustomer: vi.fn().mockResolvedValue(undefined), getPaymentIntentFailureReason: vi.fn().mockResolvedValue(null), + hasDefaultPaymentMethod: vi.fn().mockResolvedValue(true), } as IBillingProviderService; describe("MonthlyProrationService Integration Tests", () => { @@ -365,6 +366,7 @@ describe("MonthlyProrationService Integration Tests", () => { subscriptionId: proration!.subscriptionId, subscriptionItemId: proration!.subscriptionItemId, membershipCount: proration!.seatsAtEnd, + prorationBehavior: "none", }); }); diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts index 02cf98fa330a9c..15be6cae71cee5 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts @@ -99,6 +99,7 @@ const mockBillingService: IBillingProviderService = { getSubscriptions: vi.fn().mockResolvedValue(null), updateCustomer: vi.fn().mockResolvedValue(undefined), getPaymentIntentFailureReason: vi.fn().mockResolvedValue(null), + hasDefaultPaymentMethod: vi.fn().mockResolvedValue(true), } as IBillingProviderService; vi.mock("../../../repository/proration/MonthlyProrationTeamRepository", () => ({ @@ -183,6 +184,7 @@ describe("MonthlyProrationService", () => { subscriptionId: "sub_123", subscriptionItemId: "si_123", membershipCount: 10, + prorationBehavior: "none", }); }); @@ -239,6 +241,68 @@ describe("MonthlyProrationService", () => { expect(mockBillingService.finalizeInvoice).toHaveBeenCalled(); }); + it("should send invoice when no default payment method exists", async () => { + const subscriptionStart = new Date("2026-01-01"); + const subscriptionEnd = new Date("2027-01-01"); + + const { SeatChangeTrackingService } = await import("../../seatTracking/SeatChangeTrackingService"); + vi.spyOn(SeatChangeTrackingService.prototype, "markAsProcessed").mockResolvedValueOnce(2); + vi.mocked(mockBillingService.hasDefaultPaymentMethod).mockResolvedValueOnce(false); + + mockTeamRepository.getTeamWithBilling.mockResolvedValueOnce({ + id: 1, + isOrganization: false, + memberCount: 12, + billing: { + id: "team-billing-789", + subscriptionId: "sub_789", + subscriptionItemId: "si_789", + customerId: "cus_789", + billingPeriod: "ANNUALLY", + pricePerSeat: 10000, + subscriptionStart, + subscriptionEnd, + paidSeats: 10, + }, + }); + + mockProrationRepository.createProration.mockResolvedValueOnce({ + id: "proration-789", + customerId: "cus_789", + proratedAmount: 5000, + netSeatIncrease: 2, + monthKey: "2026-01", + teamId: 1, + subscriptionId: "sub_789", + subscriptionItemId: "si_789", + seatsAtEnd: 12, + } as any); + + mockProrationRepository.updateProrationStatus.mockResolvedValueOnce({ + id: "proration-789", + status: "PENDING", + } as any); + + await service.createProrationForTeam({ + teamId: 1, + monthKey: "2026-01", + }); + + expect(mockBillingService.createInvoice).toHaveBeenCalledWith({ + customerId: "cus_789", + autoAdvance: true, + collectionMethod: "send_invoice", + metadata: { + type: "monthly_proration", + prorationId: "proration-789", + }, + }); + expect(mockProrationRepository.updateProrationStatus).toHaveBeenCalledWith("proration-789", "PENDING", { + invoiceItemId: "ii_test_123", + invoiceId: "in_test_123", + }); + }); + it("should use organization billing for organizations", async () => { const { SeatChangeTrackingService } = await import("../../seatTracking/SeatChangeTrackingService"); vi.spyOn(SeatChangeTrackingService.prototype, "markAsProcessed").mockResolvedValueOnce(3); @@ -333,6 +397,7 @@ describe("MonthlyProrationService", () => { subscriptionId: "sub_123", subscriptionItemId: "si_123", membershipCount: 13, + prorationBehavior: "none", }); }); }); diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts index d744d7d3bb187e..0f716521583b0d 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts @@ -1,12 +1,9 @@ -import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock"; - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; import { WEBAPP_URL } from "@calcom/lib/constants"; - -import { Plan, SubscriptionStatus } from "../../repository/billing/IBillingRepository"; +import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IBillingRepository } from "../../repository/billing/IBillingRepository"; +import { Plan, SubscriptionStatus } from "../../repository/billing/IBillingRepository"; import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; import { TeamBillingPublishResponseStatus } from "./ITeamBillingService"; @@ -24,6 +21,14 @@ vi.mock("@calcom/features/ee/teams/lib/payments", () => ({ purchaseTeamOrOrgSubscription: vi.fn(), })); +const shouldApplyMonthlyProration = vi.fn().mockResolvedValue(false); + +vi.mock("../billingPeriod/BillingPeriodService", () => ({ + BillingPeriodService: class { + shouldApplyMonthlyProration = shouldApplyMonthlyProration; + }, +})); + const mockTeam = { id: 1, metadata: { @@ -56,6 +61,7 @@ const createMockBillingProviderService = (): IBillingProviderService => ({ finalizeInvoice: vi.fn(), getSubscription: vi.fn(), getPaymentIntentFailureReason: vi.fn(), + hasDefaultPaymentMethod: vi.fn(), }); const createMockTeamBillingDataRepository = (): ITeamBillingDataRepository => ({ @@ -177,6 +183,7 @@ describe("TeamBillingService", () => { paymentId: "cs_789", paymentRequired: false, }); + shouldApplyMonthlyProration.mockResolvedValue(false); await teamBillingService.updateQuantity(); @@ -186,6 +193,31 @@ describe("TeamBillingService", () => { membershipCount: 10, }); }); + + it("should skip subscription updates when monthly proration applies", async () => { + const mockTeamNotOrg = { + ...mockTeam, + isOrganization: false, + }; + const teamBillingService = new TeamBillingService({ + team: mockTeamNotOrg, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + prismaMock.membership.count.mockResolvedValue(10); + vi.spyOn(teamBillingService, "checkIfTeamPaymentRequired").mockResolvedValue({ + url: "http://checkout.url", + paymentId: "cs_789", + paymentRequired: false, + }); + shouldApplyMonthlyProration.mockResolvedValue(true); + + await teamBillingService.updateQuantity(); + + expect(mockBillingProviderService.handleSubscriptionUpdate).not.toHaveBeenCalled(); + }); }); describe("checkIfTeamPaymentRequired", () => { diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.ts b/packages/features/ee/billing/service/teams/TeamBillingService.ts index 143a9f58276ba5..e2f40630337072 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.ts @@ -1,5 +1,3 @@ -import type { z } from "zod"; - import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -10,18 +8,19 @@ import { safeStringify } from "@calcom/lib/safeStringify"; import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; - +import type { z } from "zod"; // import billing from "../.."; import type { IBillingRepository, IBillingRepositoryCreateArgs, } from "../../repository/billing/IBillingRepository"; -import { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; +import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; +import { BillingPeriodService } from "../billingPeriod/BillingPeriodService"; import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; import { - TeamBillingPublishResponseStatus, type ITeamBillingService, type TeamBillingInput, + TeamBillingPublishResponseStatus, } from "./ITeamBillingService"; const log = logger.getSubLogger({ prefix: ["TeamBilling"] }); @@ -165,6 +164,14 @@ export class TeamBillingService implements ITeamBillingService { const membershipCount = await prisma.membership.count({ where: { teamId } }); if (!subscriptionId) throw Error("missing subscriptionId"); if (!subscriptionItemId) throw Error("missing subscriptionItemId"); + + const billingPeriodService = new BillingPeriodService(); + const shouldApplyMonthlyProration = await billingPeriodService.shouldApplyMonthlyProration(teamId); + if (shouldApplyMonthlyProration) { + log.info(`Skipping subscription update for team ${teamId} because monthly proration is enabled.`); + return; + } + await this.billingProviderService.handleSubscriptionUpdate({ subscriptionId, subscriptionItemId, @@ -221,6 +228,6 @@ export class TeamBillingService implements ITeamBillingService { } } async saveTeamBilling(args: IBillingRepositoryCreateArgs) { -await this.billingRepository.create(args); + await this.billingRepository.create(args); } } diff --git a/packages/features/ee/billing/teams/internal-team-billing.test.ts b/packages/features/ee/billing/teams/internal-team-billing.test.ts index 067ffd1ed92213..0419d598ebb5f2 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.test.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.test.ts @@ -1,13 +1,9 @@ -import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock"; - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; import { WEBAPP_URL } from "@calcom/lib/constants"; - -import { Plan, SubscriptionStatus } from "../repository/billing/IBillingRepository"; - +import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IBillingRepository } from "../repository/billing/IBillingRepository"; +import { Plan, SubscriptionStatus } from "../repository/billing/IBillingRepository"; import type { ITeamBillingDataRepository } from "../repository/teamBillingData/ITeamBillingDataRepository"; import type { IBillingProviderService } from "../service/billingProvider/IBillingProviderService"; import { TeamBillingPublishResponseStatus } from "../service/teams/ITeamBillingService"; @@ -24,6 +20,14 @@ vi.mock("@calcom/lib/constants", async () => { vi.mock("@calcom/features/ee/teams/lib/payments", () => ({ purchaseTeamOrOrgSubscription: vi.fn(), })); + +const shouldApplyMonthlyProration = vi.fn().mockResolvedValue(false); + +vi.mock("../service/billingPeriod/BillingPeriodService", () => ({ + BillingPeriodService: class { + shouldApplyMonthlyProration = shouldApplyMonthlyProration; + }, +})); const mockTeam = { id: 1, metadata: { @@ -65,6 +69,7 @@ describe("TeamBillingService", () => { finalizeInvoice: vi.fn(), getSubscription: vi.fn(), getPaymentIntentFailureReason: vi.fn(), + hasDefaultPaymentMethod: vi.fn(), } as IBillingProviderService; mockTeamBillingDataRepository = { @@ -160,6 +165,7 @@ describe("TeamBillingService", () => { paymentId: "cs_789", paymentRequired: false, }); + shouldApplyMonthlyProration.mockResolvedValue(false); await teamBillingServiceNotOrg.updateQuantity(); @@ -169,6 +175,30 @@ describe("TeamBillingService", () => { membershipCount: 10, }); }); + + it("should skip subscription updates when monthly proration applies", async () => { + const mockTeamNotOrg = { + ...mockTeam, + isOrganization: false, + }; + const teamBillingServiceNotOrg = new TeamBillingService({ + team: mockTeamNotOrg, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + prismaMock.membership.count.mockResolvedValue(10); + vi.spyOn(teamBillingServiceNotOrg, "checkIfTeamPaymentRequired").mockResolvedValue({ + url: "http://checkout.url", + paymentId: "cs_789", + paymentRequired: false, + }); + shouldApplyMonthlyProration.mockResolvedValue(true); + + await teamBillingServiceNotOrg.updateQuantity(); + + expect(mockBillingProviderService.handleSubscriptionUpdate).not.toHaveBeenCalled(); + }); }); describe("checkIfTeamPaymentRequired", () => { diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts index 2fb6806d1a4389..c1a2267a6a9855 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts @@ -1,27 +1,43 @@ -import { describe, it, vi, expect, beforeEach } from "vitest"; - import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; -import { MembershipRole } from "@calcom/prisma/enums"; - +import { CreationSource, MembershipRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { TeamWithParent } from "./types"; import type { UserWithMembership } from "./utils"; -import { INVITE_STATUS } from "./utils"; import { - ensureAtleastAdminPermissions, - getUniqueInvitationsOrThrowIfEmpty, - getOrgState, - getOrgConnectionInfo, canBeInvited, - getAutoJoinStatus, checkInputEmailIsValid, createMemberships, + createNewUsersConnectToOrgIfExists, + ensureAtleastAdminPermissions, + getAutoJoinStatus, + getOrgConnectionInfo, + getOrgState, + getUniqueInvitationsOrThrowIfEmpty, } from "./utils"; -const { mockCreateMany } = vi.hoisted(() => { +const { mockCreateMany, mockUserCreate, mockMembershipCreate, mockTransaction } = vi.hoisted(() => { const mockCreateManyFn = vi.fn(); - return { mockCreateMany: mockCreateManyFn }; + const mockUserCreateFn = vi.fn(); + const mockMembershipCreateFn = vi.fn(); + const mockTransactionFn = vi.fn(async (callback: (tx: any) => Promise) => { + return callback({ + user: { + create: mockUserCreateFn, + }, + membership: { + create: mockMembershipCreateFn, + }, + }); + }); + + return { + mockCreateMany: mockCreateManyFn, + mockUserCreate: mockUserCreateFn, + mockMembershipCreate: mockMembershipCreateFn, + mockTransaction: mockTransactionFn, + }; }); vi.mock("@calcom/prisma", () => { @@ -30,6 +46,10 @@ vi.mock("@calcom/prisma", () => { membership: { createMany: mockCreateMany, }, + user: { + create: mockUserCreate, + }, + $transaction: mockTransaction, }, }; }); @@ -47,6 +67,20 @@ vi.mock("@calcom/features/pbac/services/permission-check.service", () => { }; }); +const { mockLogSeatAddition } = vi.hoisted(() => { + return { mockLogSeatAddition: vi.fn() }; +}); + +vi.mock("@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService", () => ({ + SeatChangeTrackingService: class { + logSeatAddition = mockLogSeatAddition; + }, +})); + +vi.mock("@calcom/lib/server/i18n", () => ({ + getTranslation: vi.fn().mockResolvedValue((key: string) => key), +})); + vi.mock("@calcom/lib/logger", () => { const mockSubLogger = { debug: vi.fn(), @@ -639,6 +673,50 @@ describe("Invite Member Utils", () => { }); }); + describe("createNewUsersConnectToOrgIfExists", () => { + beforeEach(() => { + mockUserCreate.mockReset(); + mockMembershipCreate.mockReset(); + mockTransaction.mockClear(); + mockLogSeatAddition.mockClear(); + }); + + it("logs seat changes for new users on regular teams", async () => { + let nextId = 100; + mockUserCreate.mockImplementation(async ({ data }) => ({ + id: nextId++, + email: data.email, + })); + mockMembershipCreate.mockResolvedValue({}); + + const invitations = [ + { usernameOrEmail: "new1@example.com", role: MembershipRole.MEMBER }, + { usernameOrEmail: "new2@example.com", role: MembershipRole.MEMBER }, + ]; + const orgConnectInfoByUsernameOrEmail = { + "new1@example.com": { orgId: undefined, autoAccept: false }, + "new2@example.com": { orgId: undefined, autoAccept: false }, + }; + + const result = await createNewUsersConnectToOrgIfExists({ + invitations, + isOrg: false, + teamId: mockedRegularTeam.id, + parentId: null, + autoAcceptEmailDomain: null, + orgConnectInfoByUsernameOrEmail, + language: "en", + creationSource: CreationSource.WEBAPP, + }); + + expect(result).toHaveLength(2); + expect(mockLogSeatAddition).toHaveBeenCalledWith({ + teamId: mockedRegularTeam.id, + seatCount: 2, + }); + }); + }); + describe("createMemberships - Privilege Escalation Prevention", () => { beforeEach(() => { mockCreateMany.mockClear(); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index f65be82765d101..be8486256c85a4 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -1,6 +1,4 @@ import { randomBytes } from "node:crypto"; -import type { TFunction } from "i18next"; - import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; import { sendTeamInviteEmail } from "@calcom/emails/organization-email-service"; import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; @@ -19,14 +17,13 @@ import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; -import type { Membership, OrganizationSettings, Team } from "@calcom/prisma/client"; -import { type User as UserType, type UserPassword, Prisma } from "@calcom/prisma/client"; -import type { Profile as ProfileType } from "@calcom/prisma/client"; +import type { Membership, OrganizationSettings, Profile as ProfileType, Team } from "@calcom/prisma/client"; +import { Prisma, type UserPassword, type User as UserType } from "@calcom/prisma/client"; import type { CreationSource } from "@calcom/prisma/enums"; import { MembershipRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; - import { TRPCError } from "@trpc/server"; +import type { TFunction } from "i18next"; import { isEmail } from "../util"; import type { TeamWithParent } from "./types"; @@ -138,7 +135,7 @@ export async function getUniqueInvitationsOrThrowIfEmpty(invitations: Invitation return uniqueInvitations; } -export const enum INVITE_STATUS { +export enum INVITE_STATUS { USER_PENDING_MEMBER_OF_THE_ORG = "USER_PENDING_MEMBER_OF_THE_ORG", USER_ALREADY_INVITED_OR_MEMBER = "USER_ALREADY_INVITED_OR_MEMBER", USER_MEMBER_OF_OTHER_ORGANIZATION = "USER_MEMBER_OF_OTHER_ORGANIZATION", @@ -271,7 +268,7 @@ export function getOrgConnectionInfo({ team: Pick; isOrg: boolean; }) { - let orgId: number | undefined = undefined; + let orgId: number | undefined; let autoAccept = false; if (team.parentId || isOrg) { @@ -416,6 +413,16 @@ export async function createNewUsersConnectToOrgIfExists({ }, { timeout: 10000 } ); + + if (createdUsers.length > 0) { + const seatTracker = new SeatChangeTrackingService(); + const trackingTeamId = parentId ?? teamId; + await seatTracker.logSeatAddition({ + teamId: trackingTeamId, + seatCount: createdUsers.length, + }); + } + return createdUsers; } From d18341d16adf279777663b822a7671a39db44f25 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Wed, 14 Jan 2026 08:54:56 +0000 Subject: [PATCH 09/26] fix: address proration implementation flaws (#26823) - Add operationId field to SeatChangeLog for idempotency (prevents duplicate seat change logs from race conditions) - Add voidInvoice method to billing service (prevents double charging on retry) - Add days_until_due for send_invoice collection method (ensures invoices have proper due dates) - Fix type error in stripe-subscription-utils (null to undefined conversion) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../billing/lib/stripe-subscription-utils.ts | 4 +-- .../seatChangeLogs/SeatChangeLogRepository.ts | 15 +++++++++++ .../IBillingProviderService.ts | 3 +++ .../billingProvider/StripeBillingService.ts | 20 ++++++++++++++- .../proration/MonthlyProrationService.ts | 7 ++++++ .../seatTracking/SeatChangeTrackingService.ts | 25 +++++++++++++++++-- packages/prisma/schema.prisma | 4 +++ 7 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/features/ee/billing/lib/stripe-subscription-utils.ts b/packages/features/ee/billing/lib/stripe-subscription-utils.ts index 36fe396a414795..6340c339f3776b 100644 --- a/packages/features/ee/billing/lib/stripe-subscription-utils.ts +++ b/packages/features/ee/billing/lib/stripe-subscription-utils.ts @@ -11,9 +11,9 @@ export function extractBillingDataFromStripeSubscription(subscription: Stripe.Su const billingPeriod: BillingPeriod = subscription.items.data[0]?.price.recurring?.interval === "year" ? "ANNUALLY" : "MONTHLY"; - const pricePerSeat = subscription.items.data[0]?.price.unit_amount; + const pricePerSeat = subscription.items.data[0]?.price.unit_amount ?? undefined; - const paidSeats = subscription.items.data[0]?.quantity; + const paidSeats = subscription.items.data[0]?.quantity ?? undefined; return { billingPeriod, diff --git a/packages/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository.ts b/packages/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository.ts index 8162ad692fcae6..576e41c3e42301 100644 --- a/packages/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository.ts +++ b/packages/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository.ts @@ -10,6 +10,7 @@ export interface CreateSeatChangeLogData { userId?: number; triggeredBy?: number; monthKey: string; + operationId?: string; metadata?: Prisma.InputJsonValue; teamBillingId: string | null; organizationBillingId: string | null; @@ -28,6 +29,20 @@ export class SeatChangeLogRepository { } async create(data: CreateSeatChangeLogData): Promise { + // If operationId is provided, use upsert to prevent duplicates + if (data.operationId) { + return await this.prisma.seatChangeLog.upsert({ + where: { + teamId_operationId: { + teamId: data.teamId, + operationId: data.operationId, + }, + }, + create: data, + update: {}, // No update needed - if it exists, we skip + }); + } + return await this.prisma.seatChangeLog.create({ data, }); diff --git a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts index b1fe572a027b9b..9217830aea0160 100644 --- a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts +++ b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts @@ -80,11 +80,14 @@ export interface IBillingProviderService { customerId: string; autoAdvance: boolean; collectionMethod?: "charge_automatically" | "send_invoice"; + daysUntilDue?: number; metadata?: Record; }): Promise<{ invoiceId: string }>; finalizeInvoice(invoiceId: string): Promise; + voidInvoice(invoiceId: string): Promise; + getPaymentIntentFailureReason(paymentIntentId: string): Promise; hasDefaultPaymentMethod(args: { customerId: string; subscriptionId?: string }): Promise; diff --git a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts index 44637cb4e7a8a3..864b1c65f2d5d7 100644 --- a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts @@ -260,11 +260,13 @@ export class StripeBillingService implements IBillingProviderService { } async createInvoice(args: Parameters[0]) { - const { customerId, autoAdvance, collectionMethod, metadata } = args; + const { customerId, autoAdvance, collectionMethod, daysUntilDue, metadata } = args; const invoice = await this.stripe.invoices.create({ customer: customerId, auto_advance: autoAdvance, collection_method: collectionMethod, + // days_until_due is required for send_invoice collection method + ...(collectionMethod === "send_invoice" && { days_until_due: daysUntilDue ?? 30 }), metadata, }); @@ -275,6 +277,22 @@ export class StripeBillingService implements IBillingProviderService { await this.stripe.invoices.finalizeInvoice(invoiceId); } + async voidInvoice(invoiceId: string) { + try { + const invoice = await this.stripe.invoices.retrieve(invoiceId); + // Can only void invoices that are open or uncollectible + if (invoice.status === "open" || invoice.status === "uncollectible") { + await this.stripe.invoices.voidInvoice(invoiceId); + } else if (invoice.status === "draft") { + // Delete draft invoices instead of voiding + await this.stripe.invoices.del(invoiceId); + } + // If paid or void, no action needed + } catch (error) { + log.warn("Failed to void invoice", { invoiceId, error }); + } + } + async getPaymentIntentFailureReason(paymentIntentId: string) { try { const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId); diff --git a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts index 3c8af3491dd3a5..879f4a3dacba49 100644 --- a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts +++ b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts @@ -193,6 +193,8 @@ export class MonthlyProrationService { netSeatIncrease: number; monthKey: string; teamId: number; + subscriptionId: string; + invoiceId?: string | null; }) { const amountInCents = Math.round(proration.proratedAmount); @@ -305,6 +307,11 @@ export class MonthlyProrationService { if (!proration) throw new Error(`Proration ${prorationId} not found`); if (proration.status !== "FAILED") throw new Error(`Proration ${prorationId} is not in FAILED status`); + // Void the old invoice to prevent double charging + if (proration.invoiceId) { + await this.billingService.voidInvoice(proration.invoiceId); + } + await this.createStripeInvoiceItem(proration); } diff --git a/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts b/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts index 03b9a83678d25e..3c81316e60c071 100644 --- a/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts +++ b/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts @@ -14,6 +14,9 @@ export interface SeatChangeLogParams { seatCount?: number; metadata?: Prisma.InputJsonValue; monthKey?: string; + // Idempotency key to prevent duplicate seat change logs from race conditions + // Format: "{source}-{uniqueId}" e.g., "membership-123" or "invite-abc" + operationId?: string; } export interface MonthlyChanges { @@ -32,7 +35,15 @@ export class SeatChangeTrackingService { } async logSeatAddition(params: SeatChangeLogParams): Promise { - const { teamId, userId, triggeredBy, seatCount = 1, metadata, monthKey: providedMonthKey } = params; + const { + teamId, + userId, + triggeredBy, + seatCount = 1, + metadata, + monthKey: providedMonthKey, + operationId, + } = params; const monthKey = providedMonthKey || this.calculateMonthKey(new Date()); const { teamBillingId, organizationBillingId } = await this.repository.getTeamBillingIds(teamId); @@ -44,6 +55,7 @@ export class SeatChangeTrackingService { userId, triggeredBy, monthKey, + operationId, metadata: (metadata || {}) as Prisma.InputJsonValue, teamBillingId, organizationBillingId, @@ -51,7 +63,15 @@ export class SeatChangeTrackingService { } async logSeatRemoval(params: SeatChangeLogParams): Promise { - const { teamId, userId, triggeredBy, seatCount = 1, metadata, monthKey: providedMonthKey } = params; + const { + teamId, + userId, + triggeredBy, + seatCount = 1, + metadata, + monthKey: providedMonthKey, + operationId, + } = params; const monthKey = providedMonthKey || this.calculateMonthKey(new Date()); const { teamBillingId, organizationBillingId } = await this.repository.getTeamBillingIds(teamId); @@ -63,6 +83,7 @@ export class SeatChangeTrackingService { userId, triggeredBy, monthKey, + operationId, metadata: (metadata || {}) as Prisma.InputJsonValue, teamBillingId, organizationBillingId, diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 85027b3f20d09c..c070594434ef69 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -2988,6 +2988,9 @@ model SeatChangeLog { changeDate DateTime @default(now()) monthKey String + // Idempotency key to prevent duplicate seat change logs from race conditions + operationId String? + processedInProrationId String? proration MonthlyProration? @relation(fields: [processedInProrationId], references: [id]) @@ -2999,6 +3002,7 @@ model SeatChangeLog { organizationBillingId String? organizationBilling OrganizationBilling? @relation(fields: [organizationBillingId], references: [id]) + @@unique([teamId, operationId]) @@index([teamId, monthKey]) @@index([teamId, processedInProrationId]) @@index([monthKey]) From 2a5cf624e826b3657eb05689982129101c6176b3 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:03:24 +0000 Subject: [PATCH 10/26] fix: re-throw error in voidInvoice to prevent silent failures (#26824) Addresses Cubic AI feedback: silent error suppression could lead to double charging if the Stripe API call fails. Now the error is logged and re-thrown so callers can handle failures appropriately. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ee/billing/service/billingProvider/StripeBillingService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts index 864b1c65f2d5d7..93a03e885b28b2 100644 --- a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts @@ -290,6 +290,7 @@ export class StripeBillingService implements IBillingProviderService { // If paid or void, no action needed } catch (error) { log.warn("Failed to void invoice", { invoiceId, error }); + throw error; } } From 6cd796b8ea56a78ddfd10ba26ef61148178f2a0a Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Wed, 14 Jan 2026 10:32:23 +0000 Subject: [PATCH 11/26] fix type error + add additional test case --- .../__tests__/MonthlyProrationService.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts index 15be6cae71cee5..104a8c07002789 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts @@ -100,6 +100,7 @@ const mockBillingService: IBillingProviderService = { updateCustomer: vi.fn().mockResolvedValue(undefined), getPaymentIntentFailureReason: vi.fn().mockResolvedValue(null), hasDefaultPaymentMethod: vi.fn().mockResolvedValue(true), + voidInvoice: vi.fn().mockResolvedValue(undefined), } as IBillingProviderService; vi.mock("../../../repository/proration/MonthlyProrationTeamRepository", () => ({ @@ -241,6 +242,70 @@ describe("MonthlyProrationService", () => { expect(mockBillingService.finalizeInvoice).toHaveBeenCalled(); }); + it("should charge only for net additions when removals exist", async () => { + const subscriptionStart = new Date("2026-01-01"); + const subscriptionEnd = new Date("2027-01-01"); + + const { SeatChangeTrackingService } = await import("../../seatTracking/SeatChangeTrackingService"); + vi.spyOn(SeatChangeTrackingService.prototype, "getMonthlyChanges").mockResolvedValueOnce({ + additions: 20, + removals: 10, + netChange: 10, + }); + vi.spyOn(SeatChangeTrackingService.prototype, "markAsProcessed").mockResolvedValueOnce(10); + + mockTeamRepository.getTeamWithBilling.mockResolvedValueOnce({ + id: 1, + isOrganization: false, + memberCount: 120, + billing: { + id: "team-billing-999", + subscriptionId: "sub_999", + subscriptionItemId: "si_999", + customerId: "cus_999", + billingPeriod: "ANNUALLY", + pricePerSeat: 10000, + subscriptionStart, + subscriptionEnd, + paidSeats: 110, + }, + }); + + mockProrationRepository.createProration.mockResolvedValueOnce({ + id: "proration-999", + customerId: "cus_999", + proratedAmount: 25000, + netSeatIncrease: 10, + monthKey: "2026-01", + teamId: 1, + subscriptionId: "sub_999", + subscriptionItemId: "si_999", + seatsAtEnd: 120, + } as any); + + mockProrationRepository.updateProrationStatus.mockResolvedValueOnce({ + id: "proration-999", + status: "INVOICE_CREATED", + } as any); + + await service.createProrationForTeam({ + teamId: 1, + monthKey: "2026-01", + }); + + expect(mockProrationRepository.createProration).toHaveBeenCalledWith( + expect.objectContaining({ + seatsAdded: 20, + seatsRemoved: 10, + netSeatIncrease: 10, + seatsAtStart: 110, + seatsAtEnd: 120, + }) + ); + expect(mockBillingService.createInvoiceItem).toHaveBeenCalled(); + expect(mockBillingService.createInvoice).toHaveBeenCalled(); + }); + it("should send invoice when no default payment method exists", async () => { const subscriptionStart = new Date("2026-01-01"); const subscriptionEnd = new Date("2027-01-01"); From 7522a814559191d8314cac1e60a982eeabf64298 Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Wed, 14 Jan 2026 10:40:27 +0000 Subject: [PATCH 12/26] Link proration item directly to line item --- .../service/billingProvider/IBillingProviderService.ts | 1 + .../billing/service/billingProvider/StripeBillingService.ts | 3 ++- .../ee/billing/service/proration/MonthlyProrationService.ts | 1 + .../proration/__tests__/MonthlyProrationService.test.ts | 6 +++++- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts index 9217830aea0160..425336de8611cd 100644 --- a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts +++ b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts @@ -73,6 +73,7 @@ export interface IBillingProviderService { amount: number; currency: string; description: string; + subscriptionId?: string; metadata?: Record; }): Promise<{ invoiceItemId: string }>; diff --git a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts index 93a03e885b28b2..a8e88c4380a4a0 100644 --- a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts @@ -247,12 +247,13 @@ export class StripeBillingService implements IBillingProviderService { } async createInvoiceItem(args: Parameters[0]) { - const { customerId, amount, currency, description, metadata } = args; + const { customerId, amount, currency, description, subscriptionId, metadata } = args; const invoiceItem = await this.stripe.invoiceItems.create({ customer: customerId, amount, currency, description, + subscription: subscriptionId, metadata, }); diff --git a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts index 879f4a3dacba49..7faddd7a21e19a 100644 --- a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts +++ b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts @@ -205,6 +205,7 @@ export class MonthlyProrationService { description: `Additional ${proration.netSeatIncrease} seat${ proration.netSeatIncrease > 1 ? "s" : "" } for ${proration.monthKey}`, + subscriptionId: proration.subscriptionId, metadata: { type: "monthly_proration", prorationId: proration.id, diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts index 104a8c07002789..1a812fd0e68b1c 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts @@ -302,7 +302,11 @@ describe("MonthlyProrationService", () => { seatsAtEnd: 120, }) ); - expect(mockBillingService.createInvoiceItem).toHaveBeenCalled(); + expect(mockBillingService.createInvoiceItem).toHaveBeenCalledWith( + expect.objectContaining({ + subscriptionId: "sub_999", + }) + ); expect(mockBillingService.createInvoice).toHaveBeenCalled(); }); From 4ede8838a7012d1363bec55e03653484e6712109 Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Wed, 14 Jan 2026 13:59:18 +0000 Subject: [PATCH 13/26] feat: don't charge invoice + stripe incremnt for current year --- .../MonthlyProrationTeamRepository.ts | 17 ++++- .../IBillingProviderService.ts | 5 ++ .../billingProvider/StripeBillingService.ts | 21 +++++- .../proration/MonthlyProrationService.ts | 70 +++++++++++-------- ...onthlyProrationService.integration-test.ts | 2 + .../__tests__/MonthlyProrationService.test.ts | 8 ++- .../service/teams/TeamBillingFactory.test.ts | 1 + .../service/teams/TeamBillingService.test.ts | 1 + .../teams/internal-team-billing.test.ts | 1 + 9 files changed, 92 insertions(+), 34 deletions(-) diff --git a/packages/features/ee/billing/repository/proration/MonthlyProrationTeamRepository.ts b/packages/features/ee/billing/repository/proration/MonthlyProrationTeamRepository.ts index cb49d0cc712f9f..7e128a2ae46c8e 100644 --- a/packages/features/ee/billing/repository/proration/MonthlyProrationTeamRepository.ts +++ b/packages/features/ee/billing/repository/proration/MonthlyProrationTeamRepository.ts @@ -70,7 +70,9 @@ export class MonthlyProrationTeamRepository { if (!team) return null; - let billing: BillingInfo | null = team.isOrganization ? team.organizationBilling : team.teamBilling; + let billing: BillingInfo | null = team.isOrganization + ? team.organizationBilling + : team.teamBilling; if (!billing) { billing = this.extractBillingFromMetadata(team.metadata); @@ -162,6 +164,19 @@ export class MonthlyProrationTeamRepository { } } + async getTeamMemberCount(teamId: number): Promise { + const team = await this.prisma.team.findUnique({ + where: { id: teamId }, + select: { + _count: { + select: { members: true }, + }, + }, + }); + + return team?._count.members ?? null; + } + async createOrganizationBilling(data: { teamId: number; subscriptionId: string; diff --git a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts index 425336de8611cd..9dc3680f02a7e9 100644 --- a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts +++ b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts @@ -74,14 +74,19 @@ export interface IBillingProviderService { currency: string; description: string; subscriptionId?: string; + invoiceId?: string; metadata?: Record; }): Promise<{ invoiceItemId: string }>; + deleteInvoiceItem(invoiceItemId: string): Promise; + createInvoice(args: { customerId: string; autoAdvance: boolean; collectionMethod?: "charge_automatically" | "send_invoice"; daysUntilDue?: number; + pendingInvoiceItemsBehavior?: "exclude" | "include"; + subscriptionId?: string; metadata?: Record; }): Promise<{ invoiceId: string }>; diff --git a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts index a8e88c4380a4a0..7b077d4416d899 100644 --- a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts @@ -247,12 +247,13 @@ export class StripeBillingService implements IBillingProviderService { } async createInvoiceItem(args: Parameters[0]) { - const { customerId, amount, currency, description, subscriptionId, metadata } = args; + const { customerId, amount, currency, description, subscriptionId, invoiceId, metadata } = args; const invoiceItem = await this.stripe.invoiceItems.create({ customer: customerId, amount, currency, description, + invoice: invoiceId, subscription: subscriptionId, metadata, }); @@ -260,12 +261,28 @@ export class StripeBillingService implements IBillingProviderService { return { invoiceItemId: invoiceItem.id }; } + async deleteInvoiceItem(invoiceItemId: string) { + await this.stripe.invoiceItems.del(invoiceItemId); + } + async createInvoice(args: Parameters[0]) { - const { customerId, autoAdvance, collectionMethod, daysUntilDue, metadata } = args; + const { + customerId, + autoAdvance, + collectionMethod, + daysUntilDue, + pendingInvoiceItemsBehavior, + subscriptionId, + metadata, + } = args; const invoice = await this.stripe.invoices.create({ customer: customerId, auto_advance: autoAdvance, collection_method: collectionMethod, + ...(pendingInvoiceItemsBehavior && !subscriptionId + ? { pending_invoice_items_behavior: pendingInvoiceItemsBehavior } + : {}), + subscription: subscriptionId, // days_until_due is required for send_invoice collection method ...(collectionMethod === "send_invoice" && { days_until_due: daysUntilDue ?? 30 }), metadata, diff --git a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts index 7faddd7a21e19a..bfa7a024ab3841 100644 --- a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts +++ b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts @@ -198,6 +198,11 @@ export class MonthlyProrationService { }) { const amountInCents = Math.round(proration.proratedAmount); + const hasDefaultPaymentMethod = await this.billingService.hasDefaultPaymentMethod({ + customerId: proration.customerId, + subscriptionId: proration.subscriptionId, + }); + const { invoiceItemId } = await this.billingService.createInvoiceItem({ customerId: proration.customerId, amount: amountInCents, @@ -214,33 +219,40 @@ export class MonthlyProrationService { }, }); - const hasDefaultPaymentMethod = await this.billingService.hasDefaultPaymentMethod({ - customerId: proration.customerId, - subscriptionId: proration.subscriptionId, - }); + let invoiceId: string | null = null; - const { invoiceId } = await this.billingService.createInvoice({ - customerId: proration.customerId, - autoAdvance: true, - collectionMethod: hasDefaultPaymentMethod ? "charge_automatically" : "send_invoice", - metadata: { - type: "monthly_proration", - prorationId: proration.id, - }, - }); + try { + const invoice = await this.billingService.createInvoice({ + customerId: proration.customerId, + autoAdvance: true, + collectionMethod: hasDefaultPaymentMethod ? "charge_automatically" : "send_invoice", + subscriptionId: proration.subscriptionId, + metadata: { + type: "monthly_proration", + prorationId: proration.id, + }, + }); - await this.billingService.finalizeInvoice(invoiceId); + invoiceId = invoice.invoiceId; - const updatedProration = await this.prorationRepository.updateProrationStatus( - proration.id, - hasDefaultPaymentMethod ? "INVOICE_CREATED" : "PENDING", - { - invoiceItemId, - invoiceId, - } - ); + await this.billingService.finalizeInvoice(invoiceId); - return updatedProration; + return await this.prorationRepository.updateProrationStatus( + proration.id, + hasDefaultPaymentMethod ? "INVOICE_CREATED" : "PENDING", + { + invoiceItemId, + invoiceId, + } + ); + } catch (error) { + if (invoiceId) { + await this.billingService.voidInvoice(invoiceId); + } else { + await this.billingService.deleteInvoiceItem(invoiceItemId); + } + throw error; + } } async handleProrationPaymentSuccess(prorationId: string) { @@ -253,21 +265,19 @@ export class MonthlyProrationService { chargedAt: new Date(), }); + const currentMemberCount = await this.teamRepository.getTeamMemberCount(proration.teamId); + const seatsToApply = currentMemberCount ?? proration.seatsAtEnd; + await this.updateSubscriptionQuantity( proration.subscriptionId, proration.subscriptionItemId, - proration.seatsAtEnd + seatsToApply ); const billingId = proration.teamBillingId || proration.organizationBillingId; if (billingId) { const isOrganization = !!proration.organizationBillingId; - await this.teamRepository.updatePaidSeats( - proration.teamId, - isOrganization, - billingId, - proration.seatsAtEnd - ); + await this.teamRepository.updatePaidSeats(proration.teamId, isOrganization, billingId, seatsToApply); } } diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts index 812042e481ea32..11b9b5161aa26e 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts @@ -10,6 +10,7 @@ import { MonthlyProrationService } from "../MonthlyProrationService"; const mockBillingService: IBillingProviderService = { createInvoiceItem: vi.fn().mockResolvedValue({ invoiceItemId: "ii_test_123" }), + deleteInvoiceItem: vi.fn().mockResolvedValue(undefined), createInvoice: vi.fn().mockResolvedValue({ invoiceId: "in_test_123" }), finalizeInvoice: vi.fn().mockResolvedValue(undefined), getSubscription: vi.fn().mockResolvedValue({ @@ -165,6 +166,7 @@ describe("MonthlyProrationService Integration Tests", () => { customerId: billingCustomerId, autoAdvance: true, collectionMethod: "charge_automatically", + subscriptionId: proration!.subscriptionId, metadata: { type: "monthly_proration", prorationId: proration!.id, diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts index 1a812fd0e68b1c..9ecd2cf6d530c7 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts @@ -59,6 +59,7 @@ const mockTeamRepository = { getTeamWithBilling: vi.fn(), getAnnualTeamsWithSeatChanges: vi.fn(), updatePaidSeats: vi.fn(), + getTeamMemberCount: vi.fn(), }; const mockProrationRepository = { @@ -69,6 +70,7 @@ const mockProrationRepository = { const mockBillingService: IBillingProviderService = { createInvoiceItem: vi.fn().mockResolvedValue({ invoiceItemId: "ii_test_123" }), + deleteInvoiceItem: vi.fn().mockResolvedValue(undefined), createInvoice: vi.fn().mockResolvedValue({ invoiceId: "in_test_123" }), finalizeInvoice: vi.fn().mockResolvedValue(undefined), getSubscription: vi.fn().mockResolvedValue({ @@ -108,6 +110,7 @@ vi.mock("../../../repository/proration/MonthlyProrationTeamRepository", () => ({ getTeamWithBilling = mockTeamRepository.getTeamWithBilling; getAnnualTeamsWithSeatChanges = mockTeamRepository.getAnnualTeamsWithSeatChanges; updatePaidSeats = mockTeamRepository.updatePaidSeats; + getTeamMemberCount = mockTeamRepository.getTeamMemberCount; }, })); @@ -361,6 +364,7 @@ describe("MonthlyProrationService", () => { customerId: "cus_789", autoAdvance: true, collectionMethod: "send_invoice", + subscriptionId: "sub_789", metadata: { type: "monthly_proration", prorationId: "proration-789", @@ -456,6 +460,7 @@ describe("MonthlyProrationService", () => { } as any); mockProrationRepository.updateProrationStatus.mockResolvedValueOnce(undefined); + mockTeamRepository.getTeamMemberCount.mockResolvedValueOnce(15); await service.handleProrationPaymentSuccess("proration-123"); @@ -465,9 +470,10 @@ describe("MonthlyProrationService", () => { expect(mockBillingService.handleSubscriptionUpdate).toHaveBeenCalledWith({ subscriptionId: "sub_123", subscriptionItemId: "si_123", - membershipCount: 13, + membershipCount: 15, prorationBehavior: "none", }); + expect(mockTeamRepository.updatePaidSeats).toHaveBeenCalledWith(1, false, "billing-123", 15); }); }); diff --git a/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts b/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts index 45658cfac4e2ea..aac1582bbb8640 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts @@ -33,6 +33,7 @@ describe("TeamBilling", () => { getSubscriptions: vi.fn(), updateCustomer: vi.fn(), createInvoiceItem: vi.fn(), + deleteInvoiceItem: vi.fn(), createInvoice: vi.fn(), finalizeInvoice: vi.fn(), getSubscription: vi.fn(), diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts index 0f716521583b0d..09aec002c83190 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts @@ -57,6 +57,7 @@ const createMockBillingProviderService = (): IBillingProviderService => ({ getSubscriptions: vi.fn(), updateCustomer: vi.fn(), createInvoiceItem: vi.fn(), + deleteInvoiceItem: vi.fn(), createInvoice: vi.fn(), finalizeInvoice: vi.fn(), getSubscription: vi.fn(), diff --git a/packages/features/ee/billing/teams/internal-team-billing.test.ts b/packages/features/ee/billing/teams/internal-team-billing.test.ts index 0419d598ebb5f2..1109fff3eefb12 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.test.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.test.ts @@ -65,6 +65,7 @@ describe("TeamBillingService", () => { getSubscriptions: vi.fn(), updateCustomer: vi.fn(), createInvoiceItem: vi.fn(), + deleteInvoiceItem: vi.fn(), createInvoice: vi.fn(), finalizeInvoice: vi.fn(), getSubscription: vi.fn(), From 3533ee1f817a8161e1d5aa28f3787653ecda67ed Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Wed, 14 Jan 2026 15:14:49 +0000 Subject: [PATCH 14/26] chore: cleanup utils and tidy up + refactors: --- .../app/api/cron/monthly-proration/route.ts | 5 +- .../webhook/_invoice.payment_failed.test.ts | 6 +- .../api/webhook/_invoice.payment_failed.ts | 3 +- .../_invoice.payment_succeeded.test.ts | 6 +- .../api/webhook/_invoice.payment_succeeded.ts | 3 +- packages/features/ee/billing/lib/month-key.ts | 5 ++ .../ee/billing/lib/proration-utils.ts | 30 +++++++ .../billing/lib/stripe-subscription-utils.ts | 49 +++++++++-- .../ee/billing/lib/subscription-updates.ts | 30 +++++++ .../proration/MonthlyProrationService.ts | 85 ++++++++----------- ...onthlyProrationService.integration-test.ts | 6 +- .../__tests__/MonthlyProrationService.test.ts | 7 +- .../seatTracking/SeatChangeTrackingService.ts | 14 +-- .../service/teams/TeamBillingService.ts | 6 +- 14 files changed, 165 insertions(+), 90 deletions(-) create mode 100644 packages/features/ee/billing/lib/month-key.ts create mode 100644 packages/features/ee/billing/lib/proration-utils.ts create mode 100644 packages/features/ee/billing/lib/subscription-updates.ts diff --git a/apps/web/app/api/cron/monthly-proration/route.ts b/apps/web/app/api/cron/monthly-proration/route.ts index 9089bd7b5856ae..2bfaf9b5766e3f 100644 --- a/apps/web/app/api/cron/monthly-proration/route.ts +++ b/apps/web/app/api/cron/monthly-proration/route.ts @@ -1,4 +1,5 @@ import process from "node:process"; +import { formatMonthKey } from "@calcom/features/ee/billing/lib/month-key"; import { MonthlyProrationService } from "@calcom/features/ee/billing/service/proration/MonthlyProrationService"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import logger from "@calcom/lib/logger"; @@ -32,9 +33,7 @@ async function getHandler(request: NextRequest) { const now = new Date(); const startOfCurrentMonthUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); const previousMonthUtc = subMonths(startOfCurrentMonthUtc, 1); - const defaultMonthKey = `${previousMonthUtc.getUTCFullYear()}-${String( - previousMonthUtc.getUTCMonth() + 1 - ).padStart(2, "0")}`; + const defaultMonthKey = formatMonthKey(previousMonthUtc); const monthKey = requestedMonthKey || defaultMonthKey; log.info(`Processing monthly prorations for ${monthKey}`); diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts index d94ec2f40cad94..33ef977e389f58 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildMonthlyProrationMetadata } from "../../lib/proration-utils"; import type { SWHMap } from "./__handler"; import handler from "./_invoice.payment_failed"; @@ -31,10 +32,7 @@ describe("invoice.payment_failed webhook", () => { lines: { data: [ { - metadata: { - type: "monthly_proration", - prorationId: "pr_123", - }, + metadata: buildMonthlyProrationMetadata({ prorationId: "pr_123" }), }, ], }, diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts index 6676014660cc4b..f28eaad672fafa 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts @@ -1,6 +1,7 @@ import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; import logger from "@calcom/lib/logger"; +import { findMonthlyProrationLineItem } from "../../lib/proration-utils"; import { MonthlyProrationService } from "../../service/proration/MonthlyProrationService"; import type { SWHMap } from "./__handler"; @@ -11,7 +12,7 @@ type Data = SWHMap["invoice.payment_failed"]["data"]; const handler = async (data: Data) => { const invoice = data.object; - const prorationLineItem = invoice.lines.data.find((line) => line.metadata?.type === "monthly_proration"); + const prorationLineItem = findMonthlyProrationLineItem(invoice.lines.data); if (!prorationLineItem) { return { success: true, message: "no proration line items in invoice" }; diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts index a585a7cb0221f8..2ebc00da4e5e0d 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildMonthlyProrationMetadata } from "../../lib/proration-utils"; import type { SWHMap } from "./__handler"; import handler from "./_invoice.payment_succeeded"; @@ -22,10 +23,7 @@ describe("invoice.payment_succeeded webhook", () => { lines: { data: [ { - metadata: { - type: "monthly_proration", - prorationId: "pr_123", - }, + metadata: buildMonthlyProrationMetadata({ prorationId: "pr_123" }), }, ], }, diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts index 7465304c108fd2..d7a4b448ade982 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts @@ -1,5 +1,6 @@ import logger from "@calcom/lib/logger"; +import { findMonthlyProrationLineItem } from "../../lib/proration-utils"; import { MonthlyProrationService } from "../../service/proration/MonthlyProrationService"; import type { SWHMap } from "./__handler"; @@ -10,7 +11,7 @@ type Data = SWHMap["invoice.payment_succeeded"]["data"]; const handler = async (data: Data) => { const invoice = data.object; - const prorationLineItem = invoice.lines.data.find((line) => line.metadata?.type === "monthly_proration"); + const prorationLineItem = findMonthlyProrationLineItem(invoice.lines.data); if (!prorationLineItem) { return { success: true, message: "no proration line items in invoice" }; diff --git a/packages/features/ee/billing/lib/month-key.ts b/packages/features/ee/billing/lib/month-key.ts new file mode 100644 index 00000000000000..fff73a3406f400 --- /dev/null +++ b/packages/features/ee/billing/lib/month-key.ts @@ -0,0 +1,5 @@ +export function formatMonthKey(date: Date): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + return `${year}-${month}`; +} diff --git a/packages/features/ee/billing/lib/proration-utils.ts b/packages/features/ee/billing/lib/proration-utils.ts new file mode 100644 index 00000000000000..a981de1d133ca6 --- /dev/null +++ b/packages/features/ee/billing/lib/proration-utils.ts @@ -0,0 +1,30 @@ +export const MONTHLY_PRORATION_METADATA_TYPE = "monthly_proration"; + +type MetadataContainer = { + metadata?: Record | null; +}; + +export function buildMonthlyProrationMetadata(params: { + prorationId: string; + teamId?: number; + monthKey?: string; +}): Record { + const metadata: Record = { + type: MONTHLY_PRORATION_METADATA_TYPE, + prorationId: params.prorationId, + }; + + if (typeof params.teamId === "number") { + metadata.teamId = params.teamId.toString(); + } + + if (params.monthKey) { + metadata.monthKey = params.monthKey; + } + + return metadata; +} + +export function findMonthlyProrationLineItem(lineItems: T[]): T | undefined { + return lineItems.find((line) => line.metadata?.type === MONTHLY_PRORATION_METADATA_TYPE); +} diff --git a/packages/features/ee/billing/lib/stripe-subscription-utils.ts b/packages/features/ee/billing/lib/stripe-subscription-utils.ts index 6340c339f3776b..5c6a1808892a13 100644 --- a/packages/features/ee/billing/lib/stripe-subscription-utils.ts +++ b/packages/features/ee/billing/lib/stripe-subscription-utils.ts @@ -1,23 +1,62 @@ -import type Stripe from "stripe"; import type { BillingPeriod } from "@calcom/prisma/enums"; +type SubscriptionItemLike = { + id: string; + quantity: number | null; + price: { + unit_amount: number | null; + recurring: { interval: string } | null; + }; +}; + +type SubscriptionItems = SubscriptionItemLike[] | { data: SubscriptionItemLike[] }; + +export interface StripeSubscriptionLike { + items: SubscriptionItems; + current_period_start: number; + current_period_end: number; + trial_end: number | null; +} + export interface BillingData { billingPeriod: BillingPeriod; pricePerSeat: number | undefined; paidSeats: number | undefined; + subscriptionStart: Date | null; + subscriptionEnd: Date | null; + subscriptionTrialEnd: Date | null; } -export function extractBillingDataFromStripeSubscription(subscription: Stripe.Subscription): BillingData { +const getSubscriptionItems = (subscription: StripeSubscriptionLike) => + Array.isArray(subscription.items) ? subscription.items : subscription.items.data; + +export function extractBillingDataFromStripeSubscription(subscription: StripeSubscriptionLike): BillingData { + const subscriptionItems = getSubscriptionItems(subscription); + const primaryItem = subscriptionItems[0]; + const billingPeriod: BillingPeriod = - subscription.items.data[0]?.price.recurring?.interval === "year" ? "ANNUALLY" : "MONTHLY"; + primaryItem?.price.recurring?.interval === "year" ? "ANNUALLY" : "MONTHLY"; + + const pricePerSeat = primaryItem?.price.unit_amount ?? undefined; + + const paidSeats = primaryItem?.quantity ?? undefined; + + const subscriptionStart = subscription.current_period_start + ? new Date(subscription.current_period_start * 1000) + : null; - const pricePerSeat = subscription.items.data[0]?.price.unit_amount ?? undefined; + const subscriptionEnd = subscription.current_period_end + ? new Date(subscription.current_period_end * 1000) + : null; - const paidSeats = subscription.items.data[0]?.quantity ?? undefined; + const subscriptionTrialEnd = subscription.trial_end ? new Date(subscription.trial_end * 1000) : null; return { billingPeriod, pricePerSeat, paidSeats, + subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd, }; } diff --git a/packages/features/ee/billing/lib/subscription-updates.ts b/packages/features/ee/billing/lib/subscription-updates.ts new file mode 100644 index 00000000000000..2812381ac8d324 --- /dev/null +++ b/packages/features/ee/billing/lib/subscription-updates.ts @@ -0,0 +1,30 @@ +import type { Logger } from "tslog"; + +import type { IBillingProviderService } from "../service/billingProvider/IBillingProviderService"; + +type ProrationBehavior = "none" | "create_prorations" | "always_invoice"; + +export async function updateSubscriptionQuantity(params: { + billingService: IBillingProviderService; + subscriptionId: string; + subscriptionItemId: string; + quantity: number; + prorationBehavior?: ProrationBehavior; + logger?: Logger; +}): Promise { + const { billingService, subscriptionId, subscriptionItemId, quantity, prorationBehavior, logger } = params; + + try { + await billingService.handleSubscriptionUpdate({ + subscriptionId, + subscriptionItemId, + membershipCount: quantity, + ...(prorationBehavior ? { prorationBehavior } : {}), + }); + } catch (error) { + if (logger) { + logger.error(`Failed to update subscription ${subscriptionId} quantity to ${quantity}:`, error); + } + throw error; + } +} diff --git a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts index bfa7a024ab3841..d3bbf579bc9097 100644 --- a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts +++ b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts @@ -1,6 +1,9 @@ import stripe from "@calcom/features/ee/payments/server/stripe"; import logger from "@calcom/lib/logger"; import type { Logger } from "tslog"; +import { buildMonthlyProrationMetadata } from "../../lib/proration-utils"; +import { extractBillingDataFromStripeSubscription } from "../../lib/stripe-subscription-utils"; +import { updateSubscriptionQuantity } from "../../lib/subscription-updates"; import { MonthlyProrationRepository } from "../../repository/proration/MonthlyProrationRepository"; import type { BillingInfo } from "../../repository/proration/MonthlyProrationTeamRepository"; import { MonthlyProrationTeamRepository } from "../../repository/proration/MonthlyProrationTeamRepository"; @@ -140,11 +143,14 @@ export class MonthlyProrationService { return updatedProration; } - await this.updateSubscriptionQuantity( - proration.subscriptionId, - proration.subscriptionItemId, - proration.seatsAtEnd - ); + await updateSubscriptionQuantity({ + billingService: this.billingService, + subscriptionId: proration.subscriptionId, + subscriptionItemId: proration.subscriptionItemId, + quantity: proration.seatsAtEnd, + prorationBehavior: "none", + logger: this.logger, + }); await this.teamRepository.updatePaidSeats( teamId, @@ -211,12 +217,11 @@ export class MonthlyProrationService { proration.netSeatIncrease > 1 ? "s" : "" } for ${proration.monthKey}`, subscriptionId: proration.subscriptionId, - metadata: { - type: "monthly_proration", + metadata: buildMonthlyProrationMetadata({ prorationId: proration.id, - teamId: proration.teamId.toString(), + teamId: proration.teamId, monthKey: proration.monthKey, - }, + }), }); let invoiceId: string | null = null; @@ -227,10 +232,7 @@ export class MonthlyProrationService { autoAdvance: true, collectionMethod: hasDefaultPaymentMethod ? "charge_automatically" : "send_invoice", subscriptionId: proration.subscriptionId, - metadata: { - type: "monthly_proration", - prorationId: proration.id, - }, + metadata: buildMonthlyProrationMetadata({ prorationId: proration.id }), }); invoiceId = invoice.invoiceId; @@ -268,11 +270,14 @@ export class MonthlyProrationService { const currentMemberCount = await this.teamRepository.getTeamMemberCount(proration.teamId); const seatsToApply = currentMemberCount ?? proration.seatsAtEnd; - await this.updateSubscriptionQuantity( - proration.subscriptionId, - proration.subscriptionItemId, - seatsToApply - ); + await updateSubscriptionQuantity({ + billingService: this.billingService, + subscriptionId: proration.subscriptionId, + subscriptionItemId: proration.subscriptionItemId, + quantity: seatsToApply, + prorationBehavior: "none", + logger: this.logger, + }); const billingId = proration.teamBillingId || proration.organizationBillingId; if (billingId) { @@ -281,24 +286,6 @@ export class MonthlyProrationService { } } - private async updateSubscriptionQuantity( - subscriptionId: string, - subscriptionItemId: string, - quantity: number - ): Promise { - try { - await this.billingService.handleSubscriptionUpdate({ - subscriptionId, - subscriptionItemId, - membershipCount: quantity, - prorationBehavior: "none", - }); - } catch (error) { - this.logger.error(`Failed to update subscription ${subscriptionId} quantity to ${quantity}:`, error); - throw error; - } - } - async handleProrationPaymentFailure(params: { prorationId: string; reason: string }) { const { prorationId, reason } = params; @@ -348,25 +335,21 @@ export class MonthlyProrationService { throw new Error(`Subscription ${billing.subscriptionId} not found`); } - const billingPeriod: "ANNUALLY" | "MONTHLY" = - subscription.items[0]?.price.recurring?.interval === "year" ? "ANNUALLY" : "MONTHLY"; - - const pricePerSeat = subscription.items[0]?.price.unit_amount ?? 0; - - const subscriptionStart = subscription.current_period_start - ? new Date(subscription.current_period_start * 1000) - : null; - - const subscriptionEnd = subscription.current_period_end - ? new Date(subscription.current_period_end * 1000) - : null; - + const { + billingPeriod, + pricePerSeat: rawPricePerSeat, + paidSeats: rawPaidSeats, + subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd, + } = extractBillingDataFromStripeSubscription(subscription); + + const pricePerSeat = rawPricePerSeat ?? 0; + const paidSeats = rawPaidSeats ?? 0; const subscriptionItemId = subscription.items[0]?.id || billing.subscriptionItemId || ""; const customerId = subscription.customer; - const subscriptionTrialEnd = subscription.trial_end ? new Date(subscription.trial_end * 1000) : null; if (needsCreation) { - const paidSeats = subscription.items[0]?.quantity || 0; const billingData = { teamId, subscriptionId: billing.subscriptionId, diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts index 11b9b5161aa26e..4718da95bcc293 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts @@ -4,6 +4,7 @@ import type { Team, User } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import { createMemberships } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildMonthlyProrationMetadata } from "../../../lib/proration-utils"; import type { IBillingProviderService } from "../../billingProvider/IBillingProviderService"; import { SeatChangeTrackingService } from "../../seatTracking/SeatChangeTrackingService"; import { MonthlyProrationService } from "../MonthlyProrationService"; @@ -167,10 +168,7 @@ describe("MonthlyProrationService Integration Tests", () => { autoAdvance: true, collectionMethod: "charge_automatically", subscriptionId: proration!.subscriptionId, - metadata: { - type: "monthly_proration", - prorationId: proration!.id, - }, + metadata: buildMonthlyProrationMetadata({ prorationId: proration!.id }), }); const seatChanges = await prisma.seatChangeLog.findMany({ diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts index 9ecd2cf6d530c7..a724ea09f48da0 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts @@ -1,7 +1,7 @@ import { prisma } from "@calcom/prisma"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildMonthlyProrationMetadata } from "../../../lib/proration-utils"; import type { IBillingProviderService } from "../../billingProvider/IBillingProviderService"; - import { MonthlyProrationService } from "../MonthlyProrationService"; vi.mock("@calcom/prisma", () => ({ @@ -365,10 +365,7 @@ describe("MonthlyProrationService", () => { autoAdvance: true, collectionMethod: "send_invoice", subscriptionId: "sub_789", - metadata: { - type: "monthly_proration", - prorationId: "proration-789", - }, + metadata: buildMonthlyProrationMetadata({ prorationId: "proration-789" }), }); expect(mockProrationRepository.updateProrationStatus).toHaveBeenCalledWith("proration-789", "PENDING", { invoiceItemId: "ii_test_123", diff --git a/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts b/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts index 3c81316e60c071..c6e405d309018b 100644 --- a/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts +++ b/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts @@ -1,9 +1,9 @@ -import type { Logger } from "tslog"; - +import { formatMonthKey } from "@calcom/features/ee/billing/lib/month-key"; import { SeatChangeLogRepository } from "@calcom/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository"; import logger from "@calcom/lib/logger"; import type { Prisma } from "@calcom/prisma/client"; import type { SeatChangeType } from "@calcom/prisma/enums"; +import type { Logger } from "tslog"; const log = logger.getSubLogger({ prefix: ["SeatChangeTrackingService"] }); @@ -44,7 +44,7 @@ export class SeatChangeTrackingService { monthKey: providedMonthKey, operationId, } = params; - const monthKey = providedMonthKey || this.calculateMonthKey(new Date()); + const monthKey = providedMonthKey || formatMonthKey(new Date()); const { teamBillingId, organizationBillingId } = await this.repository.getTeamBillingIds(teamId); @@ -72,7 +72,7 @@ export class SeatChangeTrackingService { monthKey: providedMonthKey, operationId, } = params; - const monthKey = providedMonthKey || this.calculateMonthKey(new Date()); + const monthKey = providedMonthKey || formatMonthKey(new Date()); const { teamBillingId, organizationBillingId } = await this.repository.getTeamBillingIds(teamId); @@ -115,10 +115,4 @@ export class SeatChangeTrackingService { return await this.repository.markAsProcessed({ teamId, monthKey, prorationId }); } - - private calculateMonthKey(date: Date): string { - const year = date.getUTCFullYear(); - const month = String(date.getUTCMonth() + 1).padStart(2, "0"); - return `${year}-${month}`; - } } diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.ts b/packages/features/ee/billing/service/teams/TeamBillingService.ts index e2f40630337072..a17d262163f9c8 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.ts @@ -9,6 +9,7 @@ import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; import type { z } from "zod"; +import { updateSubscriptionQuantity } from "../../lib/subscription-updates"; // import billing from "../.."; import type { IBillingRepository, @@ -172,10 +173,11 @@ export class TeamBillingService implements ITeamBillingService { return; } - await this.billingProviderService.handleSubscriptionUpdate({ + await updateSubscriptionQuantity({ + billingService: this.billingProviderService, subscriptionId, subscriptionItemId, - membershipCount, + quantity: membershipCount, }); log.info(`Updated subscription ${subscriptionId} for team ${teamId} to ${membershipCount} seats.`); } catch (error) { From b3c313c0db31a8157864f56f8dec1f62bb0fcdcb Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Wed, 14 Jan 2026 15:40:15 +0000 Subject: [PATCH 15/26] tasker --- .../app/api/cron/monthly-proration/route.ts | 57 +++++++++++++++++-- .../tasker/MonthlyProrationSyncTasker.ts | 16 ++++++ .../tasker/MonthlyProrationTasker.ts | 22 +++++++ .../MonthlyProrationTriggerDevTasker.ts | 13 +++++ .../service/proration/tasker/constants.ts | 1 + .../proration/tasker/trigger/config.ts | 20 +++++++ .../trigger/processMonthlyProrationBatch.ts | 23 ++++++++ .../proration/tasker/trigger/schema.ts | 10 ++++ .../billing/service/proration/tasker/types.ts | 8 +++ packages/features/trigger.config.ts | 3 +- 10 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 packages/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/MonthlyProrationTasker.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/MonthlyProrationTriggerDevTasker.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/constants.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/trigger/config.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/trigger/schema.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/types.ts diff --git a/apps/web/app/api/cron/monthly-proration/route.ts b/apps/web/app/api/cron/monthly-proration/route.ts index 2bfaf9b5766e3f..20f7ef20a592a5 100644 --- a/apps/web/app/api/cron/monthly-proration/route.ts +++ b/apps/web/app/api/cron/monthly-proration/route.ts @@ -1,7 +1,12 @@ import process from "node:process"; import { formatMonthKey } from "@calcom/features/ee/billing/lib/month-key"; -import { MonthlyProrationService } from "@calcom/features/ee/billing/service/proration/MonthlyProrationService"; +import { MonthlyProrationTeamRepository } from "@calcom/features/ee/billing/repository/proration/MonthlyProrationTeamRepository"; +import { MONTHLY_PRORATION_BATCH_SIZE } from "@calcom/features/ee/billing/service/proration/tasker/constants"; +import { MonthlyProrationSyncTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker"; +import { MonthlyProrationTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationTasker"; +import { MonthlyProrationTriggerDevTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationTriggerDevTasker"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { ENABLE_ASYNC_TASKER } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; import { subMonths } from "date-fns"; @@ -36,14 +41,56 @@ async function getHandler(request: NextRequest) { const defaultMonthKey = formatMonthKey(previousMonthUtc); const monthKey = requestedMonthKey || defaultMonthKey; - log.info(`Processing monthly prorations for ${monthKey}`); + log.info(`Scheduling monthly proration tasks for ${monthKey}`); - const prorationService = new MonthlyProrationService(); - const results = await prorationService.processMonthlyProrations({ monthKey }); + const teamRepository = new MonthlyProrationTeamRepository(prisma); + const teamIdsList = await teamRepository.getAnnualTeamsWithSeatChanges(monthKey); + + if (teamIdsList.length === 0) { + return NextResponse.json({ + monthKey, + scheduledTeams: 0, + scheduledBatches: 0, + batchSize: MONTHLY_PRORATION_BATCH_SIZE, + }); + } + + const prorationTasker = new MonthlyProrationTasker({ + logger: log, + asyncTasker: new MonthlyProrationTriggerDevTasker({ logger: log }), + syncTasker: new MonthlyProrationSyncTasker(log), + }); + + const batches: number[][] = []; + for (let index = 0; index < teamIdsList.length; index += MONTHLY_PRORATION_BATCH_SIZE) { + batches.push(teamIdsList.slice(index, index + MONTHLY_PRORATION_BATCH_SIZE)); + } + + log.info(`Scheduling ${teamIdsList.length} teams in ${batches.length} batches for ${monthKey}`); + + const isAsyncTaskerEnabled = + ENABLE_ASYNC_TASKER && process.env.TRIGGER_SECRET_KEY && process.env.TRIGGER_API_URL; + + if (isAsyncTaskerEnabled) { + await Promise.all( + batches.map((teamIds) => + prorationTasker.processBatch({ + monthKey, + teamIds, + }) + ) + ); + } else { + for (const teamIds of batches) { + await prorationTasker.processBatch({ monthKey, teamIds }); + } + } return NextResponse.json({ monthKey, - processedTeams: results.length, + scheduledTeams: teamIdsList.length, + scheduledBatches: batches.length, + batchSize: MONTHLY_PRORATION_BATCH_SIZE, }); } diff --git a/packages/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker.ts b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker.ts new file mode 100644 index 00000000000000..e9ed45d0a11718 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker.ts @@ -0,0 +1,16 @@ +import { nanoid } from "nanoid"; +import type { Logger } from "tslog"; + +import { MonthlyProrationService } from "../MonthlyProrationService"; +import type { IMonthlyProrationTasker } from "./types"; + +export class MonthlyProrationSyncTasker implements IMonthlyProrationTasker { + constructor(private readonly logger: Logger) {} + + async processBatch(payload: Parameters[0]) { + const runId = `sync_${nanoid(10)}`; + const prorationService = new MonthlyProrationService(this.logger); + await prorationService.processMonthlyProrations(payload); + return { runId }; + } +} diff --git a/packages/features/ee/billing/service/proration/tasker/MonthlyProrationTasker.ts b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationTasker.ts new file mode 100644 index 00000000000000..2d2043c96da361 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationTasker.ts @@ -0,0 +1,22 @@ +import { Tasker } from "@calcom/lib/tasker/Tasker"; +import type { Logger } from "tslog"; + +import type { MonthlyProrationSyncTasker } from "./MonthlyProrationSyncTasker"; +import type { MonthlyProrationTriggerDevTasker } from "./MonthlyProrationTriggerDevTasker"; +import type { IMonthlyProrationTasker, MonthlyProrationBatchPayload } from "./types"; + +export interface MonthlyProrationTaskerDependencies { + asyncTasker: MonthlyProrationTriggerDevTasker; + syncTasker: MonthlyProrationSyncTasker; + logger: Logger; +} + +export class MonthlyProrationTasker extends Tasker { + constructor(dependencies: MonthlyProrationTaskerDependencies) { + super(dependencies); + } + + async processBatch(payload: MonthlyProrationBatchPayload): Promise<{ runId: string }> { + return await this.dispatch("processBatch", payload); + } +} diff --git a/packages/features/ee/billing/service/proration/tasker/MonthlyProrationTriggerDevTasker.ts b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationTriggerDevTasker.ts new file mode 100644 index 00000000000000..6a1d99a9d3ae02 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationTriggerDevTasker.ts @@ -0,0 +1,13 @@ +import type { ITaskerDependencies } from "@calcom/lib/tasker/types"; + +import type { IMonthlyProrationTasker } from "./types"; + +export class MonthlyProrationTriggerDevTasker implements IMonthlyProrationTasker { + constructor(public readonly dependencies: ITaskerDependencies) {} + + async processBatch(payload: Parameters[0]) { + const { processMonthlyProrationBatch } = await import("./trigger/processMonthlyProrationBatch"); + const handle = await processMonthlyProrationBatch.trigger(payload); + return { runId: handle.id }; + } +} diff --git a/packages/features/ee/billing/service/proration/tasker/constants.ts b/packages/features/ee/billing/service/proration/tasker/constants.ts new file mode 100644 index 00000000000000..6e642feed4dc2d --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/constants.ts @@ -0,0 +1 @@ +export const MONTHLY_PRORATION_BATCH_SIZE = 10; diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/config.ts b/packages/features/ee/billing/service/proration/tasker/trigger/config.ts new file mode 100644 index 00000000000000..42d89553654338 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/config.ts @@ -0,0 +1,20 @@ +import { queue, type schemaTask } from "@trigger.dev/sdk"; + +type MonthlyProrationTaskConfig = Pick[0], "machine" | "retry" | "queue">; + +export const monthlyProrationQueue = queue({ + name: "monthly-proration", + concurrencyLimit: 5, +}); + +export const monthlyProrationTaskConfig: MonthlyProrationTaskConfig = { + queue: monthlyProrationQueue, + machine: "small-2x", + retry: { + maxAttempts: 3, + factor: 2, + minTimeoutInMs: 1_000, + maxTimeoutInMs: 30_000, + randomize: true, + }, +}; diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts b/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts new file mode 100644 index 00000000000000..eb1fb56fe4ad9f --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts @@ -0,0 +1,23 @@ +import { schemaTask } from "@trigger.dev/sdk"; + +import { monthlyProrationTaskConfig } from "./config"; +import { monthlyProrationBatchSchema } from "./schema"; + +export const processMonthlyProrationBatch = schemaTask({ + id: "billing.monthly-proration.batch", + ...monthlyProrationTaskConfig, + schema: monthlyProrationBatchSchema, + run: async (payload) => { + const { TriggerDevLogger } = await import("@calcom/lib/triggerDevLogger"); + const { MonthlyProrationService } = await import("../../MonthlyProrationService"); + + const triggerDevLogger = new TriggerDevLogger(); + const taskLogger = triggerDevLogger.getSubLogger({ name: "MonthlyProrationTask" }); + const prorationService = new MonthlyProrationService(taskLogger); + + await prorationService.processMonthlyProrations({ + monthKey: payload.monthKey, + teamIds: payload.teamIds, + }); + }, +}); diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/schema.ts b/packages/features/ee/billing/service/proration/tasker/trigger/schema.ts new file mode 100644 index 00000000000000..1bc5ee1c380e39 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +import { MONTHLY_PRORATION_BATCH_SIZE } from "../constants"; + +const monthKeyRegex = /^\d{4}-(0[1-9]|1[0-2])$/; + +export const monthlyProrationBatchSchema = z.object({ + monthKey: z.string().regex(monthKeyRegex), + teamIds: z.array(z.number()).min(1).max(MONTHLY_PRORATION_BATCH_SIZE), +}); diff --git a/packages/features/ee/billing/service/proration/tasker/types.ts b/packages/features/ee/billing/service/proration/tasker/types.ts new file mode 100644 index 00000000000000..eaa479e8c740f8 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/types.ts @@ -0,0 +1,8 @@ +export type MonthlyProrationBatchPayload = { + monthKey: string; + teamIds: number[]; +}; + +export interface IMonthlyProrationTasker { + processBatch(payload: MonthlyProrationBatchPayload): Promise<{ runId: string }>; +} diff --git a/packages/features/trigger.config.ts b/packages/features/trigger.config.ts index 156ee73162bf3c..09f05f2cf49756 100644 --- a/packages/features/trigger.config.ts +++ b/packages/features/trigger.config.ts @@ -1,3 +1,4 @@ +import process from "node:process"; import { defineConfig } from "@trigger.dev/sdk"; import dotEnv from "dotenv"; @@ -8,7 +9,7 @@ export default defineConfig({ project: process.env.TRIGGER_DEV_PROJECT_REF ?? "", // e.g., "proj_abc123" // Directories containing your tasks - dirs: ["./bookings/lib/tasker/trigger/notifications"], // Customize based on your project structure + dirs: ["./bookings/lib/tasker/trigger/notifications", "./ee/billing/service/proration/tasker/trigger"], // Retry configuration retries: { From 0957d03879d72ee365efc51e99cbdd9e3bef217e Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Wed, 14 Jan 2026 15:51:35 +0000 Subject: [PATCH 16/26] chore: Move tasker to DI --- .../app/api/cron/monthly-proration/route.ts | 10 ++----- .../MonthlyProrationSyncTasker.module.ts | 21 +++++++++++++++ .../MonthlyProrationTasker.container.ts | 13 +++++++++ .../tasker/MonthlyProrationTasker.module.ts | 27 +++++++++++++++++++ ...MonthlyProrationTriggerDevTasker.module.ts | 23 ++++++++++++++++ .../features/ee/billing/di/tasker/tokens.ts | 8 ++++++ 6 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 packages/features/ee/billing/di/tasker/MonthlyProrationSyncTasker.module.ts create mode 100644 packages/features/ee/billing/di/tasker/MonthlyProrationTasker.container.ts create mode 100644 packages/features/ee/billing/di/tasker/MonthlyProrationTasker.module.ts create mode 100644 packages/features/ee/billing/di/tasker/MonthlyProrationTriggerDevTasker.module.ts create mode 100644 packages/features/ee/billing/di/tasker/tokens.ts diff --git a/apps/web/app/api/cron/monthly-proration/route.ts b/apps/web/app/api/cron/monthly-proration/route.ts index 20f7ef20a592a5..dab6c7c3bc8ebd 100644 --- a/apps/web/app/api/cron/monthly-proration/route.ts +++ b/apps/web/app/api/cron/monthly-proration/route.ts @@ -1,10 +1,8 @@ import process from "node:process"; +import { getMonthlyProrationTasker } from "@calcom/features/ee/billing/di/tasker/MonthlyProrationTasker.container"; import { formatMonthKey } from "@calcom/features/ee/billing/lib/month-key"; import { MonthlyProrationTeamRepository } from "@calcom/features/ee/billing/repository/proration/MonthlyProrationTeamRepository"; import { MONTHLY_PRORATION_BATCH_SIZE } from "@calcom/features/ee/billing/service/proration/tasker/constants"; -import { MonthlyProrationSyncTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker"; -import { MonthlyProrationTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationTasker"; -import { MonthlyProrationTriggerDevTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationTriggerDevTasker"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { ENABLE_ASYNC_TASKER } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; @@ -55,11 +53,7 @@ async function getHandler(request: NextRequest) { }); } - const prorationTasker = new MonthlyProrationTasker({ - logger: log, - asyncTasker: new MonthlyProrationTriggerDevTasker({ logger: log }), - syncTasker: new MonthlyProrationSyncTasker(log), - }); + const prorationTasker = getMonthlyProrationTasker(); const batches: number[][] = []; for (let index = 0; index < teamIdsList.length; index += MONTHLY_PRORATION_BATCH_SIZE) { diff --git a/packages/features/ee/billing/di/tasker/MonthlyProrationSyncTasker.module.ts b/packages/features/ee/billing/di/tasker/MonthlyProrationSyncTasker.module.ts new file mode 100644 index 00000000000000..1ddc6100c0e8cf --- /dev/null +++ b/packages/features/ee/billing/di/tasker/MonthlyProrationSyncTasker.module.ts @@ -0,0 +1,21 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as loggerServiceModule } from "@calcom/features/di/shared/services/logger.service"; +import { MonthlyProrationSyncTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker"; + +import { MONTHLY_PRORATION_TASKER_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_SYNC_TASKER; +const moduleToken = MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_SYNC_TASKER_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: MonthlyProrationSyncTasker, + dep: loggerServiceModule, +}); + +export const moduleLoader = { + token, + loadModule, +} satisfies ModuleLoader; diff --git a/packages/features/ee/billing/di/tasker/MonthlyProrationTasker.container.ts b/packages/features/ee/billing/di/tasker/MonthlyProrationTasker.container.ts new file mode 100644 index 00000000000000..fc704c757b45a8 --- /dev/null +++ b/packages/features/ee/billing/di/tasker/MonthlyProrationTasker.container.ts @@ -0,0 +1,13 @@ +import { createContainer } from "@calcom/features/di/di"; + +import type { MonthlyProrationTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationTasker"; + +import { moduleLoader as monthlyProrationTaskerModule } from "./MonthlyProrationTasker.module"; +import { MONTHLY_PRORATION_TASKER_DI_TOKENS } from "./tokens"; + +const container = createContainer(); + +export function getMonthlyProrationTasker(): MonthlyProrationTasker { + monthlyProrationTaskerModule.loadModule(container); + return container.get(MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_TASKER); +} diff --git a/packages/features/ee/billing/di/tasker/MonthlyProrationTasker.module.ts b/packages/features/ee/billing/di/tasker/MonthlyProrationTasker.module.ts new file mode 100644 index 00000000000000..5197ff5ba3afe2 --- /dev/null +++ b/packages/features/ee/billing/di/tasker/MonthlyProrationTasker.module.ts @@ -0,0 +1,27 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as loggerServiceModule } from "@calcom/features/di/shared/services/logger.service"; +import { MonthlyProrationTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationTasker"; + +import { moduleLoader as monthlyProrationSyncTaskerModule } from "./MonthlyProrationSyncTasker.module"; +import { moduleLoader as monthlyProrationTriggerTaskerModule } from "./MonthlyProrationTriggerDevTasker.module"; +import { MONTHLY_PRORATION_TASKER_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_TASKER; +const moduleToken = MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_TASKER_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: MonthlyProrationTasker, + depsMap: { + logger: loggerServiceModule, + asyncTasker: monthlyProrationTriggerTaskerModule, + syncTasker: monthlyProrationSyncTaskerModule, + }, +}); + +export const moduleLoader = { + token, + loadModule, +} satisfies ModuleLoader; diff --git a/packages/features/ee/billing/di/tasker/MonthlyProrationTriggerDevTasker.module.ts b/packages/features/ee/billing/di/tasker/MonthlyProrationTriggerDevTasker.module.ts new file mode 100644 index 00000000000000..05a21a0571bef4 --- /dev/null +++ b/packages/features/ee/billing/di/tasker/MonthlyProrationTriggerDevTasker.module.ts @@ -0,0 +1,23 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as loggerServiceModule } from "@calcom/features/di/shared/services/logger.service"; +import { MonthlyProrationTriggerDevTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationTriggerDevTasker"; + +import { MONTHLY_PRORATION_TASKER_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_TRIGGER_TASKER; +const moduleToken = MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_TRIGGER_TASKER_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: MonthlyProrationTriggerDevTasker, + depsMap: { + logger: loggerServiceModule, + }, +}); + +export const moduleLoader = { + token, + loadModule, +} satisfies ModuleLoader; diff --git a/packages/features/ee/billing/di/tasker/tokens.ts b/packages/features/ee/billing/di/tasker/tokens.ts new file mode 100644 index 00000000000000..d7edb00978ca8c --- /dev/null +++ b/packages/features/ee/billing/di/tasker/tokens.ts @@ -0,0 +1,8 @@ +export const MONTHLY_PRORATION_TASKER_DI_TOKENS = { + MONTHLY_PRORATION_TASKER: Symbol("MonthlyProrationTasker"), + MONTHLY_PRORATION_TASKER_MODULE: Symbol("MonthlyProrationTaskerModule"), + MONTHLY_PRORATION_SYNC_TASKER: Symbol("MonthlyProrationSyncTasker"), + MONTHLY_PRORATION_SYNC_TASKER_MODULE: Symbol("MonthlyProrationSyncTaskerModule"), + MONTHLY_PRORATION_TRIGGER_TASKER: Symbol("MonthlyProrationTriggerTasker"), + MONTHLY_PRORATION_TRIGGER_TASKER_MODULE: Symbol("MonthlyProrationTriggerTaskerModule"), +}; From 6132adec3d24ce5d9ce6261c9444ea271595ce6a Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Thu, 15 Jan 2026 10:09:16 +0000 Subject: [PATCH 17/26] fix type error --- packages/features/ee/billing/lib/stripe-subscription-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/ee/billing/lib/stripe-subscription-utils.ts b/packages/features/ee/billing/lib/stripe-subscription-utils.ts index 5c6a1808892a13..2db725a26c58fa 100644 --- a/packages/features/ee/billing/lib/stripe-subscription-utils.ts +++ b/packages/features/ee/billing/lib/stripe-subscription-utils.ts @@ -2,7 +2,7 @@ import type { BillingPeriod } from "@calcom/prisma/enums"; type SubscriptionItemLike = { id: string; - quantity: number | null; + quantity?: number | null; price: { unit_amount: number | null; recurring: { interval: string } | null; From a5cfb7177be8f613752685340bcf718a3ca556b3 Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Thu, 15 Jan 2026 11:12:06 +0000 Subject: [PATCH 18/26] feat: tasker implementation working --- .../app/api/cron/monthly-proration/route.ts | 43 +++++++++--- .../proration/MonthlyProrationService.ts | 70 ++++++++++++++++++- packages/features/package.json | 2 +- packages/lib/tasker/Tasker.ts | 28 ++++++-- .../migration.sql | 14 ++++ yarn.lock | 28 ++++---- 6 files changed, 153 insertions(+), 32 deletions(-) create mode 100644 packages/prisma/migrations/20260115101600_add_unique_constraint/migration.sql diff --git a/apps/web/app/api/cron/monthly-proration/route.ts b/apps/web/app/api/cron/monthly-proration/route.ts index dab6c7c3bc8ebd..c787a250923318 100644 --- a/apps/web/app/api/cron/monthly-proration/route.ts +++ b/apps/web/app/api/cron/monthly-proration/route.ts @@ -14,14 +14,18 @@ import { NextResponse } from "next/server"; const log = logger.getSubLogger({ prefix: ["monthly-proration-cron"] }); async function getHandler(request: NextRequest) { - const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); + const apiKey = + request.headers.get("authorization") || + request.nextUrl.searchParams.get("apiKey"); if (process.env.CRON_API_KEY !== apiKey) { return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); } const featuresRepository = new FeaturesRepository(prisma); - const isEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("monthly-proration"); + const isEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( + "monthly-proration" + ); if (!isEnabled) { return NextResponse.json({ message: "Monthly proration disabled" }); @@ -30,11 +34,16 @@ async function getHandler(request: NextRequest) { const requestedMonthKey = request.nextUrl.searchParams.get("monthKey"); const monthKeyPattern = /^\d{4}-(0[1-9]|1[0-2])$/; if (requestedMonthKey && !monthKeyPattern.test(requestedMonthKey)) { - return NextResponse.json({ message: "Invalid monthKey format. Use YYYY-MM." }, { status: 400 }); + return NextResponse.json( + { message: "Invalid monthKey format. Use YYYY-MM." }, + { status: 400 } + ); } const now = new Date(); - const startOfCurrentMonthUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const startOfCurrentMonthUtc = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) + ); const previousMonthUtc = subMonths(startOfCurrentMonthUtc, 1); const defaultMonthKey = formatMonthKey(previousMonthUtc); const monthKey = requestedMonthKey || defaultMonthKey; @@ -42,7 +51,13 @@ async function getHandler(request: NextRequest) { log.info(`Scheduling monthly proration tasks for ${monthKey}`); const teamRepository = new MonthlyProrationTeamRepository(prisma); - const teamIdsList = await teamRepository.getAnnualTeamsWithSeatChanges(monthKey); + const teamIdsList = await teamRepository.getAnnualTeamsWithSeatChanges( + monthKey + ); + + console.log({ + teamIdsList, + }); if (teamIdsList.length === 0) { return NextResponse.json({ @@ -56,14 +71,24 @@ async function getHandler(request: NextRequest) { const prorationTasker = getMonthlyProrationTasker(); const batches: number[][] = []; - for (let index = 0; index < teamIdsList.length; index += MONTHLY_PRORATION_BATCH_SIZE) { - batches.push(teamIdsList.slice(index, index + MONTHLY_PRORATION_BATCH_SIZE)); + for ( + let index = 0; + index < teamIdsList.length; + index += MONTHLY_PRORATION_BATCH_SIZE + ) { + batches.push( + teamIdsList.slice(index, index + MONTHLY_PRORATION_BATCH_SIZE) + ); } - log.info(`Scheduling ${teamIdsList.length} teams in ${batches.length} batches for ${monthKey}`); + log.info( + `Scheduling ${teamIdsList.length} teams in ${batches.length} batches for ${monthKey}` + ); const isAsyncTaskerEnabled = - ENABLE_ASYNC_TASKER && process.env.TRIGGER_SECRET_KEY && process.env.TRIGGER_API_URL; + ENABLE_ASYNC_TASKER && + process.env.TRIGGER_SECRET_KEY && + process.env.TRIGGER_API_URL; if (isAsyncTaskerEnabled) { await Promise.all( diff --git a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts index d3bbf579bc9097..95898835089b2e 100644 --- a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts +++ b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts @@ -45,6 +45,15 @@ export class MonthlyProrationService { const { monthKey, teamIds } = params; const teamIdsList = teamIds || (await this.teamRepository.getAnnualTeamsWithSeatChanges(monthKey)); + const teamIdsPreview = teamIds && teamIds.length <= 25 ? teamIds : undefined; + const teamIdsTruncated = teamIds ? teamIds.length > 25 : false; + + this.logger.info("Monthly proration batch started", { + monthKey, + teamCount: teamIdsList.length, + teamIds: teamIdsPreview, + teamIdsTruncated, + }); const teamsToProcess = teamIdsList.map((id: number) => ({ id })); @@ -57,18 +66,34 @@ export class MonthlyProrationService { if (result) results.push(result); } + this.logger.info("Monthly proration batch finished", { + monthKey, + teamCount: teamIdsList.length, + processedCount: results.length, + skippedCount: teamIdsList.length - results.length, + }); + return results; } async createProrationForTeam(params: CreateProrationParams) { const { teamId, monthKey } = params; + this.logger.info(`[${teamId}] starting monthly proration`, { monthKey }); + const seatTracker = new SeatChangeTrackingService(); const changes = await seatTracker.getMonthlyChanges({ teamId, monthKey }); + this.logger.info(`[${teamId}] seat changes`, { + additions: changes.additions, + removals: changes.removals, + netChange: changes.netChange, + }); + // If there are no changes at all (no additions or removals), skip processing if (changes.additions === 0 && changes.removals === 0) { + this.logger.info(`[${teamId}] no seat changes, skipping proration`); return null; } @@ -99,6 +124,12 @@ export class MonthlyProrationService { const paidSeats = billing.paidSeats ?? (await this.getSubscriptionQuantity(billing.subscriptionId)); + this.logger.info(`[${teamId}] billing summary`, { + billingPeriod: billing.billingPeriod, + pricePerSeat: billing.pricePerSeat, + paidSeats, + }); + const [year, month] = monthKey.split("-").map(Number); const periodEnd = new Date(Date.UTC(year, month, 0, 23, 59, 59, 999)); @@ -110,6 +141,13 @@ export class MonthlyProrationService { monthEnd: periodEnd, }); + this.logger.info(`[${teamId}] proration calculation`, { + netSeatIncrease: changes.netChange, + pricePerSeat: billing.pricePerSeat, + remainingDays: calculation.remainingDays, + proratedAmount: calculation.proratedAmount, + }); + const proration = await this.prorationRepository.createProration({ teamId, monthKey, @@ -132,6 +170,14 @@ export class MonthlyProrationService { organizationBillingId: teamWithBilling.isOrganization ? billing.id : null, }); + this.logger.info(`[${teamId}] proration record created`, { + prorationId: proration.id, + seatsAdded: proration.seatsAdded, + seatsRemoved: proration.seatsRemoved, + netSeatIncrease: proration.netSeatIncrease, + status: proration.status, + }); + await seatTracker.markAsProcessed({ teamId, monthKey, @@ -139,10 +185,24 @@ export class MonthlyProrationService { }); if (calculation.proratedAmount > 0) { + this.logger.info(`[${teamId}] creating invoice item`, { + prorationId: proration.id, + proratedAmount: proration.proratedAmount, + }); const updatedProration = await this.createStripeInvoiceItem(proration); + this.logger.info(`[${teamId}] invoice processed`, { + prorationId: updatedProration.id, + status: updatedProration.status, + invoiceId: updatedProration.invoiceId, + }); return updatedProration; } + this.logger.info(`[${teamId}] no charge required, updating subscription quantity`, { + prorationId: proration.id, + seatsAtEnd: proration.seatsAtEnd, + }); + await updateSubscriptionQuantity({ billingService: this.billingService, subscriptionId: proration.subscriptionId, @@ -159,9 +219,17 @@ export class MonthlyProrationService { proration.seatsAtEnd ); - return await this.prorationRepository.updateProrationStatus(proration.id, "CHARGED", { + const updatedProration = await this.prorationRepository.updateProrationStatus(proration.id, "CHARGED", { chargedAt: new Date(), }); + + this.logger.info(`[${teamId}] subscription updated without charge`, { + prorationId: updatedProration.id, + status: updatedProration.status, + seatsAtEnd: updatedProration.seatsAtEnd, + }); + + return updatedProration; } private calculateProration(params: { diff --git a/packages/features/package.json b/packages/features/package.json index 540a33306635ce..458295c7c8a1fd 100644 --- a/packages/features/package.json +++ b/packages/features/package.json @@ -20,7 +20,7 @@ "@evyweb/ioctopus": "1.2.0", "@tanstack/react-table": "8.20.6", "@tanstack/react-virtual": "3.10.9", - "@trigger.dev/sdk": "4.1.2", + "@trigger.dev/sdk": "4.3.2", "@vercel/functions": "1.4.0", "city-timezones": "1.2.1", "class-variance-authority": "0.7.1", diff --git a/packages/lib/tasker/Tasker.ts b/packages/lib/tasker/Tasker.ts index 758d8d3a382ae9..c87724d71c6c48 100644 --- a/packages/lib/tasker/Tasker.ts +++ b/packages/lib/tasker/Tasker.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { configure } from "@trigger.dev/sdk"; +import process from "node:process"; +import { configure } from "@trigger.dev/sdk"; import { ENABLE_ASYNC_TASKER } from "../constants"; import type { ILogger } from "./types"; @@ -53,9 +54,13 @@ export abstract class Tasker { const method = this.asyncTasker[taskName] as (...args: any[]) => any; return await method.apply(this.asyncTasker, args); } catch (err) { + const taskerLabel = isAsyncTaskerEnabled ? "AsyncTasker" : "SyncTasker"; + const baseUrlInfo = isAsyncTaskerEnabled + ? ` (baseURL: ${process.env.TRIGGER_API_URL ?? "unknown"})` + : ""; this.logger.error( - `${isAsyncTaskerEnabled ? "AsyncTasker" : "SyncTasker"} failed for '${String(taskName)}'.`, - (err as Error)?.message ?? "ERROR MESSAGE UNAVAILABLE" + `${taskerLabel} failed for '${String(taskName)}'.${baseUrlInfo}`, + this.getErrorDetails(err) ); if (this.asyncTasker === this.syncTasker) { @@ -68,12 +73,21 @@ export abstract class Tasker { const fallbackMethod = this.syncTasker[taskName] as (...args: any[]) => any; return await fallbackMethod.apply(this.syncTasker, args); } catch (err) { - this.logger.error( - `SyncTasker failed for '${String(taskName)}'.`, - (err as Error)?.message ?? "ERROR MESSAGE UNAVAILABLE" - ); + this.logger.error(`SyncTasker failed for '${String(taskName)}'.`, this.getErrorDetails(err)); throw err; } } } + + private getErrorDetails(err: unknown) { + if (err instanceof Error) { + return { + name: err.name, + message: err.message, + stack: err.stack, + }; + } + + return { message: String(err) }; + } } diff --git a/packages/prisma/migrations/20260115101600_add_unique_constraint/migration.sql b/packages/prisma/migrations/20260115101600_add_unique_constraint/migration.sql new file mode 100644 index 00000000000000..bd7c8e3e55e235 --- /dev/null +++ b/packages/prisma/migrations/20260115101600_add_unique_constraint/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[teamId,operationId]` on the table `SeatChangeLog` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "public"."SeatChangeLog" ADD COLUMN "operationId" TEXT; + +-- CreateIndex +CREATE INDEX "IntegrationAttributeSync_credentialId_idx" ON "public"."IntegrationAttributeSync"("credentialId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SeatChangeLog_teamId_operationId_key" ON "public"."SeatChangeLog"("teamId", "operationId"); diff --git a/yarn.lock b/yarn.lock index 5cbec33c58c6f5..9212e2a46a1642 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2587,7 +2587,7 @@ __metadata: "@tanstack/react-table": "npm:8.20.6" "@tanstack/react-virtual": "npm:3.10.9" "@testing-library/react-hooks": "npm:8.0.1" - "@trigger.dev/sdk": "npm:4.1.2" + "@trigger.dev/sdk": "npm:4.3.2" "@types/web-push": "npm:3.6.3" "@vercel/functions": "npm:1.4.0" city-timezones: "npm:1.2.1" @@ -14938,9 +14938,9 @@ __metadata: languageName: node linkType: hard -"@trigger.dev/core@npm:4.1.2": - version: 4.1.2 - resolution: "@trigger.dev/core@npm:4.1.2" +"@trigger.dev/core@npm:4.3.1": + version: 4.3.1 + resolution: "@trigger.dev/core@npm:4.3.1" dependencies: "@bugsnag/cuid": "npm:^3.1.1" "@electric-sql/client": "npm:1.0.14" @@ -14975,13 +14975,13 @@ __metadata: zod: "npm:3.25.76" zod-error: "npm:1.5.0" zod-validation-error: "npm:^1.5.0" - checksum: 10/5960e2aaabcf37e05de9abf15cfb39c0ce049955f2b67270d827a90a31ec895a753f94c29e1022a59bf85eec9a5ee1346ed48108e7081c5eeb0f6d0ac1c9f6d7 + checksum: 10/8b8299f617dae3809b737b98b8e37b3e44fdea8e24013a96badb5e3f564500850b8afaa16b48c95212bc30152f9d2dd022191793fe20de3ed3925decdef693f7 languageName: node linkType: hard -"@trigger.dev/core@npm:4.3.1": - version: 4.3.1 - resolution: "@trigger.dev/core@npm:4.3.1" +"@trigger.dev/core@npm:4.3.2": + version: 4.3.2 + resolution: "@trigger.dev/core@npm:4.3.2" dependencies: "@bugsnag/cuid": "npm:^3.1.1" "@electric-sql/client": "npm:1.0.14" @@ -15016,7 +15016,7 @@ __metadata: zod: "npm:3.25.76" zod-error: "npm:1.5.0" zod-validation-error: "npm:^1.5.0" - checksum: 10/8b8299f617dae3809b737b98b8e37b3e44fdea8e24013a96badb5e3f564500850b8afaa16b48c95212bc30152f9d2dd022191793fe20de3ed3925decdef693f7 + checksum: 10/541c3076d37500a5c8c973a9751bf0b4e454ee105caadc6d53424fe97f7fb45ce0e3b6a51b847831b3428a824ad6b1e7517742ca17fbfa013ceb41ca71efe953 languageName: node linkType: hard @@ -15050,13 +15050,13 @@ __metadata: languageName: node linkType: hard -"@trigger.dev/sdk@npm:4.1.2": - version: 4.1.2 - resolution: "@trigger.dev/sdk@npm:4.1.2" +"@trigger.dev/sdk@npm:4.3.2": + version: 4.3.2 + resolution: "@trigger.dev/sdk@npm:4.3.2" dependencies: "@opentelemetry/api": "npm:1.9.0" "@opentelemetry/semantic-conventions": "npm:1.36.0" - "@trigger.dev/core": "npm:4.1.2" + "@trigger.dev/core": "npm:4.3.2" chalk: "npm:^5.2.0" cronstrue: "npm:^2.21.0" debug: "npm:^4.3.4" @@ -15072,7 +15072,7 @@ __metadata: peerDependenciesMeta: ai: optional: true - checksum: 10/24c6e31d1c07e7177758c32c7017a67d4aa2de4bebfb3b56641dfc498df4da098214d8d38c965d399959b1c79914c4d65ac0bc4ae765a8df41792168ed21de94 + checksum: 10/e6645d5c09859ec97779892cb16ba2cf3b56b8906c3ad5688cb99096c7a94ef342f9a210dca413ede0846b30d4ac6e102158fa8f0a04ecaf88332130368b516d languageName: node linkType: hard From 5498cff9f9c602b2eb7d419c493033c3ae3e829f Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Thu, 15 Jan 2026 11:28:37 +0000 Subject: [PATCH 19/26] feat: monthly-proration-taskerh --- .../app/api/cron/monthly-proration/route.ts | 116 ++++++++++++++++++ apps/web/vercel.json | 4 + .../MonthlyProrationSyncTasker.module.ts | 21 ++++ .../MonthlyProrationTasker.container.ts | 13 ++ .../tasker/MonthlyProrationTasker.module.ts | 27 ++++ ...MonthlyProrationTriggerDevTasker.module.ts | 23 ++++ .../features/ee/billing/di/tasker/tokens.ts | 8 ++ packages/features/ee/billing/lib/month-key.ts | 5 + .../tasker/MonthlyProrationSyncTasker.ts | 16 +++ .../tasker/MonthlyProrationTasker.ts | 22 ++++ .../MonthlyProrationTriggerDevTasker.ts | 13 ++ .../service/proration/tasker/constants.ts | 1 + .../proration/tasker/trigger/config.ts | 20 +++ .../trigger/processMonthlyProrationBatch.ts | 23 ++++ .../proration/tasker/trigger/schema.ts | 10 ++ .../billing/service/proration/tasker/types.ts | 8 ++ packages/features/trigger.config.ts | 3 +- packages/lib/tasker/Tasker.ts | 28 +++-- 18 files changed, 353 insertions(+), 8 deletions(-) create mode 100644 apps/web/app/api/cron/monthly-proration/route.ts create mode 100644 packages/features/ee/billing/di/tasker/MonthlyProrationSyncTasker.module.ts create mode 100644 packages/features/ee/billing/di/tasker/MonthlyProrationTasker.container.ts create mode 100644 packages/features/ee/billing/di/tasker/MonthlyProrationTasker.module.ts create mode 100644 packages/features/ee/billing/di/tasker/MonthlyProrationTriggerDevTasker.module.ts create mode 100644 packages/features/ee/billing/di/tasker/tokens.ts create mode 100644 packages/features/ee/billing/lib/month-key.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/MonthlyProrationTasker.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/MonthlyProrationTriggerDevTasker.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/constants.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/trigger/config.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/trigger/schema.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/types.ts diff --git a/apps/web/app/api/cron/monthly-proration/route.ts b/apps/web/app/api/cron/monthly-proration/route.ts new file mode 100644 index 00000000000000..c787a250923318 --- /dev/null +++ b/apps/web/app/api/cron/monthly-proration/route.ts @@ -0,0 +1,116 @@ +import process from "node:process"; +import { getMonthlyProrationTasker } from "@calcom/features/ee/billing/di/tasker/MonthlyProrationTasker.container"; +import { formatMonthKey } from "@calcom/features/ee/billing/lib/month-key"; +import { MonthlyProrationTeamRepository } from "@calcom/features/ee/billing/repository/proration/MonthlyProrationTeamRepository"; +import { MONTHLY_PRORATION_BATCH_SIZE } from "@calcom/features/ee/billing/service/proration/tasker/constants"; +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { ENABLE_ASYNC_TASKER } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { prisma } from "@calcom/prisma"; +import { subMonths } from "date-fns"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +const log = logger.getSubLogger({ prefix: ["monthly-proration-cron"] }); + +async function getHandler(request: NextRequest) { + const apiKey = + request.headers.get("authorization") || + request.nextUrl.searchParams.get("apiKey"); + + if (process.env.CRON_API_KEY !== apiKey) { + return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); + } + + const featuresRepository = new FeaturesRepository(prisma); + const isEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( + "monthly-proration" + ); + + if (!isEnabled) { + return NextResponse.json({ message: "Monthly proration disabled" }); + } + + const requestedMonthKey = request.nextUrl.searchParams.get("monthKey"); + const monthKeyPattern = /^\d{4}-(0[1-9]|1[0-2])$/; + if (requestedMonthKey && !monthKeyPattern.test(requestedMonthKey)) { + return NextResponse.json( + { message: "Invalid monthKey format. Use YYYY-MM." }, + { status: 400 } + ); + } + + const now = new Date(); + const startOfCurrentMonthUtc = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) + ); + const previousMonthUtc = subMonths(startOfCurrentMonthUtc, 1); + const defaultMonthKey = formatMonthKey(previousMonthUtc); + const monthKey = requestedMonthKey || defaultMonthKey; + + log.info(`Scheduling monthly proration tasks for ${monthKey}`); + + const teamRepository = new MonthlyProrationTeamRepository(prisma); + const teamIdsList = await teamRepository.getAnnualTeamsWithSeatChanges( + monthKey + ); + + console.log({ + teamIdsList, + }); + + if (teamIdsList.length === 0) { + return NextResponse.json({ + monthKey, + scheduledTeams: 0, + scheduledBatches: 0, + batchSize: MONTHLY_PRORATION_BATCH_SIZE, + }); + } + + const prorationTasker = getMonthlyProrationTasker(); + + const batches: number[][] = []; + for ( + let index = 0; + index < teamIdsList.length; + index += MONTHLY_PRORATION_BATCH_SIZE + ) { + batches.push( + teamIdsList.slice(index, index + MONTHLY_PRORATION_BATCH_SIZE) + ); + } + + log.info( + `Scheduling ${teamIdsList.length} teams in ${batches.length} batches for ${monthKey}` + ); + + const isAsyncTaskerEnabled = + ENABLE_ASYNC_TASKER && + process.env.TRIGGER_SECRET_KEY && + process.env.TRIGGER_API_URL; + + if (isAsyncTaskerEnabled) { + await Promise.all( + batches.map((teamIds) => + prorationTasker.processBatch({ + monthKey, + teamIds, + }) + ) + ); + } else { + for (const teamIds of batches) { + await prorationTasker.processBatch({ monthKey, teamIds }); + } + } + + return NextResponse.json({ + monthKey, + scheduledTeams: teamIdsList.length, + scheduledBatches: batches.length, + batchSize: MONTHLY_PRORATION_BATCH_SIZE, + }); +} + +export const GET = getHandler; diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 007507b6d356d1..45793682a3830b 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -27,6 +27,10 @@ { "path": "/api/cron/selected-calendars", "schedule": "*/5 * * * *" + }, + { + "path": "/api/cron/monthly-proration", + "schedule": "0 0 1 * *" } ], "functions": { diff --git a/packages/features/ee/billing/di/tasker/MonthlyProrationSyncTasker.module.ts b/packages/features/ee/billing/di/tasker/MonthlyProrationSyncTasker.module.ts new file mode 100644 index 00000000000000..1ddc6100c0e8cf --- /dev/null +++ b/packages/features/ee/billing/di/tasker/MonthlyProrationSyncTasker.module.ts @@ -0,0 +1,21 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as loggerServiceModule } from "@calcom/features/di/shared/services/logger.service"; +import { MonthlyProrationSyncTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker"; + +import { MONTHLY_PRORATION_TASKER_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_SYNC_TASKER; +const moduleToken = MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_SYNC_TASKER_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: MonthlyProrationSyncTasker, + dep: loggerServiceModule, +}); + +export const moduleLoader = { + token, + loadModule, +} satisfies ModuleLoader; diff --git a/packages/features/ee/billing/di/tasker/MonthlyProrationTasker.container.ts b/packages/features/ee/billing/di/tasker/MonthlyProrationTasker.container.ts new file mode 100644 index 00000000000000..fc704c757b45a8 --- /dev/null +++ b/packages/features/ee/billing/di/tasker/MonthlyProrationTasker.container.ts @@ -0,0 +1,13 @@ +import { createContainer } from "@calcom/features/di/di"; + +import type { MonthlyProrationTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationTasker"; + +import { moduleLoader as monthlyProrationTaskerModule } from "./MonthlyProrationTasker.module"; +import { MONTHLY_PRORATION_TASKER_DI_TOKENS } from "./tokens"; + +const container = createContainer(); + +export function getMonthlyProrationTasker(): MonthlyProrationTasker { + monthlyProrationTaskerModule.loadModule(container); + return container.get(MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_TASKER); +} diff --git a/packages/features/ee/billing/di/tasker/MonthlyProrationTasker.module.ts b/packages/features/ee/billing/di/tasker/MonthlyProrationTasker.module.ts new file mode 100644 index 00000000000000..5197ff5ba3afe2 --- /dev/null +++ b/packages/features/ee/billing/di/tasker/MonthlyProrationTasker.module.ts @@ -0,0 +1,27 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as loggerServiceModule } from "@calcom/features/di/shared/services/logger.service"; +import { MonthlyProrationTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationTasker"; + +import { moduleLoader as monthlyProrationSyncTaskerModule } from "./MonthlyProrationSyncTasker.module"; +import { moduleLoader as monthlyProrationTriggerTaskerModule } from "./MonthlyProrationTriggerDevTasker.module"; +import { MONTHLY_PRORATION_TASKER_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_TASKER; +const moduleToken = MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_TASKER_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: MonthlyProrationTasker, + depsMap: { + logger: loggerServiceModule, + asyncTasker: monthlyProrationTriggerTaskerModule, + syncTasker: monthlyProrationSyncTaskerModule, + }, +}); + +export const moduleLoader = { + token, + loadModule, +} satisfies ModuleLoader; diff --git a/packages/features/ee/billing/di/tasker/MonthlyProrationTriggerDevTasker.module.ts b/packages/features/ee/billing/di/tasker/MonthlyProrationTriggerDevTasker.module.ts new file mode 100644 index 00000000000000..05a21a0571bef4 --- /dev/null +++ b/packages/features/ee/billing/di/tasker/MonthlyProrationTriggerDevTasker.module.ts @@ -0,0 +1,23 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as loggerServiceModule } from "@calcom/features/di/shared/services/logger.service"; +import { MonthlyProrationTriggerDevTasker } from "@calcom/features/ee/billing/service/proration/tasker/MonthlyProrationTriggerDevTasker"; + +import { MONTHLY_PRORATION_TASKER_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_TRIGGER_TASKER; +const moduleToken = MONTHLY_PRORATION_TASKER_DI_TOKENS.MONTHLY_PRORATION_TRIGGER_TASKER_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: MonthlyProrationTriggerDevTasker, + depsMap: { + logger: loggerServiceModule, + }, +}); + +export const moduleLoader = { + token, + loadModule, +} satisfies ModuleLoader; diff --git a/packages/features/ee/billing/di/tasker/tokens.ts b/packages/features/ee/billing/di/tasker/tokens.ts new file mode 100644 index 00000000000000..d7edb00978ca8c --- /dev/null +++ b/packages/features/ee/billing/di/tasker/tokens.ts @@ -0,0 +1,8 @@ +export const MONTHLY_PRORATION_TASKER_DI_TOKENS = { + MONTHLY_PRORATION_TASKER: Symbol("MonthlyProrationTasker"), + MONTHLY_PRORATION_TASKER_MODULE: Symbol("MonthlyProrationTaskerModule"), + MONTHLY_PRORATION_SYNC_TASKER: Symbol("MonthlyProrationSyncTasker"), + MONTHLY_PRORATION_SYNC_TASKER_MODULE: Symbol("MonthlyProrationSyncTaskerModule"), + MONTHLY_PRORATION_TRIGGER_TASKER: Symbol("MonthlyProrationTriggerTasker"), + MONTHLY_PRORATION_TRIGGER_TASKER_MODULE: Symbol("MonthlyProrationTriggerTaskerModule"), +}; diff --git a/packages/features/ee/billing/lib/month-key.ts b/packages/features/ee/billing/lib/month-key.ts new file mode 100644 index 00000000000000..fff73a3406f400 --- /dev/null +++ b/packages/features/ee/billing/lib/month-key.ts @@ -0,0 +1,5 @@ +export function formatMonthKey(date: Date): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + return `${year}-${month}`; +} diff --git a/packages/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker.ts b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker.ts new file mode 100644 index 00000000000000..e9ed45d0a11718 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationSyncTasker.ts @@ -0,0 +1,16 @@ +import { nanoid } from "nanoid"; +import type { Logger } from "tslog"; + +import { MonthlyProrationService } from "../MonthlyProrationService"; +import type { IMonthlyProrationTasker } from "./types"; + +export class MonthlyProrationSyncTasker implements IMonthlyProrationTasker { + constructor(private readonly logger: Logger) {} + + async processBatch(payload: Parameters[0]) { + const runId = `sync_${nanoid(10)}`; + const prorationService = new MonthlyProrationService(this.logger); + await prorationService.processMonthlyProrations(payload); + return { runId }; + } +} diff --git a/packages/features/ee/billing/service/proration/tasker/MonthlyProrationTasker.ts b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationTasker.ts new file mode 100644 index 00000000000000..2d2043c96da361 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationTasker.ts @@ -0,0 +1,22 @@ +import { Tasker } from "@calcom/lib/tasker/Tasker"; +import type { Logger } from "tslog"; + +import type { MonthlyProrationSyncTasker } from "./MonthlyProrationSyncTasker"; +import type { MonthlyProrationTriggerDevTasker } from "./MonthlyProrationTriggerDevTasker"; +import type { IMonthlyProrationTasker, MonthlyProrationBatchPayload } from "./types"; + +export interface MonthlyProrationTaskerDependencies { + asyncTasker: MonthlyProrationTriggerDevTasker; + syncTasker: MonthlyProrationSyncTasker; + logger: Logger; +} + +export class MonthlyProrationTasker extends Tasker { + constructor(dependencies: MonthlyProrationTaskerDependencies) { + super(dependencies); + } + + async processBatch(payload: MonthlyProrationBatchPayload): Promise<{ runId: string }> { + return await this.dispatch("processBatch", payload); + } +} diff --git a/packages/features/ee/billing/service/proration/tasker/MonthlyProrationTriggerDevTasker.ts b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationTriggerDevTasker.ts new file mode 100644 index 00000000000000..6a1d99a9d3ae02 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/MonthlyProrationTriggerDevTasker.ts @@ -0,0 +1,13 @@ +import type { ITaskerDependencies } from "@calcom/lib/tasker/types"; + +import type { IMonthlyProrationTasker } from "./types"; + +export class MonthlyProrationTriggerDevTasker implements IMonthlyProrationTasker { + constructor(public readonly dependencies: ITaskerDependencies) {} + + async processBatch(payload: Parameters[0]) { + const { processMonthlyProrationBatch } = await import("./trigger/processMonthlyProrationBatch"); + const handle = await processMonthlyProrationBatch.trigger(payload); + return { runId: handle.id }; + } +} diff --git a/packages/features/ee/billing/service/proration/tasker/constants.ts b/packages/features/ee/billing/service/proration/tasker/constants.ts new file mode 100644 index 00000000000000..6e642feed4dc2d --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/constants.ts @@ -0,0 +1 @@ +export const MONTHLY_PRORATION_BATCH_SIZE = 10; diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/config.ts b/packages/features/ee/billing/service/proration/tasker/trigger/config.ts new file mode 100644 index 00000000000000..42d89553654338 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/config.ts @@ -0,0 +1,20 @@ +import { queue, type schemaTask } from "@trigger.dev/sdk"; + +type MonthlyProrationTaskConfig = Pick[0], "machine" | "retry" | "queue">; + +export const monthlyProrationQueue = queue({ + name: "monthly-proration", + concurrencyLimit: 5, +}); + +export const monthlyProrationTaskConfig: MonthlyProrationTaskConfig = { + queue: monthlyProrationQueue, + machine: "small-2x", + retry: { + maxAttempts: 3, + factor: 2, + minTimeoutInMs: 1_000, + maxTimeoutInMs: 30_000, + randomize: true, + }, +}; diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts b/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts new file mode 100644 index 00000000000000..eb1fb56fe4ad9f --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts @@ -0,0 +1,23 @@ +import { schemaTask } from "@trigger.dev/sdk"; + +import { monthlyProrationTaskConfig } from "./config"; +import { monthlyProrationBatchSchema } from "./schema"; + +export const processMonthlyProrationBatch = schemaTask({ + id: "billing.monthly-proration.batch", + ...monthlyProrationTaskConfig, + schema: monthlyProrationBatchSchema, + run: async (payload) => { + const { TriggerDevLogger } = await import("@calcom/lib/triggerDevLogger"); + const { MonthlyProrationService } = await import("../../MonthlyProrationService"); + + const triggerDevLogger = new TriggerDevLogger(); + const taskLogger = triggerDevLogger.getSubLogger({ name: "MonthlyProrationTask" }); + const prorationService = new MonthlyProrationService(taskLogger); + + await prorationService.processMonthlyProrations({ + monthKey: payload.monthKey, + teamIds: payload.teamIds, + }); + }, +}); diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/schema.ts b/packages/features/ee/billing/service/proration/tasker/trigger/schema.ts new file mode 100644 index 00000000000000..1bc5ee1c380e39 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +import { MONTHLY_PRORATION_BATCH_SIZE } from "../constants"; + +const monthKeyRegex = /^\d{4}-(0[1-9]|1[0-2])$/; + +export const monthlyProrationBatchSchema = z.object({ + monthKey: z.string().regex(monthKeyRegex), + teamIds: z.array(z.number()).min(1).max(MONTHLY_PRORATION_BATCH_SIZE), +}); diff --git a/packages/features/ee/billing/service/proration/tasker/types.ts b/packages/features/ee/billing/service/proration/tasker/types.ts new file mode 100644 index 00000000000000..eaa479e8c740f8 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/types.ts @@ -0,0 +1,8 @@ +export type MonthlyProrationBatchPayload = { + monthKey: string; + teamIds: number[]; +}; + +export interface IMonthlyProrationTasker { + processBatch(payload: MonthlyProrationBatchPayload): Promise<{ runId: string }>; +} diff --git a/packages/features/trigger.config.ts b/packages/features/trigger.config.ts index 8596ee163b5184..987ad096ad058e 100644 --- a/packages/features/trigger.config.ts +++ b/packages/features/trigger.config.ts @@ -1,3 +1,4 @@ +import process from "node:process"; import { syncVercelEnvVars } from "@trigger.dev/build/extensions/core"; import { defineConfig } from "@trigger.dev/sdk"; import dotEnv from "dotenv"; @@ -15,7 +16,7 @@ export default defineConfig({ project: process.env.TRIGGER_DEV_PROJECT_REF ?? "", // e.g., "proj_abc123" // Directories containing your tasks - dirs: ["./bookings/lib/tasker/trigger/notifications"], // Customize based on your project structure + dirs: ["./bookings/lib/tasker/trigger/notifications", "./ee/billing/service/proration/tasker/trigger"], // Customize based on your project structure // Retry configuration retries: { diff --git a/packages/lib/tasker/Tasker.ts b/packages/lib/tasker/Tasker.ts index 758d8d3a382ae9..c87724d71c6c48 100644 --- a/packages/lib/tasker/Tasker.ts +++ b/packages/lib/tasker/Tasker.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { configure } from "@trigger.dev/sdk"; +import process from "node:process"; +import { configure } from "@trigger.dev/sdk"; import { ENABLE_ASYNC_TASKER } from "../constants"; import type { ILogger } from "./types"; @@ -53,9 +54,13 @@ export abstract class Tasker { const method = this.asyncTasker[taskName] as (...args: any[]) => any; return await method.apply(this.asyncTasker, args); } catch (err) { + const taskerLabel = isAsyncTaskerEnabled ? "AsyncTasker" : "SyncTasker"; + const baseUrlInfo = isAsyncTaskerEnabled + ? ` (baseURL: ${process.env.TRIGGER_API_URL ?? "unknown"})` + : ""; this.logger.error( - `${isAsyncTaskerEnabled ? "AsyncTasker" : "SyncTasker"} failed for '${String(taskName)}'.`, - (err as Error)?.message ?? "ERROR MESSAGE UNAVAILABLE" + `${taskerLabel} failed for '${String(taskName)}'.${baseUrlInfo}`, + this.getErrorDetails(err) ); if (this.asyncTasker === this.syncTasker) { @@ -68,12 +73,21 @@ export abstract class Tasker { const fallbackMethod = this.syncTasker[taskName] as (...args: any[]) => any; return await fallbackMethod.apply(this.syncTasker, args); } catch (err) { - this.logger.error( - `SyncTasker failed for '${String(taskName)}'.`, - (err as Error)?.message ?? "ERROR MESSAGE UNAVAILABLE" - ); + this.logger.error(`SyncTasker failed for '${String(taskName)}'.`, this.getErrorDetails(err)); throw err; } } } + + private getErrorDetails(err: unknown) { + if (err instanceof Error) { + return { + name: err.name, + message: err.message, + stack: err.stack, + }; + } + + return { message: String(err) }; + } } From f34d7a1e7f76e79372842e16993c3f22c156f16d Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Thu, 15 Jan 2026 11:30:43 +0000 Subject: [PATCH 20/26] remove cronjob from tasker implementation --- .../app/api/cron/monthly-proration/route.ts | 116 ------------------ apps/web/vercel.json | 4 - 2 files changed, 120 deletions(-) delete mode 100644 apps/web/app/api/cron/monthly-proration/route.ts diff --git a/apps/web/app/api/cron/monthly-proration/route.ts b/apps/web/app/api/cron/monthly-proration/route.ts deleted file mode 100644 index c787a250923318..00000000000000 --- a/apps/web/app/api/cron/monthly-proration/route.ts +++ /dev/null @@ -1,116 +0,0 @@ -import process from "node:process"; -import { getMonthlyProrationTasker } from "@calcom/features/ee/billing/di/tasker/MonthlyProrationTasker.container"; -import { formatMonthKey } from "@calcom/features/ee/billing/lib/month-key"; -import { MonthlyProrationTeamRepository } from "@calcom/features/ee/billing/repository/proration/MonthlyProrationTeamRepository"; -import { MONTHLY_PRORATION_BATCH_SIZE } from "@calcom/features/ee/billing/service/proration/tasker/constants"; -import { FeaturesRepository } from "@calcom/features/flags/features.repository"; -import { ENABLE_ASYNC_TASKER } from "@calcom/lib/constants"; -import logger from "@calcom/lib/logger"; -import { prisma } from "@calcom/prisma"; -import { subMonths } from "date-fns"; -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - -const log = logger.getSubLogger({ prefix: ["monthly-proration-cron"] }); - -async function getHandler(request: NextRequest) { - const apiKey = - request.headers.get("authorization") || - request.nextUrl.searchParams.get("apiKey"); - - if (process.env.CRON_API_KEY !== apiKey) { - return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); - } - - const featuresRepository = new FeaturesRepository(prisma); - const isEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( - "monthly-proration" - ); - - if (!isEnabled) { - return NextResponse.json({ message: "Monthly proration disabled" }); - } - - const requestedMonthKey = request.nextUrl.searchParams.get("monthKey"); - const monthKeyPattern = /^\d{4}-(0[1-9]|1[0-2])$/; - if (requestedMonthKey && !monthKeyPattern.test(requestedMonthKey)) { - return NextResponse.json( - { message: "Invalid monthKey format. Use YYYY-MM." }, - { status: 400 } - ); - } - - const now = new Date(); - const startOfCurrentMonthUtc = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) - ); - const previousMonthUtc = subMonths(startOfCurrentMonthUtc, 1); - const defaultMonthKey = formatMonthKey(previousMonthUtc); - const monthKey = requestedMonthKey || defaultMonthKey; - - log.info(`Scheduling monthly proration tasks for ${monthKey}`); - - const teamRepository = new MonthlyProrationTeamRepository(prisma); - const teamIdsList = await teamRepository.getAnnualTeamsWithSeatChanges( - monthKey - ); - - console.log({ - teamIdsList, - }); - - if (teamIdsList.length === 0) { - return NextResponse.json({ - monthKey, - scheduledTeams: 0, - scheduledBatches: 0, - batchSize: MONTHLY_PRORATION_BATCH_SIZE, - }); - } - - const prorationTasker = getMonthlyProrationTasker(); - - const batches: number[][] = []; - for ( - let index = 0; - index < teamIdsList.length; - index += MONTHLY_PRORATION_BATCH_SIZE - ) { - batches.push( - teamIdsList.slice(index, index + MONTHLY_PRORATION_BATCH_SIZE) - ); - } - - log.info( - `Scheduling ${teamIdsList.length} teams in ${batches.length} batches for ${monthKey}` - ); - - const isAsyncTaskerEnabled = - ENABLE_ASYNC_TASKER && - process.env.TRIGGER_SECRET_KEY && - process.env.TRIGGER_API_URL; - - if (isAsyncTaskerEnabled) { - await Promise.all( - batches.map((teamIds) => - prorationTasker.processBatch({ - monthKey, - teamIds, - }) - ) - ); - } else { - for (const teamIds of batches) { - await prorationTasker.processBatch({ monthKey, teamIds }); - } - } - - return NextResponse.json({ - monthKey, - scheduledTeams: teamIdsList.length, - scheduledBatches: batches.length, - batchSize: MONTHLY_PRORATION_BATCH_SIZE, - }); -} - -export const GET = getHandler; diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 45793682a3830b..007507b6d356d1 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -27,10 +27,6 @@ { "path": "/api/cron/selected-calendars", "schedule": "*/5 * * * *" - }, - { - "path": "/api/cron/monthly-proration", - "schedule": "0 0 1 * *" } ], "functions": { From 7b883f9740ad8c04fd124fe668a1e51cc2eb59a4 Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Fri, 16 Jan 2026 10:55:02 +0000 Subject: [PATCH 21/26] fix: tidy up slop --- .../app/api/cron/monthly-proration/route.ts | 29 ++++++++++++++----- .../webhook/_customer.subscription.updated.ts | 8 +++++ .../proration/MonthlyProrationService.ts | 5 ++-- .../__tests__/MonthlyProrationService.test.ts | 8 ++--- .../routers/viewer/organizations/utils.ts | 6 ++-- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/apps/web/app/api/cron/monthly-proration/route.ts b/apps/web/app/api/cron/monthly-proration/route.ts index c787a250923318..82d79c7e8ee6ba 100644 --- a/apps/web/app/api/cron/monthly-proration/route.ts +++ b/apps/web/app/api/cron/monthly-proration/route.ts @@ -14,9 +14,7 @@ import { NextResponse } from "next/server"; const log = logger.getSubLogger({ prefix: ["monthly-proration-cron"] }); async function getHandler(request: NextRequest) { - const apiKey = - request.headers.get("authorization") || - request.nextUrl.searchParams.get("apiKey"); + const apiKey = request.headers.get("authorization"); if (process.env.CRON_API_KEY !== apiKey) { return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); @@ -48,6 +46,27 @@ async function getHandler(request: NextRequest) { const defaultMonthKey = formatMonthKey(previousMonthUtc); const monthKey = requestedMonthKey || defaultMonthKey; + // Validate monthKey is not in the future and is within a reasonable range (12 months) + if (requestedMonthKey) { + const [year, month] = requestedMonthKey.split("-").map(Number); + const requestedDate = new Date(Date.UTC(year, month - 1, 1)); + const twelveMonthsAgo = subMonths(startOfCurrentMonthUtc, 12); + + if (requestedDate >= startOfCurrentMonthUtc) { + return NextResponse.json( + { message: "monthKey cannot be the current month or in the future." }, + { status: 400 } + ); + } + + if (requestedDate < twelveMonthsAgo) { + return NextResponse.json( + { message: "monthKey cannot be more than 12 months in the past." }, + { status: 400 } + ); + } + } + log.info(`Scheduling monthly proration tasks for ${monthKey}`); const teamRepository = new MonthlyProrationTeamRepository(prisma); @@ -55,10 +74,6 @@ async function getHandler(request: NextRequest) { monthKey ); - console.log({ - teamIdsList, - }); - if (teamIdsList.length === 0) { return NextResponse.json({ monthKey, diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts index bb2aa738c2d9a3..76eb290f08d23f 100644 --- a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts @@ -1,7 +1,10 @@ import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; import { PrismaPhoneNumberRepository } from "@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository"; import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils"; +import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; + +const log = logger.getSubLogger({ prefix: ["subscription-updated-webhook"] }); import type { Prisma } from "@calcom/prisma/client"; import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; @@ -123,6 +126,11 @@ async function handleTeamBillingRenewal( return { success: true, type: "organization", teamId: orgBilling.teamId }; } + log.warn("Subscription renewal received but no billing record found", { + subscriptionId: subscription.id, + customerId: typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id, + }); + return { skipped: true, reason: "no billing record found" }; } diff --git a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts index 95898835089b2e..cf2d567f2a289b 100644 --- a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts +++ b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts @@ -335,8 +335,9 @@ export class MonthlyProrationService { chargedAt: new Date(), }); - const currentMemberCount = await this.teamRepository.getTeamMemberCount(proration.teamId); - const seatsToApply = currentMemberCount ?? proration.seatsAtEnd; + // Use the seat count that was captured when the proration was created. + // Any member changes after proration creation will be captured in the next month's cycle. + const seatsToApply = proration.seatsAtEnd; await updateSubscriptionQuantity({ billingService: this.billingService, diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts index a724ea09f48da0..1662e982513277 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts @@ -444,7 +444,7 @@ describe("MonthlyProrationService", () => { }); describe("handleProrationPaymentSuccess", () => { - it("should update proration status to CHARGED", async () => { + it("should update proration status to CHARGED using seatsAtEnd from proration record", async () => { mockProrationRepository.findById.mockResolvedValueOnce({ id: "proration-123", status: "INVOICE_CREATED", @@ -457,20 +457,20 @@ describe("MonthlyProrationService", () => { } as any); mockProrationRepository.updateProrationStatus.mockResolvedValueOnce(undefined); - mockTeamRepository.getTeamMemberCount.mockResolvedValueOnce(15); await service.handleProrationPaymentSuccess("proration-123"); expect(mockProrationRepository.updateProrationStatus).toHaveBeenCalledWith("proration-123", "CHARGED", { chargedAt: expect.any(Date), }); + // Should use seatsAtEnd from proration record, not current member count expect(mockBillingService.handleSubscriptionUpdate).toHaveBeenCalledWith({ subscriptionId: "sub_123", subscriptionItemId: "si_123", - membershipCount: 15, + membershipCount: 13, prorationBehavior: "none", }); - expect(mockTeamRepository.updatePaidSeats).toHaveBeenCalledWith(1, false, "billing-123", 15); + expect(mockTeamRepository.updatePaidSeats).toHaveBeenCalledWith(1, false, "billing-123", 13); }); }); diff --git a/packages/trpc/server/routers/viewer/organizations/utils.ts b/packages/trpc/server/routers/viewer/organizations/utils.ts index 6137c3f7c4fc12..18453b33c9a249 100644 --- a/packages/trpc/server/routers/viewer/organizations/utils.ts +++ b/packages/trpc/server/routers/viewer/organizations/utils.ts @@ -151,9 +151,9 @@ export const addMembersToTeams = async ({ ); } - membershipData.forEach(async ({ userId, teamId }) => { - await updateNewTeamMemberEventTypes(userId, teamId); - }); + await Promise.all( + membershipData.map(({ userId, teamId }) => updateNewTeamMemberEventTypes(userId, teamId)) + ); return { success: true, From 9d48c8c76ee9baf5ab82795037d946b07be6e9d0 Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Fri, 16 Jan 2026 11:09:45 +0000 Subject: [PATCH 22/26] chore: tidy up stripe error handling --- .../proration/MonthlyProrationService.ts | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts index cf2d567f2a289b..58d00c05020ee3 100644 --- a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts +++ b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts @@ -293,6 +293,7 @@ export class MonthlyProrationService { }); let invoiceId: string | null = null; + let invoiceFinalized = false; try { const invoice = await this.billingService.createInvoice({ @@ -306,6 +307,7 @@ export class MonthlyProrationService { invoiceId = invoice.invoiceId; await this.billingService.finalizeInvoice(invoiceId); + invoiceFinalized = true; return await this.prorationRepository.updateProrationStatus( proration.id, @@ -316,12 +318,57 @@ export class MonthlyProrationService { } ); } catch (error) { + await this.handleInvoiceCreationFailure({ + error, + prorationId: proration.id, + invoiceId, + invoiceItemId, + invoiceFinalized, + }); + throw error; + } + } + + private async handleInvoiceCreationFailure(params: { + error: unknown; + prorationId: string; + invoiceId: string | null; + invoiceItemId: string; + invoiceFinalized: boolean; + }) { + const { error, prorationId, invoiceId, invoiceItemId, invoiceFinalized } = params; + + if (invoiceFinalized) { + // Invoice is live and potentially being charged - don't clean up + // The webhook will handle payment success/failure + this.logger.error("Proration status update failed after invoice finalized - webhook will handle", { + prorationId, + invoiceId, + error, + }); + return; + } + + // Invoice not yet live - mark as FAILED to enable retry via retryFailedProration() + const failureReason = error instanceof Error ? error.message : "Invoice creation failed"; + try { + await this.prorationRepository.updateProrationStatus(prorationId, "FAILED", { + failedAt: new Date(), + failureReason, + }); + } catch (statusError) { + this.logger.error("Failed to update proration status to FAILED", { prorationId, error: statusError }); + } + + // Clean up Stripe artifacts + try { if (invoiceId) { await this.billingService.voidInvoice(invoiceId); } else { await this.billingService.deleteInvoiceItem(invoiceItemId); } - throw error; + } catch (cleanupError) { + this.logger.error("Failed to clean up Stripe artifacts", { prorationId, invoiceId, invoiceItemId, error: cleanupError }); } } From 8e43fac493f0226a6ffb9a03d6d5a57e3de4a96e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:07:06 +0000 Subject: [PATCH 23/26] feat: add scheduled trigger.dev task for monthly proration Co-Authored-By: sean@cal.com --- .../MonthlyProrationFeatureRepository.ts | 15 ++++ .../trigger/scheduleMonthlyProration.ts | 79 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 packages/features/ee/billing/repository/proration/MonthlyProrationFeatureRepository.ts create mode 100644 packages/features/ee/billing/service/proration/tasker/trigger/scheduleMonthlyProration.ts diff --git a/packages/features/ee/billing/repository/proration/MonthlyProrationFeatureRepository.ts b/packages/features/ee/billing/repository/proration/MonthlyProrationFeatureRepository.ts new file mode 100644 index 00000000000000..a71382155530cc --- /dev/null +++ b/packages/features/ee/billing/repository/proration/MonthlyProrationFeatureRepository.ts @@ -0,0 +1,15 @@ +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import type { PrismaClient } from "@calcom/prisma"; +import { prisma as defaultPrisma } from "@calcom/prisma"; + +export class MonthlyProrationFeatureRepository { + private featuresRepository: FeaturesRepository; + + constructor(prisma?: PrismaClient) { + this.featuresRepository = new FeaturesRepository(prisma || defaultPrisma); + } + + async isMonthlyProrationEnabled(): Promise { + return this.featuresRepository.checkIfFeatureIsEnabledGlobally("monthly-proration"); + } +} diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/scheduleMonthlyProration.ts b/packages/features/ee/billing/service/proration/tasker/trigger/scheduleMonthlyProration.ts new file mode 100644 index 00000000000000..9aeac6d3140661 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/scheduleMonthlyProration.ts @@ -0,0 +1,79 @@ +import { schedules } from "@trigger.dev/sdk"; + +import { monthlyProrationTaskConfig } from "./config"; + +export const scheduleMonthlyProration = schedules.task({ + id: "billing.monthly-proration.schedule", + ...monthlyProrationTaskConfig, + cron: { + pattern: "0 0 1 * *", + timezone: "UTC", + }, + run: async () => { + const { subMonths } = await import("date-fns"); + const { TriggerDevLogger } = await import("@calcom/lib/triggerDevLogger"); + const { formatMonthKey } = await import("@calcom/features/ee/billing/lib/month-key"); + const { MonthlyProrationTeamRepository } = await import( + "@calcom/features/ee/billing/repository/proration/MonthlyProrationTeamRepository" + ); + const { MonthlyProrationFeatureRepository } = await import( + "@calcom/features/ee/billing/repository/proration/MonthlyProrationFeatureRepository" + ); + const { MONTHLY_PRORATION_BATCH_SIZE } = await import("../constants"); + const { processMonthlyProrationBatch } = await import("./processMonthlyProrationBatch"); + + const triggerDevLogger = new TriggerDevLogger(); + const log = triggerDevLogger.getSubLogger({ name: "MonthlyProrationSchedule" }); + + const featureRepository = new MonthlyProrationFeatureRepository(); + const isEnabled = await featureRepository.isMonthlyProrationEnabled(); + + if (!isEnabled) { + log.info("Monthly proration feature is disabled"); + return { status: "disabled" }; + } + + const now = new Date(); + const startOfCurrentMonthUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const previousMonthUtc = subMonths(startOfCurrentMonthUtc, 1); + const monthKey = formatMonthKey(previousMonthUtc); + + log.info(`Scheduling monthly proration tasks for ${monthKey}`); + + const teamRepository = new MonthlyProrationTeamRepository(); + const teamIdsList = await teamRepository.getAnnualTeamsWithSeatChanges(monthKey); + + if (teamIdsList.length === 0) { + log.info(`No teams with seat changes found for ${monthKey}`); + return { + monthKey, + scheduledTeams: 0, + scheduledBatches: 0, + batchSize: MONTHLY_PRORATION_BATCH_SIZE, + }; + } + + const batches: number[][] = []; + for (let index = 0; index < teamIdsList.length; index += MONTHLY_PRORATION_BATCH_SIZE) { + batches.push(teamIdsList.slice(index, index + MONTHLY_PRORATION_BATCH_SIZE)); + } + + log.info(`Scheduling ${teamIdsList.length} teams in ${batches.length} batches for ${monthKey}`); + + await Promise.all( + batches.map((teamIds) => + processMonthlyProrationBatch.trigger({ + monthKey, + teamIds, + }) + ) + ); + + return { + monthKey, + scheduledTeams: teamIdsList.length, + scheduledBatches: batches.length, + batchSize: MONTHLY_PRORATION_BATCH_SIZE, + }; + }, +}); From 9302f109af61cc94409c19d18ebc9c75dc098dac Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:12:06 +0000 Subject: [PATCH 24/26] refactor: use existing FeaturesRepository DI instead of separate repository Co-Authored-By: sean@cal.com --- .../MonthlyProrationFeatureRepository.ts | 15 --------------- .../tasker/trigger/scheduleMonthlyProration.ts | 8 +++----- 2 files changed, 3 insertions(+), 20 deletions(-) delete mode 100644 packages/features/ee/billing/repository/proration/MonthlyProrationFeatureRepository.ts diff --git a/packages/features/ee/billing/repository/proration/MonthlyProrationFeatureRepository.ts b/packages/features/ee/billing/repository/proration/MonthlyProrationFeatureRepository.ts deleted file mode 100644 index a71382155530cc..00000000000000 --- a/packages/features/ee/billing/repository/proration/MonthlyProrationFeatureRepository.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { FeaturesRepository } from "@calcom/features/flags/features.repository"; -import type { PrismaClient } from "@calcom/prisma"; -import { prisma as defaultPrisma } from "@calcom/prisma"; - -export class MonthlyProrationFeatureRepository { - private featuresRepository: FeaturesRepository; - - constructor(prisma?: PrismaClient) { - this.featuresRepository = new FeaturesRepository(prisma || defaultPrisma); - } - - async isMonthlyProrationEnabled(): Promise { - return this.featuresRepository.checkIfFeatureIsEnabledGlobally("monthly-proration"); - } -} diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/scheduleMonthlyProration.ts b/packages/features/ee/billing/service/proration/tasker/trigger/scheduleMonthlyProration.ts index 9aeac6d3140661..31a694d0fc309d 100644 --- a/packages/features/ee/billing/service/proration/tasker/trigger/scheduleMonthlyProration.ts +++ b/packages/features/ee/billing/service/proration/tasker/trigger/scheduleMonthlyProration.ts @@ -16,17 +16,15 @@ export const scheduleMonthlyProration = schedules.task({ const { MonthlyProrationTeamRepository } = await import( "@calcom/features/ee/billing/repository/proration/MonthlyProrationTeamRepository" ); - const { MonthlyProrationFeatureRepository } = await import( - "@calcom/features/ee/billing/repository/proration/MonthlyProrationFeatureRepository" - ); + const { getFeaturesRepository } = await import("@calcom/features/di/containers/FeaturesRepository"); const { MONTHLY_PRORATION_BATCH_SIZE } = await import("../constants"); const { processMonthlyProrationBatch } = await import("./processMonthlyProrationBatch"); const triggerDevLogger = new TriggerDevLogger(); const log = triggerDevLogger.getSubLogger({ name: "MonthlyProrationSchedule" }); - const featureRepository = new MonthlyProrationFeatureRepository(); - const isEnabled = await featureRepository.isMonthlyProrationEnabled(); + const featuresRepository = getFeaturesRepository(); + const isEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("monthly-proration"); if (!isEnabled) { log.info("Monthly proration feature is disabled"); From b22f03bc476b6c6a253e6c9e1f517d21ee6905ba Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Sat, 17 Jan 2026 16:07:36 +0000 Subject: [PATCH 25/26] fix types --- .../ee/billing/lib/stripe-subscription-utils.ts | 12 ++++++------ .../features/ee/billing/lib/subscription-updates.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/features/ee/billing/lib/stripe-subscription-utils.ts b/packages/features/ee/billing/lib/stripe-subscription-utils.ts index 2db725a26c58fa..d0725979459fb0 100644 --- a/packages/features/ee/billing/lib/stripe-subscription-utils.ts +++ b/packages/features/ee/billing/lib/stripe-subscription-utils.ts @@ -22,9 +22,9 @@ export interface BillingData { billingPeriod: BillingPeriod; pricePerSeat: number | undefined; paidSeats: number | undefined; - subscriptionStart: Date | null; - subscriptionEnd: Date | null; - subscriptionTrialEnd: Date | null; + subscriptionStart: Date | undefined; + subscriptionEnd: Date | undefined; + subscriptionTrialEnd: Date | undefined; } const getSubscriptionItems = (subscription: StripeSubscriptionLike) => @@ -43,13 +43,13 @@ export function extractBillingDataFromStripeSubscription(subscription: StripeSub const subscriptionStart = subscription.current_period_start ? new Date(subscription.current_period_start * 1000) - : null; + : undefined; const subscriptionEnd = subscription.current_period_end ? new Date(subscription.current_period_end * 1000) - : null; + : undefined; - const subscriptionTrialEnd = subscription.trial_end ? new Date(subscription.trial_end * 1000) : null; + const subscriptionTrialEnd = subscription.trial_end ? new Date(subscription.trial_end * 1000) : undefined; return { billingPeriod, diff --git a/packages/features/ee/billing/lib/subscription-updates.ts b/packages/features/ee/billing/lib/subscription-updates.ts index 2812381ac8d324..a9980da0134201 100644 --- a/packages/features/ee/billing/lib/subscription-updates.ts +++ b/packages/features/ee/billing/lib/subscription-updates.ts @@ -1,4 +1,4 @@ -import type { Logger } from "tslog"; +import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; import type { IBillingProviderService } from "../service/billingProvider/IBillingProviderService"; @@ -10,7 +10,7 @@ export async function updateSubscriptionQuantity(params: { subscriptionItemId: string; quantity: number; prorationBehavior?: ProrationBehavior; - logger?: Logger; + logger?: ISimpleLogger; }): Promise { const { billingService, subscriptionId, subscriptionItemId, quantity, prorationBehavior, logger } = params; From c9b4c7724e47f6027abfdd10928b7546ea02b842 Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Sat, 17 Jan 2026 16:11:06 +0000 Subject: [PATCH 26/26] fix tests to use --- .../_customer.subscription.updated.test.ts | 55 +++++++------------ .../ee/teams/services/teamService.test.ts | 11 ++++ .../viewer/organizations/utils.test.ts | 1 + .../inviteMember/inviteMemberUtils.test.ts | 1 + 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.test.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.test.ts index a5730a63d9c4c0..1ed686ff8919ee 100644 --- a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.test.ts +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.test.ts @@ -3,25 +3,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SWHMap } from "./__handler"; import handler from "./_customer.subscription.updated"; -let prismaMock!: { - teamBilling: { - findUnique: ReturnType; - update: ReturnType; - }; - organizationBilling: { - findUnique: ReturnType; - update: ReturnType; +const { findByStripeSubscriptionId, prismaMock } = vi.hoisted(() => { + const findByStripeSubscriptionIdFn = vi.fn().mockResolvedValue(null); + const prismaMockObj = { + teamBilling: { + findUnique: vi.fn(), + update: vi.fn(), + }, + organizationBilling: { + findUnique: vi.fn(), + update: vi.fn(), + }, + calAiPhoneNumber: { + update: vi.fn(), + }, }; - calAiPhoneNumber: { - update: ReturnType; + return { + findByStripeSubscriptionId: findByStripeSubscriptionIdFn, + prismaMock: prismaMockObj, }; -}; - -let findByStripeSubscriptionId!: ReturnType; +}); vi.mock("@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository", () => { - findByStripeSubscriptionId = vi.fn().mockResolvedValue(null); - return { PrismaPhoneNumberRepository: class { findByStripeSubscriptionId = findByStripeSubscriptionId; @@ -39,25 +42,9 @@ vi.mock("@calcom/ee/billing/di/containers/Billing", () => ({ }), })); -vi.mock("@calcom/prisma", () => { - prismaMock = { - teamBilling: { - findUnique: vi.fn(), - update: vi.fn(), - }, - organizationBilling: { - findUnique: vi.fn(), - update: vi.fn(), - }, - calAiPhoneNumber: { - update: vi.fn(), - }, - }; - - return { - default: prismaMock, - }; -}); +vi.mock("@calcom/prisma", () => ({ + default: prismaMock, +})); describe("customer.subscription.updated webhook", () => { beforeEach(() => { diff --git a/packages/features/ee/teams/services/teamService.test.ts b/packages/features/ee/teams/services/teamService.test.ts index cb3ad49863f3e6..ec680673beb412 100644 --- a/packages/features/ee/teams/services/teamService.test.ts +++ b/packages/features/ee/teams/services/teamService.test.ts @@ -14,6 +14,14 @@ import { MembershipRole } from "@calcom/prisma/enums"; import { TeamService } from "./teamService"; +const { MockSeatChangeTrackingService } = vi.hoisted(() => { + class MockSeatChangeTrackingService { + logSeatAddition = vi.fn().mockResolvedValue(undefined); + logSeatRemoval = vi.fn().mockResolvedValue(undefined); + } + return { MockSeatChangeTrackingService }; +}); + vi.mock("@calcom/ee/billing/di/containers/Billing"); vi.mock("@calcom/features/ee/teams/repositories/TeamRepository"); vi.mock("@calcom/features/ee/workflows/lib/service/WorkflowService"); @@ -21,6 +29,9 @@ vi.mock("@calcom/lib/domainManager/organization"); vi.mock("@calcom/features/ee/teams/lib/removeMember"); vi.mock("@calcom/features/profile/lib/createAProfileForAnExistingUser"); vi.mock("@calcom/features/ee/teams/lib/queries"); +vi.mock("@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService", () => ({ + SeatChangeTrackingService: MockSeatChangeTrackingService, +})); const mockTeamBilling = { cancel: vi.fn(), diff --git a/packages/trpc/server/routers/viewer/organizations/utils.test.ts b/packages/trpc/server/routers/viewer/organizations/utils.test.ts index 1a376edba03b79..676220d0e4b683 100644 --- a/packages/trpc/server/routers/viewer/organizations/utils.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/utils.test.ts @@ -55,6 +55,7 @@ describe("addMembersToTeams", () => { mockCheckPermission.mockResolvedValue(true); mockUpdateNewTeamMemberEventTypes.mockResolvedValue(undefined); + prisma.team.findMany.mockResolvedValue([]); prisma.membership.findMany.mockResolvedValue([]); prisma.membership.createMany.mockResolvedValue({ count: 0 }); }); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts index c1a2267a6a9855..7067d8c03c65b4 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts @@ -15,6 +15,7 @@ import { getOrgConnectionInfo, getOrgState, getUniqueInvitationsOrThrowIfEmpty, + INVITE_STATUS, } from "./utils"; const { mockCreateMany, mockUserCreate, mockMembershipCreate, mockTransaction } = vi.hoisted(() => {