stripe
This commit is contained in:
1 parent
01c0e9ef4d
commit
1b162578c2
22 files changed
+450
-72
No files matched your search
@@ -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 (
|
||||
<Stack
|
||||
direction="row"
|
||||
|
||||
@@ -32,7 +32,6 @@ interface BasePageWithStatesProps extends StackProps {
|
||||
error: any;
|
||||
items: any[];
|
||||
bullets: string[] | unknown;
|
||||
|
||||
page: string;
|
||||
actionButtonText: string;
|
||||
actionLink: string;
|
||||
@@ -83,6 +82,7 @@ interface MonitorBasePageWithStatesProps extends StackProps {
|
||||
loading: boolean;
|
||||
error: any;
|
||||
items: any[];
|
||||
monitorLimitReached: boolean;
|
||||
page: string;
|
||||
actionLink?: string;
|
||||
children: React.ReactNode;
|
||||
@@ -100,6 +100,7 @@ export const MonitorBasePageWithStates: React.FC<
|
||||
loading,
|
||||
error,
|
||||
items,
|
||||
monitorLimitReached,
|
||||
page,
|
||||
actionLink,
|
||||
children,
|
||||
@@ -130,6 +131,7 @@ export const MonitorBasePageWithStates: React.FC<
|
||||
bullets={t(`${page}Monitor.fallback.checks`, { returnObjects: true })}
|
||||
actionButtonText={t(`${page}Monitor.fallback.actionButton`)}
|
||||
actionLink={actionLink || ""}
|
||||
monitorLimitReached={monitorLimitReached}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -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 (
|
||||
<BasePage>
|
||||
<InfoBox
|
||||
@@ -48,6 +58,18 @@ const BillingPage = () => {
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
{selectedPlan !== "free" && (
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleCancel}
|
||||
loading={isPosting}
|
||||
>
|
||||
Cancel subscription
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<ApiResponse<any>>(
|
||||
`/billing/confirm?plan=${plan}`,
|
||||
{},
|
||||
{}
|
||||
);
|
||||
|
||||
const success = response?.data?.success ?? false;
|
||||
if (success === true) {
|
||||
return <Navigate to="/billing" replace />;
|
||||
} else {
|
||||
return (
|
||||
<BasePage>
|
||||
<Stack direction="row" alignItems={"center"} gap={theme.spacing(10)}>
|
||||
<CircularProgress color="accent" />
|
||||
<Typography>Awaiting confirmation..</Typography>
|
||||
</Stack>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default BillingSuccessPage;
|
||||
@@ -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<IMonitor | null>(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"
|
||||
>
|
||||
|
||||
@@ -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<IMonitor | null>(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}
|
||||
>
|
||||
<InfoBox
|
||||
title="Website Performance Tracking"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { HeaderCreate } from "@/components/common";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { Dialog } from "@/components/inputs";
|
||||
import { MonitorTable } from "@/pages/uptime/MonitorTable";
|
||||
import { InitializingStatusBox } from "@/components/design-elements/StatusBox";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
@@ -26,12 +27,13 @@ import {
|
||||
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 UptimeMonitors = () => {
|
||||
const theme = useTheme();
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
const { t } = useTranslation();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<IMonitor | null>(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}
|
||||
>
|
||||
<InfoBox
|
||||
title="Website & API Uptime Monitoring"
|
||||
|
||||
Vendored
+6
@@ -23,3 +23,9 @@ declare module "@mui/material/Button" {
|
||||
accent: true;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@mui/material/CircularProgress" {
|
||||
interface CircularProgressPropsColorOverrides {
|
||||
accent: true;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface IEnvConfig {
|
||||
SMTP_USER: string;
|
||||
SMTP_PASS: string;
|
||||
STRIPE_SECRET: string;
|
||||
STRIPE_WEBHOOK_SECRET: string;
|
||||
}
|
||||
|
||||
dotenv.config();
|
||||
@@ -32,4 +33,5 @@ export const config: IEnvConfig = {
|
||||
SMTP_USER: process.env.SMTP_USER || "not_set",
|
||||
SMTP_PASS: process.env.SMTP_PASS || "not_set",
|
||||
STRIPE_SECRET: process.env.STRIPE_SECRET || "not_set",
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET || "not_set",
|
||||
};
|
||||
@@ -12,6 +12,11 @@ export interface IBillingController {
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => Promise<void>;
|
||||
confirmPlan: (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<IOrg>(
|
||||
},
|
||||
currentPeriodStart: { type: Date },
|
||||
currentPeriodEnd: { type: Date },
|
||||
trialEnd: { type: Date },
|
||||
cancelAtPeriodEnd: { type: Boolean },
|
||||
entitlements: {
|
||||
plan: { type: String },
|
||||
monitorsMax: { type: Number },
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -15,6 +15,8 @@ export interface IBillingService {
|
||||
orgId: string,
|
||||
planKey: PlanKey
|
||||
): Promise<string>;
|
||||
cancelSubscription(orgId: string): Promise<string>;
|
||||
confirmPlan(orgId: string, expectedPlanKey: PlanKey): Promise<boolean>;
|
||||
}
|
||||
|
||||
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<Entitlements[]> {
|
||||
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<string> => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Stripe.Subscription | null>;
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
) => {
|
||||
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;
|
||||
Reference in new issue
Block a user