feat: Add team billing tables (#24148)

* Init billing tables

* Create `IBillingRepository` types

* Create billing repositories

* Create billingRepositoryFactory

* Eslint fix - remove unused organizationOnboarding

* internal-team-billing create saveTeamBilling method using repositories

* On new teams write to team billing table

* On new org write to org billing table

* Change fields to organizationId

* Add todo comment

* Revert "Change fields to organizationId"

This reverts commit bbb2e5dfa6b4c20a8a395f5730848a492cd70d68.

* test: add comprehensive tests for team billing tables

- Fix credit-service.test.ts Prisma mock to export prisma object
- Replace any types with proper TypeScript types in credit-service.test.ts
- Add unit tests for PrismaTeamBillingRepository covering record creation, enum casting, and error handling
- Add unit tests for PrismaOrganizationBillingRepository with same coverage
- Add unit tests for BillingRepositoryFactory to verify correct repository selection
- Add unit tests for InternalTeamBilling.saveTeamBilling() method testing delegation to correct repositories
- All 53 tests pass with TZ=UTC yarn test
- Type checking passes with yarn type-check:ci --force

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: update saveTeamBilling tests to mock repository interface

- Replace prismaMock usage with BillingRepositoryFactory mock
- Mock IBillingRepository interface instead of Prisma directly
- Follow repository mocking pattern from handleResponse.test.ts
- All tests passing (53 total)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Remove log statement

* Remove repository tests

* Address feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Joe Au-Yeung
2025-10-07 14:25:31 -04:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 92169eb0d2
commit c702da0334
13 changed files with 446 additions and 12 deletions
+16 -1
View File
@@ -4,9 +4,11 @@ import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { z } from "zod";
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { MembershipSchema } from "@calcom/prisma/zod/modelSchema/MembershipSchema";
import { TeamSchema } from "@calcom/prisma/zod/modelSchema/TeamSchema";
@@ -54,6 +56,19 @@ async function handler(request: NextRequest) {
include: { members: true },
});
if (checkoutSessionSubscription) {
const internalBillingService = new InternalTeamBilling(finalizedTeam);
await internalBillingService.saveTeamBilling({
teamId: finalizedTeam.id,
subscriptionId: checkoutSessionSubscription.id,
subscriptionItemId: checkoutSessionSubscription.items.data[0].id,
customerId: checkoutSessionSubscription.customer as string,
// TODO: Implement true subscription status when webhook events are implemented
status: SubscriptionStatus.ACTIVE,
planName: Plan.TEAM,
});
}
const response = {
message: `Team created successfully. We also made user with ID=${checkoutSessionMetadata.ownerId} the owner of this team.`,
team: schemaTeamReadPublic.parse(finalizedTeam),
+15
View File
@@ -4,6 +4,8 @@ import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { z } from "zod";
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
@@ -84,6 +86,19 @@ async function getHandler(req: NextRequest) {
},
});
if (checkoutSession && subscription) {
const internalBillingService = new InternalTeamBilling(team);
await internalBillingService.saveTeamBilling({
teamId: team.id,
subscriptionId: subscription.id,
subscriptionItemId: subscription.items.data[0].id,
customerId: subscription.customer as string,
// TODO: Implement true subscription status when webhook events are implemented
status: SubscriptionStatus.ACTIVE,
planName: Plan.TEAM,
});
}
// redirect to team screen
return NextResponse.redirect(
new URL(`/settings/teams/${team.id}/onboard-members?event=team_created`, req.nextUrl.origin),
@@ -1,5 +1,7 @@
import { z } from "zod";
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import { createOrganizationFromOnboarding } from "@calcom/features/ee/organizations/lib/server/createOrganizationFromOnboarding";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -94,6 +96,17 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
paymentSubscriptionItemId,
});
const internalTeamBillingService = new InternalTeamBilling(organization);
await internalTeamBillingService.saveTeamBilling({
teamId: organization.id,
subscriptionId: paymentSubscriptionId,
subscriptionItemId: paymentSubscriptionItemId,
customerId: invoice.customer,
// TODO: Write actual status when webhook events are added
status: SubscriptionStatus.ACTIVE,
planName: Plan.ORGANIZATION,
});
logger.debug(`Marking onboarding as complete for organization ${organization.id}`);
await OrganizationOnboardingRepository.markAsComplete(organizationOnboarding.id);
return { success: true };
@@ -14,11 +14,16 @@ import { InternalTeamBilling } from "./teams/internal-team-billing";
const MOCK_TX = {};
vi.mock("@calcom/prisma", () => {
vi.mock("@calcom/prisma", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
default: {
$transaction: vi.fn((fn) => fn(MOCK_TX)),
},
prisma: {
$transaction: vi.fn((fn) => fn(MOCK_TX)),
},
};
});
@@ -85,7 +90,7 @@ CreditsRepository.findCreditBalance.mockResolvedValueOnce({
describe("CreditService", () => {
let creditService: CreditService;
let stripeMock: any;
let stripeMock: Partial<Stripe>;
beforeEach(() => {
vi.restoreAllMocks();
@@ -96,7 +101,7 @@ describe("CreditService", () => {
retrieve: vi.fn().mockResolvedValue({ id: "price_123", unit_amount: 1000 }),
},
};
(Stripe as any).mockImplementation(() => stripeMock);
vi.mocked(Stripe).mockImplementation(() => stripeMock as Stripe);
creditService = new CreditService();
vi.mocked(CreditsRepository.findCreditExpenseLogByExternalRef).mockResolvedValue(null);
@@ -400,7 +405,7 @@ describe("CreditService", () => {
members: [{ accepted: true }],
}),
};
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as unknown as TeamRepository);
const mockTeamBillingService = {
getSubscriptionStatus: vi.fn().mockResolvedValue("trialing"),
@@ -421,7 +426,7 @@ describe("CreditService", () => {
members: [{ accepted: true }, { accepted: true }, { accepted: true }],
}),
};
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as unknown as TeamRepository);
const mockTeamBillingService = {
getSubscriptionStatus: vi.fn().mockResolvedValue("active"),
@@ -450,7 +455,7 @@ describe("CreditService", () => {
members: [{ accepted: true }, { accepted: true }],
}),
};
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as unknown as TeamRepository);
const mockTeamBillingService = {
getSubscriptionStatus: vi.fn().mockResolvedValue("active"),
@@ -473,7 +478,7 @@ describe("CreditService", () => {
members: [{ accepted: true }, { accepted: true }, { accepted: true }],
}),
};
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as unknown as TeamRepository);
const mockTeamBillingService = {
getSubscriptionStatus: vi.fn().mockResolvedValue("active"),
@@ -0,0 +1,40 @@
export enum Plan {
TEAM = "TEAM",
ORGANIZATION = "ORGANIZATION",
ENTERPRISE = "ENTERPRISE",
}
export enum SubscriptionStatus {
ACTIVE = "ACTIVE",
CANCELLED = "CANCELLED",
PAST_DUE = "PAST_DUE",
TRIALING = "TRIALING",
}
export interface BillingRecord {
id: string;
teamId: number;
subscriptionId: string;
subscriptionItemId: string;
customerId: string;
planName: Plan;
status: SubscriptionStatus;
}
export interface IBillingRepository {
create(args: IBillingRepositoryCreateArgs): Promise<BillingRecord>;
}
export interface IBillingRepositoryConstructorArgs {
teamId: number;
isOrganization: boolean;
}
export interface IBillingRepositoryCreateArgs {
teamId: number;
subscriptionId: string;
subscriptionItemId: string;
customerId: string;
planName: Plan;
status: SubscriptionStatus;
}
@@ -0,0 +1,26 @@
import type { PrismaClient } from "@calcom/prisma";
import {
IBillingRepository,
IBillingRepositoryCreateArgs,
BillingRecord,
Plan,
SubscriptionStatus,
} from "./IBillingRepository";
export class PrismaOrganizationBillingRepository implements IBillingRepository {
constructor(private readonly prismaClient: PrismaClient) {}
async create(args: IBillingRepositoryCreateArgs): Promise<BillingRecord> {
const billingRecord = await this.prismaClient.organizationBilling.create({
data: {
...args,
},
});
return {
...billingRecord,
planName: billingRecord.planName as Plan,
status: billingRecord.status as SubscriptionStatus,
};
}
}
@@ -0,0 +1,26 @@
import type { PrismaClient } from "@calcom/prisma";
import {
IBillingRepository,
IBillingRepositoryCreateArgs,
BillingRecord,
Plan,
SubscriptionStatus,
} from "./IBillingRepository";
export class PrismaTeamBillingRepository implements IBillingRepository {
constructor(private readonly prismaClient: PrismaClient) {}
async create(args: IBillingRepositoryCreateArgs): Promise<BillingRecord> {
const billingRecord = await this.prismaClient.teamBilling.create({
data: {
...args,
},
});
return {
...billingRecord,
planName: billingRecord.planName as Plan,
status: billingRecord.status as SubscriptionStatus,
};
}
}
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { PrismaOrganizationBillingRepository } from "./PrismaOrganizationBillingRepository";
import { PrismaTeamBillingRepository } from "./PrismaTeamBillingRepository";
import { BillingRepositoryFactory } from "./billingRepositoryFactory";
describe("BillingRepositoryFactory", () => {
describe("getRepository", () => {
it("should return PrismaOrganizationBillingRepository when isOrganization is true", () => {
const repository = BillingRepositoryFactory.getRepository(true);
expect(repository).toBeInstanceOf(PrismaOrganizationBillingRepository);
});
it("should return PrismaTeamBillingRepository when isOrganization is false", () => {
const repository = BillingRepositoryFactory.getRepository(false);
expect(repository).toBeInstanceOf(PrismaTeamBillingRepository);
});
it("should return same repository type for multiple calls with same parameter", () => {
const repository1 = BillingRepositoryFactory.getRepository(true);
const repository2 = BillingRepositoryFactory.getRepository(true);
expect(repository1).toBeInstanceOf(PrismaOrganizationBillingRepository);
expect(repository2).toBeInstanceOf(PrismaOrganizationBillingRepository);
});
it("should return different repository types for different parameters", () => {
const orgRepository = BillingRepositoryFactory.getRepository(true);
const teamRepository = BillingRepositoryFactory.getRepository(false);
expect(orgRepository).toBeInstanceOf(PrismaOrganizationBillingRepository);
expect(teamRepository).toBeInstanceOf(PrismaTeamBillingRepository);
expect(orgRepository).not.toBeInstanceOf(PrismaTeamBillingRepository);
expect(teamRepository).not.toBeInstanceOf(PrismaOrganizationBillingRepository);
});
});
});
@@ -0,0 +1,13 @@
import { prisma } from "@calcom/prisma";
import { PrismaOrganizationBillingRepository } from "./PrismaOrganizationBillingRepository";
import { PrismaTeamBillingRepository } from "./PrismaTeamBillingRepository";
export class BillingRepositoryFactory {
static getRepository(isOrganization: boolean) {
if (isOrganization) {
return new PrismaOrganizationBillingRepository(prisma);
}
return new PrismaTeamBillingRepository(prisma);
}
}
@@ -6,6 +6,7 @@ import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/pay
import { WEBAPP_URL } from "@calcom/lib/constants";
import * as billingModule from "..";
import { BillingRepositoryFactory } from "../repository/billingRepositoryFactory";
import { InternalTeamBilling } from "./internal-team-billing";
import { TeamBillingPublishResponseStatus } from "./team-billing";
@@ -28,6 +29,8 @@ vi.mock("..", () => ({
vi.mock("@calcom/features/ee/teams/lib/payments", () => ({
purchaseTeamOrOrgSubscription: vi.fn(),
}));
vi.mock("../repository/billingRepositoryFactory");
const mockTeam = {
id: 1,
metadata: {
@@ -173,4 +176,160 @@ describe("InternalTeamBilling", () => {
});
});
});
describe("saveTeamBilling", () => {
const mockOrgRepository = {
create: vi.fn(),
};
const mockTeamRepository = {
create: vi.fn(),
};
it("should delegate to organization billing repository when team is an organization", async () => {
const mockOrgTeam = {
id: 1,
metadata: {},
isOrganization: true,
parentId: null,
};
const mockBillingArgs = {
teamId: 1,
subscriptionId: "sub_org_123",
subscriptionItemId: "si_org_123",
customerId: "cus_org_123",
planName: "ORGANIZATION" as const,
status: "ACTIVE" as const,
};
const mockCreatedRecord = {
id: "billing_org_123",
...mockBillingArgs,
createdAt: new Date(),
updatedAt: new Date(),
};
mockOrgRepository.create.mockResolvedValue(mockCreatedRecord);
vi.mocked(BillingRepositoryFactory.getRepository).mockReturnValue(
mockOrgRepository as unknown as ReturnType<typeof BillingRepositoryFactory.getRepository>
);
const internalTeamBilling = new InternalTeamBilling(mockOrgTeam);
await internalTeamBilling.saveTeamBilling(mockBillingArgs);
expect(BillingRepositoryFactory.getRepository).toHaveBeenCalledWith(true);
expect(mockOrgRepository.create).toHaveBeenCalledWith(mockBillingArgs);
});
it("should delegate to team billing repository when team is not an organization", async () => {
const mockRegularTeam = {
id: 2,
metadata: {},
isOrganization: false,
parentId: null,
};
const mockBillingArgs = {
teamId: 2,
subscriptionId: "sub_team_456",
subscriptionItemId: "si_team_456",
customerId: "cus_team_456",
planName: "TEAM" as const,
status: "ACTIVE" as const,
};
const mockCreatedRecord = {
id: "billing_team_456",
...mockBillingArgs,
createdAt: new Date(),
updatedAt: new Date(),
};
mockTeamRepository.create.mockResolvedValue(mockCreatedRecord);
vi.mocked(BillingRepositoryFactory.getRepository).mockReturnValue(
mockTeamRepository as unknown as ReturnType<typeof BillingRepositoryFactory.getRepository>
);
const internalTeamBilling = new InternalTeamBilling(mockRegularTeam);
await internalTeamBilling.saveTeamBilling(mockBillingArgs);
expect(BillingRepositoryFactory.getRepository).toHaveBeenCalledWith(false);
expect(mockTeamRepository.create).toHaveBeenCalledWith(mockBillingArgs);
});
it("should pass all billing arguments correctly to repository", async () => {
const mockTeam = {
id: 3,
metadata: {},
isOrganization: false,
parentId: null,
};
const mockBillingArgs = {
teamId: 3,
subscriptionId: "sub_detailed_789",
subscriptionItemId: "si_detailed_789",
customerId: "cus_detailed_789",
planName: "ENTERPRISE" as const,
status: "TRIALING" as const,
};
const mockCreatedRecord = {
id: "billing_detailed_789",
...mockBillingArgs,
createdAt: new Date(),
updatedAt: new Date(),
};
mockTeamRepository.create.mockResolvedValue(mockCreatedRecord);
vi.mocked(BillingRepositoryFactory.getRepository).mockReturnValue(
mockTeamRepository as unknown as ReturnType<typeof BillingRepositoryFactory.getRepository>
);
const internalTeamBilling = new InternalTeamBilling(mockTeam);
await internalTeamBilling.saveTeamBilling(mockBillingArgs);
expect(mockTeamRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
teamId: 3,
subscriptionId: "sub_detailed_789",
subscriptionItemId: "si_detailed_789",
customerId: "cus_detailed_789",
planName: "ENTERPRISE",
status: "TRIALING",
})
);
});
it("should propagate repository errors", async () => {
const mockTeam = {
id: 4,
metadata: {},
isOrganization: false,
parentId: null,
};
const mockBillingArgs = {
teamId: 4,
subscriptionId: "sub_error_999",
subscriptionItemId: "si_error_999",
customerId: "cus_error_999",
planName: "TEAM" as const,
status: "ACTIVE" as const,
};
const repositoryError = new Error("Database constraint violation");
mockTeamRepository.create.mockRejectedValue(repositoryError);
vi.mocked(BillingRepositoryFactory.getRepository).mockReturnValue(
mockTeamRepository as unknown as ReturnType<typeof BillingRepositoryFactory.getRepository>
);
const internalTeamBilling = new InternalTeamBilling(mockTeam);
await expect(internalTeamBilling.saveTeamBilling(mockBillingArgs)).rejects.toThrow(
"Database constraint violation"
);
});
});
});
@@ -7,12 +7,13 @@ import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers";
import logger from "@calcom/lib/logger";
import { Redirect } from "@calcom/lib/redirect";
import { safeStringify } from "@calcom/lib/safeStringify";
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
import billing from "..";
import { IBillingRepository, IBillingRepositoryCreateArgs } from "../repository/IBillingRepository";
import { BillingRepositoryFactory } from "../repository/billingRepositoryFactory";
import { TeamBillingPublishResponseStatus, type TeamBilling, type TeamBillingInput } from "./team-billing";
const log = logger.getSubLogger({ prefix: ["TeamBilling"] });
@@ -23,8 +24,10 @@ export class InternalTeamBilling implements TeamBilling {
private _team!: Omit<TeamBillingInput, "metadata"> & {
metadata: NonNullable<z.infer<typeof teamPaymentMetadataSchema>>;
};
private billingRepository: IBillingRepository;
constructor(team: TeamBillingInput) {
this.team = team;
this.billingRepository = BillingRepositoryFactory.getRepository(team.isOrganization);
}
set team(team: TeamBillingInput) {
const metadata = teamPaymentMetadataSchema.parse(team.metadata || {});
@@ -125,9 +128,6 @@ export class InternalTeamBilling implements TeamBilling {
const { id: teamId, metadata, isOrganization } = this.team;
const { url } = await this.checkIfTeamPaymentRequired();
const organizationOnboarding = await OrganizationOnboardingRepository.findByOrganizationId(
this.team.id
);
log.debug("updateQuantity", safeStringify({ url, team: this.team }));
/**
@@ -205,4 +205,7 @@ export class InternalTeamBilling implements TeamBilling {
return false;
}
}
async saveTeamBilling(args: IBillingRepositoryCreateArgs) {
await this.billingRepository.create(args);
}
}
@@ -0,0 +1,47 @@
-- CreateTable
CREATE TABLE "TeamBilling" (
"id" TEXT NOT NULL,
"teamId" INTEGER NOT NULL,
"subscriptionId" TEXT NOT NULL,
"subscriptionItemId" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"planName" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TeamBilling_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrganizationBilling" (
"id" TEXT NOT NULL,
"teamId" INTEGER NOT NULL,
"subscriptionId" TEXT NOT NULL,
"subscriptionItemId" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"planName" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationBilling_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TeamBilling_teamId_key" ON "TeamBilling"("teamId");
-- CreateIndex
CREATE UNIQUE INDEX "TeamBilling_subscriptionId_key" ON "TeamBilling"("subscriptionId");
-- CreateIndex
CREATE UNIQUE INDEX "OrganizationBilling_teamId_key" ON "OrganizationBilling"("teamId");
-- CreateIndex
CREATE UNIQUE INDEX "OrganizationBilling_subscriptionId_key" ON "OrganizationBilling"("subscriptionId");
-- AddForeignKey
ALTER TABLE "TeamBilling" ADD CONSTRAINT "TeamBilling_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrganizationBilling" ADD CONSTRAINT "OrganizationBilling_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+33
View File
@@ -588,6 +588,9 @@ model Team {
managedOrganizations ManagedOrganization[] @relation("ManagerOrganization")
filterSegments FilterSegment[]
teamBilling TeamBilling? @relation("TeamBilling")
organizationBilling OrganizationBilling? @relation("OrganizationBilling")
@@unique([slug, parentId])
@@index([parentId])
}
@@ -2595,6 +2598,36 @@ model CalAiPhoneNumber {
@@index([outboundAgentId])
}
model TeamBilling {
id String @id @default(uuid())
teamId Int @unique
team Team? @relation("TeamBilling", fields: [teamId], references: [id], onDelete: Cascade)
subscriptionId String @unique
subscriptionItemId String
customerId String
status String
planName String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OrganizationBilling {
id String @id @default(uuid())
teamId Int @unique
team Team @relation("OrganizationBilling", fields: [teamId], references: [id], onDelete: Cascade)
subscriptionId String @unique
subscriptionItemId String
customerId String
status String
planName String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum CalendarCacheEventStatus {
confirmed @map("confirmed")
tentative @map("tentative")