Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
efb0f8a
feat: track creation for team billing + add logging for seat tracking
sean-brydon Jan 12, 2026
10e1aec
track payment failures and successes on proration
sean-brydon Jan 12, 2026
2510d2e
test: wip on tests for webhooks and services
sean-brydon Jan 12, 2026
f81e784
feat: add cron job
sean-brydon Jan 12, 2026
7b357a7
collect invoice automatically with payment method
sean-brydon Jan 13, 2026
d144c3d
feat: add auto charge + testing
sean-brydon Jan 13, 2026
2f2dbbe
fix test
sean-brydon Jan 13, 2026
81bfebb
Merge branch 'main' into feat/proration
sean-brydon Jan 13, 2026
fb2ba75
feat: proration auto charge
sean-brydon Jan 13, 2026
d18341d
fix: address proration implementation flaws (#26823)
sean-brydon Jan 14, 2026
2a5cf62
fix: re-throw error in voidInvoice to prevent silent failures (#26824)
sean-brydon Jan 14, 2026
6cd796b
fix type error + add additional test case
sean-brydon Jan 14, 2026
7522a81
Link proration item directly to line item
sean-brydon Jan 14, 2026
4ede883
feat: don't charge invoice + stripe incremnt for current year
sean-brydon Jan 14, 2026
3533ee1
chore: cleanup utils and tidy up + refactors:
sean-brydon Jan 14, 2026
b3c313c
tasker
sean-brydon Jan 14, 2026
0957d03
chore: Move tasker to DI
sean-brydon Jan 14, 2026
6132ade
fix type error
sean-brydon Jan 15, 2026
a5cfb71
feat: tasker implementation working
sean-brydon Jan 15, 2026
5498cff
feat: monthly-proration-taskerh
sean-brydon Jan 15, 2026
f34d7a1
remove cronjob from tasker implementation
sean-brydon Jan 15, 2026
4aa400a
Merge branch 'main' into feat/monthly-proration-tasker
sean-brydon Jan 16, 2026
c3591ce
Merge branch 'feat/monthly-proration-tasker' into feat/proration
sean-brydon Jan 16, 2026
7b883f9
fix: tidy up slop
sean-brydon Jan 16, 2026
9d48c8c
chore: tidy up stripe error handling
sean-brydon Jan 16, 2026
8e43fac
feat: add scheduled trigger.dev task for monthly proration
devin-ai-integration[bot] Jan 16, 2026
9302f10
refactor: use existing FeaturesRepository DI instead of separate repo…
devin-ai-integration[bot] Jan 16, 2026
94e8946
Merge branch 'main' into feat/proration
sean-brydon Jan 17, 2026
b22f03b
fix types
sean-brydon Jan 17, 2026
c9b4c77
fix tests to use
sean-brydon Jan 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions apps/web/app/api/cron/monthly-proration/route.ts
Comment thread
dastratakos marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import process from "node:process";
import { getMonthlyProrationTasker } from "@calcom/features/ee/billing/di/tasker/MonthlyProrationTasker.container";
Comment thread
dastratakos marked this conversation as resolved.
import { formatMonthKey } from "@calcom/features/ee/billing/lib/month-key";
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending

Comment thread
dastratakos marked this conversation as resolved.
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";
Comment thread
dastratakos marked this conversation as resolved.
import { subMonths } from "date-fns";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

const log = logger.getSubLogger({ prefix: ["monthly-proration-cron"] });
Comment thread
dastratakos marked this conversation as resolved.

async function getHandler(request: NextRequest) {
const apiKey = request.headers.get("authorization");

if (process.env.CRON_API_KEY !== apiKey) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}

const featuresRepository = new FeaturesRepository(prisma);
Comment thread
dastratakos marked this conversation as resolved.
const isEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"monthly-proration"
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending

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;

// Validate monthKey is not in the future and is within a reasonable range (12 months)
if (requestedMonthKey) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's going on here?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test reply

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." },
Comment thread
dastratakos marked this conversation as resolved.
{ status: 400 }
);
}
}

log.info(`Scheduling monthly proration tasks for ${monthKey}`);

const teamRepository = new MonthlyProrationTeamRepository(prisma);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending reply

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending reply 2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a

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 = getMonthlyProrationTasker();

const batches: number[][] = [];
for (
Comment thread
dastratakos marked this conversation as resolved.
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,
});
Comment thread
dastratakos marked this conversation as resolved.
}

export const GET = getHandler;
33 changes: 33 additions & 0 deletions apps/web/app/api/teams/[team]/upgrade/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -86,6 +90,35 @@ async function getHandler(req: NextRequest, { params }: { params: Promise<Params
}
}

