diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx
index 05c095caa1..c6a35e9749 100644
--- a/apps/web/components/apps/AppPage.tsx
+++ b/apps/web/components/apps/AppPage.tsx
@@ -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]}
{" "}
+ {paid && (
+ <>
+
+ {Intl.NumberFormat(i18n.language, {
+ style: "currency",
+ currency: "USD",
+ useGrouping: false,
+ maximumFractionDigits: 0,
+ }).format(paid.priceInUsd)}
+ /{t("month")}
+
+ >
+ )}
•{" "}
{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 = ({
))}
- {price !== 0 && (
+ {price !== 0 && !paid && (
{feeType === "usage-based" ? `${commission}% + ${priceInDollar}/booking` : priceInDollar}
{feeType === "monthly" && `/${t("month")}`}
@@ -273,23 +290,27 @@ export const AppPage = ({
{body}
- {t("pricing")}
-
- {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")}`}
- >
- )}
-
+ {!paid && (
+ <>
+ {t("pricing")}
+
+ {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")}`}
+ >
+ )}
+
+ >
+ )}
{t("contact")}
diff --git a/apps/web/components/apps/InstallAppButtonChild.tsx b/apps/web/components/apps/InstallAppButtonChild.tsx
index b6ec80ddca..0c13e754e8 100644
--- a/apps/web/components/apps/InstallAppButtonChild.tsx
+++ b/apps/web/components/apps/InstallAppButtonChild.tsx
@@ -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 (
+
+ );
+ }
+
+ if (
+ !userAdminTeams?.length ||
+ !doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid })
+ ) {
return (
diff --git a/apps/web/pages/api/integrations/subscriptions/webhook.ts b/apps/web/pages/api/integrations/subscriptions/webhook.ts
new file mode 100644
index 0000000000..63f4cba477
--- /dev/null
+++ b/apps/web/pages/api/integrations/subscriptions/webhook.ts
@@ -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;
+
+const webhookHandlers: Record = {
+ "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 });
+}
diff --git a/apps/web/pages/apps/[slug]/index.tsx b/apps/web/pages/apps/[slug]/index.tsx
index 0289358470..da41a33402 100644
--- a/apps/web/pages/apps/[slug]/index.tsx
+++ b/apps/web/pages/apps/[slug]/index.tsx
@@ -79,6 +79,7 @@ function SingleAppPage(props: inferSSRProps) {
isTemplate={data.isTemplate}
dependencies={data.dependencies}
concurrentMeetings={data.concurrentMeetings}
+ paid={data.paid}
// tos="https://zoom.us/terms"
// privacy="https://zoom.us/privacy"
body={
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 732fb2d5dc..9c7bd29d8f 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -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",
diff --git a/packages/app-store/_utils/installation.ts b/packages/app-store/_utils/installation.ts
index eb635fb6d9..6ee52e8c42 100644
--- a/packages/app-store/_utils/installation.ts
+++ b/packages/app-store/_utils/installation.ts
@@ -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) {
diff --git a/packages/app-store/_utils/paid-apps.ts b/packages/app-store/_utils/paid-apps.ts
new file mode 100644
index 0000000000..6d39c98ff6
--- /dev/null
+++ b/packages/app-store/_utils/paid-apps.ts
@@ -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 });
+};
diff --git a/packages/app-store/_utils/stripe.ts b/packages/app-store/_utils/stripe.ts
new file mode 100644
index 0000000000..2b3b5895bc
--- /dev/null
+++ b/packages/app-store/_utils/stripe.ts
@@ -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()({
+ select: {
+ email: true,
+ metadata: true,
+ },
+});
+
+export type UserType = Prisma.UserGetPayload;
+/** This will retrieve the customer ID from Stripe or create it if it doesn't exists yet. */
+export async function getStripeCustomerId(user: UserType): Promise {
+ 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",
+});
diff --git a/packages/app-store/cal-ai/api/_getAdd.ts b/packages/app-store/cal-ai/api/_getAdd.ts
index a6dec2da5a..a607a83e7b 100644
--- a/packages/app-store/cal-ai/api/_getAdd.ts
+++ b/packages/app-store/cal-ai/api/_getAdd.ts
@@ -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);
diff --git a/packages/app-store/cal-ai/api/_getCallback.ts b/packages/app-store/cal-ai/api/_getCallback.ts
new file mode 100644
index 0000000000..b85f38e479
--- /dev/null
+++ b/packages/app-store/cal-ai/api/_getCallback.ts
@@ -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);
diff --git a/packages/app-store/cal-ai/api/callback.ts b/packages/app-store/cal-ai/api/callback.ts
new file mode 100644
index 0000000000..07586cf85f
--- /dev/null
+++ b/packages/app-store/cal-ai/api/callback.ts
@@ -0,0 +1,5 @@
+import { defaultHandler } from "@calcom/lib/server";
+
+export default defaultHandler({
+ GET: import("./_getCallback"),
+});
diff --git a/packages/app-store/cal-ai/api/index.ts b/packages/app-store/cal-ai/api/index.ts
index 4c0d2ead01..eb12c1b4ed 100644
--- a/packages/app-store/cal-ai/api/index.ts
+++ b/packages/app-store/cal-ai/api/index.ts
@@ -1 +1,2 @@
export { default as add } from "./add";
+export { default as callback } from "./callback";
diff --git a/packages/app-store/cal-ai/config.json b/packages/app-store/cal-ai/config.json
index b371cc1e13..e6718b7b5d 100644
--- a/packages/app-store/cal-ai/config.json
+++ b/packages/app-store/cal-ai/config.json
@@ -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"
+ }
}
diff --git a/packages/app-store/package.json b/packages/app-store/package.json
index c4185db134..6cfd20e06a 100644
--- a/packages/app-store/package.json
+++ b/packages/app-store/package.json
@@ -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": "*"
diff --git a/packages/app-store/utils.ts b/packages/app-store/utils.ts
index aaacb56292..a358f9d03a 100644
--- a/packages/app-store/utils.ts
+++ b/packages/app-store/utils.ts
@@ -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" ||
diff --git a/packages/features/tips/Tips.tsx b/packages/features/tips/Tips.tsx
index 9e707bb29b..e0e3a14fac 100644
--- a/packages/features/tips/Tips.tsx
+++ b/packages/features/tips/Tips.tsx
@@ -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();
diff --git a/packages/lib/piiFreeData.ts b/packages/lib/piiFreeData.ts
index 6953a52477..dfa346f63f 100644
--- a/packages/lib/piiFreeData.ts
+++ b/packages/lib/piiFreeData.ts
@@ -105,7 +105,7 @@ export function getPiiFreeUser(user: {
allowDynamicBooking?: boolean | null;
defaultScheduleId?: number | null;
organizationId?: number | null;
- credentials?: Credential[];
+ credentials?: Partial[];
destinationCalendar?: DestinationCalendar | null;
}) {
return {
diff --git a/packages/lib/server/getServerErrorFromUnknown.ts b/packages/lib/server/getServerErrorFromUnknown.ts
index 7279f9fc97..081be54ebe 100644
--- a/packages/lib/server/getServerErrorFromUnknown.ts
+++ b/packages/lib/server/getServerErrorFromUnknown.ts
@@ -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,
diff --git a/packages/prisma/migrations/20231110122349_paid_apps/migration.sql b/packages/prisma/migrations/20231110122349_paid_apps/migration.sql
new file mode 100644
index 0000000000..5ba5814c67
--- /dev/null
+++ b/packages/prisma/migrations/20231110122349_paid_apps/migration.sql
@@ -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");
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index ad21ba81bd..c9c2bf6596 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -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 {
diff --git a/packages/types/App.d.ts b/packages/types/App.d.ts
index 32839135e8..60f6b3308b 100644
--- a/packages/types/App.d.ts
+++ b/packages/types/App.d.ts
@@ -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.
diff --git a/packages/ui/components/apps/AppCard.tsx b/packages/ui/components/apps/AppCard.tsx
index 04ff2ad44f..f692435b90 100644
--- a/packages/ui/components/apps/AppCard.tsx
+++ b/packages/ui/components/apps/AppCard.tsx
@@ -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 (
+
+ );
+ }
+
+ if (
+ !userAdminTeams?.length ||
+ !doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid })
+ ) {
return (