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 <j.auyeung419@gmail.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>
This commit is contained in:
Joe Au-Yeung
2025-09-01 08:07:36 +05:30
committed by GitHub
co-authored by Devin AI
parent c87a88b244
commit f1d1674693
4 changed files with 101 additions and 6 deletions
@@ -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) {
+51 -5
View File
@@ -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,
@@ -27,3 +27,7 @@ export async function retrieveSubscriptionIdFromStripeCustomerId(
subscriptionId: subscription.id,
};
}
export async function getSubscriptionFromId(subscriptionId: string) {
return await stripe.subscriptions.retrieve(subscriptionId);
}
+21
View File
@@ -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],
},
},
},
},
});
}
}