Skip to content

Commit c702da0

Browse files
feat: Add team billing tables (calcom#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 bbb2e5d. * 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>
1 parent 92169eb commit c702da0

13 files changed

Lines changed: 446 additions & 12 deletions

File tree

apps/web/app/api/teams/api/create/route.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { NextResponse } from "next/server";
44
import type Stripe from "stripe";
55
import { z } from "zod";
66

7+
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
8+
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
79
import stripe from "@calcom/features/ee/payments/server/stripe";
810
import { HttpError } from "@calcom/lib/http-error";
9-
import prisma from "@calcom/prisma";
11+
import { prisma } from "@calcom/prisma";
1012
import { MembershipRole } from "@calcom/prisma/enums";
1113
import { MembershipSchema } from "@calcom/prisma/zod/modelSchema/MembershipSchema";
1214
import { TeamSchema } from "@calcom/prisma/zod/modelSchema/TeamSchema";
@@ -54,6 +56,19 @@ async function handler(request: NextRequest) {
5456
include: { members: true },
5557
});
5658

59+
if (checkoutSessionSubscription) {
60+
const internalBillingService = new InternalTeamBilling(finalizedTeam);
61+
await internalBillingService.saveTeamBilling({
62+
teamId: finalizedTeam.id,
63+
subscriptionId: checkoutSessionSubscription.id,
64+
subscriptionItemId: checkoutSessionSubscription.items.data[0].id,
65+
customerId: checkoutSessionSubscription.customer as string,
66+
// TODO: Implement true subscription status when webhook events are implemented
67+
status: SubscriptionStatus.ACTIVE,
68+
planName: Plan.TEAM,
69+
});
70+
}
71+
5772
const response = {
5873
message: `Team created successfully. We also made user with ID=${checkoutSessionMetadata.ownerId} the owner of this team.`,
5974
team: schemaTeamReadPublic.parse(finalizedTeam),

apps/web/app/api/teams/create/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { NextResponse } from "next/server";
44
import type Stripe from "stripe";
55
import { z } from "zod";
66

7+
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
8+
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
79
import stripe from "@calcom/features/ee/payments/server/stripe";
810
import { HttpError } from "@calcom/lib/http-error";
911
import prisma from "@calcom/prisma";
@@ -84,6 +86,19 @@ async function getHandler(req: NextRequest) {
8486
},
8587
});
8688

89+
if (checkoutSession && subscription) {
90+
const internalBillingService = new InternalTeamBilling(team);
91+
await internalBillingService.saveTeamBilling({
92+
teamId: team.id,
93+
subscriptionId: subscription.id,
94+
subscriptionItemId: subscription.items.data[0].id,
95+
customerId: subscription.customer as string,
96+
// TODO: Implement true subscription status when webhook events are implemented
97+
status: SubscriptionStatus.ACTIVE,
98+
planName: Plan.TEAM,
99+
});
100+
}
101+
87102
// redirect to team screen
88103
return NextResponse.redirect(
89104
new URL(`/settings/teams/${team.id}/onboard-members?event=team_created`, req.nextUrl.origin),

packages/features/ee/billing/api/webhook/_invoice.paid.org.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { z } from "zod";
22

3+
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
4+
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
35
import { createOrganizationFromOnboarding } from "@calcom/features/ee/organizations/lib/server/createOrganizationFromOnboarding";
46
import logger from "@calcom/lib/logger";
57
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -94,6 +96,17 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
9496
paymentSubscriptionItemId,
9597
});
9698

99+
const internalTeamBillingService = new InternalTeamBilling(organization);
100+
await internalTeamBillingService.saveTeamBilling({
101+
teamId: organization.id,
102+
subscriptionId: paymentSubscriptionId,
103+
subscriptionItemId: paymentSubscriptionItemId,
104+
customerId: invoice.customer,
105+
// TODO: Write actual status when webhook events are added
106+
status: SubscriptionStatus.ACTIVE,
107+
planName: Plan.ORGANIZATION,
108+
});
109+
97110
logger.debug(`Marking onboarding as complete for organization ${organization.id}`);
98111
await OrganizationOnboardingRepository.markAsComplete(organizationOnboarding.id);
99112
return { success: true };

