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:
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'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"
|
||||
}
|
||||
@@ -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 |
@@ -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({});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user