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
17 changes: 16 additions & 1 deletion apps/web/app/api/teams/api/create/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -54,6 +56,19 @@ async function handler(request: NextRequest) {
include: { members: true },
});

if (checkoutSessionSubscription) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

If billing information is present then write to the billing table for new teams

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,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Until we implement handling webhook events from Stripe, assume all new subscriptions coming from the checkout session are 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),
Expand Down
15 changes: 15 additions & 0 deletions apps/web/app/api/teams/create/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -84,6 +86,19 @@ async function getHandler(req: NextRequest) {
},
});

if (checkoutSession && subscription) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same here. If creating a new team then write to the TeamBilling table and set the status to ACTIVE

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),
Expand Down
13 changes: 13 additions & 0 deletions packages/features/ee/billing/api/webhook/_invoice.paid.org.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -94,6 +96,17 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
paymentSubscriptionItemId,
});

const internalTeamBillingService = new InternalTeamBilling(organization);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the webhook event when a new organization is created and the payment succeeds. Write to the OrganizationBilling table

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 };
Expand Down
19 changes: 12 additions & 7 deletions packages/features/ee/billing/credit-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
},
};
});

Expand Down Expand Up @@ -85,7 +90,7 @@ CreditsRepository.findCreditBalance.mockResolvedValueOnce({

describe("CreditService", () => {
let creditService: CreditService;
let stripeMock: any;
let stripeMock: Partial<Stripe>;

beforeEach(() => {
vi.restoreAllMocks();
Expand All @@ -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);
Expand Down Expand Up @@ -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"),
Expand All @@ -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"),
Expand Down Expand Up @@ -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"),
Expand All @@ -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"),
Expand Down
40 changes: 40 additions & 0 deletions packages/features/ee/billing/repository/IBillingRepository.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading