create customer

This commit is contained in:
Alex Holliday committed 2025-12-01 03:45:57 +00:00
1 parent 7b207f2ccb
commit 01c0e9ef4d
11 files changed
+159 -14

No files matched your search

+2 -2
View File
@@ -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;
}
};
+5
View File
@@ -0,0 +1,5 @@
const BillingSuccessPage = () => {
return "Great";
};
export default BillingSuccessPage;
+4 -1
View File
@@ -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 = () => {
<Route path="profile" element={<ProfilePage />} />
<Route path="export" element={<ExportPage />} />
<Route path="billing" element={<BillingPage />} />
<Route path="billing/success" element={<BillingSuccessPage />} />
</Route>
<Route path="no-team" element={<NoTeam />} />
<Route path="*" element={<NotFound />} />
+24 -2
View File
@@ -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": {
+1
View File
@@ -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",
+22 -6
View File
@@ -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<void>;
@@ -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);
}
@@ -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) {
+13 -1
View File
@@ -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() {
+72 -1
View File
@@ -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<Entitlements[]>;
subscribePlan(
email: string,
orgId: string,
planKey: PlanKey
): Promise<string>;
}
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<Entitlements[]> {
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;
+9 -1
View File
@@ -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;
+5
View File
@@ -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<typeof monitorImportSchema>;
export const monitorPatchSchema = monitorSchema