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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user