chore: Stripe paid apps flow
parent
defa8df7ca
commit
6d2ae60b9e
|
@ -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;
|
||||
|
@ -206,6 +208,7 @@ export const AppPage = ({
|
|||
addAppMutationInput={{ type, variant, slug }}
|
||||
multiInstall
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
paid={paid}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
@ -244,6 +247,7 @@ export const AppPage = ({
|
|||
addAppMutationInput={{ type, variant, slug }}
|
||||
credentials={appDbQuery.data?.credentials}
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
paid={paid}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
@ -48,6 +50,14 @@ export const InstallAppButtonChild = ({
|
|||
});
|
||||
|
||||
if (!userAdminTeams?.length || !doesAppSupportTeamInstall(appCategories, concurrentMeetings)) {
|
||||
if (paid) {
|
||||
return (
|
||||
<Button data-testid="install-app-button" {...props} color="primary" size="base">
|
||||
{t("install_paid_app")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -1,52 +1,39 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
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 appConfig from "../config.json";
|
||||
import { getStripeCustomerIdFromUserId, stripe } from "../lib/stripe";
|
||||
|
||||
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);
|
||||
const redirect_uri = `${WEBAPP_URL}/api/integrations/${slug}/callback?checkoutId={CHECKOUT_SESSION_ID}`;
|
||||
|
||||
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`,
|
||||
const stripeCustomerId = await getStripeCustomerIdFromUserId(session.user.id);
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
success_url: redirect_uri,
|
||||
cancel_url: redirect_uri,
|
||||
mode: "payment",
|
||||
payment_method_types: ["card"],
|
||||
allow_promotion_codes: true,
|
||||
customer: stripeCustomerId,
|
||||
line_items: [
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
quantity: 1,
|
||||
price: appConfig.paid.priceId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: session.user.id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
],
|
||||
});
|
||||
|
||||
return { url: getInstalledAppPath({ variant: appConfig.variant, slug: "cal-ai" }) };
|
||||
if (!checkoutSession) {
|
||||
return res.status(500).json({ message: "Failed to create Stripe checkout session" });
|
||||
}
|
||||
|
||||
return { url: checkoutSession.url };
|
||||
}
|
||||
|
||||
export default defaultResponder(getHandler);
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
import getInstalledAppPath from "_utils/getInstalledAppPath";
|
||||
import { checkInstalled, createDefaultInstallation } from "_utils/installation";
|
||||
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 appConfig from "../config.json";
|
||||
import { stripe } from "../lib/stripe";
|
||||
|
||||
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = checkSession(req);
|
||||
const slug = appConfig.slug;
|
||||
const appType = appConfig.type;
|
||||
|
||||
console.log("HELLO FROM WITHIN CALLBACK", req.query);
|
||||
|
||||
const { checkoutId } = req.query as { checkoutId: string };
|
||||
if (!checkoutId) {
|
||||
return { url: `/apps/installed?error=${JSON.stringify({ message: "No Stripe Checkout Session ID" })}` };
|
||||
}
|
||||
|
||||
const checkoutSession = await stripe.checkout.sessions.retrieve(checkoutId);
|
||||
if (!checkoutSession) {
|
||||
return {
|
||||
url: `/apps/installed?error=${JSON.stringify({ message: "Unknown Stripe Checkout Session ID" })}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (checkoutSession.payment_status !== "paid") {
|
||||
return { url: `/apps/installed?error=${JSON.stringify({ message: "Stripe Payment not processed" })}` };
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
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" }) };
|
||||
}
|
||||
|
||||
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";
|
||||
|
|
|
@ -14,5 +14,9 @@
|
|||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "basic",
|
||||
"dirName": "cal-ai"
|
||||
"dirName": "cal-ai",
|
||||
"paid": {
|
||||
"price": 25,
|
||||
"priceId": "price_1O5ScNDVtJmy8ddXvHYTaovD"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
|
@ -5,7 +5,9 @@
|
|||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/prisma": "workspace:^",
|
||||
"stripe": "^14.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
|
|
|
@ -30,6 +30,12 @@ type DynamicLinkBasedEventLocation = {
|
|||
|
||||
export type EventLocationTypeFromAppMeta = StaticLinkBasedEventLocation | DynamicLinkBasedEventLocation;
|
||||
|
||||
type PaidAppData = {
|
||||
price: number;
|
||||
priceId: string;
|
||||
hasTrial?: boolean;
|
||||
};
|
||||
|
||||
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 +148,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.
|
||||
|
|
|
@ -120,6 +120,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 +147,7 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
|
|||
appCategories={app.categories}
|
||||
credentials={credentials}
|
||||
concurrentMeetings={app.concurrentMeetings}
|
||||
paid={app.paid}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
@ -174,6 +176,7 @@ const InstallAppButtonChild = ({
|
|||
appCategories,
|
||||
credentials,
|
||||
concurrentMeetings,
|
||||
paid,
|
||||
...props
|
||||
}: {
|
||||
userAdminTeams?: UserAdminTeams;
|
||||
|
@ -181,6 +184,7 @@ const InstallAppButtonChild = ({
|
|||
appCategories: string[];
|
||||
credentials?: Credential[];
|
||||
concurrentMeetings?: boolean;
|
||||
paid: App["paid"];
|
||||
} & ButtonProps) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
@ -201,6 +205,19 @@ const InstallAppButtonChild = ({
|
|||
});
|
||||
|
||||
if (!userAdminTeams?.length || !doesAppSupportTeamInstall(appCategories, concurrentMeetings)) {
|
||||
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}>
|
||||
{t("install_paid")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
color="secondary"
|
||||
|
|
Loading…
Reference in New Issue