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...
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+type PaymentCheckerProps = PaymentPageProps;
+
+function PaymentChecker(props: PaymentCheckerProps) {
+ // TODO: move booking success code to a common lib function
+ // TODO: subscribe rather than polling
+ const searchParams = useCompatSearchParams();
+ const bookingSuccessRedirect = useBookingSuccessRedirect();
+ const utils = trpc.useUtils();
+ const { t } = useLocale();
+
+ useEffect(() => {
+ if (searchParams === null) {
+ return;
+ }
+
+ // use closure to ensure non-nullability
+ const sp = searchParams;
+ const interval = setInterval(() => {
+ (async () => {
+ try {
+ 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: sp.get("email"),
+ location: t("web_conferencing_details_to_follow"),
+ };
+
+ bookingSuccessRedirect({
+ successRedirectUrl: props.eventType.successRedirectUrl,
+ query: params,
+ booking: props.booking,
+ forwardParamsSuccessRedirect: props.eventType.forwardParamsSuccessRedirect,
+ });
+ }
+ } catch (e) {}
+ })();
+ }, 2000);
+
+ return () => clearInterval(interval);
+ }, [
+ bookingSuccessRedirect,
+ props.booking,
+ props.booking.id,
+ props.booking.status,
+ props.eventType.id,
+ props.eventType.successRedirectUrl,
+ props.eventType.forwardParamsSuccessRedirect,
+ props.payment.success,
+ searchParams,
+ t,
+ utils.viewer.bookings,
+ ]);
+
+ return null;
+}
diff --git a/packages/app-store/btcpayserver/components/EventTypeAppCardInterface.tsx b/packages/app-store/btcpayserver/components/EventTypeAppCardInterface.tsx
new file mode 100644
index 00000000000000..97d94727224b31
--- /dev/null
+++ b/packages/app-store/btcpayserver/components/EventTypeAppCardInterface.tsx
@@ -0,0 +1,50 @@
+import { usePathname } from "next/navigation";
+import { useState } from "react";
+
+import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
+import AppCard from "@calcom/app-store/_components/AppCard";
+import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+
+import checkForMultiplePaymentApps from "../../_utils/payments/checkForMultiplePaymentApps";
+import type { appDataSchema } from "../zod";
+import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface";
+
+type Option = { value: string; label: string };
+
+const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
+ eventType,
+ app,
+ eventTypeFormMetadata,
+}) {
+ const { t } = useLocale();
+ const pathname = usePathname();
+ const { getAppData, setAppData, disabled } = useAppContextWithSchema();
+ const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
+ const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata);
+ const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled;
+
+ return (
+ {
+ setRequirePayment(e);
+ }}
+ description={<>Add lightning payments to your events and booking>}
+ disableSwitch={shouldDisableSwitch}
+ switchTooltip={shouldDisableSwitch ? t("other_payment_app_enabled") : undefined}>
+
+
+ );
+};
+
+export default EventTypeAppCard;
diff --git a/packages/app-store/btcpayserver/components/EventTypeAppSettingsInterface.tsx b/packages/app-store/btcpayserver/components/EventTypeAppSettingsInterface.tsx
new file mode 100644
index 00000000000000..9db586113c8f40
--- /dev/null
+++ b/packages/app-store/btcpayserver/components/EventTypeAppSettingsInterface.tsx
@@ -0,0 +1,134 @@
+import { useState, useEffect } from "react";
+
+import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Alert } from "@calcom/ui/components/alert";
+import { Select } from "@calcom/ui/components/form";
+import { TextField } from "@calcom/ui/components/form";
+
+import {
+ currencyOptions,
+ convertToSmallestCurrencyUnit,
+ convertFromSmallestToPresentableCurrencyUnit,
+} from "../lib/currencyOptions";
+import { BTCPayPaymentOptions as paymentOptions } from "../zod";
+
+type Option = { value: string; label: string };
+
+const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
+ eventType,
+ getAppData,
+ setAppData,
+}) => {
+ const { t } = useLocale();
+ const price = getAppData("price");
+ const currency = getAppData("currency") || (currencyOptions.length > 0 ? currencyOptions[0].value : "");
+ const [selectedCurrency, setSelectedCurrency] = useState(
+ currencyOptions.find((c) => c.value === currency) ||
+ (currencyOptions.length > 0
+ ? {
+ label: currencyOptions[0].label,
+ value: currencyOptions[0].value,
+ }
+ : null)
+ );
+
+ const paymentOption = getAppData("paymentOption");
+ const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
+ label: paymentOptions.length > 0 ? paymentOptions[0].label : "",
+ value: paymentOptions.length > 0 ? paymentOptions[0].value : "",
+ };
+ const seatsEnabled = !!eventType.seatsPerTimeSlot;
+ const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
+ const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
+
+ // make sure a currency is selected
+ useEffect(() => {
+ if (requirePayment && !getAppData("currency")) {
+ setAppData("currency", currencyOptions[0].value);
+ }
+ }, [requirePayment, getAppData, setAppData]);
+
+ const disableDecimalPlace = (value: number) => {
+ return Math.floor(value);
+ };
+
+ return (
+ <>
+ {recurringEventDefined ? (
+
+ ) : (
+ requirePayment && (
+ <>
+
+ {
+ setAppData("price", convertToSmallestCurrencyUnit(Number(e.target.value), currency));
+ }}
+ value={
+ price && price > 0
+ ? disableDecimalPlace(convertFromSmallestToPresentableCurrencyUnit(price, currency))
+ : undefined
+ }
+ />
+
+
+
+
+
+
+
+
+
+ {seatsEnabled && paymentOption === "HOLD" && (
+
+ )}
+ >
+ )
+ )}
+ >
+ );
+};
+
+export default EventTypeAppSettingsInterface;
diff --git a/packages/app-store/btcpayserver/components/KeyInput.tsx b/packages/app-store/btcpayserver/components/KeyInput.tsx
new file mode 100644
index 00000000000000..e28084995067b4
--- /dev/null
+++ b/packages/app-store/btcpayserver/components/KeyInput.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import classNames from "classnames";
+import type { FormEvent } from "react";
+import React, { forwardRef, useState, useEffect, useId, useCallback } from "react";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Label } from "@calcom/ui/components/form";
+import { Input } from "@calcom/ui/components/form";
+import type { InputFieldProps } from "@calcom/ui/components/form";
+import { Icon } from "@calcom/ui/components/icon";
+import { Skeleton } from "@calcom/ui/components/skeleton";
+
+type AddonProps = {
+ children: React.ReactNode;
+ className?: string;
+ error?: boolean;
+ onClickAddon?: () => void;
+};
+
+const Addon = ({ children, className, error }: AddonProps) => (
+
+);
+
+export const KeyField: React.FC = forwardRef<
+ HTMLInputElement,
+ InputFieldProps & { defaultValue: string }
+>(function KeyField(props, ref) {
+ const id = useId();
+ const [isPasswordVisible, setIsPasswordVisible] = useState(false);
+ const [currentValue, setCurrentValue] = useState("");
+ const toggleIsPasswordVisible = useCallback(
+ () => setIsPasswordVisible(!isPasswordVisible),
+ [isPasswordVisible, setIsPasswordVisible]
+ );
+
+ const { t: _t, isLocaleReady, i18n } = useLocale();
+ const t = props.t || _t;
+ const name = props.name || "";
+ const {
+ label = t(name),
+ labelProps,
+ labelClassName,
+ LockedIcon,
+ placeholder = isLocaleReady && i18n.exists(`${name}_placeholder`) ? t(`${name}_placeholder`) : "",
+ className,
+ addOnLeading,
+ addOnClassname,
+ inputIsFullWidth,
+ labelSrOnly,
+ noLabel,
+ containerClassName,
+ readOnly,
+ showAsteriskIndicator,
+ defaultValue,
+ ...passThrough
+ } = props;
+
+ useEffect(() => {
+ if (currentValue.trim().length === 0) {
+ setIsPasswordVisible(true);
+ }
+ }, [currentValue]);
+
+ useEffect(() => {
+ setCurrentValue(defaultValue);
+ if (defaultValue.length > 0) {
+ setIsPasswordVisible(false);
+ }
+ }, [defaultValue]);
+
+ const getHiddenKey = (): string => {
+ let hiddenKey = currentValue;
+ const length = currentValue.length;
+ if (length > 6) {
+ const start = currentValue.slice(0, 3);
+ const end = currentValue.slice(length - 3);
+ hiddenKey = `${start}${"*".repeat(length - 6)}${end}`;
+ }
+
+ return hiddenKey;
+ };
+
+ const onInput = (event: FormEvent) => {
+ const target = event.target as HTMLInputElement;
+ const fullValue = target.value;
+ setCurrentValue(fullValue);
+ target.value = fullValue;
+ };
+
+ return (
+
+ {!!label && !noLabel && (
+
+ {label}
+ {showAsteriskIndicator && !readOnly && passThrough.required ? (
+ *
+ ) : null}
+ {LockedIcon}
+
+ )}
+
+
+
+ );
+});
+
+export default KeyField;
diff --git a/packages/app-store/btcpayserver/config.json b/packages/app-store/btcpayserver/config.json
new file mode 100644
index 00000000000000..bb96e7a7a48693
--- /dev/null
+++ b/packages/app-store/btcpayserver/config.json
@@ -0,0 +1,16 @@
+{
+ "name": "BTCPayServer",
+ "slug": "btcpayserver",
+ "type": "btcpayserver_payment",
+ "logo": "icon.svg",
+ "url": "https://btcpayserver.org",
+ "variant": "payment",
+ "categories": ["payment"],
+ "publisher": "BTCPay Server Team",
+ "email": "chat.btcpayserver.org",
+ "description": "BTCPay Server is a self-hosted open source Bitcoin payment processor. Start receiving bitcoin payments for your events and bookings.",
+ "extendsFeature": "EventType",
+ "isTemplate": false,
+ "__createdUsingCli": true,
+ "__template": "event-type-app-card"
+}
diff --git a/packages/app-store/btcpayserver/index.ts b/packages/app-store/btcpayserver/index.ts
new file mode 100644
index 00000000000000..e2e9d7b029c031
--- /dev/null
+++ b/packages/app-store/btcpayserver/index.ts
@@ -0,0 +1,2 @@
+export * as api from "./api";
+export * as lib from "./lib";
diff --git a/packages/app-store/btcpayserver/lib/PaymentService.ts b/packages/app-store/btcpayserver/lib/PaymentService.ts
new file mode 100644
index 00000000000000..7b59e63f82bbe3
--- /dev/null
+++ b/packages/app-store/btcpayserver/lib/PaymentService.ts
@@ -0,0 +1,204 @@
+import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
+import { v4 as uuidv4 } from "uuid";
+import type z from "zod";
+
+import { ErrorCode } from "@calcom/lib/errorCodes";
+import logger from "@calcom/lib/logger";
+import { safeStringify } from "@calcom/lib/safeStringify";
+import type { IBookingPaymentRepository } from "@calcom/lib/server/repository/BookingPaymentRepository.interface";
+import { PrismaBookingPaymentRepository } from "@calcom/lib/server/repository/PrismaBookingPaymentRepository";
+import type { CalendarEvent } from "@calcom/types/Calendar";
+import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
+
+import appConfig from "../config.json";
+import { btcpayCredentialKeysSchema } from "./btcpayCredentialKeysSchema";
+import { convertFromSmallestToPresentableCurrencyUnit } from "./currencyOptions";
+
+const log = logger.getSubLogger({ prefix: ["payment-service:btcpayserver"] });
+
+interface BTCPayInvoice {
+ id: string;
+ checkoutLink: string;
+ status: string;
+ amount: string;
+ currency: string;
+ createdTime: number;
+ expirationTime: number;
+ metadata?: Record;
+ checkout?: Record;
+ receipt?: Record;
+ payments?: Array<{
+ id: string;
+ amount: string;
+ paymentMethod: string;
+ }>;
+ [key: string]: any;
+}
+
+export class PaymentService implements IAbstractPaymentService {
+ private credentials: z.infer | null;
+ private bookingPaymentRepository: IBookingPaymentRepository;
+
+ constructor(
+ credentials: { key: Prisma.JsonValue },
+ bookingPaymentRepository: IBookingPaymentRepository = new PrismaBookingPaymentRepository()
+ ) {
+ const keyParsing = btcpayCredentialKeysSchema.safeParse(credentials.key);
+ if (keyParsing.success) {
+ this.credentials = keyParsing.data;
+ } else {
+ this.credentials = null;
+ }
+ this.bookingPaymentRepository = bookingPaymentRepository;
+ }
+
+ private async BTCPayApiCall(endpoint: string, options: RequestInit = {}) {
+ if (!this.credentials) throw new Error("BTCPay server credentials not found");
+
+ const serverUrl = this.credentials.serverUrl.endsWith("/")
+ ? this.credentials.serverUrl.slice(0, -1)
+ : this.credentials.serverUrl;
+ const url = `${serverUrl}${endpoint}`;
+ const headers = {
+ Authorization: `token ${this.credentials.apiKey}`,
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ ...options.headers,
+ };
+
+ try {
+ const response = await fetch(url, { ...options, headers });
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`BTCPay server API error (${response.status}): ${errorText}`);
+ }
+ return await response.json();
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ async create(
+ payment: Pick,
+ bookingId: Booking["id"],
+ userId: Booking["userId"],
+ username: string | null,
+ bookerName: string,
+ paymentOption: PaymentOption,
+ bookerEmail: string,
+ bookerPhoneNumber?: string | null,
+ eventTitle?: string,
+ bookingTitle?: string
+ ) {
+ try {
+ if (!this.credentials?.storeId) {
+ throw new Error("BTCPay server: Store ID not found");
+ }
+
+ const uid = uuidv4();
+ const invoiceRequest = {
+ metadata: {
+ orderId: `cal-booking-${bookingId}`,
+ itemDesc: bookingTitle || `Booking #${bookingId}`,
+ appId: "cal.com",
+ referenceId: uid,
+ customerName: bookerName,
+ customerEmail: bookerEmail,
+ bookingDescription: bookingTitle || `Booking with ${bookerName}`,
+ },
+ checkout: {
+ buyerEmail: bookerEmail,
+ },
+ receipt: {
+ enabled: true,
+ },
+ amount: convertFromSmallestToPresentableCurrencyUnit(payment.amount, payment.currency),
+ currency: payment.currency === "BTC" ? "SATS" : payment.currency,
+ additionalSearchTerms: [`cal-booking-${bookingId}`, bookerName, bookerEmail],
+ };
+ const invoiceResponse = (await this.BTCPayApiCall(
+ `/api/v1/stores/${this.credentials.storeId}/invoices`,
+ { method: "POST", body: JSON.stringify(invoiceRequest) }
+ )) as BTCPayInvoice;
+
+ const paymentData = await this.bookingPaymentRepository.createPaymentRecord({
+ uid,
+ app: { connect: { slug: appConfig.slug } },
+ booking: { connect: { id: bookingId } },
+ amount: payment.amount,
+ externalId: invoiceResponse.id,
+ currency: payment.currency,
+ fee: 0,
+ success: false,
+ refunded: false,
+ data: Object.assign(
+ {},
+ {
+ invoice: {
+ ...invoiceResponse,
+ isPaid: false,
+ attendee: { name: bookerName, email: bookerEmail },
+ },
+ }
+ ),
+ });
+ if (!paymentData) throw new Error("Failed to store Payment data");
+ return paymentData;
+ } catch (error) {
+ log.error("BTCPay server: Payment could not be created", bookingId, safeStringify(error));
+ throw new Error(ErrorCode.PaymentCreationFailure);
+ }
+ }
+
+ async update(): Promise {
+ throw new Error("Method not implemented.");
+ }
+ async refund(): Promise {
+ throw new Error("BTCPay Server does not support automatic refunds for Bitcoin payments");
+ }
+
+ async collectCard(
+ _payment: Pick,
+ _bookingId: number,
+ _bookerEmail: string,
+ _paymentOption: PaymentOption
+ ): Promise {
+ throw new Error("Method not implemented");
+ }
+
+ chargeCard(
+ _payment: Pick,
+ _bookingId: number
+ ): Promise {
+ throw new Error("Method not implemented.");
+ }
+
+ async getPaymentPaidStatus(): Promise {
+ throw new Error("Method not implemented.");
+ }
+
+ async getPaymentDetails(): Promise {
+ throw new Error("Method not implemented.");
+ }
+
+ async afterPayment(
+ _event: CalendarEvent,
+ _booking: {
+ user: { email: string | null; name: string | null; timeZone: string } | null;
+ id: number;
+ startTime: { toISOString: () => string };
+ uid: string;
+ },
+ _paymentData: Payment
+ ): Promise {
+ return Promise.resolve();
+ }
+
+ deletePayment(_paymentId: number): Promise {
+ return Promise.resolve(false);
+ }
+
+ isSetupAlready(): boolean {
+ return !!this.credentials;
+ }
+}
diff --git a/packages/app-store/btcpayserver/lib/btcpayCredentialKeysSchema.ts b/packages/app-store/btcpayserver/lib/btcpayCredentialKeysSchema.ts
new file mode 100644
index 00000000000000..fb74f8a63b516d
--- /dev/null
+++ b/packages/app-store/btcpayserver/lib/btcpayCredentialKeysSchema.ts
@@ -0,0 +1,8 @@
+import z from "zod";
+
+export const btcpayCredentialKeysSchema = z.object({
+ serverUrl: z.string().url(),
+ storeId: z.string(),
+ apiKey: z.string(),
+ webhookSecret: z.string(),
+});
diff --git a/packages/app-store/btcpayserver/lib/currencyOptions.ts b/packages/app-store/btcpayserver/lib/currencyOptions.ts
new file mode 100644
index 00000000000000..266dbc46b1b7bc
--- /dev/null
+++ b/packages/app-store/btcpayserver/lib/currencyOptions.ts
@@ -0,0 +1,20 @@
+export const currencyOptions = [
+ { label: "SATS", value: "BTC", unit: "SATS" },
+ { label: "USD", value: "USD", unit: "USD" },
+];
+
+const zeroDecimalCurrencies = ["SATS", "BTC"];
+
+export const convertToSmallestCurrencyUnit = (amount: number, currency: string) => {
+ if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
+ return amount;
+ }
+ return Math.round(amount * 100);
+};
+
+export const convertFromSmallestToPresentableCurrencyUnit = (amount: number, currency: string) => {
+ if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
+ return amount;
+ }
+ return amount / 100;
+};
diff --git a/packages/app-store/btcpayserver/lib/index.ts b/packages/app-store/btcpayserver/lib/index.ts
new file mode 100644
index 00000000000000..30894fcab96cfb
--- /dev/null
+++ b/packages/app-store/btcpayserver/lib/index.ts
@@ -0,0 +1,2 @@
+export * from "./PaymentService";
+export * from "./btcpayCredentialKeysSchema";
diff --git a/packages/app-store/btcpayserver/package.json b/packages/app-store/btcpayserver/package.json
new file mode 100644
index 00000000000000..1b1394a748b5a3
--- /dev/null
+++ b/packages/app-store/btcpayserver/package.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "private": true,
+ "name": "@calcom/btcpayserver",
+ "version": "0.0.0",
+ "main": "./index.ts",
+ "dependencies": {
+ "@calcom/lib": "*"
+ },
+ "devDependencies": {
+ "@calcom/types": "*"
+ },
+ "description": "BTCPay Server is a self-hosted open source Bitcoin payment processor. Start receiving bitcoin payments for your events and bookings."
+}
diff --git a/packages/app-store/btcpayserver/pages/setup/_getServerSideProps.tsx b/packages/app-store/btcpayserver/pages/setup/_getServerSideProps.tsx
new file mode 100644
index 00000000000000..796eb62c7971d7
--- /dev/null
+++ b/packages/app-store/btcpayserver/pages/setup/_getServerSideProps.tsx
@@ -0,0 +1,36 @@
+import type { GetServerSidePropsContext } from "next";
+
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { CredentialRepository } from "@calcom/lib/server/repository/credential";
+
+import { btcpayCredentialKeysSchema } from "../../lib/btcpayCredentialKeysSchema";
+import type { IBTCPaySetupProps } from "./index";
+
+export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
+ try {
+ const notFound = { notFound: true } as const;
+ if (typeof ctx.params?.slug !== "string") return notFound;
+
+ const { req } = ctx;
+ const session = await getServerSession({ req });
+ if (!session?.user?.id) return { redirect: { permanent: false, destination: "/auth/login" } };
+
+ const credential = await CredentialRepository.findFirstByUserIdAndType({
+ userId: session.user.id,
+ type: "btcpayserver_payment",
+ });
+
+ let props: IBTCPaySetupProps | undefined;
+ if (credential?.key) {
+ const keyParsing = btcpayCredentialKeysSchema.safeParse(credential.key);
+ if (keyParsing.success) {
+ props = keyParsing.data;
+ }
+ }
+ return { props: props ?? {} };
+ } catch (error) {
+ return {
+ props: {},
+ };
+ }
+};
diff --git a/packages/app-store/btcpayserver/pages/setup/index.tsx b/packages/app-store/btcpayserver/pages/setup/index.tsx
new file mode 100644
index 00000000000000..da867304dda810
--- /dev/null
+++ b/packages/app-store/btcpayserver/pages/setup/index.tsx
@@ -0,0 +1,354 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useSession } from "next-auth/react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useState, useEffect } from "react";
+import { useForm } from "react-hook-form";
+import { Toaster } from "sonner";
+import { z } from "zod";
+
+import AppNotInstalledMessage from "@calcom/app-store/_components/AppNotInstalledMessage";
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc";
+import { Button } from "@calcom/ui/components/button";
+import { Icon } from "@calcom/ui/components/icon";
+import { showToast } from "@calcom/ui/components/toast";
+
+import KeyField from "../../components/KeyInput";
+import { btcpayCredentialKeysSchema } from "../../lib/btcpayCredentialKeysSchema";
+
+export type IBTCPaySetupProps = z.infer;
+
+export default function BTCPaySetup(props: IBTCPaySetupProps) {
+ const params = useCompatSearchParams();
+ if (params?.get("callback") === "true") {
+ return ;
+ }
+ return ;
+}
+
+enum BTCPayOAuthError {
+ Declined = "declined",
+ Unknown = "unknown",
+}
+
+function BTCPaySetupCallback() {
+ const [error, setError] = useState(null);
+ const searchParams = useCompatSearchParams();
+
+ useEffect(() => {
+ if (!searchParams) {
+ return;
+ }
+ if (!window.opener) {
+ setError("Something went wrong. Opener not available. Please contact support");
+ return;
+ }
+ const code = searchParams?.get("code");
+ const error = searchParams?.get("error");
+
+ if (!code) {
+ setError(BTCPayOAuthError.Declined);
+ }
+ if (error) {
+ setError(error);
+ return;
+ }
+
+ window.opener.postMessage({
+ type: "btcpayserver:oauth:success",
+ payload: { code },
+ });
+ window.close();
+ }, [searchParams]);
+
+ return (
+
+ {error &&
Authorization failed: {error}
}
+ {!error &&
Connecting...
}
+
+ );
+}
+
+function BTCPaySetupPage(props: IBTCPaySetupProps) {
+ const router = useRouter();
+ const { t } = useLocale();
+ const session = useSession();
+ const [loading, setLoading] = useState(false);
+ const [validating, setValidating] = useState(false);
+ const [updatable, setUpdatable] = useState(false);
+ const [keyData, setKeyData] = useState<
+ | {
+ storeId: string;
+ serverUrl: string;
+ apiKey: string;
+ webhookSecret: string;
+ }
+ | undefined
+ >();
+ const settingsSchema = z.object({
+ storeId: z.string().trim(),
+ serverUrl: z.string().trim(),
+ apiKey: z.string().trim(),
+ webhookSecret: z.string().optional(),
+ });
+ const integrations = trpc.viewer.apps.integrations.useQuery({ variant: "payment", appId: "btcpayserver" });
+ const [btcPayPaymentAppCredentials] = integrations.data?.items || [];
+ const [credentialId] = btcPayPaymentAppCredentials?.userCredentialIds || [-1];
+ const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
+
+ const saveKeysMutation = trpc.viewer.apps.updateAppCredentials.useMutation({
+ onSuccess: () => {
+ showToast(t("keys_have_been_saved"), "success");
+ router.push("/event-types");
+ },
+ onError: (error) => {
+ showToast(error.message, "error");
+ },
+ });
+ const deleteMutation = trpc.viewer.credentials.delete.useMutation({
+ onSuccess: () => {
+ router.push("/apps/btcpayserver");
+ },
+ onError: () => {
+ showToast(t("error_removing_app"), "error");
+ },
+ });
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ watch,
+ reset,
+ } = useForm>({
+ reValidateMode: "onChange",
+ resolver: zodResolver(settingsSchema),
+ });
+
+ useEffect(() => {
+ const _keyData = {
+ storeId: props?.storeId || "",
+ serverUrl: props?.serverUrl || "",
+ apiKey: props?.apiKey || "",
+ webhookSecret: props?.webhookSecret || "",
+ };
+ setKeyData(_keyData);
+ }, [props]);
+
+ useEffect(() => {
+ const subscription = watch((value) => {
+ const { serverUrl, storeId, apiKey, webhookSecret } = value;
+ if (
+ serverUrl &&
+ storeId &&
+ apiKey &&
+ (keyData?.serverUrl !== serverUrl || keyData?.storeId !== storeId || keyData?.apiKey !== apiKey)
+ ) {
+ setUpdatable(true);
+ } else {
+ setUpdatable(false);
+ }
+ });
+ return () => subscription.unsubscribe();
+ }, [watch, keyData]);
+
+ const configureBTCPayWebhook = async (data: z.infer) => {
+ setValidating(true);
+ const specificEvents = ["InvoiceSettled", "InvoiceProcessing"];
+ const serverUrl = data.serverUrl.endsWith("/") ? data.serverUrl.slice(0, -1) : data.serverUrl;
+ const endpoint = `${serverUrl}/api/v1/stores/${data.storeId}/webhooks`;
+ const webhookUrl = `${WEBAPP_URL}/api/integrations/btcpayserver/webhook`;
+ const requestBody = {
+ enabled: true,
+ automaticRedelivery: false,
+ url: webhookUrl,
+ authorizedEvents: {
+ everything: false,
+ specificEvents: specificEvents,
+ },
+ secret: null,
+ };
+
+ try {
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `token ${data.apiKey}`,
+ },
+ body: JSON.stringify(requestBody),
+ });
+ if (!response.ok) {
+ const errorBody = await response.text();
+ showToast(`Failed to configure webhook: ${errorBody}`, "error");
+ return false;
+ }
+ const webhookResponse = await response.json();
+ saveKeysMutation.mutate({
+ credentialId,
+ key: btcpayCredentialKeysSchema.parse({
+ ...data,
+ webhookSecret: webhookResponse.secret,
+ }),
+ });
+ return true;
+ } catch (error) {
+ if (error instanceof Error) {
+ showToast(error.message || "Failed to configure BTCPay webhook", "error");
+ } else {
+ showToast("An unknown error occurred while configuring BTCPay webhook", "error");
+ }
+ return false;
+ } finally {
+ setValidating(false);
+ }
+ };
+
+ const onSubmit = handleSubmit(async (data) => {
+ if (loading) return;
+ setLoading(true);
+
+ try {
+ const isValid = await configureBTCPayWebhook(data);
+ if (!isValid) {
+ setLoading(false);
+ return;
+ }
+ } catch (error: unknown) {
+ let message = "";
+ if (error instanceof Error) {
+ message = error.message;
+ }
+ showToast(message, "error");
+ } finally {
+ setLoading(false);
+ }
+ });
+
+ const onCancel = () => {
+ deleteMutation.mutate({ id: credentialId });
+ };
+
+ const btcpayIcon = (
+ <>
+
+ >
+ );
+
+ if (session.status === "loading") return <>>;
+
+ if (integrations.isPending) {
+ return ;
+ }
+
+ const isNewCredential = !props.serverUrl && !props.storeId && !props.webhookSecret && !props.apiKey;
+ const webhookUri = `${WEBAPP_URL}/api/integrations/btcpayserver/webhook`;
+
+ return (
+ <>
+
+ {showContent ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ );
+}
diff --git a/packages/app-store/btcpayserver/static/checkout.png b/packages/app-store/btcpayserver/static/checkout.png
new file mode 100644
index 00000000000000..866ab9e8227b96
Binary files /dev/null and b/packages/app-store/btcpayserver/static/checkout.png differ
diff --git a/packages/app-store/btcpayserver/static/icon.svg b/packages/app-store/btcpayserver/static/icon.svg
new file mode 100644
index 00000000000000..7c7fcf6237bea9
--- /dev/null
+++ b/packages/app-store/btcpayserver/static/icon.svg
@@ -0,0 +1,97 @@
+
+
diff --git a/packages/app-store/btcpayserver/static/integrations.png b/packages/app-store/btcpayserver/static/integrations.png
new file mode 100644
index 00000000000000..057d24b41ea50b
Binary files /dev/null and b/packages/app-store/btcpayserver/static/integrations.png differ
diff --git a/packages/app-store/btcpayserver/static/website.png b/packages/app-store/btcpayserver/static/website.png
new file mode 100644
index 00000000000000..f7ab97d66e1c7d
Binary files /dev/null and b/packages/app-store/btcpayserver/static/website.png differ
diff --git a/packages/app-store/btcpayserver/zod.ts b/packages/app-store/btcpayserver/zod.ts
new file mode 100644
index 00000000000000..69ebb6bcaf0701
--- /dev/null
+++ b/packages/app-store/btcpayserver/zod.ts
@@ -0,0 +1,36 @@
+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 BTCPayPaymentOptions = [
+ {
+ label: "on_booking_option",
+ value: "ON_BOOKING",
+ },
+];
+
+type PaymentOption = (typeof BTCPayPaymentOptions)[number]["value"];
+const VALUES: [PaymentOption, ...PaymentOption[]] = [
+ BTCPayPaymentOptions[0].value,
+ ...BTCPayPaymentOptions.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(),
+ credentialId: z.number().optional(),
+ })
+);
+
+export const appKeysSchema = z.object({});
diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts
index 031681ce0eaf1d..8ddea439c5fe0d 100644
--- a/packages/app-store/index.ts
+++ b/packages/app-store/index.ts
@@ -42,6 +42,7 @@ const appStore = {
telegramvideo: createCachedImport(() => import("./telegram")),
shimmervideo: createCachedImport(() => import("./shimmervideo")),
hitpay: createCachedImport(() => import("./hitpay")),
+ btcpayserver: createCachedImport(() => import("./btcpayserver")),
};
function createCachedImport(importFunc: () => Promise): () => Promise {
diff --git a/packages/features/ee/payments/components/PaymentPage.tsx b/packages/features/ee/payments/components/PaymentPage.tsx
index 2614fab9d8ba20..0c396b214fc687 100644
--- a/packages/features/ee/payments/components/PaymentPage.tsx
+++ b/packages/features/ee/payments/components/PaymentPage.tsx
@@ -51,6 +51,16 @@ const HitpayPaymentComponent = dynamic(
}
);
+const BtcpayPaymentComponent = dynamic(
+ () =>
+ import("@calcom/app-store/btcpayserver/components/BtcpayPaymentComponent").then(
+ (m) => m.BtcpayPaymentComponent
+ ),
+ {
+ ssr: false,
+ }
+);
+
const PaymentPage: FC = (props) => {
const { t, i18n } = useLocale();
const [is24h, setIs24h] = useState(isBrowserLocale24h());
@@ -166,6 +176,9 @@ const PaymentPage: FC = (props) => {
{props.payment.appId === "hitpay" && !props.payment.success && (
)}
+ {props.payment.appId === "btcpayserver" && !props.payment.success && (
+
+ )}
{props.payment.refunded && (
{t("refunded")}
)}
diff --git a/packages/lib/server/repository/BookingPaymentRepository.interface.ts b/packages/lib/server/repository/BookingPaymentRepository.interface.ts
new file mode 100644
index 00000000000000..e17cfb2689be86
--- /dev/null
+++ b/packages/lib/server/repository/BookingPaymentRepository.interface.ts
@@ -0,0 +1,37 @@
+import type { JsonValue } from "@calcom/types/Json";
+
+export interface BookingPaymentWithCredentials {
+ id: number;
+ amount: number;
+ success: boolean;
+ bookingId: number;
+ booking: {
+ user: {
+ credentials: Array<{
+ key: JsonValue;
+ }>;
+ } | null;
+ } | null;
+}
+
+export interface CreatePaymentData {
+ uid: string;
+ app: { connect: { slug: string } };
+ booking: { connect: { id: number } };
+ amount: number;
+ fee: number;
+ externalId: string;
+ refunded: boolean;
+ success: boolean;
+ currency: string;
+ data: Record;
+}
+
+export interface IBookingPaymentRepository {
+ findByExternalIdIncludeBookingUserCredentials(
+ externalId: string,
+ credentialType: string
+ ): Promise;
+
+ createPaymentRecord(data: CreatePaymentData): Promise;
+}
diff --git a/packages/lib/server/repository/PrismaBookingPaymentRepository.ts b/packages/lib/server/repository/PrismaBookingPaymentRepository.ts
new file mode 100644
index 00000000000000..d3a4b64eb67d37
--- /dev/null
+++ b/packages/lib/server/repository/PrismaBookingPaymentRepository.ts
@@ -0,0 +1,48 @@
+import type { PrismaClient } from "@calcom/prisma";
+import prisma from "@calcom/prisma";
+
+import type {
+ IBookingPaymentRepository,
+ BookingPaymentWithCredentials,
+ CreatePaymentData,
+} from "./BookingPaymentRepository.interface";
+
+export class PrismaBookingPaymentRepository implements IBookingPaymentRepository {
+ constructor(private readonly prismaClient: PrismaClient = prisma) {}
+
+ async findByExternalIdIncludeBookingUserCredentials(
+ externalId: string,
+ credentialType: string
+ ): Promise {
+ return await this.prismaClient.payment.findFirst({
+ where: {
+ externalId,
+ },
+ select: {
+ id: true,
+ amount: true,
+ success: true,
+ bookingId: true,
+ booking: {
+ select: {
+ user: {
+ select: {
+ credentials: {
+ where: { type: credentialType },
+ select: { key: true },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+ }
+
+ async createPaymentRecord(data: CreatePaymentData) {
+ const createdPayment = await this.prismaClient.payment.create({
+ data,
+ });
+ return createdPayment;
+ }
+}
diff --git a/packages/types/Json.d.ts b/packages/types/Json.d.ts
new file mode 100644
index 00000000000000..b175ee86711f9e
--- /dev/null
+++ b/packages/types/Json.d.ts
@@ -0,0 +1,5 @@
+export declare type JsonObject = {
+ [Key in string]?: JsonValue;
+};
+export type JsonArray = Array;
+export type JsonValue = string | number | boolean | JsonObject | JsonArray | null;
diff --git a/yarn.lock b/yarn.lock
index aae64608f91b95..05f52624ec4d56 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2783,6 +2783,15 @@ __metadata:
languageName: unknown
linkType: soft
+"@calcom/btcpayserver@workspace:packages/app-store/btcpayserver":
+ version: 0.0.0-use.local
+ resolution: "@calcom/btcpayserver@workspace:packages/app-store/btcpayserver"
+ dependencies:
+ "@calcom/lib": "*"
+ "@calcom/types": "*"
+ languageName: unknown
+ linkType: soft
+
"@calcom/caldavcalendar@workspace:packages/app-store/caldavcalendar":
version: 0.0.0-use.local
resolution: "@calcom/caldavcalendar@workspace:packages/app-store/caldavcalendar"