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:
parent
87b514b91b
commit
a804a29516
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 });
|
||||
}
|
|
@ -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={
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 });
|
||||
};
|
|
@ -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",
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
|
@ -0,0 +1,5 @@
|
|||
import { defaultHandler } from "@calcom/lib/server";
|
||||
|
||||
export default defaultHandler({
|
||||
GET: import("./_getCallback"),
|
||||
});
|
|
@ -1 +1,2 @@
|
|||
export { default as add } from "./add";
|
||||
export { default as callback } from "./callback";
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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": "*"
|
||||
|
|
|
@ -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" ||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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");
|
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
|
80
yarn.lock
80
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue
Block a user