diff --git a/server/src/config/index.ts b/server/src/config/index.ts index c634a449e..c5d64ab60 100644 --- a/server/src/config/index.ts +++ b/server/src/config/index.ts @@ -13,6 +13,7 @@ export interface IEnvConfig { SMTP_PORT: number; SMTP_USER: string; SMTP_PASS: string; + STRIPE_SECRET: string; } dotenv.config(); @@ -30,4 +31,5 @@ export const config: IEnvConfig = { SMTP_PORT: process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT, 10) : -1, SMTP_USER: process.env.SMTP_USER || "not_set", SMTP_PASS: process.env.SMTP_PASS || "not_set", + STRIPE_SECRET: process.env.STRIPE_SECRET || "not_set", }; diff --git a/server/src/controllers/MonitorController.ts b/server/src/controllers/MonitorController.ts index f158d78c6..a2ec60bff 100644 --- a/server/src/controllers/MonitorController.ts +++ b/server/src/controllers/MonitorController.ts @@ -400,7 +400,7 @@ class MonitorController { throw new ApiError("Entitlements not found", 500); } - const result: any = await this.monitorService.import( + const result = await this.monitorService.import( orgId, teamId, userContext.sub, @@ -414,7 +414,8 @@ class MonitorController { UNEXPECTED: `Import failed unexpectedly`, }; - const message = result?.message || defaultMessageMap[result?.code] || "Import completed"; + const message = + result?.message || defaultMessageMap[result?.code] || "Import completed"; res.status(result?.status ?? 200).json({ message, data: result }); try { diff --git a/server/src/controllers/StripeController.ts b/server/src/controllers/StripeController.ts new file mode 100644 index 000000000..fb3874950 --- /dev/null +++ b/server/src/controllers/StripeController.ts @@ -0,0 +1,50 @@ +import type { Request, Response, NextFunction } from "express"; + +export interface IStripeController { + webhook: (req: Request, res: Response, next: NextFunction) => Promise; +} + +class StripeController implements IStripeController { + webhook = async (req: Request, res: Response, next: NextFunction) => { + try { + const sig = req.headers["stripe-signature"] as string | undefined; + const raw = req.body as unknown; // Buffer when express.raw is used + + let event: any = undefined; + if (Buffer.isBuffer(raw)) { + const json = raw.toString("utf8"); + try { + event = JSON.parse(json); + } catch (_e) { + res.status(401).json({ message: "Unauthorized" }); + } + } else if (typeof raw === "string") { + try { + event = JSON.parse(raw); + } catch (_e) { + res.status(400).json({ message: "Invalid JSON payload" }); + } + } else if (raw && typeof raw === "object") { + event = raw; + } + + // Basic routing by event type (no-ops for now) + const type = event?.type; + switch (type) { + case "checkout.session.completed": + case "customer.subscription.updated": + case "customer.subscription.deleted": + case "invoice.payment_succeeded": + case "invoice.payment_failed": + default: + break; + } + + res.status(200); + } catch (error) { + throw error; + } + }; +} + +export default StripeController; diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index ba1e2f36f..8aa36299a 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -14,3 +14,4 @@ export { default as RecoveryController } from "./RecoveryController.js"; export { default as ProfileController } from "./ProfileController.js"; export { default as IncidentsController } from "./IncidentsController.js"; export { default as SettingsController } from "./SettingsController.js"; +export { default as StripeController } from "./StripeController.js"; diff --git a/server/src/index.ts b/server/src/index.ts index 8623fc803..a2ffc167c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -18,6 +18,13 @@ const createApp = async () => { const services = await initServices(); const controllers = initControllers(services); const app = express(); + // Stripe webhook requires raw body for signature verification; mount it first + app.post( + "/api/v1/stripe/webhook", + express.raw({ type: "application/json" }) as any, + // Defer to router handler registered later; this preserves raw body + (req, res, next) => next() + ); app.use(express.json()); app.use(cookieParser()); app.use( diff --git a/server/src/init/controllers.ts b/server/src/init/controllers.ts index b3200b576..020847bf0 100644 --- a/server/src/init/controllers.ts +++ b/server/src/init/controllers.ts @@ -15,6 +15,7 @@ import { ProfileController, IncidentsController, SettingsController, + StripeController, } from "@/controllers/index.js"; export const initControllers = (services: any) => { @@ -74,5 +75,7 @@ export const initControllers = (services: any) => { services.notificationService ); + controllers.stripeController = new StripeController(); + return controllers; }; diff --git a/server/src/init/routes.ts b/server/src/init/routes.ts index a507b25e6..a76f48340 100644 --- a/server/src/init/routes.ts +++ b/server/src/init/routes.ts @@ -18,6 +18,8 @@ import { SettingsRoutes, MeRoutes, } from "@/routes/index.js"; +import StripeRoutes from "@/routes/stripe.js"; +import StripeController from "@/controllers/StripeController.js"; import { errorHandler } from "@/middleware/ErrorHandler.js"; export const initRoutes = (controllers: any, app: Express) => { const authRoutes = new AuthRoutes(controllers.authController); @@ -48,6 +50,7 @@ export const initRoutes = (controllers: any, app: Express) => { const incidentsRoutes = new IncidentsRoutes(controllers.incidentsController); const settingsRoutes = new SettingsRoutes(controllers.settingsController); const meRoutes = new MeRoutes(); + const stripeRoutes = new StripeRoutes(controllers.stripeController); app.use("/api/v1/auth", authRoutes.getRouter()); app.use("/api/v1/invite", inviteRoutes.getRouter()); @@ -69,5 +72,7 @@ export const initRoutes = (controllers: any, app: Express) => { app.use("/api/v1/incidents", incidentsRoutes.getRouter()); app.use("/api/v1/settings", settingsRoutes.getRouter()); app.use("/api/v1/me", meRoutes.getRouter()); + // Stripe webhook uses raw body; mounted separately in index.ts + app.use("/api/v1/stripe", stripeRoutes.getRouter()); app.use(errorHandler); }; diff --git a/server/src/routes/monitors.ts b/server/src/routes/monitors.ts index 394e3a4db..a4ee302c7 100644 --- a/server/src/routes/monitors.ts +++ b/server/src/routes/monitors.ts @@ -66,6 +66,9 @@ class MonitorRoutes { verifyToken, addUserContext, verifyTeamPermission([PERMISSIONS.monitors.write]), + enforceMax("monitorsMax", async (req) => + Monitor.countDocuments({ orgId: req?.user?.orgId }) + ), validateBody(monitorImportSchema), this.controller.import ); diff --git a/server/src/routes/stripe.ts b/server/src/routes/stripe.ts new file mode 100644 index 000000000..055aab977 --- /dev/null +++ b/server/src/routes/stripe.ts @@ -0,0 +1,24 @@ +import { Router } from "express"; +import StripeController from "@/controllers/StripeController.js"; + +// Note: webhook route must be mounted with express.raw at app/init level. +class StripeRoutes { + private router; + private controller; + constructor(stripeController: StripeController) { + this.router = Router(); + this.controller = stripeController; + this.initRoutes(); + } + + initRoutes = () => { + this.router.post("/webhook", this.controller.webhook); + }; + + getRouter() { + return this.router; + } +} + +export default StripeRoutes; + diff --git a/server/src/services/business/MonitorService.ts b/server/src/services/business/MonitorService.ts index 2c3f05b6c..b6e49e4a0 100644 --- a/server/src/services/business/MonitorService.ts +++ b/server/src/services/business/MonitorService.ts @@ -15,6 +15,24 @@ export interface IImportedMonitor { n: number; } +export enum MonitorImportCode { + OK = "OK", + PARTIAL = "PARTIAL", + NO_CAPACITY = "NO_CAPACITY", + UNEXPECTED = "UNEXPECTED", +} + +export interface MonitorImportResult { + ok: boolean; + status: number; + code: MonitorImportCode; + attempted: number; + eligible: number; + imported: number; + errors: number; + message?: string; +} + const SERVICE_NAME = "MonitorService"; export interface IMonitorService { @@ -68,7 +86,7 @@ export interface IMonitorService { userId: string, entitlements: Entitlements, data: { monitors: Array } - ) => Promise<{ imported: number; errors: number }>; + ) => Promise; } class MonitorService implements IMonitorService { @@ -691,7 +709,7 @@ class MonitorService implements IMonitorService { entitlements: Entitlements, data: { monitors: Array } ) => { - const attempted = Array.isArray(data?.monitors) ? data.monitors.length : 0; + const attempted = data.monitors.length ?? 0; let result: IMonitor[] = []; let canImport = 0; try { @@ -703,12 +721,12 @@ class MonitorService implements IMonitorService { return { ok: true, status: 200, - code: "NO_CAPACITY", attempted, + code: MonitorImportCode.NO_CAPACITY, eligible: 0, imported: 0, errors: attempted, - } as any; + }; } // Proceed with import @@ -732,18 +750,19 @@ class MonitorService implements IMonitorService { return { ok: false, status: 500, - code: "UNEXPECTED", + code: MonitorImportCode.UNEXPECTED, attempted, eligible: canImport, imported: result.length, errors: Math.max(0, attempted - result.length), message: error.message, - } as any; + }; } const imported = result.length; const errors = Math.max(0, attempted - imported); - const code = imported === attempted ? "OK" : "PARTIAL"; + const code = + imported === attempted ? MonitorImportCode.OK : MonitorImportCode.PARTIAL; const status = imported === attempted ? 200 : 207; return { ok: true, @@ -753,7 +772,7 @@ class MonitorService implements IMonitorService { eligible: canImport, imported, errors, - } as any; + }; }; }