Skip to content

Commit

Permalink
feat: add free platform plan (#17581)
Browse files Browse the repository at this point in the history
* feat: add free platform plan

* chore: handle payment success / fail overdue

* chore: refactor and add tests
  • Loading branch information
ThyMinimalDev authored Nov 12, 2024
1 parent 95b833b commit 6889592
Show file tree
Hide file tree
Showing 13 changed files with 425 additions and 73 deletions.
8 changes: 4 additions & 4 deletions apps/api/v2/src/modules/billing/billing.processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ export class BillingProcessor {
return;
}

const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
billingSubscription.subscriptionId
);
const stripeSubscription = await this.stripeService
.getStripe()
.subscriptions.retrieve(billingSubscription.subscriptionId);
if (!stripeSubscription?.id) {
this.logger.error(`Failed to retrieve stripe subscription (${billingSubscription.subscriptionId})`, {
teamId,
Expand All @@ -69,7 +69,7 @@ export class BillingProcessor {
return;
}

await this.stripeService.stripe.subscriptionItems.createUsageRecord(meteredItem.id, {
await this.stripeService.getStripe().subscriptionItems.createUsageRecord(meteredItem.id, {
action: "increment",
quantity: 1,
timestamp: "now",
Expand Down
32 changes: 31 additions & 1 deletion apps/api/v2/src/modules/billing/billing.repository.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { PlatformPlan } from "@/modules/billing/types";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";

@Injectable()
export class BillingRepository {
private readonly logger = new Logger("BillingRepository");
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}

getBillingForTeam = (teamId: number) =>
Expand All @@ -30,6 +31,35 @@ export class BillingRepository {
billingCycleEnd: billingEnd,
subscriptionId,
plan: plan.toString(),
overdue: false,
},
});
}

async updateBillingOverdue(subId: string, cusId: string, overdue: boolean) {
try {
return this.dbWrite.prisma.platformBilling.update({
where: {
subscriptionId: subId,
customerId: cusId,
},
data: {
overdue,
},
});
} catch (err) {
this.logger.error("Could not update billing overdue", {
subId,
cusId,
err,
});
}
}

async deleteBilling(id: number) {
return this.dbWrite.prisma.platformBilling.delete({
where: {
id,
},
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { UserWithProfile } from "@/modules/users/users.repository";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import Stripe from "stripe";
import * as request from "supertest";
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";

import { Team, PlatformOAuthClient, PlatformBilling } from "@calcom/prisma/client";

describe("Platform Billing Controller (e2e)", () => {
let app: INestApplication;
const userEmail = "platform-billing-controller-e2e@api.com";
let user: UserWithProfile;
let billing: PlatformBilling;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let organization: Team;

beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
organization = await organizationsRepositoryFixture.create({ name: "platform billing" });

user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});

await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: userEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
team: { connect: { id: organization.id } },
user: { connect: { id: user.id } },
accepted: true,
});

billing = await platformBillingRepositoryFixture.create(organization.id);

app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);

await app.init();
});

afterAll(async () => {
userRepositoryFixture.deleteByEmail(user.email);
await app.close();
});

it("/billing/webhook (POST) should set billing free plan for org", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "checkout.session.completed",
data: {
object: {
metadata: {
teamId: organization.id,
plan: "FREE",
},
mode: "subscription",
},
},
};
},
},
} as unknown as Stripe)
);

return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing?.plan).toEqual("FREE");
});
});
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "invoice.payment_failed",
data: {
object: {
customer: billing?.customerId,
subscription: billing?.subscriptionId,
},
},
};
},
},
} as unknown as Stripe)
);

return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing?.overdue).toEqual(true);
});
});

it("/billing/webhook (POST) success payment should set billing free plan to not overdue", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "invoice.payment_succeeded",
data: {
object: {
customer: billing?.customerId,
subscription: billing?.subscriptionId,
},
},
};
},
},
} as unknown as Stripe)
);

return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing?.overdue).toEqual(false);
});
});

it("/billing/webhook (POST) should delete subscription", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "customer.subscription.deleted",
data: {
object: {
metadata: {
teamId: organization.id,
plan: "FREE",
},
id: billing?.subscriptionId,
},
},
};
},
},
} as unknown as Stripe)
);

return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing).toBeNull();
});
});
});
25 changes: 19 additions & 6 deletions apps/api/v2/src/modules/billing/controllers/billing.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,26 @@ export class BillingController {
@Req() request: Request,
@Headers("stripe-signature") stripeSignature: string
): Promise<ApiResponse> {
const event = await this.billingService.stripeService.stripe.webhooks.constructEventAsync(
request.body,
stripeSignature,
this.stripeWhSecret
);
const event = await this.billingService.stripeService
.getStripe()
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);

await this.billingService.createOrUpdateStripeSubscription(event);
switch (event.type) {
case "checkout.session.completed":
await this.billingService.handleStripeCheckoutEvents(event);
break;
case "customer.subscription.deleted":
await this.billingService.handleStripeSubscriptionDeleted(event);
break;
case "invoice.payment_failed":
await this.billingService.handleStripePaymentFailed(event);
break;
case "invoice.payment_succeeded":
await this.billingService.handleStripePaymentSuccess(event);
break;
default:
break;
}

return {
status: "success",
Expand Down
Loading

0 comments on commit 6889592

Please sign in to comment.