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:
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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×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 };
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: </strong>e.g. PayNow, DuitNow, QRIS, PromptPay</span><br/><span><strong data-stringify-type=\"bold\">E-Wallets: </strong>e.g. GrabPay, ShopeePay, GCash, AliPay, WeChat Pay</span><br/><span><strong data-stringify-type=\"bold\">Credit & Debit Cards: </strong>e.g. Visa, Mastercard, American Express, UnionPay</span><br/><span><strong data-stringify-type=\"bold\">Buy Now, Pay Later: </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 <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
@@ -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 |
Reference in New Issue
Block a user