* feat: add free platform plan * chore: handle payment success / fail overdue * chore: refactor and add tests
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import { AppConfig } from "@/config/type";
|
|
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
|
import { BillingRepository } from "@/modules/billing/billing.repository";
|
|
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
|
import { PlatformPlan } from "@/modules/billing/types";
|
|
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
|
import { StripeService } from "@/modules/stripe/stripe.service";
|
|
import { InjectQueue } from "@nestjs/bull";
|
|
import {
|
|
Injectable,
|
|
InternalServerErrorException,
|
|
Logger,
|
|
NotFoundException,
|
|
OnModuleDestroy,
|
|
} from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { Queue } from "bull";
|
|
import { DateTime } from "luxon";
|
|
import Stripe from "stripe";
|
|
|
|
@Injectable()
|
|
export class BillingService implements OnModuleDestroy {
|
|
private logger = new Logger("BillingService");
|
|
private readonly webAppUrl: string;
|
|
|
|
constructor(
|
|
private readonly teamsRepository: OrganizationsRepository,
|
|
public readonly stripeService: StripeService,
|
|
private readonly billingRepository: BillingRepository,
|
|
private readonly configService: ConfigService<AppConfig>,
|
|
private readonly billingConfigService: BillingConfigService,
|
|
@InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue
|
|
) {
|
|
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
|
|
}
|
|
|
|
async getBillingData(teamId: number) {
|
|
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
|
if (teamWithBilling?.platformBilling) {
|
|
if (!teamWithBilling?.platformBilling.subscriptionId) {
|
|
return { team: teamWithBilling, status: "no_subscription", plan: "none" };
|
|
}
|
|
|
|
return { team: teamWithBilling, status: "valid", plan: teamWithBilling.platformBilling.plan };
|
|
} else {
|
|
return { team: teamWithBilling, status: "no_billing", plan: "none" };
|
|
}
|
|
}
|
|
|
|
async createTeamBilling(teamId: number) {
|
|
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
|
let customerId = teamWithBilling?.platformBilling?.customerId;
|
|
|
|
if (!teamWithBilling?.platformBilling) {
|
|
customerId = await this.teamsRepository.createNewBillingRelation(teamId);
|
|
|
|
this.logger.log("Team had no Stripe Customer ID, created one for them.", {
|
|
id: teamId,
|
|
stripeId: customerId,
|
|
});
|
|
}
|
|
|
|
return customerId;
|
|
}
|
|
|
|
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
|
|
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
|
|
customer: customerId,
|
|
line_items: [
|
|
{
|
|
price: this.billingConfigService.get(plan)?.base,
|
|
quantity: 1,
|
|
},
|
|
{
|
|
price: this.billingConfigService.get(plan)?.overage,
|
|
},
|
|
],
|
|
success_url: `${this.webAppUrl}/settings/platform/`,
|
|
cancel_url: `${this.webAppUrl}/settings/platform/`,
|
|
mode: "subscription",
|
|
metadata: {
|
|
teamId: teamId.toString(),
|
|
plan: plan.toString(),
|
|
},
|
|
currency: "usd",
|
|
subscription_data: {
|
|
metadata: {
|
|
teamId: teamId.toString(),
|
|
plan: plan.toString(),
|
|
},
|
|
},
|
|
allow_promotion_codes: true,
|
|
});
|
|
|
|
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
|
|
|
|
return url;
|
|
}
|
|
|
|
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
|
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
|
const customerId = teamWithBilling?.platformBilling?.customerId;
|
|
|
|
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
|
|
customer: customerId,
|
|
success_url: `${this.webAppUrl}/settings/platform/`,
|
|
cancel_url: `${this.webAppUrl}/settings/platform/plans`,
|
|
mode: "setup",
|
|
metadata: {
|
|
teamId: teamId.toString(),
|
|
plan: plan.toString(),
|
|
},
|
|
currency: "usd",
|
|
});
|
|
|
|
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
|
|
|
|
return url;
|
|
}
|
|
|
|
async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
|
|
const billingCycleStart = DateTime.now().get("day");
|
|
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
|
|
|
|
return this.billingRepository.updateTeamBilling(
|
|
teamId,
|
|
billingCycleStart,
|
|
billingCycleEnd,
|
|
plan,
|
|
subscriptionId
|
|
);
|
|
}
|
|
|
|
async handleStripeSubscriptionDeleted(event: Stripe.Event) {
|
|
const subscription = event.data.object as Stripe.Subscription;
|
|
const teamId = subscription?.metadata?.teamId;
|
|
const plan = PlatformPlan[subscription?.metadata?.plan?.toUpperCase() as keyof typeof PlatformPlan];
|
|
if (teamId && plan) {
|
|
const currentBilling = await this.billingRepository.getBillingForTeam(Number.parseInt(teamId));
|
|
if (currentBilling?.subscriptionId === subscription.id) {
|
|
await this.billingRepository.deleteBilling(currentBilling.id);
|
|
this.logger.log(`Stripe Subscription deleted`, {
|
|
customerId: currentBilling.customerId,
|
|
subscriptionId: currentBilling.subscriptionId,
|
|
teamId,
|
|
});
|
|
return;
|
|
}
|
|
this.logger.log("No platform billing found.");
|
|
return;
|
|
}
|
|
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
|
|
return;
|
|
}
|
|
|
|
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
|
if (typeof invoice.subscription === "string") {
|
|
return invoice.subscription;
|
|
} else if (invoice.subscription && typeof invoice.subscription === "object") {
|
|
return invoice.subscription.id;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
|
if (typeof invoice.customer === "string") {
|
|
return invoice.customer;
|
|
} else if (invoice.customer && typeof invoice.customer === "object") {
|
|
return invoice.customer.id;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async handleStripePaymentSuccess(event: Stripe.Event) {
|
|
const invoice = event.data.object as Stripe.Invoice;
|
|
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
|
const customerId = this.getCustomerIdFromInvoice(invoice);
|
|
if (subscriptionId && customerId) {
|
|
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false);
|
|
}
|
|
}
|
|
|
|
async handleStripePaymentFailed(event: Stripe.Event) {
|
|
const invoice = event.data.object as Stripe.Invoice;
|
|
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
|
const customerId = this.getCustomerIdFromInvoice(invoice);
|
|
if (subscriptionId && customerId) {
|
|
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, true);
|
|
}
|
|
}
|
|
|
|
async handleStripeCheckoutEvents(event: Stripe.Event) {
|
|
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
|
|
|
if (!checkoutSession.metadata?.teamId) {
|
|
return;
|
|
}
|
|
|
|
const teamId = Number.parseInt(checkoutSession.metadata.teamId);
|
|
const plan = checkoutSession.metadata.plan;
|
|
if (!plan || !teamId) {
|
|
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
|
|
return;
|
|
}
|
|
|
|
if (checkoutSession.mode === "subscription") {
|
|
await this.setSubscriptionForTeam(
|
|
teamId,
|
|
checkoutSession.subscription as string,
|
|
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
|
);
|
|
}
|
|
|
|
if (checkoutSession.mode === "setup") {
|
|
await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
|
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
|
|
|
if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) {
|
|
throw new NotFoundException("Team plan not found");
|
|
}
|
|
|
|
const existingUserSubscription = await this.stripeService
|
|
.getStripe()
|
|
.subscriptions.retrieve(teamWithBilling?.platformBilling?.subscriptionId);
|
|
const currentLicensedItem = existingUserSubscription.items.data.find(
|
|
(item) => item.price?.recurring?.usage_type === "licensed"
|
|
);
|
|
const currentOverageItem = existingUserSubscription.items.data.find(
|
|
(item) => item.price?.recurring?.usage_type === "metered"
|
|
);
|
|
|
|
if (!currentLicensedItem) {
|
|
throw new NotFoundException("There is no licensed item present in the subscription");
|
|
}
|
|
|
|
if (!currentOverageItem) {
|
|
throw new NotFoundException("There is no overage item present in the subscription");
|
|
}
|
|
|
|
await this.stripeService
|
|
.getStripe()
|
|
.subscriptions.update(teamWithBilling?.platformBilling?.subscriptionId, {
|
|
items: [
|
|
{
|
|
id: currentLicensedItem.id,
|
|
price: this.billingConfigService.get(plan)?.base,
|
|
},
|
|
{
|
|
id: currentOverageItem.id,
|
|
price: this.billingConfigService.get(plan)?.overage,
|
|
clear_usage: false,
|
|
},
|
|
],
|
|
billing_cycle_anchor: "now",
|
|
proration_behavior: "create_prorations",
|
|
});
|
|
|
|
await this.setSubscriptionForTeam(
|
|
teamId,
|
|
teamWithBilling?.platformBilling?.subscriptionId,
|
|
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
|
);
|
|
}
|
|
/**
|
|
*
|
|
* Adds a job to the queue to increment usage of a stripe subscription.
|
|
* we delay the job until the booking starts.
|
|
* the delay ensure we can adapt to cancel / reschedule.
|
|
*/
|
|
async increaseUsageByUserId(
|
|
userId: number,
|
|
booking: {
|
|
uid: string;
|
|
startTime: Date;
|
|
fromReschedule?: string | null;
|
|
}
|
|
) {
|
|
const { uid, startTime, fromReschedule } = booking;
|
|
|
|
const delay = startTime.getTime() - Date.now();
|
|
if (fromReschedule) {
|
|
// cancel the usage increment job for the booking that is being rescheduled
|
|
await this.cancelUsageByBookingUid(fromReschedule);
|
|
this.logger.log(`Cancelled usage increment job for rescheduled booking uid: ${fromReschedule}`);
|
|
}
|
|
await this.billingQueue.add(
|
|
INCREMENT_JOB,
|
|
{
|
|
userId,
|
|
} satisfies IncrementJobDataType,
|
|
{ delay: delay > 0 ? delay : 0, jobId: `increment-${uid}`, removeOnComplete: true }
|
|
);
|
|
this.logger.log(`Added stripe usage increment job for booking ${uid} and user ${userId}`);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* Cancels the usage increment job for a booking when it is cancelled.
|
|
* Removing an attendee from a booking does not cancel the usage increment job.
|
|
*/
|
|
async cancelUsageByBookingUid(bookingUid: string) {
|
|
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
|
|
if (job) {
|
|
await job.remove();
|
|
this.logger.log(`Removed increment job for cancelled booking ${bookingUid}`);
|
|
}
|
|
}
|
|
|
|
async onModuleDestroy() {
|
|
try {
|
|
await this.billingQueue.close();
|
|
} catch (err) {
|
|
this.logger.error(err);
|
|
}
|
|
}
|
|
}
|