diff --git a/server/src/controllers/StripeController.ts b/server/src/controllers/StripeController.ts index b861aff1d..dde5ba24f 100644 --- a/server/src/controllers/StripeController.ts +++ b/server/src/controllers/StripeController.ts @@ -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; @@ -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 => { 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); } }; } diff --git a/server/src/services/system/BillingService.ts b/server/src/services/system/BillingService.ts index f71ffacd9..50ac0c347 100644 --- a/server/src/services/system/BillingService.ts +++ b/server/src/services/system/BillingService.ts @@ -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 => { + 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 => { + 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; diff --git a/server/src/services/system/StripeService.ts b/server/src/services/system/StripeService.ts index f9de3d435..ac63d31ae 100644 --- a/server/src/services/system/StripeService.ts +++ b/server/src/services/system/StripeService.ts @@ -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; };