diff --git a/packages/app-store/hitpay/api/callback.ts b/packages/app-store/hitpay/api/callback.ts index fcba2ddf98..cdf2b1a9e6 100644 --- a/packages/app-store/hitpay/api/callback.ts +++ b/packages/app-store/hitpay/api/callback.ts @@ -1,9 +1,17 @@ import type { NextApiRequest, NextApiResponse } from "next"; import qs from "qs"; +import { z } from "zod"; import { HttpError as HttpCode } from "@calcom/lib/http-error"; import prisma from "@calcom/prisma"; +const PaymentDataSchema = z.object({ + id: z.string(), + url: z.string(), + defaultLink: z.string(), + email: z.string(), +}); + export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { reference, status } = req.query; if (!reference) { @@ -18,6 +26,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) id: true, amount: true, bookingId: true, + data: true, booking: { select: { uid: true, @@ -51,7 +60,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) throw new HttpCode({ statusCode: 204, message: "Credential not found" }); } - if (!payment.booking || !payment.booking.user || !payment.booking.eventType || !payment.booking.responses) { + if (!payment.booking || !payment.booking.user || !payment.booking.eventType) { throw new HttpCode({ statusCode: 204, message: "Booking not correct" }); } @@ -60,10 +69,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.redirect(url); } + const parsedData = PaymentDataSchema.parse(payment.data); const queryParams = { "flag.coep": false, isSuccessBookingPage: true, - email: (payment.booking.responses as { email: string }).email, + email: parsedData.email, eventTypeSlug: payment.booking.eventType.slug, }; diff --git a/packages/app-store/hitpay/api/webhook.ts b/packages/app-store/hitpay/api/webhook.ts index ec2ab8d9c5..6cc7f896ab 100644 --- a/packages/app-store/hitpay/api/webhook.ts +++ b/packages/app-store/hitpay/api/webhook.ts @@ -44,7 +44,6 @@ function generateSignatureArray(secret: string, vals: T) { export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { - debugger; if (req.method !== "POST") { throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" }); } diff --git a/packages/app-store/hitpay/components/HitPayDropIn.ts b/packages/app-store/hitpay/components/HitPayDropIn.ts new file mode 100644 index 0000000000..d56e16aabb --- /dev/null +++ b/packages/app-store/hitpay/components/HitPayDropIn.ts @@ -0,0 +1,176 @@ +import { useState, useEffect, useRef } from "react"; + +interface InitOptions { + scheme?: string; + domain?: string; + path?: string; +} + +interface Callbacks { + onClose?: () => void; + onSuccess?: () => void; + onError?: (error: unknown) => void; +} + +interface CheckoutOptions { + paymentRequest?: string; +} + +interface HitPayOptions { + visible: boolean; + defaultUrl: string; + initOptions: InitOptions; + callbacks?: Callbacks; + checkoutOptions: CheckoutOptions; +} + +export interface HitPayDropInResult { + isInitialized: boolean; + init: ( + url: string, + initOptions: InitOptions, + checkoutOptions: CheckoutOptions, + callbacks?: Callbacks + ) => Promise; + toggle: (checkoutOptions: CheckoutOptions) => Promise; +} + +export const useHitPayDropIn = (): HitPayDropInResult => { + const [isInitialized, setIsInitialized] = useState(false); + const iframe = useRef(null); + const loadPromise = useRef | null>(null); + const resolveLoad = useRef<(() => void) | null>(null); + const previousOverflow = useRef("visible"); + const toggling = useRef(false); + + const hitPayOptions = useRef({ + visible: false, + defaultUrl: "", + initOptions: { scheme: "", domain: "" }, + checkoutOptions: {}, + }); + + const init = async ( + url: string, + initOptions: InitOptions, + checkoutOptions: CheckoutOptions, + callbacks?: Callbacks + ) => { + if (!isInitialized) { + hitPayOptions.current.defaultUrl = url; + hitPayOptions.current.initOptions = initOptions; + hitPayOptions.current.callbacks = callbacks; + hitPayOptions.current.checkoutOptions.paymentRequest = checkoutOptions.paymentRequest || ""; + + const scheme = initOptions.scheme || "https"; + const domain = initOptions.domain || "hit-pay.com"; + const path = initOptions.path || ""; + + document.body.style.cssText = "width: 100vw; height: 100vh; overflow: hidden; margin: 0; padding: 0;"; + + const iframeSrc = `${scheme}://${domain}${path}/hitpay-iframe.html?post-parent=true×tamp=${Date.now()}`; + + iframe.current = document.createElement("iframe"); + iframe.current.setAttribute("src", iframeSrc); + iframe.current.setAttribute("allowFullscreen", "true"); + iframe.current.style.position = "fixed"; + iframe.current.style.border = "0"; + iframe.current.style.width = "100vw"; + iframe.current.style.height = "100vh"; + iframe.current.style.margin = "0"; + iframe.current.style.padding = "0"; + iframe.current.style.zIndex = "99999999"; + iframe.current.style.top = "0"; + iframe.current.style.left = "0"; + iframe.current.style.display = "none"; + + document.body.appendChild(iframe.current); + + loadPromise.current = new Promise((resolve) => { + resolveLoad.current = resolve; + setTimeout(resolve, 2000); + }); + } + }; + + const toggle = async (checkoutOptions: CheckoutOptions) => { + toggling.current = true; + if (loadPromise.current) await loadPromise.current; + + if (hitPayOptions.current.visible) { + document.body.style.overflow = previousOverflow.current; + } else { + previousOverflow.current = document.body.style.overflow; + document.body.style.overflow = "hidden"; + if (iframe.current) { + iframe.current.style.display = "block"; + } + } + + const delay = hitPayOptions.current.visible ? 0 : 500; + setTimeout(() => { + iframe.current?.contentWindow?.postMessage( + { + type: "toggle", + props: { + defaultUrl: hitPayOptions.current.defaultUrl, + ...hitPayOptions.current.initOptions, + checkoutOptions, + }, + }, + "*" + ); + + hitPayOptions.current.visible = !hitPayOptions.current.visible; + + if (!hitPayOptions.current.visible) { + if (iframe.current) { + iframe.current.style.display = "none"; + } + + hitPayOptions.current.callbacks?.onClose && hitPayOptions.current.callbacks.onClose(); + } + + toggling.current = false; + }, delay); + }; + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.data && !toggling.current) { + switch (event.data.type) { + case "loaded": + toggle(hitPayOptions.current.checkoutOptions); + loadPromise.current = null; + setIsInitialized(true); + if (resolveLoad.current) { + resolveLoad.current(); + } + break; + case "toggle": + toggle({}); + break; + case "success": + if (hitPayOptions.current.callbacks?.onSuccess) { + hitPayOptions.current.callbacks?.onSuccess(); + } + break; + case "error": + if (hitPayOptions.current.callbacks?.onError) { + hitPayOptions.current.callbacks.onError(event.data.error); + } + break; + default: + break; + } + } + }; + window.addEventListener("message", handleMessage); + + return () => { + window.removeEventListener("message", handleMessage); + }; + }, []); + + return { isInitialized, init, toggle }; +}; diff --git a/packages/app-store/hitpay/components/HitpayPaymentComponent.tsx b/packages/app-store/hitpay/components/HitpayPaymentComponent.tsx index 787cbc991b..b215819785 100644 --- a/packages/app-store/hitpay/components/HitpayPaymentComponent.tsx +++ b/packages/app-store/hitpay/components/HitpayPaymentComponent.tsx @@ -1,8 +1,18 @@ -import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import qs from "qs"; +import { useEffect, useRef } from "react"; import { z } from "zod"; +import { useHitPayDropIn } from "./HitPayDropIn"; + const PaymentHitpayDataSchema = z.object({ + id: z.string(), url: z.string(), + defaultLink: z.string(), + eventTypeSlug: z.string(), + bookingUid: z.string(), + email: z.string(), + bookingUserName: z.string(), }); interface IPaymentComponentProps { @@ -12,6 +22,9 @@ interface IPaymentComponentProps { } export const HitpayPaymentComponent = (props: IPaymentComponentProps) => { + const { isInitialized, init } = useHitPayDropIn(); + const isSucceeded = useRef(false); + const router = useRouter(); const { payment } = props; const { data } = payment; const wrongUrl = ( @@ -23,18 +36,63 @@ export const HitpayPaymentComponent = (props: IPaymentComponentProps) => { const parsedData = PaymentHitpayDataSchema.safeParse(data); useEffect(() => { - if (window) { - if (parsedData.success) { - if (window.self !== window.top && window.top) { - window.top.open(parsedData.data.url, "_blank"); - } else { - window.location.href = parsedData.data.url; + if (parsedData.success) { + if (window.self !== window.top && window.top) { + if (!isInitialized) { + const subUrl = parsedData.data.url.substring("https://securecheckout.".length); + const arr = subUrl.split("/"); + const domain = arr[0]; + + init( + parsedData.data.defaultLink || "", + { + domain, + }, + { + paymentRequest: parsedData.data.id, + }, + { + onClose: onClose, + onSuccess: onSuccess, + onError: onError, + } + ); } + } else { + router.replace(parsedData.data.url); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const onSuccess = () => { + isSucceeded.current = true; + }; + + const onClose = () => { + if (isSucceeded.current) { + if (parsedData.success) { + const queryParams = { + "flag.coep": false, + isSuccessBookingPage: true, + email: parsedData.data.email, + eventTypeSlug: parsedData.data.eventTypeSlug, + }; + + const query = qs.stringify(queryParams); + const url = `/booking/${parsedData.data.bookingUid}?${query}`; + router.replace(url); + } + } + }; + + const onError = (error: unknown) => { + if (parsedData.success) { + const url = `/${parsedData.data.bookingUserName}/${parsedData.data.eventTypeSlug}`; + router.replace(url); + } + }; + if (!parsedData.success || !parsedData.data?.url) { return wrongUrl; } diff --git a/packages/app-store/hitpay/components/KeyInput.tsx b/packages/app-store/hitpay/components/KeyInput.tsx index 7bc2c5359b..de829c0dee 100644 --- a/packages/app-store/hitpay/components/KeyInput.tsx +++ b/packages/app-store/hitpay/components/KeyInput.tsx @@ -1,11 +1,11 @@ "use client"; +import classNames from "classnames"; import type { FormEvent } from "react"; import React, { forwardRef, useState, useEffect, useId, useCallback } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Input, Skeleton, Icon, Label } from "@calcom/ui"; -import classNames from "@calcom/ui/classNames"; import type { InputFieldProps } from "@calcom/ui/components/form/inputs/types"; type AddonProps = { diff --git a/packages/app-store/hitpay/config.json b/packages/app-store/hitpay/config.json index ad98701aa1..e030ca5f37 100644 --- a/packages/app-store/hitpay/config.json +++ b/packages/app-store/hitpay/config.json @@ -4,12 +4,12 @@ "slug": "hitpay", "type": "hitpay_payment", "logo": "icon.svg", - "url": "https://dashboard.hit-pay.com", + "url": "https://hitpayapp.com", "variant": "payment", "categories": ["payment"], "publisher": "HitPay", "email": "support@hit-pay.com", - "description": "HitPay helps over 15,000 businesses across Southeast Asia and around the globe process payments efficiently and securely. We unify online, point of sale, and B2B payments into a single, integrated payment processing system.", + "description": "HitPay's Cal.com payment integration combines powerful scheduling with seamless local payment acceptance. Our online scheduling software integration is built for customers in APAC, supporting their favorite e-wallets and banking apps.

Accept a wide range of local payments including:

Real-time QR Code Payments: e.g. PayNow, DuitNow, QRIS, PromptPay
E-Wallets: e.g. GrabPay, ShopeePay, GCash, AliPay, WeChat Pay
Credit & Debit Cards: e.g. Visa, Mastercard, American Express, UnionPay
Buy Now, Pay Later: e.g. PayLater by Grab, SPayLater, Atome

About HitPay

HitPay is a full-stack payments infrastructure platform designed for growing businesses in APAC. We unify e-commerce, point of sale, and B2B payments into a single platform. HitPay is regulated in 6 APAC jurisdictions and backed by leading global investors, including Tiger Global, Y Combinator, Global Founders Capital, and HOF Capital.

Learn more at https://hitpayapp.com/cal.com

", "extendsFeature": "EventType", "isTemplate": false, "__createdUsingCli": true, diff --git a/packages/app-store/hitpay/lib/PaymentService.ts b/packages/app-store/hitpay/lib/PaymentService.ts index f4092cff9c..cd3cfe3ed5 100644 --- a/packages/app-store/hitpay/lib/PaymentService.ts +++ b/packages/app-store/hitpay/lib/PaymentService.ts @@ -15,6 +15,7 @@ import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; import appConfig from "../config.json"; import { API_HITPAY, SANDBOX_API_HITPAY } from "./constants"; import { hitpayCredentialKeysSchema } from "./hitpayCredentialKeysSchema"; +import type { PaidBooking } from "./types"; const log = logger.getSubLogger({ prefix: ["payment-service:hitpay"] }); @@ -40,13 +41,22 @@ export class PaymentService implements IAbstractPaymentService { bookerEmail: string ) { try { - const booking = await prisma.booking.findFirst({ + const booking: PaidBooking | null = await prisma.booking.findFirst({ + where: { + id: bookingId, + }, select: { uid: true, title: true, - }, - where: { - id: bookingId, + startTime: true, + endTime: true, + eventType: { + select: { + slug: true, + seatsPerTimeSlot: true, + }, + }, + attendees: { include: { bookingSeat: true } }, }, }); @@ -54,6 +64,32 @@ export class PaymentService implements IAbstractPaymentService { throw new Error("Booking or API key not found"); } + const { startTime, endTime } = booking; + const bookingsWithSameTimeSlot = await prisma.booking.findMany({ + where: { + startTime, + endTime, + }, + select: { + uid: true, + title: true, + }, + }); + + if (booking.eventType?.seatsPerTimeSlot) { + if ( + booking.eventType.seatsPerTimeSlot <= + booking.attendees.filter((attendee) => !!attendee.bookingSeat).length || + bookingsWithSameTimeSlot.length > booking.eventType.seatsPerTimeSlot + ) { + throw new Error(ErrorCode.BookingSeatsFull); + } + } else { + if (bookingsWithSameTimeSlot.length > 1) { + throw new Error(ErrorCode.NoAvailableUsersFound); + } + } + const { isSandbox } = this.credentials; const keyObj = isSandbox ? this.credentials.sandbox : this.credentials.prod; if (!keyObj || !keyObj?.apiKey) { @@ -87,6 +123,18 @@ export class PaymentService implements IAbstractPaymentService { }); const data = response.data; + + const getRequestUrl = `${hitpayAPIurl}/v1/payment-requests?request_id=${data.id}&is_default=1`; + const getResponse = await axios.get(getRequestUrl, { + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", + "X-BUSINESS-API-KEY": keyObj.apiKey, + "X-Requested-With": "XMLHttpRequest", + }, + }); + const getData = getResponse.data; + const defaultLink = getData.data[0].url; + const uid = uuidv4(); const paymentData = await prisma.payment.create({ @@ -105,7 +153,12 @@ export class PaymentService implements IAbstractPaymentService { amount: parseFloat(data.amount.replace(/,/g, "")) * 100, externalId: data.id, currency: data.currency, - data: Object.assign({}, data, { isPaid: false }) as unknown as Prisma.InputJsonValue, + data: Object.assign( + {}, + { defaultLink, ...data }, + { bookingUserName: username, eventTypeSlug: booking.eventType?.slug, bookingUid: booking.uid }, + { isPaid: false } + ) as unknown as Prisma.InputJsonValue, fee: 0, refunded: false, success: false, diff --git a/packages/app-store/hitpay/lib/types.d.ts b/packages/app-store/hitpay/lib/types.d.ts new file mode 100644 index 0000000000..8f0a9a7267 --- /dev/null +++ b/packages/app-store/hitpay/lib/types.d.ts @@ -0,0 +1,17 @@ +import type { Prisma } from "@prisma/client"; + +export type PaidBooking = Prisma.BookingGetPayload<{ + select: { + uid: true; + title: true; + startTime: true; + endTime: true; + eventType: { + select: { + slug: true; + seatsPerTimeSlot: true; + }; + }; + attendees: { include: { bookingSeat: true } }; + }; +}>; diff --git a/packages/app-store/hitpay/pages/setup/index.tsx b/packages/app-store/hitpay/pages/setup/index.tsx index b9e652db90..0597abd416 100644 --- a/packages/app-store/hitpay/pages/setup/index.tsx +++ b/packages/app-store/hitpay/pages/setup/index.tsx @@ -88,15 +88,15 @@ function HitPaySetupPage(props: IHitPaySetupProps) { .string() .trim() .min(64) - .max(64, { - message: t("max_limit_allowed_hint", { limit: 64 }), + .max(128, { + message: t("max_limit_allowed_hint", { limit: 128 }), }), saltKey: z .string() .trim() .min(64) - .max(64, { - message: t("max_limit_allowed_hint", { limit: 64 }), + .max(128, { + message: t("max_limit_allowed_hint", { limit: 128 }), }), }); @@ -137,11 +137,13 @@ function HitPaySetupPage(props: IHitPaySetupProps) { useEffect(() => { const keyObj = isSandbox ? props.sandbox : props.prod; - reset({ + const _keyData = { apiKey: keyObj?.apiKey || "", saltKey: keyObj?.saltKey || "", - }); - setKeyData(keyObj); + }; + + reset(_keyData); + setKeyData(_keyData); }, [isSandbox]); useEffect(() => { diff --git a/packages/app-store/hitpay/static/4.jpeg b/packages/app-store/hitpay/static/4.jpeg index 73fae5c70c..69f9812251 100644 Binary files a/packages/app-store/hitpay/static/4.jpeg and b/packages/app-store/hitpay/static/4.jpeg differ