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