billing tweaks

This commit is contained in:
Alex Holliday committed 2025-12-02 19:12:19 +00:00
1 parent a9a598941f
commit 07efd08837
3 files changed
+117 -50

No files matched your search

+28 -7
View File
@@ -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);
}
};
}
+66 -32
View File
@@ -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;
+23 -11
View File
@@ -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;
};