feat: pbac org billing (#23709)
* refactor layout to not check session * add actions for orgs + org admins * update types on actions to be correctly non nullable * Add tests for utils * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * restore lock file * add permission check action for org authentication managment * WIP BRANCH * Git merge fix conflicts * Fix imports * intro to pbac team billing * restore log * refactor to be a billing portal factory service * fix merge conflict * Fix merge conflicts * Passing test with non hardcoded vars * fix migration * Wip * remove layout permision checks * Fix type check * remove logs * improve error handling and logs * Fix nits * nits * Use push instead of slice for billing tab * Add permissions to memo * Fix credits handlers to use PBAC also * fix imports --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
coderabbitai[bot]
parent
14b3344a41
commit
224e606439
+25
-18
@@ -101,10 +101,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
name: "privacy",
|
||||
href: "/settings/organizations/privacy",
|
||||
},
|
||||
{
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
},
|
||||
|
||||
{ name: "OAuth Clients", href: "/settings/organizations/platform/oauth-clients" },
|
||||
{
|
||||
name: "SSO",
|
||||
@@ -173,17 +170,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
// The following keys are assigned to admin only
|
||||
const adminRequiredKeys = ["admin"];
|
||||
const organizationRequiredKeys = ["organization"];
|
||||
const organizationAdminKeys = [
|
||||
"privacy",
|
||||
"billing",
|
||||
"OAuth Clients",
|
||||
"SSO",
|
||||
"directory_sync",
|
||||
"delegation_credential",
|
||||
];
|
||||
const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"];
|
||||
|
||||
export interface SettingsPermissions {
|
||||
canViewRoles?: boolean;
|
||||
canViewOrganizationBilling?: boolean;
|
||||
}
|
||||
|
||||
const useTabs = ({
|
||||
@@ -232,11 +223,27 @@ const useTabs = ({
|
||||
|
||||
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
|
||||
// This prevents showing the menu item when user has no organization permissions
|
||||
if (isPbacEnabled && permissions?.canViewRoles) {
|
||||
newArray.push({
|
||||
name: "roles_and_permissions",
|
||||
href: "/settings/organizations/roles",
|
||||
});
|
||||
if (isPbacEnabled) {
|
||||
if (permissions?.canViewRoles) {
|
||||
newArray.push({
|
||||
name: "roles_and_permissions",
|
||||
href: "/settings/organizations/roles",
|
||||
});
|
||||
}
|
||||
|
||||
if (permissions?.canViewOrganizationBilling) {
|
||||
newArray.push({
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (isOrgAdminOrOwner) {
|
||||
newArray.push({
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -272,7 +279,7 @@ const useTabs = ({
|
||||
if (isAdmin) return true;
|
||||
return !adminRequiredKeys.includes(tab.name);
|
||||
});
|
||||
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled]);
|
||||
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]);
|
||||
|
||||
return processTabsMemod;
|
||||
};
|
||||
|
||||
@@ -3,11 +3,12 @@ import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { TeamFeatures } from "@calcom/features/flags/config";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
||||
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { Resource, CrudAction, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
@@ -45,13 +46,16 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
|
||||
let teamFeatures: Record<number, TeamFeatures> | null = null;
|
||||
let canViewRoles = false;
|
||||
let canViewOrganizationBilling = false;
|
||||
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
|
||||
|
||||
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
|
||||
if (orgId) {
|
||||
const [features, rolePermissions] = await Promise.all([
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(session.user.org?.role);
|
||||
const [features, rolePermissions, organizationPermissions] = await Promise.all([
|
||||
getTeamFeatures(orgId),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Role),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Organization),
|
||||
]);
|
||||
|
||||
if (features) {
|
||||
@@ -62,6 +66,8 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
// Check if user has permission to read roles
|
||||
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
|
||||
canViewRoles = roleActions[CrudAction.Read] ?? false;
|
||||
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
|
||||
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +76,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
<SettingsLayoutAppDirClient
|
||||
{...props}
|
||||
teamFeatures={teamFeatures ?? {}}
|
||||
permissions={{ canViewRoles }}
|
||||
permissions={{ canViewRoles, canViewOrganizationBilling }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+6
-3
@@ -2,10 +2,11 @@ import { _generateMetadata } from "app/_utils";
|
||||
import { getTranslate } from "app/_utils";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import BillingView from "~/settings/billing/billing-view";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
import { validateUserHasOrgPerms } from "../../actions/validateUserHasOrgPerms";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -18,9 +19,11 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
await validateUserHasOrgAdmin();
|
||||
|
||||
// TODO(SEAN): Add PBAC to this page in the next PR
|
||||
await validateUserHasOrgPerms({
|
||||
permission: "organization.manageBilling",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
|
||||
-20
@@ -1,24 +1,4 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const OrgAdminOnlyLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const userProfile = session?.user?.profile;
|
||||
const userId = session?.user?.id;
|
||||
const orgRole =
|
||||
session?.user?.org?.role ??
|
||||
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
|
||||
|
||||
if (!isOrgAdminOrOwner) {
|
||||
return redirect("/settings/organizations/profile");
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrg } from "./validateUserHasOrg";
|
||||
|
||||
export const validateUserHasOrgPerms = async ({
|
||||
redirectTo,
|
||||
fallbackRoles,
|
||||
permission,
|
||||
}: {
|
||||
redirectTo?: string;
|
||||
permission: PermissionString;
|
||||
fallbackRoles: MembershipRole[];
|
||||
}) => {
|
||||
const session = await validateUserHasOrg();
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.org.id,
|
||||
permission,
|
||||
fallbackRoles,
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
redirect(redirectTo || "/settings/my-account/profile");
|
||||
}
|
||||
|
||||
return session;
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import type { Session } from "next-auth";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import {
|
||||
BillingPortalServiceFactory,
|
||||
TeamBillingPortalService,
|
||||
OrganizationBillingPortalService,
|
||||
UserBillingPortalService,
|
||||
} from "../../lib/BillingPortalService";
|
||||
import * as customerModule from "../../lib/customer";
|
||||
import { validateAuthentication, buildReturnUrl } from "../portal";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@calcom/features/pbac/services/permission-check.service");
|
||||
vi.mock("@calcom/lib/server/repository/team");
|
||||
vi.mock("../../lib/customer");
|
||||
vi.mock("../../lib/server");
|
||||
vi.mock("../../lib/subscriptions");
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
const mockPermissionService = vi.mocked(PermissionCheckService);
|
||||
const mockTeamRepository = vi.mocked(TeamRepository);
|
||||
const mockCustomerModule = vi.mocked(customerModule);
|
||||
|
||||
interface RequestWithSession extends NextApiRequest {
|
||||
session?: Session | null;
|
||||
}
|
||||
|
||||
interface MockPermissionService {
|
||||
checkPermission: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface MockTeamRepository {
|
||||
findById: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
describe("Portal API - Service-Based Architecture", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("validateAuthentication", () => {
|
||||
it("should return user when session exists", () => {
|
||||
const req = {
|
||||
session: {
|
||||
user: { id: 123 },
|
||||
hasValidLicense: true,
|
||||
upId: "test-upid",
|
||||
expires: "2024-12-31T23:59:59.999Z"
|
||||
} as Session,
|
||||
} as RequestWithSession;
|
||||
|
||||
const result = validateAuthentication(req as NextApiRequest);
|
||||
|
||||
expect(result).toEqual({ id: 123 });
|
||||
});
|
||||
|
||||
it("should return null when session is missing", () => {
|
||||
const req = {} as NextApiRequest;
|
||||
|
||||
const result = validateAuthentication(req);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when user id is missing", () => {
|
||||
const req = {
|
||||
session: {
|
||||
user: {} as any,
|
||||
hasValidLicense: true,
|
||||
upId: "test-upid",
|
||||
expires: "2024-12-31T23:59:59.999Z"
|
||||
} as Session,
|
||||
} as RequestWithSession;
|
||||
|
||||
const result = validateAuthentication(req as NextApiRequest);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReturnUrl", () => {
|
||||
it("should return default URL when returnTo is not provided", () => {
|
||||
const result = buildReturnUrl();
|
||||
|
||||
expect(result).toBe(`${WEBAPP_URL}/settings/billing`);
|
||||
});
|
||||
|
||||
it("should return default URL when returnTo is not a string", () => {
|
||||
const result = buildReturnUrl(123 as unknown as string);
|
||||
|
||||
expect(result).toBe(`${WEBAPP_URL}/settings/billing`);
|
||||
});
|
||||
|
||||
it("should return safe redirect URL when valid returnTo is provided", () => {
|
||||
const returnTo = `${WEBAPP_URL}/settings/teams`;
|
||||
|
||||
const result = buildReturnUrl(returnTo);
|
||||
|
||||
expect(result).toBe(`${WEBAPP_URL}/settings/teams`);
|
||||
});
|
||||
|
||||
it("should return WEBAPP_URL root when unsafe redirect URL is provided", () => {
|
||||
const returnTo = "http://malicious-site.com";
|
||||
|
||||
const result = buildReturnUrl(returnTo);
|
||||
|
||||
expect(result).toBe(`${WEBAPP_URL}/`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingPortalServiceFactory", () => {
|
||||
let mockTeamRepo: MockTeamRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
mockTeamRepo = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
mockTeamRepository.mockImplementation(() => mockTeamRepo as unknown as TeamRepository);
|
||||
});
|
||||
|
||||
it("should create OrganizationBillingPortalService for organizations", async () => {
|
||||
const mockTeam = { id: 1, isOrganization: true, metadata: {} };
|
||||
mockTeamRepo.findById.mockResolvedValue(mockTeam);
|
||||
|
||||
const service = await BillingPortalServiceFactory.createService(1);
|
||||
|
||||
expect(service).toBeInstanceOf(OrganizationBillingPortalService);
|
||||
});
|
||||
|
||||
it("should create TeamBillingPortalService for regular teams", async () => {
|
||||
const mockTeam = { id: 1, isOrganization: false, metadata: {} };
|
||||
mockTeamRepo.findById.mockResolvedValue(mockTeam);
|
||||
|
||||
const service = await BillingPortalServiceFactory.createService(1);
|
||||
|
||||
expect(service).toBeInstanceOf(TeamBillingPortalService);
|
||||
});
|
||||
|
||||
it("should throw error when team not found", async () => {
|
||||
mockTeamRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(BillingPortalServiceFactory.createService(1)).rejects.toThrow("Team not found");
|
||||
});
|
||||
|
||||
it("should create UserBillingPortalService", () => {
|
||||
const service = BillingPortalServiceFactory.createUserService();
|
||||
|
||||
expect(service).toBeInstanceOf(UserBillingPortalService);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TeamBillingPortalService", () => {
|
||||
let service: TeamBillingPortalService;
|
||||
let mockPermissionServiceInstance: MockPermissionService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPermissionServiceInstance = {
|
||||
checkPermission: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
vi.mocked(PermissionCheckService).mockImplementation(
|
||||
() => mockPermissionServiceInstance as unknown as PermissionCheckService
|
||||
);
|
||||
|
||||
const mockTeamRepo: MockTeamRepository = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
mockTeamRepository.mockImplementation(() => mockTeamRepo as unknown as TeamRepository);
|
||||
|
||||
service = new TeamBillingPortalService();
|
||||
});
|
||||
|
||||
it("should check team.manageBilling permission", async () => {
|
||||
const result = await service.checkPermissions(123, 456);
|
||||
|
||||
expect(mockPermissionServiceInstance.checkPermission).toHaveBeenCalledWith({
|
||||
userId: 123,
|
||||
teamId: 456,
|
||||
permission: "team.manageBilling",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrganizationBillingPortalService", () => {
|
||||
let service: OrganizationBillingPortalService;
|
||||
let mockPermissionServiceInstance: MockPermissionService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPermissionServiceInstance = {
|
||||
checkPermission: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
vi.mocked(PermissionCheckService).mockImplementation(
|
||||
() => mockPermissionServiceInstance as unknown as PermissionCheckService
|
||||
);
|
||||
|
||||
service = new OrganizationBillingPortalService();
|
||||
});
|
||||
|
||||
it("should check organization.manageBilling permission", async () => {
|
||||
const result = await service.checkPermissions(123, 456);
|
||||
|
||||
expect(mockPermissionServiceInstance.checkPermission).toHaveBeenCalledWith({
|
||||
userId: 123,
|
||||
teamId: 456,
|
||||
permission: "organization.manageBilling",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserBillingPortalService", () => {
|
||||
let service: UserBillingPortalService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new UserBillingPortalService();
|
||||
mockCustomerModule.getStripeCustomerIdFromUserId = vi.fn();
|
||||
});
|
||||
|
||||
it("should get customer ID for user", async () => {
|
||||
mockCustomerModule.getStripeCustomerIdFromUserId.mockResolvedValue("cus_123");
|
||||
|
||||
const result = await service.getCustomerId(123);
|
||||
|
||||
expect(result).toBe("cus_123");
|
||||
expect(mockCustomerModule.getStripeCustomerIdFromUserId).toHaveBeenCalledWith(123);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,87 +1,59 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { getStripeCustomerIdFromUserId } from "../lib/customer";
|
||||
import stripe from "../lib/server";
|
||||
import { getSubscriptionFromId } from "../lib/subscriptions";
|
||||
import { BillingPortalServiceFactory } from "../lib/BillingPortalService";
|
||||
|
||||
const getBillingPortalUrl = async (customerId: string, return_url: string) => {
|
||||
const log = logger.getSubLogger({ prefix: ["getBillingPortalUrl"] });
|
||||
try {
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url,
|
||||
});
|
||||
interface AuthenticatedUser {
|
||||
id: number;
|
||||
}
|
||||
|
||||
return portalSession.url;
|
||||
} catch (e) {
|
||||
log.error(`Failed to create billing portal session for ${customerId}: ${e}`);
|
||||
throw new Error("Failed to create billing portal session");
|
||||
}
|
||||
interface RequestWithSession extends NextApiRequest {
|
||||
session?: Session | null;
|
||||
}
|
||||
|
||||
export const validateAuthentication = (req: NextApiRequest): AuthenticatedUser | null => {
|
||||
const userId = (req as RequestWithSession).session?.user?.id;
|
||||
if (!userId) return null;
|
||||
return { id: userId };
|
||||
};
|
||||
|
||||
export const buildReturnUrl = (returnTo?: string): string => {
|
||||
const defaultUrl = `${WEBAPP_URL}/settings/billing`;
|
||||
|
||||
if (typeof returnTo !== "string") return defaultUrl;
|
||||
|
||||
const safeRedirectUrl = getSafeRedirectUrl(returnTo);
|
||||
return safeRedirectUrl || defaultUrl;
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST" && req.method !== "GET")
|
||||
if (req.method !== "POST" && req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
if (!req.session?.user?.id) return res.status(401).json({ message: "Not authenticated" });
|
||||
const user = validateAuthentication(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const userId = req.session.user.id;
|
||||
const teamId = req.query.teamId ? parseInt(req.query.teamId as string) : null;
|
||||
let return_url = `${WEBAPP_URL}/settings/billing`;
|
||||
if (!teamId) {
|
||||
const customerId = await getStripeCustomerIdFromUserId(userId);
|
||||
if (!customerId) return res.status(404).json({ message: "CustomerId not found" });
|
||||
const returnUrl = buildReturnUrl(req.query.returnTo as string);
|
||||
|
||||
const billingPortalUrl = await getBillingPortalUrl(customerId, return_url);
|
||||
try {
|
||||
if (!teamId) {
|
||||
const userService = BillingPortalServiceFactory.createUserService();
|
||||
return await userService.processBillingPortal(user.id, returnUrl, res);
|
||||
}
|
||||
|
||||
return res.redirect(302, billingPortalUrl);
|
||||
const billingService = await BillingPortalServiceFactory.createService(teamId);
|
||||
return await billingService.processBillingPortal(user.id, teamId, returnUrl, res);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "Team not found") {
|
||||
return res.status(404).json({ message: "Team not found" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const teamRepository = new TeamRepository(prisma);
|
||||
const team = await teamRepository.getTeamByIdIfUserIsAdmin({
|
||||
teamId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!team) return res.status(404).json({ message: "Team not found" });
|
||||
|
||||
if (typeof req.query.returnTo === "string") {
|
||||
const safeRedirectUrl = getSafeRedirectUrl(req.query.returnTo);
|
||||
if (safeRedirectUrl) return_url = safeRedirectUrl;
|
||||
}
|
||||
|
||||
const teamMetadataParsed = teamMetadataSchema.safeParse(team.metadata);
|
||||
|
||||
if (!teamMetadataParsed.success) {
|
||||
return res.status(400).json({ message: "Invalid team metadata" });
|
||||
}
|
||||
|
||||
if (!teamMetadataParsed.data?.subscriptionId) {
|
||||
return res.status(400).json({ message: "subscriptionId not found for team" });
|
||||
}
|
||||
|
||||
const subscription = await getSubscriptionFromId(teamMetadataParsed.data.subscriptionId);
|
||||
|
||||
if (!subscription) {
|
||||
return res.status(400).json({ message: "Subscription not found" });
|
||||
}
|
||||
|
||||
if (!subscription.customer) {
|
||||
return res.status(400).json({ message: "Subscription customer not found" });
|
||||
}
|
||||
|
||||
const customerId = subscription.customer as string;
|
||||
|
||||
if (!customerId) return res.status(400).json({ message: "CustomerId not found in stripe" });
|
||||
|
||||
const billingPortalUrl = await getBillingPortalUrl(customerId, return_url);
|
||||
|
||||
res.redirect(302, billingPortalUrl);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Re-export all services for backward compatibility
|
||||
export {
|
||||
BillingPortalService,
|
||||
TeamBillingPortalService,
|
||||
OrganizationBillingPortalService,
|
||||
UserBillingPortalService,
|
||||
BillingPortalServiceFactory,
|
||||
} from "./services";
|
||||
|
||||
export type { TeamEntity, BillingPortalResult } from "./services";
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { NextApiResponse } from "next";
|
||||
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import stripe from "../../server";
|
||||
|
||||
export interface TeamEntity {
|
||||
id: number;
|
||||
metadata: unknown;
|
||||
isOrganization: boolean;
|
||||
}
|
||||
|
||||
export interface BillingPortalResult {
|
||||
success: boolean;
|
||||
customerId?: string;
|
||||
portalUrl?: string;
|
||||
}
|
||||
|
||||
export abstract class BillingPortalService {
|
||||
protected permissionService: PermissionCheckService;
|
||||
protected teamRepository: TeamRepository;
|
||||
protected contextName = "Team"; // Can be overridden by subclasses
|
||||
|
||||
constructor() {
|
||||
this.permissionService = new PermissionCheckService();
|
||||
this.teamRepository = new TeamRepository(prisma);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a billing portal URL for a Stripe customer
|
||||
*/
|
||||
protected async createBillingPortalUrl(customerId: string, returnUrl: string): Promise<string> {
|
||||
const log = logger.getSubLogger({ prefix: ["createBillingPortalUrl"] });
|
||||
|
||||
try {
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
return portalSession.url;
|
||||
} catch (e) {
|
||||
log.error(`Failed to create billing portal session for ${customerId}: ${e}`);
|
||||
throw new Error("Failed to create billing portal session");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a safe return URL for the billing portal
|
||||
*/
|
||||
protected buildReturnUrl(returnTo?: string): string {
|
||||
const defaultUrl = `${WEBAPP_URL}/settings/billing`;
|
||||
|
||||
if (typeof returnTo !== "string") return defaultUrl;
|
||||
|
||||
const safeRedirectUrl = getSafeRedirectUrl(returnTo);
|
||||
return safeRedirectUrl || defaultUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract method to check permissions - implemented by subclasses
|
||||
*/
|
||||
abstract checkPermissions(userId: number, teamId: number): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Abstract method to get customer ID - implemented by subclasses
|
||||
*/
|
||||
abstract getCustomerId(teamId: number): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Process billing portal request for a team
|
||||
*/
|
||||
async processBillingPortal(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
returnUrl: string,
|
||||
res: NextApiResponse
|
||||
): Promise<void> {
|
||||
// Check permissions
|
||||
const hasPermission = await this.checkPermissions(userId, teamId);
|
||||
if (!hasPermission) {
|
||||
res.status(403).json({ message: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get customer ID
|
||||
const customerId = await this.getCustomerId(teamId);
|
||||
if (!customerId) {
|
||||
res.status(400).json({
|
||||
message: `${this.contextName} billing not properly configured. Please contact support.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create portal URL and redirect
|
||||
const billingPortalUrl = await this.createBillingPortalUrl(customerId, returnUrl);
|
||||
res.redirect(302, billingPortalUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { BillingPortalService } from "../base/BillingPortalService";
|
||||
import { OrganizationBillingPortalService } from "../organization/OrganizationBillingPortalService";
|
||||
import { TeamBillingPortalService } from "../team/TeamBillingPortalService";
|
||||
import { UserBillingPortalService } from "../user/UserBillingPortalService";
|
||||
|
||||
/**
|
||||
* Factory to create the appropriate billing portal service based on team type
|
||||
*/
|
||||
export class BillingPortalServiceFactory {
|
||||
/**
|
||||
* Determines team type and returns the appropriate service
|
||||
*/
|
||||
static async createService(teamId: number): Promise<BillingPortalService> {
|
||||
const teamRepository = new TeamRepository(prisma);
|
||||
const team = await teamRepository.findById({ id: teamId });
|
||||
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
if (team.isOrganization) {
|
||||
return new OrganizationBillingPortalService();
|
||||
}
|
||||
|
||||
return new TeamBillingPortalService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a user billing portal service
|
||||
*/
|
||||
static createUserService(): UserBillingPortalService {
|
||||
return new UserBillingPortalService();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { BillingPortalService } from "./base/BillingPortalService";
|
||||
export type { TeamEntity, BillingPortalResult } from "./base/BillingPortalService";
|
||||
export { TeamBillingPortalService } from "./team/TeamBillingPortalService";
|
||||
export { OrganizationBillingPortalService } from "./organization/OrganizationBillingPortalService";
|
||||
export { UserBillingPortalService } from "./user/UserBillingPortalService";
|
||||
export { BillingPortalServiceFactory } from "./factory/BillingPortalServiceFactory";
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { getSubscriptionFromId } from "../../subscriptions";
|
||||
import { BillingPortalService } from "../base/BillingPortalService";
|
||||
|
||||
/**
|
||||
* Billing portal service for organizations
|
||||
*/
|
||||
export class OrganizationBillingPortalService extends BillingPortalService {
|
||||
constructor() {
|
||||
super();
|
||||
this.contextName = "Organization";
|
||||
}
|
||||
|
||||
async checkPermissions(userId: number, teamId: number): Promise<boolean> {
|
||||
return await this.permissionService.checkPermission({
|
||||
userId,
|
||||
teamId,
|
||||
permission: "organization.manageBilling",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
}
|
||||
|
||||
async getCustomerId(teamId: number): Promise<string | null> {
|
||||
const log = logger.getSubLogger({ prefix: ["OrganizationBillingPortalService", "getCustomerId"] });
|
||||
|
||||
const team = await this.teamRepository.findById({ id: teamId });
|
||||
if (!team) return null;
|
||||
|
||||
const teamMetadataParsed = teamMetadataSchema.safeParse(team.metadata);
|
||||
|
||||
if (!teamMetadataParsed.success || !teamMetadataParsed.data?.subscriptionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = await getSubscriptionFromId(teamMetadataParsed.data.subscriptionId);
|
||||
|
||||
if (!subscription?.customer) {
|
||||
log.warn("Subscription found but no customer ID", {
|
||||
teamId,
|
||||
subscriptionId: teamMetadataParsed.data.subscriptionId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscription.customer as string;
|
||||
} catch (error) {
|
||||
log.error("Failed to retrieve subscription", { teamId, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { getSubscriptionFromId } from "../../subscriptions";
|
||||
import { BillingPortalService } from "../base/BillingPortalService";
|
||||
|
||||
/**
|
||||
* Billing portal service for regular teams
|
||||
*/
|
||||
export class TeamBillingPortalService extends BillingPortalService {
|
||||
async checkPermissions(userId: number, teamId: number): Promise<boolean> {
|
||||
return await this.permissionService.checkPermission({
|
||||
userId,
|
||||
teamId,
|
||||
permission: "team.manageBilling",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
}
|
||||
|
||||
async getCustomerId(teamId: number): Promise<string | null> {
|
||||
const log = logger.getSubLogger({ prefix: ["TeamBillingPortalService", "getCustomerId"] });
|
||||
|
||||
const team = await this.teamRepository.findById({ id: teamId });
|
||||
if (!team) return null;
|
||||
|
||||
const teamMetadataParsed = teamMetadataSchema.safeParse(team.metadata);
|
||||
|
||||
if (!teamMetadataParsed.success || !teamMetadataParsed.data?.subscriptionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = await getSubscriptionFromId(teamMetadataParsed.data.subscriptionId);
|
||||
|
||||
if (!subscription?.customer) {
|
||||
log.warn("Subscription found but no customer ID", {
|
||||
teamId,
|
||||
subscriptionId: teamMetadataParsed.data.subscriptionId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscription.customer as string;
|
||||
} catch (error) {
|
||||
log.error("Failed to retrieve subscription", { teamId, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { NextApiResponse } from "next";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
import { getStripeCustomerIdFromUserId } from "../../customer";
|
||||
import stripe from "../../server";
|
||||
|
||||
/**
|
||||
* Billing portal service for individual users
|
||||
*/
|
||||
export class UserBillingPortalService {
|
||||
/**
|
||||
* Get customer ID for a user
|
||||
*/
|
||||
async getCustomerId(userId: number): Promise<string | null> {
|
||||
return await getStripeCustomerIdFromUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process billing portal request for a user
|
||||
*/
|
||||
async processBillingPortal(userId: number, returnUrl: string, res: NextApiResponse): Promise<void> {
|
||||
const customerId = await this.getCustomerId(userId);
|
||||
if (!customerId) {
|
||||
res.status(404).json({ message: "CustomerId not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const billingPortalUrl = await this.createBillingPortalUrl(customerId, returnUrl);
|
||||
res.redirect(302, billingPortalUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a billing portal URL for a Stripe customer
|
||||
*/
|
||||
private async createBillingPortalUrl(customerId: string, returnUrl: string): Promise<string> {
|
||||
const log = logger.getSubLogger({ prefix: ["createBillingPortalUrl"] });
|
||||
|
||||
try {
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
return portalSession.url;
|
||||
} catch (e) {
|
||||
log.error(`Failed to create billing portal session for ${customerId}: ${e}`);
|
||||
throw new Error("Failed to create billing portal session");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,6 +294,13 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
|
||||
descriptionI18nKey: "pbac_desc_impersonate_team_members",
|
||||
dependsOn: ["team.read", "team.listMembers"],
|
||||
},
|
||||
[CustomAction.ManageBilling]: {
|
||||
description: "Manage billing",
|
||||
category: "team",
|
||||
i18nKey: "pbac_action_manage_billing",
|
||||
descriptionI18nKey: "pbac_desc_manage_billing",
|
||||
scope: [], // Empty scope because this permission is only used for TEAM billing outside of an org. (We dont want to show this in the UI)
|
||||
},
|
||||
},
|
||||
[Resource.Organization]: {
|
||||
_resource: {
|
||||
|
||||
@@ -218,7 +218,7 @@ export class TeamService {
|
||||
}
|
||||
|
||||
// TODO: Needs to be moved to repository
|
||||
private static async fetchTeamOrThrow(teamId: number): Promise<TeamWithSettings> {
|
||||
static async fetchTeamOrThrow(teamId: number): Promise<TeamWithSettings> {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
|
||||
SELECT
|
||||
gen_random_uuid(), 'admin_role', resource, action, NOW()
|
||||
FROM (
|
||||
VALUES
|
||||
('team', 'manageBilling')
|
||||
) AS permissions(resource, action);
|
||||
@@ -1,8 +1,11 @@
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { TeamService } from "@calcom/lib/server/service/teamService";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -27,9 +30,17 @@ export const buyCreditsHandler = async ({ ctx, input }: BuyCreditsOptions) => {
|
||||
const { quantity, teamId } = input;
|
||||
|
||||
if (teamId) {
|
||||
const adminMembership = await MembershipRepository.getAdminOrOwnerMembership(ctx.user.id, teamId);
|
||||
const team = await TeamService.fetchTeamOrThrow(teamId);
|
||||
|
||||
if (!adminMembership) {
|
||||
const permissionService = new PermissionCheckService();
|
||||
const hasManageBillingPermission = await permissionService.checkPermission({
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
permission: team.isOrganization ? "organization.manageBilling" : "team.manageBilling",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
if (!hasManageBillingPermission) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { TeamService } from "@calcom/lib/server/service/teamService";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -28,9 +30,17 @@ export const downloadExpenseLogHandler = async ({ ctx, input }: DownloadExpenseL
|
||||
const { teamId, startDate, endDate } = input;
|
||||
|
||||
if (teamId) {
|
||||
const adminMembership = await MembershipRepository.getAdminOrOwnerMembership(ctx.user.id, teamId);
|
||||
const team = await TeamService.fetchTeamOrThrow(teamId);
|
||||
|
||||
if (!adminMembership) {
|
||||
const permissionService = new PermissionCheckService();
|
||||
const hasManageBillingPermission = await permissionService.checkPermission({
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
permission: team.isOrganization ? "organization.manageBilling" : "team.manageBilling",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
if (!hasManageBillingPermission) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { TeamService } from "@calcom/lib/server/service/teamService";
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -16,9 +19,17 @@ export const getAllCreditsHandler = async ({ ctx, input }: GetAllCreditsOptions)
|
||||
const { teamId } = input;
|
||||
|
||||
if (teamId) {
|
||||
const adminMembership = await MembershipRepository.getAdminOrOwnerMembership(ctx.user.id, teamId);
|
||||
const team = await TeamService.fetchTeamOrThrow(teamId);
|
||||
|
||||
if (!adminMembership) {
|
||||
const permissionService = new PermissionCheckService();
|
||||
const hasManageBillingPermission = await permissionService.checkPermission({
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
permission: team.isOrganization ? "organization.manageBilling" : "team.manageBilling",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
if (!hasManageBillingPermission) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user