feat: Stripe paid apps flow (#12103)

* chore: Stripe paid apps flow

* chore: Subscription

* chore: Webhooks

* chore: Abstract functions

* chore: Lockfile

* chore: Webhook handler

* chore: Use catch-all

* chore: Webhook changes, etc

* chore: Cleanup

* chore: Use actual price id

* chore: Updates

* chore: Install normally until expiry date

* Disable team install for paid apps and cal.ai\

* Fix the same at another place

* Fix Typescript error

* redactedCause doesnt have message has enumerable prop

* Fix reinstallation of an already installed app

* chore: Remove unused deps

* chore: Ensure index

* chore: Price in usd

* chore: PR suggestion

* Fix missing packages in yarn.lock

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Erik 2023-11-15 09:29:41 -03:00 committed by GitHub
parent 87b514b91b
commit a804a29516
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 630 additions and 100 deletions

View File

@ -42,6 +42,7 @@ export type AppPageProps = {
disableInstall?: boolean;
dependencies?: string[];
concurrentMeetings: AppType["concurrentMeetings"];
paid?: AppType["paid"];
};
export const AppPage = ({
@ -67,6 +68,7 @@ export const AppPage = ({
isTemplate,
dependencies,
concurrentMeetings,
paid,
}: AppPageProps) => {
const { t, i18n } = useLocale();
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
@ -163,6 +165,19 @@ export const AppPage = ({
className="bg-subtle text-emphasis rounded-md p-1 text-xs capitalize">
{categories[0]}
</Link>{" "}
{paid && (
<>
<Badge className="mr-1">
{Intl.NumberFormat(i18n.language, {
style: "currency",
currency: "USD",
useGrouping: false,
maximumFractionDigits: 0,
}).format(paid.priceInUsd)}
/{t("month")}
</Badge>
</>
)}
{" "}
<a target="_blank" rel="noreferrer" href={website}>
{t("published_by", { author })}
@ -206,6 +221,7 @@ export const AppPage = ({
addAppMutationInput={{ type, variant, slug }}
multiInstall
concurrentMeetings={concurrentMeetings}
paid={paid}
{...props}
/>
);
@ -244,6 +260,7 @@ export const AppPage = ({
addAppMutationInput={{ type, variant, slug }}
credentials={appDbQuery.data?.credentials}
concurrentMeetings={concurrentMeetings}
paid={paid}
{...props}
/>
);
@ -263,7 +280,7 @@ export const AppPage = ({
<SkeletonButton className="mt-6 h-20 grow" />
))}
{price !== 0 && (
{price !== 0 && !paid && (
<span className="block text-right">
{feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar}
{feeType === "monthly" && `/${t("month")}`}
@ -273,23 +290,27 @@ export const AppPage = ({
<div className="prose-sm prose prose-a:text-default prose-headings:text-emphasis prose-code:text-default prose-strong:text-default text-default mt-8">
{body}
</div>
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
<span className="text-default">
{teamsPlanRequired ? (
t("teams_plan_required")
) : price === 0 ? (
t("free_to_use_apps")
) : (
<>
{Intl.NumberFormat(i18n.language, {
style: "currency",
currency: "USD",
useGrouping: false,
}).format(price)}
{feeType === "monthly" && `/${t("month")}`}
</>
)}
</span>
{!paid && (
<>
<h4 className="text-emphasis mt-8 font-semibold ">{t("pricing")}</h4>
<span className="text-default">
{teamsPlanRequired ? (
t("teams_plan_required")
) : price === 0 ? (
t("free_to_use_apps")
) : (
<>
{Intl.NumberFormat(i18n.language, {
style: "currency",
currency: "USD",
useGrouping: false,
}).format(price)}
{feeType === "monthly" && `/${t("month")}`}
</>
)}
</span>
</>
)}
<h4 className="text-emphasis mb-2 mt-8 font-semibold ">{t("contact")}</h4>
<ul className="prose-sm -ml-1 -mr-1 leading-5">

View File

@ -26,6 +26,7 @@ export const InstallAppButtonChild = ({
multiInstall,
credentials,
concurrentMeetings,
paid,
...props
}: {
userAdminTeams?: UserAdminTeams;
@ -34,6 +35,7 @@ export const InstallAppButtonChild = ({
multiInstall?: boolean;
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
concurrentMeetings?: boolean;
paid?: AppFrontendPayload["paid"];
} & ButtonProps) => {
const { t } = useLocale();
@ -46,8 +48,27 @@ export const InstallAppButtonChild = ({
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
},
});
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
if (!userAdminTeams?.length || !doesAppSupportTeamInstall(appCategories, concurrentMeetings)) {
// Paid apps don't support team installs at the moment
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
if (paid) {
return (
<Button
data-testid="install-app-button"
{...props}
disabled={shouldDisableInstallation}
color="primary"
size="base">
{paid.trial ? t("start_paid_trial") : t("install_paid_app")}
</Button>
);
}
if (
!userAdminTeams?.length ||
!doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid })
) {
return (
<Button
data-testid="install-app-button"
@ -55,6 +76,7 @@ export const InstallAppButtonChild = ({
// @TODO: Overriding color and size prevent us from
// having to duplicate InstallAppButton for now.
color="primary"
disabled={shouldDisableInstallation}
size="base">
{multiInstall ? t("install_another") : t("install_app")}
</Button>

View File

@ -0,0 +1,116 @@
import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next";
import type Stripe from "stripe";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
export const config = {
api: {
bodyParser: false,
},
};
// This file is a catch-all for any integration related subscription/paid app.
const handleSubscriptionUpdate = async (event: Stripe.Event) => {
const subscription = event.data.object as Stripe.Subscription;
if (!subscription.id) throw new HttpCode({ statusCode: 400, message: "Subscription ID not found" });
const app = await prisma.credential.findFirst({
where: {
subscriptionId: subscription.id,
},
});
if (!app) {
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
}
await prisma.credential.update({
where: {
id: app.id,
},
data: {
paymentStatus: subscription.status,
},
});
};
const handleSubscriptionDeleted = async (event: Stripe.Event) => {
const subscription = event.data.object as Stripe.Subscription;
if (!subscription.id) throw new HttpCode({ statusCode: 400, message: "Subscription ID not found" });
const app = await prisma.credential.findFirst({
where: {
subscriptionId: subscription.id,
},
});
if (!app) {
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
}
// should we delete the credential here rather than marking as inactive?
await prisma.credential.update({
where: {
id: app.id,
},
data: {
paymentStatus: "inactive",
billingCycleStart: null,
},
});
};
type WebhookHandler = (event: Stripe.Event) => Promise<void>;
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
"customer.subscription.updated": handleSubscriptionUpdate,
"customer.subscription.deleted": handleSubscriptionDeleted,
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") {
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const sig = req.headers["stripe-signature"];
if (!sig) {
throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" });
}
if (!process.env.STRIPE_WEBHOOK_SECRET) {
throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" });
}
const requestBuffer = await buffer(req);
const payload = requestBuffer.toString();
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET);
const handler = webhookHandlers[event.type];
if (handler) {
await handler(event);
} else {
/** Not really an error, just letting Stripe know that the webhook was received but unhandled */
throw new HttpCode({
statusCode: 202,
message: `Unhandled Stripe Webhook event type ${event.type}`,
});
}
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
res.status(err.statusCode ?? 500).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
});
return;
}
// Return a response to acknowledge receipt of the event
res.json({ received: true });
}

View File

@ -79,6 +79,7 @@ function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
isTemplate={data.isTemplate}
dependencies={data.dependencies}
concurrentMeetings={data.concurrentMeetings}
paid={data.paid}
// tos="https://zoom.us/terms"
// privacy="https://zoom.us/privacy"
body={

View File

@ -849,6 +849,8 @@
"next_step": "Skip step",
"prev_step": "Prev step",
"install": "Install",
"install_paid_app": "Subscribe",
"start_paid_trial": "Start free Trial",
"installed": "Installed",
"active_install_one": "{{count}} active install",
"active_install_other": "{{count}} active installs",

View File

@ -15,25 +15,36 @@ export async function checkInstalled(slug: string, userId: number) {
}
}
type InstallationArgs = {
appType: string;
userId: number;
slug: string;
key?: Prisma.InputJsonValue;
teamId?: number;
subscriptionId?: string | null;
paymentStatus?: string | null;
billingCycleStart?: number | null;
};
export async function createDefaultInstallation({
appType,
userId,
slug,
key = {},
teamId,
}: {
appType: string;
userId: number;
slug: string;
key?: Prisma.InputJsonValue;
teamId?: number;
}) {
billingCycleStart,
paymentStatus,
subscriptionId,
}: InstallationArgs) {
const installation = await prisma.credential.create({
data: {
type: appType,
key,
...(teamId ? { teamId } : { userId }),
appId: slug,
subscriptionId,
paymentStatus,
billingCycleStart,
},
});
if (!installation) {

View File

@ -0,0 +1,92 @@
import type Stripe from "stripe";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getStripeCustomerIdFromUserId, stripe } from "./stripe";
interface RedirectArgs {
userId: number;
appSlug: string;
appPaidMode: string;
priceId: string;
trialDays?: number;
}
export const withPaidAppRedirect = async ({
appSlug,
appPaidMode,
priceId,
userId,
trialDays,
}: RedirectArgs) => {
const redirect_uri = `${WEBAPP_URL}/api/integrations/${appSlug}/callback?checkoutId={CHECKOUT_SESSION_ID}`;
const stripeCustomerId = await getStripeCustomerIdFromUserId(userId);
const checkoutSession = await stripe.checkout.sessions.create({
success_url: redirect_uri,
cancel_url: redirect_uri,
mode: appPaidMode === "subscription" ? "subscription" : "payment",
payment_method_types: ["card"],
allow_promotion_codes: true,
customer: stripeCustomerId,
line_items: [
{
quantity: 1,
price: priceId,
},
],
client_reference_id: userId.toString(),
...(trialDays
? {
subscription_data: {
trial_period_days: trialDays,
trial_settings: { end_behavior: { missing_payment_method: "cancel" } },
},
}
: undefined),
});
return checkoutSession.url;
};
export const withStripeCallback = async (
checkoutId: string,
appSlug: string,
callback: (args: { checkoutSession: Stripe.Checkout.Session }) => Promise<{ url: string }>
): Promise<{ url: string }> => {
if (!checkoutId) {
return {
url: `/apps/installed?error=${encodeURIComponent(
JSON.stringify({ message: "No Stripe Checkout Session ID" })
)}`,
};
}
const checkoutSession = await stripe.checkout.sessions.retrieve(checkoutId);
if (!checkoutSession) {
return {
url: `/apps/installed?error=${encodeURIComponent(
JSON.stringify({ message: "Unknown Stripe Checkout Session ID" })
)}`,
};
}
if (checkoutSession.payment_status !== "paid") {
return {
url: `/apps/installed?error=${encodeURIComponent(
JSON.stringify({ message: "Stripe Payment not processed" })
)}`,
};
}
if (checkoutSession.mode === "subscription" && checkoutSession.subscription) {
await stripe.subscriptions.update(checkoutSession.subscription.toString(), {
metadata: {
appSlug,
},
});
}
// Execute the callback if all checks pass
return callback({ checkoutSession });
};

View File

@ -0,0 +1,74 @@
import { Prisma } from "@prisma/client";
import Stripe from "stripe";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
export async function getStripeCustomerIdFromUserId(userId: number) {
// Get user
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
email: true,
name: true,
metadata: true,
},
});
if (!user?.email) throw new HttpError({ statusCode: 404, message: "User email not found" });
const customerId = await getStripeCustomerId(user);
return customerId;
}
const userType = Prisma.validator<Prisma.UserArgs>()({
select: {
email: true,
metadata: true,
},
});
export type UserType = Prisma.UserGetPayload<typeof userType>;
/** This will retrieve the customer ID from Stripe or create it if it doesn't exists yet. */
export async function getStripeCustomerId(user: UserType): Promise<string> {
let customerId: string | null = null;
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
} else {
/* We fallback to finding the customer by email (which is not optimal) */
const customersResponse = await stripe.customers.list({
email: user.email,
limit: 1,
});
if (customersResponse.data[0]?.id) {
customerId = customersResponse.data[0].id;
} else {
/* Creating customer on Stripe and saving it on prisma */
const customer = await stripe.customers.create({ email: user.email });
customerId = customer.id;
}
await prisma.user.update({
where: {
email: user.email,
},
data: {
metadata: {
...(user.metadata as Prisma.JsonObject),
stripeCustomerId: customerId,
},
},
});
}
return customerId;
}
const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY || "";
export const stripe = new Stripe(stripePrivateKey, {
apiVersion: "2020-08-27",
});

View File

@ -7,46 +7,63 @@ import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_route
import checkSession from "../../_utils/auth";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { checkInstalled, createDefaultInstallation } from "../../_utils/installation";
import { withPaidAppRedirect } from "../../_utils/paid-apps";
import appConfig from "../config.json";
const trialEndDate = new Date(Date.UTC(2023, 11, 1));
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const session = checkSession(req);
const slug = appConfig.slug;
const appType = appConfig.type;
const ctx = await createContext({ req, res });
const caller = apiKeysRouter.createCaller(ctx);
// if date is in the future, we install normally.
if (new Date() < trialEndDate) {
const ctx = await createContext({ req, res });
const caller = apiKeysRouter.createCaller(ctx);
const apiKey = await caller.create({
note: "Cal.ai",
expiresAt: null,
appId: "cal-ai",
});
const apiKey = await caller.create({
note: "Cal.ai",
expiresAt: null,
appId: "cal-ai",
});
await checkInstalled(slug, session.user.id);
await createDefaultInstallation({
appType,
userId: session.user.id,
slug,
key: {
apiKey,
},
});
await fetch(
`${process.env.NODE_ENV === "development" ? "http://localhost:3005" : "https://cal.ai"}/api/onboard`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
await checkInstalled(appConfig.slug, session.user.id);
await createDefaultInstallation({
appType: appConfig.type,
userId: session.user.id,
slug: appConfig.slug,
key: {
apiKey,
},
body: JSON.stringify({
userId: session.user.id,
}),
}
);
});
return { url: getInstalledAppPath({ variant: appConfig.variant, slug: "cal-ai" }) };
await fetch(
`${process.env.NODE_ENV === "development" ? "http://localhost:3005" : "https://cal.ai"}/api/onboard`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: session.user.id,
}),
}
);
return { url: getInstalledAppPath({ variant: appConfig.variant, slug: "cal-ai" }) };
}
const redirectUrl = await withPaidAppRedirect({
appPaidMode: appConfig.paid.mode,
appSlug: appConfig.slug,
userId: session.user.id,
priceId: appConfig.paid.priceId,
});
if (!redirectUrl) {
return res.status(500).json({ message: "Failed to create Stripe checkout session" });
}
return { url: redirectUrl };
}
export default defaultResponder(getHandler);

View File

@ -0,0 +1,65 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { defaultResponder } from "@calcom/lib/server";
import { createContext } from "@calcom/trpc/server/createContext";
import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_router";
import checkSession from "../../_utils/auth";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { checkInstalled, createDefaultInstallation } from "../../_utils/installation";
import { withStripeCallback } from "../../_utils/paid-apps";
import appConfig from "../config.json";
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const session = checkSession(req);
const slug = appConfig.slug;
const appType = appConfig.type;
const { checkoutId } = req.query as { checkoutId: string };
if (!checkoutId) {
return { url: `/apps/installed?error=${JSON.stringify({ message: "No Stripe Checkout Session ID" })}` };
}
const { url } = await withStripeCallback(checkoutId, slug, async ({ checkoutSession }) => {
const ctx = await createContext({ req, res });
const caller = apiKeysRouter.createCaller(ctx);
const apiKey = await caller.create({
note: "Cal.ai",
expiresAt: null,
appId: "cal-ai",
});
await checkInstalled(slug, session.user.id);
await createDefaultInstallation({
appType,
userId: session.user.id,
slug,
key: {
apiKey,
},
subscriptionId: checkoutSession.subscription?.toString(),
billingCycleStart: new Date().getDate(),
paymentStatus: "active",
});
await fetch(
`${process.env.NODE_ENV === "development" ? "http://localhost:3005" : "https://cal.ai"}/api/onboard`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: session.user.id,
}),
}
);
return { url: getInstalledAppPath({ variant: appConfig.variant, slug: "cal-ai" }) };
});
return res.redirect(url);
}
export default defaultResponder(getHandler);

