feat: HitPay Payment App (#17213)

* Build HitPay app

* Deleted a lint comment

* Add redirect for iframe

* Fix type check error

* Fixed issue of payment pending

* Update hitpay package.json

* fix: fix lint issue in KeyInput and update zod

* fix: fix issue of parsing string to number

* fix: add default hitpay API links

* fix: resolve atoms build error

---------

Co-authored-by: kutsaniuk <kutsaniuk@gmail.com>
This commit is contained in:
Muhammad Aiman Sulaimanandkutsaniuk authored and GitHub committed 2025-01-20 15:36:23 +01:00
1 parent 5357228f72
commit 7131a1f015
39 files changed
+1464 -1

No files matched your search

@@ -5,6 +5,7 @@ export const AppSetupPageMap = {
make: import("../../make/pages/setup/_getServerSideProps"),
zapier: import("../../zapier/pages/setup/_getServerSideProps"),
stripe: import("../../stripepayment/pages/setup/_getServerSideProps"),
hitpay: import("../../hitpay/pages/setup/_getServerSideProps"),
};
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
@@ -15,6 +15,7 @@ export const AppSetupMap = {
sendgrid: dynamic(() => import("../../sendgrid/pages/setup")),
stripe: dynamic(() => import("../../stripepayment/pages/setup")),
paypal: dynamic(() => import("../../paypal/pages/setup")),
hitpay: dynamic(() => import("../../hitpay/pages/setup")),
};
export const AppSetupPage = (props: { slug: string }) => {
@@ -27,6 +27,7 @@ export const EventTypeAddonMap = {
ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),
giphy: dynamic(() => import("./giphy/components/EventTypeAppCardInterface")),
gtm: dynamic(() => import("./gtm/components/EventTypeAppCardInterface")),
hitpay: dynamic(() => import("./hitpay/components/EventTypeAppCardInterface")),
hubspot: dynamic(() => import("./hubspot/components/EventTypeAppCardInterface")),
insihts: dynamic(() => import("./insihts/components/EventTypeAppCardInterface")),
matomo: dynamic(() => import("./matomo/components/EventTypeAppCardInterface")),
@@ -57,6 +58,7 @@ export const EventTypeSettingsMap = {
ga4: dynamic(() => import("./ga4/components/EventTypeAppSettingsInterface")),
giphy: dynamic(() => import("./giphy/components/EventTypeAppSettingsInterface")),
gtm: dynamic(() => import("./gtm/components/EventTypeAppSettingsInterface")),
hitpay: dynamic(() => import("./hitpay/components/EventTypeAppSettingsInterface")),
metapixel: dynamic(() => import("./metapixel/components/EventTypeAppSettingsInterface")),
paypal: dynamic(() => import("./paypal/components/EventTypeAppSettingsInterface")),
plausible: dynamic(() => import("./plausible/components/EventTypeAppSettingsInterface")),
@@ -14,6 +14,7 @@ import { appKeysSchema as giphy_zod_ts } from "./giphy/zod";
import { appKeysSchema as googlecalendar_zod_ts } from "./googlecalendar/zod";
import { appKeysSchema as googlevideo_zod_ts } from "./googlevideo/zod";
import { appKeysSchema as gtm_zod_ts } from "./gtm/zod";
import { appKeysSchema as hitpay_zod_ts } from "./hitpay/zod";
import { appKeysSchema as hubspot_zod_ts } from "./hubspot/zod";
import { appKeysSchema as insihts_zod_ts } from "./insihts/zod";
import { appKeysSchema as intercom_zod_ts } from "./intercom/zod";
@@ -63,6 +64,7 @@ export const appKeysSchemas = {
googlecalendar: googlecalendar_zod_ts,
googlevideo: googlevideo_zod_ts,
gtm: gtm_zod_ts,
hitpay: hitpay_zod_ts,
hubspot: hubspot_zod_ts,
insihts: insihts_zod_ts,
intercom: intercom_zod_ts,
@@ -36,6 +36,7 @@ import { metadata as googlecalendar__metadata_ts } from "./googlecalendar/_metad
import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata";
import granola_config_json from "./granola/config.json";
import gtm_config_json from "./gtm/config.json";
import hitpay_config_json from "./hitpay/config.json";
import horizon_workrooms_config_json from "./horizon-workrooms/config.json";
import { metadata as hubspot__metadata_ts } from "./hubspot/_metadata";
import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata";
@@ -138,6 +139,7 @@ export const appStoreMetadata = {
googlevideo: googlevideo__metadata_ts,
granola: granola_config_json,
gtm: gtm_config_json,
hitpay: hitpay_config_json,
"horizon-workrooms": horizon_workrooms_config_json,
hubspot: hubspot__metadata_ts,
huddle01video: huddle01video__metadata_ts,
@@ -14,6 +14,7 @@ import { appDataSchema as giphy_zod_ts } from "./giphy/zod";
import { appDataSchema as googlecalendar_zod_ts } from "./googlecalendar/zod";
import { appDataSchema as googlevideo_zod_ts } from "./googlevideo/zod";
import { appDataSchema as gtm_zod_ts } from "./gtm/zod";
import { appDataSchema as hitpay_zod_ts } from "./hitpay/zod";
import { appDataSchema as hubspot_zod_ts } from "./hubspot/zod";
import { appDataSchema as insihts_zod_ts } from "./insihts/zod";
import { appDataSchema as intercom_zod_ts } from "./intercom/zod";
@@ -63,6 +64,7 @@ export const appDataSchemas = {
googlecalendar: googlecalendar_zod_ts,
googlevideo: googlevideo_zod_ts,
gtm: gtm_zod_ts,
hitpay: hitpay_zod_ts,
hubspot: hubspot_zod_ts,
insihts: insihts_zod_ts,
intercom: intercom_zod_ts,
@@ -36,6 +36,7 @@ export const apiHandlers = {
googlevideo: import("./googlevideo/api"),
granola: import("./granola/api"),
gtm: import("./gtm/api"),
hitpay: import("./hitpay/api"),
"horizon-workrooms": import("./horizon-workrooms/api"),
hubspot: import("./hubspot/api"),
huddle01video: import("./huddle01video/api"),
+9
View File
@@ -0,0 +1,9 @@
---
items:
- 1.jpeg
- 2.jpeg
- 3.jpeg
- 4.jpeg
---
{DESCRIPTION}
+42
View File
@@ -0,0 +1,42 @@
The Cal.com Commercial License (EE) license (the “EE License”)
Copyright (c) 2020-present Cal.com, Inc
With regard to the Cal.com Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Cal.com Subscription Terms available
at https://cal.com/terms (the “EE Terms”), or other agreements governing
the use of the Software, as mutually agreed by you and Cal.com, Inc ("Cal.com"),
and otherwise have a valid Cal.com Commercial License subscription ("EE Subscription")
for the correct number of hosts as defined in the EE Terms ("Hosts"). Subject to the foregoing sentence,
you are free to modify this Software and publish patches to the Software. You agree
that Cal.com and/or its licensors (as applicable) retain all right, title and interest in
and to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid EE Subscription for the correct number of hosts.
Notwithstanding the foregoing, you may copy and modify the Software for development
and testing purposes, without requiring a subscription. You agree that Cal.com and/or
its licensors (as applicable) retain all right, title and interest in and to all such
modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
This EE License applies only to the part of this Software that is not distributed under
the AGPLv3 license. Any part of this Software distributed under the MIT license or which
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
in part, is copyrighted under the AGPLv3 license. The full text of this EE License shall
be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Cal.com Software, those
components are licensed under the original license provided by the owner of the
applicable component.
+6
View File
@@ -0,0 +1,6 @@
## Obraining HitPay API key and Salt
1. Create a [HitPay Sandbox](https://dashboard.sandbox.hit-pay.com/) or [HitPay](https://dashboard.hit-pay.com/), if you don't have one.
2. Sign into your [HitPay Sandbox](https://dashboard.sandbox.hit-pay.com/login/) or [HitPay](https://dashboard.hit-pay.com/login/)
3. On the left side bar, go to API Keys page by clicking on API Keys.
4. Under API Keys on the right side screen, copy API Keys and Salt and paste them to API Keys and Salt on the HitPay app setup page respectively, while installing or updating the app.
+42
View File
@@ -0,0 +1,42 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import config from "../config.json";
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: "hitpay",
},
});
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/hitpay/setup" });
}
+74
View File
@@ -0,0 +1,74 @@
import type { NextApiRequest, NextApiResponse } from "next";
import qs from "qs";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { reference, status } = req.query;
if (!reference) {
throw new HttpCode({ statusCode: 204, message: "Reference not found" });
}
const payment = await prisma.payment.findFirst({
where: {
externalId: reference as string,
},
select: {
id: true,
amount: true,
bookingId: true,
booking: {
select: {
uid: true,
user: {
select: {
email: true,
username: true,
credentials: {
where: {
type: "hitpay_payment",
},
},
},
},
responses: true,
eventType: {
select: {
slug: true,
},
},
},
},
},
});
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: "Credential not found" });
}
if (!payment.booking || !payment.booking.user || !payment.booking.eventType || !payment.booking.responses) {
throw new HttpCode({ statusCode: 204, message: "Booking not correct" });
}
if (status !== "completed") {
const url = `/${payment.booking.user.username}/${payment.booking.eventType.slug}`;
return res.redirect(url);
}
const queryParams = {
"flag.coep": false,
isSuccessBookingPage: true,
email: (payment.booking.responses as { email: string }).email,
eventTypeSlug: payment.booking.eventType.slug,
};
const query = qs.stringify(queryParams);
const url = `/booking/${payment.booking.uid}?${query}`;
return res.redirect(url);
}
+3
View File
@@ -0,0 +1,3 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
export { default as webhook } from "./webhook";
+114
View File
@@ -0,0 +1,114 @@
import { createHmac } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import type z from "zod";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
import prisma from "@calcom/prisma";
import type { hitpayCredentialKeysSchema } from "../lib/hitpayCredentialKeysSchema";
export const config = {
api: {
bodyParser: false,
},
};
interface WebhookReturn {
payment_id: string;
payment_request_id: string;
phone: string;
amount: string;
currency: string;
status: string;
reference_number: string;
hmac: string;
}
type ExcludedWebhookReturn = Omit<WebhookReturn, "hmac">;
function generateSignatureArray<T>(secret: string, vals: T) {
const source: string[] = [];
Object.keys(vals as { [K: string]: string })
.sort()
.forEach((key) => {
source.push(`${key}${(vals as { [K: string]: string })[key]}`);
});
const payload = source.join("");
const hmac = createHmac("sha256", secret);
const signed = hmac.update(payload, "utf-8").digest("hex");
return signed;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
debugger;
if (req.method !== "POST") {
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const obj: WebhookReturn = req.body as WebhookReturn;
const excluded = { ...obj } as Partial<WebhookReturn>;
delete excluded.hmac;
const payment = await prisma.payment.findFirst({
where: {
externalId: obj.payment_request_id,
},
select: {
id: true,
amount: true,
bookingId: true,
booking: {
select: {
user: {
select: {
credentials: {
where: {
type: "hitpay_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 { isSandbox, prod, sandbox } = key as z.infer<typeof hitpayCredentialKeysSchema>;
const keyObj = isSandbox ? sandbox : prod;
if (!keyObj) {
throw new HttpCode({
statusCode: 204,
message: `${isSandbox ? "Sandbox" : "Production"} Credentials not found`,
});
}
const { saltKey } = keyObj;
const signed = generateSignatureArray(saltKey, excluded as ExcludedWebhookReturn);
if (signed !== obj.hmac) {
throw new HttpCode({ statusCode: 400, message: "Bad Request" });
}
if (excluded.status !== "completed") {
throw new HttpCode({ statusCode: 204, message: `Payment is ${excluded.status}` });
}
return await handlePaymentSuccess(payment.id, payment.bookingId);
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
return res.status(200).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
});
}
}
Whitespace-only changes.
@@ -0,0 +1,50 @@
import { usePathname } from "next/navigation";
import { useState } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import checkForMultiplePaymentApps from "../../_utils/payments/checkForMultiplePaymentApps";
import useIsAppEnabled from "../../_utils/useIsAppEnabled";
import type { appDataSchema } from "../zod";
import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
app,
eventType,
eventTypeFormMetadata,
}) {
const { t } = useLocale();
const pathname = usePathname();
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const { enabled, updateEnabled } = useIsAppEnabled(app);
const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata);
const [requirePayment] = useState(getAppData("enabled"));
const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled;
return (
<AppCard
returnTo={`${WEBAPP_URL}${pathname}?tabName=apps`}
app={app}
switchChecked={enabled}
switchOnClick={(e) => {
updateEnabled(e);
}}
teamId={eventType.team?.id || undefined}
disableSwitch={shouldDisableSwitch}
switchTooltip={shouldDisableSwitch ? t("other_payment_app_enabled") : undefined}>
<EventTypeAppSettingsInterface
eventType={eventType}
slug={app.slug}
disabled={disabled}
getAppData={getAppData}
setAppData={setAppData}
/>
</AppCard>
);
};
export default EventTypeAppCard;
@@ -0,0 +1,123 @@
import { useState, useEffect } from "react";
import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Select, TextField } from "@calcom/ui";
import {
convertToSmallestCurrencyUnit,
convertFromSmallestToPresentableCurrencyUnit,
} from "../lib/currencyConversions";
import { paymentOptions, currencyOptions } from "./constants";
const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
getAppData,
setAppData,
disabled,
eventType,
}) => {
const price = getAppData("price");
const currency = getAppData("currency") || currencyOptions[0].value;
const [selectedCurrency, setSelectedCurrency] = useState(
currencyOptions.find((c) => c.value === currency) || {
label: currencyOptions[0].label,
value: currencyOptions[0].value,
}
);
const paymentOption = getAppData("paymentOption");
const requirePayment = getAppData("enabled");
const { t } = useLocale();
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
const seatsEnabled = !!eventType.seatsPerTimeSlot;
const getCurrencySymbol = (locale: string, currency: string) =>
(0)
.toLocaleString(locale, {
style: "currency",
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})
.replace(/\d/g, "")
.trim();
useEffect(() => {
if (requirePayment) {
if (!getAppData("currency")) {
setAppData("currency", currencyOptions[0].value);
}
if (!getAppData("paymentOption")) {
setAppData("paymentOption", paymentOptions[0].value);
}
}
}, [requirePayment, getAppData, setAppData]);
const disableDecimalPlace = (value: number) => {
const nValue = Math.floor(value);
const sValue = nValue.toString();
const ret = parseInt(sValue);
return ret;
};
return (
<>
{recurringEventDefined && (
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
)}
{!recurringEventDefined && requirePayment && (
<>
<div className="mt-4 block items-center justify-start sm:flex sm:space-x-2">
<TextField
data-testid="stripe-price-input"
label={t("price")}
className="h-[38px]"
addOnLeading={
<>{selectedCurrency.value ? getCurrencySymbol("en", selectedCurrency.value) : ""}</>
}
addOnSuffix={currency.toUpperCase()}
addOnClassname="h-[38px]"
step="1"
min="1"
type="number"
required
placeholder="Price"
disabled={disabled}
onChange={(e) => {
setAppData("price", convertToSmallestCurrencyUnit(Number(e.target.value), currency));
}}
value={
price > 0
? disableDecimalPlace(convertFromSmallestToPresentableCurrencyUnit(price, currency))
: undefined
}
/>
</div>
<div className="mt-5 w-60">
<label className="text-default mb-1 block text-sm font-medium" htmlFor="currency">
{t("currency")}
</label>
<Select
data-testid="stripe-currency-select"
variant="default"
options={currencyOptions}
value={selectedCurrency}
className="text-black"
defaultValue={selectedCurrency}
onChange={(e) => {
if (e) {
setSelectedCurrency(e);
setAppData("currency", e.value);
}
}}
/>
</div>
{seatsEnabled && paymentOption === "HOLD" && (
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
)}
</>
)}
</>
);
};
export default EventTypeAppSettingsInterface;
@@ -0,0 +1,43 @@
import { useEffect } from "react";
import { z } from "zod";
const PaymentHitpayDataSchema = z.object({
url: z.string(),
});
interface IPaymentComponentProps {
payment: {
data: unknown;
};
}
export const HitpayPaymentComponent = (props: IPaymentComponentProps) => {
const { payment } = props;
const { data } = payment;
const wrongUrl = (
<>
<p className="mt-3 text-center">Couldn&apos;t obtain payment URL</p>
</>
);
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;
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!parsedData.success || !parsedData.data?.url) {
return wrongUrl;
}
return <div />;
};
@@ -0,0 +1,187 @@
"use client";
import type { FormEvent } from "react";
import React, { forwardRef, useState, useEffect, useId, useCallback } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Input, Skeleton, Icon, Label } from "@calcom/ui";
import type { InputFieldProps } from "@calcom/ui/components/form/inputs/types";
type AddonProps = {
children: React.ReactNode;
isFilled?: boolean;
className?: string;
error?: boolean;
onClickAddon?: () => void;
};
const Addon = ({ isFilled, children, className, error }: AddonProps) => (
<div
className={classNames(
"addon-wrapper border-default [input:hover_+_&]:border-emphasis [input:hover_+_&]:border-l-default [&:has(+_input:hover)]:border-emphasis [&:has(+_input:hover)]:border-r-default h-9 border px-3 transition disabled:hover:cursor-not-allowed",
isFilled && "bg-subtle",
className
)}>
<div
className={classNames(
"min-h-9 flex flex-col justify-center text-sm leading-7",
error ? "text-error" : "text-default"
)}>
<span
className="flex max-w-2xl overflow-y-auto whitespace-nowrap"
style={{
WebkitOverflowScrolling: "touch",
scrollbarWidth: "none",
overflow: "-ms-scroll-chaining",
msOverflowStyle: "-ms-autohiding-scrollbar",
}}>
{children}
</span>
</div>
</div>
);
export const KeyField = forwardRef<HTMLInputElement, InputFieldProps & { defaultValue: string }>(
function KeyField(props, ref) {
const id = useId();
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [currentValue, setCurrentValue] = useState<string>("");
const toggleIsPasswordVisible = useCallback(
() => setIsPasswordVisible(!isPasswordVisible),
[isPasswordVisible, setIsPasswordVisible]
);
const { t: _t, isLocaleReady, i18n } = useLocale();
const t = props.t || _t;
const name = props.name || "";
const {
label = t(name),
labelProps,
labelClassName,
LockedIcon,
placeholder = isLocaleReady && i18n.exists(`${name}_placeholder`) ? t(`${name}_placeholder`) : "",
className,
addOnLeading,
addOnFilled = true,
addOnClassname,
inputIsFullWidth,
labelSrOnly,
noLabel,
containerClassName,
readOnly,
showAsteriskIndicator,
defaultValue,
...passThrough
} = props;
useEffect(() => {
if (currentValue.trim().length === 0) {
setIsPasswordVisible(true);
}
}, [currentValue]);
useEffect(() => {
setCurrentValue(defaultValue);
if (defaultValue.length > 0) {
setIsPasswordVisible(false);
}
}, [defaultValue]);
const getHiddenKey = (): string => {
let hiddenKey = currentValue;
const length = currentValue.length;
if (length > 6) {
const start = currentValue.slice(0, 3);
const end = currentValue.slice(length - 3);
hiddenKey = `${start}${"*".repeat(length - 6)}${end}`;
}
return hiddenKey;
};
const onInput = (event: FormEvent<HTMLInputElement>) => {
const target = event.target as HTMLInputElement;
const fullValue = target.value;
setCurrentValue(fullValue);
target.value = fullValue;
};
return (
<div className={classNames(containerClassName)}>
{!!label && !noLabel && (
<Skeleton
as={Label}
htmlFor={id}
loadingClassName="w-16"
{...labelProps}
className={classNames(labelClassName, labelSrOnly && "sr-only", props.error && "text-error")}>
{label}
{showAsteriskIndicator && !readOnly && passThrough.required ? (
<span className="text-default ml-1 font-medium">*</span>
) : null}
{LockedIcon}
</Skeleton>
)}
<div
dir="ltr"
className="focus-within:ring-brand-default group relative mb-1 flex items-center rounded-md transition focus-within:outline-none focus-within:ring-2">
{addOnLeading && (
<Addon
isFilled={addOnFilled}
className={classNames("ltr:rounded-l-md rtl:rounded-r-md", addOnClassname)}>
{addOnLeading}
</Addon>
)}
<Input
id={id}
type="text"
placeholder={placeholder}
isFullWidth={inputIsFullWidth}
className={classNames(
className,
"disabled:bg-subtle disabled:hover:border-subtle mb-0 rounded-r-none border-r-0 disabled:cursor-not-allowed",
addOnLeading && "rounded-l-none border-l-0",
isPasswordVisible && "inline-block",
!isPasswordVisible && "hidden",
"!my-0 !ring-0"
)}
{...passThrough}
ref={ref}
onInput={onInput}
/>
<Input
type="text"
isFullWidth={inputIsFullWidth}
className={classNames(
className,
"disabled:bg-subtle disabled:hover:border-subtle mb-0 rounded-r-none border-r-0 disabled:cursor-not-allowed",
addOnLeading && "rounded-l-none border-l-0",
!isPasswordVisible && "inline-block",
isPasswordVisible && "hidden",
"!my-0 !ring-0"
)}
disabled
value={getHiddenKey()}
/>
<Addon className={classNames("ltr:rounded-r-md rtl:rounded-l-md", addOnClassname)}>
<button
className="text-emphasis h-9"
tabIndex={-1}
type="button"
onClick={() => toggleIsPasswordVisible()}>
{isPasswordVisible ? (
<Icon name="eye-off" className="h-4 w-4 stroke-[2.5px]" />
) : (
<Icon name="eye" className="h-4 w-4 stroke-[2.5px]" />
)}
</button>
</Addon>
</div>
</div>
);
}
);
export default KeyField;
@@ -0,0 +1,71 @@
export const paymentOptions = [
{
label: "on_booking_option",
value: "ON_BOOKING",
},
];
export const supportedPaymentMethods = [
"paynow",
"grabpay",
"shopeepay",
"zip",
"fpx",
"visa",
"master",
"american_express",
"alipay",
"atome",
"unionpay",
"gcash",
"qrph",
"billease",
"seveneleven",
"cebuana",
"palawa",
"ovo",
"gopay",
"linkaja",
"dana",
"kredivo",
"akulakupaylater",
"akulaku",
"indomaret",
"alfamart",
"bri",
"cimb",
"sbpl",
"payid",
"qris",
"bdo",
"bpi",
"duitnow",
"wechatpay",
"qr_promptpay",
"line_pay",
"truemoney_pay",
];
export const currencyOptions = [
{ label: "United States dollar (USD)", value: "usd" },
{ label: "Singapore dollar (SGD)", value: "sgd" },
{ label: "Malaysian ringgit (MYR)", value: "myr" },
{ label: "Indonesian rupiah (IDR)", value: "idr" },
{ label: "Japanese yen (JPY)", value: "jpy" },
{ label: "Hong Kong dollar (HKD)", value: "hkd" },
{ label: "Thai baht (THB)", value: "thb" },
{ label: "Australian dollar (AUD)", value: "aud" },
{ label: "New Zealand dollar (NZD)", value: "nzd" },
{ label: "British pound sterling (GBP)", value: "gbp" },
{ label: "Philippine peso (PHP)", value: "php" },
{ label: "Indian rupee (INR)", value: "inr" },
{ label: "Chinese yuan (CNY)", value: "cny" },
{ label: "Euro (EUR)", value: "eur" },
{ label: "Swiss franc (CHF)", value: "chf" },
{ label: "Danish krone (DKK)", value: "dkk" },
{ label: "Swedish krona (SEK)", value: "sek" },
{ label: "Norwegian krone (NOK)", value: "nok" },
{ label: "Vietnamese đồng (VND)", value: "vnd" },
{ label: "Canadian dollar (CAD)", value: "cad" },
{ label: "South Korean won (KRW)", value: "krw" },
];
+19
View File
@@ -0,0 +1,19 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "HitPay",
"slug": "hitpay",
"type": "hitpay_payment",
"logo": "icon.svg",
"url": "https://dashboard.hit-pay.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.",
"extendsFeature": "EventType",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-app-card",
"dirName": "hitpay",
"isOAuth": true
}
+2
View File
@@ -0,0 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";
@@ -0,0 +1,170 @@
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
import axios from "axios";
import qs from "qs";
import { v4 as uuidv4 } from "uuid";
import type z from "zod";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { ErrorCode } from "@calcom/lib/errorCodes";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
import appConfig from "../config.json";
import { API_HITPAY, SANDBOX_API_HITPAY } from "./constants";
import { hitpayCredentialKeysSchema } from "./hitpayCredentialKeysSchema";
const log = logger.getSubLogger({ prefix: ["payment-service:hitpay"] });
export class PaymentService implements IAbstractPaymentService {
private credentials: z.infer<typeof hitpayCredentialKeysSchema> | null;
constructor(credentials: { key: Prisma.JsonValue }) {
const keyParsing = hitpayCredentialKeysSchema.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"],
userId: Booking["userId"],
username: string | null,
bookerName: string,
paymentOption: PaymentOption,
bookerEmail: string
) {
try {
const booking = await prisma.booking.findFirst({
select: {
uid: true,
title: true,
},
where: {
id: bookingId,
},
});
if (!booking || !this.credentials) {
throw new Error("Booking or API key not found");
}
const { isSandbox } = this.credentials;
const keyObj = isSandbox ? this.credentials.sandbox : this.credentials.prod;
if (!keyObj || !keyObj?.apiKey) {
throw new Error("API key not found");
}
const hitpayAPIurl = isSandbox ? SANDBOX_API_HITPAY : API_HITPAY;
const requestUrl = `${hitpayAPIurl}/v1/payment-requests`;
const redirectUri = `${WEBAPP_URL}/api/integrations/${appConfig.slug}/callback`;
const webhookUri = `${WEBAPP_URL}/api/integrations/${appConfig.slug}/webhook`;
const formData = {
amount: payment.amount / 100,
currency: payment.currency,
email: bookerEmail,
name: bookerName,
reference_number: bookingId.toString(),
redirect_url: redirectUri,
webhook: webhookUri,
channel: "api_cal",
purpose: booking.title,
};
const response = await axios.post(requestUrl, qs.stringify(formData), {
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
"X-BUSINESS-API-KEY": keyObj.apiKey,
"X-Requested-With": "XMLHttpRequest",
},
});
const data = response.data;
const uid = uuidv4();
const paymentData = await prisma.payment.create({
data: {
uid,
app: {
connect: {
slug: "hitpay",
},
},
booking: {
connect: {
id: bookingId,
},
},
amount: parseFloat(data.amount.replace(/,/g, "")) * 100,
externalId: data.id,
currency: data.currency,
data: Object.assign({}, data, { isPaid: false }) as unknown as Prisma.InputJsonValue,
fee: 0,
refunded: false,
success: false,
},
});
if (!paymentData) {
throw new Error("Failed to store Payment data");
}
return paymentData;
} catch (error) {
log.error("Payment could not be created", bookingId, safeStringify(error));
throw new Error(ErrorCode.PaymentCreationFailure);
}
}
async update(): Promise<Payment> {
throw new Error("Method not implemented.");
}
async refund(): Promise<Payment> {
throw new Error("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;
}
}
@@ -0,0 +1,3 @@
export const API_HITPAY = process.env.NEXT_PUBLIC_API_HITPAY_PRODUCTION || "https://api.hit-pay.com";
export const SANDBOX_API_HITPAY =
process.env.NEXT_PUBLIC_API_HITPAY_SANDBOX || "https://api.sandbox.hit-pay.com";
@@ -0,0 +1,32 @@
const zeroDecimalCurrencies = [
"BIF",
"CLP",
"DJF",
"GNF",
"JPY",
"KMF",
"KRW",
"MGA",
"PYG",
"RWF",
"UGX",
"VND",
"VUV",
"XAF",
"XOF",
"XPF",
];
export const convertToSmallestCurrencyUnit = (amount: number, currency: string) => {
if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
return amount;
}
return Math.round(amount * 100);
};
export const convertFromSmallestToPresentableCurrencyUnit = (amount: number, currency: string) => {
if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
return amount;
}
return amount / 100;
};
@@ -0,0 +1,17 @@
import z from "zod";
export const hitpayCredentialKeysSchema = z.object({
prod: z
.object({
apiKey: z.string(),
saltKey: z.string(),
})
.optional(),
sandbox: z
.object({
apiKey: z.string(),
saltKey: z.string(),
})
.optional(),
isSandbox: z.boolean(),
});
+2
View File
@@ -0,0 +1,2 @@
export * from "./constants";
export * from "./PaymentService";
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"license": "AGPLv3 (see LICENSE file)",
"name": "@calcom/hitpay",
"version": "0.0.0",
"main": "./index.ts",
"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.",
"dependencies": {
"@calcom/lib": "*",
"@calcom/prisma": "*"
},
"devDependencies": {
"@calcom/types": "*"
}
}
@@ -0,0 +1,47 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import prisma from "@calcom/prisma";
import { hitpayCredentialKeysSchema } from "../../lib/hitpayCredentialKeysSchema";
import type { IHitPaySetupProps } from "./index";
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const notFound = { notFound: true } as const;
if (typeof ctx.params?.slug !== "string") return notFound;
const { req, res } = ctx;
const session = await getServerSession({ req, res });
if (!session?.user?.id) {
const redirect = { redirect: { permanent: false, destination: "/auth/login" } } as const;
return redirect;
}
const credentials = await prisma.credential.findFirst({
where: {
type: "hitpay_payment",
userId: session?.user.id,
},
});
let props: IHitPaySetupProps = {
isSandbox: false,
};
if (credentials?.key) {
const keyParsing = hitpayCredentialKeysSchema.safeParse(credentials.key);
if (keyParsing.success) {
props = {
...props,
...keyParsing.data,
};
}
}
return {
props,
};
};
@@ -0,0 +1,301 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { Toaster } from "react-hot-toast";
import { z } from "zod";
import AppNotInstalledMessage from "@calcom/app-store/_components/AppNotInstalledMessage";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, showToast, Icon, Switch } from "@calcom/ui";
import { HeadSeo } from "@calcom/ui";
import KeyField from "../../components/KeyInput";
import { hitpayCredentialKeysSchema } from "../../lib/hitpayCredentialKeysSchema";
export type IHitPaySetupProps = z.infer<typeof hitpayCredentialKeysSchema>;
export default function HitPaySetup(props: IHitPaySetupProps) {
const params = useCompatSearchParams();
if (params?.get("callback") === "true") {
return <HitPaySetupCallback />;
}
return <HitPaySetupPage {...props} />;
}
function HitPaySetupCallback() {
const [error, setError] = useState<string | null>(null);
const searchParams = useCompatSearchParams();
useEffect(() => {
if (!searchParams) {
return;
}
if (!window.opener) {
setError("Something went wrong. Opener not available. Please contact support@getalby.com");
return;
}
const code = searchParams?.get("code");
const error = searchParams?.get("error");
if (!code) {
setError("declined");
}
if (error) {
setError(error);
return;
}
window.opener.postMessage({
type: "hitpay:oauth:success",
payload: { code },
});
window.close();
}, [searchParams]);
return (
<div>
{error && <p>Authorization failed: {error}</p>}
{!error && <p>Connecting...</p>}
</div>
);
}
function HitPaySetupPage(props: IHitPaySetupProps) {
const [loading, setLoading] = useState<boolean>(false);
const [updatable, setUpdatable] = useState<boolean>(false);
const [isSandbox, SetIsSandbox] = useState<boolean>(props.isSandbox || false);
const [keyData, setKeyData] = useState<
| {
apiKey: string;
saltKey: string;
}
| undefined
>();
const session = useSession();
const router = useRouter();
const { t } = useLocale();
const settingsSchema = z.object({
apiKey: z
.string()
.trim()
.min(64)
.max(64, {
message: t("max_limit_allowed_hint", { limit: 64 }),
}),
saltKey: z
.string()
.trim()
.min(64)
.max(64, {
message: t("max_limit_allowed_hint", { limit: 64 }),
}),
});
const integrations = trpc.viewer.integrations.useQuery({ variant: "payment", appId: "hitpay" });
const [HitPayPaymentAppCredentials] = integrations.data?.items || [];
const [credentialId] = HitPayPaymentAppCredentials?.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 deleteMutation = trpc.viewer.deleteCredential.useMutation({
onSuccess: () => {
router.push("/apps/hitpay");
},
onError: () => {
showToast(t("error_removing_app"), "error");
},
});
const {
register,
handleSubmit,
formState: { errors },
watch,
reset,
} = useForm<z.infer<typeof settingsSchema>>({
reValidateMode: "onChange",
resolver: zodResolver(settingsSchema),
});
useEffect(() => {
const keyObj = isSandbox ? props.sandbox : props.prod;
reset({
apiKey: keyObj?.apiKey || "",
saltKey: keyObj?.saltKey || "",
});
setKeyData(keyObj);
}, [isSandbox]);
useEffect(() => {
const subscription = watch((value) => {
const { apiKey, saltKey } = value;
if (apiKey && saltKey && (keyData?.apiKey !== apiKey || keyData?.saltKey !== saltKey)) {
setUpdatable(true);
} else {
setUpdatable(false);
}
});
return () => subscription.unsubscribe();
}, [watch, keyData]);
const onSubmit = handleSubmit(async (data) => {
if (loading) return;
setLoading(true);
try {
const keyParams = {
isSandbox,
prod: isSandbox ? props.prod : data,
sandbox: isSandbox ? data : props.sandbox,
};
saveKeysMutation.mutate({
credentialId,
key: hitpayCredentialKeysSchema.parse(keyParams),
});
} catch (error: unknown) {
let message = "";
if (error instanceof Error) {
message = error.message;
}
showToast(message, "error");
} finally {
setLoading(false);
}
});
const onCancel = () => {
deleteMutation.mutate({ id: credentialId });
};
const hitpayIcon = (
<>
<img className="h-5 w-5" src="/app-store/hitpay/icon.svg" alt="HitPay Icon" />
</>
);
if (session.status === "loading") return <></>;
if (integrations.isPending) {
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
}
return (
<>
<HeadSeo nextSeoProps={{ noindex: true, nofollow: true }} title="HitPay" description="" />
<div className="bg-default flex h-screen items-center justify-center">
{showContent ? (
<div className="flex w-full w-full max-w-[43em] flex-col items-center justify-center space-y-4 p-4 lg:space-y-5">
<div className="rounded bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-950 dark:text-blue-300">
<Icon name="info" className="mb-0.5 inline-flex h-4 w-4" /> Create or connect to an existing
HitPay account to receive payments for your paid bookings.
</div>
<form className="w-full space-y-4" onSubmit={onSubmit}>
<div className="bg-default border-subtle overflow-auto rounded border">
<div className="border-subtle flex items-center justify-between border-b-[1px] p-4 md:p-5">
<h2 className="text-2xl font-semibold">Account Information</h2>
<div className="ml-auto flex items-center">
<Switch
onCheckedChange={(value) => SetIsSandbox(value as boolean)}
checked={isSandbox}
label="Sandbox"
/>
</div>
</div>
<div className="w-full space-y-4 p-4 md:p-5">
<div className="w-full">
<KeyField
{...register("apiKey", {
required: true,
})}
id="apiKey"
name="apiKey"
containerClassName="w-full"
label={t("api_key")}
autoComplete="off"
autoCorrect="off"
defaultValue={keyData?.apiKey || ""}
/>
{errors.apiKey && (
<p data-testid="required" className="py-2 text-xs text-red-500">
{errors.apiKey?.message}
</p>
)}
</div>
<div className="w-full">
<KeyField
{...register("saltKey", {
required: true,
})}
id="saltKey"
name="saltKey"
containerClassName="w-full"
label="Salt"
autoComplete="off"
autoCorrect="off"
defaultValue={keyData?.saltKey || ""}
/>
{errors.saltKey && (
<p data-testid="required" className="py-2 text-xs text-red-500">
{errors.saltKey?.message}
</p>
)}
</div>
</div>
</div>
{!props.prod && !props.sandbox ? (
<div className="flex justify-end gap-4">
<Button color="secondary" className="h-10 text-base" onClick={onCancel}>
Cancel
</Button>
<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="submit"
disabled={loading}>
{hitpayIcon}
<span className="mr-2">Connect with HitPay</span>
</button>
</div>
) : (
<div className="flex justify-end gap-4">
<Link href="/apps/hitpay" className="inline-block">
<Button color="secondary">Go to App Store</Button>
</Link>
<Button color="primary" type="submit" disabled={!updatable}>
Update
</Button>
</div>
)}
</form>
</div>
) : (
<AppNotInstalledMessage appName="hitpay" />
)}
<Toaster position="bottom-right" />
</div>
</>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="450pt" height="450pt" viewBox="0 0 450 450"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0,450) scale(0.1,-0.1)"
fill="#002884" stroke="none">
<path d="M2000 4484 c-1310 -157 -2208 -1381 -1954 -2664 85 -431 281 -808
583 -1122 488 -509 1176 -761 1861 -682 536 62 1004 291 1376 675 331 342 535
756 611 1239 25 159 25 481 0 640 -104 658 -460 1210 -1018 1573 -415 270
-968 400 -1459 341z m550 -170 c689 -100 1285 -539 1586 -1169 237 -495 266
-1059 80 -1591 -240 -687 -834 -1205 -1546 -1348 -278 -56 -563 -57 -835 -1
-783 159 -1415 762 -1610 1534 -82 325 -80 697 5 1027 268 1037 1265 1702
2320 1548z"/>
<path d="M2090 3845 c-219 -24 -361 -65 -555 -161 -164 -81 -292 -172 -419
-300 -220 -219 -371 -498 -437 -802 -30 -140 -37 -400 -15 -550 73 -493 300
-857 698 -1116 101 -66 311 -169 380 -188 43 -11 50 -10 75 8 15 11 31 33 36
48 15 46 -15 74 -129 121 -203 84 -347 178 -494 325 -165 165 -278 348 -349
566 -52 159 -73 287 -73 454 0 167 21 297 72 451 147 444 477 774 920 919 428
141 921 69 1275 -185 196 -141 360 -325 450 -505 15 -30 36 -65 48 -77 38 -43
117 -23 131 33 18 69 -145 316 -328 500 -278 278 -582 418 -1008 464 -114 12
-128 12 -278 -5z"/>
<path d="M1100 3793 c-49 -25 -326 -302 -401 -403 -253 -336 -370 -682 -371
-1095 -3 -573 196 -1058 588 -1435 287 -277 615 -441 1034 -517 133 -24 457
-24 590 0 390 71 715 227 1018 489 295 255 500 626 577 1044 30 162 47 367 34
403 -21 54 -83 68 -123 28 -25 -25 -29 -46 -42 -227 -23 -325 -167 -680 -384
-945 -83 -101 -242 -250 -350 -328 -113 -80 -337 -193 -470 -236 -438 -142
-915 -109 -1314 90 -576 288 -909 754 -986 1381 -18 146 -8 456 20 583 80 373
277 696 590 970 98 87 110 100 110 130 0 66 -58 98 -120 68z"/>
<path d="M1770 2175 l0 -725 95 0 95 0 0 325 0 325 365 0 365 0 0 -325 0 -325
95 0 95 0 0 725 0 725 -95 0 -95 0 0 -325 0 -325 -365 0 -365 0 0 325 0 325
-95 0 -95 0 0 -725z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+27
View File
@@ -0,0 +1,27 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
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({});
+1
View File
@@ -43,6 +43,7 @@ const appStore = {
basecamp3: () => import("./basecamp3"),
telegramvideo: () => import("./telegram"),
shimmervideo: () => import("./shimmervideo"),
hitpay: () => import("./hitpay"),
};
const exportedAppStore: typeof appStore & {
@@ -41,6 +41,16 @@ const AlbyPaymentComponent = dynamic(
}
);
const HitpayPaymentComponent = dynamic(
() =>
import("@calcom/app-store/hitpay/components/HitpayPaymentComponent").then(
(m) => m.HitpayPaymentComponent
),
{
ssr: false,
}
);
const PaymentPage: FC<PaymentPageProps> = (props) => {
const { t, i18n } = useLocale();
const [is24h, setIs24h] = useState(isBrowserLocale24h());
@@ -159,6 +169,9 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
{props.payment.appId === "alby" && !props.payment.success && (
<AlbyPaymentComponent payment={props.payment} paymentPageProps={props} />
)}
{props.payment.appId === "hitpay" && !props.payment.success && (
<HitpayPaymentComponent payment={props.payment} />
)}
{props.payment.refunded && (
<div className="text-default mt-4 text-center dark:text-gray-300">{t("refunded")}</div>
)}
+3 -1
View File
@@ -47,7 +47,9 @@
"STRIPE_ORG_MONTHLY_PRICE_ID",
"NEXT_PUBLIC_API_V2_URL",
"NEXT_PUBLIC_VAPID_PUBLIC_KEY",
"BUILD_STANDALONE"
"BUILD_STANDALONE",
"NEXT_PUBLIC_API_HITPAY_PRODUCTION",
"NEXT_PUBLIC_API_HITPAY_SANDBOX"
]
},
"@calcom/web#dx": {