Skip to content

Commit f1d1674

Browse files
feat: allow organization owners access to shared billing portal (calcom#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>
1 parent c87a88b commit f1d1674

4 files changed

Lines changed: 101 additions & 6 deletions

File tree

apps/web/modules/settings/billing/billing-view.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { useSession } from "next-auth/react";
34
import { usePathname } from "next/navigation";
45

56
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -42,9 +43,32 @@ export const CtaRow = ({ title, description, className, children }: CtaRowProps)
4243

4344
const BillingView = () => {
4445
const pathname = usePathname();
46+
const session = useSession();
4547
const { t } = useLocale();
4648
const returnTo = pathname;
47-
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
49+
50+
// Determine the billing context and extract appropriate team/org ID
51+
const getTeamIdFromContext = () => {
52+
if (!pathname) return null;
53+
54+
// Team billing: /settings/teams/{id}/billing
55+
if (pathname.includes("/teams/") && pathname.includes("/billing")) {
56+
const teamIdMatch = pathname.match(/\/teams\/(\d+)\/billing/);
57+
return teamIdMatch ? teamIdMatch[1] : null;
58+
}
59+
60+
// Organization billing: /settings/organizations/billing
61+
if (pathname.includes("/organizations/billing")) {
62+
const orgId = session.data?.user?.org?.id;
63+
return typeof orgId === "number" ? orgId.toString() : null;
64+
}
65+
};
66+
67+
const teamId = getTeamIdFromContext();
68+
69+
const billingHref = teamId
70+
? `/api/integrations/stripepayment/portal?teamId=${teamId}&returnTo=${WEBAPP_URL}${returnTo}`
71+
: `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
4872

4973
const onContactSupportClick = async () => {
5074
if (window.Plain) {

packages/app-store/stripepayment/api/portal.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,75 @@ import type { NextApiRequest, NextApiResponse } from "next";
22

33
import { WEBAPP_URL } from "@calcom/lib/constants";
44
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
5+
import { TeamRepository } from "@calcom/lib/server/repository/team";
6+
import prisma from "@calcom/prisma";
7+
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
58

69
import { getStripeCustomerIdFromUserId } from "../lib/customer";
710
import stripe from "../lib/server";
11+
import { getSubscriptionFromId } from "../lib/subscriptions";
812

913
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
1014
if (req.method !== "POST" && req.method !== "GET")
1115
return res.status(405).json({ message: "Method not allowed" });
1216

13-
// if (!referer) return res.status(400).json({ message: "Missing referrer" });
14-
1517
if (!req.session?.user?.id) return res.status(401).json({ message: "Not authenticated" });
1618

17-
// If accessing a user's portal
18-
const customerId = await getStripeCustomerIdFromUserId(req.session.user.id);
19-
if (!customerId) return res.status(400).json({ message: "CustomerId not found in stripe" });
19+
const userId = req.session.user.id;
20+
const teamId = req.query.teamId ? parseInt(req.query.teamId as string) : null;
21+
22+
if (!teamId) {
23+
return res.status(400).json({ message: "Team ID is required" });
24+
}
2025

26+
const teamRepository = new TeamRepository(prisma);
27+
const team = await teamRepository.getTeamByIdIfUserIsAdmin({
28+
teamId,
29+
userId,
30+
});
2131
let return_url = `${WEBAPP_URL}/settings/billing`;
2232

2333
if (typeof req.query.returnTo === "string") {
2434
const safeRedirectUrl = getSafeRedirectUrl(req.query.returnTo);
2535
if (safeRedirectUrl) return_url = safeRedirectUrl;
2636
}
2737

38+
if (!team) {
39+
const customerId = await getStripeCustomerIdFromUserId(userId);
40+
if (!customerId) return res.status(404).json({ message: "CustomerId not found" });
41+
42+
const portalSession = await stripe.billingPortal.sessions.create({
43+
customer: customerId,
44+
return_url,
45+
});
46+
47+
return res.status(200).json({ url: portalSession.url });
48+
}
49+
50+
const teamMetadataParsed = teamMetadataSchema.safeParse(team.metadata);
51+
52+
if (!teamMetadataParsed.success) {
53+
return res.status(400).json({ message: "Invalid team metadata" });
54+
}
55+
56+
if (!teamMetadataParsed.data?.subscriptionId) {
57+
return res.status(400).json({ message: "subscriptionId not found for team" });
58+
}
59+
60+
const subscription = await getSubscriptionFromId(teamMetadataParsed.data.subscriptionId);
61+
62+
if (!subscription) {
63+
return res.status(400).json({ message: "Subscription not found" });
64+
}
65+
66+
if (!subscription.customer) {
67+
return res.status(400).json({ message: "Subscription customer not found" });
68+
}
69+
70+
const customerId = subscription.customer as string;
71+
72+
if (!customerId) return res.status(400).json({ message: "CustomerId not found in stripe" });
73+
2874
const stripeSession = await stripe.billingPortal.sessions.create({
2975
customer: customerId,
3076
return_url,

packages/app-store/stripepayment/lib/subscriptions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,7 @@ export async function retrieveSubscriptionIdFromStripeCustomerId(
2727
subscriptionId: subscription.id,
2828
};
2929
}
30+
31+
export async function getSubscriptionFromId(subscriptionId: string) {
32+
return await stripe.subscriptions.retrieve(subscriptionId);
33+
}

packages/lib/server/repository/team.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizatio
55
import logger from "@calcom/lib/logger";
66
import type { PrismaClient } from "@calcom/prisma";
77
import prisma from "@calcom/prisma";
8+
import { MembershipRole } from "@calcom/prisma/enums";
89
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
910

1011
import { getParsedTeam } from "./teamUtils";
@@ -377,4 +378,24 @@ export class TeamRepository {
377378
},
378379
});
379380
}
381+
382+
async getTeamByIdIfUserIsAdmin({ userId, teamId }: { userId: number; teamId: number }) {
383+
return await this.prismaClient.team.findUnique({
384+
where: {
385+
id: teamId,
386+
},
387+
select: {
388+
id: true,
389+
metadata: true,
390+
members: {
391+
where: {
392+
userId,
393+
role: {
394+
in: [MembershipRole.ADMIN, MembershipRole.OWNER],
395+
},
396+
},
397+
},
398+
},
399+
});
400+
}
380401
}

0 commit comments

Comments
 (0)