-
Notifications
You must be signed in to change notification settings - Fork 0
Implement proration #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
efb0f8a
10e1aec
2510d2e
f81e784
7b357a7
d144c3d
2f2dbbe
81bfebb
fb2ba75
d18341d
2a5cf62
6cd796b
7522a81
4ede883
3533ee1
b3c313c
0957d03
6132ade
a5cfb71
5498cff
f34d7a1
4aa400a
c3591ce
7b883f9
9d48c8c
8e43fac
9302f10
94e8946
b22f03b
c9b4c77
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; | ||
|
dastratakos marked this conversation as resolved.
|
||
| import { formatMonthKey } from "@calcom/features/ee/billing/lib/month-key"; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. test comment
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pending
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"; | ||
|
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"] }); | ||
|
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); | ||
|
dastratakos marked this conversation as resolved.
|
||
| const isEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( | ||
| "monthly-proration" | ||
| ); | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what's going on here?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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." }, | ||
|
dastratakos marked this conversation as resolved.
|
||
| { status: 400 } | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| log.info(`Scheduling monthly proration tasks for ${monthKey}`); | ||
|
|
||
| const teamRepository = new MonthlyProrationTeamRepository(prisma); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pending comment
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pending reply
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pending reply 2
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( | ||
|
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, | ||
| }); | ||
|
dastratakos marked this conversation as resolved.
|
||
| } | ||
|
|
||
| export const GET = getHandler; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| import stripe from "@calcom/features/ee/payments/server/stripe"; | ||
| import { HttpError } from "@calcom/lib/http-error"; | ||
|
|
@@ -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); | ||
|
|
@@ -72,6 +77,11 @@ async function handler(request: NextRequest) { | |
| status: SubscriptionStatus.ACTIVE, | ||
| planName: Plan.TEAM, | ||
| subscriptionStart, | ||
| subscriptionEnd, | ||
| subscriptionTrialEnd, | ||
| billingPeriod, | ||
| pricePerSeat, | ||
| paidSeats, | ||
| }); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
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"; | ||
|
|
@@ -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({ | ||
|
|
@@ -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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. test comment 2
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. test reply
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pending reply |
||
| }); | ||
| } | ||
|
|
||
|
|
||
| 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(), | ||
| }, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }, | ||
| }); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.