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 e167880e7f..8b556ed6bb 100644 --- a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts @@ -5,6 +5,7 @@ import { PrismaTeamBillingRepository } from "@calcom/features/ee/billing/reposit import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils"; import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; + import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; const log = logger.getSubLogger({ prefix: ["subscription-updated-webhook"] }); @@ -31,7 +32,10 @@ const handler = async (data: Data) => { ? await handleCalAIPhoneNumberSubscriptionUpdate(subscription, phoneNumber) : null; - const teamBillingResult = await handleTeamBillingRenewal(subscription, previousAttributes); + const teamBillingResult = await handleTeamBillingRenewal( + subscription, + previousAttributes + ); return { phoneNumber: phoneNumberResult, @@ -57,7 +61,8 @@ async function handleCalAIPhoneNumberSubscriptionUpdate( paused: PhoneNumberSubscriptionStatus.CANCELLED, }; - const subscriptionStatus = statusMap[subscription.status] || PhoneNumberSubscriptionStatus.UNPAID; + const subscriptionStatus = + statusMap[subscription.status] || PhoneNumberSubscriptionStatus.UNPAID; await prisma.calAiPhoneNumber.update({ where: { @@ -68,7 +73,11 @@ async function handleCalAIPhoneNumberSubscriptionUpdate( }, }); - return { success: true, subscriptionId: subscription.id, status: subscriptionStatus }; + return { + success: true, + subscriptionId: subscription.id, + status: subscriptionStatus, + }; } async function handleTeamBillingRenewal( @@ -83,7 +92,8 @@ async function handleTeamBillingRenewal( const { subscriptionStart, subscriptionEnd, subscriptionTrialEnd } = billingProviderService.extractSubscriptionDates(subscription); - const { billingPeriod, pricePerSeat, paidSeats } = extractBillingDataFromStripeSubscription(subscription); + const { billingPeriod, pricePerSeat, paidSeats } = + extractBillingDataFromStripeSubscription(subscription); const teamBillingRepo = new PrismaTeamBillingRepository(prisma); const orgBillingRepo = new PrismaOrganizationBillingRepository(prisma); @@ -97,7 +107,9 @@ async function handleTeamBillingRenewal( pricePerSeat: pricePerSeat ?? null, }; - const teamBilling = await teamBillingRepo.findBySubscriptionId(subscription.id); + const teamBilling = await teamBillingRepo.findBySubscriptionId( + subscription.id + ); if (teamBilling) { await teamBillingRepo.updateById(teamBilling.id, billingUpdateData); @@ -113,7 +125,10 @@ async function handleTeamBillingRenewal( log.warn("Subscription renewal received but no billing record found", { subscriptionId: subscription.id, - customerId: typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.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/lib/month-key.ts b/packages/features/ee/billing/lib/month-key.ts index fff73a3406..8fb22b7d82 100644 --- a/packages/features/ee/billing/lib/month-key.ts +++ b/packages/features/ee/billing/lib/month-key.ts @@ -3,3 +3,9 @@ export function formatMonthKey(date: Date): string { const month = String(date.getUTCMonth() + 1).padStart(2, "0"); return `${year}-${month}`; } + +const MONTH_KEY_REGEX = /^\d{4}-(0[1-9]|1[0-2])$/; + +export function isValidMonthKey(value: string): boolean { + return MONTH_KEY_REGEX.test(value); +} 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 ac36ce508d..cbbc22677e 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 @@ -11,13 +11,19 @@ import { SeatChangeTrackingService } from "../../seatTracking/SeatChangeTracking import { MonthlyProrationService } from "../MonthlyProrationService"; const mockBillingService: IBillingProviderService = { - createInvoiceItem: vi.fn().mockResolvedValue({ invoiceItemId: "ii_test_123" }), + 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({ items: [ - { id: "si_test_123", quantity: 1, price: { unit_amount: 12000, recurring: { interval: "year" } } }, + { + id: "si_test_123", + quantity: 1, + price: { unit_amount: 12000, recurring: { interval: "year" } }, + }, ], customer: "cus_test_123", status: "active", @@ -31,11 +37,18 @@ const mockBillingService: IBillingProviderService = { handleSubscriptionCancel: vi.fn().mockResolvedValue(undefined), handleSubscriptionCreation: vi.fn().mockResolvedValue(undefined), handleEndTrial: vi.fn().mockResolvedValue(undefined), - createCustomer: vi.fn().mockResolvedValue({ stripeCustomerId: "cus_test_123" }), - createPaymentIntent: vi.fn().mockResolvedValue({ id: "pi_test_123", client_secret: "secret_123" }), + createCustomer: vi + .fn() + .mockResolvedValue({ stripeCustomerId: "cus_test_123" }), + createPaymentIntent: vi + .fn() + .mockResolvedValue({ id: "pi_test_123", client_secret: "secret_123" }), createSubscriptionCheckout: vi .fn() - .mockResolvedValue({ checkoutUrl: "https://checkout.test", sessionId: "cs_test_123" }), + .mockResolvedValue({ + checkoutUrl: "https://checkout.test", + sessionId: "cs_test_123", + }), createPrice: vi.fn().mockResolvedValue({ priceId: "price_test_123" }), getPrice: vi.fn().mockResolvedValue(null), getSubscriptionStatus: vi.fn().mockResolvedValue(null), @@ -131,7 +144,10 @@ describe("MonthlyProrationService Integration Tests", () => { }); it("should process end-to-end proration for annual team with seat additions", async () => { - const prorationService = new MonthlyProrationService(undefined, mockBillingService); + const prorationService = new MonthlyProrationService( + undefined, + mockBillingService + ); const timestamp = Date.now(); const randomSuffix = Math.random().toString(36).substring(7); @@ -201,12 +217,17 @@ describe("MonthlyProrationService Integration Tests", () => { }); expect(seatChanges).toHaveLength(2); - expect(seatChanges.every((sc) => sc.processedInProrationId === proration?.id)).toBe(true); + expect( + seatChanges.every((sc) => sc.processedInProrationId === proration?.id) + ).toBe(true); }); it("should create a $0 proration for team with no net change", async () => { const seatTracker = new SeatChangeTrackingService(); - const prorationService = new MonthlyProrationService(undefined, mockBillingService); + const prorationService = new MonthlyProrationService( + undefined, + mockBillingService + ); await seatTracker.logSeatAddition({ teamId: testTeam.id, @@ -301,16 +322,25 @@ describe("MonthlyProrationService Integration Tests", () => { featuresRepository: mockFeaturesRepository, billingService: mockBillingService, }); - const results = await prorationService.processMonthlyProrations({ monthKey }); + const results = await prorationService.processMonthlyProrations({ + monthKey, + }); - const filteredResults = results.filter((r) => [testTeam.id, testTeam2.id].includes(r.teamId)); + const filteredResults = results.filter((r) => + [testTeam.id, testTeam2.id].includes(r.teamId) + ); expect(filteredResults).toHaveLength(2); - expect(filteredResults.every((r) => r.status === "INVOICE_CREATED")).toBe(true); + expect(filteredResults.every((r) => r.status === "INVOICE_CREATED")).toBe( + true + ); }); it("should handle payment success callback", async () => { const seatTracker = new SeatChangeTrackingService(); - const prorationService = new MonthlyProrationService(undefined, mockBillingService); + const prorationService = new MonthlyProrationService( + undefined, + mockBillingService + ); await seatTracker.logSeatAddition({ teamId: testTeam.id, @@ -338,7 +368,10 @@ describe("MonthlyProrationService Integration Tests", () => { it("should handle payment failure callback", async () => { const seatTracker = new SeatChangeTrackingService(); - const prorationService = new MonthlyProrationService(undefined, mockBillingService); + const prorationService = new MonthlyProrationService( + undefined, + mockBillingService + ); await seatTracker.logSeatAddition({ teamId: testTeam.id, @@ -369,7 +402,10 @@ describe("MonthlyProrationService Integration Tests", () => { it("should call handleSubscriptionUpdate when updating subscription quantity", async () => { const seatTracker = new SeatChangeTrackingService(); - const prorationService = new MonthlyProrationService(undefined, mockBillingService); + const prorationService = new MonthlyProrationService( + undefined, + mockBillingService + ); // Reset the mock to track calls vi.mocked(mockBillingService.handleSubscriptionUpdate).mockClear(); @@ -403,9 +439,14 @@ describe("MonthlyProrationService Integration Tests", () => { const seatTracker = new SeatChangeTrackingService(); const failingBillingService = { ...mockBillingService, - handleSubscriptionUpdate: vi.fn().mockRejectedValue(new Error("Subscription not found")), + handleSubscriptionUpdate: vi + .fn() + .mockRejectedValue(new Error("Subscription not found")), }; - const prorationService = new MonthlyProrationService(undefined, failingBillingService); + const prorationService = new MonthlyProrationService( + undefined, + failingBillingService + ); await seatTracker.logSeatAddition({ teamId: testTeam.id, @@ -420,8 +461,8 @@ describe("MonthlyProrationService Integration Tests", () => { }); // Should throw when trying to update subscription - await expect(prorationService.handleProrationPaymentSuccess(proration!.id)).rejects.toThrow( - "Subscription not found" - ); + await expect( + prorationService.handleProrationPaymentSuccess(proration!.id) + ).rejects.toThrow("Subscription not found"); }); }); diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts b/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts index 94f9546844..ab5ab8ae44 100644 --- a/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts +++ b/packages/features/ee/billing/service/proration/tasker/trigger/processMonthlyProrationBatch.ts @@ -14,7 +14,7 @@ export const processMonthlyProrationBatch = schemaTask({ const prorationService = getMonthlyProrationService(); - await prorationService.processMonthlyProrations({ + return await prorationService.processMonthlyProrations({ monthKey: payload.monthKey, teamIds: payload.teamIds, }); 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 0000000000..7584bba949 --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/scheduleMonthlyProration.ts @@ -0,0 +1,92 @@ +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 (payload) => { + const { subMonths } = await import("date-fns"); + const { TriggerDevLogger } = await import("@calcom/lib/triggerDevLogger"); + const { formatMonthKey, isValidMonthKey } = await import( + "@calcom/features/ee/billing/lib/month-key" + ); + const { MonthlyProrationTeamRepository } = await import( + "@calcom/features/ee/billing/repository/proration/MonthlyProrationTeamRepository" + ); + const { getFeaturesRepository } = await import( + "@calcom/features/di/containers/FeaturesRepository" + ); + const { processMonthlyProrationBatch } = await import( + "./processMonthlyProrationBatch" + ); + + const triggerDevLogger = new TriggerDevLogger(); + const log = triggerDevLogger.getSubLogger({ + name: "MonthlyProrationSchedule", + }); + + const featuresRepository = getFeaturesRepository(); + const isEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( + "monthly-proration" + ); + + if (!isEnabled) { + log.info("Monthly proration feature is disabled"); + return { status: "disabled" }; + } + + const externalIdMonthKey = + payload.externalId && isValidMonthKey(payload.externalId) + ? payload.externalId + : null; + + let monthKey: string; + if (externalIdMonthKey) { + monthKey = externalIdMonthKey; + log.info(`Using monthKey from externalId: ${monthKey}`); + } else { + const now = new Date(); + const startOfCurrentMonthUtc = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) + ); + const previousMonthUtc = subMonths(startOfCurrentMonthUtc, 1); + 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, + scheduledTasks: 0, + }; + } + + log.info(`Scheduling ${teamIdsList.length} tasks for ${monthKey}`); + + await processMonthlyProrationBatch.batchTrigger( + teamIdsList.map((teamId) => ({ + payload: { + monthKey, + teamIds: [teamId], + }, + })) + ); + + return { + monthKey, + scheduledTasks: teamIdsList.length, + }; + }, +});