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 (