feat: Alby integration (#11495)

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Roland 2023-09-28 19:03:01 +07:00 committed by GitHub
parent ef45cbfb3f
commit 2021b641ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
77 changed files with 2479 additions and 639 deletions

View File

@ -89,6 +89,7 @@ export const AppPage = ({
const [existingCredentials, setExistingCredentials] = useState<number[]>([]);
const [showDisconnectIntegration, setShowDisconnectIntegration] = useState(false);
const appDbQuery = trpc.viewer.appCredentialsByType.useQuery(
{ appType: type },
{

View File

@ -0,0 +1 @@
export { default, config } from "@calcom/app-store/alby/api/webhook";

View File

@ -1 +1 @@
export { default, config } from "@calcom/features/ee/payments/api/paypal-webhook";
export { default, config } from "@calcom/app-store/paypal/api/webhook";

View File

@ -1,14 +1,14 @@
import type { GetStaticPaths, InferGetStaticPropsType } from "next";
import type { InferGetServerSidePropsType } from "next";
import { useSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { AppSetupPage } from "@calcom/app-store/_pages/setup";
import { getStaticProps } from "@calcom/app-store/_pages/setup/_getStaticProps";
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
import { HeadSeo } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export default function SetupInformation(props: InferGetStaticPropsType<typeof getStaticProps>) {
export default function SetupInformation(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
const searchParams = useSearchParams();
const router = useRouter();
const slug = searchParams?.get("slug") as string;
@ -36,11 +36,4 @@ export default function SetupInformation(props: InferGetStaticPropsType<typeof g
SetupInformation.PageWrapper = PageWrapper;
export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: [],
fallback: "blocking",
};
};
export { getStaticProps };
export { getServerSideProps };

View File

@ -23,6 +23,7 @@ import {
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
import { SMS_REMINDER_NUMBER_FIELD, SystemField } from "@calcom/features/bookings/lib/SystemField";
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
@ -93,7 +94,7 @@ const querySchema = z.object({
});
export default function Success(props: SuccessProps) {
const { t, i18n } = useLocale();
const { t } = useLocale();
const router = useRouter();
const routerQuery = useRouterQuery();
const pathname = usePathname();
@ -490,10 +491,7 @@ export default function Success(props: SuccessProps) {
: t("payment")}
</div>
<div className="col-span-2 mb-2 mt-3">
{new Intl.NumberFormat(i18n.language, {
style: "currency",
currency: props.paymentStatus.currency,
}).format(props.paymentStatus.amount / 100.0)}
<Price currency={props.paymentStatus.currency} price={props.paymentStatus.amount} />
</div>
</>
)}

View File

@ -556,6 +556,8 @@ export async function apiLogin(
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, page: Page) {
await page.goto(`/event-types/${eventType?.id}?tabName=apps`);
await page.locator("div > .ml-auto").first().click();
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId("select-option-usd").click();
await page.getByPlaceholder("Price").fill("100");
await page.getByTestId("update-eventtype").click();
}

View File

@ -174,7 +174,7 @@ test.describe("Stripe integration", () => {
await page.getByTestId("price-input-stripe").fill("200");
// Select currency in dropdown
await page.locator("div").filter({ hasText: "United States dollar (USD)" }).nth(1).click();
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.locator("#react-select-2-input").fill("mexi");
await page.locator("#react-select-2-option-81").click();

View File

@ -0,0 +1,203 @@
import { expect } from "@playwright/test";
import prisma from "@calcom/prisma";
import { test } from "./lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Payment app", () => {
test("Should be able to edit alby price, currency", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "alby_payment",
userId: user.id,
key: {
account_id: "random",
account_email: "random@example.com",
webhook_endpoint_id: "ep_randomString",
webhook_endpoint_secret: "whsec_randomString",
account_lightning_address: "random@getalby.com",
},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByPlaceholder("Price").click();
await page.getByPlaceholder("Price").fill("200");
await page.getByText("SatoshissatsCurrencyBTCPayment optionCollect payment on booking").click();
await page.getByTestId("update-eventtype").click();
await page.goto(`${user.username}/${paymentEvent.slug}`);
// expect 200 sats to be displayed in page
expect(await page.locator("text=200 sats").first()).toBeTruthy();
await selectFirstAvailableTimeSlotNextMonth(page);
expect(await page.locator("text=200 sats").first()).toBeTruthy();
// go to /event-types and check if the price is 200 sats
await page.goto(`event-types/`);
expect(await page.locator("text=200 sats").first()).toBeTruthy();
});
test("Should be able to edit stripe price, currency", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "stripe_payment",
userId: user.id,
key: {
scope: "read_write",
livemode: false,
token_type: "bearer",
access_token: "sk_test_randomString",
refresh_token: "rt_randomString",
stripe_user_id: "acct_randomString",
default_currency: "usd",
stripe_publishable_key: "pk_test_randomString",
},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.getByTestId("select-option-usd").click();
await page.getByTestId("price-input-stripe").click();
await page.getByTestId("price-input-stripe").fill("350");
await page.getByTestId("update-eventtype").click();
await page.goto(`${user.username}/${paymentEvent.slug}`);
// expect 200 sats to be displayed in page
expect(await page.locator("text=350").first()).toBeTruthy();
await selectFirstAvailableTimeSlotNextMonth(page);
expect(await page.locator("text=350").first()).toBeTruthy();
// go to /event-types and check if the price is 200 sats
await page.goto(`event-types/`);
expect(await page.locator("text=350").first()).toBeTruthy();
});
test("Should be able to edit paypal price, currency", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "paypal_payment",
userId: user.id,
key: {
client_id: "randomString",
secret_key: "randomString",
webhook_id: "randomString",
},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByPlaceholder("Price").click();
await page.getByPlaceholder("Price").fill("150");
await page.locator(".text-black > .bg-default > div > div:nth-child(2)").first().click();
await page.locator("#react-select-2-option-13").click();
await page.locator(".mb-1 > .bg-default > div > div:nth-child(2)").first().click();
await page.getByText("$MXNCurrencyMexican pesoPayment option").click();
await page.getByTestId("update-eventtype").click();
await page.goto(`${user.username}/${paymentEvent.slug}`);
// expect 150 to be displayed in page
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
await selectFirstAvailableTimeSlotNextMonth(page);
// expect 150 to be displayed in page
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
// go to /event-types and check if the price is 150
await page.goto(`event-types/`);
expect(await page.locator("text=MX$150.00").first()).toBeTruthy();
});
test("Should display App is not setup already for alby", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "alby_payment",
userId: user.id,
key: {},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
// expect text "This app has not been setup yet" to be displayed
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
await page.getByRole("button", { name: "Setup" }).click();
// Expect "Connect with Alby" to be displayed
expect(await page.locator("text=Connect with Alby").first()).toBeTruthy();
});
test("Should display App is not setup already for paypal", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
if (!paymentEvent) {
throw new Error("No payment event found");
}
await prisma.credential.create({
data: {
type: "paypal_payment",
userId: user.id,
key: {},
},
});
await page.goto(`event-types/${paymentEvent.id}?tabName=apps`);
await page.locator("#event-type-form").getByRole("switch").click();
// expect text "This app has not been setup yet" to be displayed
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
await page.getByRole("button", { name: "Setup" }).click();
// Expect "Getting started with Paypal APP" to be displayed
expect(await page.locator("text=Getting started with Paypal APP").first()).toBeTruthy();
});
});

View File

@ -2059,5 +2059,6 @@
"edit_users_availability":"Edit user's availability: {{username}}",
"resend_invitation": "Resend invitation",
"invitation_resent": "The invitation was resent.",
"this_app_is_not_setup_already": "This app has not been setup yet",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -19,4 +19,4 @@
1. Add API documentation links in comments for files `api`, `lib` and `types`
2. Use [`AppDeclarativeHandler`](../types/AppHandler.d.ts) across all apps. Whatever isn't supported in it, support that.
3. README should be added in the respective app and can be linked in main README [like this](https://github.com/calcom/cal.com/pull/10429/files/155ac84537d12026f595551fe3542e810b029714#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R509)
4. Also, no env variables should be added by an app. They should be [added in `zod.ts`](https://github.com/calcom/cal.com/blob/main/packages/app-store/jitsivideo/zod.ts) and then they would be automatically available to be modified by the cal.com app admin.
4. Also, no env variables should be added by an app. They should be [added in `zod.ts`](https://github.com/calcom/cal.com/blob/main/packages/app-store/jitsivideo/zod.ts) and then they would be automatically available to be modified by the cal.com app admin. In local development you can open /settings/admin with the admin credentials (see [seed.ts](packages/prisma/seed.ts))

View File

@ -1,10 +1,12 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import { classNames } from "@calcom/lib";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Switch, Badge, Avatar } from "@calcom/ui";
import { Switch, Badge, Avatar, Button } from "@calcom/ui";
import { Settings } from "@calcom/ui/components/icon";
import type { CredentialOwner } from "../types";
import OmniInstallAppButton from "./OmniInstallAppButton";
@ -27,6 +29,7 @@ export default function AppCard({
teamId?: number;
LockedIcon?: React.ReactNode;
}) {
const { t } = useTranslation();
const [animationRef] = useAutoAnimate<HTMLDivElement>();
const { setAppData, LockedIcon, disabled } = useAppContextWithSchema();
@ -111,8 +114,23 @@ export default function AppCard({
</div>
<div ref={animationRef}>
{app?.isInstalled && switchChecked && <hr className="border-subtle" />}
{app?.isInstalled && switchChecked ? (
<div className="p-4 pt-5 text-sm [&_input]:mb-0 [&_input]:leading-4">{children}</div>
app.isSetupAlready ? (
<div className="relative p-4 pt-5 text-sm [&_input]:mb-0 [&_input]:leading-4">
<Link href={`/apps/${app.slug}/setup`} className="absolute right-4 top-4">
<Settings className="text-default h-4 w-4" aria-hidden="true" />
</Link>
{children}
</div>
) : (
<div className="flex h-64 w-full flex-col items-center justify-center gap-4 ">
<p>{t("this_app_is_not_setup_already")}</p>
<Link href={`/apps/${app.slug}/setup`}>
<Button StartIcon={Settings}>{t("setup")}</Button>
</Link>
</div>
)
) : null}
</div>
</div>

View File

@ -0,0 +1,23 @@
import type { GetServerSidePropsContext } from "next";
export const AppSetupPageMap = {
alby: import("../../alby/pages/setup/_getServerSideProps"),
make: import("../../make/pages/setup/_getServerSideProps"),
zapier: import("../../zapier/pages/setup/_getServerSideProps"),
stripe: import("../../stripepayment/pages/setup/_getServerSideProps"),
};
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const { slug } = ctx.params || {};
if (typeof slug !== "string") return { notFound: true } as const;
if (!(slug in AppSetupPageMap)) return { props: {} };
const page = await AppSetupPageMap[slug as keyof typeof AppSetupPageMap];
if (!page.getServerSideProps) return { props: {} };
const props = await page.getServerSideProps(ctx);
return props;
};

View File

@ -1,21 +0,0 @@
import type { GetStaticPropsContext } from "next";
export const AppSetupPageMap = {
zapier: import("../../zapier/pages/setup/_getStaticProps"),
make: import("../../make/pages/setup/_getStaticProps"),
};
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
const { slug } = ctx.params || {};
if (typeof slug !== "string") return { notFound: true } as const;
if (!(slug in AppSetupPageMap)) return { props: {} };
const page = await AppSetupPageMap[slug as keyof typeof AppSetupPageMap];
if (!page.getStaticProps) return { props: {} };
const props = await page.getStaticProps(ctx);
return props;
};

View File

@ -3,6 +3,7 @@ import dynamic from "next/dynamic";
import { DynamicComponent } from "../../_components/DynamicComponent";
export const AppSetupMap = {
alby: dynamic(() => import("../../alby/pages/setup")),
"apple-calendar": dynamic(() => import("../../applecalendar/pages/setup")),
exchange: dynamic(() => import("../../exchangecalendar/pages/setup")),
"exchange2013-calendar": dynamic(() => import("../../exchange2013calendar/pages/setup")),
@ -12,6 +13,7 @@ export const AppSetupMap = {
make: dynamic(() => import("../../make/pages/setup")),
closecom: dynamic(() => import("../../closecom/pages/setup")),
sendgrid: dynamic(() => import("../../sendgrid/pages/setup")),
stripe: dynamic(() => import("../../stripepayment/pages/setup")),
paypal: dynamic(() => import("../../paypal/pages/setup")),
};

View File

@ -1,16 +1,42 @@
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import type { NextApiRequest, NextApiResponse } from "next";
import appConfig from "../config.json";
import prisma from "@calcom/prisma";
const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
};
import config from "../config.json";
export default handler;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}
const appType = config.type;
try {
const alreadyInstalled = await prisma.credential.findFirst({
where: {
type: appType,
userId: req.session.user.id,
},
});
if (alreadyInstalled) {
throw new Error("Already installed");
}
const installation = await prisma.credential.create({
data: {
type: appType,
key: {},
userId: req.session.user.id,
appId: "alby",
},
});
if (!installation) {
throw new Error("Unable to create user credential for Alby");
}
} catch (error: unknown) {
if (error instanceof Error) {
return res.status(500).json({ message: error.message });
}
return res.status(500);
}
return res.status(200).json({ url: "/apps/alby/setup" });
}

View File

@ -1 +1,2 @@
export { default as add } from "./add";
export { default as webhook, config } from "@calcom/web/pages/api/integrations/alby/webhook";

View File

@ -0,0 +1,125 @@
import type { NextApiRequest, NextApiResponse } from "next";
import getRawBody from "raw-body";
import * as z from "zod";
import { albyCredentialKeysSchema } from "@calcom/app-store/alby/lib";
import parseInvoice from "@calcom/app-store/alby/lib/parseInvoice";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
import prisma from "@calcom/prisma";
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") {
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const bodyRaw = await getRawBody(req);
const headers = req.headers;
const bodyAsString = bodyRaw.toString();
const parseHeaders = webhookHeadersSchema.safeParse(headers);
if (!parseHeaders.success) {
console.error(parseHeaders.error);
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
}
const { data: parsedHeaders } = parseHeaders;
const parse = eventSchema.safeParse(JSON.parse(bodyAsString));
if (!parse.success) {
console.error(parse.error);
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
}
const { data: parsedPayload } = parse;
if (parsedPayload.metadata?.payer_data?.appId !== "cal.com") {
throw new HttpCode({ statusCode: 204, message: "Payment not for cal.com" });
}
const payment = await prisma.payment.findFirst({
where: {
uid: parsedPayload.metadata.payer_data.referenceId,
},
select: {
id: true,
amount: true,
bookingId: true,
booking: {
select: {
user: {
select: {
credentials: {
where: {
type: "alby_payment",
},
},
},
},
},
},
},
});
if (!payment) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
const key = payment.booking?.user?.credentials?.[0].key;
if (!key) throw new HttpCode({ statusCode: 204, message: "Credentials not found" });
const parseCredentials = albyCredentialKeysSchema.safeParse(key);
if (!parseCredentials.success) {
console.error(parseCredentials.error);
throw new HttpCode({ statusCode: 500, message: "Credentials not valid" });
}
const credentials = parseCredentials.data;
const albyInvoice = await parseInvoice(bodyAsString, parsedHeaders, credentials.webhook_endpoint_secret);
if (!albyInvoice) throw new HttpCode({ statusCode: 204, message: "Invoice not found" });
if (albyInvoice.amount !== payment.amount) {
throw new HttpCode({ statusCode: 400, message: "invoice amount does not match payment amount" });
}
return await handlePaymentSuccess(payment.id, payment.bookingId);
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
return res.status(err.statusCode || 500).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
});
}
}
const payerDataSchema = z
.object({
appId: z.string().optional(),
referenceId: z.string().optional(),
})
.optional();
const metadataSchema = z
.object({
payer_data: payerDataSchema,
})
.optional();
const eventSchema = z.object({
metadata: metadataSchema,
});
const webhookHeadersSchema = z
.object({
"svix-id": z.string(),
"svix-timestamp": z.string(),
"svix-signature": z.string(),
})
.passthrough();

