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:
sean-brydon
2025-09-23 14:01:21 +01:00
committed by GitHub
co-authored by coderabbitai[bot]
parent 14b3344a41
commit 224e606439
20 changed files with 723 additions and 121 deletions
@@ -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 }}
/>
</>
);
@@ -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
@@ -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;
};
@@ -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);
});
});
});
+41 -69
View File
@@ -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";
@@ -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: {
+1 -1
View File
@@ -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",
});