feat: add scheduled trigger.dev task for monthly proration (#26991)
* feat: add seat tracking infrastructure for monthly proration Add seat change logging infrastructure with operationId for idempotency. This PR adds the foundation for monthly proration billing by tracking seat additions and removals, gated behind the monthly-proration feature flag. - Add operationId field to SeatChangeLog for idempotency - Update SeatChangeLogRepository to support upsert with operationId - Add feature flag guard in SeatChangeTrackingService - Integrate seat tracking in team member invites - Integrate seat tracking in bulk user deletions - Integrate seat tracking in team service operations - Integrate seat tracking in DSYNC user creation When monthly-proration feature flag is disabled, seat logging is skipped and behavior remains unchanged. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: add monthly proration processing Add monthly proration billing processing that works on top of the seat tracking infrastructure. This PR implements the core proration logic, webhook handlers, and integration with Stripe billing. - Enhance MonthlyProrationService to process seat change logs - Add payment webhook handlers (invoice.payment_succeeded, invoice.payment_failed) - Update subscription webhook to sync billing period on renewals - Update TeamBillingService to skip real-time updates when proration enabled - Enhance StripeBillingService with proration capabilities - Add Tasker enhancements for processing queues - Update team creation/upgrade routes Depends on: feat/monthly-proration-seat-tracking Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: remove unused logger from SeatChangeTrackingService * fix: description for calculation * fix null check on trial * feat: add scheduled trigger.dev task for monthly proration * feat: add custom month key support and use batchTrigger * feat: add isValidMonthKey and return result from batch task * fix merge artifact --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
01a8f53ce7
commit
c150828988
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+60
-19
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ export const processMonthlyProrationBatch = schemaTask({
|
||||
|
||||
const prorationService = getMonthlyProrationService();
|
||||
|
||||
await prorationService.processMonthlyProrations({
|
||||
return await prorationService.processMonthlyProrations({
|
||||
monthKey: payload.monthKey,
|
||||
teamIds: payload.teamIds,
|
||||
});
|
||||
|
||||
+92
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user