View File

@ -0,0 +1,177 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import QRCode from "react-qr-code";
import z from "zod";
import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment";
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button } from "@calcom/ui";
import { showToast } from "@calcom/ui";
import { ClipboardCheck, Clipboard } from "@calcom/ui/components/icon";
import { Spinner } from "@calcom/ui/components/icon/Spinner";
interface IAlbyPaymentComponentProps {
payment: {
// Will be parsed on render
data: unknown;
};
paymentPageProps: PaymentPageProps;
}
// Create zod schema for data
const PaymentAlbyDataSchema = z.object({
invoice: z
.object({
paymentRequest: z.string(),
})
.required(),
});
export const AlbyPaymentComponent = (props: IAlbyPaymentComponentProps) => {
const { payment } = props;
const { data } = payment;
const [showQRCode, setShowQRCode] = useState(window.webln === undefined);
const [isPaying, setPaying] = useState(false);
const { copyToClipboard, isCopied } = useCopy();
const wrongUrl = (
<>
<p className="mt-3 text-center">Couldn&apos;t obtain payment URL</p>
</>
);
const parsedData = PaymentAlbyDataSchema.safeParse(data);
if (!parsedData.success || !parsedData.data?.invoice?.paymentRequest) {
return wrongUrl;
}
const paymentRequest = parsedData.data.invoice.paymentRequest;
return (
<div className="mb-4 mt-8 flex h-full w-full flex-col items-center justify-center gap-4">
<PaymentChecker {...props.paymentPageProps} />
{isPaying && <Spinner className="mt-12 h-8 w-8" />}
{!isPaying && (
<>
{!showQRCode && (
<div className="flex gap-4">
<Button color="secondary" onClick={() => setShowQRCode(true)}>
Show QR
</Button>
{window.webln && (
<Button
onClick={async () => {
try {
if (!window.webln) {
throw new Error("webln not found");
}
setPaying(true);
await window.webln.enable();
window.webln.sendPayment(paymentRequest);
} catch (error) {
setPaying(false);
alert((error as Error).message);
}
}}>
Pay Now
</Button>
)}
</div>
)}
{showQRCode && (
<>
<div className="flex items-center justify-center gap-2">
<p className="text-xs">Waiting for payment...</p>
<Spinner className="h-4 w-4" />
</div>
<p className="text-sm">Click or scan the invoice below to pay</p>
<Link
href={`lightning:${paymentRequest}`}
className="inline-flex items-center justify-center rounded-2xl rounded-md border border-transparent p-2
font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
<QRCode size={128} value={paymentRequest} />
</Link>
<Button
size="sm"
color="secondary"
onClick={() => copyToClipboard(paymentRequest)}
className="text-subtle rounded-md"
StartIcon={isCopied ? ClipboardCheck : Clipboard}>
Copy Invoice
</Button>
<Link target="_blank" href="https://getalby.com" className="link mt-4 text-sm underline">
Don&apos;t have a lightning wallet?
</Link>
</>
)}
</>
)}
<Link target="_blank" href="https://getalby.com">
<div className="mt-4 flex items-center text-sm">
Powered by
<img title="Alby" src="/app-store/alby/icon.svg" alt="Alby" className="h-8 w-8" />
Alby
</div>
</Link>
</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 = useSearchParams();
const bookingSuccessRedirect = useBookingSuccessRedirect();
const utils = trpc.useContext();
const { t } = useLocale();
useEffect(() => {
const interval = setInterval(() => {
(async () => {
if (props.booking.status === "ACCEPTED") {
return;
}
const { booking: bookingResult } = await utils.viewer.bookings.find.fetch({
bookingUid: props.booking.uid,
});
if (bookingResult?.paid) {
showToast("Payment successful", "success");
const params: {
uid: string;
email: string | null;
location: string;
} = {
uid: props.booking.uid,
email: searchParams.get("email"),
location: t("web_conferencing_details_to_follow"),
};
bookingSuccessRedirect({
successRedirectUrl: props.eventType.successRedirectUrl,
query: params,
booking: props.booking,
});
}
})();
}, 1000);
return () => clearInterval(interval);
}, [
bookingSuccessRedirect,
props.booking,
props.booking.id,
props.booking.status,
props.eventType.id,
props.eventType.successRedirectUrl,
props.payment.success,
searchParams,
t,
utils.viewer.bookings,
]);
return null;
}

View File

@ -0,0 +1,30 @@
import { fiat } from "@getalby/lightning-tools";
import React from "react";
import { Tooltip } from "@calcom/ui";
import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol";
type AlbyPriceComponentProps = {
displaySymbol: boolean;
price: number;
formattedPrice: string;
};
export function AlbyPriceComponent({ displaySymbol, price, formattedPrice }: AlbyPriceComponentProps) {
const [fiatValue, setFiatValue] = React.useState<string>("loading...");
React.useEffect(() => {
(async () => {
const unformattedFiatValue = await fiat.getFiatValue({ satoshi: price, currency: "USD" });
setFiatValue(`$${unformattedFiatValue.toFixed(2)}`);
})();
}, [price]);
return (
<Tooltip content={fiatValue}>
<div className="inline-flex items-center justify-center">
{displaySymbol && <SatSymbol className="h-4 w-4" />}
{formattedPrice}
</div>
</Tooltip>
);
}

View File

@ -0,0 +1,127 @@
import { useRouter } from "next/router";
import { useState, useEffect } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import { currencyOptions } from "@calcom/app-store/alby/lib/currencyOptions";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Select, TextField } from "@calcom/ui";
import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol";
import type { appDataSchema } from "../zod";
import { PaypalPaymentOptions as paymentOptions } from "../zod";
type Option = { value: string; label: string };
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const { asPath } = useRouter();
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const price = getAppData("price");
const currency = getAppData("currency");
const [selectedCurrency, setSelectedCurrency] = useState(
currencyOptions.find((c) => c.value === currency) || currencyOptions[0]
);
const paymentOption = getAppData("paymentOption");
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
label: paymentOptions[0].label,
value: paymentOptions[0].value,
};
const seatsEnabled = !!eventType.seatsPerTimeSlot;
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
const { t } = useLocale();
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
// make sure a currency is selected
useEffect(() => {
if (!currency && requirePayment) {
setAppData("currency", selectedCurrency.value);
}
}, [currency, selectedCurrency, setAppData, requirePayment]);
return (
<AppCard
returnTo={WEBAPP_URL + asPath}
app={app}
switchChecked={requirePayment}
switchOnClick={(enabled) => {
setRequirePayment(enabled);
}}
description={<>Add bitcoin lightning payments to your events</>}>
<>
{recurringEventDefined ? (
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
) : (
requirePayment && (
<>
<div className="mt-2 block items-center sm:flex">
<TextField
label="Price"
labelSrOnly
addOnLeading={<SatSymbol className="h-4 w-4" />}
addOnSuffix={selectedCurrency.unit || selectedCurrency.value}
type="number"
required
className="block w-full rounded-sm border-gray-300 pl-2 pr-12 text-sm"
placeholder="Price"
onChange={(e) => {
setAppData("price", Number(e.target.value));
if (currency) {
setAppData("currency", currency);
}
}}
value={price && price > 0 ? price : undefined}
/>
</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="currency">
Payment option
</label>
<Select<Option>
defaultValue={
paymentOptionSelectValue
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
: { ...paymentOptions[0], label: t(paymentOptions[0].label) }
}
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")} />
)}
</>
)
)}
</>
</AppCard>
);
};
export default EventTypeAppCard;

