feat: Updates to HitPay Payment App (#19737)

* feat: add hitpay embed feature for cal embed

feat: update hitpay app description

fix: fixed customers being able to exceed bookings in the same timeslot when using HitPay

fix: fixed callback issue in cal.com using hitpay app when not receiving payment responses

* fix: fixed type-check error
This commit is contained in:
Muhammad Aiman Sulaiman
2025-03-05 11:29:38 +00:00
committed by GitHub
parent d79a5c3adf
commit 58d7e59b38
10 changed files with 340 additions and 25 deletions
+12 -2
View File
@@ -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,
};
-1
View File
@@ -44,7 +44,6 @@ function generateSignatureArray<T>(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" });
}
@@ -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<void>;
toggle: (checkoutOptions: CheckoutOptions) => Promise<void>;
}
export const useHitPayDropIn = (): HitPayDropInResult => {
const [isInitialized, setIsInitialized] = useState(false);
const iframe = useRef<HTMLIFrameElement | null>(null);
const loadPromise = useRef<Promise<void> | null>(null);
const resolveLoad = useRef<(() => void) | null>(null);
const previousOverflow = useRef<string>("visible");
const toggling = useRef<boolean>(false);
const hitPayOptions = useRef<HitPayOptions>({
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&timestamp=${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 };
};
@@ -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<boolean>(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;
}
@@ -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 = {
+2 -2
View File
@@ -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.<p>Accept a wide range of local payments including:</p><span><strong data-stringify-type=\"bold\">Real-time QR Code Payments:&nbsp;</strong>e.g. PayNow, DuitNow, QRIS, PromptPay</span><br/><span><strong data-stringify-type=\"bold\">E-Wallets:&nbsp;</strong>e.g. GrabPay, ShopeePay, GCash, AliPay, WeChat Pay</span><br/><span><strong data-stringify-type=\"bold\">Credit &amp; Debit Cards:&nbsp;</strong>e.g. Visa, Mastercard, American Express, UnionPay</span><br/><span><strong data-stringify-type=\"bold\">Buy Now, Pay Later:&nbsp;</strong>e.g. PayLater by Grab, SPayLater, Atome</span><br/><p>About HitPay</p><span>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.</span><br/><p>Learn more at&nbsp;<a class=\"c-link\" href=\"https://hitpayapp.com/cal.com\" target=\"_blank\" rel=\"noopener noreferrer\" data-stringify-link=\"https://hitpayapp.com/cal.com\" data-sk=\"tooltip_parent\">https://hitpayapp.com/cal.com</a></p>",
"extendsFeature": "EventType",
"isTemplate": false,
"__createdUsingCli": true,
@@ -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,
+17
View File
@@ -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 } };
};
}>;
@@ -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(() => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 850 KiB