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 <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Rajiv Sahal
2024-10-30 15:28:26 +02:00
committed by GitHub
co-authored by Morgan Vernay Morgan
parent aaa5531a3a
commit c3f03c8000
12 changed files with 210 additions and 34 deletions
+1 -1
View File
@@ -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": "*",
@@ -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,
},
});
}
}
@@ -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 } });
}
@@ -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<StripConnectOutputResponseDto> {
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<StripCredentialsCheckOutputResponseDto> {
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<StripCredentialsCheckOutputResponseDto> {
return await this.stripeService.checkIfTeamStripeAccountConnected(Number(teamId));
}
}
@@ -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 {}
@@ -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);
+1 -1
View File
@@ -81,7 +81,7 @@ export default function AppCard({
</p>
</div>
<div className="ml-auto flex items-center space-x-2">
{app.credentialOwner && (
{app.credentialOwner && !isPlatform && (
<div className="ml-auto">
<Badge variant="gray">
<div className="flex items-center">
@@ -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<Partial<StripeConnectProps>> = ({
teamId,
icon,
color,
isClickable,
@@ -41,17 +43,25 @@ export const StripeConnect: FC<Partial<StripeConnectProps>> = ({
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");
@@ -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({
@@ -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 <div>Checking...</div>;
return (
<div>
@@ -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={
<StripeConnect
teamId={eventType.teamId}
label="Connect to Stripe"
loadingLabel="Connect to Stripe"
alreadyConnectedLabel="Connect to Stripe"
@@ -43,8 +51,7 @@ const EventPaymentsTabPlatformWrapper = ({ eventType }: { eventType: EventTypeSe
const StripeAppCard = ({ eventType }: { eventType: EventTypeSetupProps["eventType"] }) => {
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 (
<div>
if (!!transformedAppData.teams && transformedAppData.teams.length > 0) {
const eventTypeAssociatedTeam = transformedAppData.teams.find((team) => team.teamId === eventType.teamId);
if (!eventTypeAssociatedTeam) return null;
return (
<EventTypeAppCard
getAppData={getAppDataGetter(transformedAppData.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(
transformedAppData.slug as EventTypeAppsList,
transformedAppData.categories as string[],
transformedAppData.userCredentialIds && (transformedAppData.userCredentialIds[0] as number)
eventTypeAssociatedTeam.credentialId
)}
key={transformedAppData.slug}
app={transformedAppData as EventTypeApp}
key={(transformedAppData.slug ?? stripeData.app.slug) + eventTypeAssociatedTeam.credentialId}
app={
{
...transformedAppData,
credentialOwner: {
name: eventTypeAssociatedTeam.name,
avatar: eventTypeAssociatedTeam.logoUrl,
teamId: eventTypeAssociatedTeam.teamId,
credentialId: eventTypeAssociatedTeam.credentialId,
},
} as unknown as EventTypeApp
}
eventType={eventType as unknown as EventTypeForAppCard}
eventTypeFormMetadata={eventTypeFormMetadata}
/>
</div>
);
}
return (
<EventTypeAppCard
getAppData={getAppDataGetter(transformedAppData.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(
transformedAppData.slug as EventTypeAppsList,
transformedAppData.categories as string[],
transformedAppData.userCredentialIds && (transformedAppData.userCredentialIds[0] as number)
)}
key={transformedAppData.slug}
app={transformedAppData as unknown as EventTypeApp}
eventType={eventType as unknown as EventTypeForAppCard}
eventTypeFormMetadata={eventTypeFormMetadata}
/>
);
};
@@ -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<ApiResponse<{ checked: boolean; allowConnect: boolean }>>(`/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();
},
};
};
@@ -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<ApiResponse<{ authUrl: string }>>(
`/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();