diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index 632863962e..88954141e9 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -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; }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx index 0f1d7ead79..56fc2baf93 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx @@ -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 | 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) { ); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx index 97070aa5d0..2e84ad1f13 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx @@ -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 ( { - 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; }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgPerms.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgPerms.tsx new file mode 100644 index 0000000000..f882640401 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/actions/validateUserHasOrgPerms.tsx @@ -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; +}; diff --git a/packages/app-store/stripepayment/api/__tests__/portal.test.ts b/packages/app-store/stripepayment/api/__tests__/portal.test.ts new file mode 100644 index 0000000000..8c13dc3c14 --- /dev/null +++ b/packages/app-store/stripepayment/api/__tests__/portal.test.ts @@ -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; +} + +interface MockTeamRepository { + findById: ReturnType; +} + +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); + }); + }); +}); diff --git a/packages/app-store/stripepayment/api/portal.ts b/packages/app-store/stripepayment/api/portal.ts index 85fc320295..2434bc9684 100644 --- a/packages/app-store/stripepayment/api/portal.ts +++ b/packages/app-store/stripepayment/api/portal.ts @@ -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); } diff --git a/packages/app-store/stripepayment/lib/BillingPortalService.ts b/packages/app-store/stripepayment/lib/BillingPortalService.ts new file mode 100644 index 0000000000..c12c637b81 --- /dev/null +++ b/packages/app-store/stripepayment/lib/BillingPortalService.ts @@ -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"; diff --git a/packages/app-store/stripepayment/lib/services/base/BillingPortalService.ts b/packages/app-store/stripepayment/lib/services/base/BillingPortalService.ts new file mode 100644 index 0000000000..d89afcc68d --- /dev/null +++ b/packages/app-store/stripepayment/lib/services/base/BillingPortalService.ts @@ -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 { + 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; + + /** + * Abstract method to get customer ID - implemented by subclasses + */ + abstract getCustomerId(teamId: number): Promise; + + /** + * Process billing portal request for a team + */ + async processBillingPortal( + userId: number, + teamId: number, + returnUrl: string, + res: NextApiResponse + ): Promise { + // 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); + } +} diff --git a/packages/app-store/stripepayment/lib/services/factory/BillingPortalServiceFactory.ts b/packages/app-store/stripepayment/lib/services/factory/BillingPortalServiceFactory.ts new file mode 100644 index 0000000000..ece4731ebd --- /dev/null +++ b/packages/app-store/stripepayment/lib/services/factory/BillingPortalServiceFactory.ts @@ -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 { + 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(); + } +} diff --git a/packages/app-store/stripepayment/lib/services/index.ts b/packages/app-store/stripepayment/lib/services/index.ts new file mode 100644 index 0000000000..7ccd24cf69 --- /dev/null +++ b/packages/app-store/stripepayment/lib/services/index.ts @@ -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"; diff --git a/packages/app-store/stripepayment/lib/services/organization/OrganizationBillingPortalService.ts b/packages/app-store/stripepayment/lib/services/organization/OrganizationBillingPortalService.ts new file mode 100644 index 0000000000..d58c0eae58 --- /dev/null +++ b/packages/app-store/stripepayment/lib/services/organization/OrganizationBillingPortalService.ts @@ -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 { + return await this.permissionService.checkPermission({ + userId, + teamId, + permission: "organization.manageBilling", + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }); + } + + async getCustomerId(teamId: number): Promise { + 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; + } + } +} diff --git a/packages/app-store/stripepayment/lib/services/team/TeamBillingPortalService.ts b/packages/app-store/stripepayment/lib/services/team/TeamBillingPortalService.ts new file mode 100644 index 0000000000..77eb559bcb --- /dev/null +++ b/packages/app-store/stripepayment/lib/services/team/TeamBillingPortalService.ts @@ -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 { + return await this.permissionService.checkPermission({ + userId, + teamId, + permission: "team.manageBilling", + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }); + } + + async getCustomerId(teamId: number): Promise { + 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; + } + } +} diff --git a/packages/app-store/stripepayment/lib/services/user/UserBillingPortalService.ts b/packages/app-store/stripepayment/lib/services/user/UserBillingPortalService.ts new file mode 100644 index 0000000000..25cd484ca7 --- /dev/null +++ b/packages/app-store/stripepayment/lib/services/user/UserBillingPortalService.ts @@ -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 { + return await getStripeCustomerIdFromUserId(userId); + } + + /** + * Process billing portal request for a user + */ + async processBillingPortal(userId: number, returnUrl: string, res: NextApiResponse): Promise { + 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 { + 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"); + } + } +} diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts index 986c223ffa..3cfbc54d9d 100644 --- a/packages/features/pbac/domain/types/permission-registry.ts +++ b/packages/features/pbac/domain/types/permission-registry.ts @@ -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: { diff --git a/packages/lib/server/service/teamService.ts b/packages/lib/server/service/teamService.ts index c33320ca4d..64c947a058 100644 --- a/packages/lib/server/service/teamService.ts +++ b/packages/lib/server/service/teamService.ts @@ -218,7 +218,7 @@ export class TeamService { } // TODO: Needs to be moved to repository - private static async fetchTeamOrThrow(teamId: number): Promise { + static async fetchTeamOrThrow(teamId: number): Promise { const team = await prisma.team.findUnique({ where: { id: teamId }, select: { diff --git a/packages/prisma/migrations/20250911093331_pbac_team_billing/migration.sql b/packages/prisma/migrations/20250911093331_pbac_team_billing/migration.sql new file mode 100644 index 0000000000..2b67281002 --- /dev/null +++ b/packages/prisma/migrations/20250911093331_pbac_team_billing/migration.sql @@ -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); diff --git a/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts b/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts index 321e45557a..2478a07b2d 100644 --- a/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts +++ b/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts @@ -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", }); diff --git a/packages/trpc/server/routers/viewer/credits/downloadExpenseLog.handler.ts b/packages/trpc/server/routers/viewer/credits/downloadExpenseLog.handler.ts index 3637b7e6e0..c88e62e6f4 100644 --- a/packages/trpc/server/routers/viewer/credits/downloadExpenseLog.handler.ts +++ b/packages/trpc/server/routers/viewer/credits/downloadExpenseLog.handler.ts @@ -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", }); diff --git a/packages/trpc/server/routers/viewer/credits/getAllCredits.handler.ts b/packages/trpc/server/routers/viewer/credits/getAllCredits.handler.ts index 061d534dcd..f3149f25f8 100644 --- a/packages/trpc/server/routers/viewer/credits/getAllCredits.handler.ts +++ b/packages/trpc/server/routers/viewer/credits/getAllCredits.handler.ts @@ -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", });