View File

@ -4,14 +4,15 @@
"slug": "alby",
"type": "alby_payment",
"logo": "icon.svg",
"url": "https://example.com/link",
"url": "https://getalby.com",
"variant": "payment",
"categories": ["payment"],
"publisher": "Alby",
"email": "support@getalby.com",
"description": "Your Bitcoin & Nostr companion for the web. Use Alby to charge Satoshi for your Cal.com meetings.\r",
"extendsFeature": "EventType",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic",
"__template": "event-type-app-card",
"dirName": "alby"
}

View File

@ -1 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";

View File

@ -0,0 +1,133 @@
import { LightningAddress } from "@getalby/lightning-tools";
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import type z from "zod";
import prisma from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
import { albyCredentialKeysSchema } from "./albyCredentialKeysSchema";
export class PaymentService implements IAbstractPaymentService {
private credentials: z.infer<typeof albyCredentialKeysSchema> | null;
constructor(credentials: { key: Prisma.JsonValue }) {
const keyParsing = albyCredentialKeysSchema.safeParse(credentials.key);
if (keyParsing.success) {
this.credentials = keyParsing.data;
} else {
this.credentials = null;
}
}
async create(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"]
) {
try {
const booking = await prisma.booking.findFirst({
select: {
uid: true,
title: true,
},
where: {
id: bookingId,
},
});
if (!booking || !this.credentials?.account_lightning_address) {
throw new Error();
}
const uid = uuidv4();
const lightningAddress = new LightningAddress(this.credentials.account_lightning_address);
await lightningAddress.fetch();
const invoice = await lightningAddress.requestInvoice({
satoshi: payment.amount,
payerdata: {
appId: "cal.com",
referenceId: uid,
},
});
console.log("Created invoice", invoice, uid);
const paymentData = await prisma.payment.create({
data: {
uid,
app: {
connect: {
slug: "alby",
},
},
booking: {
connect: {
id: bookingId,
},
},
amount: payment.amount,
externalId: invoice.paymentRequest,
currency: payment.currency,
data: Object.assign({}, { invoice }) as unknown as Prisma.InputJsonValue,
fee: 0,
refunded: false,
success: false,
},
});
if (!paymentData) {
throw new Error();
}
return paymentData;
} catch (error) {
console.error(error);
throw new Error("Payment could not be created");
}
}
async update(): Promise<Payment> {
throw new Error("Method not implemented.");
}
async refund(): Promise<Payment> {
throw new Error("Method not implemented.");
}
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.");
}
getPaymentPaidStatus(): Promise<string> {
throw new Error("Method not implemented.");
}
getPaymentDetails(): Promise<Payment> {
throw new Error("Method not implemented.");
}
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;
}
}

View File

@ -0,0 +1,9 @@
import z from "zod";
export const albyCredentialKeysSchema = z.object({
account_id: z.string(),
account_email: z.string(),
account_lightning_address: z.string(),
webhook_endpoint_id: z.string(),
webhook_endpoint_secret: z.string(),
});

View File

@ -0,0 +1 @@
export const currencyOptions = [{ label: "BTC", value: "BTC", unit: "sats" }];

View File

@ -0,0 +1,13 @@
import { z } from "zod";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
export const getAlbyKeys = async () => {
const appKeys = await getAppKeysFromSlug("alby");
return appKeysSchema.parse(appKeys);
};
const appKeysSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
});

View File

@ -0,0 +1,6 @@
import type { Invoice as AlbyInvoice } from "@getalby/sdk/dist/types";
export * from "./PaymentService";
export * from "./albyCredentialKeysSchema";
export type { AlbyInvoice };

View File

@ -0,0 +1,22 @@
import type { Invoice } from "@getalby/sdk/dist/types";
import { Webhook } from "svix";
export default function parseInvoice(
body: string,
headers: {
"svix-id": string;
"svix-timestamp": string;
"svix-signature": string;
},
webhookEndpointSecret: string
): Invoice | null {
try {
const wh = new Webhook(webhookEndpointSecret);
return wh.verify(body, headers) as Invoice;
} catch (err) {
// Looks like alby might sent multiple webhooks for the same invoice but it should only work once
// TODO: remove the Alby webhook when uninstalling the Alby app
console.error(err);
}
return null;
}

View File

@ -5,7 +5,12 @@
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
"@calcom/lib": "*",
"@getalby/lightning-tools": "^4.0.2",
"@getalby/sdk": "^2.4.0",
"@webbtc/webln-types": "^2.0.1",
"react-qr-code": "^2.0.12",
"svix": "^0.85.1"
},
"devDependencies": {
"@calcom/types": "*"

View File

@ -0,0 +1,49 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getAlbyKeys } from "../../lib/getAlbyKeys";
import type { IAlbySetupProps } from "./index";
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
const { req, res } = ctx;
const session = await getServerSession({ req, res });
if (!session?.user?.id) {
return res.writeHead(401).end();
}
const credentials = await prisma.credential.findFirst({
where: {
type: "alby_payment",
userId: session.user.id,
},
});
const { client_id: clientId, client_secret: clientSecret } = await getAlbyKeys();
const props: IAlbySetupProps = {
email: null,
lightningAddress: null,
clientId,
clientSecret,
};
if (credentials?.key) {
const { account_lightning_address, account_email } = credentials.key as {
account_lightning_address?: string;
account_email?: string;
};
if (account_lightning_address) {
props.lightningAddress = account_lightning_address;
}
if (account_email) {
props.email = account_email;
}
}
return {
props,
};
};

View File

