feat: Alby integration (#11495)
Co-authored-by: Peer Richelsen <peer@cal.com> Co-authored-by: alannnc <alannnc@gmail.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
parent
ef45cbfb3f
commit
2021b641ce
|
@ -89,6 +89,7 @@ export const AppPage = ({
|
|||
|
||||
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
|
||||
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
|
||||
|
||||
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
|
||||
{ appType: type },
|
||||
{
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { default, config } from "@calcom/app-store/alby/api/webhook";
|
|
@ -1 +1 @@
|
|||
export { default, config } from "@calcom/features/ee/payments/api/paypal-webhook";
|
||||
export { default, config } from "@calcom/app-store/paypal/api/webhook";
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import type { GetStaticPaths, InferGetStaticPropsType } from "next";
|
||||
import type { InferGetServerSidePropsType } from "next";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
|
||||
import { getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
|
||||
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
export default function SetupInformation(props: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const slug = searchParams?.get("slug") as string;
|
||||
|
@ -36,11 +36,4 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
|
|||
|
||||
SetupInformation.PageWrapper = PageWrapper;
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
return {
|
||||
paths: [],
|
||||
fallback: "blocking",
|
||||
};
|
||||
};
|
||||
|
||||
export { getStaticProps };
|
||||
export { getServerSideProps };
|
||||
|
|
|
@ -23,6 +23,7 @@ import {
|
|||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
|
||||
import { SMS_REMINDER_NUMBER_FIELD, SystemField } from "@calcom/features/bookings/lib/SystemField";
|
||||
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
|
@ -93,7 +94,7 @@ const querySchema = z.object({
|
|||
});
|
||||
|
||||
export default function Success(props: SuccessProps) {
|
||||
const { t, i18n } = useLocale();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const routerQuery = useRouterQuery();
|
||||
const pathname = usePathname();
|
||||
|
@ -490,10 +491,7 @@ export default function Success(props: SuccessProps) {
|
|||
: t("payment")}
|
||||
</div>
|
||||
<div className="col-span-2 mb-2 mt-3">
|
||||
{new Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: props.paymentStatus.currency,
|
||||
}).format(props.paymentStatus.amount / 100.0)}
|
||||
<Price currency={props.paymentStatus.currency} price={props.paymentStatus.amount} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -556,6 +556,8 @@ export async function apiLogin(
|
|||
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
|
||||
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
|
||||
await page.locator("div > .ml-auto").first().click();
|
||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.getByTestId("select-option-usd").click();
|
||||
await page.getByPlaceholder("Price").fill("100");
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
}
|
||||
|
|
|
@ -174,7 +174,7 @@ test.describe("Stripe integration", () => {
|
|||
await page.getByTestId("price-input-stripe").fill("200");
|
||||
|
||||
// Select currency in dropdown
|
||||
await page.locator("div").filter({ hasText: "United States dollar (USD)" }).nth(1).click();
|
||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.locator("#react-select-2-input").fill("mexi");
|
||||
await page.locator("#react-select-2-option-81").click();
|
||||
|
||||
|
|
|
@ -0,0 +1,203 @@
|
|||
import { expect } from "@playwright/test";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Payment app", () => {
|
||||
test("Should be able to edit alby price, currency", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
if (!paymentEvent) {
|
||||
throw new Error("No payment event found");
|
||||
}
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "alby_payment",
|
||||
userId: user.id,
|
||||
key: {
|
||||
account_id: "random",
|
||||
account_email: "random@example.com",
|
||||
webhook_endpoint_id: "ep_randomString",
|
||||
webhook_endpoint_secret: "whsec_randomString",
|
||||
account_lightning_address: "random@getalby.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
await page.getByPlaceholder("Price").click();
|
||||
await page.getByPlaceholder("Price").fill("200");
|
||||
await page.getByText("SatoshissatsCurrencyBTCPayment optionCollect payment on booking").click();
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
|
||||
await page.goto(`${user.username}/${paymentEvent.slug}`);
|
||||
|
||||
// expect 200 sats to be displayed in page
|
||||
expect(await page.locator("text=200 sats").first()).toBeTruthy();
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
expect(await page.locator("text=200 sats").first()).toBeTruthy();
|
||||
|
||||
// go to /event-types and check if the price is 200 sats
|
||||
await page.goto(`event-types/`);
|
||||
expect(await page.locator("text=200 sats").first()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Should be able to edit stripe price, currency", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
if (!paymentEvent) {
|
||||
throw new Error("No payment event found");
|
||||
}
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "stripe_payment",
|
||||
userId: user.id,
|
||||
key: {
|
||||
scope: "read_write",
|
||||
livemode: false,
|
||||
token_type: "bearer",
|
||||
access_token: "sk_test_randomString",
|
||||
refresh_token: "rt_randomString",
|
||||
stripe_user_id: "acct_randomString",
|
||||
default_currency: "usd",
|
||||
stripe_publishable_key: "pk_test_randomString",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.getByTestId("select-option-usd").click();
|
||||
|
||||
await page.getByTestId("price-input-stripe").click();
|
||||
await page.getByTestId("price-input-stripe").fill("350");
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
|
||||
await page.goto(`${user.username}/${paymentEvent.slug}`);
|
||||
|
||||
// expect 200 sats to be displayed in page
|
||||
expect(await page.locator("text=350").first()).toBeTruthy();
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
expect(await page.locator("text=350").first()).toBeTruthy();
|
||||
|
||||
// go to /event-types and check if the price is 200 sats
|
||||
await page.goto(`event-types/`);
|
||||
expect(await page.locator("text=350").first()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Should be able to edit paypal price, currency", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
if (!paymentEvent) {
|
||||
throw new Error("No payment event found");
|
||||
}
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "paypal_payment",
|
||||
userId: user.id,
|
||||
key: {
|
||||
client_id: "randomString",
|
||||
secret_key: "randomString",
|
||||
webhook_id: "randomString",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
|
||||
await page.getByPlaceholder("Price").click();
|
||||
await page.getByPlaceholder("Price").fill("150");
|
||||
|
||||
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.locator("#react-select-2-option-13").click();
|
||||
|
||||
await page.locator(".mb-1 > .bg-default > div > div:nth-child(2)").first().click();
|
||||
|
||||
await page.getByText("$MXNCurrencyMexican pesoPayment option").click();
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
|
||||
await page.goto(`${user.username}/${paymentEvent.slug}`);
|
||||
|
||||
// expect 150 to be displayed in page
|
||||
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
// expect 150 to be displayed in page
|
||||
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
|
||||
|
||||
// go to /event-types and check if the price is 150
|
||||
await page.goto(`event-types/`);
|
||||
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Should display App is not setup already for alby", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
if (!paymentEvent) {
|
||||
throw new Error("No payment event found");
|
||||
}
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "alby_payment",
|
||||
userId: user.id,
|
||||
key: {},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
|
||||
// expect text "This app has not been setup yet" to be displayed
|
||||
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
|
||||
|
||||
await page.getByRole("button", { name: "Setup" }).click();
|
||||
|
||||
// Expect "Connect with Alby" to be displayed
|
||||
expect(await page.locator("text=Connect with Alby").first()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Should display App is not setup already for paypal", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
if (!paymentEvent) {
|
||||
throw new Error("No payment event found");
|
||||
}
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "paypal_payment",
|
||||
userId: user.id,
|
||||
key: {},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
|
||||
// expect text "This app has not been setup yet" to be displayed
|
||||
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
|
||||
|
||||
await page.getByRole("button", { name: "Setup" }).click();
|
||||
|
||||
// Expect "Getting started with Paypal APP" to be displayed
|
||||
expect(await page.locator("text=Getting started with Paypal APP").first()).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -2059,5 +2059,6 @@
|
|||
"edit_users_availability":"Edit user's availability: {{username}}",
|
||||
"resend_invitation": "Resend invitation",
|
||||
"invitation_resent": "The invitation was resent.",
|
||||
"this_app_is_not_setup_already": "This app has not been setup yet",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -19,4 +19,4 @@
|
|||
1. Add API documentation links in comments for files `api`, `lib` and `types`
|
||||
2. Use [`AppDeclarativeHandler`](../types/AppHandler.d.ts) across all apps. Whatever isn't supported in it, support that.
|
||||
3. README should be added in the respective app and can be linked in main README [like this](https://github.com/calcom/cal.com/pull/10429/files/155ac84537d12026f595551fe3542e810b029714#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R509)
|
||||
4. Also, no env variables should be added by an app. They should be [added in `zod.ts`](https://github.com/calcom/cal.com/blob/main/packages/app-store/jitsivideo/zod.ts) and then they would be automatically available to be modified by the cal.com app admin.
|
||||
4. Also, no env variables should be added by an app. They should be [added in `zod.ts`](https://github.com/calcom/cal.com/blob/main/packages/app-store/jitsivideo/zod.ts) and then they would be automatically available to be modified by the cal.com app admin. In local development you can open /settings/admin with the admin credentials (see [seed.ts](packages/prisma/seed.ts))
|
|
@ -1,10 +1,12 @@
|
|||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Switch, Badge, Avatar } from "@calcom/ui";
|
||||
import { Switch, Badge, Avatar, Button } from "@calcom/ui";
|
||||
import { Settings } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { CredentialOwner } from "../types";
|
||||
import OmniInstallAppButton from "./OmniInstallAppButton";
|
||||
|
@ -27,6 +29,7 @@ export default function AppCard({
|
|||
teamId?: number;
|
||||
LockedIcon?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const { setAppData, LockedIcon, disabled } = useAppContextWithSchema();
|
||||
|
||||
|
@ -111,8 +114,23 @@ export default function AppCard({
|
|||
</div>
|
||||
<div ref={animationRef}>
|
||||
{app?.isInstalled && switchChecked && <hr className="border-subtle" />}
|
||||
|
||||
{app?.isInstalled && switchChecked ? (
|
||||
<div className="p-4 pt-5 text-sm [&_input]:mb-0 [&_input]:leading-4">{children}</div>
|
||||
app.isSetupAlready ? (
|
||||
<div className="relative p-4 pt-5 text-sm [&_input]:mb-0 [&_input]:leading-4">
|
||||
<Link href={`/apps/${app.slug}/setup`} className="absolute right-4 top-4">
|
||||
<Settings className="text-default h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-64 w-full flex-col items-center justify-center gap-4 ">
|
||||
<p>{t("this_app_is_not_setup_already")}</p>
|
||||
<Link href={`/apps/${app.slug}/setup`}>
|
||||
<Button StartIcon={Settings}>{t("setup")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
export const AppSetupPageMap = {
|
||||
alby: import("../../alby/pages/setup/_getServerSideProps"),
|
||||
make: import("../../make/pages/setup/_getServerSideProps"),
|
||||
zapier: import("../../zapier/pages/setup/_getServerSideProps"),
|
||||
stripe: import("../../stripepayment/pages/setup/_getServerSideProps"),
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const { slug } = ctx.params || {};
|
||||
if (typeof slug !== "string") return { notFound: true } as const;
|
||||
|
||||
if (!(slug in AppSetupPageMap)) return { props: {} };
|
||||
|
||||
const page = await AppSetupPageMap[slug as keyof typeof AppSetupPageMap];
|
||||
|
||||
if (!page.getServerSideProps) return { props: {} };
|
||||
|
||||
const props = await page.getServerSideProps(ctx);
|
||||
|
||||
return props;
|
||||
};
|
|
@ -1,21 +0,0 @@
|
|||
import type { GetStaticPropsContext } from "next";
|
||||
|
||||
export const AppSetupPageMap = {
|
||||
zapier: import("../../zapier/pages/setup/_getStaticProps"),
|
||||
make: import("../../make/pages/setup/_getStaticProps"),
|
||||
};
|
||||
|
||||
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
const { slug } = ctx.params || {};
|
||||
if (typeof slug !== "string") return { notFound: true } as const;
|
||||
|
||||
if (!(slug in AppSetupPageMap)) return { props: {} };
|
||||
|
||||
const page = await AppSetupPageMap[slug as keyof typeof AppSetupPageMap];
|
||||
|
||||
if (!page.getStaticProps) return { props: {} };
|
||||
|
||||
const props = await page.getStaticProps(ctx);
|
||||
|
||||
return props;
|
||||
};
|
|
@ -3,6 +3,7 @@ import dynamic from "next/dynamic";
|
|||
import { DynamicComponent } from "../../_components/DynamicComponent";
|
||||
|
||||
export const AppSetupMap = {
|
||||
alby: dynamic(() => import("../../alby/pages/setup")),
|
||||
"apple-calendar": dynamic(() => import("../../applecalendar/pages/setup")),
|
||||
exchange: dynamic(() => import("../../exchangecalendar/pages/setup")),
|
||||
"exchange2013-calendar": dynamic(() => import("../../exchange2013calendar/pages/setup")),
|
||||
|
@ -12,6 +13,7 @@ export const AppSetupMap = {
|
|||
make: dynamic(() => import("../../make/pages/setup")),
|
||||
closecom: dynamic(() => import("../../closecom/pages/setup")),
|
||||
sendgrid: dynamic(() => import("../../sendgrid/pages/setup")),
|
||||
stripe: dynamic(() => import("../../stripepayment/pages/setup")),
|
||||
paypal: dynamic(() => import("../../paypal/pages/setup")),
|
||||
};
|
||||
|
||||
|
|
|
@ -1,16 +1,42 @@
|
|||
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
|
||||
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import appConfig from "../config.json";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const handler: AppDeclarativeHandler = {
|
||||
appType: appConfig.type,
|
||||
variant: appConfig.variant,
|
||||
slug: appConfig.slug,
|
||||
supportsMultipleInstalls: false,
|
||||
handlerType: "add",
|
||||
createCredential: ({ appType, user, slug, teamId }) =>
|
||||
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
|
||||
};
|
||||
import config from "../config.json";
|
||||
|
||||
export default handler;
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session?.user?.id) {
|
||||
return res.status(401).json({ message: "You must be logged in to do this" });
|
||||
}
|
||||
const appType = config.type;
|
||||
try {
|
||||
const alreadyInstalled = await prisma.credential.findFirst({
|
||||
where: {
|
||||
type: appType,
|
||||
userId: req.session.user.id,
|
||||
},
|
||||
});
|
||||
if (alreadyInstalled) {
|
||||
throw new Error("Already installed");
|
||||
}
|
||||
const installation = await prisma.credential.create({
|
||||
data: {
|
||||
type: appType,
|
||||
key: {},
|
||||
userId: req.session.user.id,
|
||||
appId: "alby",
|
||||
},
|
||||
});
|
||||
|
||||
if (!installation) {
|
||||
throw new Error("Unable to create user credential for Alby");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return res.status(500).json({ message: error.message });
|
||||
}
|
||||
return res.status(500);
|
||||
}
|
||||
|
||||
return res.status(200).json({ url: "/apps/alby/setup" });
|
||||
}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export { default as add } from "./add";
|
||||
export { default as webhook, config } from "@calcom/web/pages/api/integrations/alby/webhook";
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import getRawBody from "raw-body";
|
||||
import * as z from "zod";
|
||||
|
||||
import { albyCredentialKeysSchema } from "@calcom/app-store/alby/lib";
|
||||
import parseInvoice from "@calcom/app-store/alby/lib/parseInvoice";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (req.method !== "POST") {
|
||||
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
|
||||
}
|
||||
|
||||
const bodyRaw = await getRawBody(req);
|
||||
const headers = req.headers;
|
||||
const bodyAsString = bodyRaw.toString();
|
||||
|
||||
const parseHeaders = webhookHeadersSchema.safeParse(headers);
|
||||
if (!parseHeaders.success) {
|
||||
console.error(parseHeaders.error);
|
||||
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
|
||||
}
|
||||
|
||||
const { data: parsedHeaders } = parseHeaders;
|
||||
|
||||
const parse = eventSchema.safeParse(JSON.parse(bodyAsString));
|
||||
if (!parse.success) {
|
||||
console.error(parse.error);
|
||||
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
|
||||
}
|
||||
|
||||
const { data: parsedPayload } = parse;
|
||||
|
||||
if (parsedPayload.metadata?.payer_data?.appId !== "cal.com") {
|
||||
throw new HttpCode({ statusCode: 204, message: "Payment not for cal.com" });
|
||||
}
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
uid: parsedPayload.metadata.payer_data.referenceId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
bookingId: true,
|
||||
booking: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
credentials: {
|
||||
where: {
|
||||
type: "alby_payment",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!payment) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
|
||||
const key = payment.booking?.user?.credentials?.[0].key;
|
||||
if (!key) throw new HttpCode({ statusCode: 204, message: "Credentials not found" });
|
||||
|
||||
const parseCredentials = albyCredentialKeysSchema.safeParse(key);
|
||||
if (!parseCredentials.success) {
|
||||
console.error(parseCredentials.error);
|
||||
throw new HttpCode({ statusCode: 500, message: "Credentials not valid" });
|
||||
}
|
||||
|
||||
const credentials = parseCredentials.data;
|
||||
|
||||
const albyInvoice = await parseInvoice(bodyAsString, parsedHeaders, credentials.webhook_endpoint_secret);
|
||||
if (!albyInvoice) throw new HttpCode({ statusCode: 204, message: "Invoice not found" });
|
||||
if (albyInvoice.amount !== payment.amount) {
|
||||
throw new HttpCode({ statusCode: 400, message: "invoice amount does not match payment amount" });
|
||||
}
|
||||
|
||||
return await handlePaymentSuccess(payment.id, payment.bookingId);
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
console.error(`Webhook Error: ${err.message}`);
|
||||
return res.status(err.statusCode || 500).send({
|
||||
message: err.message,
|
||||
stack: IS_PRODUCTION ? undefined : err.stack,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const payerDataSchema = z
|
||||
.object({
|
||||
appId: z.string().optional(),
|
||||
referenceId: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const metadataSchema = z
|
||||
.object({
|
||||
payer_data: payerDataSchema,
|
||||
})
|
||||
.optional();
|
||||
|
||||
const eventSchema = z.object({
|
||||
metadata: metadataSchema,
|
||||
});
|
||||
|
||||
const webhookHeadersSchema = z
|
||||
.object({
|
||||
"svix-id": z.string(),
|
||||
"svix-timestamp": z.string(),
|
||||
"svix-signature": z.string(),
|
||||
})
|
||||
.passthrough();
|
|
@ -0,0 +1,177 @@
|
|||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import QRCode from "react-qr-code";
|
||||
import z from "zod";
|
||||
|
||||
import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment";
|
||||
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { showToast } from "@calcom/ui";
|
||||
import { ClipboardCheck, Clipboard } from "@calcom/ui/components/icon";
|
||||
import { Spinner } from "@calcom/ui/components/icon/Spinner";
|
||||
|
||||
interface IAlbyPaymentComponentProps {
|
||||
payment: {
|
||||
// Will be parsed on render
|
||||
data: unknown;
|
||||
};
|
||||
paymentPageProps: PaymentPageProps;
|
||||
}
|
||||
|
||||
// Create zod schema for data
|
||||
const PaymentAlbyDataSchema = z.object({
|
||||
invoice: z
|
||||
.object({
|
||||
paymentRequest: z.string(),
|
||||
})
|
||||
.required(),
|
||||
});
|
||||
|
||||
export const AlbyPaymentComponent = (props: IAlbyPaymentComponentProps) => {
|
||||
const { payment } = props;
|
||||
const { data } = payment;
|
||||
const [showQRCode, setShowQRCode] = useState(window.webln === undefined);
|
||||
const [isPaying, setPaying] = useState(false);
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
const wrongUrl = (
|
||||
<>
|
||||
<p className="mt-3 text-center">Couldn't obtain payment URL</p>
|
||||
</>
|
||||
);
|
||||
|
||||
const parsedData = PaymentAlbyDataSchema.safeParse(data);
|
||||
if (!parsedData.success || !parsedData.data?.invoice?.paymentRequest) {
|
||||
return wrongUrl;
|
||||
}
|
||||
const paymentRequest = parsedData.data.invoice.paymentRequest;
|
||||
|
||||
return (
|
||||
<div className="mb-4 mt-8 flex h-full w-full flex-col items-center justify-center gap-4">
|
||||
<PaymentChecker {...props.paymentPageProps} />
|
||||
{isPaying && <Spinner className="mt-12 h-8 w-8" />}
|
||||
{!isPaying && (
|
||||
<>
|
||||
{!showQRCode && (
|
||||
<div className="flex gap-4">
|
||||
<Button color="secondary" onClick={() => setShowQRCode(true)}>
|
||||
Show QR
|
||||
</Button>
|
||||
{window.webln && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (!window.webln) {
|
||||
throw new Error("webln not found");
|
||||
}
|
||||
setPaying(true);
|
||||
await window.webln.enable();
|
||||
window.webln.sendPayment(paymentRequest);
|
||||
} catch (error) {
|
||||
setPaying(false);
|
||||
alert((error as Error).message);
|
||||
}
|
||||
}}>
|
||||
Pay Now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showQRCode && (
|
||||
<>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<p className="text-xs">Waiting for payment...</p>
|
||||
<Spinner className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm">Click or scan the invoice below to pay</p>
|
||||
<Link
|
||||
href={`lightning:${paymentRequest}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl rounded-md border border-transparent p-2
|
||||
font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
|
||||
<QRCode size={128} value={paymentRequest} />
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={() => copyToClipboard(paymentRequest)}
|
||||
className="text-subtle rounded-md"
|
||||
StartIcon={isCopied ? ClipboardCheck : Clipboard}>
|
||||
Copy Invoice
|
||||
</Button>
|
||||
<Link target="_blank" href="https://getalby.com" className="link mt-4 text-sm underline">
|
||||
Don't have a lightning wallet?
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Link target="_blank" href="https://getalby.com">
|
||||
<div className="mt-4 flex items-center text-sm">
|
||||
Powered by
|
||||
<img title="Alby" src="/app-store/alby/icon.svg" alt="Alby" className="h-8 w-8" />
|
||||
Alby
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type PaymentCheckerProps = PaymentPageProps;
|
||||
|
||||
function PaymentChecker(props: PaymentCheckerProps) {
|
||||
// TODO: move booking success code to a common lib function
|
||||
// TODO: subscribe rather than polling
|
||||
const searchParams = useSearchParams();
|
||||
const bookingSuccessRedirect = useBookingSuccessRedirect();
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
(async () => {
|
||||
if (props.booking.status === "ACCEPTED") {
|
||||
return;
|
||||
}
|
||||
const { booking: bookingResult } = await utils.viewer.bookings.find.fetch({
|
||||
bookingUid: props.booking.uid,
|
||||
});
|
||||
|
||||
if (bookingResult?.paid) {
|
||||
showToast("Payment successful", "success");
|
||||
|
||||
const params: {
|
||||
uid: string;
|
||||
email: string | null;
|
||||
location: string;
|
||||
} = {
|
||||
uid: props.booking.uid,
|
||||
email: searchParams.get("email"),
|
||||
location: t("web_conferencing_details_to_follow"),
|
||||
};
|
||||
|
||||
bookingSuccessRedirect({
|
||||
successRedirectUrl: props.eventType.successRedirectUrl,
|
||||
query: params,
|
||||
booking: props.booking,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [
|
||||
bookingSuccessRedirect,
|
||||
props.booking,
|
||||
props.booking.id,
|
||||
props.booking.status,
|
||||
props.eventType.id,
|
||||
props.eventType.successRedirectUrl,
|
||||
props.payment.success,
|
||||
searchParams,
|
||||
t,
|
||||
utils.viewer.bookings,
|
||||
]);
|
||||
return null;
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import { fiat } from "@getalby/lightning-tools";
|
||||
import React from "react";
|
||||
|
||||
import { Tooltip } from "@calcom/ui";
|
||||
import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol";
|
||||
|
||||
type AlbyPriceComponentProps = {
|
||||
displaySymbol: boolean;
|
||||
price: number;
|
||||
formattedPrice: string;
|
||||
};
|
||||
|
||||
export function AlbyPriceComponent({ displaySymbol, price, formattedPrice }: AlbyPriceComponentProps) {
|
||||
const [fiatValue, setFiatValue] = React.useState<string>("loading...");
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
const unformattedFiatValue = await fiat.getFiatValue({ satoshi: price, currency: "USD" });
|
||||
setFiatValue(`$${unformattedFiatValue.toFixed(2)}`);
|
||||
})();
|
||||
}, [price]);
|
||||
|
||||
return (
|
||||
<Tooltip content={fiatValue}>
|
||||
<div className="inline-flex items-center justify-center">
|
||||
{displaySymbol && <SatSymbol className="h-4 w-4" />}
|
||||
{formattedPrice}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
import { useRouter } from "next/router";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import AppCard from "@calcom/app-store/_components/AppCard";
|
||||
import { currencyOptions } from "@calcom/app-store/alby/lib/currencyOptions";
|
||||
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert, Select, TextField } from "@calcom/ui";
|
||||
import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol";
|
||||
|
||||
import type { appDataSchema } from "../zod";
|
||||
import { PaypalPaymentOptions as paymentOptions } from "../zod";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
|
||||
const { asPath } = useRouter();
|
||||
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const price = getAppData("price");
|
||||
const currency = getAppData("currency");
|
||||
const [selectedCurrency, setSelectedCurrency] = useState(
|
||||
currencyOptions.find((c) => c.value === currency) || currencyOptions[0]
|
||||
);
|
||||
const paymentOption = getAppData("paymentOption");
|
||||
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
|
||||
label: paymentOptions[0].label,
|
||||
value: paymentOptions[0].value,
|
||||
};
|
||||
const seatsEnabled = !!eventType.seatsPerTimeSlot;
|
||||
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
|
||||
const { t } = useLocale();
|
||||
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
|
||||
|
||||
// make sure a currency is selected
|
||||
useEffect(() => {
|
||||
if (!currency && requirePayment) {
|
||||
setAppData("currency", selectedCurrency.value);
|
||||
}
|
||||
}, [currency, selectedCurrency, setAppData, requirePayment]);
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
returnTo={WEBAPP_URL + asPath}
|
||||
app={app}
|
||||
switchChecked={requirePayment}
|
||||
switchOnClick={(enabled) => {
|
||||
setRequirePayment(enabled);
|
||||
}}
|
||||
description={<>Add bitcoin lightning payments to your events</>}>
|
||||
<>
|
||||
{recurringEventDefined ? (
|
||||
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
|
||||
) : (
|
||||
requirePayment && (
|
||||
<>
|
||||
<div className="mt-2 block items-center sm:flex">
|
||||
<TextField
|
||||
label="Price"
|
||||
labelSrOnly
|
||||
addOnLeading={<SatSymbol className="h-4 w-4" />}
|
||||
addOnSuffix={selectedCurrency.unit || selectedCurrency.value}
|
||||
type="number"
|
||||
required
|
||||
className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm"
|
||||
placeholder="Price"
|
||||
onChange={(e) => {
|
||||
setAppData("price", Number(e.target.value));
|
||||
if (currency) {
|
||||
setAppData("currency", currency);
|
||||
}
|
||||
}}
|
||||
value={price && price > 0 ? price : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 w-60">
|
||||
<label className="text-default block text-sm font-medium" htmlFor="currency">
|
||||
{t("currency")}
|
||||
</label>
|
||||
<Select
|
||||
variant="default"
|
||||
options={currencyOptions}
|
||||
value={selectedCurrency}
|
||||
className="text-black"
|
||||
defaultValue={selectedCurrency}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
setSelectedCurrency(e);
|
||||
setAppData("currency", e.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 w-60">
|
||||
<label className="text-default block text-sm font-medium" htmlFor="currency">
|
||||
Payment option
|
||||
</label>
|
||||
<Select<Option>
|
||||
defaultValue={
|
||||
paymentOptionSelectValue
|
||||
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
|
||||
: { ...paymentOptions[0], label: t(paymentOptions[0].label) }
|
||||
}
|
||||
options={paymentOptions.map((option) => {
|
||||
return { ...option, label: t(option.label) || option.label };
|
||||
})}
|
||||
onChange={(input) => {
|
||||
if (input) setAppData("paymentOption", input.value);
|
||||
}}
|
||||
className="mb-1 h-[38px] w-full"
|
||||
isDisabled={seatsEnabled}
|
||||
/>
|
||||
</div>
|
||||
{seatsEnabled && paymentOption === "HOLD" && (
|
||||
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
</AppCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeAppCard;
|
|
@ -4,14 +4,15 @@
|
|||
"slug": "alby",
|
||||
"type": "alby_payment",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://example.com/link",
|
||||
"url": "https://getalby.com",
|
||||
"variant": "payment",
|
||||
"categories": ["payment"],
|
||||
"publisher": "Alby",
|
||||
"email": "support@getalby.com",
|
||||
"description": "Your Bitcoin & Nostr companion for the web. Use Alby to charge Satoshi for your Cal.com meetings.\r",
|
||||
"extendsFeature": "EventType",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "basic",
|
||||
"__template": "event-type-app-card",
|
||||
"dirName": "alby"
|
||||
}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export * as api from "./api";
|
||||
export * as lib from "./lib";
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
import { LightningAddress } from "@getalby/lightning-tools";
|
||||
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type z from "zod";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
|
||||
|
||||
import { albyCredentialKeysSchema } from "./albyCredentialKeysSchema";
|
||||
|
||||
export class PaymentService implements IAbstractPaymentService {
|
||||
private credentials: z.infer<typeof albyCredentialKeysSchema> | null;
|
||||
|
||||
constructor(credentials: { key: Prisma.JsonValue }) {
|
||||
const keyParsing = albyCredentialKeysSchema.safeParse(credentials.key);
|
||||
if (keyParsing.success) {
|
||||
this.credentials = keyParsing.data;
|
||||
} else {
|
||||
this.credentials = null;
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: Booking["id"]
|
||||
) {
|
||||
try {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
},
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
});
|
||||
if (!booking || !this.credentials?.account_lightning_address) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
const uid = uuidv4();
|
||||
|
||||
const lightningAddress = new LightningAddress(this.credentials.account_lightning_address);
|
||||
await lightningAddress.fetch();
|
||||
const invoice = await lightningAddress.requestInvoice({
|
||||
satoshi: payment.amount,
|
||||
payerdata: {
|
||||
appId: "cal.com",
|
||||
referenceId: uid,
|
||||
},
|
||||
});
|
||||
console.log("Created invoice", invoice, uid);
|
||||
|
||||
const paymentData = await prisma.payment.create({
|
||||
data: {
|
||||
uid,
|
||||
app: {
|
||||
connect: {
|
||||
slug: "alby",
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: bookingId,
|
||||
},
|
||||
},
|
||||
amount: payment.amount,
|
||||
externalId: invoice.paymentRequest,
|
||||
currency: payment.currency,
|
||||
data: Object.assign({}, { invoice }) as unknown as Prisma.InputJsonValue,
|
||||
fee: 0,
|
||||
refunded: false,
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!paymentData) {
|
||||
throw new Error();
|
||||
}
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Payment could not be created");
|
||||
}
|
||||
}
|
||||
async update(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
async refund(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async collectCard(
|
||||
_payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
_bookingId: number,
|
||||
_bookerEmail: string,
|
||||
_paymentOption: PaymentOption
|
||||
): Promise<Payment> {
|
||||
throw new Error("Method not implemented");
|
||||
}
|
||||
chargeCard(
|
||||
_payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
_bookingId: number
|
||||
): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
getPaymentPaidStatus(): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
getPaymentDetails(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
afterPayment(
|
||||
_event: CalendarEvent,
|
||||
_booking: {
|
||||
user: { email: string | null; name: string | null; timeZone: string } | null;
|
||||
id: number;
|
||||
startTime: { toISOString: () => string };
|
||||
uid: string;
|
||||
},
|
||||
_paymentData: Payment
|
||||
): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
deletePayment(_paymentId: number): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
isSetupAlready(): boolean {
|
||||
return !!this.credentials;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import z from "zod";
|
||||
|
||||
export const albyCredentialKeysSchema = z.object({
|
||||
account_id: z.string(),
|
||||
account_email: z.string(),
|
||||
account_lightning_address: z.string(),
|
||||
webhook_endpoint_id: z.string(),
|
||||
webhook_endpoint_secret: z.string(),
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export const currencyOptions = [{ label: "BTC", value: "BTC", unit: "sats" }];
|
|
@ -0,0 +1,13 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
|
||||
export const getAlbyKeys = async () => {
|
||||
const appKeys = await getAppKeysFromSlug("alby");
|
||||
return appKeysSchema.parse(appKeys);
|
||||
};
|
||||
|
||||
const appKeysSchema = z.object({
|
||||
client_id: z.string().min(1),
|
||||
client_secret: z.string().min(1),
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
import type { Invoice as AlbyInvoice } from "@getalby/sdk/dist/types";
|
||||
|
||||
export * from "./PaymentService";
|
||||
export * from "./albyCredentialKeysSchema";
|
||||
|
||||
export type { AlbyInvoice };
|
|
@ -0,0 +1,22 @@
|
|||
import type { Invoice } from "@getalby/sdk/dist/types";
|
||||
import { Webhook } from "svix";
|
||||
|
||||
export default function parseInvoice(
|
||||
body: string,
|
||||
headers: {
|
||||
"svix-id": string;
|
||||
"svix-timestamp": string;
|
||||
"svix-signature": string;
|
||||
},
|
||||
webhookEndpointSecret: string
|
||||
): Invoice | null {
|
||||
try {
|
||||
const wh = new Webhook(webhookEndpointSecret);
|
||||
return wh.verify(body, headers) as Invoice;
|
||||
} catch (err) {
|
||||
// Looks like alby might sent multiple webhooks for the same invoice but it should only work once
|
||||
// TODO: remove the Alby webhook when uninstalling the Alby app
|
||||
console.error(err);
|
||||
}
|
||||
return null;
|
||||
}
|
|
@ -5,7 +5,12 @@
|
|||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
"@calcom/lib": "*",
|
||||
"@getalby/lightning-tools": "^4.0.2",
|
||||
"@getalby/sdk": "^2.4.0",
|
||||
"@webbtc/webln-types": "^2.0.1",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"svix": "^0.85.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { getAlbyKeys } from "../../lib/getAlbyKeys";
|
||||
import type { IAlbySetupProps } from "./index";
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
|
||||
|
||||
const { req, res } = ctx;
|
||||
const session = await getServerSession({ req, res });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return res.writeHead(401).end();
|
||||
}
|
||||
|
||||
const credentials = await prisma.credential.findFirst({
|
||||
where: {
|
||||
type: "alby_payment",
|
||||
userId: session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { client_id: clientId, client_secret: clientSecret } = await getAlbyKeys();
|
||||
|
||||
const props: IAlbySetupProps = {
|
||||
email: null,
|
||||
lightningAddress: null,
|
||||
clientId,
|
||||
clientSecret,
|
||||
};
|
||||
if (credentials?.key) {
|
||||
const { account_lightning_address, account_email } = credentials.key as {
|
||||
account_lightning_address?: string;
|
||||
account_email?: string;
|
||||
};
|
||||
if (account_lightning_address) {
|
||||
props.lightningAddress = account_lightning_address;
|
||||
}
|
||||
if (account_email) {
|
||||
props.email = account_email;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,178 @@
|
|||
import { auth, Client, webln } from "@getalby/sdk";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Badge, Button, showToast } from "@calcom/ui";
|
||||
import { Info } from "@calcom/ui/components/icon";
|
||||
|
||||
import { albyCredentialKeysSchema } from "../../lib/albyCredentialKeysSchema";
|
||||
|
||||
export interface IAlbySetupProps {
|
||||
email: string | null;
|
||||
lightningAddress: string | null;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export default function AlbySetup(props: IAlbySetupProps) {
|
||||
const params = useSearchParams();
|
||||
if (params?.get("callback") === "true") {
|
||||
return <AlbySetupCallback />;
|
||||
}
|
||||
|
||||
return <AlbySetupPage {...props} />;
|
||||
}
|
||||
|
||||
function AlbySetupCallback() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const params = useSearchParams();
|
||||
useEffect(() => {
|
||||
if (!window.opener) {
|
||||
setError("Something went wrong. Opener not available. Please contact support@getalby.com");
|
||||
return;
|
||||
}
|
||||
|
||||
const code = params.get("code");
|
||||
const error = params.get("error");
|
||||
|
||||
if (!code) {
|
||||
setError("declined");
|
||||
}
|
||||
if (error) {
|
||||
setError(error);
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
|
||||
window.opener.postMessage({
|
||||
type: "alby:oauth:success",
|
||||
payload: { code },
|
||||
});
|
||||
window.close();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <p>Authorization failed: {error}</p>}
|
||||
{!error && <p>Connecting...</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlbySetupPage(props: IAlbySetupProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const integrations = trpc.viewer.integrations.useQuery({ variant: "payment", appId: "alby" });
|
||||
const [albyPaymentAppCredentials] = integrations.data?.items || [];
|
||||
const [credentialId] = albyPaymentAppCredentials?.userCredentialIds || [-1];
|
||||
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
|
||||
const saveKeysMutation = trpc.viewer.appsRouter.updateAppCredentials.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("keys_have_been_saved"), "success");
|
||||
router.push("/event-types");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const connectWithAlby = useCallback(async () => {
|
||||
const authClient = new auth.OAuth2User({
|
||||
client_id: props.clientId,
|
||||
client_secret: props.clientSecret,
|
||||
callback: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/apps/alby/setup?callback=true`,
|
||||
scopes: ["invoices:read", "account:read"],
|
||||
user_agent: "cal.com",
|
||||
});
|
||||
|
||||
const weblnOAuthProvider = new webln.OauthWeblnProvider({
|
||||
auth: authClient,
|
||||
});
|
||||
await weblnOAuthProvider.enable();
|
||||
|
||||
const client = new Client(authClient);
|
||||
const accountInfo = await client.accountInformation({});
|
||||
// TODO: add a way to delete the endpoint when the app is uninstalled
|
||||
const webhookEndpoint = await client.createWebhookEndpoint({
|
||||
filter_types: ["invoice.incoming.settled"],
|
||||
url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/integrations/alby/webhook`,
|
||||
description: "Cal.com",
|
||||
});
|
||||
|
||||
saveKeysMutation.mutate({
|
||||
credentialId,
|
||||
key: albyCredentialKeysSchema.parse({
|
||||
account_id: accountInfo.identifier,
|
||||
account_email: accountInfo.email,
|
||||
account_lightning_address: accountInfo.lightning_address,
|
||||
webhook_endpoint_id: webhookEndpoint.id,
|
||||
webhook_endpoint_secret: webhookEndpoint.endpoint_secret,
|
||||
}),
|
||||
});
|
||||
}, [credentialId, props.clientId, props.clientSecret, saveKeysMutation]);
|
||||
|
||||
if (integrations.isLoading) {
|
||||
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-default flex h-screen">
|
||||
{showContent ? (
|
||||
<div className="flex w-full items-center justify-center p-4">
|
||||
<div className="bg-default border-subtle m-auto flex max-w-[43em] flex-col items-center justify-center gap-4 overflow-auto rounded border p-4 md:p-10">
|
||||
{!props.lightningAddress ? (
|
||||
<>
|
||||
<p className="text-default">
|
||||
Create or connect to an existing Alby account to receive lightning payments for your paid
|
||||
bookings.
|
||||
</p>
|
||||
<button
|
||||
className="font-body flex h-10 w-56 items-center justify-center gap-2 rounded-md font-bold text-black shadow transition-all hover:brightness-90 active:scale-95"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #FFDE6E 63.72%, #F8C455 95.24%)",
|
||||
}}
|
||||
type="button"
|
||||
onClick={connectWithAlby}>
|
||||
<img className="h-8 w-8" src="/api/app-store/alby/icon2.svg" alt="Alby Logo" />
|
||||
<span className="mr-2">Connect with Alby</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<img className="h-16 w-16" src="/api/app-store/alby/icon2.svg" alt="Alby Logo" />
|
||||
<p>Alby Connected!</p>
|
||||
<Badge>Email: {props.email}</Badge>
|
||||
<Badge>Lightning Address: {props.lightningAddress}</Badge>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TODO: remove when invoices are generated using user identifier */}
|
||||
<div className="mt-4 rounded bg-blue-50 p-3 text-sm text-blue-700">
|
||||
<Info className="mb-0.5 inline-flex h-4 w-4" /> Your Alby lightning address will be used to
|
||||
generate invoices. If you update your lightning address, please disconnect and setup the Alby
|
||||
app again.
|
||||
</div>
|
||||
<Link href="/apps/alby">
|
||||
<Button color="secondary">Go to App Store Listing</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-5 mt-5">
|
||||
<div>Alby</div>
|
||||
<div className="mt-3">
|
||||
<Link href="/apps/alby" passHref={true} legacyBehavior>
|
||||
<Button>{t("go_to_app_store")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="554" height="554" viewBox="0 0 554 554" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M127.7 202.4C107.431 202.4 90.9999 185.969 90.9999 165.7C90.9999 145.431 107.431 129 127.7 129C147.969 129 164.4 145.431 164.4 165.7C164.4 185.969 147.969 202.4 127.7 202.4Z" fill="black"/>
|
||||
<path d="M121.6 160.2L190.1 228.7" stroke="black" stroke-width="18.3"/>
|
||||
<path d="M427.2 202.4C447.469 202.4 463.9 185.969 463.9 165.7C463.9 145.431 447.469 129 427.2 129C406.931 129 390.5 145.431 390.5 165.7C390.5 185.969 406.931 202.4 427.2 202.4Z" fill="black"/>
|
||||
<path d="M434 160.2L365.5 228.7" stroke="black" stroke-width="18.3"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M138.1 384.6C128.451 380.122 120.543 372.597 115.592 363.182C110.641 353.768 108.922 342.987 110.7 332.5C127.3 240 196.2 170.6 278.7 170.6C361.4 170.6 430.5 240.3 446.8 333.2C448.539 343.695 446.779 354.47 441.792 363.867C436.805 373.263 428.866 380.759 419.2 385.2C375.441 405.799 327.664 416.454 279.3 416.4C228.8 416.4 180.9 405 138.1 384.6Z" fill="#FFDF6F"/>
|
||||
<path d="M119.8 334.2C135.8 244.7 201.8 179.8 278.8 179.8V161.5C190.6 161.5 118.8 235.3 101.7 330.9L119.7 334.1L119.8 334.2ZM278.8 179.8C355.8 179.8 422 245.1 437.8 334.8L455.8 331.6C439 235.6 367 161.5 278.8 161.5V179.8ZM415.3 376.9C372.76 396.917 326.314 407.265 279.3 407.2V425.5C330.7 425.5 379.4 414 423.1 393.5L415.3 376.9ZM279.3 407.2C230.2 407.2 183.7 396.1 142.1 376.3L134.2 392.9C178.2 413.9 227.4 425.5 279.3 425.5V407.2ZM437.8 334.8C440.8 352.1 431.6 369.3 415.3 376.9L423.1 393.5C446.5 382.5 460.4 357.5 455.8 331.5L437.8 334.8ZM101.7 330.8C99.5558 343.269 101.577 356.098 107.451 367.303C113.325 378.509 122.725 387.47 134.2 392.8L142.1 376.3C134.267 372.688 127.84 366.599 123.81 358.973C119.78 351.347 118.371 342.606 119.8 334.1L101.6 330.9L101.7 330.8Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M173.4 361.6C157.5 355.1 148.1 338.2 153.6 321.9C170.6 271.7 220.2 235.4 278.7 235.4C337.2 235.4 386.8 271.7 403.8 321.9C409.4 338.2 399.9 355.1 384 361.6C350.559 375.19 314.796 382.153 278.7 382.1C241.5 382.1 205.9 374.8 173.4 361.6Z" fill="black"/>
|
||||
<path d="M320.9 338.1C337.8 338.1 351.5 327.176 351.5 313.7C351.5 300.224 337.8 289.3 320.9 289.3C304 289.3 290.3 300.224 290.3 313.7C290.3 327.176 304 338.1 320.9 338.1Z" fill="white"/>
|
||||
<path d="M233.4 338.1C250.3 338.1 264 327.176 264 313.7C264 300.224 250.3 289.3 233.4 289.3C216.5 289.3 202.8 300.224 202.8 313.7C202.8 327.176 216.5 338.1 233.4 338.1Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
|
@ -0,0 +1,37 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
|
||||
|
||||
const paymentOptionSchema = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const paymentOptionsSchema = z.array(paymentOptionSchema);
|
||||
|
||||
export const PaypalPaymentOptions = [
|
||||
{
|
||||
label: "on_booking_option",
|
||||
value: "ON_BOOKING",
|
||||
},
|
||||
];
|
||||
|
||||
type PaymentOption = (typeof PaypalPaymentOptions)[number]["value"];
|
||||
const VALUES: [PaymentOption, ...PaymentOption[]] = [
|
||||
PaypalPaymentOptions[0].value,
|
||||
...PaypalPaymentOptions.slice(1).map((option) => option.value),
|
||||
];
|
||||
export const paymentOptionEnum = z.enum(VALUES);
|
||||
|
||||
export const appDataSchema = eventTypeAppCardZod.merge(
|
||||
z.object({
|
||||
price: z.number(),
|
||||
currency: z.string(),
|
||||
paymentOption: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
);
|
||||
export const appKeysSchema = z.object({
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
});
|
|
@ -20,6 +20,7 @@ export const AppSettingsComponentsMap = {
|
|||
zapier: dynamic(() => import("./zapier/components/AppSettingsInterface")),
|
||||
};
|
||||
export const EventTypeAddonMap = {
|
||||
alby: dynamic(() => import("./alby/components/EventTypeAppCardInterface")),
|
||||
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")),
|
||||
fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")),
|
||||
ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
This file is autogenerated using the command `yarn app-store:build --watch`.
|
||||
Don't modify this file manually.
|
||||
**/
|
||||
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
|
||||
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
||||
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||
import { appKeysSchema as fathom_zod_ts } from "./fathom/zod";
|
||||
|
@ -35,6 +36,7 @@ import { appKeysSchema as zohocrm_zod_ts } from "./zohocrm/zod";
|
|||
import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
||||
|
||||
export const appKeysSchemas = {
|
||||
alby: alby_zod_ts,
|
||||
basecamp3: basecamp3_zod_ts,
|
||||
dailyvideo: dailyvideo_zod_ts,
|
||||
fathom: fathom_zod_ts,
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
This file is autogenerated using the command `yarn app-store:build --watch`.
|
||||
Don't modify this file manually.
|
||||
**/
|
||||
import { appDataSchema as alby_zod_ts } from "./alby/zod";
|
||||
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
||||
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||
import { appDataSchema as fathom_zod_ts } from "./fathom/zod";
|
||||
|
@ -35,6 +36,7 @@ import { appDataSchema as zohocrm_zod_ts } from "./zohocrm/zod";
|
|||
import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
||||
|
||||
export const appDataSchemas = {
|
||||
alby: alby_zod_ts,
|
||||
basecamp3: basecamp3_zod_ts,
|
||||
dailyvideo: dailyvideo_zod_ts,
|
||||
fathom: fathom_zod_ts,
|
||||
|
|
|
@ -27,6 +27,10 @@ export const projectHandler = async ({ ctx }: ProjectsHandlerOptions) => {
|
|||
throw new TRPCError({ code: "FORBIDDEN", message: "No credential found for user" });
|
||||
}
|
||||
let credentialKey = credential.key as BasecampToken;
|
||||
if (!credentialKey.account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (credentialKey.expires_at < Date.now()) {
|
||||
credentialKey = (await refreshAccessToken(credential)) as BasecampToken;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
const appStore = {
|
||||
// example: () => import("./example"),
|
||||
alby: () => import("./alby"),
|
||||
applecalendar: () => import("./applecalendar"),
|
||||
aroundvideo: () => import("./around"),
|
||||
caldavcalendar: () => import("./caldavcalendar"),
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { GetStaticPropsContext } from "next";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import getAppKeysFromSlug from "../../../_utils/getAppKeysFromSlug";
|
||||
|
||||
|
@ -6,7 +6,7 @@ export interface IMakeSetupProps {
|
|||
inviteLink: string;
|
||||
}
|
||||
|
||||
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
|
||||
let inviteLink = "";
|
||||
const appKeys = await getAppKeysFromSlug("make");
|
|
@ -1,4 +1,4 @@
|
|||
import type { InferGetStaticPropsType } from "next";
|
||||
import type { InferGetServerSidePropsType } from "next";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
@ -9,11 +9,11 @@ import { trpc } from "@calcom/trpc/react";
|
|||
import { Button, Tooltip, showToast } from "@calcom/ui";
|
||||
import { Clipboard } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { getStaticProps } from "./_getStaticProps";
|
||||
import type { getServerSideProps } from "./_getServerSideProps";
|
||||
|
||||
const MAKE = "make";
|
||||
|
||||
export default function MakeSetup({ inviteLink }: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
export default function MakeSetup({ inviteLink }: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const [newApiKeys, setNewApiKeys] = useState<Record<string, string>>({});
|
||||
|
||||
const { t } = useLocale();
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import { findPaymentCredentials } from "@calcom/app-store/paypal/api/webhook";
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import { findPaymentCredentials } from "@calcom/features/ee/payments/api/paypal-webhook";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
|
|
|
@ -1,23 +1,14 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import getRawBody from "raw-body";
|
||||
import * as z from "zod";
|
||||
|
||||
import { paypalCredentialKeysSchema } from "@calcom/app-store/paypal/lib";
|
||||
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { sendScheduledEmails } from "@calcom/emails";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
|
@ -25,19 +16,7 @@ export const config = {
|
|||
},
|
||||
};
|
||||
|
||||
async function getEventType(id: number) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handlePaymentSuccess(
|
||||
export async function handlePaypalPaymentSuccess(
|
||||
payload: z.infer<typeof eventSchema>,
|
||||
rawPayload: string,
|
||||
webhookHeaders: WebHookHeadersType
|
||||
|
@ -49,7 +28,6 @@ export async function handlePaymentSuccess(
|
|||
select: {
|
||||
id: true,
|
||||
bookingId: true,
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -60,31 +38,7 @@ export async function handlePaymentSuccess(
|
|||
id: payment.bookingId,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
eventType: true,
|
||||
smsReminderNumber: true,
|
||||
location: true,
|
||||
eventTypeId: true,
|
||||
userId: true,
|
||||
uid: true,
|
||||
paid: true,
|
||||
destinationCalendar: true,
|
||||
status: true,
|
||||
responses: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
credentials: {
|
||||
select: credentialForCalendarServiceSelect,
|
||||
},
|
||||
timeZone: true,
|
||||
email: true,
|
||||
name: true,
|
||||
locale: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -108,106 +62,7 @@ export async function handlePaymentSuccess(
|
|||
},
|
||||
});
|
||||
|
||||
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
|
||||
let eventTypeRaw: EventTypeRaw | null = null;
|
||||
if (booking.eventTypeId) {
|
||||
eventTypeRaw = await getEventType(booking.eventTypeId);
|
||||
}
|
||||
|
||||
const { user } = booking;
|
||||
|
||||
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
|
||||
|
||||
const t = await getTranslation(user.locale ?? "en", "common");
|
||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: {
|
||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||
locale: attendee.locale ?? "en",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
description: booking.description || undefined,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
customInputs: isPrismaObjOrUndefined(booking.customInputs),
|
||||
...getCalEventResponses({
|
||||
booking: booking,
|
||||
bookingFields: booking.eventType?.bookingFields || null,
|
||||
}),
|
||||
organizer: {
|
||||
email: user.email,
|
||||
name: user.name!,
|
||||
timeZone: user.timeZone,
|
||||
language: { translate: t, locale: user.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
uid: booking.uid,
|
||||
destinationCalendar: booking.destinationCalendar
|
||||
? [booking.destinationCalendar]
|
||||
: user.destinationCalendar
|
||||
? [user.destinationCalendar]
|
||||
: [],
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
|
||||
};
|
||||
|
||||
if (booking.location) evt.location = booking.location;
|
||||
|
||||
const bookingData: Prisma.BookingUpdateInput = {
|
||||
paid: true,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
};
|
||||
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
if (isConfirmed) {
|
||||
const eventManager = new EventManager(user);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
bookingData.references = { create: scheduleResult.referencesToCreate };
|
||||
}
|
||||
|
||||
if (eventTypeRaw?.requiresConfirmation) {
|
||||
delete bookingData.status;
|
||||
}
|
||||
|
||||
if (!payment?.success) {
|
||||
await prisma.payment.update({
|
||||
where: {
|
||||
id: payment.id,
|
||||
},
|
||||
data: {
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (booking.status === "PENDING") {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: booking.id,
|
||||
},
|
||||
data: bookingData,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) {
|
||||
await handleConfirmation({ user, evt, prisma, bookingId: booking.id, booking, paid: true });
|
||||
} else {
|
||||
await sendScheduledEmails({ ...evt });
|
||||
}
|
||||
|
||||
throw new HttpCode({
|
||||
statusCode: 200,
|
||||
message: `Booking with id '${booking.id}' was paid and confirmed.`,
|
||||
});
|
||||
return await handlePaymentSuccess(payment.id, payment.bookingId);
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
@ -234,7 +89,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
const { data: parsedPayload } = parse;
|
||||
|
||||
if (parsedPayload.event_type === "CHECKOUT.ORDER.APPROVED") {
|
||||
return await handlePaymentSuccess(parsedPayload, bodyAsString, parseHeaders.data);
|
||||
return await handlePaypalPaymentSuccess(parsedPayload, bodyAsString, parseHeaders.data);
|
||||
}
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
|
@ -25,6 +25,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
|||
value: currencyOptions[0].value,
|
||||
}
|
||||
);
|
||||
|
||||
const paymentOption = getAppData("paymentOption");
|
||||
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
|
||||
label: paymentOptions[0].label,
|
||||
|
@ -55,7 +56,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
|||
label="Price"
|
||||
labelSrOnly
|
||||
addOnLeading="$"
|
||||
addOnSuffix={currency || "No selected currency"}
|
||||
addOnSuffix={selectedCurrency.value || "No selected currency"}
|
||||
step="0.01"
|
||||
min="0.5"
|
||||
type="number"
|
||||
|
@ -64,8 +65,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
|||
placeholder="Price"
|
||||
onChange={(e) => {
|
||||
setAppData("price", Number(e.target.value) * 100);
|
||||
if (currency) {
|
||||
setAppData("currency", currency);
|
||||
if (selectedCurrency) {
|
||||
setAppData("currency", selectedCurrency.value);
|
||||
}
|
||||
}}
|
||||
value={price > 0 ? price / 100 : undefined}
|
||||
|
|
|
@ -17,10 +17,15 @@ export const paypalCredentialKeysSchema = z.object({
|
|||
});
|
||||
|
||||
export class PaymentService implements IAbstractPaymentService {
|
||||
private credentials: z.infer<typeof paypalCredentialKeysSchema>;
|
||||
private credentials: z.infer<typeof paypalCredentialKeysSchema> | null;
|
||||
|
||||
constructor(credentials: { key: Prisma.JsonValue }) {
|
||||
this.credentials = paypalCredentialKeysSchema.parse(credentials.key);
|
||||
const keyParsing = paypalCredentialKeysSchema.safeParse(credentials.key);
|
||||
if (keyParsing.success) {
|
||||
this.credentials = keyParsing.data;
|
||||
} else {
|
||||
this.credentials = null;
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
|
@ -37,7 +42,7 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
id: bookingId,
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
if (!booking || !this.credentials) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
|
@ -113,7 +118,7 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
id: bookingId,
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
if (!booking || !this.credentials) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
|
@ -192,4 +197,8 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
deletePayment(paymentId: number): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
isSetupAlready(): boolean {
|
||||
return !!this.credentials;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ export const appDataSchema = eventTypeAppCardZod.merge(
|
|||
price: z.number(),
|
||||
currency: z.string(),
|
||||
paymentOption: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
);
|
||||
export const appKeysSchema = z.object({});
|
||||
|
|
|
@ -3,7 +3,6 @@ import { useState } from "react";
|
|||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import AppCard from "@calcom/app-store/_components/AppCard";
|
||||
import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
|
||||
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -11,9 +10,9 @@ import { Alert, Select, TextField } from "@calcom/ui";
|
|||
|
||||
import { paymentOptions } from "../lib/constants";
|
||||
import {
|
||||
convertFromSmallestToPresentableCurrencyUnit,
|
||||
convertToSmallestCurrencyUnit,
|
||||
} from "../lib/currencyConvertions";
|
||||
convertFromSmallestToPresentableCurrencyUnit,
|
||||
} from "../lib/currencyConversions";
|
||||
import { currencyOptions } from "../lib/currencyOptions";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
|
@ -26,13 +25,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
|||
const currency = getAppData("currency");
|
||||
const [selectedCurrency, setSelectedCurrency] = useState(
|
||||
currencyOptions.find((c) => c.value === currency) || {
|
||||
label: currencyOptions[0].label,
|
||||
value: currencyOptions[0].value,
|
||||
label: "",
|
||||
value: "",
|
||||
}
|
||||
);
|
||||
const paymentOption = getAppData("paymentOption");
|
||||
const paymentOptionSelectValue = paymentOptions.find((option) => paymentOption === option.value);
|
||||
const { enabled: requirePayment, updateEnabled: setRequirePayment } = useIsAppEnabled(app);
|
||||
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
|
||||
|
||||
const { t } = useLocale();
|
||||
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
|
||||
|
|
|
@ -14,7 +14,7 @@ import { createPaymentLink } from "./client";
|
|||
import { retrieveOrCreateStripeCustomerByEmail } from "./customer";
|
||||
import type { StripePaymentData, StripeSetupIntentData } from "./server";
|
||||
|
||||
const stripeCredentialKeysSchema = z.object({
|
||||
export const stripeCredentialKeysSchema = z.object({
|
||||
stripe_user_id: z.string(),
|
||||
default_currency: z.string(),
|
||||
stripe_publishable_key: z.string(),
|
||||
|
@ -28,11 +28,15 @@ const stripeAppKeysSchema = z.object({
|
|||
|
||||
export class PaymentService implements IAbstractPaymentService {
|
||||
private stripe: Stripe;
|
||||
private credentials: z.infer<typeof stripeCredentialKeysSchema>;
|
||||
private credentials: z.infer<typeof stripeCredentialKeysSchema> | null;
|
||||
|
||||
constructor(credentials: { key: Prisma.JsonValue }) {
|
||||
// parse credentials key
|
||||
this.credentials = stripeCredentialKeysSchema.parse(credentials.key);
|
||||
const keyParsing = stripeCredentialKeysSchema.safeParse(credentials.key);
|
||||
if (keyParsing.success) {
|
||||
this.credentials = keyParsing.data;
|
||||
} else {
|
||||
this.credentials = null;
|
||||
}
|
||||
this.stripe = new Stripe(process.env.STRIPE_PRIVATE_KEY || "", {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
|
@ -63,15 +67,9 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
throw new Error("Payment option is not compatible with create method");
|
||||
}
|
||||
|
||||
// Load stripe keys
|
||||
const stripeAppKeys = await prisma.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
},
|
||||
where: {
|
||||
slug: "stripe",
|
||||
},
|
||||
});
|
||||
if (!this.credentials) {
|
||||
throw new Error("Stripe credentials not found");
|
||||
}
|
||||
|
||||
const customer = await retrieveOrCreateStripeCustomerByEmail(
|
||||
bookerEmail,
|
||||
|
@ -142,21 +140,15 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
paymentOption: PaymentOption
|
||||
): Promise<Payment> {
|
||||
try {
|
||||
if (!this.credentials) {
|
||||
throw new Error("Stripe credentials not found");
|
||||
}
|
||||
|
||||
// Ensure that the payment service can support the passed payment option
|
||||
if (paymentOptionEnum.parse(paymentOption) !== "HOLD") {
|
||||
throw new Error("Payment option is not compatible with create method");
|
||||
}
|
||||
|
||||
// Load stripe keys
|
||||
const stripeAppKeys = await prisma.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
},
|
||||
where: {
|
||||
slug: "stripe",
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await retrieveOrCreateStripeCustomerByEmail(
|
||||
bookerEmail,
|
||||
this.credentials.stripe_user_id
|
||||
|
@ -214,6 +206,10 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
|
||||
async chargeCard(payment: Payment, _bookingId?: Booking["id"]): Promise<Payment> {
|
||||
try {
|
||||
if (!this.credentials) {
|
||||
throw new Error("Stripe credentials not found");
|
||||
}
|
||||
|
||||
const stripeAppKeys = await prisma.app.findFirst({
|
||||
select: {
|
||||
keys: true,
|
||||
|
@ -385,4 +381,8 @@ export class PaymentService implements IAbstractPaymentService {
|
|||
getPaymentDetails(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
isSetupAlready(): boolean {
|
||||
return !!this.credentials;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
|
||||
const targetUrl = "https://dashboard.stripe.com/settings/connect";
|
||||
return {
|
||||
redirect: {
|
||||
destination: targetUrl,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
export default function StripePaymentSetup() {
|
||||
return null;
|
||||
}
|
|
@ -17,6 +17,7 @@ export const appDataSchema = eventTypeAppCardZod.merge(
|
|||
price: z.number(),
|
||||
currency: z.string(),
|
||||
paymentOption: paymentOptionEnum.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { GetStaticPropsContext } from "next";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import getAppKeysFromSlug from "../../../_utils/getAppKeysFromSlug";
|
||||
|
||||
|
@ -6,7 +6,7 @@ export interface IZapierSetupProps {
|
|||
inviteLink: string;
|
||||
}
|
||||
|
||||
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
|
||||
let inviteLink = "";
|
||||
const appKeys = await getAppKeysFromSlug("zapier");
|
|
@ -1,16 +1,14 @@
|
|||
{
|
||||
"name": "Zoho Calendar",
|
||||
"description": "Zoho Calendar is an online business calendar that makes scheduling easy for you. You can use it to stay on top of your schedule and also share calendars with your team to keep everyone on the same page.",
|
||||
"slug": "zohocalendar",
|
||||
"type": "zoho_calendar",
|
||||
"title": "Zoho Calendar",
|
||||
"variant": "calendar",
|
||||
"category": "calendar",
|
||||
"categories": [
|
||||
"calendar"
|
||||
],
|
||||
"logo": "icon.svg",
|
||||
"publisher": "Cal.com",
|
||||
"url": "https://cal.com/",
|
||||
"email": "help@cal.com"
|
||||
}
|
||||
"name": "Zoho Calendar",
|
||||
"description": "Zoho Calendar is an online business calendar that makes scheduling easy for you. You can use it to stay on top of your schedule and also share calendars with your team to keep everyone on the same page.",
|
||||
"slug": "zohocalendar",
|
||||
"type": "zoho_calendar",
|
||||
"title": "Zoho Calendar",
|
||||
"variant": "calendar",
|
||||
"category": "calendar",
|
||||
"categories": ["calendar"],
|
||||
"logo": "icon.svg",
|
||||
"publisher": "Cal.com",
|
||||
"url": "https://cal.com/",
|
||||
"email": "help@cal.com"
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { formatPrice } from "@calcom/lib/price";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
|
@ -87,11 +88,12 @@ export const BaseScheduledEmail = (
|
|||
<UserFieldsResponses calEvent={props.calEvent} />
|
||||
{props.calEvent.paymentInfo?.amount && (
|
||||
<Info
|
||||
label={props.calEvent.paymentInfo?.paymentOption === "HOLD" ? t("no_show_fee") : t("price")}
|
||||
description={new Intl.NumberFormat(props.attendee.language.locale, {
|
||||
style: "currency",
|
||||
currency: props.calEvent.paymentInfo?.currency || "USD",
|
||||
}).format(props.calEvent.paymentInfo?.amount / 100.0)}
|
||||
label={props.calEvent.paymentInfo.paymentOption === "HOLD" ? t("no_show_fee") : t("price")}
|
||||
description={formatPrice(
|
||||
props.calEvent.paymentInfo.amount,
|
||||
props.calEvent.paymentInfo.currency,
|
||||
props.attendee.language.locale
|
||||
)}
|
||||
withSpacer
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -4,14 +4,15 @@ import React from "react";
|
|||
import classNames from "@calcom/lib/classNames";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Clock, CheckSquare, RefreshCcw, CreditCard } from "@calcom/ui/components/icon";
|
||||
import { Clock, CheckSquare, RefreshCcw } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { PublicEvent } from "../../types";
|
||||
import { EventDetailBlocks } from "../../types";
|
||||
import { AvailableEventLocations } from "./AvailableEventLocations";
|
||||
import { EventDuration } from "./Duration";
|
||||
import { EventOccurences } from "./Occurences";
|
||||
import { EventPrice } from "./Price";
|
||||
import { Price } from "./Price";
|
||||
import { getPriceIcon } from "./getPriceIcon";
|
||||
|
||||
type EventDetailsPropsBase = {
|
||||
event: PublicEvent;
|
||||
|
@ -156,8 +157,12 @@ export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: Even
|
|||
if (event.price <= 0 || paymentAppData.price <= 0) return null;
|
||||
|
||||
return (
|
||||
<EventMetaBlock key={block} icon={CreditCard}>
|
||||
<EventPrice event={event} />
|
||||
<EventMetaBlock key={block} icon={getPriceIcon(event.currency)}>
|
||||
<Price
|
||||
price={paymentAppData.price}
|
||||
currency={event.currency}
|
||||
displayAlternateSymbol={false}
|
||||
/>
|
||||
</EventMetaBlock>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,18 +1,28 @@
|
|||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import type { PublicEvent } from "../../types";
|
||||
import { formatPrice } from "@calcom/lib/price";
|
||||
|
||||
export const EventPrice = ({ event }: { event: PublicEvent }) => {
|
||||
const stripeAppData = getPaymentAppData(event);
|
||||
import type { EventPrice } from "../../types";
|
||||
|
||||
if (stripeAppData.price === 0) return null;
|
||||
const AlbyPriceComponent = dynamic(
|
||||
() => import("@calcom/app-store/alby/components/AlbyPriceComponent").then((m) => m.AlbyPriceComponent),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Intl.NumberFormat("en", {
|
||||
style: "currency",
|
||||
currency: stripeAppData.currency.toUpperCase(),
|
||||
}).format(stripeAppData.price / 100.0)}
|
||||
</>
|
||||
export const Price = ({ price, currency, displayAlternateSymbol = true }: EventPrice) => {
|
||||
if (price === 0) return null;
|
||||
|
||||
const formattedPrice = formatPrice(price, currency);
|
||||
|
||||
return currency !== "BTC" ? (
|
||||
<>{formattedPrice}</>
|
||||
) : (
|
||||
<AlbyPriceComponent
|
||||
displaySymbol={displayAlternateSymbol}
|
||||
price={price}
|
||||
formattedPrice={formattedPrice}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import { CreditCard, Zap } from "lucide-react";
|
||||
|
||||
export function getPayIcon(currency: string): React.FC<{ className: string }> | string {
|
||||
return currency !== "BTC" ? CreditCard : Zap;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { CreditCard } from "lucide-react";
|
||||
|
||||
import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol";
|
||||
|
||||
export function getPriceIcon(currency: string): React.FC<{ className: string }> | string {
|
||||
return currency !== "BTC" ? CreditCard : (SatSymbol as React.FC<{ className: string }>);
|
||||
}
|
|
@ -2,3 +2,5 @@ export { EventDetails, EventMetaBlock } from "./Details";
|
|||
export { EventTitle } from "./Title";
|
||||
export { EventMetaSkeleton } from "./Skeleton";
|
||||
export { EventMembers } from "./Members";
|
||||
export { getPriceIcon } from "./getPriceIcon";
|
||||
export { getPayIcon } from "./getPayIcon";
|
||||
|
|
|
@ -7,6 +7,8 @@ import type { AppsStatus } from "@calcom/types/Calendar";
|
|||
export type PublicEvent = NonNullable<RouterOutputs["viewer"]["public"]["event"]>;
|
||||
export type ValidationErrors<T extends object> = { key: FieldPath<T>; error: ErrorOption }[];
|
||||
|
||||
export type EventPrice = { currency: string; price: number; displayAlternateSymbol?: boolean };
|
||||
|
||||
export enum EventDetailBlocks {
|
||||
// Includes duration select when event has multiple durations.
|
||||
DURATION,
|
||||
|
|
|
@ -6,13 +6,13 @@ import type Stripe from "stripe";
|
|||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendAttendeeRequestEmail, sendOrganizerRequestEmail, sendScheduledEmails } from "@calcom/emails";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { sendOrganizerRequestEmail, sendAttendeeRequestEmail } from "@calcom/emails";
|
||||
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { bookingMinimalSelect, prisma } from "@calcom/prisma";
|
||||
|
@ -129,7 +129,7 @@ async function getBooking(bookingId: number) {
|
|||
};
|
||||
}
|
||||
|
||||
async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
async function handleStripePaymentSuccess(event: Stripe.Event) {
|
||||
const paymentIntent = event.data.object as Stripe.PaymentIntent;
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
|
@ -145,145 +145,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
|||
}
|
||||
if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: payment.bookingId,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
eventType: true,
|
||||
smsReminderNumber: true,
|
||||
location: true,
|
||||
eventTypeId: true,
|
||||
userId: true,
|
||||
uid: true,
|
||||
paid: true,
|
||||
destinationCalendar: true,
|
||||
status: true,
|
||||
responses: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
credentials: { select: credentialForCalendarServiceSelect },
|
||||
timeZone: true,
|
||||
timeFormat: true,
|
||||
email: true,
|
||||
name: true,
|
||||
locale: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
|
||||
|
||||
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
|
||||
let eventTypeRaw: EventTypeRaw | null = null;
|
||||
if (booking.eventTypeId) {
|
||||
eventTypeRaw = await getEventType(booking.eventTypeId);
|
||||
}
|
||||
|
||||
const { user: userWithCredentials } = booking;
|
||||
|
||||
if (!userWithCredentials) throw new HttpCode({ statusCode: 204, message: "No user found" });
|
||||
|
||||
const { credentials, ...user } = userWithCredentials;
|
||||
|
||||
const t = await getTranslation(user.locale ?? "en", "common");
|
||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: {
|
||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||
locale: attendee.locale ?? "en",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
description: booking.description || undefined,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
customInputs: isPrismaObjOrUndefined(booking.customInputs),
|
||||
...getCalEventResponses({
|
||||
booking: booking,
|
||||
bookingFields: booking.eventType?.bookingFields || null,
|
||||
}),
|
||||
organizer: {
|
||||
email: user.email,
|
||||
name: user.name!,
|
||||
timeZone: user.timeZone,
|
||||
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
|
||||
language: { translate: t, locale: user.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
location: booking.location,
|
||||
uid: booking.uid,
|
||||
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
|
||||
};
|
||||
|
||||
if (booking.location) evt.location = booking.location;
|
||||
|
||||
const bookingData: Prisma.BookingUpdateInput = {
|
||||
paid: true,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
};
|
||||
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
if (isConfirmed) {
|
||||
const eventManager = new EventManager(userWithCredentials);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
bookingData.references = { create: scheduleResult.referencesToCreate };
|
||||
}
|
||||
|
||||
if (eventTypeRaw?.requiresConfirmation) {
|
||||
delete bookingData.status;
|
||||
}
|
||||
|
||||
const paymentUpdate = prisma.payment.update({
|
||||
where: {
|
||||
id: payment.id,
|
||||
},
|
||||
data: {
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingUpdate = prisma.booking.update({
|
||||
where: {
|
||||
id: booking.id,
|
||||
},
|
||||
data: bookingData,
|
||||
});
|
||||
|
||||
await prisma.$transaction([paymentUpdate, bookingUpdate]);
|
||||
|
||||
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) {
|
||||
await handleConfirmation({
|
||||
user: userWithCredentials,
|
||||
evt,
|
||||
prisma,
|
||||
bookingId: booking.id,
|
||||
booking,
|
||||
paid: true,
|
||||
});
|
||||
} else {
|
||||
await sendScheduledEmails({ ...evt });
|
||||
}
|
||||
|
||||
throw new HttpCode({
|
||||
statusCode: 200,
|
||||
message: `Booking with id '${booking.id}' was paid and confirmed.`,
|
||||
});
|
||||
await handlePaymentSuccess(payment.id, payment.bookingId);
|
||||
}
|
||||
|
||||
const handleSetupSuccess = async (event: Stripe.Event) => {
|
||||
|
@ -372,7 +234,7 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
|
|||
type WebhookHandler = (event: Stripe.Event) => Promise<void>;
|
||||
|
||||
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
|
||||
"payment_intent.succeeded": handlePaymentSuccess,
|
||||
"payment_intent.succeeded": handleStripePaymentSuccess,
|
||||
"setup_intent.succeeded": handleSetupSuccess,
|
||||
};
|
||||
|
||||
|
|
|
@ -7,13 +7,14 @@ import { useEffect, useState } from "react";
|
|||
import { getSuccessPageLocationMessage } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
|
||||
import { getPayIcon } from "@calcom/features/bookings/components/event-meta/getPayIcon";
|
||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { CreditCard } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { PaymentPageProps } from "../pages/payment";
|
||||
|
||||
|
@ -31,6 +32,13 @@ const PaypalPaymentComponent = dynamic(
|
|||
}
|
||||
);
|
||||
|
||||
const AlbyPaymentComponent = dynamic(
|
||||
() => import("@calcom/app-store/alby/components/AlbyPaymentComponent").then((m) => m.AlbyPaymentComponent),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const [is24h, setIs24h] = useState(isBrowserLocale24h());
|
||||
|
@ -66,6 +74,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
|||
}, [isEmbed]);
|
||||
|
||||
const eventName = props.booking.title;
|
||||
const PayIcon = getPayIcon(paymentAppData.currency);
|
||||
|
||||
return (
|
||||
<div className="h-screen">
|
||||
|
@ -92,7 +101,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
|||
aria-labelledby="modal-headline">
|
||||
<div>
|
||||
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<CreditCard className="h-8 w-8 text-green-600" />
|
||||
<PayIcon className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
|
@ -121,10 +130,11 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
|||
{props.payment.paymentOption === "HOLD" ? t("no_show_fee") : t("price")}
|
||||
</div>
|
||||
<div className="col-span-2 mb-6 font-semibold">
|
||||
{new Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: paymentAppData.currency,
|
||||
}).format(paymentAppData.price / 100.0)}
|
||||
<Price
|
||||
currency={paymentAppData.currency}
|
||||
price={paymentAppData.price}
|
||||
displayAlternateSymbol={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -146,6 +156,9 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
|||
{props.payment.appId === "paypal" && !props.payment.success && (
|
||||
<PaypalPaymentComponent payment={props.payment} />
|
||||
)}
|
||||
{props.payment.appId === "alby" && !props.payment.success && (
|
||||
<AlbyPaymentComponent payment={props.payment} paymentPageProps={props} />
|
||||
)}
|
||||
{props.payment.refunded && (
|
||||
<div className="text-default mt-4 text-center dark:text-gray-300">{t("refunded")}</div>
|
||||
)}
|
||||
|
|
|
@ -2,6 +2,8 @@ import type { Prisma } from "@prisma/client";
|
|||
import { useMemo } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
|
||||
import { getPriceIcon } from "@calcom/features/bookings/components/event-meta/getPriceIcon";
|
||||
import { classNames, parseRecurringEvent } from "@calcom/lib";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -9,7 +11,7 @@ import type { baseEventTypeSelect } from "@calcom/prisma";
|
|||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { EventTypeModel } from "@calcom/prisma/zod";
|
||||
import { Badge } from "@calcom/ui";
|
||||
import { Clock, Users, RefreshCw, CreditCard, Clipboard, Plus, User, Lock } from "@calcom/ui/components/icon";
|
||||
import { Clock, Users, RefreshCw, Clipboard, Plus, User, Lock } from "@calcom/ui/components/icon";
|
||||
|
||||
export type EventTypeDescriptionProps = {
|
||||
eventType: Pick<
|
||||
|
@ -94,11 +96,12 @@ export const EventTypeDescription = ({
|
|||
)}
|
||||
{paymentAppData.enabled && (
|
||||
<li>
|
||||
<Badge variant="gray" startIcon={CreditCard}>
|
||||
{new Intl.NumberFormat(i18n.language, {
|
||||
style: "currency",
|
||||
currency: paymentAppData.currency,
|
||||
}).format(paymentAppData.price / 100)}
|
||||
<Badge variant="gray" startIcon={getPriceIcon(paymentAppData.currency)}>
|
||||
<Price
|
||||
currency={paymentAppData.currency}
|
||||
price={paymentAppData.price}
|
||||
displayAlternateSymbol={false}
|
||||
/>
|
||||
</Badge>
|
||||
</li>
|
||||
)}
|
||||
|
|
|
@ -47,6 +47,7 @@ const getEnabledAppsFromCredentials = async (
|
|||
..._where,
|
||||
...(filterOnIds.credentials.some.OR.length && filterOnIds),
|
||||
};
|
||||
|
||||
const enabledApps = await prisma.app.findMany({
|
||||
where,
|
||||
select: { slug: true, enabled: true },
|
||||
|
|
|
@ -8,7 +8,6 @@ import { getOrgFullDomain } from "@calcom/ee/organizations/lib/orgDomains";
|
|||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseBookingLimit, parseDurationLimit, parseRecurringEvent } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { Credential } from "@calcom/prisma/client";
|
||||
|
@ -256,14 +255,9 @@ export default async function getEventTypeById({
|
|||
const newMetadata = EventTypeMetaDataSchema.parse(metadata || {}) || {};
|
||||
const apps = newMetadata?.apps || {};
|
||||
const eventTypeWithParsedMetadata = { ...rawEventType, metadata: newMetadata };
|
||||
const stripeMetaData = getPaymentAppData(eventTypeWithParsedMetadata, true);
|
||||
|
||||
newMetadata.apps = {
|
||||
...apps,
|
||||
stripe: {
|
||||
...stripeMetaData,
|
||||
paymentOption: stripeMetaData.paymentOption as string,
|
||||
currency: getStripeCurrency(stripeMetaData, credentials),
|
||||
},
|
||||
giphy: getEventTypeAppData(eventTypeWithParsedMetadata, "giphy", true),
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,179 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { sendScheduledEmails } from "@calcom/emails";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "../timeFormat";
|
||||
|
||||
export async function handlePaymentSuccess(paymentId: number, bookingId: number) {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
eventType: true,
|
||||
smsReminderNumber: true,
|
||||
location: true,
|
||||
eventTypeId: true,
|
||||
userId: true,
|
||||
uid: true,
|
||||
paid: true,
|
||||
destinationCalendar: true,
|
||||
status: true,
|
||||
responses: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
credentials: { select: credentialForCalendarServiceSelect },
|
||||
timeZone: true,
|
||||
timeFormat: true,
|
||||
email: true,
|
||||
name: true,
|
||||
locale: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
},
|
||||
payment: {
|
||||
select: {
|
||||
amount: true,
|
||||
currency: true,
|
||||
paymentOption: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
|
||||
|
||||
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
|
||||
let eventTypeRaw: EventTypeRaw | null = null;
|
||||
if (booking.eventTypeId) {
|
||||
eventTypeRaw = await getEventType(booking.eventTypeId);
|
||||
}
|
||||
|
||||
const { user: userWithCredentials } = booking;
|
||||
if (!userWithCredentials) throw new HttpCode({ statusCode: 204, message: "No user found" });
|
||||
const { credentials, ...user } = userWithCredentials;
|
||||
|
||||
const t = await getTranslation(user.locale ?? "en", "common");
|
||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: {
|
||||
translate: await getTranslation(attendee.locale ?? "en", "common"),
|
||||
locale: attendee.locale ?? "en",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
|
||||
const evt: CalendarEvent = {
|
||||
type: booking.title,
|
||||
title: booking.title,
|
||||
description: booking.description || undefined,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
customInputs: isPrismaObjOrUndefined(booking.customInputs),
|
||||
...getCalEventResponses({
|
||||
booking: booking,
|
||||
bookingFields: booking.eventType?.bookingFields || null,
|
||||
}),
|
||||
organizer: {
|
||||
email: user.email,
|
||||
name: user.name!,
|
||||
timeZone: user.timeZone,
|
||||
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
|
||||
language: { translate: t, locale: user.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
location: booking.location,
|
||||
uid: booking.uid,
|
||||
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
|
||||
paymentInfo: booking.payment?.[0] && {
|
||||
amount: booking.payment[0].amount,
|
||||
currency: booking.payment[0].currency,
|
||||
paymentOption: booking.payment[0].paymentOption,
|
||||
},
|
||||
};
|
||||
|
||||
if (booking.location) evt.location = booking.location;
|
||||
|
||||
const bookingData: Prisma.BookingUpdateInput = {
|
||||
paid: true,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
};
|
||||
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
if (isConfirmed) {
|
||||
const eventManager = new EventManager(userWithCredentials);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
bookingData.references = { create: scheduleResult.referencesToCreate };
|
||||
}
|
||||
|
||||
if (eventTypeRaw?.requiresConfirmation) {
|
||||
delete bookingData.status;
|
||||
}
|
||||
|
||||
const paymentUpdate = prisma.payment.update({
|
||||
where: {
|
||||
id: paymentId,
|
||||
},
|
||||
data: {
|
||||
success: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingUpdate = prisma.booking.update({
|
||||
where: {
|
||||
id: booking.id,
|
||||
},
|
||||
data: bookingData,
|
||||
});
|
||||
|
||||
await prisma.$transaction([paymentUpdate, bookingUpdate]);
|
||||
|
||||
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) {
|
||||
await handleConfirmation({
|
||||
user: userWithCredentials,
|
||||
evt,
|
||||
prisma,
|
||||
bookingId: booking.id,
|
||||
booking,
|
||||
paid: true,
|
||||
});
|
||||
} else {
|
||||
await sendScheduledEmails({ ...evt });
|
||||
}
|
||||
|
||||
throw new HttpCode({
|
||||
statusCode: 200,
|
||||
message: `Booking with id '${booking.id}' was paid and confirmed.`,
|
||||
});
|
||||
}
|
||||
|
||||
async function getEventType(id: number) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
recurringEvent: true,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
export const formatPrice = (price: number, currency: string | undefined, locale = "en") => {
|
||||
switch (currency) {
|
||||
case "BTC":
|
||||
return `${price} sats`;
|
||||
default:
|
||||
return `${Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency: currency?.toUpperCase() || "USD",
|
||||
}).format(price / 100.0)}`;
|
||||
}
|
||||
};
|
|
@ -1,5 +1,6 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import appStore from "@calcom/app-store";
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials";
|
||||
import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp";
|
||||
|
@ -9,6 +10,7 @@ import { MembershipRole } from "@calcom/prisma/enums";
|
|||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
import type { PaymentApp } from "@calcom/types/PaymentService";
|
||||
|
||||
import type { TIntegrationsInputSchema } from "./integrations.schema";
|
||||
|
||||
|
@ -132,34 +134,48 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
|
|||
...(appId ? { where: { slug: appId } } : {}),
|
||||
});
|
||||
//TODO: Refactor this to pick up only needed fields and prevent more leaking
|
||||
let apps = enabledApps.map(
|
||||
({ credentials: _, credential: _1, key: _2 /* don't leak to frontend */, ...app }) => {
|
||||
let apps = await Promise.all(
|
||||
enabledApps.map(async ({ credentials: _, credential, key: _2 /* don't leak to frontend */, ...app }) => {
|
||||
const userCredentialIds = credentials.filter((c) => c.type === app.type && !c.teamId).map((c) => c.id);
|
||||
const invalidCredentialIds = credentials
|
||||
.filter((c) => c.type === app.type && c.invalid)
|
||||
.map((c) => c.id);
|
||||
const teams = credentials
|
||||
.filter((c) => c.type === app.type && c.teamId)
|
||||
.map((c) => {
|
||||
const team = userTeams.find((team) => team.id === c.teamId);
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
teamId: team.id,
|
||||
name: team.name,
|
||||
logo: team.logo,
|
||||
credentialId: c.id,
|
||||
isAdmin:
|
||||
team.members[0].role === MembershipRole.ADMIN || team.members[0].role === MembershipRole.OWNER,
|
||||
};
|
||||
});
|
||||
const teams = await Promise.all(
|
||||
credentials
|
||||
.filter((c) => c.type === app.type && c.teamId)
|
||||
.map(async (c) => {
|
||||
const team = userTeams.find((team) => team.id === c.teamId);
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
teamId: team.id,
|
||||
name: team.name,
|
||||
logo: team.logo,
|
||||
credentialId: c.id,
|
||||
isAdmin:
|
||||
team.members[0].role === MembershipRole.ADMIN ||
|
||||
team.members[0].role === MembershipRole.OWNER,
|
||||
};
|
||||
})
|
||||
);
|
||||
// type infer as CredentialOwner
|
||||
const credentialOwner: CredentialOwner = {
|
||||
name: user.name,
|
||||
avatar: user.avatar,
|
||||
};
|
||||
|
||||
// We need to know if app is payment type
|
||||
let isSetupAlready = false;
|
||||
if (credential && app.categories.includes("payment")) {
|
||||
const paymentApp = (await appStore[app.dirName as keyof typeof appStore]()) as PaymentApp | null;
|
||||
if (paymentApp && "lib" in paymentApp && paymentApp?.lib && "PaymentService" in paymentApp?.lib) {
|
||||
const PaymentService = paymentApp.lib.PaymentService;
|
||||
const paymentInstance = new PaymentService(credential);
|
||||
isSetupAlready = paymentInstance.isSetupAlready();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...app,
|
||||
...(teams.length && {
|
||||
|
@ -169,8 +185,9 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
|
|||
invalidCredentialIds,
|
||||
teams,
|
||||
isInstalled: !!userCredentialIds.length || !!teams.length || app.isGlobal,
|
||||
isSetupAlready,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (variant) {
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import publicProcedure from "../../../procedures/publicProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { ZConfirmInputSchema } from "./confirm.schema";
|
||||
import { ZEditLocationInputSchema } from "./editLocation.schema";
|
||||
import { ZFindInputSchema } from "./find.schema";
|
||||
import { ZGetInputSchema } from "./get.schema";
|
||||
import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema";
|
||||
import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema";
|
||||
|
@ -13,6 +15,7 @@ type BookingsRouterHandlerCache = {
|
|||
editLocation?: typeof import("./editLocation.handler").editLocationHandler;
|
||||
confirm?: typeof import("./confirm.handler").confirmHandler;
|
||||
getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler;
|
||||
find?: typeof import("./find.handler").getHandler;
|
||||
};
|
||||
|
||||
const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {};
|
||||
|
@ -105,4 +108,20 @@ export const bookingsRouter = router({
|
|||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
find: publicProcedure.input(ZFindInputSchema).query(async ({ input, ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.find) {
|
||||
UNSTABLE_HANDLER_CACHE.find = await import("./find.handler").then((mod) => mod.getHandler);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.find) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.find({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import type { TFindInputSchema } from "./find.schema";
|
||||
|
||||
type GetOptions = {
|
||||
ctx: {
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
input: TFindInputSchema;
|
||||
};
|
||||
|
||||
export const getHandler = async ({ ctx, input }: GetOptions) => {
|
||||
const { prisma } = ctx;
|
||||
const { bookingUid } = input;
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
description: true,
|
||||
status: true,
|
||||
paid: true,
|
||||
eventTypeId: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Don't leak anything private from the booking
|
||||
return {
|
||||
booking,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const ZFindInputSchema = z.object({
|
||||
bookingUid: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TFindInputSchema = z.infer<typeof ZFindInputSchema>;
|
||||
|
||||
export { ZFindInputSchema };
|
|
@ -245,12 +245,26 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
|||
* but the real detail will be inside app metadata, so with this you can have different prices in different apps
|
||||
* So the price and currency inside eventType will be deprecated soon or just keep as reference.
|
||||
*/
|
||||
if (input?.metadata?.apps?.stripe?.price || input?.metadata?.apps?.paypal?.price) {
|
||||
data.price = input.metadata.apps.stripe?.price || input.metadata.apps.paypal?.price;
|
||||
if (
|
||||
input.metadata?.apps?.alby?.price ||
|
||||
input?.metadata?.apps?.paypal?.price ||
|
||||
input?.metadata?.apps?.stripe?.price
|
||||
) {
|
||||
data.price =
|
||||
input.metadata?.apps?.alby?.price ||
|
||||
input.metadata.apps.paypal?.price ||
|
||||
input.metadata.apps.stripe?.price;
|
||||
}
|
||||
|
||||
if (input?.metadata?.apps?.stripe?.currency || input?.metadata?.apps?.paypal?.currency) {
|
||||
data.currency = input.metadata.apps.stripe?.currency || input.metadata.apps.paypal?.currency;
|
||||
if (
|
||||
input.metadata?.apps?.alby?.currency ||
|
||||
input?.metadata?.apps?.paypal?.currency ||
|
||||
input?.metadata?.apps?.stripe?.currency
|
||||
) {
|
||||
data.currency =
|
||||
input.metadata?.apps?.alby?.currency ||
|
||||
input.metadata.apps.paypal?.currency ||
|
||||
input.metadata.apps.stripe?.currency;
|
||||
}
|
||||
|
||||
const connectedLink = await ctx.prisma.hashedLink.findFirst({
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import type { Payment, Prisma, Booking, PaymentOption } from "@prisma/client";
|
||||
|
||||
import type { PaymentService } from "@calcom/app-store/paypal/lib/PaymentService";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
export interface PaymentApp {
|
||||
lib?: {
|
||||
PaymentService: IAbstractPaymentService;
|
||||
PaymentService: typeof PaymentService;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -32,6 +33,7 @@ export interface IAbstractPaymentService {
|
|||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId?: Booking["id"]
|
||||
): Promise<Payment>;
|
||||
|
||||
update(paymentId: Payment["id"], data: Partial<Prisma.PaymentUncheckedCreateInput>): Promise<Payment>;
|
||||
refund(paymentId: Payment["id"]): Promise<Payment>;
|
||||
getPaymentPaidStatus(): Promise<string>;
|
||||
|
@ -47,4 +49,5 @@ export interface IAbstractPaymentService {
|
|||
paymentData: Payment
|
||||
): Promise<void>;
|
||||
deletePayment(paymentId: Payment["id"]): Promise<boolean>;
|
||||
isSetupAlready(): boolean;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import React, { forwardRef } from "react";
|
||||
|
||||
export const SatSymbol = forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(function SatSymbol(props) {
|
||||
return (
|
||||
<svg
|
||||
className={props.className}
|
||||
id="Layer_1"
|
||||
data-name="Layer 1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 360 360">
|
||||
<title>Satoshis</title>
|
||||
|
||||
<rect
|
||||
fill="currentColor"
|
||||
x="201.48"
|
||||
y="37.16"
|
||||
width="23.49"
|
||||
height="40.14"
|
||||
transform="translate(21.82 -52.79) rotate(14.87)"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
x="135.03"
|
||||
y="287.5"
|
||||
width="23.49"
|
||||
height="40.14"
|
||||
transform="translate(83.82 -27.36) rotate(14.87)"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
x="184.27"
|
||||
y="38.29"
|
||||
width="23.49"
|
||||
height="167.49"
|
||||
transform="translate(364.26 -36.11) rotate(104.87)"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
x="168.36"
|
||||
y="98.26"
|
||||
width="23.49"
|
||||
height="167.49"
|
||||
transform="translate(402.22 54.61) rotate(104.87)"
|
||||
/>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
x="152.89"
|
||||
y="156.52"
|
||||
width="23.49"
|
||||
height="167.49"
|
||||
transform="translate(439.1 142.78) rotate(104.87)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
import React, { forwardRef } from "react";
|
||||
|
||||
export const Spinner = forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(function Spinner(props) {
|
||||
return (
|
||||
<svg
|
||||
className={props.className}
|
||||
id="Layer_1"
|
||||
data-name="Layer 1"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
className="fill-default"
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||
opacity=".25"
|
||||
/>
|
||||
<path
|
||||
className="animate-spinning fill-emphasis origin-center"
|
||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
});
|
|
@ -8,7 +8,9 @@
|
|||
".": "./index.tsx",
|
||||
"./components/toast": "./components/toast/index.tsx",
|
||||
"./components/icon": "./components/icon/index.ts",
|
||||
"./components/icon/Discord": "./components/icon/Discord.tsx"
|
||||
"./components/icon/Discord": "./components/icon/Discord.tsx",
|
||||
"./components/icon/SatSymbol": "./components/icon/SatSymbol.tsx",
|
||||
"./components/icon/Spinner": "./components/icon/Spinner.tsx"
|
||||
},
|
||||
"types": "./index.tsx",
|
||||
"license": "MIT",
|
||||
|
|
Loading…
Reference in New Issue
Block a user