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();