From f1d16746933eaac9fa76a2903a21b70b428f6641 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Sun, 31 Aug 2025 22:37:36 -0400 Subject: [PATCH] feat: allow organization owners access to shared billing portal (#23451) * feat: allow organization owners access to shared billing portal - Modify stripepayment portal endpoint to accept teamId parameter - Use team's subscription ID to get customer ID from Stripe subscription - Validate user permissions before allowing team billing access - Update platform and regular billing views to pass teamId - Maintain backward compatibility with individual user billing Co-Authored-By: joe@cal.com * Add `getTeamByIdIfUserIsAdmin` to `TeamRepository` * Add `getSubscriptionFromId` function * Generate portal URL via the team's subscription not the requesting user's customerId * Pass teamId to `/portal` endpoint * Undo Devin change to platform billing * Address coderabbit comments --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../modules/settings/billing/billing-view.tsx | 26 ++++++++- .../app-store/stripepayment/api/portal.ts | 56 +++++++++++++++++-- .../stripepayment/lib/subscriptions.ts | 4 ++ packages/lib/server/repository/team.ts | 21 +++++++ 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/apps/web/modules/settings/billing/billing-view.tsx b/apps/web/modules/settings/billing/billing-view.tsx index d1098b9027..82459fab49 100644 --- a/apps/web/modules/settings/billing/billing-view.tsx +++ b/apps/web/modules/settings/billing/billing-view.tsx @@ -1,5 +1,6 @@ "use client"; +import { useSession } from "next-auth/react"; import { usePathname } from "next/navigation"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -42,9 +43,32 @@ export const CtaRow = ({ title, description, className, children }: CtaRowProps) const BillingView = () => { const pathname = usePathname(); + const session = useSession(); const { t } = useLocale(); const returnTo = pathname; - const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`; + + // Determine the billing context and extract appropriate team/org ID + const getTeamIdFromContext = () => { + if (!pathname) return null; + + // Team billing: /settings/teams/{id}/billing + if (pathname.includes("/teams/") && pathname.includes("/billing")) { + const teamIdMatch = pathname.match(/\/teams\/(\d+)\/billing/); + return teamIdMatch ? teamIdMatch[1] : null; + } + + // Organization billing: /settings/organizations/billing + if (pathname.includes("/organizations/billing")) { + const orgId = session.data?.user?.org?.id; + return typeof orgId === "number" ? orgId.toString() : null; + } + }; + + const teamId = getTeamIdFromContext(); + + const billingHref = teamId + ? `/api/integrations/stripepayment/portal?teamId=${teamId}&returnTo=${WEBAPP_URL}${returnTo}` + : `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`; const onContactSupportClick = async () => { if (window.Plain) { diff --git a/packages/app-store/stripepayment/api/portal.ts b/packages/app-store/stripepayment/api/portal.ts index 8c508bae51..fee06da72c 100644 --- a/packages/app-store/stripepayment/api/portal.ts +++ b/packages/app-store/stripepayment/api/portal.ts @@ -2,22 +2,32 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +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"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "POST" && req.method !== "GET") return res.status(405).json({ message: "Method not allowed" }); - // if (!referer) return res.status(400).json({ message: "Missing referrer" }); - if (!req.session?.user?.id) return res.status(401).json({ message: "Not authenticated" }); - // If accessing a user's portal - const customerId = await getStripeCustomerIdFromUserId(req.session.user.id); - if (!customerId) return res.status(400).json({ message: "CustomerId not found in stripe" }); + const userId = req.session.user.id; + const teamId = req.query.teamId ? parseInt(req.query.teamId as string) : null; + if (!teamId) { + return res.status(400).json({ message: "Team ID is required" }); + } + + const teamRepository = new TeamRepository(prisma); + const team = await teamRepository.getTeamByIdIfUserIsAdmin({ + teamId, + userId, + }); let return_url = `${WEBAPP_URL}/settings/billing`; if (typeof req.query.returnTo === "string") { @@ -25,6 +35,42 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (safeRedirectUrl) return_url = safeRedirectUrl; } + if (!team) { + const customerId = await getStripeCustomerIdFromUserId(userId); + if (!customerId) return res.status(404).json({ message: "CustomerId not found" }); + + const portalSession = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url, + }); + + return res.status(200).json({ url: portalSession.url }); + } + + 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 stripeSession = await stripe.billingPortal.sessions.create({ customer: customerId, return_url, diff --git a/packages/app-store/stripepayment/lib/subscriptions.ts b/packages/app-store/stripepayment/lib/subscriptions.ts index ed1bc184cf..669d257bf6 100644 --- a/packages/app-store/stripepayment/lib/subscriptions.ts +++ b/packages/app-store/stripepayment/lib/subscriptions.ts @@ -27,3 +27,7 @@ export async function retrieveSubscriptionIdFromStripeCustomerId( subscriptionId: subscription.id, }; } + +export async function getSubscriptionFromId(subscriptionId: string) { + return await stripe.subscriptions.retrieve(subscriptionId); +} diff --git a/packages/lib/server/repository/team.ts b/packages/lib/server/repository/team.ts index 984c5fed93..72769f8831 100644 --- a/packages/lib/server/repository/team.ts +++ b/packages/lib/server/repository/team.ts @@ -5,6 +5,7 @@ import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizatio import logger from "@calcom/lib/logger"; import type { PrismaClient } from "@calcom/prisma"; import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { getParsedTeam } from "./teamUtils"; @@ -377,4 +378,24 @@ export class TeamRepository { }, }); } + + async getTeamByIdIfUserIsAdmin({ userId, teamId }: { userId: number; teamId: number }) { + return await this.prismaClient.team.findUnique({ + where: { + id: teamId, + }, + select: { + id: true, + metadata: true, + members: { + where: { + userId, + role: { + in: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }, + }, + }); + } }