Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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: subscriptionStart ?? undefined,
subscriptionEnd: subscriptionEnd ?? undefined,
subscriptionTrialEnd: subscriptionTrialEnd ?? undefined,
billingPeriod,
pricePerSeat,
paidSeats,
});
}

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

if (!session) {
Expand Down
14 changes: 12 additions & 2 deletions 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";
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 @@ -71,7 +76,12 @@ async function handler(request: NextRequest) {
// TODO: Implement true subscription status when webhook events are implemented
status: SubscriptionStatus.ACTIVE,
planName: Plan.TEAM,
subscriptionStart,
subscriptionStart: subscriptionStart ?? undefined,
subscriptionEnd: subscriptionEnd ?? undefined,
subscriptionTrialEnd: subscriptionTrialEnd ?? undefined,
billingPeriod,
pricePerSeat,
paidSeats,
});
}

Expand Down
14 changes: 12 additions & 2 deletions 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";
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 @@ -105,7 +110,12 @@ async function getHandler(req: NextRequest) {
// TODO: Implement true subscription status when webhook events are implemented
status: SubscriptionStatus.ACTIVE,
planName: Plan.TEAM,
subscriptionStart,
subscriptionStart: subscriptionStart ?? undefined,
subscriptionEnd: subscriptionEnd ?? undefined,
subscriptionTrialEnd: subscriptionTrialEnd ?? undefined,
billingPeriod,
pricePerSeat,
paidSeats,
});
}

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(),
},
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 },
});
});
});
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing";
import { PrismaPhoneNumberRepository } from "@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository";
import { PrismaOrganizationBillingRepository } from "@calcom/features/ee/billing/repository/billing/PrismaOrganizationBillingRepository";
import { PrismaTeamBillingRepository } from "@calcom/features/ee/billing/repository/billing/PrismaTeamBillingRepository";
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"] });

import type { SWHMap } from "./__handler";
import { HttpCode } from "./__handler";

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");
Expand All @@ -19,11 +27,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"];
Expand Down Expand Up @@ -58,4 +71,52 @@ 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 teamBillingRepo = new PrismaTeamBillingRepository(prisma);
const orgBillingRepo = new PrismaOrganizationBillingRepository(prisma);

const billingUpdateData = {
paidSeats: paidSeats ?? null,
subscriptionStart,
subscriptionEnd,
subscriptionTrialEnd,
billingPeriod,
pricePerSeat: pricePerSeat ?? null,
};

const teamBilling = await teamBillingRepo.findBySubscriptionId(subscription.id);

if (teamBilling) {
await teamBillingRepo.updateById(teamBilling.id, billingUpdateData);
return { success: true, type: "team", teamId: teamBilling.teamId };
}

const orgBilling = await orgBillingRepo.findBySubscriptionId(subscription.id);

if (orgBilling) {
await orgBillingRepo.updateById(orgBilling.id, billingUpdateData);
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" };
}

export default handler;
14 changes: 12 additions & 2 deletions packages/features/ee/billing/api/webhook/_invoice.paid.org.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -136,7 +141,12 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
// TODO: Write actual status when webhook events are added
status: SubscriptionStatus.ACTIVE,
planName: Plan.ORGANIZATION,
subscriptionStart,
subscriptionStart: subscriptionStart ?? undefined,
subscriptionEnd: subscriptionEnd ?? undefined,
subscriptionTrialEnd: subscriptionTrialEnd ?? undefined,
billingPeriod,
pricePerSeat,
paidSeats,
});

logger.debug(`Marking onboarding as complete for organization ${organization.id}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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";

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: buildMonthlyProrationMetadata({ 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 });
});
});
Loading
Loading