View File

@ -0,0 +1,5 @@
import { defaultHandler } from "@calcom/lib/server";
export default defaultHandler({
GET: import("./_getCallback"),
});

View File

@ -1 +1,2 @@
export { default as add } from "./add";
export { default as callback } from "./callback";

View File

@ -3,7 +3,6 @@
"name": "Cal.ai",
"slug": "cal-ai",
"type": "cal-ai_automation",
"trial": 14,
"logo": "icon.png",
"url": "https://cal.ai",
"variant": "automation",
@ -14,5 +13,10 @@
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic",
"dirName": "cal-ai"
"dirName": "cal-ai",
"paid": {
"priceInUsd": 25,
"priceId": "price_1O1ziDH8UDiwIftkDHp3MCTP",
"mode": "subscription"
}
}

View File

@ -25,7 +25,8 @@
"@calcom/zoomvideo": "*",
"lodash": "^4.17.21",
"qs-stringify": "^1.2.1",
"react-i18next": "^12.2.0"
"react-i18next": "^12.2.0",
"stripe": "^14.3.0"
},
"devDependencies": {
"@calcom/types": "*"

View File

@ -142,10 +142,19 @@ export function getAppFromLocationValue(type: string): AppMeta | undefined {
* @param concurrentMeetings - from app metadata
* @returns - true if app supports team install
*/
export function doesAppSupportTeamInstall(
appCategories: string[],
concurrentMeetings: boolean | undefined = undefined
) {
export function doesAppSupportTeamInstall({
appCategories,
concurrentMeetings = undefined,
isPaid,
}: {
appCategories: string[];
concurrentMeetings: boolean | undefined;
isPaid: boolean;
}) {
// Paid apps can't be installed on team level - That isn't supported
if (isPaid) {
return false;
}
return !appCategories.some(
(category) =>
category === "calendar" ||

View File

@ -95,12 +95,13 @@ export const tips = [
},
{
id: 12,
thumbnailUrl: "https://ph-files.imgix.net/46d376e1-f897-40fc-9921-c64de971ee13.jpeg?auto=compress&codec=mozjpeg&cs=strip&auto=format&w=390&h=220&fit=max&dpr=2",
thumbnailUrl:
"https://ph-files.imgix.net/46d376e1-f897-40fc-9921-c64de971ee13.jpeg?auto=compress&codec=mozjpeg&cs=strip&auto=format&w=390&h=220&fit=max&dpr=2",
mediaLink: "https://go.cal.com/cal-ai",
title: "Cal.ai",
description: "Your personal AI scheduling assistant",
href: "https://go.cal.com/cal-ai",
}
},
];
const reversedTips = tips.slice(0).reverse();

View File

@ -105,7 +105,7 @@ export function getPiiFreeUser(user: {
allowDynamicBooking?: boolean | null;
defaultScheduleId?: number | null;
organizationId?: number | null;
credentials?: Credential[];
credentials?: Partial<Credential>[];
destinationCalendar?: DestinationCalendar | null;
}) {
return {

View File

@ -55,6 +55,7 @@ export function getServerErrorFromUnknown(cause: unknown): HttpError {
const redactedCause = redactError(cause);
return {
...redactedCause,
message: redactedCause.message,
cause: cause.cause,
url: cause.url,
statusCode: cause.statusCode,

View File

@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "Credential" ADD COLUMN "billingCycleStart" INTEGER,
ADD COLUMN "paymentStatus" TEXT,
ADD COLUMN "subscriptionId" TEXT;
-- CreateIndex
CREATE INDEX "Credential_subscriptionId_idx" ON "Credential"("subscriptionId");

View File

@ -126,17 +126,23 @@ model EventType {
}
model Credential {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
// @@type is deprecated
type String
key Json
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int?
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int?
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
type String
key Json
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int?
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int?
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
// How to make it a required column?
appId String?
appId String?
// paid apps
subscriptionId String?
paymentStatus String?
billingCycleStart Int?
destinationCalendars DestinationCalendar[]
selectedCalendars SelectedCalendar[]
invalid Boolean? @default(false)
@ -144,6 +150,7 @@ model Credential {
@@index([userId])
@@index([appId])
@@index([subscriptionId])
}
enum IdentityProvider {

View File

@ -30,6 +30,13 @@ type DynamicLinkBasedEventLocation = {
export type EventLocationTypeFromAppMeta = StaticLinkBasedEventLocation | DynamicLinkBasedEventLocation;
type PaidAppData = {
priceInUsd: number;
priceId: string;
trial?: number;
mode?: "subscription" | "one_time";
};
type AppData = {
/**
* TODO: We must assert that if `location` is set in App config.json, then it must have atleast Messaging or Conferencing as a category.
@ -142,6 +149,9 @@ export interface App {
upgradeUrl: string;
};
appData?: AppData;
/** Represents paid app data, such as price, trials, etc */
paid?: PaidAppData;
/**
* @deprecated
* Used only by legacy apps which had slug different from their directory name.

View File

@ -39,8 +39,11 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
const { t } = useLocale();
const allowedMultipleInstalls = app.categories && app.categories.indexOf("calendar") > -1;
const appAdded = (credentials && credentials.length) || 0;
const enabledOnTeams = doesAppSupportTeamInstall(app.categories, app.concurrentMeetings);
const enabledOnTeams = doesAppSupportTeamInstall({
appCategories: app.categories,
concurrentMeetings: app.concurrentMeetings,
isPaid: !!app.paid,
});
const appInstalled = enabledOnTeams && userAdminTeams ? userAdminTeams.length < appAdded : appAdded > 0;
@ -120,6 +123,7 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
addAppMutationInput={{ type: app.type, variant: app.variant, slug: app.slug }}
appCategories={app.categories}
concurrentMeetings={app.concurrentMeetings}
paid={app.paid}
/>
);
}}
@ -146,6 +150,7 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
appCategories={app.categories}
credentials={credentials}
concurrentMeetings={app.concurrentMeetings}
paid={app.paid}
{...props}
/>
);
@ -174,6 +179,7 @@ const InstallAppButtonChild = ({
appCategories,
credentials,
concurrentMeetings,
paid,
...props
}: {
userAdminTeams?: UserAdminTeams;
@ -181,6 +187,7 @@ const InstallAppButtonChild = ({
appCategories: string[];
credentials?: Credential[];
concurrentMeetings?: boolean;
paid: App["paid"];
} & ButtonProps) => {
const { t } = useLocale();
const router = useRouter();
@ -200,7 +207,25 @@ const InstallAppButtonChild = ({
},
});
if (!userAdminTeams?.length || !doesAppSupportTeamInstall(appCategories, concurrentMeetings)) {
// Paid apps don't support team installs at the moment
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
if (paid) {
return (
<Button
color="secondary"
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
StartIcon={Plus}
data-testid="install-app-button"
{...props}>
{paid.trial ? t("start_paid_trial") : t("install_paid_app")}
</Button>
);
}
if (
!userAdminTeams?.length ||
!doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid })
) {
return (
<Button
color="secondary"

View File

@ -3484,6 +3484,7 @@ __metadata:
lodash: ^4.17.21
qs-stringify: ^1.2.1
react-i18next: ^12.2.0
stripe: ^14.3.0
languageName: unknown
linkType: soft
@ -3545,12 +3546,12 @@ __metadata:
"@types/react-dom": ^18.0.9
eslint: ^8.34.0
eslint-config-next: ^13.2.1
next: ^13.5.4
next: ^13.4.6
next-auth: ^4.22.1
postcss: ^8.4.18
react: ^18.2.0
react-dom: ^18.2.0
tailwindcss: ^3.3.3
tailwindcss: ^3.3.1
typescript: ^4.9.4
languageName: unknown
linkType: soft
@ -3719,6 +3720,15 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/deel@workspace:packages/app-store/deel":
version: 0.0.0-use.local
resolution: "@calcom/deel@workspace:packages/app-store/deel"
dependencies:
"@calcom/lib": "*"
"@calcom/types": "*"
languageName: unknown
linkType: soft
"@calcom/discord@workspace:packages/app-store/discord":
version: 0.0.0-use.local
resolution: "@calcom/discord@workspace:packages/app-store/discord"
@ -4226,6 +4236,15 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/shimmer-video@workspace:packages/app-store/shimmervideo":
version: 0.0.0-use.local
resolution: "@calcom/shimmer-video@workspace:packages/app-store/shimmervideo"
dependencies:
"@calcom/lib": "*"
"@calcom/types": "*"
languageName: unknown
linkType: soft
"@calcom/signal@workspace:packages/app-store/signal":
version: 0.0.0-use.local
resolution: "@calcom/signal@workspace:packages/app-store/signal"
@ -8494,17 +8513,6 @@ __metadata:
languageName: node
linkType: hard
"@prisma/debug@npm:5.3.1":
version: 5.3.1
resolution: "@prisma/debug@npm:5.3.1"
dependencies:
"@types/debug": 4.1.8
debug: 4.3.4
strip-ansi: 6.0.1
checksum: 9eaddf4b4d60a1bd41901a9d28b4f37ae369e4b952f53cdd02681eaadfff81222ef29fe73f37a6060746eeb5e457a26d9aa1cbdc217e882aeab7e48dce74e1d5
languageName: node
linkType: hard
"@prisma/debug@npm:5.4.2":
version: 5.4.2
resolution: "@prisma/debug@npm:5.4.2"
@ -8516,6 +8524,17 @@ __metadata:
languageName: node
linkType: hard
"@prisma/debug@npm:5.5.2":
version: 5.5.2
resolution: "@prisma/debug@npm:5.5.2"
dependencies:
"@types/debug": 4.1.9
debug: 4.3.4
strip-ansi: 6.0.1
checksum: ff082622d4ba1b6fe07edda85a7b4dfb499308857e015645b9fec2041288fb0247d73974386edda2c25bac7d5167b6008e118386f169d20215035a70d5742d2a
languageName: node
linkType: hard
"@prisma/engines-version@npm:5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574":
version: 5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574
resolution: "@prisma/engines-version@npm:5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574"
@ -8584,14 +8603,14 @@ __metadata:
linkType: hard
"@prisma/generator-helper@npm:^5.0.0":
version: 5.3.1
resolution: "@prisma/generator-helper@npm:5.3.1"
version: 5.5.2
resolution: "@prisma/generator-helper@npm:5.5.2"
dependencies:
"@prisma/debug": 5.3.1
"@types/cross-spawn": 6.0.2
"@prisma/debug": 5.5.2
"@types/cross-spawn": 6.0.3
cross-spawn: 7.0.3
kleur: 4.1.5
checksum: 2cb0cf86c1719db5007d3ac46d2e0a741d1431d26cda032d180b2edbad7a849c2d7b8cb90fbd1638da24de429ac1b6b0c4326eab38d2d97f66f774361ec11206
checksum: 4aef64ba4bcf3211358148fc1dc782541a33129154e5bb86687b60aff41674e1578ac8ccadee5a9e719d4d9b304963395ae7276d8b59760dec93bfa4517dff34
languageName: node
linkType: hard
@ -24839,9 +24858,9 @@ __metadata:
linkType: hard
"immer@npm:^10.0.2":
version: 10.0.2
resolution: "immer@npm:10.0.2"
checksum: 525a3b14210d02ae420c3b9f6ca14f7e9bcf625611d1356e773e7739f14c7c8de50dac442e6c7de3a6e24a782f7b792b6b8666bc0b3f00269d21a95f8f68ca84
version: 10.0.3
resolution: "immer@npm:10.0.3"
checksum: 76acabe6f40e752028313762ba477a5d901e57b669f3b8fb406b87b9bb9b14e663a6fbbf5a6d1ab323737dd38f4b2494a4e28002045b88948da8dbf482309f28
languageName: node
linkType: hard
@ -33331,6 +33350,15 @@ __metadata:
languageName: node
linkType: hard
"qs@npm:^6.11.0":
version: 6.11.2
resolution: "qs@npm:6.11.2"
dependencies:
side-channel: ^1.0.4
checksum: e812f3c590b2262548647d62f1637b6989cc56656dc960b893fe2098d96e1bd633f36576f4cd7564dfbff9db42e17775884db96d846bebe4f37420d073ecdc0b
languageName: node
linkType: hard
"qs@npm:~6.5.2":
version: 6.5.3
resolution: "qs@npm:6.5.3"
@ -37382,6 +37410,16 @@ __metadata:
languageName: node
linkType: hard
"stripe@npm:^14.3.0":
version: 14.3.0
resolution: "stripe@npm:14.3.0"
dependencies:
"@types/node": ">=8.1.0"
qs: ^6.11.0
checksum: 1aa0dec1fe8cd4c0d2a5378b9d3c69f7df505efdc86b8d6352e194d656129db83b9faaf189b5138fb5fd9a0b90e618dfcff854bb4773d289a0de0b65d0a94cb2
languageName: node
linkType: hard
"stripe@npm:^9.16.0":
version: 9.16.0
resolution: "stripe@npm:9.16.0"