@ -0,0 +1,178 @@
import { auth, Client, webln } from "@getalby/sdk";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useState, useCallback, useEffect } from "react";
import { Toaster } from "react-hot-toast";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Badge, Button, showToast } from "@calcom/ui";
import { Info } from "@calcom/ui/components/icon";
import { albyCredentialKeysSchema } from "../../lib/albyCredentialKeysSchema";
export interface IAlbySetupProps {
email: string | null;
lightningAddress: string | null;
clientId: string;
clientSecret: string;
}
export default function AlbySetup(props: IAlbySetupProps) {
const params = useSearchParams();
if (params?.get("callback") === "true") {
return <AlbySetupCallback />;
}
return <AlbySetupPage {...props} />;
}
function AlbySetupCallback() {
const [error, setError] = useState<string | null>(null);
const params = useSearchParams();
useEffect(() => {
if (!window.opener) {
setError("Something went wrong. Opener not available. Please contact support@getalby.com");
return;
}
const code = params.get("code");
const error = params.get("error");
if (!code) {
setError("declined");
}
if (error) {
setError(error);
alert(error);
return;
}
window.opener.postMessage({
type: "alby:oauth:success",
payload: { code },
});
window.close();
}, []);
return (
<div>
{error && <p>Authorization failed: {error}</p>}
{!error && <p>Connecting...</p>}
</div>
);
}
function AlbySetupPage(props: IAlbySetupProps) {
const router = useRouter();
const { t } = useLocale();
const integrations = trpc.viewer.integrations.useQuery({ variant: "payment", appId: "alby" });
const [albyPaymentAppCredentials] = integrations.data?.items || [];
const [credentialId] = albyPaymentAppCredentials?.userCredentialIds || [-1];
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
const saveKeysMutation = trpc.viewer.appsRouter.updateAppCredentials.useMutation({
onSuccess: () => {
showToast(t("keys_have_been_saved"), "success");
router.push("/event-types");
},
onError: (error) => {
showToast(error.message, "error");
},
});
const connectWithAlby = useCallback(async () => {
const authClient = new auth.OAuth2User({
client_id: props.clientId,
client_secret: props.clientSecret,
callback: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/apps/alby/setup?callback=true`,
scopes: ["invoices:read", "account:read"],
user_agent: "cal.com",
});
const weblnOAuthProvider = new webln.OauthWeblnProvider({
auth: authClient,
});
await weblnOAuthProvider.enable();
const client = new Client(authClient);
const accountInfo = await client.accountInformation({});
// TODO: add a way to delete the endpoint when the app is uninstalled
const webhookEndpoint = await client.createWebhookEndpoint({
filter_types: ["invoice.incoming.settled"],
url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/integrations/alby/webhook`,
description: "Cal.com",
});
saveKeysMutation.mutate({
credentialId,
key: albyCredentialKeysSchema.parse({
account_id: accountInfo.identifier,
account_email: accountInfo.email,
account_lightning_address: accountInfo.lightning_address,
webhook_endpoint_id: webhookEndpoint.id,
webhook_endpoint_secret: webhookEndpoint.endpoint_secret,
}),
});
}, [credentialId, props.clientId, props.clientSecret, saveKeysMutation]);
if (integrations.isLoading) {
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
}
return (
<div className="bg-default flex h-screen">
{showContent ? (
<div className="flex w-full items-center justify-center p-4">
<div className="bg-default border-subtle m-auto flex max-w-[43em] flex-col items-center justify-center gap-4 overflow-auto rounded border p-4 md:p-10">
{!props.lightningAddress ? (
<>
<p className="text-default">
Create or connect to an existing Alby account to receive lightning payments for your paid
bookings.
</p>
<button
className="font-body flex h-10 w-56 items-center justify-center gap-2 rounded-md font-bold text-black shadow transition-all hover:brightness-90 active:scale-95"
style={{
background: "linear-gradient(180deg, #FFDE6E 63.72%, #F8C455 95.24%)",
}}
type="button"
onClick={connectWithAlby}>
<img className="h-8 w-8" src="/api/app-store/alby/icon2.svg" alt="Alby Logo" />
<span className="mr-2">Connect with Alby</span>
</button>
</>
) : (
<>
<img className="h-16 w-16" src="/api/app-store/alby/icon2.svg" alt="Alby Logo" />
<p>Alby Connected!</p>
<Badge>Email: {props.email}</Badge>
<Badge>Lightning Address: {props.lightningAddress}</Badge>
</>
)}
{/* TODO: remove when invoices are generated using user identifier */}
<div className="mt-4 rounded bg-blue-50 p-3 text-sm text-blue-700">
<Info className="mb-0.5 inline-flex h-4 w-4" /> Your Alby lightning address will be used to
generate invoices. If you update your lightning address, please disconnect and setup the Alby
app again.
</div>
<Link href="/apps/alby">
<Button color="secondary">Go to App Store Listing</Button>
</Link>
</div>
</div>
) : (
<div className="ml-5 mt-5">
<div>Alby</div>
<div className="mt-3">
<Link href="/apps/alby" passHref={true} legacyBehavior>
<Button>{t("go_to_app_store")}</Button>
</Link>
</div>
</div>
)}
<Toaster position="bottom-right" />
</div>
);
}

View File

@ -0,0 +1,11 @@
<svg width="554" height="554" viewBox="0 0 554 554" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M127.7 202.4C107.431 202.4 90.9999 185.969 90.9999 165.7C90.9999 145.431 107.431 129 127.7 129C147.969 129 164.4 145.431 164.4 165.7C164.4 185.969 147.969 202.4 127.7 202.4Z" fill="black"/>
<path d="M121.6 160.2L190.1 228.7" stroke="black" stroke-width="18.3"/>
<path d="M427.2 202.4C447.469 202.4 463.9 185.969 463.9 165.7C463.9 145.431 447.469 129 427.2 129C406.931 129 390.5 145.431 390.5 165.7C390.5 185.969 406.931 202.4 427.2 202.4Z" fill="black"/>
<path d="M434 160.2L365.5 228.7" stroke="black" stroke-width="18.3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M138.1 384.6C128.451 380.122 120.543 372.597 115.592 363.182C110.641 353.768 108.922 342.987 110.7 332.5C127.3 240 196.2 170.6 278.7 170.6C361.4 170.6 430.5 240.3 446.8 333.2C448.539 343.695 446.779 354.47 441.792 363.867C436.805 373.263 428.866 380.759 419.2 385.2C375.441 405.799 327.664 416.454 279.3 416.4C228.8 416.4 180.9 405 138.1 384.6Z" fill="#FFDF6F"/>
<path d="M119.8 334.2C135.8 244.7 201.8 179.8 278.8 179.8V161.5C190.6 161.5 118.8 235.3 101.7 330.9L119.7 334.1L119.8 334.2ZM278.8 179.8C355.8 179.8 422 245.1 437.8 334.8L455.8 331.6C439 235.6 367 161.5 278.8 161.5V179.8ZM415.3 376.9C372.76 396.917 326.314 407.265 279.3 407.2V425.5C330.7 425.5 379.4 414 423.1 393.5L415.3 376.9ZM279.3 407.2C230.2 407.2 183.7 396.1 142.1 376.3L134.2 392.9C178.2 413.9 227.4 425.5 279.3 425.5V407.2ZM437.8 334.8C440.8 352.1 431.6 369.3 415.3 376.9L423.1 393.5C446.5 382.5 460.4 357.5 455.8 331.5L437.8 334.8ZM101.7 330.8C99.5558 343.269 101.577 356.098 107.451 367.303C113.325 378.509 122.725 387.47 134.2 392.8L142.1 376.3C134.267 372.688 127.84 366.599 123.81 358.973C119.78 351.347 118.371 342.606 119.8 334.1L101.6 330.9L101.7 330.8Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M173.4 361.6C157.5 355.1 148.1 338.2 153.6 321.9C170.6 271.7 220.2 235.4 278.7 235.4C337.2 235.4 386.8 271.7 403.8 321.9C409.4 338.2 399.9 355.1 384 361.6C350.559 375.19 314.796 382.153 278.7 382.1C241.5 382.1 205.9 374.8 173.4 361.6Z" fill="black"/>
<path d="M320.9 338.1C337.8 338.1 351.5 327.176 351.5 313.7C351.5 300.224 337.8 289.3 320.9 289.3C304 289.3 290.3 300.224 290.3 313.7C290.3 327.176 304 338.1 320.9 338.1Z" fill="white"/>
<path d="M233.4 338.1C250.3 338.1 264 327.176 264 313.7C264 300.224 250.3 289.3 233.4 289.3C216.5 289.3 202.8 300.224 202.8 313.7C202.8 327.176 216.5 338.1 233.4 338.1Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,37 @@
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 PaypalPaymentOptions = [
{
label: "on_booking_option",
value: "ON_BOOKING",
},
];
type PaymentOption = (typeof PaypalPaymentOptions)[number]["value"];
const VALUES: [PaymentOption, ...PaymentOption[]] = [
PaypalPaymentOptions[0].value,
...PaypalPaymentOptions.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(),
})
);
export const appKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});

View File

@ -20,6 +20,7 @@ export const AppSettingsComponentsMap = {
zapier: dynamic(() => import("./zapier/components/AppSettingsInterface")),
};
export const EventTypeAddonMap = {
alby: dynamic(() => import("./alby/components/EventTypeAppCardInterface")),
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")),
fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")),
ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),

View File

