billing tweaks
This commit is contained in:
1 parent
a9a598941f
commit
07efd08837
3 files changed
+117
-50
No files matched your search
@@ -1,6 +1,5 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { StripeService } from "@/services/index.js";
|
||||
import { config } from "@/config/index.js";
|
||||
|
||||
export interface IStripeController {
|
||||
webhook: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
||||
@@ -12,18 +11,40 @@ class StripeController implements IStripeController {
|
||||
constructor(stripeService: StripeService) {
|
||||
this.stripeService = stripeService;
|
||||
}
|
||||
webhook = async (req: Request, res: Response, next: NextFunction) => {
|
||||
webhook = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
try {
|
||||
let event = req.body;
|
||||
const event = req.body;
|
||||
const signature = req.headers["stripe-signature"];
|
||||
if (!signature || Array.isArray(signature)) {
|
||||
throw new Error("Stripe signature is missing or invalid");
|
||||
res
|
||||
.status(400)
|
||||
.json({ message: "Stripe signature is missing or invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.stripeService.webhook(event, signature);
|
||||
await this.stripeService.webhook(event, signature);
|
||||
res.status(200).send({ received: true });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
return;
|
||||
} catch (error: any) {
|
||||
const msg = String(error?.message ?? "");
|
||||
const isSignatureIssue =
|
||||
msg.includes(
|
||||
"Webhook payload must be provided as a string or a Buffer"
|
||||
) ||
|
||||
msg.includes("No signatures found matching the expected signature") ||
|
||||
msg.includes("Stripe signature is missing or invalid") ||
|
||||
msg.includes("Error while decoding the event") ||
|
||||
msg.includes("Invalid payload");
|
||||
|
||||
if (isSignatureIssue) {
|
||||
res.status(400).json({ message: "Invalid Stripe webhook signature" });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +34,56 @@ class BillingService implements IBillingService {
|
||||
return order.map((key) => Plans[key]);
|
||||
};
|
||||
|
||||
private buildSuccessUrl = (planKey: PlanKey) =>
|
||||
`${config.ORIGIN}/billing/success?plan=${planKey}`;
|
||||
|
||||
private updateExistingSubscription = async (
|
||||
customerId: string,
|
||||
subscriptionId: string,
|
||||
successUrl: string
|
||||
): Promise<string> => {
|
||||
const session = await this.stripeClient.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: successUrl,
|
||||
flow_data: {
|
||||
type: "subscription_update",
|
||||
subscription_update: {
|
||||
subscription: subscriptionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return session.url as string;
|
||||
};
|
||||
|
||||
private createNewSubscription = async (
|
||||
orgId: string,
|
||||
customerId: string,
|
||||
priceId: string,
|
||||
successUrl: string,
|
||||
cancelUrl: string
|
||||
): Promise<string> => {
|
||||
const session = await this.stripeClient.checkout.sessions.create({
|
||||
billing_address_collection: "auto",
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: "subscription",
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
customer: customerId,
|
||||
client_reference_id: orgId,
|
||||
subscription_data: { metadata: { orgId } },
|
||||
metadata: { orgId },
|
||||
});
|
||||
if (!session.url) {
|
||||
throw new ApiError("Failed to create checkout session", 500);
|
||||
}
|
||||
return session.url;
|
||||
};
|
||||
|
||||
subscribePlan = async (email: string, orgId: string, planKey: PlanKey) => {
|
||||
if (!planKey) {
|
||||
throw new ApiError("planKey is required", 400);
|
||||
@@ -45,6 +95,8 @@ class BillingService implements IBillingService {
|
||||
|
||||
const org = await Org.findById(orgId);
|
||||
if (!org) throw new ApiError("Organization not found", 404);
|
||||
|
||||
// If the org does not have a Stripe customer ID, create one
|
||||
if (!org.billingCustomerId) {
|
||||
const customer = await this.stripeClient.customers.create({
|
||||
email,
|
||||
@@ -54,6 +106,7 @@ class BillingService implements IBillingService {
|
||||
await org.save();
|
||||
}
|
||||
|
||||
// Look up price for the selected plan
|
||||
const prices = await this.stripeClient.prices.list({
|
||||
lookup_keys: [planKey],
|
||||
expand: ["data.product"],
|
||||
@@ -65,48 +118,29 @@ class BillingService implements IBillingService {
|
||||
}
|
||||
|
||||
let redirectUrl: string;
|
||||
const successUrl = `${config.ORIGIN}/billing/success?plan=${planKey}`;
|
||||
const successUrl = this.buildSuccessUrl(planKey);
|
||||
const cancelUrl = `${config.ORIGIN}/billing/`;
|
||||
|
||||
const subscriptionId = org.subscriptionId;
|
||||
|
||||
// Updating an existing subscription
|
||||
if (subscriptionId) {
|
||||
const session = await this.stripeClient.billingPortal.sessions.create({
|
||||
customer: String(org.billingCustomerId),
|
||||
return_url: successUrl,
|
||||
flow_data: {
|
||||
type: "subscription_update",
|
||||
subscription_update: {
|
||||
subscription: subscriptionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
redirectUrl = session.url;
|
||||
redirectUrl = await this.updateExistingSubscription(
|
||||
String(org.billingCustomerId),
|
||||
subscriptionId,
|
||||
successUrl
|
||||
);
|
||||
}
|
||||
|
||||
// Creating a new subscription
|
||||
else {
|
||||
const session = await this.stripeClient.checkout.sessions.create({
|
||||
billing_address_collection: "auto",
|
||||
line_items: [
|
||||
{
|
||||
price,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: "subscription",
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
customer: String(org.billingCustomerId),
|
||||
client_reference_id: String(org._id),
|
||||
subscription_data: { metadata: { orgId: String(org._id) } },
|
||||
metadata: { orgId: String(org._id) },
|
||||
});
|
||||
if (!session.url) {
|
||||
throw new ApiError("Failed to create checkout session", 500);
|
||||
}
|
||||
redirectUrl = session.url;
|
||||
redirectUrl = await this.createNewSubscription(
|
||||
String(org._id),
|
||||
String(org.billingCustomerId),
|
||||
price,
|
||||
successUrl,
|
||||
cancelUrl
|
||||
);
|
||||
}
|
||||
|
||||
return redirectUrl;
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
NotificationChannel,
|
||||
} from "@/db/models/index.js";
|
||||
import { PlanKey, Plans } from "@/types/entitlements.js";
|
||||
import { getChildLogger } from "@/logger/Logger.js";
|
||||
|
||||
const SERVICE_NAME = "StripeService";
|
||||
const logger = getChildLogger(SERVICE_NAME);
|
||||
|
||||
interface IStripeService {
|
||||
webhook: (
|
||||
@@ -118,34 +120,46 @@ class StripeService implements IStripeService {
|
||||
};
|
||||
|
||||
webhook = async (event: string | Buffer, signature: string) => {
|
||||
logger.debug("Processing Stripe webhook event");
|
||||
const whSecret = config.STRIPE_WEBHOOK_SECRET;
|
||||
if (!whSecret || whSecret === "not_set") {
|
||||
throw new Error("Stripe webhook secret is not set");
|
||||
}
|
||||
|
||||
const stripeEvent = this.stripe.webhooks.constructEvent(
|
||||
event,
|
||||
signature,
|
||||
whSecret
|
||||
);
|
||||
let stripeEvent: Stripe.Event | null = null;
|
||||
|
||||
try {
|
||||
stripeEvent = this.stripe.webhooks.constructEvent(
|
||||
event,
|
||||
signature,
|
||||
whSecret
|
||||
);
|
||||
} catch (err: any) {
|
||||
logger.error(err);
|
||||
}
|
||||
|
||||
if (!stripeEvent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let subscription: Stripe.Subscription | null;
|
||||
let status: Stripe.Subscription.Status;
|
||||
logger.debug(`Received Stripe webhook event: ${stripeEvent.type}`);
|
||||
let status: string;
|
||||
switch (stripeEvent.type) {
|
||||
case "customer.subscription.deleted":
|
||||
subscription = stripeEvent.data.object;
|
||||
status = subscription.status;
|
||||
this.handleCancel(subscription);
|
||||
await this.handleCancel(subscription);
|
||||
break;
|
||||
case "customer.subscription.created":
|
||||
subscription = stripeEvent.data.object;
|
||||
status = subscription.status;
|
||||
this.handleSubscription(subscription);
|
||||
await this.handleSubscription(subscription);
|
||||
break;
|
||||
case "customer.subscription.updated":
|
||||
subscription = stripeEvent.data.object;
|
||||
status = subscription.status;
|
||||
this.handleSubscription(subscription);
|
||||
await this.handleSubscription(subscription);
|
||||
break;
|
||||
|
||||
case "checkout.session.completed":
|
||||
@@ -155,13 +169,11 @@ class StripeService implements IStripeService {
|
||||
|
||||
case "invoice.payment_succeeded":
|
||||
subscription = null;
|
||||
console.log("Invoice payment succeeded.");
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unexpected event type
|
||||
subscription = null;
|
||||
console.log(`Unhandled event type ${stripeEvent.type}.`);
|
||||
}
|
||||
return subscription;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user