chore: Stripe paid apps flow
parent
defa8df7ca
commit
6d2ae60b9e
|
@ -42,6 +42,7 @@ export type AppPageProps = {
|
||||||
disableInstall?: boolean;
|
disableInstall?: boolean;
|
||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
concurrentMeetings: AppType["concurrentMeetings"];
|
concurrentMeetings: AppType["concurrentMeetings"];
|
||||||
|
paid?: AppType["paid"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AppPage = ({
|
export const AppPage = ({
|
||||||
|
@ -67,6 +68,7 @@ export const AppPage = ({
|
||||||
isTemplate,
|
isTemplate,
|
||||||
dependencies,
|
dependencies,
|
||||||
concurrentMeetings,
|
concurrentMeetings,
|
||||||
|
paid,
|
||||||
}: AppPageProps) => {
|
}: AppPageProps) => {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
||||||
|
@ -206,6 +208,7 @@ export const AppPage = ({
|
||||||
addAppMutationInput={{ type, variant, slug }}
|
addAppMutationInput={{ type, variant, slug }}
|
||||||
multiInstall
|
multiInstall
|
||||||
concurrentMeetings={concurrentMeetings}
|
concurrentMeetings={concurrentMeetings}
|
||||||
|
paid={paid}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -244,6 +247,7 @@ export const AppPage = ({
|
||||||
addAppMutationInput={{ type, variant, slug }}
|
addAppMutationInput={{ type, variant, slug }}
|
||||||
credentials={appDbQuery.data?.credentials}
|
credentials={appDbQuery.data?.credentials}
|
||||||
concurrentMeetings={concurrentMeetings}
|
concurrentMeetings={concurrentMeetings}
|
||||||
|
paid={paid}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -26,6 +26,7 @@ export const InstallAppButtonChild = ({
|
||||||
multiInstall,
|
multiInstall,
|
||||||
credentials,
|
credentials,
|
||||||
concurrentMeetings,
|
concurrentMeetings,
|
||||||
|
paid,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
userAdminTeams?: UserAdminTeams;
|
userAdminTeams?: UserAdminTeams;
|
||||||
|
@ -34,6 +35,7 @@ export const InstallAppButtonChild = ({
|
||||||
multiInstall?: boolean;
|
multiInstall?: boolean;
|
||||||
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
||||||
concurrentMeetings?: boolean;
|
concurrentMeetings?: boolean;
|
||||||
|
paid?: AppFrontendPayload["paid"];
|
||||||
} & ButtonProps) => {
|
} & ButtonProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
@ -48,6 +50,14 @@ export const InstallAppButtonChild = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!userAdminTeams?.length || !doesAppSupportTeamInstall(appCategories, concurrentMeetings)) {
|
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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
data-testid="install-app-button"
|
data-testid="install-app-button"
|
||||||
|
|
|
@ -79,6 +79,7 @@ function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
|
||||||
isTemplate={data.isTemplate}
|
isTemplate={data.isTemplate}
|
||||||
dependencies={data.dependencies}
|
dependencies={data.dependencies}
|
||||||
concurrentMeetings={data.concurrentMeetings}
|
concurrentMeetings={data.concurrentMeetings}
|
||||||
|
paid={data.paid}
|
||||||
// tos="https://zoom.us/terms"
|
// tos="https://zoom.us/terms"
|
||||||
// privacy="https://zoom.us/privacy"
|
// privacy="https://zoom.us/privacy"
|
||||||
body={
|
body={
|
||||||
|
|
|
@ -1,52 +1,39 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
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 checkSession from "../../_utils/auth";
|
||||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
|
||||||
import { checkInstalled, createDefaultInstallation } from "../../_utils/installation";
|
|
||||||
import appConfig from "../config.json";
|
import appConfig from "../config.json";
|
||||||
|
import { getStripeCustomerIdFromUserId, stripe } from "../lib/stripe";
|
||||||
|
|
||||||
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const session = checkSession(req);
|
const session = checkSession(req);
|
||||||
const slug = appConfig.slug;
|
const slug = appConfig.slug;
|
||||||
const appType = appConfig.type;
|
|
||||||
|
|
||||||
const ctx = await createContext({ req, res });
|
const redirect_uri = `${WEBAPP_URL}/api/integrations/${slug}/callback?checkoutId={CHECKOUT_SESSION_ID}`;
|
||||||
const caller = apiKeysRouter.createCaller(ctx);
|
|
||||||
|
|
||||||
const apiKey = await caller.create({
|
const stripeCustomerId = await getStripeCustomerIdFromUserId(session.user.id);
|
||||||
note: "Cal.ai",
|
const checkoutSession = await stripe.checkout.sessions.create({
|
||||||
expiresAt: null,
|
success_url: redirect_uri,
|
||||||
appId: "cal-ai",
|
cancel_url: redirect_uri,
|
||||||
});
|
mode: "payment",
|
||||||
|
payment_method_types: ["card"],
|
||||||
await checkInstalled(slug, session.user.id);
|
allow_promotion_codes: true,
|
||||||
await createDefaultInstallation({
|
customer: stripeCustomerId,
|
||||||
appType,
|
line_items: [
|
||||||
userId: session.user.id,
|
|
||||||
slug,
|
|
||||||
key: {
|
|
||||||
apiKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await fetch(
|
|
||||||
`${process.env.NODE_ENV === "development" ? "http://localhost:3005" : "https://cal.ai"}/api/onboard`,
|
|
||||||
{
|
{
|
||||||
method: "POST",
|
quantity: 1,
|
||||||
headers: {
|
price: appConfig.paid.priceId,
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
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);
|
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 add } from "./add";
|
||||||
|
export { default as callback } from "./callback";
|
||||||
|
|
|
@ -14,5 +14,9 @@
|
||||||
"isTemplate": false,
|
"isTemplate": false,
|
||||||
"__createdUsingCli": true,
|
"__createdUsingCli": true,
|
||||||
"__template": "basic",
|
"__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",
|
"version": "0.0.0",
|
||||||
"main": "./index.ts",
|
"main": "./index.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@calcom/lib": "*"
|
"@calcom/lib": "*",
|
||||||
|
"@calcom/prisma": "workspace:^",
|
||||||
|
"stripe": "^14.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@calcom/types": "*"
|
"@calcom/types": "*"
|
||||||
|
|
|
@ -30,6 +30,12 @@ type DynamicLinkBasedEventLocation = {
|
||||||
|
|
||||||
export type EventLocationTypeFromAppMeta = StaticLinkBasedEventLocation | DynamicLinkBasedEventLocation;
|
export type EventLocationTypeFromAppMeta = StaticLinkBasedEventLocation | DynamicLinkBasedEventLocation;
|
||||||
|
|
||||||
|
type PaidAppData = {
|
||||||
|
price: number;
|
||||||
|
priceId: string;
|
||||||
|
hasTrial?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type AppData = {
|
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.
|
* 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;
|
upgradeUrl: string;
|
||||||
};
|
};
|
||||||
appData?: AppData;
|
appData?: AppData;
|
||||||
|
/** Represents paid app data, such as price, trials, etc */
|
||||||
|
paid?: PaidAppData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated
|
* @deprecated
|
||||||
* Used only by legacy apps which had slug different from their directory name.
|
* 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 }}
|
addAppMutationInput={{ type: app.type, variant: app.variant, slug: app.slug }}
|
||||||
appCategories={app.categories}
|
appCategories={app.categories}
|
||||||
concurrentMeetings={app.concurrentMeetings}
|
concurrentMeetings={app.concurrentMeetings}
|
||||||
|
paid={app.paid}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
@ -146,6 +147,7 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
|
||||||
appCategories={app.categories}
|
appCategories={app.categories}
|
||||||
credentials={credentials}
|
credentials={credentials}
|
||||||
concurrentMeetings={app.concurrentMeetings}
|
concurrentMeetings={app.concurrentMeetings}
|
||||||
|
paid={app.paid}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -174,6 +176,7 @@ const InstallAppButtonChild = ({
|
||||||
appCategories,
|
appCategories,
|
||||||
credentials,
|
credentials,
|
||||||
concurrentMeetings,
|
concurrentMeetings,
|
||||||
|
paid,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
userAdminTeams?: UserAdminTeams;
|
userAdminTeams?: UserAdminTeams;
|
||||||
|
@ -181,6 +184,7 @@ const InstallAppButtonChild = ({
|
||||||
appCategories: string[];
|
appCategories: string[];
|
||||||
credentials?: Credential[];
|
credentials?: Credential[];
|
||||||
concurrentMeetings?: boolean;
|
concurrentMeetings?: boolean;
|
||||||
|
paid: App["paid"];
|
||||||
} & ButtonProps) => {
|
} & ButtonProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -201,6 +205,19 @@ const InstallAppButtonChild = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!userAdminTeams?.length || !doesAppSupportTeamInstall(appCategories, concurrentMeetings)) {
|
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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|
Loading…
Reference in New Issue