chore: Stripe paid apps flow

feat/paid-apps
exception 2023-10-26 09:59:18 -03:00
parent defa8df7ca
commit 6d2ae60b9e
No known key found for this signature in database
GPG Key ID: 23773F008D05B460
13 changed files with 3489 additions and 462 deletions

View File

@ -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}
/>
);

View File

@ -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"

View File

@ -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={

View File

@ -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`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
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: [
{
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);

View File

@ -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);

View File

@ -0,0 +1,5 @@
import { defaultHandler } from "@calcom/lib/server";
export default defaultHandler({
GET: import("./_getCallback"),
});

View File

@ -1 +1,2 @@
export { default as add } from "./add";
export { default as callback } from "./callback";

View File

@ -14,5 +14,9 @@
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic",
"dirName": "cal-ai"
"dirName": "cal-ai",
"paid": {
"price": 25,
"priceId": "price_1O5ScNDVtJmy8ddXvHYTaovD"
}
}

View File

@ -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",
});

View File

@ -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": "*"

View File

@ -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.

View File

@ -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"

3692
yarn.lock

File diff suppressed because it is too large Load Diff