diff --git a/client/src/pages/billing/Billing.tsx b/client/src/pages/billing/Billing.tsx
index 8035f2d10..7e88f5c4a 100644
--- a/client/src/pages/billing/Billing.tsx
+++ b/client/src/pages/billing/Billing.tsx
@@ -20,10 +20,10 @@ const BillingPage = () => {
const selectedPlan = user?.entitlements?.plan || null;
const handlePlanClick = async (planKey: PlanKey) => {
- const result = await post("/billing/subscribe", { plan: planKey });
+ const result = await post("/billing/subscribe", { planKey });
const redirectUrl = result?.data?.redirectUrl;
if (redirectUrl) {
- window.location.href = redirectUrl;
+ window.location = redirectUrl;
}
};
diff --git a/client/src/pages/billing/Success.tsx b/client/src/pages/billing/Success.tsx
new file mode 100644
index 000000000..d689a8081
--- /dev/null
+++ b/client/src/pages/billing/Success.tsx
@@ -0,0 +1,5 @@
+const BillingSuccessPage = () => {
+ return "Great";
+};
+
+export default BillingSuccessPage;
diff --git a/client/src/routes/Router.tsx b/client/src/routes/Router.tsx
index d5436fb73..88ff5c4b7 100644
--- a/client/src/routes/Router.tsx
+++ b/client/src/routes/Router.tsx
@@ -51,9 +51,11 @@ import ExportPage from "@/pages/export/Export";
import NoTeam from "@/pages/errors/NoTeam";
import NotFound from "@/pages/errors/NotFound";
+import BillingPage from "@/pages/billing/Billing";
+import BillingSuccessPage from "@/pages/billing/Success";
+
import RootLayout from "@/components/layouts/RootLayout";
import { ProtectedRoute } from "@/components/protected-route";
-import BillingPage from "@/pages/billing/Billing";
const Router = () => {
return (
@@ -143,6 +145,7 @@ const Router = () => {
} />
} />
} />
+ } />
} />
} />
diff --git a/server/package-lock.json b/server/package-lock.json
index 1d92b49a9..be6523557 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -20,6 +20,7 @@
"mongoose": "8.19.1",
"nodemailer": "7.0.9",
"ping": "1.0.0",
+ "stripe": "^20.0.0",
"super-simple-scheduler": "1.4.6",
"swagger-ui-express": "5.0.1",
"winston": "3.18.3",
@@ -3946,8 +3947,9 @@
"version": "24.7.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
"integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.14.0"
}
@@ -10228,6 +10230,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/stripe": {
+ "version": "20.0.0",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.0.0.tgz",
+ "integrity": "sha512-EaZeWpbJOCcDytdjKSwdrL5BxzbDGNueiCfHjHXlPdBQvLqoxl6AAivC35SPzTmVXJb5duXQlXFGS45H0+e6Gg==",
+ "license": "MIT",
+ "dependencies": {
+ "qs": "^6.11.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "@types/node": ">=16"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/strnum": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
@@ -10594,7 +10616,7 @@
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/unpipe": {
diff --git a/server/package.json b/server/package.json
index 78afff5b4..fcf044273 100644
--- a/server/package.json
+++ b/server/package.json
@@ -41,6 +41,7 @@
"mongoose": "8.19.1",
"nodemailer": "7.0.9",
"ping": "1.0.0",
+ "stripe": "^20.0.0",
"super-simple-scheduler": "1.4.6",
"swagger-ui-express": "5.0.1",
"winston": "3.18.3",
diff --git a/server/src/controllers/BillingController.ts b/server/src/controllers/BillingController.ts
index 38a20a8fb..21c17dc0f 100644
--- a/server/src/controllers/BillingController.ts
+++ b/server/src/controllers/BillingController.ts
@@ -1,7 +1,9 @@
import type { Request, Response, NextFunction } from "express";
-import BillingService, {
- type IBillingService,
-} from "@/services/system/BillingService.js";
+import { type IBillingService } from "@/services/system/BillingService.js";
+import { Plans, type PlanKey } from "@/types/entitlements.js";
+import ApiError from "@/utils/ApiError.js";
+import { config } from "@/config/index.js";
+import { Org } from "@/db/models/index.js";
export interface IBillingController {
listPlans: (req: Request, res: Response, next: NextFunction) => Promise;
@@ -29,10 +31,24 @@ class BillingController implements IBillingController {
subscribePlan = async (req: Request, res: Response, next: NextFunction) => {
try {
- res.json({
- message: "OK",
- data: { redirectUrl: "https://www.google.com" },
+ const user = req.user;
+ if (!user) throw new ApiError("Unauthorized", 401);
+
+ const orgId = req.user?.orgId;
+ if (!orgId) throw new ApiError("Organization not found", 404);
+
+ const planKey: PlanKey | undefined = req.body.planKey;
+ const redirectUrl = await this.billingService.subscribePlan(
+ user.email,
+ orgId,
+ planKey!
+ );
+
+ res.status(200).json({
+ message: "Subscription initiation successful",
+ data: { redirectUrl },
});
+ return;
} catch (error) {
next(error);
}
diff --git a/server/src/middleware/VerifyPermission.ts b/server/src/middleware/VerifyPermission.ts
index 0386fe010..fcc93409d 100644
--- a/server/src/middleware/VerifyPermission.ts
+++ b/server/src/middleware/VerifyPermission.ts
@@ -61,6 +61,8 @@ const verifyOrgPermission = (resourceActions: string[]) => {
return next(new ApiError("User not authenticated", 401));
}
+ console.log(req.user);
+
const orgPermissions = userContext.roles?.orgRole?.permissions || [];
const allowed = hasPermission(orgPermissions, resourceActions);
if (!allowed) {
diff --git a/server/src/routes/billing.ts b/server/src/routes/billing.ts
index cd12fad87..9f05c41fc 100644
--- a/server/src/routes/billing.ts
+++ b/server/src/routes/billing.ts
@@ -1,6 +1,11 @@
import { Router } from "express";
import BillingController from "@/controllers/BillingController.js";
import { verifyToken } from "@/middleware/VerifyToken.js";
+import { validateBody } from "@/middleware/validation.js";
+import { subscribePlanSchema } from "@/validation/index.js";
+import { verifyOrgPermission } from "@/middleware/VerifyPermission.js";
+import { PERMISSIONS } from "@/types/permissions.js";
+import { addUserContext } from "@/middleware/AddUserContext.js";
class BillingRoutes {
private router;
@@ -13,7 +18,14 @@ class BillingRoutes {
initRoutes = () => {
this.router.get("/plans", verifyToken, this.controller.listPlans);
- this.router.post("/subscribe", verifyToken, this.controller.subscribePlan);
+ this.router.post(
+ "/subscribe",
+ verifyToken,
+ addUserContext,
+ verifyOrgPermission([PERMISSIONS.billing.all]),
+ validateBody(subscribePlanSchema),
+ this.controller.subscribePlan
+ );
};
getRouter() {
diff --git a/server/src/services/system/BillingService.ts b/server/src/services/system/BillingService.ts
index 617aa3e3e..10cfa95f2 100644
--- a/server/src/services/system/BillingService.ts
+++ b/server/src/services/system/BillingService.ts
@@ -1,20 +1,91 @@
-import { Plans, type Entitlements, type PlanKey } from "@/types/entitlements.js";
+import {
+ Plans,
+ type Entitlements,
+ type PlanKey,
+} from "@/types/entitlements.js";
+import { config } from "@/config/index.js";
+import { Org } from "@/db/models/index.js";
+import ApiError from "@/utils/ApiError.js";
+import Stripe from "stripe";
export interface IBillingService {
listPlans(): Promise;
+ subscribePlan(
+ email: string,
+ orgId: string,
+ planKey: PlanKey
+ ): Promise;
}
const SERVICE_NAME = "BillingService";
class BillingService implements IBillingService {
public SERVICE_NAME: string;
+ private stripeClient: Stripe;
+
constructor() {
this.SERVICE_NAME = SERVICE_NAME;
+ this.stripeClient = new Stripe(config.STRIPE_SECRET);
}
async listPlans(): Promise {
const order: PlanKey[] = ["free", "pro", "business", "enterprise"];
return order.map((key) => Plans[key]);
}
+
+ subscribePlan = async (email: string, orgId: string, planKey: PlanKey) => {
+ if (!planKey) {
+ throw new ApiError("planKey is required", 400);
+ }
+
+ if (planKey && !Plans[planKey]) {
+ throw new ApiError("Invalid planKey", 400);
+ }
+
+ // Ensure billing customer exists for this org
+ const org = await Org.findById(orgId);
+ if (!org) throw new ApiError("Organization not found", 404);
+ if (!org.billingCustomerId) {
+ const customer = await this.stripeClient.customers.create({
+ email,
+ metadata: { orgId: String(org._id) },
+ });
+ org.billingCustomerId = customer.id;
+ await org.save();
+ }
+
+ const prices = await this.stripeClient.prices.list({
+ lookup_keys: [planKey],
+ expand: ["data.product"],
+ });
+
+ let price = prices.data[0]?.id;
+ if (!price) {
+ throw new ApiError("Price not found for the selected plan", 404);
+ }
+
+ const successUrl = `${config.ORIGIN}/billing/success?plan=${planKey}`;
+ const cancelUrl = `${config.ORIGIN}/billing/`;
+ 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 === null) {
+ throw new ApiError("Failed to create checkout session", 500);
+ }
+ return session.url;
+ };
}
export default BillingService;
diff --git a/server/src/types/entitlements.ts b/server/src/types/entitlements.ts
index f69b489a8..e8547fdbb 100644
--- a/server/src/types/entitlements.ts
+++ b/server/src/types/entitlements.ts
@@ -1,4 +1,12 @@
-export type PlanKey = "free" | "pro" | "business" | "enterprise" | "unlimited";
+export const PlanKeys = [
+ "free",
+ "pro",
+ "business",
+ "enterprise",
+ "unlimited",
+] as const;
+
+export type PlanKey = (typeof PlanKeys)[number];
export interface Entitlements {
plan: PlanKey;
diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts
index a2d4b4f4c..39c011914 100644
--- a/server/src/validation/index.ts
+++ b/server/src/validation/index.ts
@@ -6,6 +6,7 @@ import {
} from "@/db/models/index.js";
import { z } from "zod";
import mongoose from "mongoose";
+import { PlanKeys, type PlanKey } from "@/types/entitlements.js";
export const registerSchema = z.object({
email: z.email({ message: "Invalid email address" }),
@@ -127,6 +128,10 @@ export const monitorImportSchema = z.object({
.min(1, { message: "Provide at least one monitor" }),
});
+export const subscribePlanSchema = z.object({
+ planKey: z.enum(PlanKeys).optional(),
+});
+
export type MonitorImportPayload = z.infer;
export const monitorPatchSchema = monitorSchema