packages/features/ee/billing/credit-service.test.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,16 @@ import { InternalTeamBilling } from "./teams/internal-team-billing";
1414

1515
const MOCK_TX = {};
1616

17-
vi.mock("@calcom/prisma", () => {
17+
vi.mock("@calcom/prisma", async (importOriginal) => {
18+
const actual = await importOriginal();
1819
return {
20+
...actual,
1921
default: {
2022
$transaction: vi.fn((fn) => fn(MOCK_TX)),
2123
},
24+
prisma: {
25+
$transaction: vi.fn((fn) => fn(MOCK_TX)),
26+
},
2227
};
2328
});
2429

@@ -85,7 +90,7 @@ CreditsRepository.findCreditBalance.mockResolvedValueOnce({
8590

8691
describe("CreditService", () => {
8792
let creditService: CreditService;
88-
let stripeMock: any;
93+
let stripeMock: Partial<Stripe>;
8994

9095
beforeEach(() => {
9196
vi.restoreAllMocks();
@@ -96,7 +101,7 @@ describe("CreditService", () => {
96101
retrieve: vi.fn().mockResolvedValue({ id: "price_123", unit_amount: 1000 }),
97102
},
98103
};
99-
(Stripe as any).mockImplementation(() => stripeMock);
104+
vi.mocked(Stripe).mockImplementation(() => stripeMock as Stripe);
100105
creditService = new CreditService();
101106

102107
vi.mocked(CreditsRepository.findCreditExpenseLogByExternalRef).mockResolvedValue(null);
@@ -400,7 +405,7 @@ describe("CreditService", () => {
400405
members: [{ accepted: true }],
401406
}),
402407
};
403-
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
408+
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as unknown as TeamRepository);
404409

405410
const mockTeamBillingService = {
406411
getSubscriptionStatus: vi.fn().mockResolvedValue("trialing"),
@@ -421,7 +426,7 @@ describe("CreditService", () => {
421426
members: [{ accepted: true }, { accepted: true }, { accepted: true }],
422427
}),
423428
};
424-
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
429+
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as unknown as TeamRepository);
425430

426431
const mockTeamBillingService = {
427432
getSubscriptionStatus: vi.fn().mockResolvedValue("active"),
@@ -450,7 +455,7 @@ describe("CreditService", () => {
450455
members: [{ accepted: true }, { accepted: true }],
451456
}),
452457
};
453-
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
458+
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as unknown as TeamRepository);
454459

455460
const mockTeamBillingService = {
456461
getSubscriptionStatus: vi.fn().mockResolvedValue("active"),
@@ -473,7 +478,7 @@ describe("CreditService", () => {
473478
members: [{ accepted: true }, { accepted: true }, { accepted: true }],
474479
}),
475480
};
476-
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
481+
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as unknown as TeamRepository);
477482

478483
const mockTeamBillingService = {
479484
getSubscriptionStatus: vi.fn().mockResolvedValue("active"),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export enum Plan {
2+
TEAM = "TEAM",
3+
ORGANIZATION = "ORGANIZATION",
4+
ENTERPRISE = "ENTERPRISE",
5+
}
6+
7+
export enum SubscriptionStatus {
8+
ACTIVE = "ACTIVE",
9+
CANCELLED = "CANCELLED",
10+
PAST_DUE = "PAST_DUE",
11+
TRIALING = "TRIALING",
12+
}
13+
14+
export interface BillingRecord {
15+
id: string;
16+
teamId: number;
17+
subscriptionId: string;
18+
subscriptionItemId: string;
19+
customerId: string;
20+
planName: Plan;
21+
status: SubscriptionStatus;
22+
}
23+
24+
export interface IBillingRepository {
25+
create(args: IBillingRepositoryCreateArgs): Promise<BillingRecord>;
26+
}
27+
28+
export interface IBillingRepositoryConstructorArgs {
29+
teamId: number;
30+
isOrganization: boolean;
31+
}
32+
33+
export interface IBillingRepositoryCreateArgs {
34+
teamId: number;
35+
subscriptionId: string;
36+
subscriptionItemId: string;
37+
customerId: string;
38+
planName: Plan;
39+
status: SubscriptionStatus;
40+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { PrismaClient } from "@calcom/prisma";
2+
3+
import {
4+
IBillingRepository,
5+
IBillingRepositoryCreateArgs,
6+
BillingRecord,
7+
Plan,
8+
SubscriptionStatus,
9+
} from "./IBillingRepository";
10+
11+
export class PrismaOrganizationBillingRepository implements IBillingRepository {
12+
constructor(private readonly prismaClient: PrismaClient) {}
13+
async create(args: IBillingRepositoryCreateArgs): Promise<BillingRecord> {
14+
const billingRecord = await this.prismaClient.organizationBilling.create({
15+
data: {
16+
...args,
17+
},
18+
});
19+
20+
return {
21+
...billingRecord,
22+
planName: billingRecord.planName as Plan,
23+
status: billingRecord.status as SubscriptionStatus,
24+
};
25+
}
26+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { PrismaClient } from "@calcom/prisma";
2+
3+
import {
4+
IBillingRepository,
5+
IBillingRepositoryCreateArgs,
6+
BillingRecord,
7+
Plan,
8+
SubscriptionStatus,
9+
} from "./IBillingRepository";
10+
11+
export class PrismaTeamBillingRepository implements IBillingRepository {
12+
constructor(private readonly prismaClient: PrismaClient) {}
13+
async create(args: IBillingRepositoryCreateArgs): Promise<BillingRecord> {
14+
const billingRecord = await this.prismaClient.teamBilling.create({
15+
data: {
16+
...args,
17+
},
18+
});
19+
20+
return {
21+
...billingRecord,
22+
planName: billingRecord.planName as Plan,
23+
status: billingRecord.status as SubscriptionStatus,
24+
};
25+
}
26+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
import { PrismaOrganizationBillingRepository } from "./PrismaOrganizationBillingRepository";
4+
import { PrismaTeamBillingRepository } from "./PrismaTeamBillingRepository";
5+
import { BillingRepositoryFactory } from "./billingRepositoryFactory";
6+
7+
describe("BillingRepositoryFactory", () => {
8+
describe("getRepository", () => {
9+
it("should return PrismaOrganizationBillingRepository when isOrganization is true", () => {
10+
const repository = BillingRepositoryFactory.getRepository(true);
11+
12+
expect(repository).toBeInstanceOf(PrismaOrganizationBillingRepository);
13+
});
14+
15+
it("should return PrismaTeamBillingRepository when isOrganization is false", () => {
16+
const repository = BillingRepositoryFactory.getRepository(false);
17+
18+
expect(repository).toBeInstanceOf(PrismaTeamBillingRepository);
19+
});
20+
21+
it("should return same repository type for multiple calls with same parameter", () => {
22+
const repository1 = BillingRepositoryFactory.getRepository(true);
23+
const repository2 = BillingRepositoryFactory.getRepository(true);
24+
25+
expect(repository1).toBeInstanceOf(PrismaOrganizationBillingRepository);
26+
expect(repository2).toBeInstanceOf(PrismaOrganizationBillingRepository);
27+
});
28+
29+
it("should return different repository types for different parameters", () => {
30+
const orgRepository = BillingRepositoryFactory.getRepository(true);
31+
const teamRepository = BillingRepositoryFactory.getRepository(false);
32+
33+
expect(orgRepository).toBeInstanceOf(PrismaOrganizationBillingRepository);
34+
expect(teamRepository).toBeInstanceOf(PrismaTeamBillingRepository);
35+
expect(orgRepository).not.toBeInstanceOf(PrismaTeamBillingRepository);
36+
expect(teamRepository).not.toBeInstanceOf(PrismaOrganizationBillingRepository);
37+
});
38+
});
39+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { prisma } from "@calcom/prisma";
2+
3+
import { PrismaOrganizationBillingRepository } from "./PrismaOrganizationBillingRepository";
4+
import { PrismaTeamBillingRepository } from "./PrismaTeamBillingRepository";
5+
6+
export class BillingRepositoryFactory {
7+
static getRepository(isOrganization: boolean) {
8+
if (isOrganization) {
9+
return new PrismaOrganizationBillingRepository(prisma);
10+
}
11+
return new PrismaTeamBillingRepository(prisma);
12+
}
13+
}

0 commit comments

Comments
 (0)