-
Notifications
You must be signed in to change notification settings - Fork 12k
feat: Add team billing tables #24148
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
Changes from all commits
281a132
80812ec
259f40e
1cbc467
8c81e70
9828c1d
511c1c3
ab831fb
114bd9c
bbb2e5d
390ddb6
66276c7
423e810
8bda72f
f8d968e
d3b8af5
1daddfd
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 |
|---|---|---|
|
|
@@ -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, | ||
|
Contributor
Author
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. Until we implement handling webhook events from Stripe, assume all new subscriptions coming from the checkout session are active |
||
| planName: Plan.TEAM, | ||
| }); | ||
| } | ||
joeauyeung marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const response = { | ||
| message: `Team created successfully. We also made user with ID=${checkoutSessionMetadata.ownerId} the owner of this team.`, | ||
| team: schemaTeamReadPublic.parse(finalizedTeam), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
Contributor
Author
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. Same here. If creating a new team then write to the |
||
| 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, | ||
| }); | ||
| } | ||
joeauyeung marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // redirect to team screen | ||
| return NextResponse.redirect( | ||
| new URL(`/settings/teams/${team.id}/onboard-members?event=team_created`, req.nextUrl.origin), | ||
|
|
||
| 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"; | ||
|
|
@@ -94,6 +96,17 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => { | |
| paymentSubscriptionItemId, | ||
| }); | ||
|
|
||
| const internalTeamBillingService = new InternalTeamBilling(organization); | ||
|
Contributor
Author
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. This is the webhook event when a new organization is created and the payment succeeds. Write to the |
||
| 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 }; | ||
|
|
||
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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