feat: BTCPay Server App (#21197)

* app initialization

* btcpay-calcom payment

* include logo and images

* resolve comments

* include USD and webhook cleaning

* currency display

* fix type error

* payment service create error

* type error fix

* icon update

* bot feedback update

* Remove console

* Remove currency suffix in price

* fix coderRabbit comment

* resolve extra comments

* Use repositories and declarative installation ocode for app

* use PrismaBookingPaymentRepository as well as fix UI view

* Avoid fetching booking just for title which is already passed to create fn in handlePayment

* fix type issues

* return 200 if payment is already processed

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
authored and GitHub committed 2025-07-29 16:08:19 +00:00
1 parent 7b6d5d6de3
commit 7b2f811bfe
36 files changed
+1563

No files matched your search

@@ -0,0 +1 @@
export { default, config } from "@calcom/app-store/btcpayserver/api/webhook";
@@ -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) => {
@@ -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 }) => {
@@ -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")),
@@ -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,
@@ -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,
@@ -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,
@@ -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"),
@@ -0,0 +1,8 @@
---
items:
- website.png
- integrations.png
- checkout.png
---
{DESCRIPTION}
@@ -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;
@@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as webhook, config } from "./webhook";
@@ -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,
});
}
}
@@ -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 = (
<>
<p className="mt-3 text-center">Couldn&apos;t obtain payment URL</p>
</>
);
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 (
<div className="mb-4 mt-8 flex h-full w-full flex-col items-center justify-center gap-4">
<PaymentChecker {...props.paymentPageProps} />
{!iframeLoaded && (
<div className="flex items-center justify-center">
<Spinner className="mr-2 h-5 w-5" />
<p>Loading payment page...</p>
</div>
)}
<div className={`w-full ${iframeLoaded ? "block" : "hidden"}`}>
<iframe
src={checkoutUrl}
title="BTCPay Payment"
className="h-[1000px] w-full rounded-md border-0"
onLoad={() => setIframeLoaded(true)}
/>
</div>
<div className="flex flex-row flex-wrap items-center justify-center gap-4">
<Button
size="sm"
color="secondary"
onClick={() => copyToClipboard(checkoutUrl)}
className="text-subtle rounded-md"
StartIcon={isCopied ? "clipboard-check" : "clipboard"}>
Copy Payment Link
</Button>
<Button onClick={handleOpenInNewTab}>Open in New Tab</Button>
</div>
</div>
);
};
type PaymentCheckerProps = PaymentPageProps;
function PaymentChecker(props: PaymentCheckerProps) {
// TODO: move booking success code to a common lib function
// TODO: subscribe rather than polling
const searchParams = 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;
}
@@ -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<typeof appDataSchema>();
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata);
const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled;
return (
<AppCard
returnTo={`${WEBAPP_URL}${pathname}?tabName=apps`}
app={app}
switchChecked={requirePayment}
switchOnClick={(e) => {
setRequirePayment(e);
}}
description={<>Add lightning payments to your events and booking</>}
disableSwitch={shouldDisableSwitch}
switchTooltip={shouldDisableSwitch ? t("other_payment_app_enabled") : undefined}>
<EventTypeAppSettingsInterface
eventType={eventType}
slug={app.slug}
disabled={disabled}
getAppData={getAppData}
setAppData={setAppData}
/>
</AppCard>
);
};
export default EventTypeAppCard;
@@ -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 ? (
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
) : (
requirePayment && (
<>
<div className="mt-4 block items-center justify-start sm:flex sm:space-x-2">
<TextField
label={t("price")}
className="text-black"
addOnClassname="h-[38px]"
min="1"
type="number"
required
placeholder="Price"
onChange={(e) => {
setAppData("price", convertToSmallestCurrencyUnit(Number(e.target.value), currency));
}}
value={
price && price > 0
? disableDecimalPlace(convertFromSmallestToPresentableCurrencyUnit(price, currency))
: undefined
}
/>
</div>
<div className="mt-5 w-60">
<label className="text-default block text-sm font-medium" htmlFor="currency">
{t("currency")}
</label>
<Select
variant="default"
options={currencyOptions}
value={selectedCurrency}
className="text-black"
defaultValue={selectedCurrency}
onChange={(e) => {
if (e) {
setSelectedCurrency(e);
setAppData("currency", e.value);
}
}}
/>
</div>
<div className="mt-2 w-60">
<label className="text-default block text-sm font-medium" htmlFor="paymentOption">
{t("payment_option")}
</label>
<Select<Option>
defaultValue={
paymentOptionSelectValue
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
: paymentOptions.length > 0
? { ...paymentOptions[0], label: t(paymentOptions[0].label) }
: undefined
}
options={paymentOptions.map((option) => {
return { ...option, label: t(option.label) || option.label };
})}
onChange={(input) => {
if (input) setAppData("paymentOption", input.value);
}}
className="mb-1 h-[38px] w-full"
isDisabled={seatsEnabled}
/>
</div>
{seatsEnabled && paymentOption === "HOLD" && (
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
)}
</>
)
)}
</>
);
};
export default EventTypeAppSettingsInterface;
@@ -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) => (
<div
className={classNames(
"addon-wrapper border-default [input:hover_+_&]:border-emphasis [input:hover_+_&]:border-l-default [&:has(+_input:hover)]:border-emphasis [&:has(+_input:hover)]:border-r-default h-9 border px-3 transition disabled:hover:cursor-not-allowed",
className
)}>
<div
className={classNames(
"min-h-9 flex flex-col justify-center text-sm leading-7",
error ? "text-error" : "text-default"
)}>
<span
className="flex max-w-2xl overflow-y-auto whitespace-nowrap"
style={{
WebkitOverflowScrolling: "touch",
scrollbarWidth: "none",
overflow: "-ms-scroll-chaining",
msOverflowStyle: "-ms-autohiding-scrollbar",
}}>
{children}
</span>
</div>
</div>
);
export const KeyField: React.FC<InputFieldProps & { defaultValue: string }> = forwardRef<
HTMLInputElement,
InputFieldProps & { defaultValue: string }
>(function KeyField(props, ref) {
const id = useId();
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [currentValue, setCurrentValue] = useState<string>("");
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<HTMLInputElement>) => {
const target = event.target as HTMLInputElement;
const fullValue = target.value;
setCurrentValue(fullValue);
target.value = fullValue;
};
return (
<div className={classNames(containerClassName)}>
{!!label && !noLabel && (
<Skeleton
as={Label}
htmlFor={id}
loadingClassName="w-16"
{...labelProps}
className={classNames(labelClassName, labelSrOnly && "sr-only", props.error && "text-error")}>
{label}
{showAsteriskIndicator && !readOnly && passThrough.required ? (
<span className="text-default ml-1 font-medium">*</span>
) : null}
{LockedIcon}
</Skeleton>
)}
<div
dir="ltr"
className="focus-within:ring-brand-default group relative mb-1 flex items-center rounded-md transition focus-within:outline-none focus-within:ring-2">
{addOnLeading && (
<Addon className={classNames("ltr:rounded-l-md rtl:rounded-r-md", addOnClassname)}>
{addOnLeading}
</Addon>
)}
<Input
id={id}
type="text"
placeholder={placeholder}
isFullWidth={inputIsFullWidth}
className={classNames(
className,
"disabled:bg-subtle disabled:hover:border-subtle mb-0 rounded-r-none border-r-0 disabled:cursor-not-allowed",
addOnLeading && "rounded-l-none border-l-0",
isPasswordVisible && "inline-block",
!isPasswordVisible && "hidden",
"!my-0 !ring-0"
)}
{...passThrough}
ref={ref}
onInput={onInput}
/>
<Input
type="text"
isFullWidth={inputIsFullWidth}
className={classNames(
className,
"disabled:bg-subtle disabled:hover:border-subtle mb-0 rounded-r-none border-r-0 disabled:cursor-not-allowed",
addOnLeading && "rounded-l-none border-l-0",
!isPasswordVisible && "inline-block",
isPasswordVisible && "hidden",
"!my-0 !ring-0"
)}
disabled
value={getHiddenKey()}
/>
<Addon className={classNames("ltr:rounded-r-md rtl:rounded-l-md", addOnClassname)}>
<button
className="text-emphasis h-9"
tabIndex={-1}
type="button"
onClick={() => toggleIsPasswordVisible()}>
{isPasswordVisible ? (
<Icon name="eye-off" className="h-4 w-4 stroke-[2.5px]" />
) : (
<Icon name="eye" className="h-4 w-4 stroke-[2.5px]" />
)}
</button>
</Addon>
</div>
</div>
);
});
export default KeyField;
@@ -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"
}
+2
View File
@@ -0,0 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";
@@ -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<string, any>;
checkout?: Record<string, any>;
receipt?: Record<string, any>;
payments?: Array<{
id: string;
amount: string;
paymentMethod: string;
}>;
[key: string]: any;
}
export class PaymentService implements IAbstractPaymentService {
private credentials: z.infer<typeof btcpayCredentialKeysSchema> | 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<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
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<Payment> {
throw new Error("Method not implemented.");
}
async refund(): Promise<Payment> {
throw new Error("BTCPay Server does not support automatic refunds for Bitcoin payments");
}
async collectCard(
_payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
_bookingId: number,
_bookerEmail: string,
_paymentOption: PaymentOption
): Promise<Payment> {
throw new Error("Method not implemented");
}
chargeCard(
_payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
_bookingId: number
): Promise<Payment> {
throw new Error("Method not implemented.");
}
async getPaymentPaidStatus(): Promise<string> {
throw new Error("Method not implemented.");
}
async getPaymentDetails(): Promise<Payment> {
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<void> {
return Promise.resolve();
}
deletePayment(_paymentId: number): Promise<boolean> {
return Promise.resolve(false);
}
isSetupAlready(): boolean {
return !!this.credentials;
}
}
@@ -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(),
});
@@ -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;
};
@@ -0,0 +1,2 @@
export * from "./PaymentService";
export * from "./btcpayCredentialKeysSchema";
@@ -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."
}
@@ -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: {},
};
}
};
@@ -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<typeof btcpayCredentialKeysSchema>;
export default function BTCPaySetup(props: IBTCPaySetupProps) {
const params = useCompatSearchParams();
if (params?.get("callback") === "true") {
return <BTCPaySetupCallback />;
}
return <BTCPaySetupPage {...props} />;
}
enum BTCPayOAuthError {
Declined = "declined",
Unknown = "unknown",
}
function BTCPaySetupCallback() {
const [error, setError] = useState<string | null>(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 (
<div>
{error && <p>Authorization failed: {error}</p>}
{!error && <p>Connecting...</p>}
</div>
);
}
function BTCPaySetupPage(props: IBTCPaySetupProps) {
const router = useRouter();
const { t } = useLocale();
const session = useSession();
const [loading, setLoading] = useState<boolean>(false);
const [validating, setValidating] = useState<boolean>(false);
const [updatable, setUpdatable] = useState<boolean>(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<z.infer<typeof settingsSchema>>({
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<typeof settingsSchema>) => {
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 = (
<>
<img className="h-5 w-5" src="/app-store/btcpayserver/btcpay.svg" alt="BTCPay Server Icon" />
</>
);
if (session.status === "loading") return <></>;
if (integrations.isPending) {
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
}
const isNewCredential = !props.serverUrl && !props.storeId && !props.webhookSecret && !props.apiKey;
const webhookUri = `${WEBAPP_URL}/api/integrations/btcpayserver/webhook`;
return (
<>
<div className="bg-default flex h-screen items-center justify-center">
{showContent ? (
<div className="flex w-full w-full max-w-[43em] flex-col items-center justify-center space-y-4 p-4 lg:space-y-5">
<form className="w-full space-y-4" onSubmit={onSubmit}>
<div className="bg-default border-subtle overflow-auto rounded border">
<div className="border-subtle flex items-center justify-between border-b-[1px] p-4 md:p-5">
<h2 className="text-2xl font-semibold">BTCPay Server Information</h2>
</div>
<div className="w-full space-y-4 p-4 md:p-5">
<div className="w-full">
<KeyField
{...register("serverUrl", { required: true })}
id="serverUrl"
name="serverUrl"
containerClassName="w-full"
label="BTCPay Server Url"
autoComplete="off"
autoCorrect="off"
defaultValue={keyData?.serverUrl || ""}
/>
{errors.serverUrl && (
<p data-testid="required" className="py-2 text-xs text-red-500">
{errors.serverUrl?.message}
</p>
)}
</div>
<div className="w-full">
<KeyField
{...register("storeId", { required: true })}
id="storeId"
name="storeId"
containerClassName="w-full"
label="BTCPay Store Id"
autoComplete="off"
autoCorrect="off"
defaultValue={keyData?.storeId || ""}
/>
{errors.storeId && (
<p data-testid="required" className="py-2 text-xs text-red-500">
{errors.storeId?.message}
</p>
)}
</div>
<div className="w-full">
<KeyField
{...register("apiKey", { required: true })}
id="apiKey"
name="apiKey"
containerClassName="w-full"
label="API Key"
autoComplete="off"
autoCorrect="off"
defaultValue={keyData?.apiKey || ""}
/>
{errors.apiKey && (
<p data-testid="required" className="py-2 text-xs text-red-500">
{errors.apiKey?.message}
</p>
)}
</div>
</div>
</div>
<div className="flex justify-end gap-4">
{isNewCredential ? (
<>
<Button color="secondary" className="h-10" onClick={onCancel}>
Cancel
</Button>
<Button
className="h-10"
color="primary"
type="submit"
disabled={loading || validating || !updatable}>
{loading ? (
<>
<Icon name="loader" className="mr-2 animate-spin" />
{t("connecting")}
</>
) : (
t("connect")
)}
</Button>
</>
) : (
<>
<Link href="/apps/btcpayserver" className="inline-block">
<Button color="secondary">Go to App Store</Button>
</Link>
</>
)}
</div>
</form>
</div>
) : (
<AppNotInstalledMessage appName="btcpayserver" />
)}
<Toaster position="bottom-right" />
</div>
</>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 1706.6667 1066.6667"
height="1066.6667"
width="1706.6667"
xml:space="preserve"
id="svg2"
version="1.1"
sodipodi:docname="BTCPay Server Icon.svg"
inkscape:version="0.92.3 (2405546, 2018-03-11)"><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1017"
id="namedview82"
showgrid="false"
inkscape:zoom="0.16640624"
inkscape:cx="853.33337"
inkscape:cy="533.33337"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="g10" /><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
id="defs6"><clipPath
id="clipPath18"
clipPathUnits="userSpaceOnUse"><path
id="path16"
d="M 0,800 H 1280 V 0 H 0 Z" /></clipPath><clipPath
id="clipPath74"
clipPathUnits="userSpaceOnUse"><path
id="path72"
d="M 0,800 H 1280 V 0 H 0 Z" /></clipPath><clipPath
id="clipPath128"
clipPathUnits="userSpaceOnUse"><path
id="path126"
d="M 0,800 H 1280 V 0 H 0 Z" /></clipPath><clipPath
id="clipPath140"
clipPathUnits="userSpaceOnUse"><path
id="path138"
d="M 0,800 H 1280 V 0 H 0 Z" /></clipPath></defs><g
transform="matrix(1.3333333,0,0,-1.3333333,955.493,249.38967)"
id="g10"><g
transform="matrix(0.29765316,0,0,0.29765316,413.69235,44.782893)"
id="g134"><g
transform="matrix(3.6585926,0,0,3.6585926,-4809.6898,-2104.6753)"
clip-path="url(#clipPath140)"
id="g136"><g
transform="matrix(4.7732059,0,0,4.7732059,779.20458,11.551291)"
id="g146"><path
id="path148"
style="fill:#cedc21;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 0,0 c -4.573,0 -8.281,3.707 -8.281,8.28 v 124.622 c 0,4.573 3.708,8.281 8.281,8.281 4.573,0 8.281,-3.708 8.281,-8.281 V 8.28 C 8.281,3.707 4.573,0 0,0"
inkscape:connector-curvature="0" /></g><g
transform="matrix(4.7732059,0,0,4.7732059,779.22987,11.542212)"
id="g150"><path
id="path152"
style="fill:#51b13e;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 0,0 c -3.099,0 -6.069,1.746 -7.487,4.731 -1.96,4.132 -0.201,9.072 3.931,11.033 l 49.934,23.698 -51.295,37.79 c -3.682,2.713 -4.468,7.897 -1.754,11.58 2.711,3.681 7.898,4.467 11.577,1.754 L 67.347,44.583 c 2.345,-1.728 3.611,-4.56 3.331,-7.46 -0.279,-2.898 -2.06,-5.438 -4.693,-6.687 L 3.545,0.802 C 2.399,0.258 1.19,0 0,0"
inkscape:connector-curvature="0" /></g><g
transform="matrix(4.7732059,0,0,4.7732059,779.23704,245.35911)"
id="g154"><path
id="path156"
style="fill:#cedc21;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 0,0 c -2.542,0 -5.05,1.166 -6.673,3.369 -2.713,3.683 -1.928,8.867 1.755,11.579 L 46.376,52.739 -3.557,76.438 c -4.132,1.96 -5.891,6.899 -3.931,11.031 1.962,4.132 6.902,5.893 11.031,3.93 L 65.984,61.766 c 2.632,-1.249 4.414,-3.789 4.693,-6.688 0.279,-2.9 -0.987,-5.733 -3.332,-7.46 L 4.905,1.614 C 3.426,0.525 1.705,0 0,0"
inkscape:connector-curvature="0" /></g><g
transform="matrix(4.7732059,0,0,4.7732059,818.73005,432.0807)"
id="g158"><path
id="path160"
style="fill:#1e7a44;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 0,0 v -35.022 l 23.757,17.503 z"
inkscape:connector-curvature="0" /></g><path
id="path162"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4.77320623"
d="m 818.73151,309.4809 h -79.05385 v 192.68958 h 79.05385 z"
inkscape:connector-curvature="0" /><g
transform="matrix(4.7732059,0,0,4.7732059,779.20458,685.44488)"
id="g164"><path
id="path166"
style="fill:#cedc21;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="M 0,0 C -4.573,0 -8.281,-3.708 -8.281,-8.281 V -112.496 H 8.281 V -8.281 C 8.281,-3.708 4.573,0 0,0"
inkscape:connector-curvature="0" /></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

+36
View File
@@ -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({});
+1
View File
@@ -42,6 +42,7 @@ const appStore = {
telegramvideo: createCachedImport(() => import("./telegram")),
shimmervideo: createCachedImport(() => import("./shimmervideo")),
hitpay: createCachedImport(() => import("./hitpay")),
btcpayserver: createCachedImport(() => import("./btcpayserver")),
};
function createCachedImport<T>(importFunc: () => Promise<T>): () => Promise<T> {
@@ -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<PaymentPageProps> = (props) => {
const { t, i18n } = useLocale();
const [is24h, setIs24h] = useState(isBrowserLocale24h());
@@ -166,6 +176,9 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
{props.payment.appId === "hitpay" && !props.payment.success && (
<HitpayPaymentComponent payment={props.payment} />
)}
{props.payment.appId === "btcpayserver" && !props.payment.success && (
<BtcpayPaymentComponent payment={props.payment} paymentPageProps={props} />
)}
{props.payment.refunded && (
<div className="text-default mt-4 text-center dark:text-gray-300">{t("refunded")}</div>
)}
@@ -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<string, any>;
}
export interface IBookingPaymentRepository {
findByExternalIdIncludeBookingUserCredentials(
externalId: string,
credentialType: string
): Promise<BookingPaymentWithCredentials | null>;
createPaymentRecord(data: CreatePaymentData): Promise<any>;
}
@@ -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<BookingPaymentWithCredentials | null> {
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;
}
}
+5
View File
@@ -0,0 +1,5 @@
export declare type JsonObject = {
[Key in string]?: JsonValue;
};
export type JsonArray = Array<JsonValue>;
export type JsonValue = string | number | boolean | JsonObject | JsonArray | null;
+9
View File
@@ -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"