if (subscription) {
const billingProviderService = getBillingProviderService();
const { subscriptionStart, subscriptionEnd, subscriptionTrialEnd } =
billingProviderService.extractSubscriptionDates(subscription);

const { billingPeriod, pricePerSeat, paidSeats } =
extractBillingDataFromStripeSubscription(subscription);

const teamBillingServiceFactory = getTeamBillingServiceFactory();
const teamBillingService = teamBillingServiceFactory.init(team);
await teamBillingService.saveTeamBilling({
teamId: team.id,
subscriptionId: subscription.id,
subscriptionItemId: subscription.items.data[0].id,
customerId:
typeof checkoutSession.customer === "string"
? checkoutSession.customer
: checkoutSession.customer?.id || "",
status: SubscriptionStatus.ACTIVE,
planName: team.isOrganization ? Plan.ORGANIZATION : Plan.TEAM,
subscriptionStart,
subscriptionEnd,
subscriptionTrialEnd,
billingPeriod,
pricePerSeat,
paidSeats,
});
}

const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });

if (!session) {
Expand Down
12 changes: 11 additions & 1 deletion apps/web/app/api/teams/api/create/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { z } from "zod";

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";
Comment on lines 7 to 10
Copy link
Copy Markdown
Collaborator

@dastratakos dastratakos Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inline comment with markdown

Markdown comment

header 2

bold and italics

code

quote

link

  1. numbered
  2. list
  • []

  • []

  • []

  • []

  • []

  • []

  • bullet

  • list

  • task

  • list


Note

This is a note. It highlights information that users should take into account, even when skimming.

import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
Expand Down Expand Up @@ -59,7 +60,11 @@ async function handler(request: NextRequest) {

if (checkoutSessionSubscription) {
const billingService = getBillingProviderService();
const { subscriptionStart } = billingService.extractSubscriptionDates(checkoutSessionSubscription);
const { subscriptionStart, subscriptionEnd, subscriptionTrialEnd } =
billingService.extractSubscriptionDates(checkoutSessionSubscription);

const { billingPeriod, pricePerSeat, paidSeats } =
extractBillingDataFromStripeSubscription(checkoutSessionSubscription);

const teamBillingServiceFactory = getTeamBillingServiceFactory();
const teamBillingService = teamBillingServiceFactory.init(finalizedTeam);
Expand All @@ -72,6 +77,11 @@ async function handler(request: NextRequest) {
status: SubscriptionStatus.ACTIVE,
planName: Plan.TEAM,
subscriptionStart,
subscriptionEnd,
subscriptionTrialEnd,
billingPeriod,
pricePerSeat,
paidSeats,
});
}

Expand Down
12 changes: 11 additions & 1 deletion apps/web/app/api/teams/create/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getBillingProviderService,
getTeamBillingServiceFactory,
} from "@calcom/features/ee/billing/di/containers/Billing";
import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils";
Comment thread
dastratakos marked this conversation as resolved.
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/billing/IBillingRepository";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { WEBAPP_URL } from "@calcom/lib/constants";
Expand Down Expand Up @@ -94,7 +95,11 @@ async function getHandler(req: NextRequest) {

if (checkoutSession && subscription) {
const billingProviderService = getBillingProviderService();
const { subscriptionStart } = billingProviderService.extractSubscriptionDates(subscription);
const { subscriptionStart, subscriptionEnd, subscriptionTrialEnd } =
billingProviderService.extractSubscriptionDates(subscription);

const { billingPeriod, pricePerSeat, paidSeats } = extractBillingDataFromStripeSubscription(subscription);

const teamBillingServiceFactory = getTeamBillingServiceFactory();
const teamBillingService = teamBillingServiceFactory.init(team);
await teamBillingService.saveTeamBilling({
Expand All @@ -106,6 +111,11 @@ async function getHandler(req: NextRequest) {
status: SubscriptionStatus.ACTIVE,
planName: Plan.TEAM,
subscriptionStart,
subscriptionEnd,
subscriptionTrialEnd,
billingPeriod,
pricePerSeat,
paidSeats,
Comment on lines +114 to +118
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test comment 2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test reply

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending reply

});
}

Expand Down
4 changes: 4 additions & 0 deletions apps/web/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
{
"path": "/api/cron/selected-calendars",
"schedule": "*/5 * * * *"
},
{
"path": "/api/cron/monthly-proration",
"schedule": "0 0 1 * *"
}
],
"functions": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import type { SWHMap } from "./__handler";
import handler from "./_customer.subscription.updated";

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(),
},
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending

calAiPhoneNumber: {
update: vi.fn(),
},
};
return {
findByStripeSubscriptionId: findByStripeSubscriptionIdFn,
prismaMock: prismaMockObj,
};
});

vi.mock("@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository", () => {
return {
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 },
});
});
});
Loading