Skip to content

Commit 8b75bf3

Browse files
devin-ai-integration[bot]ThyMinimalDevhbjORbj
authored
feat: add BillingCacheService with 1-hour TTL for team subscription data (calcom#23934)
* feat: add BillingCacheService with 1-hour TTL for team subscription data - Create BillingCacheService following CalendarsCacheService pattern - Use teamId-based cache keys with 1-hour TTL (3,600,000 ms) - Integrate caching into getBillingData method in BillingService - Add cache invalidation to all webhook handlers: - handleStripeSubscriptionDeleted - handleStripePaymentSuccess - handleStripePaymentFailed - handleStripePaymentPastDue - handleStripeCheckoutEvents - Add cache invalidation to cancelTeamSubscription method - Add RedisModule import to billing module - Add BillingCacheService to billing module providers - Add findTeamByPlatformBillingId method to OrganizationsRepository for cache invalidation Co-Authored-By: morgan@cal.com <morgan@cal.com> * refactor: implement BillingServiceCachingProxy pattern - Extract IBillingService interface with all public methods - Create BillingServiceCachingProxy that implements caching logic - Remove all caching logic from original BillingService - Simplify cache invalidation using billing.id = team.id - Update module to use proxy with proper dependency injection - Update controller to inject proxy interface - Remove unused BillingService import from controller This follows the proxy pattern requested in PR feedback, separating caching concerns from core billing logic for better maintainability. Co-Authored-By: morgan@cal.com <morgan@cal.com> * chore: add e2e for billing check * chore: eslint rule for blocking importing features from appstore, lib, prisma (calcom#23832) * eslint rule * improve * fix * improve msg * chore: fix any types set by devin * fix: add mising expect in test * refactor: move cache methods into BillingServiceCachingProxy - Remove BillingCacheService abstraction as suggested by @keithwillcode - Move cache methods directly into proxy as private methods - Update proxy to inject RedisService directly - Move BillingData type to interface for better type safety - Remove BillingCacheService from module providers - Delete unused billing-cache.service.ts file This simplifies the architecture by removing unnecessary abstraction and follows standard caching proxy patterns. Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: test and legacy starter --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: morgan@cal.com <morgan@cal.com> Co-authored-by: Benny Joo <sldisek783@gmail.com> Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
1 parent 788fbdb commit 8b75bf3

8 files changed

Lines changed: 226 additions & 14 deletions

File tree

apps/api/v2/src/modules/billing/billing.module.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings
22
import { BillingProcessor } from "@/modules/billing/billing.processor";
33
import { BillingRepository } from "@/modules/billing/billing.repository";
44
import { BillingController } from "@/modules/billing/controllers/billing.controller";
5+
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
56
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
67
import { BillingService } from "@/modules/billing/services/billing.service";
78
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
89
import { MembershipsModule } from "@/modules/memberships/memberships.module";
910
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
1011
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
1112
import { PrismaModule } from "@/modules/prisma/prisma.module";
13+
import { RedisModule } from "@/modules/redis/redis.module";
1214
import { StripeModule } from "@/modules/stripe/stripe.module";
1315
import { UsersModule } from "@/modules/users/users.module";
1416
import { BullModule } from "@nestjs/bull";
@@ -18,6 +20,7 @@ import { Module } from "@nestjs/common";
1820
imports: [
1921
PrismaModule,
2022
StripeModule,
23+
RedisModule,
2124
MembershipsModule,
2225
OrganizationsModule,
2326
BullModule.registerQueue({
@@ -32,6 +35,10 @@ import { Module } from "@nestjs/common";
3235
providers: [
3336
BillingConfigService,
3437
BillingService,
38+
{
39+
provide: "IBillingService",
40+
useClass: BillingServiceCachingProxy,
41+
},
3542
BillingRepository,
3643
BillingProcessor,
3744
ManagedOrganizationsBillingService,

apps/api/v2/src/modules/billing/controllers/billing.controller.e2e-spec.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { bootstrap } from "@/app";
22
import { AppModule } from "@/app.module";
3+
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
34
import { PrismaModule } from "@/modules/prisma/prisma.module";
45
import { StripeService } from "@/modules/stripe/stripe.service";
56
import { TokensModule } from "@/modules/tokens/tokens.module";
@@ -16,7 +17,7 @@ import { OrganizationRepositoryFixture } from "test/fixtures/repository/organiza
1617
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
1718
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
1819
import { randomString } from "test/utils/randomString";
19-
import { withApiAuth } from "test/utils/withApiAuth";
20+
import { withNextAuth } from "test/utils/withNextAuth";
2021

2122
import type { Team, PlatformBilling } from "@calcom/prisma/client";
2223

@@ -31,9 +32,8 @@ describe("Platform Billing Controller (e2e)", () => {
3132
let membershipsRepositoryFixture: MembershipRepositoryFixture;
3233
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
3334
let organization: Team;
34-
3535
beforeAll(async () => {
36-
const moduleRef = await withApiAuth(
36+
const moduleRef = await withNextAuth(
3737
userEmail,
3838
Test.createTestingModule({
3939
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
@@ -44,8 +44,10 @@ describe("Platform Billing Controller (e2e)", () => {
4444
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
4545
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
4646
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
47+
4748
organization = await organizationsRepositoryFixture.create({
4849
name: `billing-organization-${randomString()}`,
50+
isPlatform: true,
4951
});
5052

5153
user = await userRepositoryFixture.create({
@@ -87,6 +89,17 @@ describe("Platform Billing Controller (e2e)", () => {
8789
await app.close();
8890
});
8991

92+
it("/billing/webhook (GET) should not get billing plan for org since it's not set yet", () => {
93+
return request(app.getHttpServer())
94+
.get(`/v2/billing/${organization.id}/check`)
95+
96+
.expect(200)
97+
.then(async (res) => {
98+
const data = res.body.data as CheckPlatformBillingResponseDto;
99+
expect(data?.plan).toEqual("FREE");
100+
});
101+
});
102+
90103
it("/billing/webhook (POST) should set billing free plan for org", () => {
91104
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
92105
() =>
@@ -119,6 +132,17 @@ describe("Platform Billing Controller (e2e)", () => {
119132
expect(billing?.plan).toEqual("FREE");
120133
});
121134
});
135+
136+
it("/billing/webhook (GET) should get billing plan for org", () => {
137+
return request(app.getHttpServer())
138+
.get(`/v2/billing/${organization.id}/check`)
139+
.expect(200)
140+
.then(async (res) => {
141+
const data = res.body.data as CheckPlatformBillingResponseDto;
142+
expect(data?.plan).toEqual("FREE");
143+
});
144+
});
145+
122146
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
123147
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
124148
() =>

apps/api/v2/src/modules/billing/controllers/billing.controller.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles
66
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
77
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
88
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
9-
import { BillingService } from "@/modules/billing/services/billing.service";
9+
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
1010
import { StripeService } from "@/modules/stripe/stripe.service";
1111
import {
1212
Body,
@@ -19,6 +19,7 @@ import {
1919
Headers,
2020
HttpCode,
2121
HttpStatus,
22+
Inject,
2223
Logger,
2324
Delete,
2425
ParseIntPipe,
@@ -40,7 +41,7 @@ export class BillingController {
4041
private logger = new Logger("Billing Controller");
4142

4243
constructor(
43-
private readonly billingService: BillingService,
44+
@Inject("IBillingService") private readonly billingService: IBillingService,
4445
public readonly stripeService: StripeService,
4546
private readonly configService: ConfigService<AppConfig>
4647
) {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { PlatformPlan } from "@/modules/billing/types";
2+
import type { StripeService } from "@/modules/stripe/stripe.service";
3+
import Stripe from "stripe";
4+
import { PlatformBilling, Team } from "@calcom/prisma/client";
5+
6+
export type BillingData = {
7+
team: (Team & { platformBilling: PlatformBilling | null }) | null;
8+
status: "valid" | "no_subscription" | "no_billing";
9+
plan: string;
10+
};
11+
12+
export interface IBillingService {
13+
getBillingData(teamId: number): Promise<BillingData>;
14+
createTeamBilling(teamId: number): Promise<string>;
15+
redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string): Promise<string>;
16+
updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string>;
17+
cancelTeamSubscription(teamId: number): Promise<void>;
18+
handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void>;
19+
handleStripePaymentSuccess(event: Stripe.Event): Promise<void>;
20+
handleStripePaymentFailed(event: Stripe.Event): Promise<void>;
21+
handleStripePaymentPastDue(event: Stripe.Event): Promise<void>;
22+
handleStripeCheckoutEvents(event: Stripe.Event): Promise<void>;
23+
handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void>;
24+
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null;
25+
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null;
26+
stripeService: StripeService;
27+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { IBillingService, BillingData } from "@/modules/billing/interfaces/billing-service.interface";
2+
import { BillingService } from "@/modules/billing/services/billing.service";
3+
import { PlatformPlan } from "@/modules/billing/types";
4+
import { RedisService } from "@/modules/redis/redis.service";
5+
import { Injectable } from "@nestjs/common";
6+
import Stripe from "stripe";
7+
8+
export const REDIS_BILLING_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:billing`;
9+
export const BILLING_CACHE_TTL_MS = 3_600_000; // 1 hour
10+
11+
@Injectable()
12+
export class BillingServiceCachingProxy implements IBillingService {
13+
constructor(private readonly billingService: BillingService, private readonly redisService: RedisService) {}
14+
15+
async getBillingData(teamId: number) {
16+
const cachedBillingData = await this.getBillingCache(teamId);
17+
if (cachedBillingData) {
18+
return cachedBillingData;
19+
}
20+
21+
const billingData = await this.billingService.getBillingData(teamId);
22+
await this.setBillingCache(teamId, billingData);
23+
return billingData;
24+
}
25+
26+
private async deleteBillingCache(teamId: number) {
27+
await this.redisService.del(REDIS_BILLING_CACHE_KEY(teamId));
28+
}
29+
30+
private async getBillingCache(teamId: number) {
31+
const cachedResult = await this.redisService.get<BillingData>(REDIS_BILLING_CACHE_KEY(teamId));
32+
return cachedResult;
33+
}
34+
35+
private async setBillingCache(teamId: number, billingData: BillingData): Promise<void> {
36+
await this.redisService.set<BillingData>(REDIS_BILLING_CACHE_KEY(teamId), billingData, {
37+
ttl: BILLING_CACHE_TTL_MS,
38+
});
39+
}
40+
41+
async createTeamBilling(teamId: number): Promise<string> {
42+
return this.billingService.createTeamBilling(teamId);
43+
}
44+
45+
async redirectToSubscribeCheckout(
46+
teamId: number,
47+
plan: PlatformPlan,
48+
customerId?: string
49+
): Promise<string> {
50+
return this.billingService.redirectToSubscribeCheckout(teamId, plan, customerId);
51+
}
52+
53+
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string> {
54+
return this.billingService.updateSubscriptionForTeam(teamId, plan);
55+
}
56+
57+
async cancelTeamSubscription(teamId: number): Promise<void> {
58+
await this.billingService.cancelTeamSubscription(teamId);
59+
await this.deleteBillingCache(teamId);
60+
}
61+
62+
async handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void> {
63+
await this.billingService.handleStripeSubscriptionDeleted(event);
64+
const subscription = event.data.object as Stripe.Subscription;
65+
const teamId = subscription?.metadata?.teamId;
66+
if (teamId) {
67+
await this.deleteBillingCache(Number.parseInt(teamId));
68+
}
69+
}
70+
71+
async handleStripePaymentSuccess(event: Stripe.Event): Promise<void> {
72+
await this.billingService.handleStripePaymentSuccess(event);
73+
const invoice = event.data.object as Stripe.Invoice;
74+
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
75+
if (subscriptionId) {
76+
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
77+
subscriptionId
78+
);
79+
if (teamBilling?.id) {
80+
await this.deleteBillingCache(teamBilling.id);
81+
}
82+
}
83+
}
84+
85+
async handleStripePaymentFailed(event: Stripe.Event): Promise<void> {
86+
await this.billingService.handleStripePaymentFailed(event);
87+
const invoice = event.data.object as Stripe.Invoice;
88+
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
89+
if (subscriptionId) {
90+
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
91+
subscriptionId
92+
);
93+
if (teamBilling?.id) {
94+
await this.deleteBillingCache(teamBilling.id);
95+
}
96+
}
97+
}
98+
99+
async handleStripePaymentPastDue(event: Stripe.Event): Promise<void> {
100+
await this.billingService.handleStripePaymentPastDue(event);
101+
const subscription = event.data.object as Stripe.Subscription;
102+
const subscriptionId = subscription.id;
103+
if (subscriptionId) {
104+
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
105+
subscriptionId
106+
);
107+
if (teamBilling?.id) {
108+
await this.deleteBillingCache(teamBilling.id);
109+
}
110+
}
111+
}
112+
113+
async handleStripeCheckoutEvents(event: Stripe.Event): Promise<void> {
114+
await this.billingService.handleStripeCheckoutEvents(event);
115+
const checkoutSession = event.data.object as Stripe.Checkout.Session;
116+
const teamId = checkoutSession.metadata?.teamId;
117+
if (teamId) {
118+
await this.deleteBillingCache(Number.parseInt(teamId));
119+
}
120+
}
121+
122+
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void> {
123+
return this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
124+
}
125+
126+
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
127+
return this.billingService.getSubscriptionIdFromInvoice(invoice);
128+
}
129+
130+
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null {
131+
return this.billingService.getCustomerIdFromInvoice(invoice);
132+
}
133+
134+
get stripeService() {
135+
return this.billingService.stripeService;
136+
}
137+
}

apps/api/v2/src/modules/billing/services/billing.service.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AppConfig } from "@/config/type";
22
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
33
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
44
import { BillingRepository } from "@/modules/billing/billing.repository";
5+
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
56
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
67
import { PlatformPlan } from "@/modules/billing/types";
78
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
@@ -23,14 +24,14 @@ import { DateTime } from "luxon";
2324
import Stripe from "stripe";
2425

2526
@Injectable()
26-
export class BillingService implements OnModuleDestroy {
27+
export class BillingService implements IBillingService, OnModuleDestroy {
2728
private logger = new Logger("BillingService");
2829
private readonly webAppUrl: string;
2930

3031
constructor(
3132
private readonly teamsRepository: OrganizationsRepository,
3233
public readonly stripeService: StripeService,
33-
private readonly billingRepository: BillingRepository,
34+
public readonly billingRepository: BillingRepository,
3435
private readonly configService: ConfigService<AppConfig>,
3536
private readonly billingConfigService: BillingConfigService,
3637
private readonly usersRepository: UsersRepository,
@@ -43,18 +44,23 @@ export class BillingService implements OnModuleDestroy {
4344

4445
async getBillingData(teamId: number) {
4546
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
47+
4648
if (teamWithBilling?.platformBilling) {
4749
if (!teamWithBilling?.platformBilling.subscriptionId) {
48-
return { team: teamWithBilling, status: "no_subscription", plan: "none" };
50+
return { team: teamWithBilling, status: "no_subscription" as const, plan: "none" };
51+
} else {
52+
return {
53+
team: teamWithBilling,
54+
status: "valid" as const,
55+
plan: teamWithBilling.platformBilling.plan,
56+
};
4957
}
50-
51-
return { team: teamWithBilling, status: "valid", plan: teamWithBilling.platformBilling.plan };
5258
} else {
53-
return { team: teamWithBilling, status: "no_billing", plan: "none" };
59+
return { team: teamWithBilling, status: "no_billing" as const, plan: "none" };
5460
}
5561
}
5662

57-
async createTeamBilling(teamId: number) {
63+
async createTeamBilling(teamId: number): Promise<string> {
5864
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
5965
let customerId = teamWithBilling?.platformBilling?.customerId;
6066

@@ -67,7 +73,7 @@ export class BillingService implements OnModuleDestroy {
6773
});
6874
}
6975

70-
return customerId;
76+
return customerId!;
7177
}
7278

7379
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {

apps/api/v2/src/modules/organizations/index/organizations.repository.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,4 +174,14 @@ export class OrganizationsRepository {
174174
},
175175
});
176176
}
177+
178+
async findTeamByPlatformBillingId(billingId: number) {
179+
return this.dbRead.prisma.team.findFirst({
180+
where: {
181+
platformBilling: {
182+
id: billingId,
183+
},
184+
},
185+
});
186+
}
177187
}

apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export class PlatformBillingRepositoryFixture {
1818
id: orgId,
1919
customerId: `cus_123_${randomString}`,
2020
subscriptionId: `sub_123_${randomString}`,
21-
plan: plan || "STARTER",
21+
plan: plan || "FREE",
2222
},
2323
});
2424
}

0 commit comments

Comments
 (0)