From 2021b641ceb779e215ed439ec7cb7af09544776a Mon Sep 17 00:00:00 2001 From: Roland <33993199+rolznz@users.noreply.github.com> Date: Thu, 28 Sep 2023 19:03:01 +0700 Subject: [PATCH] feat: Alby integration (#11495) Co-authored-by: Peer Richelsen Co-authored-by: alannnc Co-authored-by: Hariom Balhara Co-authored-by: Peer Richelsen --- apps/web/components/apps/AppPage.tsx | 1 + .../pages/api/integrations/alby/webhook.ts | 1 + .../pages/api/integrations/paypal/webhook.ts | 2 +- apps/web/pages/apps/[slug]/setup.tsx | 15 +- apps/web/pages/booking/[uid].tsx | 8 +- apps/web/playwright/fixtures/users.ts | 2 + .../web/playwright/integrations-stripe.e2e.ts | 2 +- apps/web/playwright/payment-apps.e2e.ts | 203 +++++ apps/web/public/static/locales/en/common.json | 1 + packages/app-store/CONTRIBUTING.md | 2 +- packages/app-store/_components/AppCard.tsx | 22 +- .../_pages/setup/_getServerSideProps.tsx | 23 + .../_pages/setup/_getStaticProps.tsx | 21 - packages/app-store/_pages/setup/index.tsx | 2 + packages/app-store/alby/api/add.ts | 52 +- packages/app-store/alby/api/index.ts | 1 + packages/app-store/alby/api/webhook.ts | 125 +++ .../alby/components/AlbyPaymentComponent.tsx | 177 ++++ .../alby/components/AlbyPriceComponent.tsx | 30 + .../components/EventTypeAppCardInterface.tsx | 127 +++ packages/app-store/alby/config.json | 5 +- packages/app-store/alby/index.ts | 1 + packages/app-store/alby/lib/PaymentService.ts | 133 +++ .../alby/lib/albyCredentialKeysSchema.ts | 9 + .../app-store/alby/lib/currencyOptions.ts | 1 + packages/app-store/alby/lib/getAlbyKeys.ts | 13 + packages/app-store/alby/lib/index.ts | 6 + packages/app-store/alby/lib/parseInvoice.ts | 22 + packages/app-store/alby/package.json | 7 +- .../alby/pages/setup/_getServerSideProps.tsx | 49 ++ packages/app-store/alby/pages/setup/index.tsx | 178 ++++ packages/app-store/alby/static/icon2.svg | 11 + packages/app-store/alby/zod.ts | 37 + packages/app-store/apps.browser.generated.tsx | 1 + .../app-store/apps.keys-schemas.generated.ts | 2 + packages/app-store/apps.schemas.generated.ts | 2 + .../basecamp3/trpc/projects.handler.ts | 4 + packages/app-store/index.ts | 1 + ...taticProps.tsx => _getServerSideProps.tsx} | 4 +- packages/app-store/make/pages/setup/index.tsx | 6 +- packages/app-store/paypal/api/capture.ts | 2 +- .../paypal/api/webhook.ts} | 157 +--- .../components/EventTypeAppCardInterface.tsx | 7 +- .../app-store/paypal/lib/PaymentService.ts | 17 +- packages/app-store/paypal/zod.ts | 1 + .../components/EventTypeAppCardInterface.tsx | 11 +- .../stripepayment/lib/PaymentService.ts | 46 +- ...yConvertions.ts => currencyConversions.ts} | 0 .../pages/setup/_getServerSideProps.ts | 12 + .../stripepayment/pages/setup/index.tsx | 3 + packages/app-store/stripepayment/zod.ts | 1 + ...taticProps.tsx => _getServerSideProps.tsx} | 4 +- packages/app-store/zohocalendar/config.json | 28 +- .../src/templates/BaseScheduledEmail.tsx | 12 +- .../components/event-meta/Details.tsx | 13 +- .../bookings/components/event-meta/Price.tsx | 34 +- .../components/event-meta/getPayIcon.ts | 5 + .../components/event-meta/getPriceIcon.ts | 7 + .../bookings/components/event-meta/index.ts | 2 + packages/features/bookings/types.ts | 2 + packages/features/ee/payments/api/webhook.ts | 148 +--- .../ee/payments/components/PaymentPage.tsx | 25 +- .../components/EventTypeDescription.tsx | 15 +- .../lib/apps/getEnabledAppsFromCredentials.ts | 1 + packages/lib/getEventTypeById.ts | 8 +- packages/lib/payment/handlePaymentSuccess.ts | 179 ++++ packages/lib/price.ts | 11 + .../loggedInViewer/integrations.handler.ts | 55 +- .../routers/viewer/bookings/_router.tsx | 19 + .../routers/viewer/bookings/find.handler.ts | 36 + .../routers/viewer/bookings/find.schema.ts | 9 + .../viewer/eventTypes/update.handler.ts | 22 +- packages/types/PaymentService.d.ts | 5 +- packages/ui/components/icon/SatSymbol.tsx | 55 ++ packages/ui/components/icon/Spinner.tsx | 22 + packages/ui/package.json | 4 +- yarn.lock | 831 ++++++++++++++---- 77 files changed, 2479 insertions(+), 639 deletions(-) create mode 100644 apps/web/pages/api/integrations/alby/webhook.ts create mode 100644 apps/web/playwright/payment-apps.e2e.ts create mode 100644 packages/app-store/_pages/setup/_getServerSideProps.tsx delete mode 100644 packages/app-store/_pages/setup/_getStaticProps.tsx create mode 100644 packages/app-store/alby/api/webhook.ts create mode 100644 packages/app-store/alby/components/AlbyPaymentComponent.tsx create mode 100644 packages/app-store/alby/components/AlbyPriceComponent.tsx create mode 100644 packages/app-store/alby/components/EventTypeAppCardInterface.tsx create mode 100644 packages/app-store/alby/lib/PaymentService.ts create mode 100644 packages/app-store/alby/lib/albyCredentialKeysSchema.ts create mode 100644 packages/app-store/alby/lib/currencyOptions.ts create mode 100644 packages/app-store/alby/lib/getAlbyKeys.ts create mode 100644 packages/app-store/alby/lib/index.ts create mode 100644 packages/app-store/alby/lib/parseInvoice.ts create mode 100644 packages/app-store/alby/pages/setup/_getServerSideProps.tsx create mode 100644 packages/app-store/alby/pages/setup/index.tsx create mode 100644 packages/app-store/alby/static/icon2.svg create mode 100644 packages/app-store/alby/zod.ts rename packages/app-store/make/pages/setup/{_getStaticProps.tsx => _getServerSideProps.tsx} (76%) rename packages/{features/ee/payments/api/paypal-webhook.ts => app-store/paypal/api/webhook.ts} (54%) rename packages/app-store/stripepayment/lib/{currencyConvertions.ts => currencyConversions.ts} (100%) create mode 100644 packages/app-store/stripepayment/pages/setup/_getServerSideProps.ts create mode 100644 packages/app-store/stripepayment/pages/setup/index.tsx rename packages/app-store/zapier/pages/setup/{_getStaticProps.tsx => _getServerSideProps.tsx} (76%) create mode 100644 packages/features/bookings/components/event-meta/getPayIcon.ts create mode 100644 packages/features/bookings/components/event-meta/getPriceIcon.ts create mode 100644 packages/lib/payment/handlePaymentSuccess.ts create mode 100644 packages/lib/price.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/find.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/find.schema.ts create mode 100644 packages/ui/components/icon/SatSymbol.tsx create mode 100644 packages/ui/components/icon/Spinner.tsx diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index 992c29fef6..84b4102dbd 100644 --- a/apps/web/components/apps/AppPage.tsx +++ b/apps/web/components/apps/AppPage.tsx @@ -89,6 +89,7 @@ export const AppPage = ({ const [existingCredentials, setExistingCredentials] = useState([]); const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false); + const appDbQuery = trpc.viewer.appCredentialsByType.useQuery( { appType: type }, { diff --git a/apps/web/pages/api/integrations/alby/webhook.ts b/apps/web/pages/api/integrations/alby/webhook.ts new file mode 100644 index 0000000000..e9862c0110 --- /dev/null +++ b/apps/web/pages/api/integrations/alby/webhook.ts @@ -0,0 +1 @@ +export { default, config } from "@calcom/app-store/alby/api/webhook"; diff --git a/apps/web/pages/api/integrations/paypal/webhook.ts b/apps/web/pages/api/integrations/paypal/webhook.ts index d085cb74cb..0762f351b3 100644 --- a/apps/web/pages/api/integrations/paypal/webhook.ts +++ b/apps/web/pages/api/integrations/paypal/webhook.ts @@ -1 +1 @@ -export { default, config } from "@calcom/features/ee/payments/api/paypal-webhook"; +export { default, config } from "@calcom/app-store/paypal/api/webhook"; diff --git a/apps/web/pages/apps/[slug]/setup.tsx b/apps/web/pages/apps/[slug]/setup.tsx index 3ffd9d3285..4942585a4c 100644 --- a/apps/web/pages/apps/[slug]/setup.tsx +++ b/apps/web/pages/apps/[slug]/setup.tsx @@ -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) { +export default function SetupInformation(props: InferGetServerSidePropsType) { const searchParams = useSearchParams(); const router = useRouter(); const slug = searchParams?.get("slug") as string; @@ -36,11 +36,4 @@ export default function SetupInformation(props: InferGetStaticPropsType { - return { - paths: [], - fallback: "blocking", - }; -}; - -export { getStaticProps }; +export { getServerSideProps }; diff --git a/apps/web/pages/booking/[uid].tsx b/apps/web/pages/booking/[uid].tsx index 2c20b71ad7..d96bc65361 100644 --- a/apps/web/pages/booking/[uid].tsx +++ b/apps/web/pages/booking/[uid].tsx @@ -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")}
- {new Intl.NumberFormat(i18n.language, { - style: "currency", - currency: props.paymentStatus.currency, - }).format(props.paymentStatus.amount / 100.0)} +
)} diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 58adf7c1dd..4efc29abda 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -556,6 +556,8 @@ export async function apiLogin( export async function setupEventWithPrice(eventType: Pick, 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(); } diff --git a/apps/web/playwright/integrations-stripe.e2e.ts b/apps/web/playwright/integrations-stripe.e2e.ts index edf81d0bf4..cd67112bc7 100644 --- a/apps/web/playwright/integrations-stripe.e2e.ts +++ b/apps/web/playwright/integrations-stripe.e2e.ts @@ -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(); diff --git a/apps/web/playwright/payment-apps.e2e.ts b/apps/web/playwright/payment-apps.e2e.ts new file mode 100644 index 0000000000..536aed90e0 --- /dev/null +++ b/apps/web/playwright/payment-apps.e2e.ts @@ -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(); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4a6abe4213..393a2161dc 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/app-store/CONTRIBUTING.md b/packages/app-store/CONTRIBUTING.md index f3de617bc0..0dd1cb9409 100644 --- a/packages/app-store/CONTRIBUTING.md +++ b/packages/app-store/CONTRIBUTING.md @@ -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. \ No newline at end of file +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)) \ No newline at end of file diff --git a/packages/app-store/_components/AppCard.tsx b/packages/app-store/_components/AppCard.tsx index 5489c1ea95..4e456369ff 100644 --- a/packages/app-store/_components/AppCard.tsx +++ b/packages/app-store/_components/AppCard.tsx @@ -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(); const { setAppData, LockedIcon, disabled } = useAppContextWithSchema(); @@ -111,8 +114,23 @@ export default function AppCard({
{app?.isInstalled && switchChecked &&
} + {app?.isInstalled && switchChecked ? ( -
{children}
+ app.isSetupAlready ? ( +
+ +
+ ) : ( +
+

{t("this_app_is_not_setup_already")}

+ + + +
+ ) ) : null}
diff --git a/packages/app-store/_pages/setup/_getServerSideProps.tsx b/packages/app-store/_pages/setup/_getServerSideProps.tsx new file mode 100644 index 0000000000..39b20bf398 --- /dev/null +++ b/packages/app-store/_pages/setup/_getServerSideProps.tsx @@ -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; +}; diff --git a/packages/app-store/_pages/setup/_getStaticProps.tsx b/packages/app-store/_pages/setup/_getStaticProps.tsx deleted file mode 100644 index 3a10131c9d..0000000000 --- a/packages/app-store/_pages/setup/_getStaticProps.tsx +++ /dev/null @@ -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; -}; diff --git a/packages/app-store/_pages/setup/index.tsx b/packages/app-store/_pages/setup/index.tsx index 6438b04a29..e9345f8c90 100644 --- a/packages/app-store/_pages/setup/index.tsx +++ b/packages/app-store/_pages/setup/index.tsx @@ -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")), }; diff --git a/packages/app-store/alby/api/add.ts b/packages/app-store/alby/api/add.ts index 6ab3106577..f0f47ff83b 100644 --- a/packages/app-store/alby/api/add.ts +++ b/packages/app-store/alby/api/add.ts @@ -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" }); +} diff --git a/packages/app-store/alby/api/index.ts b/packages/app-store/alby/api/index.ts index 4c0d2ead01..f29c527245 100644 --- a/packages/app-store/alby/api/index.ts +++ b/packages/app-store/alby/api/index.ts @@ -1 +1,2 @@ export { default as add } from "./add"; +export { default as webhook, config } from "@calcom/web/pages/api/integrations/alby/webhook"; diff --git a/packages/app-store/alby/api/webhook.ts b/packages/app-store/alby/api/webhook.ts new file mode 100644 index 0000000000..4910d7749c --- /dev/null +++ b/packages/app-store/alby/api/webhook.ts @@ -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(); diff --git a/packages/app-store/alby/components/AlbyPaymentComponent.tsx b/packages/app-store/alby/components/AlbyPaymentComponent.tsx new file mode 100644 index 0000000000..7e25fb4469 --- /dev/null +++ b/packages/app-store/alby/components/AlbyPaymentComponent.tsx @@ -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 = ( + <> +

Couldn't obtain payment URL

+ + ); + + const parsedData = PaymentAlbyDataSchema.safeParse(data); + if (!parsedData.success || !parsedData.data?.invoice?.paymentRequest) { + return wrongUrl; + } + const paymentRequest = parsedData.data.invoice.paymentRequest; + + return ( +
+ + {isPaying && } + {!isPaying && ( + <> + {!showQRCode && ( +
+ + {window.webln && ( + + )} +
+ )} + {showQRCode && ( + <> +
+

Waiting for payment...

+ +
+

Click or scan the invoice below to pay

+ + + + + + + Don't have a lightning wallet? + + + )} + + )} + +
+ Powered by + Alby + Alby +
+ +
+ ); +}; + +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; +} diff --git a/packages/app-store/alby/components/AlbyPriceComponent.tsx b/packages/app-store/alby/components/AlbyPriceComponent.tsx new file mode 100644 index 0000000000..d9e1d1081c --- /dev/null +++ b/packages/app-store/alby/components/AlbyPriceComponent.tsx @@ -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("loading..."); + React.useEffect(() => { + (async () => { + const unformattedFiatValue = await fiat.getFiatValue({ satoshi: price, currency: "USD" }); + setFiatValue(`$${unformattedFiatValue.toFixed(2)}`); + })(); + }, [price]); + + return ( + +
+ {displaySymbol && } + {formattedPrice} +
+
+ ); +} diff --git a/packages/app-store/alby/components/EventTypeAppCardInterface.tsx b/packages/app-store/alby/components/EventTypeAppCardInterface.tsx new file mode 100644 index 0000000000..0eb71ba136 --- /dev/null +++ b/packages/app-store/alby/components/EventTypeAppCardInterface.tsx @@ -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(); + 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 ( + { + setRequirePayment(enabled); + }} + description={<>Add bitcoin lightning payments to your events}> + <> + {recurringEventDefined ? ( + + ) : ( + requirePayment && ( + <> +
+ } + 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} + /> +
+
+ +