create customer
This commit is contained in:
1 parent
7b207f2ccb
commit
01c0e9ef4d
11 files changed
+159
-14
No files matched your search
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const BillingSuccessPage = () => {
|
||||
return "Great";
|
||||
};
|
||||
|
||||
export default BillingSuccessPage;
|
||||
@@ -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 />} />
|
||||
|
||||
Generated
+24
-2
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user