Skip to content
Merged
26 changes: 25 additions & 1 deletion apps/web/modules/settings/billing/billing-view.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useSession } from "next-auth/react";
import { usePathname } from "next/navigation";

import { WEBAPP_URL } from "@calcom/lib/constants";
Expand Down Expand Up @@ -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) {
Expand Down
56 changes: 51 additions & 5 deletions packages/app-store/stripepayment/api/portal.ts
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this endpoint we were only showing the portal if the requesting user id matched the specific customer id tied to the subscription on Stripe. Now we determine if the requesting user is an owner/admin of the team then redirect them to the billing portal.

Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,75 @@ 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,
});
Comment on lines +26 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Only query TeamRepository when team scope is present.

Avoid hitting the DB when teamId isn’t provided; set team conditionally.

Apply this diff:

-  const teamRepository = new TeamRepository(prisma);
-  const team = await teamRepository.getTeamByIdIfUserIsAdmin({
-    teamId,
-    userId,
-  });
+  let team: any = null;
+  if (hasTeamScope) {
+    const teamRepository = new TeamRepository(prisma);
+    team = await teamRepository.getTeamByIdIfUserIsAdmin({
+      teamId: teamId!,
+      userId,
+    });
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const teamRepository = new TeamRepository(prisma);
const team = await teamRepository.getTeamByIdIfUserIsAdmin({
teamId,
userId,
});
// Only fetch the team if we have a valid team scope
let team: any = null;
if (hasTeamScope) {
const teamRepository = new TeamRepository(prisma);
team = await teamRepository.getTeamByIdIfUserIsAdmin({
teamId: teamId!,
userId,
});
}
🤖 Prompt for AI Agents
In packages/app-store/stripepayment/api/portal.ts around lines 26 to 30, the
code always queries TeamRepository regardless of whether teamId is provided;
change it to only construct TeamRepository/getTeamByIdIfUserIsAdmin and await
the call when teamId (or team scope) is present, otherwise set team to undefined
(or null) so no DB hit occurs; implement a conditional branch that checks for
teamId before calling the repository and assigns the result to the team variable
only when present.

let return_url = `${WEBAPP_URL}/settings/billing`;

if (typeof req.query.returnTo === "string") {
const safeRedirectUrl = getSafeRedirectUrl(req.query.returnTo);
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,
Expand Down
4 changes: 4 additions & 0 deletions packages/app-store/stripepayment/lib/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ export async function retrieveSubscriptionIdFromStripeCustomerId(
subscriptionId: subscription.id,
};
}

export async function getSubscriptionFromId(subscriptionId: string) {
return await stripe.subscriptions.retrieve(subscriptionId);
}
21 changes: 21 additions & 0 deletions packages/lib/server/repository/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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],
},
},
},
},
});
}
}
Loading