@ -2,6 +2,7 @@
This file is autogenerated using the command `yarn app-store:build --watch`.
Don't modify this file manually.
**/
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
import { appKeysSchema as fathom_zod_ts } from "./fathom/zod";
@ -35,6 +36,7 @@ import { appKeysSchema as zohocrm_zod_ts } from "./zohocrm/zod";
import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appKeysSchemas = {
alby: alby_zod_ts,
basecamp3: basecamp3_zod_ts,
dailyvideo: dailyvideo_zod_ts,
fathom: fathom_zod_ts,

View File

@ -2,6 +2,7 @@
This file is autogenerated using the command `yarn app-store:build --watch`.
Don't modify this file manually.
**/
import { appDataSchema as alby_zod_ts } from "./alby/zod";
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
import { appDataSchema as fathom_zod_ts } from "./fathom/zod";
@ -35,6 +36,7 @@ import { appDataSchema as zohocrm_zod_ts } from "./zohocrm/zod";
import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appDataSchemas = {
alby: alby_zod_ts,
basecamp3: basecamp3_zod_ts,
dailyvideo: dailyvideo_zod_ts,
fathom: fathom_zod_ts,

View File

@ -27,6 +27,10 @@ export const projectHandler = async ({ ctx }: ProjectsHandlerOptions) => {
throw new TRPCError({ code: "FORBIDDEN", message: "No credential found for user" });
}
let credentialKey = credential.key as BasecampToken;
if (!credentialKey.account) {
return;
}
if (credentialKey.expires_at < Date.now()) {
credentialKey = (await refreshAccessToken(credential)) as BasecampToken;
}

View File

@ -1,5 +1,6 @@
const appStore = {
// example: () => import("./example"),
alby: () => import("./alby"),
applecalendar: () => import("./applecalendar"),
aroundvideo: () => import("./around"),
caldavcalendar: () => import("./caldavcalendar"),

View File

@ -1,4 +1,4 @@
import type { GetStaticPropsContext } from "next";
import type { GetServerSidePropsContext } from "next";
import getAppKeysFromSlug from "../../../_utils/getAppKeysFromSlug";
@ -6,7 +6,7 @@ export interface IMakeSetupProps {
inviteLink: string;
}
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
let inviteLink = "";
const appKeys = await getAppKeysFromSlug("make");

View File

@ -1,4 +1,4 @@
import type { InferGetStaticPropsType } from "next";
import type { InferGetServerSidePropsType } from "next";
import { Trans } from "next-i18next";
import Link from "next/link";
import { useState } from "react";
@ -9,11 +9,11 @@ import { trpc } from "@calcom/trpc/react";
import { Button, Tooltip, showToast } from "@calcom/ui";
import { Clipboard } from "@calcom/ui/components/icon";
import type { getStaticProps } from "./_getStaticProps";
import type { getServerSideProps } from "./_getServerSideProps";
const MAKE = "make";
export default function MakeSetup({ inviteLink }: InferGetStaticPropsType<typeof getStaticProps>) {
export default function MakeSetup({ inviteLink }: InferGetServerSidePropsType<typeof getServerSideProps>) {
const [newApiKeys, setNewApiKeys] = useState<Record<string, string>>({});
const { t } = useLocale();

View File

@ -1,8 +1,8 @@
import type { NextApiRequest, NextApiResponse } from "next";
import z from "zod";
import { findPaymentCredentials } from "@calcom/app-store/paypal/api/webhook";
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
import { findPaymentCredentials } from "@calcom/features/ee/payments/api/paypal-webhook";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";

View File

@ -1,23 +1,14 @@
import type { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import getRawBody from "raw-body";
import * as z from "zod";
import { paypalCredentialKeysSchema } from "@calcom/app-store/paypal/lib";
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
import EventManager from "@calcom/core/EventManager";
import { sendScheduledEmails } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
import prisma from "@calcom/prisma";
export const config = {
api: {
@ -25,19 +16,7 @@ export const config = {
},
};
async function getEventType(id: number) {
return prisma.eventType.findUnique({
where: {
id,
},
select: {
recurringEvent: true,
requiresConfirmation: true,
},
});
}
export async function handlePaymentSuccess(
export async function handlePaypalPaymentSuccess(
payload: z.infer<typeof eventSchema>,
rawPayload: string,
webhookHeaders: WebHookHeadersType
@ -49,7 +28,6 @@ export async function handlePaymentSuccess(
select: {
id: true,
bookingId: true,
success: true,
},
});
@ -60,31 +38,7 @@ export async function handlePaymentSuccess(
id: payment.bookingId,
},
select: {
...bookingMinimalSelect,
eventType: true,
smsReminderNumber: true,
location: true,
eventTypeId: true,
userId: true,
uid: true,
paid: true,
destinationCalendar: true,
status: true,
responses: true,
user: {
select: {
id: true,
username: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
timeZone: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
id: true,
},
});
@ -108,106 +62,7 @@ export async function handlePaymentSuccess(
},
});
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
let eventTypeRaw: EventTypeRaw | null = null;
if (booking.eventTypeId) {
eventTypeRaw = await getEventType(booking.eventTypeId);
}
const { user } = booking;
if (!user) throw new HttpCode({ statusCode: 204, message: "No user found" });
const t = await getTranslation(user.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
booking: booking,
bookingFields: booking.eventType?.bookingFields || null,
}),
organizer: {
email: user.email,
name: user.name!,
timeZone: user.timeZone,
language: { translate: t, locale: user.locale ?? "en" },
},
attendees: attendeesList,
uid: booking.uid,
destinationCalendar: booking.destinationCalendar
? [booking.destinationCalendar]
: user.destinationCalendar
? [user.destinationCalendar]
: [],
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
};
if (booking.location) evt.location = booking.location;
const bookingData: Prisma.BookingUpdateInput = {
paid: true,
status: BookingStatus.ACCEPTED,
};
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
if (isConfirmed) {
const eventManager = new EventManager(user);
const scheduleResult = await eventManager.create(evt);
bookingData.references = { create: scheduleResult.referencesToCreate };
}
if (eventTypeRaw?.requiresConfirmation) {
delete bookingData.status;
}
if (!payment?.success) {
await prisma.payment.update({
where: {
id: payment.id,
},
data: {
success: true,
},
});
}
if (booking.status === "PENDING") {
await prisma.booking.update({
where: {
id: booking.id,
},
data: bookingData,
});
}
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) {
await handleConfirmation({ user, evt, prisma, bookingId: booking.id, booking, paid: true });
} else {
await sendScheduledEmails({ ...evt });
}
throw new HttpCode({
statusCode: 200,
message: `Booking with id '${booking.id}' was paid and confirmed.`,
});
return await handlePaymentSuccess(payment.id, payment.bookingId);
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@ -234,7 +89,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { data: parsedPayload } = parse;
if (parsedPayload.event_type === "CHECKOUT.ORDER.APPROVED") {
return await handlePaymentSuccess(parsedPayload, bodyAsString, parseHeaders.data);
return await handlePaypalPaymentSuccess(parsedPayload, bodyAsString, parseHeaders.data);
}
} catch (_err) {
const err = getErrorFromUnknown(_err);

View File

@ -25,6 +25,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
value: currencyOptions[0].value,
}
);
const paymentOption = getAppData("paymentOption");
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
label: paymentOptions[0].label,
@ -55,7 +56,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
label="Price"
labelSrOnly
addOnLeading="$"
addOnSuffix={currency || "No selected currency"}
addOnSuffix={selectedCurrency.value || "No selected currency"}
step="0.01"
min="0.5"
type="number"
@ -64,8 +65,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
placeholder="Price"
onChange={(e) => {
setAppData("price", Number(e.target.value) * 100);
if (currency) {
setAppData("currency", currency);
if (selectedCurrency) {
setAppData("currency", selectedCurrency.value);
}
}}
value={price > 0 ? price / 100 : undefined}

View File

@ -17,10 +17,15 @@ export const paypalCredentialKeysSchema = z.object({
});
export class PaymentService implements IAbstractPaymentService {
private credentials: z.infer<typeof paypalCredentialKeysSchema>;
private credentials: z.infer<typeof paypalCredentialKeysSchema> | null;
constructor(credentials: { key: Prisma.JsonValue }) {
this.credentials = paypalCredentialKeysSchema.parse(credentials.key);
const keyParsing = paypalCredentialKeysSchema.safeParse(credentials.key);
if (keyParsing.success) {
this.credentials = keyParsing.data;
} else {
this.credentials = null;
}
}
async create(
@ -37,7 +42,7 @@ export class PaymentService implements IAbstractPaymentService {
id: bookingId,
},
});
if (!booking) {
if (!booking || !this.credentials) {
throw new Error();
}
@ -113,7 +118,7 @@ export class PaymentService implements IAbstractPaymentService {
id: bookingId,
},
});
if (!booking) {
if (!booking || !this.credentials) {
throw new Error();
}
@ -192,4 +197,8 @@ export class PaymentService implements IAbstractPaymentService {
deletePayment(paymentId: number): Promise<boolean> {
return Promise.resolve(false);
}
isSetupAlready(): boolean {
return !!this.credentials;
}
}

View File

@ -33,6 +33,7 @@ export const appDataSchema = eventTypeAppCardZod.merge(
price: z.number(),
currency: z.string(),
paymentOption: z.string().optional(),
enabled: z.boolean().optional(),
})
);
export const appKeysSchema = z.object({});

View File

@ -3,7 +3,6 @@ import { useState } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -11,9 +10,9 @@ import { Alert, Select, TextField } from "@calcom/ui";
import { paymentOptions } from "../lib/constants";
import {
convertFromSmallestToPresentableCurrencyUnit,
convertToSmallestCurrencyUnit,
} from "../lib/currencyConvertions";
convertFromSmallestToPresentableCurrencyUnit,
} from "../lib/currencyConversions";
import { currencyOptions } from "../lib/currencyOptions";
import type { appDataSchema } from "../zod";
@ -26,13 +25,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
const currency = getAppData("currency");
const [selectedCurrency, setSelectedCurrency] = useState(
currencyOptions.find((c) => c.value === currency) || {
label: currencyOptions[0].label,
value: currencyOptions[0].value,
label: "",
value: "",
}
);
const paymentOption = getAppData("paymentOption");
const paymentOptionSelectValue = paymentOptions.find((option) => paymentOption === option.value);
const { enabled: requirePayment, updateEnabled: setRequirePayment } = useIsAppEnabled(app);
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
const { t } = useLocale();
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;

View File

