diff --git a/client/src/components/common/HeaderCreate.tsx b/client/src/components/common/HeaderCreate.tsx
index 112e2ae4e..f4c8bcc4d 100644
--- a/client/src/components/common/HeaderCreate.tsx
+++ b/client/src/components/common/HeaderCreate.tsx
@@ -7,7 +7,12 @@ import Tooltip from "@mui/material/Tooltip";
import { useSelector } from "react-redux";
import type { RootState } from "@/store";
import { useMemo } from "react";
-import type { EntitlemenstKey } from "@/types/entitlements";
+type EntitlementsKey =
+ | "monitorsMax"
+ | "notificationChannelsMax"
+ | "statusPagesMax"
+ | "checksIntervalMsMin"
+ | "teamsMax";
export const HeaderCreate = ({
label,
isLoading,
@@ -18,7 +23,7 @@ export const HeaderCreate = ({
label?: string;
isLoading: boolean;
path: string;
- entitlement: EntitlemenstKey | null;
+ entitlement: EntitlementsKey | null;
entitlementCount: number;
}) => {
const theme = useTheme();
@@ -29,7 +34,7 @@ export const HeaderCreate = ({
if (!entitlement) return false;
if (!ent || typeof ent[entitlement] !== "number") return false;
return entitlementCount < ent[entitlement] ? false : true;
- }, [ent, entitlementCount]);
+ }, [ent, entitlement, entitlementCount]);
return (
);
}
diff --git a/client/src/components/design-elements/Fallback.tsx b/client/src/components/design-elements/Fallback.tsx
index c32e581c0..b52005a71 100644
--- a/client/src/components/design-elements/Fallback.tsx
+++ b/client/src/components/design-elements/Fallback.tsx
@@ -123,12 +123,14 @@ export const EmptyMonitorFallback = ({
bullets,
actionButtonText,
actionLink,
+ monitorLimitReached,
}: {
page: string;
title: string;
bullets: any;
actionButtonText: string;
actionLink: string;
+ monitorLimitReached: boolean;
}) => {
const theme = useTheme();
const navigate = useNavigate();
@@ -158,8 +160,9 @@ export const EmptyMonitorFallback = ({
variant="contained"
color="accent"
onClick={() => navigate(actionLink)}
+ disabled={monitorLimitReached}
>
- {actionButtonText}
+ {monitorLimitReached ? "Monitor limit reached" : actionButtonText}
diff --git a/client/src/pages/billing/Billing.tsx b/client/src/pages/billing/Billing.tsx
index 7e88f5c4a..48b8e9717 100644
--- a/client/src/pages/billing/Billing.tsx
+++ b/client/src/pages/billing/Billing.tsx
@@ -1,6 +1,8 @@
import { BasePage, InfoBox, PlanCard } from "@/components/design-elements";
import Grid from "@mui/material/Grid";
+import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
+import { Button } from "@/components/inputs";
import { useAppSelector } from "@/hooks/AppHooks";
import { useGet, usePost } from "@/hooks/UseApi";
@@ -27,6 +29,14 @@ const BillingPage = () => {
}
};
+ const handleCancel = async () => {
+ const result = await post("/billing/cancel", {});
+ const redirectUrl = result?.data?.redirectUrl;
+ if (redirectUrl) {
+ window.location = redirectUrl;
+ }
+ };
+
return (
{
))}
+ {selectedPlan !== "free" && (
+
+
+
+ )}
);
};
diff --git a/client/src/pages/billing/Success.tsx b/client/src/pages/billing/Success.tsx
index d689a8081..112f2b933 100644
--- a/client/src/pages/billing/Success.tsx
+++ b/client/src/pages/billing/Success.tsx
@@ -1,5 +1,37 @@
+import { BasePage } from "@/components/design-elements";
+import Stack from "@mui/material/Stack";
+import CircularProgress from "@mui/material/CircularProgress";
+import Typography from "@mui/material/Typography";
+
+import { useTheme } from "@mui/material/styles";
+import { Navigate, useSearchParams } from "react-router-dom";
+import { useGet } from "@/hooks/UseApi";
+import type { ApiResponse } from "@/hooks/UseApi";
+
const BillingSuccessPage = () => {
- return "Great";
+ const theme = useTheme();
+ const [searchParams] = useSearchParams();
+ const plan = searchParams.get("plan");
+
+ const { response } = useGet>(
+ `/billing/confirm?plan=${plan}`,
+ {},
+ {}
+ );
+
+ const success = response?.data?.success ?? false;
+ if (success === true) {
+ return ;
+ } else {
+ return (
+
+
+
+ Awaiting confirmation..
+
+
+ );
+ }
};
export default BillingSuccessPage;
diff --git a/client/src/pages/infrastructure/InfraMonitors.tsx b/client/src/pages/infrastructure/InfraMonitors.tsx
index 2161c052d..8e0c1d896 100644
--- a/client/src/pages/infrastructure/InfraMonitors.tsx
+++ b/client/src/pages/infrastructure/InfraMonitors.tsx
@@ -19,12 +19,14 @@ import useMediaQuery from "@mui/material/useMediaQuery";
import { useState } from "react";
import { useDelete } from "@/hooks/UseApi";
import { InitializingStatusBox } from "@/components/design-elements/StatusBox";
+import { useAppSelector } from "@/hooks/AppHooks";
const GLOBAL_REFRESH = import.meta.env.VITE_APP_GLOBAL_REFRESH;
const PageSpeedMonitorsPage = () => {
const theme = useTheme();
const { t } = useTranslation();
+ const { user } = useAppSelector((state) => state.auth);
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const [selectedMonitor, setSelectedMonitor] = useState(null);
const isDialogOpen = Boolean(selectedMonitor);
@@ -64,6 +66,8 @@ const PageSpeedMonitorsPage = () => {
const downCount = response?.data?.downCount || 0;
const pausedCount = response?.data?.pausedCount || 0;
+ const monitorLimitReached = count >= (user?.entitlements?.monitorsMax || 0);
+
const handleConfirm = async () => {
await deleteFn(`/monitors/${selectedMonitor?._id}`);
setSelectedMonitor(null);
@@ -79,6 +83,7 @@ const PageSpeedMonitorsPage = () => {
loading={isValidating}
error={error}
items={monitors}
+ monitorLimitReached={monitorLimitReached}
page="infrastructure"
actionLink="/infrastructure/create"
>
diff --git a/client/src/pages/pagespeed/PageSpeedMonitors.tsx b/client/src/pages/pagespeed/PageSpeedMonitors.tsx
index 94622f054..045752d52 100644
--- a/client/src/pages/pagespeed/PageSpeedMonitors.tsx
+++ b/client/src/pages/pagespeed/PageSpeedMonitors.tsx
@@ -19,11 +19,14 @@ import useMediaQuery from "@mui/material/useMediaQuery";
import { useState } from "react";
import { useDelete } from "@/hooks/UseApi";
import { InitializingStatusBox } from "@/components/design-elements/StatusBox";
+import { useAppSelector } from "@/hooks/AppHooks";
const GLOBAL_REFRESH = import.meta.env.VITE_APP_GLOBAL_REFRESH;
const PageSpeedMonitorsPage = () => {
const theme = useTheme();
+ const { user } = useAppSelector((state) => state.auth);
+
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const [selectedMonitor, setSelectedMonitor] = useState(null);
@@ -64,6 +67,8 @@ const PageSpeedMonitorsPage = () => {
const downCount = response?.data?.downCount || 0;
const pausedCount = response?.data?.pausedCount || 0;
+ const monitorLimitReached = count >= (user?.entitlements?.monitorsMax || 0);
+
const handleConfirm = async () => {
await deleteFn(`/monitors/${selectedMonitor?._id}`);
setSelectedMonitor(null);
@@ -81,6 +86,7 @@ const PageSpeedMonitorsPage = () => {
items={monitors}
page="pageSpeed"
actionLink="/pagespeed/create"
+ monitorLimitReached={monitorLimitReached}
>
{
const theme = useTheme();
+ const { user } = useAppSelector((state) => state.auth);
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const [selectedMonitor, setSelectedMonitor] = useState(null);
@@ -91,6 +93,8 @@ const UptimeMonitors = () => {
const downCount = response?.data?.downCount || 0;
const pausedCount = response?.data?.pausedCount || 0;
+ const monitorLimitReached = count >= (user?.entitlements?.monitorsMax || 0);
+
const handleConfirm = async () => {
await deleteFn(`/monitors/${selectedMonitor?._id}`);
setSelectedMonitor(null);
@@ -120,6 +124,7 @@ const UptimeMonitors = () => {
items={monitorItems}
page="uptime"
actionLink="/uptime/create"
+ monitorLimitReached={monitorLimitReached}
>
Promise;
+ confirmPlan: (
+ req: Request,
+ res: Response,
+ next: NextFunction
+ ) => Promise;
}
class BillingController implements IBillingController {
@@ -38,6 +43,7 @@ class BillingController implements IBillingController {
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,
@@ -53,6 +59,44 @@ class BillingController implements IBillingController {
next(error);
}
};
+
+ cancelPlan = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const orgId = req.user?.orgId;
+ if (!orgId) throw new ApiError("Organization not found", 404);
+
+ const redirectUrl = await this.billingService.cancelSubscription(orgId);
+
+ res.status(200).json({
+ message: "Subscription cancellation successful",
+ data: { redirectUrl },
+ });
+ return;
+ } catch (error) {
+ next(error);
+ }
+ };
+
+ confirmPlan = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const orgId = req.user?.orgId;
+ if (!orgId) throw new ApiError("Organization not found", 404);
+
+ const expectedPlanKey = req.query.plan as PlanKey;
+ const success = await this.billingService.confirmPlan(
+ orgId,
+ expectedPlanKey
+ );
+
+ res.status(200).json({
+ message: "Plan confirmation endpoint hit",
+ data: { success },
+ });
+ return;
+ } catch (error) {
+ next(error);
+ }
+ };
}
export default BillingController;
diff --git a/server/src/controllers/StripeController.ts b/server/src/controllers/StripeController.ts
index fb3874950..b861aff1d 100644
--- a/server/src/controllers/StripeController.ts
+++ b/server/src/controllers/StripeController.ts
@@ -1,46 +1,27 @@
import type { Request, Response, NextFunction } from "express";
+import { StripeService } from "@/services/index.js";
+import { config } from "@/config/index.js";
export interface IStripeController {
webhook: (req: Request, res: Response, next: NextFunction) => Promise;
}
class StripeController implements IStripeController {
+ private stripeService: StripeService;
+
+ constructor(stripeService: StripeService) {
+ this.stripeService = stripeService;
+ }
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;
+ let event = req.body;
+ const signature = req.headers["stripe-signature"];
+ if (!signature || Array.isArray(signature)) {
+ throw new Error("Stripe signature is missing or invalid");
}
- // 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);
+ const result = await this.stripeService.webhook(event, signature);
+ res.status(200).send({ received: true });
} catch (error) {
throw error;
}
diff --git a/server/src/controllers/TeamMemberController.ts b/server/src/controllers/TeamMemberController.ts
index 4e75926fa..08d89bcd3 100644
--- a/server/src/controllers/TeamMemberController.ts
+++ b/server/src/controllers/TeamMemberController.ts
@@ -147,8 +147,6 @@ class TeamMemberController implements ITeamMemberController {
throw new ApiError("No team member ID", 400);
}
- console.log(teamMemberId);
-
const deleted = await this.teamMemberService.delete(orgId, teamMemberId);
invalidateCachesForUser(teamMemberId);
if (!deleted) {
diff --git a/server/src/db/models/auth/Org.ts b/server/src/db/models/auth/Org.ts
index c5ad73290..71b1e3141 100644
--- a/server/src/db/models/auth/Org.ts
+++ b/server/src/db/models/auth/Org.ts
@@ -18,12 +18,11 @@ export interface IOrg extends Document {
// Billing
billingCustomerId?: string;
subscriptionId?: string;
+ priceId: string;
priceIds?: string[];
subscriptionStatus?: SubscriptionStatus;
currentPeriodStart?: Date;
currentPeriodEnd?: Date;
- trialEnd?: Date;
- cancelAtPeriodEnd?: boolean;
// Cached entitlements
entitlements: {
@@ -61,8 +60,6 @@ const orgSchema = new Schema(
},
currentPeriodStart: { type: Date },
currentPeriodEnd: { type: Date },
- trialEnd: { type: Date },
- cancelAtPeriodEnd: { type: Boolean },
entitlements: {
plan: { type: String },
monitorsMax: { type: Number },
diff --git a/server/src/init/controllers.ts b/server/src/init/controllers.ts
index b007f2833..91fe2af6e 100644
--- a/server/src/init/controllers.ts
+++ b/server/src/init/controllers.ts
@@ -76,7 +76,7 @@ export const initControllers = (services: any) => {
services.notificationService
);
- controllers.stripeController = new StripeController();
+ controllers.stripeController = new StripeController(services.stripeService);
controllers.billingController = new BillingController(
services.billingService
);
diff --git a/server/src/init/services.ts b/server/src/init/services.ts
index 24127cc51..8146050d9 100644
--- a/server/src/init/services.ts
+++ b/server/src/init/services.ts
@@ -28,6 +28,7 @@ import {
RecoveryService,
IncidentService,
SettingsService,
+ StripeService,
BillingService,
} from "@/services/index.js";
@@ -44,6 +45,7 @@ export const initServices = async () => {
const networkService = new NetworkService(got);
const statusService = new StatusService();
const settingsService = new SettingsService();
+ const stripeService = new StripeService();
const billingService = new BillingService();
const emailService = new EmailService(userService, settingsService);
settingsService.setEmailService(emailService);
@@ -99,6 +101,7 @@ export const initServices = async () => {
incidentService,
settingsService,
billingService,
+ stripeService,
};
Object.values(services).forEach((service) => {
diff --git a/server/src/middleware/VerifyPermission.ts b/server/src/middleware/VerifyPermission.ts
index fcc93409d..0386fe010 100644
--- a/server/src/middleware/VerifyPermission.ts
+++ b/server/src/middleware/VerifyPermission.ts
@@ -61,8 +61,6 @@ 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 9f05c41fc..baa503f2e 100644
--- a/server/src/routes/billing.ts
+++ b/server/src/routes/billing.ts
@@ -26,6 +26,18 @@ class BillingRoutes {
validateBody(subscribePlanSchema),
this.controller.subscribePlan
);
+ this.router.post(
+ "/cancel",
+ verifyToken,
+ addUserContext,
+ this.controller.cancelPlan
+ );
+ this.router.get(
+ "/confirm",
+ verifyToken,
+ addUserContext,
+ this.controller.confirmPlan
+ );
};
getRouter() {
diff --git a/server/src/routes/stripe.ts b/server/src/routes/stripe.ts
index 055aab977..56def789e 100644
--- a/server/src/routes/stripe.ts
+++ b/server/src/routes/stripe.ts
@@ -1,4 +1,4 @@
-import { Router } from "express";
+import { Router, raw } from "express";
import StripeController from "@/controllers/StripeController.js";
// Note: webhook route must be mounted with express.raw at app/init level.
@@ -12,7 +12,11 @@ class StripeRoutes {
}
initRoutes = () => {
- this.router.post("/webhook", this.controller.webhook);
+ this.router.post(
+ "/webhook",
+ raw({ type: "application/json" }),
+ this.controller.webhook
+ );
};
getRouter() {
@@ -21,4 +25,3 @@ class StripeRoutes {
}
export default StripeRoutes;
-
diff --git a/server/src/services/index.ts b/server/src/services/index.ts
index e3f1dacad..d6812d452 100644
--- a/server/src/services/index.ts
+++ b/server/src/services/index.ts
@@ -24,5 +24,6 @@ export { default as JobGenerator } from "@/services/infrastructure/JobGenerator.
export { default as ServiceRegistry } from "@/services/system/ServiceRegistry.js";
export { default as SettingsService } from "@/services/system/SettingsService.js";
export { default as BillingService } from "@/services/system/BillingService.js";
+export { default as StripeService } from "@/services/system/StripeService.js";
export * from "@/services/infrastructure/NotificationServices/index.js";
diff --git a/server/src/services/system/BillingService.ts b/server/src/services/system/BillingService.ts
index 10cfa95f2..f71ffacd9 100644
--- a/server/src/services/system/BillingService.ts
+++ b/server/src/services/system/BillingService.ts
@@ -15,6 +15,8 @@ export interface IBillingService {
orgId: string,
planKey: PlanKey
): Promise;
+ cancelSubscription(orgId: string): Promise;
+ confirmPlan(orgId: string, expectedPlanKey: PlanKey): Promise;
}
const SERVICE_NAME = "BillingService";
@@ -27,10 +29,10 @@ class BillingService implements IBillingService {
this.SERVICE_NAME = SERVICE_NAME;
this.stripeClient = new Stripe(config.STRIPE_SECRET);
}
- async listPlans(): Promise {
+ listPlans = async () => {
const order: PlanKey[] = ["free", "pro", "business", "enterprise"];
return order.map((key) => Plans[key]);
- }
+ };
subscribePlan = async (email: string, orgId: string, planKey: PlanKey) => {
if (!planKey) {
@@ -41,7 +43,6 @@ class BillingService implements IBillingService {
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) {
@@ -63,28 +64,110 @@ class BillingService implements IBillingService {
throw new ApiError("Price not found for the selected plan", 404);
}
+ let redirectUrl: string;
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,
+
+ const subscriptionId = org.subscriptionId;
+
+ // Updating an existing subscription
+ if (subscriptionId) {
+ const session = await this.stripeClient.billingPortal.sessions.create({
+ customer: String(org.billingCustomerId),
+ return_url: successUrl,
+ flow_data: {
+ type: "subscription_update",
+ subscription_update: {
+ subscription: subscriptionId,
+ },
},
- ],
- 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);
+ });
+ redirectUrl = session.url;
}
- return session.url;
+
+ // Creating a new subscription
+ else {
+ 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) {
+ throw new ApiError("Failed to create checkout session", 500);
+ }
+ redirectUrl = session.url;
+ }
+
+ return redirectUrl;
+ };
+
+ cancelSubscription = async (orgId: string): Promise => {
+ const successUrl = `${config.ORIGIN}/billing/success?plan=free`;
+
+ const org = await Org.findById(orgId);
+ if (!org) throw new ApiError("Organization not found", 404);
+
+ const subscriptionId = org.subscriptionId;
+ if (!subscriptionId) {
+ throw new ApiError("No active subscription found", 400);
+ }
+
+ const session = await this.stripeClient.billingPortal.sessions.create({
+ customer: String(org.billingCustomerId),
+ return_url: successUrl,
+ flow_data: {
+ type: "subscription_cancel",
+ subscription_cancel: {
+ subscription: subscriptionId,
+ },
+ },
+ });
+
+ const redirect = session.url;
+ if (!redirect) {
+ throw new ApiError("Failed to create billing portal session", 500);
+ }
+
+ return redirect;
+ };
+
+ confirmPlan = async (orgId: string, expectedPlanKey: PlanKey) => {
+ const org = await Org.findOne({ _id: orgId });
+ if (!org) throw new ApiError("Organization not found", 404);
+
+ if (!org.subscriptionId) {
+ return expectedPlanKey === "free";
+ }
+
+ const sub = await this.stripeClient.subscriptions.retrieve(
+ org.subscriptionId
+ );
+ const activePlan = sub.items.data[0]?.price.lookup_key;
+
+ if (activePlan === org.planKey) {
+ return true;
+ }
+
+ if (activePlan === expectedPlanKey) {
+ return true;
+ }
+
+ if (activePlan !== expectedPlanKey) {
+ return false;
+ }
+
+ return true;
};
}
diff --git a/server/src/services/system/StripeService.ts b/server/src/services/system/StripeService.ts
new file mode 100644
index 000000000..f9de3d435
--- /dev/null
+++ b/server/src/services/system/StripeService.ts
@@ -0,0 +1,170 @@
+import Stripe from "stripe";
+import { config } from "@/config/index.js";
+import {
+ Org,
+ Monitor,
+ StatusPage,
+ NotificationChannel,
+} from "@/db/models/index.js";
+import { PlanKey, Plans } from "@/types/entitlements.js";
+
+const SERVICE_NAME = "StripeService";
+
+interface IStripeService {
+ webhook: (
+ event: string | Buffer,
+ signature: string
+ ) => Promise;
+}
+
+class StripeService implements IStripeService {
+ public SERVICE_NAME: string;
+ private stripe: Stripe;
+
+ constructor() {
+ this.SERVICE_NAME = SERVICE_NAME;
+ this.stripe = new Stripe(config.STRIPE_SECRET);
+ }
+
+ private handleSubscription = async (subscription: Stripe.Subscription) => {
+ const orgId = subscription.metadata.orgId;
+ const subscriptionId = subscription.id;
+ const planKey =
+ (subscription.items.data[0]?.price.lookup_key as PlanKey) || "free";
+ const priceId = subscription.items.data[0]?.price.id || "unknown";
+ const status = subscription.status;
+ const currentPeriodStart =
+ subscription.items.data[0]?.current_period_start || 0;
+ const currentPeriodEnd =
+ subscription.items.data[0]?.current_period_end || 0;
+
+ await Org.findOneAndUpdate(
+ { _id: orgId },
+ {
+ $set: {
+ planKey,
+ subscriptionId,
+ subscriptionStatus: status,
+ priceId,
+ currentPeriodStart,
+ currentPeriodEnd,
+ entitlements: Plans[planKey],
+ },
+ },
+ { new: true }
+ );
+
+ await this.pruneResourcesForOrg(String(orgId), Plans[planKey]);
+ };
+
+ private handleCancel = async (subscription: Stripe.Subscription) => {
+ const orgId = subscription.metadata.orgId;
+ await Org.findOneAndUpdate(
+ { _id: orgId },
+ {
+ $set: {
+ planKey: "free",
+ entitlements: Plans["free"],
+ },
+ $unset: {
+ subscriptionId: "",
+ subscriptionStatus: "",
+ priceId: "",
+ currentPeriodStart: "",
+ currentPeriodEnd: "",
+ },
+ },
+ { new: true }
+ );
+ await this.pruneResourcesForOrg(String(orgId), Plans["free"]);
+ };
+
+ private pruneResourcesForOrg = async (
+ orgId: string,
+ entitlements: {
+ monitorsMax: number;
+ notificationChannelsMax: number;
+ statusPagesMax: number;
+ }
+ ) => {
+ const prune = async (
+ model: any,
+ cap: number,
+ filter: Record
+ ) => {
+ if (!Number.isFinite(cap) || cap === Number.MAX_SAFE_INTEGER) return;
+ const count = await model.countDocuments(filter);
+ if (count <= cap) return;
+ const excess = count - cap;
+ const docs = await model
+ .find(filter)
+ .sort({ createdAt: -1 })
+ .limit(excess)
+ .select("_id");
+ const ids = docs.map((d: any) => d._id);
+ if (ids.length) {
+ await model.deleteMany({ _id: { $in: ids } });
+ }
+ };
+
+ const baseFilter = { orgId } as const;
+ await prune(Monitor, entitlements.monitorsMax, baseFilter);
+ await prune(
+ NotificationChannel,
+ entitlements.notificationChannelsMax,
+ baseFilter
+ );
+ await prune(StatusPage, entitlements.statusPagesMax, baseFilter);
+ };
+
+ webhook = async (event: string | Buffer, signature: string) => {
+ const whSecret = config.STRIPE_WEBHOOK_SECRET;
+ if (!whSecret || whSecret === "not_set") {
+ throw new Error("Stripe webhook secret is not set");
+ }
+
+ const stripeEvent = this.stripe.webhooks.constructEvent(
+ event,
+ signature,
+ whSecret
+ );
+
+ let subscription: Stripe.Subscription | null;
+ let status: Stripe.Subscription.Status;
+ switch (stripeEvent.type) {
+ case "customer.subscription.deleted":
+ subscription = stripeEvent.data.object;
+ status = subscription.status;
+ this.handleCancel(subscription);
+ break;
+ case "customer.subscription.created":
+ subscription = stripeEvent.data.object;
+ status = subscription.status;
+ this.handleSubscription(subscription);
+ break;
+ case "customer.subscription.updated":
+ subscription = stripeEvent.data.object;
+ status = subscription.status;
+ this.handleSubscription(subscription);
+ break;
+
+ case "checkout.session.completed":
+ subscription = null;
+ console.log("Checkout session completed.");
+ break;
+
+ case "invoice.payment_succeeded":
+ subscription = null;
+ console.log("Invoice payment succeeded.");
+ break;
+
+ default:
+ // Unexpected event type
+ subscription = null;
+ console.log(`Unhandled event type ${stripeEvent.type}.`);
+ }
+ return subscription;
+ };
+}
+
+export default StripeService;