diff --git a/packages/app-store/_pages/setup/_getServerSideProps.tsx b/packages/app-store/_pages/setup/_getServerSideProps.tsx index 39b20bf398..36bc532288 100644 --- a/packages/app-store/_pages/setup/_getServerSideProps.tsx +++ b/packages/app-store/_pages/setup/_getServerSideProps.tsx @@ -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) => { diff --git a/packages/app-store/_pages/setup/index.tsx b/packages/app-store/_pages/setup/index.tsx index b5fd365a93..d7d160b3df 100644 --- a/packages/app-store/_pages/setup/index.tsx +++ b/packages/app-store/_pages/setup/index.tsx @@ -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 }) => { diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx index 69926275a2..01f24bca32 100644 --- a/packages/app-store/apps.browser.generated.tsx +++ b/packages/app-store/apps.browser.generated.tsx @@ -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")), diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index 316bb0b434..becb7697c0 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -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, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 29c3b8dcc8..9db12e052f 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.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, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index 1a5cd93dcf..1acffdaeec 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.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, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 5c3c922ea7..4d81f334ff 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.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"), diff --git a/packages/app-store/hitpay/DESCRIPTION.md b/packages/app-store/hitpay/DESCRIPTION.md new file mode 100644 index 0000000000..7a8120560e --- /dev/null +++ b/packages/app-store/hitpay/DESCRIPTION.md @@ -0,0 +1,9 @@ +--- +items: + - 1.jpeg + - 2.jpeg + - 3.jpeg + - 4.jpeg +--- + +{DESCRIPTION} diff --git a/packages/app-store/hitpay/LICENSE b/packages/app-store/hitpay/LICENSE new file mode 100644 index 0000000000..86aae54b29 --- /dev/null +++ b/packages/app-store/hitpay/LICENSE @@ -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. diff --git a/packages/app-store/hitpay/README.md b/packages/app-store/hitpay/README.md new file mode 100644 index 0000000000..2f1c87cffe --- /dev/null +++ b/packages/app-store/hitpay/README.md @@ -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. \ No newline at end of file diff --git a/packages/app-store/hitpay/api/add.ts b/packages/app-store/hitpay/api/add.ts new file mode 100644 index 0000000000..1d4593d604 --- /dev/null +++ b/packages/app-store/hitpay/api/add.ts @@ -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" }); +} diff --git a/packages/app-store/hitpay/api/callback.ts b/packages/app-store/hitpay/api/callback.ts new file mode 100644 index 0000000000..fcba2ddf98 --- /dev/null +++ b/packages/app-store/hitpay/api/callback.ts @@ -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); +} diff --git a/packages/app-store/hitpay/api/index.ts b/packages/app-store/hitpay/api/index.ts new file mode 100644 index 0000000000..567bbf7979 --- /dev/null +++ b/packages/app-store/hitpay/api/index.ts @@ -0,0 +1,3 @@ +export { default as add } from "./add"; +export { default as callback } from "./callback"; +export { default as webhook } from "./webhook"; diff --git a/packages/app-store/hitpay/api/webhook.ts b/packages/app-store/hitpay/api/webhook.ts new file mode 100644 index 0000000000..ec2ab8d9c5 --- /dev/null +++ b/packages/app-store/hitpay/api/webhook.ts @@ -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; + +function generateSignatureArray(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; + 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; + 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, + }); + } +} diff --git a/packages/app-store/hitpay/components/.gitkeep b/packages/app-store/hitpay/components/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/app-store/hitpay/components/EventTypeAppCardInterface.tsx b/packages/app-store/hitpay/components/EventTypeAppCardInterface.tsx new file mode 100644 index 0000000000..0340a969cd --- /dev/null +++ b/packages/app-store/hitpay/components/EventTypeAppCardInterface.tsx @@ -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(); + const { enabled, updateEnabled } = useIsAppEnabled(app); + const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata); + const [requirePayment] = useState(getAppData("enabled")); + const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled; + + return ( + { + updateEnabled(e); + }} + teamId={eventType.team?.id || undefined} + disableSwitch={shouldDisableSwitch} + switchTooltip={shouldDisableSwitch ? t("other_payment_app_enabled") : undefined}> + + + ); +}; + +export default EventTypeAppCard; diff --git a/packages/app-store/hitpay/components/EventTypeAppSettingsInterface.tsx b/packages/app-store/hitpay/components/EventTypeAppSettingsInterface.tsx new file mode 100644 index 0000000000..1fb1fc0091 --- /dev/null +++ b/packages/app-store/hitpay/components/EventTypeAppSettingsInterface.tsx @@ -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 && ( + + )} + {!recurringEventDefined && requirePayment && ( + <> +
+ {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 + } + /> +
+
+ + + + + + +
+ + ); + } +); + +export default KeyField; diff --git a/packages/app-store/hitpay/components/constants.ts b/packages/app-store/hitpay/components/constants.ts new file mode 100644 index 0000000000..cd5bad2bd9 --- /dev/null +++ b/packages/app-store/hitpay/components/constants.ts @@ -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" }, +]; diff --git a/packages/app-store/hitpay/config.json b/packages/app-store/hitpay/config.json new file mode 100644 index 0000000000..ad98701aa1 --- /dev/null +++ b/packages/app-store/hitpay/config.json @@ -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 +} diff --git a/packages/app-store/hitpay/index.ts b/packages/app-store/hitpay/index.ts new file mode 100644 index 0000000000..e2e9d7b029 --- /dev/null +++ b/packages/app-store/hitpay/index.ts @@ -0,0 +1,2 @@ +export * as api from "./api"; +export * as lib from "./lib"; diff --git a/packages/app-store/hitpay/lib/PaymentService.ts b/packages/app-store/hitpay/lib/PaymentService.ts new file mode 100644 index 0000000000..f4092cff9c --- /dev/null +++ b/packages/app-store/hitpay/lib/PaymentService.ts @@ -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 | 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, + 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 { + throw new Error("Method not implemented."); + } + async refund(): Promise { + throw new Error("Method not implemented."); + } + + async collectCard( + _payment: Pick, + _bookingId: number, + _bookerEmail: string, + _paymentOption: PaymentOption + ): Promise { + throw new Error("Method not implemented"); + } + chargeCard( + _payment: Pick, + _bookingId: number + ): Promise { + throw new Error("Method not implemented."); + } + getPaymentPaidStatus(): Promise { + throw new Error("Method not implemented."); + } + getPaymentDetails(): Promise { + 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 { + return Promise.resolve(); + } + deletePayment(_paymentId: number): Promise { + return Promise.resolve(false); + } + + isSetupAlready(): boolean { + return !!this.credentials; + } +} diff --git a/packages/app-store/hitpay/lib/constants.ts b/packages/app-store/hitpay/lib/constants.ts new file mode 100644 index 0000000000..5889ac4efa --- /dev/null +++ b/packages/app-store/hitpay/lib/constants.ts @@ -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"; diff --git a/packages/app-store/hitpay/lib/currencyConversions.ts b/packages/app-store/hitpay/lib/currencyConversions.ts new file mode 100644 index 0000000000..212bc3c519 --- /dev/null +++ b/packages/app-store/hitpay/lib/currencyConversions.ts @@ -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; +}; diff --git a/packages/app-store/hitpay/lib/hitpayCredentialKeysSchema.ts b/packages/app-store/hitpay/lib/hitpayCredentialKeysSchema.ts new file mode 100644 index 0000000000..9e9cfe630f --- /dev/null +++ b/packages/app-store/hitpay/lib/hitpayCredentialKeysSchema.ts @@ -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(), +}); diff --git a/packages/app-store/hitpay/lib/index.ts b/packages/app-store/hitpay/lib/index.ts new file mode 100644 index 0000000000..c758477166 --- /dev/null +++ b/packages/app-store/hitpay/lib/index.ts @@ -0,0 +1,2 @@ +export * from "./constants"; +export * from "./PaymentService"; diff --git a/packages/app-store/hitpay/package.json b/packages/app-store/hitpay/package.json new file mode 100644 index 0000000000..52b0a790f5 --- /dev/null +++ b/packages/app-store/hitpay/package.json @@ -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": "*" + } +} diff --git a/packages/app-store/hitpay/pages/setup/_getServerSideProps.tsx b/packages/app-store/hitpay/pages/setup/_getServerSideProps.tsx new file mode 100644 index 0000000000..f56c64e9aa --- /dev/null +++ b/packages/app-store/hitpay/pages/setup/_getServerSideProps.tsx @@ -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, + }; +}; diff --git a/packages/app-store/hitpay/pages/setup/index.tsx b/packages/app-store/hitpay/pages/setup/index.tsx new file mode 100644 index 0000000000..729007be82 --- /dev/null +++ b/packages/app-store/hitpay/pages/setup/index.tsx @@ -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; + +export default function HitPaySetup(props: IHitPaySetupProps) { + const params = useCompatSearchParams(); + if (params?.get("callback") === "true") { + return ; + } + + return ; +} + +function HitPaySetupCallback() { + const [error, setError] = useState(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 ( +
+ {error &&

Authorization failed: {error}

} + {!error &&

Connecting...

} +
+ ); +} + +function HitPaySetupPage(props: IHitPaySetupProps) { + const [loading, setLoading] = useState(false); + const [updatable, setUpdatable] = useState(false); + const [isSandbox, SetIsSandbox] = useState(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>({ + 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 = ( + <> + HitPay Icon + + ); + + if (session.status === "loading") return <>; + + if (integrations.isPending) { + return
; + } + + return ( + <> + +
+ {showContent ? ( +
+
+ Create or connect to an existing + HitPay account to receive payments for your paid bookings. +
+ +
+
+
+

Account Information

+
+ SetIsSandbox(value as boolean)} + checked={isSandbox} + label="Sandbox" + /> +
+
+
+
+ + + {errors.apiKey && ( +

+ {errors.apiKey?.message} +

+ )} +
+
+ + + {errors.saltKey && ( +

+ {errors.saltKey?.message} +

+ )} +
+
+
+ {!props.prod && !props.sandbox ? ( +
+ + +
+ ) : ( +
+ + + + +
+ )} +
+
+ ) : ( + + )} + +
+ + ); +} diff --git a/packages/app-store/hitpay/static/1.jpeg b/packages/app-store/hitpay/static/1.jpeg new file mode 100644 index 0000000000..3660aabab2 Binary files /dev/null and b/packages/app-store/hitpay/static/1.jpeg differ diff --git a/packages/app-store/hitpay/static/2.jpeg b/packages/app-store/hitpay/static/2.jpeg new file mode 100644 index 0000000000..faaca1d8f9 Binary files /dev/null and b/packages/app-store/hitpay/static/2.jpeg differ diff --git a/packages/app-store/hitpay/static/3.jpeg b/packages/app-store/hitpay/static/3.jpeg new file mode 100644 index 0000000000..85d26924a6 Binary files /dev/null and b/packages/app-store/hitpay/static/3.jpeg differ diff --git a/packages/app-store/hitpay/static/4.jpeg b/packages/app-store/hitpay/static/4.jpeg new file mode 100644 index 0000000000..73fae5c70c Binary files /dev/null and b/packages/app-store/hitpay/static/4.jpeg differ diff --git a/packages/app-store/hitpay/static/icon.svg b/packages/app-store/hitpay/static/icon.svg new file mode 100644 index 0000000000..69737e8ce9 --- /dev/null +++ b/packages/app-store/hitpay/static/icon.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/packages/app-store/hitpay/zod.ts b/packages/app-store/hitpay/zod.ts new file mode 100644 index 0000000000..f9c1dac487 --- /dev/null +++ b/packages/app-store/hitpay/zod.ts @@ -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({}); diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 6cdcd1e5c9..7f2fbe13d8 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -43,6 +43,7 @@ const appStore = { basecamp3: () => import("./basecamp3"), telegramvideo: () => import("./telegram"), shimmervideo: () => import("./shimmervideo"), + hitpay: () => import("./hitpay"), }; const exportedAppStore: typeof appStore & { diff --git a/packages/features/ee/payments/components/PaymentPage.tsx b/packages/features/ee/payments/components/PaymentPage.tsx index d14c0471a8..ade48dd055 100644 --- a/packages/features/ee/payments/components/PaymentPage.tsx +++ b/packages/features/ee/payments/components/PaymentPage.tsx @@ -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 = (props) => { const { t, i18n } = useLocale(); const [is24h, setIs24h] = useState(isBrowserLocale24h()); @@ -159,6 +169,9 @@ const PaymentPage: FC = (props) => { {props.payment.appId === "alby" && !props.payment.success && ( )} + {props.payment.appId === "hitpay" && !props.payment.success && ( + + )} {props.payment.refunded && (
{t("refunded")}
)} diff --git a/turbo.json b/turbo.json index ee906ed56d..2321803183 100644 --- a/turbo.json +++ b/turbo.json @@ -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": {