@ -14,7 +14,7 @@ import { createPaymentLink } from "./client";
import { retrieveOrCreateStripeCustomerByEmail } from "./customer";
import type { StripePaymentData, StripeSetupIntentData } from "./server";
const stripeCredentialKeysSchema = z.object({
export const stripeCredentialKeysSchema = z.object({
stripe_user_id: z.string(),
default_currency: z.string(),
stripe_publishable_key: z.string(),
@ -28,11 +28,15 @@ const stripeAppKeysSchema = z.object({
export class PaymentService implements IAbstractPaymentService {
private stripe: Stripe;
private credentials: z.infer<typeof stripeCredentialKeysSchema>;
private credentials: z.infer<typeof stripeCredentialKeysSchema> | null;
constructor(credentials: { key: Prisma.JsonValue }) {
// parse credentials key
this.credentials = stripeCredentialKeysSchema.parse(credentials.key);
const keyParsing = stripeCredentialKeysSchema.safeParse(credentials.key);
if (keyParsing.success) {
this.credentials = keyParsing.data;
} else {
this.credentials = null;
}
this.stripe = new Stripe(process.env.STRIPE_PRIVATE_KEY || "", {
apiVersion: "2020-08-27",
});
@ -63,15 +67,9 @@ export class PaymentService implements IAbstractPaymentService {
throw new Error("Payment option is not compatible with create method");
}
// Load stripe keys
const stripeAppKeys = await prisma.app.findFirst({
select: {
keys: true,
},
where: {
slug: "stripe",
},
});
if (!this.credentials) {
throw new Error("Stripe credentials not found");
}
const customer = await retrieveOrCreateStripeCustomerByEmail(
bookerEmail,
@ -142,21 +140,15 @@ export class PaymentService implements IAbstractPaymentService {
paymentOption: PaymentOption
): Promise<Payment> {
try {
if (!this.credentials) {
throw new Error("Stripe credentials not found");
}
// Ensure that the payment service can support the passed payment option
if (paymentOptionEnum.parse(paymentOption) !== "HOLD") {
throw new Error("Payment option is not compatible with create method");
}
// Load stripe keys
const stripeAppKeys = await prisma.app.findFirst({
select: {
keys: true,
},
where: {
slug: "stripe",
},
});
const customer = await retrieveOrCreateStripeCustomerByEmail(
bookerEmail,
this.credentials.stripe_user_id
@ -214,6 +206,10 @@ export class PaymentService implements IAbstractPaymentService {
async chargeCard(payment: Payment, _bookingId?: Booking["id"]): Promise<Payment> {
try {
if (!this.credentials) {
throw new Error("Stripe credentials not found");
}
const stripeAppKeys = await prisma.app.findFirst({
select: {
keys: true,
@ -385,4 +381,8 @@ export class PaymentService implements IAbstractPaymentService {
getPaymentDetails(): Promise<Payment> {
throw new Error("Method not implemented.");
}
isSetupAlready(): boolean {
return !!this.credentials;
}
}

View File

@ -0,0 +1,12 @@
import type { GetServerSidePropsContext } from "next";
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
const targetUrl = "https://dashboard.stripe.com/settings/connect";
return {
redirect: {
destination: targetUrl,
permanent: false,
},
};
};

View File

@ -0,0 +1,3 @@
export default function StripePaymentSetup() {
return null;
}

View File

@ -17,6 +17,7 @@ export const appDataSchema = eventTypeAppCardZod.merge(
price: z.number(),
currency: z.string(),
paymentOption: paymentOptionEnum.optional(),
enabled: z.boolean().optional(),
})
);

View File

@ -1,4 +1,4 @@
import type { GetStaticPropsContext } from "next";
import type { GetServerSidePropsContext } from "next";
import getAppKeysFromSlug from "../../../_utils/getAppKeysFromSlug";
@ -6,7 +6,7 @@ export interface IZapierSetupProps {
inviteLink: string;
}
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
if (typeof ctx.params?.slug !== "string") return { notFound: true } as const;
let inviteLink = "";
const appKeys = await getAppKeysFromSlug("zapier");

View File

@ -1,16 +1,14 @@
{
"name": "Zoho Calendar",
"description": "Zoho Calendar is an online business calendar that makes scheduling easy for you. You can use it to stay on top of your schedule and also share calendars with your team to keep everyone on the same page.",
"slug": "zohocalendar",
"type": "zoho_calendar",
"title": "Zoho Calendar",
"variant": "calendar",
"category": "calendar",
"categories": [
"calendar"
],
"logo": "icon.svg",
"publisher": "Cal.com",
"url": "https://cal.com/",
"email": "help@cal.com"
}
"name": "Zoho Calendar",
"description": "Zoho Calendar is an online business calendar that makes scheduling easy for you. You can use it to stay on top of your schedule and also share calendars with your team to keep everyone on the same page.",
"slug": "zohocalendar",
"type": "zoho_calendar",
"title": "Zoho Calendar",
"variant": "calendar",
"category": "calendar",
"categories": ["calendar"],
"logo": "icon.svg",
"publisher": "Cal.com",
"url": "https://cal.com/",
"email": "help@cal.com"
}

View File

@ -1,6 +1,7 @@
import type { TFunction } from "next-i18next";
import dayjs from "@calcom/dayjs";
import { formatPrice } from "@calcom/lib/price";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@ -87,11 +88,12 @@ export const BaseScheduledEmail = (
<UserFieldsResponses calEvent={props.calEvent} />
{props.calEvent.paymentInfo?.amount && (
<Info
label={props.calEvent.paymentInfo?.paymentOption === "HOLD" ? t("no_show_fee") : t("price")}
description={new Intl.NumberFormat(props.attendee.language.locale, {
style: "currency",
currency: props.calEvent.paymentInfo?.currency || "USD",
}).format(props.calEvent.paymentInfo?.amount / 100.0)}
label={props.calEvent.paymentInfo.paymentOption === "HOLD" ? t("no_show_fee") : t("price")}
description={formatPrice(
props.calEvent.paymentInfo.amount,
props.calEvent.paymentInfo.currency,
props.attendee.language.locale
)}
withSpacer
/>
)}

View File

@ -4,14 +4,15 @@ import React from "react";
import classNames from "@calcom/lib/classNames";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Clock, CheckSquare, RefreshCcw, CreditCard } from "@calcom/ui/components/icon";
import { Clock, CheckSquare, RefreshCcw } from "@calcom/ui/components/icon";
import type { PublicEvent } from "../../types";
import { EventDetailBlocks } from "../../types";
import { AvailableEventLocations } from "./AvailableEventLocations";
import { EventDuration } from "./Duration";
import { EventOccurences } from "./Occurences";
import { EventPrice } from "./Price";
import { Price } from "./Price";
import { getPriceIcon } from "./getPriceIcon";
type EventDetailsPropsBase = {
event: PublicEvent;
@ -156,8 +157,12 @@ export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: Even
if (event.price <= 0 || paymentAppData.price <= 0) return null;
return (
<EventMetaBlock key={block} icon={CreditCard}>
<EventPrice event={event} />
<EventMetaBlock key={block} icon={getPriceIcon(event.currency)}>
<Price
price={paymentAppData.price}
currency={event.currency}
displayAlternateSymbol={false}
/>
</EventMetaBlock>
);
}

View File

@ -1,18 +1,28 @@
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import dynamic from "next/dynamic";
import type { PublicEvent } from "../../types";
import { formatPrice } from "@calcom/lib/price";
export const EventPrice = ({ event }: { event: PublicEvent }) => {
const stripeAppData = getPaymentAppData(event);
import type { EventPrice } from "../../types";
if (stripeAppData.price === 0) return null;
const AlbyPriceComponent = dynamic(
() => import("@calcom/app-store/alby/components/AlbyPriceComponent").then((m) => m.AlbyPriceComponent),
{
ssr: false,
}
);
return (
<>
{Intl.NumberFormat("en", {
style: "currency",
currency: stripeAppData.currency.toUpperCase(),
}).format(stripeAppData.price / 100.0)}
</>
export const Price = ({ price, currency, displayAlternateSymbol = true }: EventPrice) => {
if (price === 0) return null;
const formattedPrice = formatPrice(price, currency);
return currency !== "BTC" ? (
<>{formattedPrice}</>
) : (
<AlbyPriceComponent
displaySymbol={displayAlternateSymbol}
price={price}
formattedPrice={formattedPrice}
/>
);
};

View File

@ -0,0 +1,5 @@
import { CreditCard, Zap } from "lucide-react";
export function getPayIcon(currency: string): React.FC<{ className: string }> | string {
return currency !== "BTC" ? CreditCard : Zap;
}

View File

@ -0,0 +1,7 @@
import { CreditCard } from "lucide-react";
import { SatSymbol } from "@calcom/ui/components/icon/SatSymbol";
export function getPriceIcon(currency: string): React.FC<{ className: string }> | string {
return currency !== "BTC" ? CreditCard : (SatSymbol as React.FC<{ className: string }>);
}

View File

@ -2,3 +2,5 @@ export { EventDetails, EventMetaBlock } from "./Details";
export { EventTitle } from "./Title";
export { EventMetaSkeleton } from "./Skeleton";
export { EventMembers } from "./Members";
export { getPriceIcon } from "./getPriceIcon";
export { getPayIcon } from "./getPayIcon";

View File

@ -7,6 +7,8 @@ import type { AppsStatus } from "@calcom/types/Calendar";
export type PublicEvent = NonNullable<RouterOutputs["viewer"]["public"]["event"]>;
export type ValidationErrors<T extends object> = { key: FieldPath<T>; error: ErrorOption }[];
export type EventPrice = { currency: string; price: number; displayAlternateSymbol?: boolean };
export enum EventDetailBlocks {
// Includes duration select when event has multiple durations.
DURATION,

View File

@ -6,13 +6,13 @@ import type Stripe from "stripe";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import EventManager from "@calcom/core/EventManager";
import dayjs from "@calcom/dayjs";
import { sendAttendeeRequestEmail, sendOrganizerRequestEmail, sendScheduledEmails } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { sendOrganizerRequestEmail, sendAttendeeRequestEmail } from "@calcom/emails";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
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 { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { bookingMinimalSelect, prisma } from "@calcom/prisma";
@ -129,7 +129,7 @@ async function getBooking(bookingId: number) {
};
}
async function handlePaymentSuccess(event: Stripe.Event) {
async function handleStripePaymentSuccess(event: Stripe.Event) {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const payment = await prisma.payment.findFirst({
where: {
@ -145,145 +145,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
}
if (!payment?.bookingId) throw new HttpCode({ statusCode: 204, message: "Payment not found" });
const booking = await prisma.booking.findUnique({
where: {
id: payment.bookingId,
},
select: {
...bookingMinimalSelect,
eventType: true,
smsReminderNumber: true,
location: true,
eventTypeId: true,
userId: true,
uid: true,
paid: true,
destinationCalendar: true,
status: true,
responses: true,
user: {
select: {
id: true,
username: true,
credentials: { select: credentialForCalendarServiceSelect },
timeZone: true,
timeFormat: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
},
});
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
let eventTypeRaw: EventTypeRaw | null = null;
if (booking.eventTypeId) {
eventTypeRaw = await getEventType(booking.eventTypeId);
}
const { user: userWithCredentials } = booking;
if (!userWithCredentials) throw new HttpCode({ statusCode: 204, message: "No user found" });
const { credentials, ...user } = userWithCredentials;
const t = await getTranslation(user.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
booking: booking,
bookingFields: booking.eventType?.bookingFields || null,
}),
organizer: {
email: user.email,
name: user.name!,
timeZone: user.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
language: { translate: t, locale: user.locale ?? "en" },
},
attendees: attendeesList,
location: booking.location,
uid: booking.uid,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
};
if (booking.location) evt.location = booking.location;
const bookingData: Prisma.BookingUpdateInput = {
paid: true,
status: BookingStatus.ACCEPTED,
};
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
if (isConfirmed) {
const eventManager = new EventManager(userWithCredentials);
const scheduleResult = await eventManager.create(evt);
bookingData.references = { create: scheduleResult.referencesToCreate };
}
if (eventTypeRaw?.requiresConfirmation) {
delete bookingData.status;
}
const paymentUpdate = prisma.payment.update({
where: {
id: payment.id,
},
data: {
success: true,
},
});
const bookingUpdate = prisma.booking.update({
where: {
id: booking.id,
},
data: bookingData,
});
await prisma.$transaction([paymentUpdate, bookingUpdate]);
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) {
await handleConfirmation({
user: userWithCredentials,
evt,
prisma,
bookingId: booking.id,
booking,
paid: true,
});
} else {
await sendScheduledEmails({ ...evt });
}
throw new HttpCode({
statusCode: 200,
message: `Booking with id '${booking.id}' was paid and confirmed.`,
});
await handlePaymentSuccess(payment.id, payment.bookingId);
}
const handleSetupSuccess = async (event: Stripe.Event) => {
@ -372,7 +234,7 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
type WebhookHandler = (event: Stripe.Event) => Promise<void>;
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
"payment_intent.succeeded": handlePaymentSuccess,
"payment_intent.succeeded": handleStripePaymentSuccess,
"setup_intent.succeeded": handleSetupSuccess,
};

View File

@ -7,13 +7,14 @@ import { useEffect, useState } from "react";
import { getSuccessPageLocationMessage } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
import { getPayIcon } from "@calcom/features/bookings/components/event-meta/getPayIcon";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
import { localStorage } from "@calcom/lib/webstorage";
import { CreditCard } from "@calcom/ui/components/icon";
import type { PaymentPageProps } from "../pages/payment";
@ -31,6 +32,13 @@ const PaypalPaymentComponent = dynamic(
}
);
const AlbyPaymentComponent = dynamic(
() => import("@calcom/app-store/alby/components/AlbyPaymentComponent").then((m) => m.AlbyPaymentComponent),
{
ssr: false,
}
);
const PaymentPage: FC<PaymentPageProps> = (props) => {
const { t, i18n } = useLocale();
const [is24h, setIs24h] = useState(isBrowserLocale24h());
@ -66,6 +74,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
}, [isEmbed]);
const eventName = props.booking.title;
const PayIcon = getPayIcon(paymentAppData.currency);
return (
<div className="h-screen">
@ -92,7 +101,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
aria-labelledby="modal-headline">
<div>
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<CreditCard className="h-8 w-8 text-green-600" />
<PayIcon className="h-8 w-8 text-green-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
@ -121,10 +130,11 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
{props.payment.paymentOption === "HOLD" ? t("no_show_fee") : t("price")}
</div>
<div className="col-span-2 mb-6 font-semibold">
{new Intl.NumberFormat(i18n.language, {
style: "currency",
currency: paymentAppData.currency,
}).format(paymentAppData.price / 100.0)}
<Price
currency={paymentAppData.currency}
price={paymentAppData.price}
displayAlternateSymbol={false}
/>
</div>
</div>
</div>
@ -146,6 +156,9 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
{props.payment.appId === "paypal" && !props.payment.success && (
<PaypalPaymentComponent payment={props.payment} />
)}
{props.payment.appId === "alby" && !props.payment.success && (
<AlbyPaymentComponent payment={props.payment} paymentPageProps={props} />
)}
{props.payment.refunded && (
<div className="text-default mt-4 text-center dark:text-gray-300">{t("refunded")}</div>
)}

View File

@ -2,6 +2,8 @@ import type { Prisma } from "@prisma/client";
import { useMemo } from "react";
import type { z } from "zod";
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
import { getPriceIcon } from "@calcom/features/bookings/components/event-meta/getPriceIcon";
import { classNames, parseRecurringEvent } from "@calcom/lib";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -9,7 +11,7 @@ import type { baseEventTypeSelect } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import type { EventTypeModel } from "@calcom/prisma/zod";
import { Badge } from "@calcom/ui";
import { Clock, Users, RefreshCw, CreditCard, Clipboard, Plus, User, Lock } from "@calcom/ui/components/icon";
import { Clock, Users, RefreshCw, Clipboard, Plus, User, Lock } from "@calcom/ui/components/icon";
export type EventTypeDescriptionProps = {
eventType: Pick<
@ -94,11 +96,12 @@ export const EventTypeDescription = ({
)}
{paymentAppData.enabled && (
<li>
<Badge variant="gray" startIcon={CreditCard}>
{new Intl.NumberFormat(i18n.language, {
style: "currency",
currency: paymentAppData.currency,
}).format(paymentAppData.price / 100)}
<Badge variant="gray" startIcon={getPriceIcon(paymentAppData.currency)}>
<Price
currency={paymentAppData.currency}
price={paymentAppData.price}
displayAlternateSymbol={false}
/>
</Badge>
</li>
)}

View File

@ -47,6 +47,7 @@ const getEnabledAppsFromCredentials = async (
..._where,
...(filterOnIds.credentials.some.OR.length && filterOnIds),
};
const enabledApps = await prisma.app.findMany({
where,
select: { slug: true, enabled: true },

View File

@ -8,7 +8,6 @@ import { getOrgFullDomain } from "@calcom/ee/organizations/lib/orgDomains";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseBookingLimit, parseDurationLimit, parseRecurringEvent } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { getTranslation } from "@calcom/lib/server/i18n";
import type { PrismaClient } from "@calcom/prisma";
import type { Credential } from "@calcom/prisma/client";
@ -256,14 +255,9 @@ export default async function getEventTypeById({
const newMetadata = EventTypeMetaDataSchema.parse(metadata || {}) || {};
const apps = newMetadata?.apps || {};
const eventTypeWithParsedMetadata = { ...rawEventType, metadata: newMetadata };
const stripeMetaData = getPaymentAppData(eventTypeWithParsedMetadata, true);
newMetadata.apps = {
...apps,
stripe: {
...stripeMetaData,
paymentOption: stripeMetaData.paymentOption as string,
currency: getStripeCurrency(stripeMetaData, credentials),
},
giphy: getEventTypeAppData(eventTypeWithParsedMetadata, "giphy", true),
};

View File

@ -0,0 +1,179 @@
import type { Prisma } from "@prisma/client";
import EventManager from "@calcom/core/EventManager";
import { sendScheduledEmails } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { getTimeFormatStringFromUserTimeFormat } from "../timeFormat";
export async function handlePaymentSuccess(paymentId: number, bookingId: number) {
const booking = await prisma.booking.findUnique({
where: {
id: bookingId,
},
select: {
...bookingMinimalSelect,
eventType: true,
smsReminderNumber: true,
location: true,
eventTypeId: true,
userId: true,
uid: true,
paid: true,
destinationCalendar: true,
status: true,
responses: true,
user: {
select: {
id: true,
username: true,
credentials: { select: credentialForCalendarServiceSelect },
timeZone: true,
timeFormat: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
payment: {
select: {
amount: true,
currency: true,
paymentOption: true,
},
},
},
});
if (!booking) throw new HttpCode({ statusCode: 204, message: "No booking found" });
type EventTypeRaw = Awaited<ReturnType<typeof getEventType>>;
let eventTypeRaw: EventTypeRaw | null = null;
if (booking.eventTypeId) {
eventTypeRaw = await getEventType(booking.eventTypeId);
}
const { user: userWithCredentials } = booking;
if (!userWithCredentials) throw new HttpCode({ statusCode: 204, message: "No user found" });
const { credentials, ...user } = userWithCredentials;
const t = await getTranslation(user.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
booking: booking,
bookingFields: booking.eventType?.bookingFields || null,
}),
organizer: {
email: user.email,
name: user.name!,
timeZone: user.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
language: { translate: t, locale: user.locale ?? "en" },
},
attendees: attendeesList,
location: booking.location,
uid: booking.uid,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
paymentInfo: booking.payment?.[0] && {
amount: booking.payment[0].amount,
currency: booking.payment[0].currency,
paymentOption: booking.payment[0].paymentOption,
},
};
if (booking.location) evt.location = booking.location;
const bookingData: Prisma.BookingUpdateInput = {
paid: true,
status: BookingStatus.ACCEPTED,
};
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
if (isConfirmed) {
const eventManager = new EventManager(userWithCredentials);
const scheduleResult = await eventManager.create(evt);
bookingData.references = { create: scheduleResult.referencesToCreate };
}
if (eventTypeRaw?.requiresConfirmation) {
delete bookingData.status;
}
const paymentUpdate = prisma.payment.update({
where: {
id: paymentId,
},
data: {
success: true,
},
});
const bookingUpdate = prisma.booking.update({
where: {
id: booking.id,
},
data: bookingData,
});
await prisma.$transaction([paymentUpdate, bookingUpdate]);
if (!isConfirmed && !eventTypeRaw?.requiresConfirmation) {
await handleConfirmation({
user: userWithCredentials,
evt,
prisma,
bookingId: booking.id,
booking,
paid: true,
});
} else {
await sendScheduledEmails({ ...evt });
}
throw new HttpCode({
statusCode: 200,
message: `Booking with id '${booking.id}' was paid and confirmed.`,
});
}
async function getEventType(id: number) {
return prisma.eventType.findUnique({
where: {
id,
},
select: {
recurringEvent: true,
requiresConfirmation: true,
},
});
}

11
packages/lib/price.ts Normal file
View File

@ -0,0 +1,11 @@
export const formatPrice = (price: number, currency: string | undefined, locale = "en") => {
switch (currency) {
case "BTC":
return `${price} sats`;
default:
return `${Intl.NumberFormat(locale, {
style: "currency",
currency: currency?.toUpperCase() || "USD",
}).format(price / 100.0)}`;
}
};

View File

@ -1,5 +1,6 @@
import type { Prisma } from "@prisma/client";
import appStore from "@calcom/app-store";
import type { CredentialOwner } from "@calcom/app-store/types";
import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials";
import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp";
@ -9,6 +10,7 @@ import { MembershipRole } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { PaymentApp } from "@calcom/types/PaymentService";
import type { TIntegrationsInputSchema } from "./integrations.schema";
@ -132,34 +134,48 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
...(appId ? { where: { slug: appId } } : {}),
});
//TODO: Refactor this to pick up only needed fields and prevent more leaking
let apps = enabledApps.map(
({ credentials: _, credential: _1, key: _2 /* don't leak to frontend */, ...app }) => {
let apps = await Promise.all(
enabledApps.map(async ({ credentials: _, credential, key: _2 /* don't leak to frontend */, ...app }) => {
const userCredentialIds = credentials.filter((c) => c.type === app.type && !c.teamId).map((c) => c.id);
const invalidCredentialIds = credentials
.filter((c) => c.type === app.type && c.invalid)
.map((c) => c.id);
const teams = credentials
.filter((c) => c.type === app.type && c.teamId)
.map((c) => {
const team = userTeams.find((team) => team.id === c.teamId);
if (!team) {
return null;
}
return {
teamId: team.id,
name: team.name,
logo: team.logo,
credentialId: c.id,
isAdmin:
team.members[0].role === MembershipRole.ADMIN || team.members[0].role === MembershipRole.OWNER,
};
});
const teams = await Promise.all(
credentials
.filter((c) => c.type === app.type && c.teamId)
.map(async (c) => {
const team = userTeams.find((team) => team.id === c.teamId);
if (!team) {
return null;
}
return {
teamId: team.id,
name: team.name,
logo: team.logo,
credentialId: c.id,
isAdmin:
team.members[0].role === MembershipRole.ADMIN ||
team.members[0].role === MembershipRole.OWNER,
};
})
);
// type infer as CredentialOwner
const credentialOwner: CredentialOwner = {
name: user.name,
avatar: user.avatar,
};
// We need to know if app is payment type
let isSetupAlready = false;
if (credential && app.categories.includes("payment")) {
const paymentApp = (await appStore[app.dirName as keyof typeof appStore]()) as PaymentApp | null;
if (paymentApp && "lib" in paymentApp && paymentApp?.lib && "PaymentService" in paymentApp?.lib) {
const PaymentService = paymentApp.lib.PaymentService;
const paymentInstance = new PaymentService(credential);
isSetupAlready = paymentInstance.isSetupAlready();
}
}
return {
...app,
...(teams.length && {
@ -169,8 +185,9 @@ export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) =
invalidCredentialIds,
teams,
isInstalled: !!userCredentialIds.length || !!teams.length || app.isGlobal,
isSetupAlready,
};
}
})
);
if (variant) {

View File

@ -1,7 +1,9 @@
import authedProcedure from "../../../procedures/authedProcedure";
import publicProcedure from "../../../procedures/publicProcedure";
import { router } from "../../../trpc";
import { ZConfirmInputSchema } from "./confirm.schema";
import { ZEditLocationInputSchema } from "./editLocation.schema";
import { ZFindInputSchema } from "./find.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema";
import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema";
@ -13,6 +15,7 @@ type BookingsRouterHandlerCache = {
editLocation?: typeof import("./editLocation.handler").editLocationHandler;
confirm?: typeof import("./confirm.handler").confirmHandler;
getBookingAttendees?: typeof import("./getBookingAttendees.handler").getBookingAttendeesHandler;
find?: typeof import("./find.handler").getHandler;
};
const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {};
@ -105,4 +108,20 @@ export const bookingsRouter = router({
input,
});
}),
find: publicProcedure.input(ZFindInputSchema).query(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.find) {
UNSTABLE_HANDLER_CACHE.find = await import("./find.handler").then((mod) => mod.getHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.find) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.find({
ctx,
input,
});
}),
});

View File

@ -0,0 +1,36 @@
import type { PrismaClient } from "@calcom/prisma";
import type { TFindInputSchema } from "./find.schema";
type GetOptions = {
ctx: {
prisma: PrismaClient;
};
input: TFindInputSchema;
};
export const getHandler = async ({ ctx, input }: GetOptions) => {
const { prisma } = ctx;
const { bookingUid } = input;
const booking = await prisma.booking.findUnique({
where: {
uid: bookingUid,
},
select: {
id: true,
uid: true,
startTime: true,
endTime: true,
description: true,
status: true,
paid: true,
eventTypeId: true,
},
});
// Don't leak anything private from the booking
return {
booking,
};
};

View File

@ -0,0 +1,9 @@
import { z } from "zod";
const ZFindInputSchema = z.object({
bookingUid: z.string().optional(),
});
export type TFindInputSchema = z.infer<typeof ZFindInputSchema>;
export { ZFindInputSchema };

View File

@ -245,12 +245,26 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
* but the real detail will be inside app metadata, so with this you can have different prices in different apps
* So the price and currency inside eventType will be deprecated soon or just keep as reference.
*/
if (input?.metadata?.apps?.stripe?.price || input?.metadata?.apps?.paypal?.price) {
data.price = input.metadata.apps.stripe?.price || input.metadata.apps.paypal?.price;
if (
input.metadata?.apps?.alby?.price ||
input?.metadata?.apps?.paypal?.price ||
input?.metadata?.apps?.stripe?.price
) {
data.price =
input.metadata?.apps?.alby?.price ||
input.metadata.apps.paypal?.price ||
input.metadata.apps.stripe?.price;
}
if (input?.metadata?.apps?.stripe?.currency || input?.metadata?.apps?.paypal?.currency) {
data.currency = input.metadata.apps.stripe?.currency || input.metadata.apps.paypal?.currency;
if (
input.metadata?.apps?.alby?.currency ||
input?.metadata?.apps?.paypal?.currency ||
input?.metadata?.apps?.stripe?.currency
) {
data.currency =
input.metadata?.apps?.alby?.currency ||
input.metadata.apps.paypal?.currency ||
input.metadata.apps.stripe?.currency;
}
const connectedLink = await ctx.prisma.hashedLink.findFirst({

View File

@ -1,10 +1,11 @@
import type { Payment, Prisma, Booking, PaymentOption } from "@prisma/client";
import type { PaymentService } from "@calcom/app-store/paypal/lib/PaymentService";
import type { CalendarEvent } from "@calcom/types/Calendar";
export interface PaymentApp {
lib?: {
PaymentService: IAbstractPaymentService;
PaymentService: typeof PaymentService;
};
}
@ -32,6 +33,7 @@ export interface IAbstractPaymentService {
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId?: Booking["id"]
): Promise<Payment>;
update(paymentId: Payment["id"], data: Partial<Prisma.PaymentUncheckedCreateInput>): Promise<Payment>;
refund(paymentId: Payment["id"]): Promise<Payment>;
getPaymentPaidStatus(): Promise<string>;
@ -47,4 +49,5 @@ export interface IAbstractPaymentService {
paymentData: Payment
): Promise<void>;
deletePayment(paymentId: Payment["id"]): Promise<boolean>;
isSetupAlready(): boolean;
}

View File

@ -0,0 +1,55 @@
import React, { forwardRef } from "react";
export const SatSymbol = forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(function SatSymbol(props) {
return (
<svg
className={props.className}
id="Layer_1"
data-name="Layer 1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 360 360">
<title>Satoshis</title>
<rect
fill="currentColor"
x="201.48"
y="37.16"
width="23.49"
height="40.14"
transform="translate(21.82 -52.79) rotate(14.87)"
/>
<rect
fill="currentColor"
x="135.03"
y="287.5"
width="23.49"
height="40.14"
transform="translate(83.82 -27.36) rotate(14.87)"
/>
<rect
fill="currentColor"
x="184.27"
y="38.29"
width="23.49"
height="167.49"
transform="translate(364.26 -36.11) rotate(104.87)"
/>
<rect
fill="currentColor"
x="168.36"
y="98.26"
width="23.49"
height="167.49"
transform="translate(402.22 54.61) rotate(104.87)"
/>
<rect
fill="currentColor"
x="152.89"
y="156.52"
width="23.49"
height="167.49"
transform="translate(439.1 142.78) rotate(104.87)"
/>
</svg>
);
});

View File

@ -0,0 +1,22 @@
import React, { forwardRef } from "react";
export const Spinner = forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(function Spinner(props) {
return (
<svg
className={props.className}
id="Layer_1"
data-name="Layer 1"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
className="fill-default"
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/>
<path
className="animate-spinning fill-emphasis origin-center"
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
/>
</svg>
);
});

View File

@ -8,7 +8,9 @@
".": "./index.tsx",
"./components/toast": "./components/toast/index.tsx",
"./components/icon": "./components/icon/index.ts",
"./components/icon/Discord": "./components/icon/Discord.tsx"
"./components/icon/Discord": "./components/icon/Discord.tsx",
"./components/icon/SatSymbol": "./components/icon/SatSymbol.tsx",
"./components/icon/Spinner": "./components/icon/Spinner.tsx"
},
"types": "./index.tsx",
"license": "MIT",

831
yarn.lock

File diff suppressed because it is too large Load Diff