From c3f03c8000312f4a15490995c0690eb34cda9f45 Mon Sep 17 00:00:00 2001
From: Rajiv Sahal
Date: Wed, 30 Oct 2024 18:58:26 +0530
Subject: [PATCH] fix: stripe connect atom for teams (#17294)
* add teamId
* add team app credentials method
* add handler to get all user admin teams
* update stripe logic to handle teams
* enable teamId for stripe connect
* update stripe module
* implement PR feedback
* update stripe module
* add method to get credential by team id
* handle check for teamId
* abstract team and individual stripe connection checks into two separate parts
* hide badge for platform user
* update typing
* add logic to handle team stripe integration
* bump platform libraries
* bump platform libraries
---------
Co-authored-by: Morgan Vernay
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
---
apps/api/v2/package.json | 2 +-
.../v2/src/modules/apps/apps.repository.ts | 11 ++++
.../credentials/credentials.repository.ts | 4 ++
.../stripe/controllers/stripe.controller.ts | 17 +++++-
.../v2/src/modules/stripe/stripe.module.ts | 3 +-
.../v2/src/modules/stripe/stripe.service.ts | 57 +++++++++++++++++--
packages/app-store/_components/AppCard.tsx | 2 +-
.../atoms/connect/stripe/StripeConnect.tsx | 20 +++++--
.../hooks/useAtomEventTypeAppIntegration.ts | 18 +++++-
.../EventPaymentsTabPlatformWrapper.tsx | 56 ++++++++++++++----
.../platform/atoms/hooks/stripe/useCheck.ts | 46 ++++++++++++++-
.../platform/atoms/hooks/stripe/useConnect.ts | 8 +--
12 files changed, 210 insertions(+), 34 deletions(-)
diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json
index 98d040fdf5..6dffb53412 100644
--- a/apps/api/v2/package.json
+++ b/apps/api/v2/package.json
@@ -28,7 +28,7 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
- "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.54",
+ "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.56",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
diff --git a/apps/api/v2/src/modules/apps/apps.repository.ts b/apps/api/v2/src/modules/apps/apps.repository.ts
index 0ed0e30c18..e045f084d0 100644
--- a/apps/api/v2/src/modules/apps/apps.repository.ts
+++ b/apps/api/v2/src/modules/apps/apps.repository.ts
@@ -21,4 +21,15 @@ export class AppsRepository {
},
});
}
+
+ async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
+ return this.dbWrite.prisma.credential.create({
+ data: {
+ type: type,
+ key: key,
+ teamId: teamId,
+ appId: appId,
+ },
+ });
+ }
}
diff --git a/apps/api/v2/src/modules/credentials/credentials.repository.ts b/apps/api/v2/src/modules/credentials/credentials.repository.ts
index 9ece085a5f..5242944338 100644
--- a/apps/api/v2/src/modules/credentials/credentials.repository.ts
+++ b/apps/api/v2/src/modules/credentials/credentials.repository.ts
@@ -37,6 +37,10 @@ export class CredentialsRepository {
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
}
+ getByTypeAndTeamId(type: string, teamId: number) {
+ return this.dbWrite.prisma.credential.findFirst({ where: { type, teamId } });
+ }
+
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
}
diff --git a/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts b/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts
index a19c65f721..2333346f17 100644
--- a/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts
+++ b/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts
@@ -21,6 +21,7 @@ import {
Req,
BadRequestException,
Headers,
+ Param,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiOperation } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
@@ -46,7 +47,8 @@ export class StripeController {
@Headers("Authorization") authorization: string,
@GetUser() user: UserWithProfile,
@Query("redir") redir?: string | null,
- @Query("errorRedir") errorRedir?: string | null
+ @Query("errorRedir") errorRedir?: string | null,
+ @Query("teamId") teamId?: string | null
): Promise {
const origin = req.headers.origin;
const accessToken = authorization.replace("Bearer ", "");
@@ -56,6 +58,7 @@ export class StripeController {
fromApp: false,
returnTo: !!redir ? redir : origin,
accessToken,
+ teamId: Number(teamId) ?? null,
};
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(
@@ -99,6 +102,16 @@ export class StripeController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Check stripe connection" })
async check(@GetUser() user: UserWithProfile): Promise {
- return await this.stripeService.checkIfStripeAccountConnected(user.id);
+ return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
+ }
+
+ @Get("/check/:teamId")
+ @UseGuards(ApiAuthGuard)
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: "Check team stripe connection" })
+ async checkTeamStripeConnection(
+ @Param("teamId") teamId: string
+ ): Promise {
+ return await this.stripeService.checkIfTeamStripeAccountConnected(Number(teamId));
}
}
diff --git a/apps/api/v2/src/modules/stripe/stripe.module.ts b/apps/api/v2/src/modules/stripe/stripe.module.ts
index 0191c2fad7..4d6456eb2d 100644
--- a/apps/api/v2/src/modules/stripe/stripe.module.ts
+++ b/apps/api/v2/src/modules/stripe/stripe.module.ts
@@ -1,5 +1,6 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
+import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeController } from "@/modules/stripe/controllers/stripe.controller";
import { StripeService } from "@/modules/stripe/stripe.service";
@@ -10,7 +11,7 @@ import { ConfigModule } from "@nestjs/config";
@Module({
imports: [ConfigModule, PrismaModule],
exports: [StripeService],
- providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository],
+ providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository],
controllers: [StripeController],
})
export class StripeModule {}
diff --git a/apps/api/v2/src/modules/stripe/stripe.service.ts b/apps/api/v2/src/modules/stripe/stripe.service.ts
index a1bf625578..dd4f89800b 100644
--- a/apps/api/v2/src/modules/stripe/stripe.service.ts
+++ b/apps/api/v2/src/modules/stripe/stripe.service.ts
@@ -1,13 +1,14 @@
import { AppConfig } from "@/config/type";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
+import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
-import type { Prisma } from "@prisma/client";
+import type { Prisma, Credential } from "@prisma/client";
import Stripe from "stripe";
import { z } from "zod";
@@ -17,6 +18,14 @@ import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas";
import stringify = require("qs-stringify");
+type IntegrationOAuthCallbackState = {
+ accessToken: string;
+ returnTo: string;
+ onErrorReturnTo: string;
+ fromApp: boolean;
+ teamId?: number | null;
+};
+
@Injectable()
export class StripeService {
public stripe: Stripe;
@@ -27,7 +36,8 @@ export class StripeService {
private readonly config: ConfigService,
private readonly appsRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
- private readonly tokensRepository: TokensRepository
+ private readonly tokensRepository: TokensRepository,
+ private readonly membershipRepository: MembershipsRepository
) {
this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
apiVersion: "2020-08-27",
@@ -76,6 +86,7 @@ export class StripeService {
async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> {
const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
+ const oAuthCallbackState: IntegrationOAuthCallbackState = JSON.parse(state);
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
@@ -92,6 +103,19 @@ export class StripeService {
data["default_currency"] = account.default_currency;
}
+ if (oAuthCallbackState.teamId) {
+ await this.checkIfUserHasAdminAccessToTeam(oAuthCallbackState.teamId, userId);
+
+ await this.appsRepository.createTeamAppCredential(
+ "stripe_payment",
+ data as unknown as Prisma.InputJsonObject,
+ oAuthCallbackState.teamId,
+ "stripe"
+ );
+
+ return { url: getReturnToValueFromQueryState(state) };
+ }
+
await this.appsRepository.createAppCredential(
"stripe_payment",
data as unknown as Prisma.InputJsonObject,
@@ -102,18 +126,39 @@ export class StripeService {
return { url: getReturnToValueFromQueryState(state) };
}
- async checkIfStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
+ async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId);
- if (!stripeCredentials) {
+ return await this.validateStripeCredentials(stripeCredentials);
+ }
+
+ async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
+ const stripeCredentials = await this.credentialRepository.getByTypeAndTeamId("stripe_payment", teamId);
+
+ return await this.validateStripeCredentials(stripeCredentials);
+ }
+
+ async checkIfUserHasAdminAccessToTeam(teamId: number, userId: number) {
+ const teamMembership = await this.membershipRepository.findMembershipByTeamId(teamId, userId);
+ const hasAdminAccessToTeam = teamMembership?.role === "ADMIN" || teamMembership?.role === "OWNER";
+
+ if (!hasAdminAccessToTeam) {
+ throw new BadRequestException("You must be team owner or admin to do this");
+ }
+ }
+
+ async validateStripeCredentials(
+ credentials?: Credential | null
+ ): Promise<{ status: typeof SUCCESS_STATUS }> {
+ if (!credentials) {
throw new NotFoundException("Credentials for stripe not found.");
}
- if (stripeCredentials.invalid) {
+ if (credentials.invalid) {
throw new BadRequestException("Invalid stripe credentials.");
}
- const stripeKey = JSON.stringify(stripeCredentials.key);
+ const stripeKey = JSON.stringify(credentials.key);
const stripeKeyObject = JSON.parse(stripeKey);
const stripeAccount = await stripeInstance.accounts.retrieve(stripeKeyObject?.stripe_user_id);
diff --git a/packages/app-store/_components/AppCard.tsx b/packages/app-store/_components/AppCard.tsx
index 094fb73eed..1140b49cbd 100644
--- a/packages/app-store/_components/AppCard.tsx
+++ b/packages/app-store/_components/AppCard.tsx
@@ -81,7 +81,7 @@ export default function AppCard({
- {app.credentialOwner && (
+ {app.credentialOwner && !isPlatform && (
diff --git a/packages/platform/atoms/connect/stripe/StripeConnect.tsx b/packages/platform/atoms/connect/stripe/StripeConnect.tsx
index eccb61a961..fb9b321fd6 100644
--- a/packages/platform/atoms/connect/stripe/StripeConnect.tsx
+++ b/packages/platform/atoms/connect/stripe/StripeConnect.tsx
@@ -6,12 +6,13 @@ import type { IconName } from "@calcom/ui";
import type { ButtonColor } from "@calcom/ui";
import type { OnCheckErrorType, UseCheckProps } from "../../hooks/connect/useCheck";
-import { useCheck } from "../../hooks/stripe/useCheck";
+import { useTeamCheck, useCheck } from "../../hooks/stripe/useCheck";
import { useConnect } from "../../hooks/stripe/useConnect";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { cn } from "../../src/lib/utils";
type StripeConnectProps = {
+ teamId?: number | null;
icon?: IconName;
color?: ButtonColor;
isClickable?: boolean;
@@ -27,6 +28,7 @@ type StripeConnectProps = {
};
export const StripeConnect: FC
> = ({
+ teamId,
icon,
color,
isClickable,
@@ -41,17 +43,25 @@ export const StripeConnect: FC> = ({
onCheckSuccess,
}) => {
const { t } = useLocale();
- const { connect } = useConnect(redir, errorRedir);
+ let displayedLabel = label || t("stripe_connect_atom_label");
+
+ const { connect } = useConnect(redir, errorRedir, teamId);
const { allowConnect, checked } = useCheck({
onCheckError,
onCheckSuccess,
initialData,
});
+ const { allowConnect: allowConnectTeam, checked: checkedTeam } = useTeamCheck({
+ teamId,
+ onCheckError,
+ onCheckSuccess,
+ initialData,
+ });
- let displayedLabel = label || t("stripe_connect_atom_label");
+ const isChecking = teamId ? !checkedTeam : !checked;
+ const isAllowConnect = teamId ? allowConnectTeam : allowConnect;
- const isChecking = !checked;
- const isDisabled = isChecking || !allowConnect;
+ const isDisabled = isChecking || !isAllowConnect;
if (isChecking) {
displayedLabel = loadingLabel || t("stripe_connect_atom_loading_label");
diff --git a/packages/platform/atoms/event-types/hooks/useAtomEventTypeAppIntegration.ts b/packages/platform/atoms/event-types/hooks/useAtomEventTypeAppIntegration.ts
index bbfb83b5d3..e6e5c3a17b 100644
--- a/packages/platform/atoms/event-types/hooks/useAtomEventTypeAppIntegration.ts
+++ b/packages/platform/atoms/event-types/hooks/useAtomEventTypeAppIntegration.ts
@@ -1,22 +1,34 @@
import { useQuery } from "@tanstack/react-query";
+import type { CredentialDataWithTeamName, LocationOption } from "@calcom/app-store/utils";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
-import type { App, CredentialDataWithTeamName, LocationOption } from "@calcom/platform-libraries-1.2.3";
import type { ApiResponse } from "@calcom/platform-types";
+import type { App } from "@calcom/types/App";
import { useAtomsContext } from "../../hooks/useAtomsContext";
import http from "../../lib/http";
+type UserTeams =
+ | []
+ | {
+ teamId: number;
+ name: string;
+ logoUrl: string | null;
+ credentialId: number;
+ isAdmin: boolean;
+ }[];
+
type EnabledAppType = App & {
credential: CredentialDataWithTeamName;
credentials: CredentialDataWithTeamName[];
userCredentialIds: number[];
locationOption: LocationOption | null;
+ teams: UserTeams;
};
export const QUERY_KEY = "use-event-app-integration";
-export const useAtomsEventTypeById = (appSlug: string | null) => {
- const pathname = `/atoms/event-types-app/${appSlug}`;
+export const useAtomsEventTypeById = (appSlug: string | null, teamId: number | null) => {
+ const pathname = `/atoms/event-types-app/${appSlug}${teamId ? `?teamId=${teamId}` : ""}`;
const { isInit, accessToken } = useAtomsContext();
return useQuery({
diff --git a/packages/platform/atoms/event-types/wrappers/EventPaymentsTabPlatformWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventPaymentsTabPlatformWrapper.tsx
index 0987e835dd..f6cdb10450 100644
--- a/packages/platform/atoms/event-types/wrappers/EventPaymentsTabPlatformWrapper.tsx
+++ b/packages/platform/atoms/event-types/wrappers/EventPaymentsTabPlatformWrapper.tsx
@@ -9,12 +9,19 @@ import useAppsData from "@calcom/lib/hooks/useAppsData";
import { EmptyScreen } from "@calcom/ui";
import { StripeConnect } from "../../connect/stripe/StripeConnect";
-import { useCheck } from "../../hooks/stripe/useCheck";
+import { useCheck, useTeamCheck } from "../../hooks/stripe/useCheck";
import { useAtomsEventTypeById } from "../hooks/useAtomEventTypeAppIntegration";
const EventPaymentsTabPlatformWrapper = ({ eventType }: { eventType: EventTypeSetupProps["eventType"] }) => {
const { allowConnect, checked } = useCheck({});
- const isStripeConnected = !checked || !allowConnect;
+ const { allowConnect: allowConnectTeam, checked: checkedTeam } = useTeamCheck({ teamId: eventType.teamId });
+
+ const isAllowConnect = eventType.teamId ? allowConnectTeam : allowConnect;
+ const isChecking = eventType.teamId ? !checkedTeam : !checked;
+
+ const isStripeConnected = isChecking || !isAllowConnect;
+
+ if (isChecking) return Checking...
;
return (
@@ -25,6 +32,7 @@ const EventPaymentsTabPlatformWrapper = ({ eventType }: { eventType: EventTypeSe
description="You need to connect Stripe to use this feature. Pleae click on the button below to connect."
buttonRaw={
{
const { getAppDataGetter, getAppDataSetter, eventTypeFormMetadata } = useAppsData();
- const { data: stripeData, isLoading } = useAtomsEventTypeById("stripe");
-
+ const { data: stripeData, isLoading } = useAtomsEventTypeById("stripe", eventType.teamId);
const transformedAppData = {
...stripeData?.app,
logo: `https://app.cal.com${stripeData?.app.logo}`,
@@ -52,21 +59,50 @@ const StripeAppCard = ({ eventType }: { eventType: EventTypeSetupProps["eventTyp
if (isLoading || !stripeData) return null;
- return (
-
+ if (!!transformedAppData.teams && transformedAppData.teams.length > 0) {
+ const eventTypeAssociatedTeam = transformedAppData.teams.find((team) => team.teamId === eventType.teamId);
+
+ if (!eventTypeAssociatedTeam) return null;
+
+ return (
-
+ );
+ }
+
+ return (
+
);
};
diff --git a/packages/platform/atoms/hooks/stripe/useCheck.ts b/packages/platform/atoms/hooks/stripe/useCheck.ts
index 5e4b631acf..0fb06d3038 100644
--- a/packages/platform/atoms/hooks/stripe/useCheck.ts
+++ b/packages/platform/atoms/hooks/stripe/useCheck.ts
@@ -19,6 +19,8 @@ export interface UseCheckProps {
};
}
const stripeQueryKey = ["get-stripe-check"];
+const stripeTeamQueryKey = ["get-stripe-team-check"];
+
export type OnCheckErrorType = (err: ApiErrorResponse) => void;
export const getQueryKey = (calendar: (typeof CALENDARS)[number]) => [`get-${calendar}-check`];
@@ -28,7 +30,6 @@ export const useCheck = ({ onCheckError, initialData, onCheckSuccess }: UseCheck
const { data: check, refetch } = useQuery({
queryKey: stripeQueryKey,
- staleTime: 6000,
enabled: isInit && !!accessToken,
queryFn: () => {
return http
@@ -60,3 +61,46 @@ export const useCheck = ({ onCheckError, initialData, onCheckSuccess }: UseCheck
},
};
};
+
+export const useTeamCheck = ({
+ teamId,
+ onCheckError,
+ initialData,
+ onCheckSuccess,
+}: UseCheckProps & { teamId?: number | null }) => {
+ const { isInit, accessToken } = useAtomsContext();
+ const queryClient = useQueryClient();
+
+ const { data: check, refetch } = useQuery({
+ queryKey: stripeTeamQueryKey,
+ enabled: isInit && !!accessToken && !!teamId,
+ queryFn: () => {
+ return http
+ ?.get>(`/stripe/check/${teamId}`)
+ .then(({ data: responseBody }) => {
+ if (responseBody.status === SUCCESS_STATUS) {
+ onCheckSuccess?.();
+ return { status: SUCCESS_STATUS, data: { allowConnect: false, checked: true } };
+ }
+ onCheckError?.(responseBody);
+ return { status: ERROR_STATUS, data: { allowConnect: true, checked: true } };
+ })
+ .catch((err) => {
+ onCheckError?.(err);
+ return { status: ERROR_STATUS, data: { allowConnect: true, checked: true } };
+ });
+ },
+ initialData,
+ });
+ return {
+ allowConnect: check?.data?.allowConnect ?? false,
+ checked: check?.data?.checked ?? false,
+ refetch: () => {
+ queryClient.setQueryData(stripeQueryKey, {
+ status: SUCCESS_STATUS,
+ data: { allowConnect: false, checked: false },
+ });
+ refetch();
+ },
+ };
+};
diff --git a/packages/platform/atoms/hooks/stripe/useConnect.ts b/packages/platform/atoms/hooks/stripe/useConnect.ts
index 4102d63dd0..5b0066be49 100644
--- a/packages/platform/atoms/hooks/stripe/useConnect.ts
+++ b/packages/platform/atoms/hooks/stripe/useConnect.ts
@@ -5,7 +5,7 @@ import type { ApiResponse } from "@calcom/platform-types";
import http from "../../lib/http";
-export const useGetRedirectUrl = (redir?: string, errorRedir?: string) => {
+export const useGetRedirectUrl = (redir?: string, errorRedir?: string, teamId?: number | null) => {
const authUrl = useQuery({
queryKey: ["get-stripe-connect-redirect-uri"],
staleTime: Infinity,
@@ -15,7 +15,7 @@ export const useGetRedirectUrl = (redir?: string, errorRedir?: string) => {
?.get>(
`/stripe/connect${redir ? `?redir=${encodeURIComponent(redir)}` : "?redir="}${
errorRedir ? `&errorRedir=${encodeURIComponent(errorRedir)}` : ""
- }`
+ }${teamId ? `&teamId=${teamId}` : ""}`
)
.then(({ data: responseBody }) => {
if (responseBody.status === SUCCESS_STATUS) {
@@ -30,8 +30,8 @@ export const useGetRedirectUrl = (redir?: string, errorRedir?: string) => {
return authUrl;
};
-export const useConnect = (redir?: string, errorRedir?: string) => {
- const { refetch } = useGetRedirectUrl(redir, errorRedir);
+export const useConnect = (redir?: string, errorRedir?: string, teamId?: number | null) => {
+ const { refetch } = useGetRedirectUrl(redir, errorRedir, teamId);
const connect = async () => {
const redirectUri = await refetch();