diff --git a/apps/web/pages/api/integrations/btcpayserver/webhook.ts b/apps/web/pages/api/integrations/btcpayserver/webhook.ts new file mode 100644 index 00000000000000..bdca1082c8a30c --- /dev/null +++ b/apps/web/pages/api/integrations/btcpayserver/webhook.ts @@ -0,0 +1 @@ +export { default, config } from "@calcom/app-store/btcpayserver/api/webhook"; diff --git a/packages/app-store/_pages/setup/_getServerSideProps.tsx b/packages/app-store/_pages/setup/_getServerSideProps.tsx index 36bc532288b6b4..d21415b793c0e1 100644 --- a/packages/app-store/_pages/setup/_getServerSideProps.tsx +++ b/packages/app-store/_pages/setup/_getServerSideProps.tsx @@ -6,6 +6,7 @@ export const AppSetupPageMap = { zapier: import("../../zapier/pages/setup/_getServerSideProps"), stripe: import("../../stripepayment/pages/setup/_getServerSideProps"), hitpay: import("../../hitpay/pages/setup/_getServerSideProps"), + btcpayserver: import("../../btcpayserver/pages/setup/_getServerSideProps"), }; export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { diff --git a/packages/app-store/_pages/setup/index.tsx b/packages/app-store/_pages/setup/index.tsx index d7d160b3df16fc..2f57ca3140340c 100644 --- a/packages/app-store/_pages/setup/index.tsx +++ b/packages/app-store/_pages/setup/index.tsx @@ -16,6 +16,7 @@ export const AppSetupMap = { stripe: dynamic(() => import("../../stripepayment/pages/setup")), paypal: dynamic(() => import("../../paypal/pages/setup")), hitpay: dynamic(() => import("../../hitpay/pages/setup")), + btcpayserver: dynamic(() => import("../../btcpayserver/pages/setup")), }; export const AppSetupPage = (props: { slug: string }) => { diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx index 01f24bca32499e..8a16b698fb6ffa 100644 --- a/packages/app-store/apps.browser.generated.tsx +++ b/packages/app-store/apps.browser.generated.tsx @@ -22,6 +22,7 @@ export const AppSettingsComponentsMap = { export const EventTypeAddonMap = { alby: dynamic(() => import("./alby/components/EventTypeAppCardInterface")), basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")), + btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppCardInterface")), closecom: dynamic(() => import("./closecom/components/EventTypeAppCardInterface")), fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")), ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")), @@ -54,6 +55,7 @@ export const EventTypeAddonMap = { export const EventTypeSettingsMap = { alby: dynamic(() => import("./alby/components/EventTypeAppSettingsInterface")), basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppSettingsInterface")), + btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppSettingsInterface")), fathom: dynamic(() => import("./fathom/components/EventTypeAppSettingsInterface")), ga4: dynamic(() => import("./ga4/components/EventTypeAppSettingsInterface")), giphy: dynamic(() => import("./giphy/components/EventTypeAppSettingsInterface")), diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index b780d034f134b4..9cf061a4401670 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -4,6 +4,7 @@ **/ import { appKeysSchema as alby_zod_ts } from "./alby/zod"; import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod"; +import { appKeysSchema as btcpayserver_zod_ts } from "./btcpayserver/zod"; import { appKeysSchema as closecom_zod_ts } from "./closecom/zod"; import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod"; import { appKeysSchema as dub_zod_ts } from "./dub/zod"; @@ -54,6 +55,7 @@ import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; export const appKeysSchemas = { alby: alby_zod_ts, basecamp3: basecamp3_zod_ts, + btcpayserver: btcpayserver_zod_ts, closecom: closecom_zod_ts, dailyvideo: dailyvideo_zod_ts, dub: dub_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 9421d124281734..1749ef3c95aa2a 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -10,6 +10,7 @@ import autocheckin_config_json from "./autocheckin/config.json"; import baa_for_hipaa_config_json from "./baa-for-hipaa/config.json"; import basecamp3_config_json from "./basecamp3/config.json"; import bolna_config_json from "./bolna/config.json"; +import btcpayserver_config_json from "./btcpayserver/config.json"; import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata"; import campfire_config_json from "./campfire/config.json"; import chatbase_config_json from "./chatbase/config.json"; @@ -117,6 +118,7 @@ export const appStoreMetadata = { "baa-for-hipaa": baa_for_hipaa_config_json, basecamp3: basecamp3_config_json, bolna: bolna_config_json, + btcpayserver: btcpayserver_config_json, caldavcalendar: caldavcalendar__metadata_ts, campfire: campfire_config_json, chatbase: chatbase_config_json, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index 3970c1a22968cf..f1cad4389f7374 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -4,6 +4,7 @@ **/ import { appDataSchema as alby_zod_ts } from "./alby/zod"; import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod"; +import { appDataSchema as btcpayserver_zod_ts } from "./btcpayserver/zod"; import { appDataSchema as closecom_zod_ts } from "./closecom/zod"; import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod"; import { appDataSchema as dub_zod_ts } from "./dub/zod"; @@ -54,6 +55,7 @@ import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; export const appDataSchemas = { alby: alby_zod_ts, basecamp3: basecamp3_zod_ts, + btcpayserver: btcpayserver_zod_ts, closecom: closecom_zod_ts, dailyvideo: dailyvideo_zod_ts, dub: dub_zod_ts, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index d3d54d7c894d85..de8307de54f744 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -11,6 +11,7 @@ export const apiHandlers = { "baa-for-hipaa": import("./baa-for-hipaa/api"), basecamp3: import("./basecamp3/api"), bolna: import("./bolna/api"), + btcpayserver: import("./btcpayserver/api"), caldavcalendar: import("./caldavcalendar/api"), campfire: import("./campfire/api"), chatbase: import("./chatbase/api"), diff --git a/packages/app-store/btcpayserver/DESCRIPTION.md b/packages/app-store/btcpayserver/DESCRIPTION.md new file mode 100644 index 00000000000000..f0c5e3587c57fe --- /dev/null +++ b/packages/app-store/btcpayserver/DESCRIPTION.md @@ -0,0 +1,8 @@ +--- +items: + - website.png + - integrations.png + - checkout.png +--- + +{DESCRIPTION} diff --git a/packages/app-store/btcpayserver/api/add.ts b/packages/app-store/btcpayserver/api/add.ts new file mode 100644 index 00000000000000..8456c349cf5cda --- /dev/null +++ b/packages/app-store/btcpayserver/api/add.ts @@ -0,0 +1,19 @@ +import type { AppDeclarativeHandler } from "@calcom/types/AppHandler"; + +import { createDefaultInstallation } from "../../_utils/installation"; +import appConfig from "../config.json"; + +const handler: AppDeclarativeHandler = { + appType: appConfig.type, + variant: appConfig.variant, + slug: appConfig.slug, + supportsMultipleInstalls: false, + handlerType: "add", + redirect: { + url: "/apps/btcpayserver/setup", + }, + createCredential: ({ appType, user, slug, teamId }) => + createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }), +}; + +export default handler; diff --git a/packages/app-store/btcpayserver/api/index.ts b/packages/app-store/btcpayserver/api/index.ts new file mode 100644 index 00000000000000..b4b88a12a7920a --- /dev/null +++ b/packages/app-store/btcpayserver/api/index.ts @@ -0,0 +1,2 @@ +export { default as add } from "./add"; +export { default as webhook, config } from "./webhook"; diff --git a/packages/app-store/btcpayserver/api/webhook.ts b/packages/app-store/btcpayserver/api/webhook.ts new file mode 100644 index 00000000000000..4236f018df1f35 --- /dev/null +++ b/packages/app-store/btcpayserver/api/webhook.ts @@ -0,0 +1,100 @@ +import crypto from "crypto"; +import type { NextApiRequest, NextApiResponse } from "next"; +import getRawBody from "raw-body"; +import { z } from "zod"; + +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 { PrismaBookingPaymentRepository as BookingPaymentRepository } from "@calcom/lib/server/repository/PrismaBookingPaymentRepository"; + +import appConfig from "../config.json"; +import { btcpayCredentialKeysSchema } from "../lib/btcpayCredentialKeysSchema"; + +export const config = { api: { bodyParser: false } }; + +function verifyBTCPaySignature(rawBody: Buffer, expectedSignature: string, webhookSecret: string): string { + const hmac = crypto.createHmac("sha256", webhookSecret); + hmac.update(rawBody); + const computedSignature = hmac.digest("hex"); + const hexRegex = /^[0-9a-fA-F]+$/; + if (!hexRegex.test(computedSignature) || !hexRegex.test(expectedSignature)) { + throw new HttpCode({ statusCode: 400, message: "signature mismatch" }); + } + return computedSignature; +} + +const btcpayWebhookSchema = z.object({ + deliveryId: z.string(), + webhookId: z.string(), + originalDeliveryId: z.string().optional(), + isRedelivery: z.boolean(), + type: z.string(), + timestamp: z.number(), + storeId: z.string(), + invoiceId: z.string(), + metadata: z.object({}).optional(), + manuallyMarked: z.boolean().optional(), + overPaid: z.boolean(), +}); +const SUPPORTED_INVOICE_EVENTS = ["InvoiceSettled", "InvoiceProcessing"]; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + if (req.method !== "POST") throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" }); + const rawBody = await getRawBody(req); + const bodyAsString = rawBody.toString(); + + const signature = req.headers["btcpay-sig"] || req.headers["BTCPay-Sig"]; + if (!signature || typeof signature !== "string" || !signature.startsWith("sha256=")) + throw new HttpCode({ statusCode: 401, message: "Missing or invalid signature format" }); + + const webhookData = btcpayWebhookSchema.safeParse(JSON.parse(bodyAsString)); + if (!webhookData.success) return res.status(400).json({ message: "Invalid webhook payload" }); + + const data = webhookData.data; + if (!SUPPORTED_INVOICE_EVENTS.includes(data.type)) + return res.status(200).send({ message: "Webhook received but ignored" }); + + const bookingPaymentRepository = new BookingPaymentRepository(); + const payment = await bookingPaymentRepository.findByExternalIdIncludeBookingUserCredentials( + data.invoiceId, + appConfig.type + ); + if (!payment) throw new HttpCode({ statusCode: 404, message: "Cal.com: payment not found" }); + if (payment.success) return res.status(200).send({ message: "Payment already registered" }); + const key = payment.booking?.user?.credentials?.[0].key; + if (!key) throw new HttpCode({ statusCode: 404, message: "Cal.com: credentials not found" }); + + const parsedKey = btcpayCredentialKeysSchema.safeParse(key); + if (!parsedKey.success) + throw new HttpCode({ statusCode: 400, message: "Cal.com: Invalid BTCPay credentials" }); + + const { webhookSecret, storeId } = parsedKey.data; + if (storeId !== data.storeId) + throw new HttpCode({ statusCode: 400, message: "Cal.com: Store ID mismatch" }); + + const expectedSignature = signature.split("=")[1]; + const computedSignature = verifyBTCPaySignature(rawBody, expectedSignature, webhookSecret); + + if (computedSignature.length !== expectedSignature.length) { + throw new HttpCode({ statusCode: 400, message: "signature mismatch" }); + } + const isValid = crypto.timingSafeEqual( + Buffer.from(computedSignature, "hex"), + Buffer.from(expectedSignature, "hex") + ); + if (!isValid) throw new HttpCode({ statusCode: 400, message: "signature mismatch" }); + + await handlePaymentSuccess(payment.id, payment.bookingId); + return res.status(200).json({ success: true }); + } catch (_err) { + const err = getErrorFromUnknown(_err); + const statusCode = err instanceof HttpCode ? err.statusCode : 500; + return res.status(statusCode).send({ + message: err.message, + stack: IS_PRODUCTION ? undefined : err.stack, + }); + } +} diff --git a/packages/app-store/btcpayserver/components/BtcpayPaymentComponent.tsx b/packages/app-store/btcpayserver/components/BtcpayPaymentComponent.tsx new file mode 100644 index 00000000000000..1a07443012e67e --- /dev/null +++ b/packages/app-store/btcpayserver/components/BtcpayPaymentComponent.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useEffect, useState } from "react"; +import z from "zod"; + +import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment"; +import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect"; +import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; +import { useCopy } from "@calcom/lib/hooks/useCopy"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import { Button } from "@calcom/ui/components/button"; +import { Spinner } from "@calcom/ui/components/icon"; +import { showToast } from "@calcom/ui/components/toast"; + +interface IPaymentComponentProps { + payment: { + // Will be parsed on render + data: unknown; + }; + paymentPageProps: PaymentPageProps; +} + +// Create zod schema for data +const PaymentBTCPayDataSchema = z.object({ + invoice: z.object({ checkoutLink: z.string() }).required(), +}); + +export const BtcpayPaymentComponent = (props: IPaymentComponentProps) => { + const { payment } = props; + const { data } = payment; + const [iframeLoaded, setIframeLoaded] = useState(false); + const { copyToClipboard, isCopied } = useCopy(); + const wrongUrl = ( + <> +

Couldn't obtain payment URL

+ + ); + + const parsedData = PaymentBTCPayDataSchema.safeParse(data); + if (!parsedData.success || !parsedData.data?.invoice?.checkoutLink) return wrongUrl; + const checkoutUrl = parsedData.data.invoice.checkoutLink; + const handleOpenInNewTab = () => { + window.open(checkoutUrl, "_blank", "noopener,noreferrer"); + }; + + return ( +
+ + + {!iframeLoaded && ( +
+ +

Loading payment page...

+
+ )} + +
+